From 99960acec3a8cbb71220b856082f87e73a10ece8 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 14 May 2020 17:14:47 +0800 Subject: [PATCH 001/693] Make FLV video seekable by a seekMap. --- .../extractor/flv/FlvExtractor.java | 48 ++++++++++++++++--- .../extractor/flv/ScriptTagPayloadReader.java | 25 ++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java index 98c5fa73a49..68e93b1f87b 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java @@ -17,6 +17,7 @@ import androidx.annotation.IntDef; import com.google.android.exoplayer2.C; +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.ExtractorOutput; @@ -29,6 +30,7 @@ import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -83,6 +85,7 @@ public final class FlvExtractor implements Extractor { private int tagDataSize; private long tagTimestampUs; private boolean outputSeekMap; + private boolean seekMapIsSeekable; private @MonotonicNonNull AudioTagPayloadReader audioReader; private @MonotonicNonNull VideoTagPayloadReader videoReader; @@ -133,7 +136,12 @@ public void init(ExtractorOutput output) { @Override public void seek(long position, long timeUs) { - state = STATE_READING_FLV_HEADER; + if (seekMapIsSeekable) { + state = STATE_READING_TAG_HEADER; + } else { + state = STATE_READING_FLV_HEADER; + mediaTagTimestampOffsetUs = C.TIME_UNSET; + } outputFirstSample = false; bytesToNextTagHeader = 0; } @@ -263,11 +271,13 @@ private boolean readTagData(ExtractorInput input) throws IOException { wasSampleOutput = videoReader.consume(prepareTagData(input), timestampUs); } else if (tagType == TAG_TYPE_SCRIPT_DATA && !outputSeekMap) { wasSampleOutput = metadataReader.consume(prepareTagData(input), timestampUs); - long durationUs = metadataReader.getDurationUs(); - if (durationUs != C.TIME_UNSET) { - extractorOutput.seekMap(new SeekMap.Unseekable(durationUs)); - outputSeekMap = true; - } + SeekMap seekMap = buildSeekMap(metadataReader.getSeekMapTimes(), + metadataReader.getSeekMapFilePositions(), + metadataReader.getDurationUs(), + input.getLength()); + seekMapIsSeekable = seekMap.isSeekable(); + extractorOutput.seekMap(seekMap); + outputSeekMap = true; } else { input.skipFully(tagDataSize); wasConsumed = false; @@ -301,6 +311,32 @@ private void ensureReadyForMediaOutput() { } } + private SeekMap buildSeekMap(List times, List filePositions, long durationUs, + long flvDataSize) { + if (durationUs == C.TIME_UNSET + || times == null || times.size() == 0 + || filePositions == null || filePositions.size() != times.size()) { + // Key frames information is missing or incomplete. + return new SeekMap.Unseekable(durationUs); + } + int keyFrameSize = times.size(); + int[] sizes = new int[keyFrameSize]; + long[] offsets = new long[keyFrameSize]; + long[] durationsUs = new long[keyFrameSize]; + long[] timesUs = new long[keyFrameSize]; + for (int i = 0; i < keyFrameSize; i++) { + timesUs[i] = (long) (times.get(i) * C.MICROS_PER_SECOND); + offsets[i] = (long) (filePositions.get(i) + 0); + } + for (int i = 0; i < keyFrameSize - 1; i++) { + sizes[i] = (int) (offsets[i + 1] - offsets[i]); + durationsUs[i] = timesUs[i + 1] - timesUs[i]; + } + sizes[keyFrameSize - 1] = (int) (flvDataSize - sizes[keyFrameSize - 2]); + durationsUs[keyFrameSize - 1] = durationUs - timesUs[keyFrameSize - 1]; + return new ChunkIndex(sizes, offsets, durationsUs, timesUs); + } + private long getCurrentTimestampUs() { return outputFirstSample ? (mediaTagTimestampOffsetUs + tagTimestampUs) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java index 806cc9fad44..1ce75b4c401 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -32,6 +33,9 @@ private static final String NAME_METADATA = "onMetaData"; private static final String KEY_DURATION = "duration"; + private static final String KEY_KEY_FRAMES = "keyframes"; + private static final String KEY_FILE_POSITIONS = "filepositions"; + private static final String KEY_TIMES = "times"; // AMF object types private static final int AMF_TYPE_NUMBER = 0; @@ -45,6 +49,9 @@ private long durationUs; + private List seekMapFilePositions; + private List seekMapTimes; + public ScriptTagPayloadReader() { super(new DummyTrackOutput()); durationUs = C.TIME_UNSET; @@ -89,6 +96,16 @@ protected boolean parsePayload(ParsableByteArray data, long timeUs) throws Parse durationUs = (long) (durationSeconds * C.MICROS_PER_SECOND); } } + if (metadata.containsKey(KEY_KEY_FRAMES)) { + Object frames = metadata.get(KEY_KEY_FRAMES); + if (frames instanceof Map) { + Map framesMap = (Map) metadata.get(KEY_KEY_FRAMES); + if (framesMap.size() > 0) { + seekMapFilePositions = (List) framesMap.get(KEY_FILE_POSITIONS); + seekMapTimes = (List) framesMap.get(KEY_TIMES); + } + } + } return false; } @@ -224,4 +241,12 @@ private static Object readAmfData(ParsableByteArray data, int type) { return null; } } + + public List getSeekMapFilePositions() { + return seekMapFilePositions; + } + + public List getSeekMapTimes() { + return seekMapTimes; + } } From 181676d95011f3518a06b645dede6507d2aac550 Mon Sep 17 00:00:00 2001 From: Yoni Obadia Date: Mon, 24 Aug 2020 15:13:21 +0200 Subject: [PATCH 002/693] dev: adding sorting for TrackSelectionDialog and TrackSelectionDialogBuilder --- .../exoplayer2/demo/DownloadTracker.java | 2 +- .../exoplayer2/demo/PlayerActivity.java | 20 +++++- .../exoplayer2/demo/TrackSelectionDialog.java | 31 ++++++--- .../ui/TrackSelectionDialogBuilder.java | 11 ++- .../exoplayer2/ui/TrackSelectionView.java | 67 +++++++++++++++++-- 5 files changed, 114 insertions(+), 17 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 8109263e550..2d0e19916c3 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 @@ -243,7 +243,7 @@ public void onPrepared(@NonNull DownloadHelper helper) { /* allowAdaptiveSelections =*/ false, /* allowMultipleOverrides= */ true, /* onClickListener= */ this, - /* onDismissListener= */ this); + /* onDismissListener= */ this, null); trackSelectionDialog.show(fragmentManager, /* tag= */ null); } 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 797eb503dde..2194d8833f8 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 @@ -34,6 +34,7 @@ import androidx.appcompat.app.AppCompatActivity; 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.PlaybackPreparer; import com.google.android.exoplayer2.Player; @@ -64,6 +65,8 @@ import java.net.CookiePolicy; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; /** An activity that plays media using {@link SimpleExoPlayer}. */ @@ -242,15 +245,30 @@ public void onClick(View view) { if (view == selectTracksButton && !isShowingTrackSelectionDialog && TrackSelectionDialog.willHaveContent(trackSelector)) { + HashMap> comparatorHashMap = new HashMap<>(); + comparatorHashMap.put(getRendererTypeIndex(C.TRACK_TYPE_AUDIO), (o1, o2) -> o1.bitrate - o2.bitrate); + comparatorHashMap.put(getRendererTypeIndex(C.TRACK_TYPE_VIDEO), (o1, o2) -> o2.bitrate - o1.bitrate); isShowingTrackSelectionDialog = true; TrackSelectionDialog trackSelectionDialog = TrackSelectionDialog.createForTrackSelector( trackSelector, - /* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false); + /* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false, comparatorHashMap); trackSelectionDialog.show(getSupportFragmentManager(), /* tag= */ null); } } + private Integer getRendererTypeIndex(int trackType) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + for(int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i); + if(trackGroupArray.length == 0) return null; + if(trackType == mappedTrackInfo.getRendererType(i)) { + return i; + } + } + return null; + } + // PlaybackPreparer implementation @Override diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java index 5cf2353f21a..af7bfd1f52f 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java @@ -33,6 +33,7 @@ import androidx.fragment.app.FragmentPagerAdapter; import androidx.viewpager.widget.ViewPager; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; @@ -42,6 +43,8 @@ import com.google.android.material.tabs.TabLayout; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; /** Dialog to select tracks. */ @@ -53,6 +56,7 @@ public final class TrackSelectionDialog extends DialogFragment { private int titleId; private DialogInterface.OnClickListener onClickListener; private DialogInterface.OnDismissListener onDismissListener; + private HashMap> comparators = new HashMap<>(); /** * Returns whether a track selection dialog will have content to display if initialized with the @@ -85,7 +89,8 @@ public static boolean willHaveContent(MappedTrackInfo mappedTrackInfo) { * dismissed. */ public static TrackSelectionDialog createForTrackSelector( - DefaultTrackSelector trackSelector, DialogInterface.OnDismissListener onDismissListener) { + DefaultTrackSelector trackSelector, DialogInterface.OnDismissListener onDismissListener, + HashMap> comparators) { MappedTrackInfo mappedTrackInfo = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog(); @@ -115,7 +120,8 @@ public static TrackSelectionDialog createForTrackSelector( } trackSelector.setParameters(builder); }, - onDismissListener); + onDismissListener, + comparators); return trackSelectionDialog; } @@ -140,7 +146,8 @@ public static TrackSelectionDialog createForMappedTrackInfoAndParameters( boolean allowAdaptiveSelections, boolean allowMultipleOverrides, DialogInterface.OnClickListener onClickListener, - DialogInterface.OnDismissListener onDismissListener) { + DialogInterface.OnDismissListener onDismissListener, + @Nullable HashMap> comparators) { TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog(); trackSelectionDialog.init( titleId, @@ -149,7 +156,8 @@ public static TrackSelectionDialog createForMappedTrackInfoAndParameters( allowAdaptiveSelections, allowMultipleOverrides, onClickListener, - onDismissListener); + onDismissListener, + comparators); return trackSelectionDialog; } @@ -167,10 +175,12 @@ private void init( boolean allowAdaptiveSelections, boolean allowMultipleOverrides, DialogInterface.OnClickListener onClickListener, - DialogInterface.OnDismissListener onDismissListener) { + DialogInterface.OnDismissListener onDismissListener, + HashMap> comparators) { this.titleId = titleId; this.onClickListener = onClickListener; this.onDismissListener = onDismissListener; + this.comparators = comparators; for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { if (showTabForRenderer(mappedTrackInfo, i)) { int trackType = mappedTrackInfo.getRendererType(/* rendererIndex= */ i); @@ -182,7 +192,9 @@ private void init( initialParameters.getRendererDisabled(/* rendererIndex= */ i), initialParameters.getSelectionOverride(/* rendererIndex= */ i, trackGroupArray), allowAdaptiveSelections, - allowMultipleOverrides); + allowMultipleOverrides, + this.comparators.get(i) != null ? comparators.get(i) : null + ); tabFragments.put(i, tabFragment); tabTrackTypes.add(trackType); } @@ -314,6 +326,7 @@ public static final class TrackSelectionViewFragment extends Fragment private int rendererIndex; private boolean allowAdaptiveSelections; private boolean allowMultipleOverrides; + private Comparator comparator; /* package */ boolean isDisabled; /* package */ List overrides; @@ -329,10 +342,12 @@ public void init( boolean initialIsDisabled, @Nullable SelectionOverride initialOverride, boolean allowAdaptiveSelections, - boolean allowMultipleOverrides) { + boolean allowMultipleOverrides, + @Nullable Comparator comparator) { this.mappedTrackInfo = mappedTrackInfo; this.rendererIndex = rendererIndex; this.isDisabled = initialIsDisabled; + this.comparator = comparator; this.overrides = initialOverride == null ? Collections.emptyList() @@ -354,7 +369,7 @@ public View onCreateView( trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides); trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); trackSelectionView.init( - mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this); + mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this, comparator); return rootView; } 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 30098054ef7..b9ae8888aef 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 @@ -24,6 +24,7 @@ import android.view.LayoutInflater; import android.view.View; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; @@ -31,6 +32,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionUtil; import java.lang.reflect.Constructor; import java.util.Collections; +import java.util.Comparator; import java.util.List; /** Builder for a dialog with a {@link TrackSelectionView}. */ @@ -60,6 +62,7 @@ public interface DialogCallback { @Nullable private TrackNameProvider trackNameProvider; private boolean isDisabled; private List overrides; + private Comparator comparator; /** * Creates a builder for a track selection dialog. @@ -195,6 +198,12 @@ public TrackSelectionDialogBuilder setShowDisableOption(boolean showDisableOptio return this; } + public void setComparator(Comparator comparator) { + if(this.comparator != comparator) { + this.comparator = comparator; + } + } + /** * Sets the {@link TrackNameProvider} used to generate the user visible name of each track and * updates the view with track names queried from the specified provider. @@ -274,7 +283,7 @@ private Dialog.OnClickListener setUpDialogView(View dialogView) { if (trackNameProvider != null) { selectionView.setTrackNameProvider(trackNameProvider); } - selectionView.init(mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ null); + selectionView.init(mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ null, comparator); return (dialog, which) -> callback.onTracksSelected(selectionView.getIsDisabled(), selectionView.getOverrides()); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java index b47feb2a718..7023ea1cc19 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java @@ -26,6 +26,7 @@ import android.widget.LinearLayout; import androidx.annotation.AttrRes; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -35,6 +36,7 @@ import com.google.android.exoplayer2.util.Assertions; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -64,6 +66,9 @@ public interface TrackSelectionListener { private boolean allowAdaptiveSelections; private boolean allowMultipleOverrides; + @Nullable private Comparator comparator; + private TrackGroupArray sortedTrackGroups; + private TrackNameProvider trackNameProvider; private CheckedTextView[][] trackViews; @@ -203,11 +208,13 @@ public void init( int rendererIndex, boolean isDisabled, List overrides, - @Nullable TrackSelectionListener listener) { + @Nullable TrackSelectionListener listener, + @Nullable Comparator comparator) { this.mappedTrackInfo = mappedTrackInfo; this.rendererIndex = rendererIndex; this.isDisabled = isDisabled; this.listener = listener; + this.comparator = comparator; int maxOverrides = allowMultipleOverrides ? overrides.size() : Math.min(overrides.size(), 1); for (int i = 0; i < maxOverrides; i++) { SelectionOverride override = overrides.get(i); @@ -251,12 +258,13 @@ private void updateViews() { defaultView.setEnabled(true); trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + sortedTrackGroups = initSortedTrackGroups(trackGroups); // Add per-track views. - trackViews = new CheckedTextView[trackGroups.length][]; + trackViews = new CheckedTextView[sortedTrackGroups.length][]; boolean enableMultipleChoiceForMultipleOverrides = shouldEnableMultiGroupSelection(); - for (int groupIndex = 0; groupIndex < trackGroups.length; groupIndex++) { - TrackGroup group = trackGroups.get(groupIndex); + for (int groupIndex = 0; groupIndex < sortedTrackGroups.length; groupIndex++) { + TrackGroup group = sortedTrackGroups.get(groupIndex); boolean enableMultipleChoiceForAdaptiveSelections = shouldEnableAdaptiveSelection(groupIndex); trackViews[groupIndex] = new CheckedTextView[group.length]; for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { @@ -294,7 +302,12 @@ private void updateViewStates() { for (int i = 0; i < trackViews.length; i++) { SelectionOverride override = overrides.get(i); for (int j = 0; j < trackViews[i].length; j++) { - trackViews[i][j].setChecked(override != null && override.containsTrack(j)); + if(override != null) { + int sortedIndex = getSortedIndexFromInitialTrackGroup(override.groupIndex, j); + trackViews[i][j].setChecked(override.containsTrack(sortedIndex)); + } else { + trackViews[i][j].setChecked(false); + } } } } @@ -328,7 +341,7 @@ private void onTrackViewClicked(View view) { @SuppressWarnings("unchecked") Pair tag = (Pair) Assertions.checkNotNull(view.getTag()); int groupIndex = tag.first; - int trackIndex = tag.second; + int trackIndex = getSortedIndexFromInitialTrackGroup(tag.first, tag.second); SelectionOverride override = overrides.get(groupIndex); Assertions.checkNotNull(mappedTrackInfo); if (override == null) { @@ -367,6 +380,48 @@ private void onTrackViewClicked(View view) { } } + private TrackGroupArray initSortedTrackGroups(TrackGroupArray trackGroups) { + TrackGroupArray trackGroupArray = trackGroups; + if(comparator != null) { + TrackGroupArray trackGroupsArray = mappedTrackInfo.getTrackGroups(rendererIndex); + for (int groupIndex = 0; groupIndex < trackGroupsArray.length; groupIndex++) { + TrackGroup group = trackGroupsArray.get(groupIndex); + Format[] listFormats = new Format[group.length]; + for (int formatIndex = 0; formatIndex < group.length; formatIndex++) { + listFormats[formatIndex] = group.getFormat(formatIndex); + } + Arrays.sort(listFormats, comparator); + trackGroupArray = new TrackGroupArray(new TrackGroup(listFormats)); + } + } + return trackGroupArray; + } + + /** + * The correspondence between trackGroup and sortedTrackGroup indexes. + * initial array (only quality for this example) : [480,720,256,1080] + * asc sorted array (only quality for this example) : [256,480,720,1080] + * initial array index for 480 is 0, but for sorted array index is 1. + * Initial array index is @param trackIndex, and the @return result is sorted array index + * @param groupIndex which TrackGroup you want to browse into + * @param trackIndex which index of the initial array + * @return index of the sorted array that correspond to the same element in the initial array + */ + private int getSortedIndexFromInitialTrackGroup(int groupIndex, int trackIndex) { + int sortedTrackIndex = trackIndex; + if(sortedTrackGroups != trackGroups) { + Format selectedFormat = sortedTrackGroups.get(rendererIndex).getFormat(trackIndex); + int trackHash = selectedFormat.hashCode(); + for (int formatIndex = 0; formatIndex < trackGroups.get(groupIndex).length; formatIndex++) { + if(trackGroups.get(groupIndex).getFormat(formatIndex).hashCode() == trackHash) { + sortedTrackIndex = formatIndex; + break; + } + } + } + return sortedTrackIndex; + } + @RequiresNonNull("mappedTrackInfo") private boolean shouldEnableAdaptiveSelection(int groupIndex) { return allowAdaptiveSelections From 425bd2d801952e9aefd01002bf16555537fbe955 Mon Sep 17 00:00:00 2001 From: Yoni Obadia Date: Wed, 26 Aug 2020 14:31:44 +0200 Subject: [PATCH 003/693] improvement: get already existing sortingTrackGroups if exists --- .../com/google/android/exoplayer2/ui/TrackSelectionView.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java index 7023ea1cc19..210d3948378 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java @@ -258,7 +258,7 @@ private void updateViews() { defaultView.setEnabled(true); trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); - sortedTrackGroups = initSortedTrackGroups(trackGroups); + sortedTrackGroups = sortedTrackGroups != null ? sortedTrackGroups : initSortedTrackGroups(trackGroups); // Add per-track views. trackViews = new CheckedTextView[sortedTrackGroups.length][]; From ed58280d239ad21f3ccfe34bbf846bf50578b403 Mon Sep 17 00:00:00 2001 From: Yoni Obadia Date: Thu, 27 Aug 2020 09:46:30 +0200 Subject: [PATCH 004/693] Review: Update according to review --- .../android/exoplayer2/ui/TrackSelectionDialogBuilder.java | 6 ++---- .../google/android/exoplayer2/ui/TrackSelectionView.java | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) 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 b9ae8888aef..ec8b3562a8d 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 @@ -62,7 +62,7 @@ public interface DialogCallback { @Nullable private TrackNameProvider trackNameProvider; private boolean isDisabled; private List overrides; - private Comparator comparator; + @Nullable private Comparator comparator; /** * Creates a builder for a track selection dialog. @@ -199,9 +199,7 @@ public TrackSelectionDialogBuilder setShowDisableOption(boolean showDisableOptio } public void setComparator(Comparator comparator) { - if(this.comparator != comparator) { - this.comparator = comparator; - } + this.comparator = comparator; } /** diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java index 210d3948378..79fa172e185 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java @@ -202,6 +202,7 @@ public void setTrackNameProvider(TrackNameProvider trackNameProvider) { * one override for each track group. If {@link #setAllowMultipleOverrides(boolean)} hasn't * been set to {@code true}, only the first override is used. * @param listener An optional listener for track selection updates. + * @param comparator An optional comparator to order track selection */ public void init( MappedTrackInfo mappedTrackInfo, @@ -258,7 +259,7 @@ private void updateViews() { defaultView.setEnabled(true); trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); - sortedTrackGroups = sortedTrackGroups != null ? sortedTrackGroups : initSortedTrackGroups(trackGroups); + sortedTrackGroups = initSortedTrackGroups(trackGroups); // Add per-track views. trackViews = new CheckedTextView[sortedTrackGroups.length][]; @@ -411,9 +412,8 @@ private int getSortedIndexFromInitialTrackGroup(int groupIndex, int trackIndex) int sortedTrackIndex = trackIndex; if(sortedTrackGroups != trackGroups) { Format selectedFormat = sortedTrackGroups.get(rendererIndex).getFormat(trackIndex); - int trackHash = selectedFormat.hashCode(); for (int formatIndex = 0; formatIndex < trackGroups.get(groupIndex).length; formatIndex++) { - if(trackGroups.get(groupIndex).getFormat(formatIndex).hashCode() == trackHash) { + if(trackGroups.get(groupIndex).getFormat(formatIndex) == selectedFormat) { sortedTrackIndex = formatIndex; break; } From 551b6d65ae137e38359ba6762016e7fb481eafd4 Mon Sep 17 00:00:00 2001 From: Yoni Obadia Date: Thu, 27 Aug 2020 10:15:17 +0200 Subject: [PATCH 005/693] Review: Simplify getSortedIndexFromInitialTrackGroup --- .../android/exoplayer2/ui/TrackSelectionView.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java index 79fa172e185..3d33e0db029 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java @@ -304,7 +304,7 @@ private void updateViewStates() { SelectionOverride override = overrides.get(i); for (int j = 0; j < trackViews[i].length; j++) { if(override != null) { - int sortedIndex = getSortedIndexFromInitialTrackGroup(override.groupIndex, j); + int sortedIndex = getSortedIndexFromInitialTrackGroup(j); trackViews[i][j].setChecked(override.containsTrack(sortedIndex)); } else { trackViews[i][j].setChecked(false); @@ -342,7 +342,7 @@ private void onTrackViewClicked(View view) { @SuppressWarnings("unchecked") Pair tag = (Pair) Assertions.checkNotNull(view.getTag()); int groupIndex = tag.first; - int trackIndex = getSortedIndexFromInitialTrackGroup(tag.first, tag.second); + int trackIndex = getSortedIndexFromInitialTrackGroup(tag.second); SelectionOverride override = overrides.get(groupIndex); Assertions.checkNotNull(mappedTrackInfo); if (override == null) { @@ -404,20 +404,14 @@ private TrackGroupArray initSortedTrackGroups(TrackGroupArray trackGroups) { * asc sorted array (only quality for this example) : [256,480,720,1080] * initial array index for 480 is 0, but for sorted array index is 1. * Initial array index is @param trackIndex, and the @return result is sorted array index - * @param groupIndex which TrackGroup you want to browse into * @param trackIndex which index of the initial array * @return index of the sorted array that correspond to the same element in the initial array */ - private int getSortedIndexFromInitialTrackGroup(int groupIndex, int trackIndex) { + private int getSortedIndexFromInitialTrackGroup(int trackIndex) { int sortedTrackIndex = trackIndex; if(sortedTrackGroups != trackGroups) { Format selectedFormat = sortedTrackGroups.get(rendererIndex).getFormat(trackIndex); - for (int formatIndex = 0; formatIndex < trackGroups.get(groupIndex).length; formatIndex++) { - if(trackGroups.get(groupIndex).getFormat(formatIndex) == selectedFormat) { - sortedTrackIndex = formatIndex; - break; - } - } + sortedTrackIndex = trackGroups.get(rendererIndex).indexOf(selectedFormat); } return sortedTrackIndex; } From c9b4b981037f1fa4a8c37877b59ada0c19bb52e8 Mon Sep 17 00:00:00 2001 From: Yoni Obadia Date: Thu, 27 Aug 2020 12:19:56 +0200 Subject: [PATCH 006/693] Review: Fix initSortedTrack to keep all groups in the TrackGroupArray --- .../google/android/exoplayer2/ui/TrackSelectionView.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java index 3d33e0db029..70b6ab9644f 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java @@ -384,16 +384,17 @@ private void onTrackViewClicked(View view) { private TrackGroupArray initSortedTrackGroups(TrackGroupArray trackGroups) { TrackGroupArray trackGroupArray = trackGroups; if(comparator != null) { - TrackGroupArray trackGroupsArray = mappedTrackInfo.getTrackGroups(rendererIndex); - for (int groupIndex = 0; groupIndex < trackGroupsArray.length; groupIndex++) { - TrackGroup group = trackGroupsArray.get(groupIndex); + TrackGroup[] tg = new TrackGroup[trackGroupArray.length]; + for (int groupIndex = 0; groupIndex < trackGroupArray.length; groupIndex++) { + TrackGroup group = trackGroupArray.get(groupIndex); Format[] listFormats = new Format[group.length]; for (int formatIndex = 0; formatIndex < group.length; formatIndex++) { listFormats[formatIndex] = group.getFormat(formatIndex); } Arrays.sort(listFormats, comparator); - trackGroupArray = new TrackGroupArray(new TrackGroup(listFormats)); + tg[groupIndex] = new TrackGroup(listFormats); } + trackGroupArray = new TrackGroupArray(tg); } return trackGroupArray; } From 50582417cb6755b73d6dd0262e5ce38b4e6f2460 Mon Sep 17 00:00:00 2001 From: Corentin Zuber Date: Fri, 31 Jul 2020 07:55:22 +0200 Subject: [PATCH 007/693] Authorize multiple preffered language and text --- .../trackselection/DefaultTrackSelector.java | 38 +++++++--- .../TrackSelectionParameters.java | 76 ++++++++++++++----- 2 files changed, 83 insertions(+), 31 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 f61c0b14d45..9933a2b023c 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 @@ -450,6 +450,12 @@ public ParametersBuilder setPreferredAudioLanguage(@Nullable String preferredAud return this; } + @Override + public ParametersBuilder setPreferredAudioLanguage(String[] preferredAudioLanguage) { + super.setPreferredAudioLanguage(preferredAudioLanguage); + return this; + } + /** * Sets the maximum allowed audio channel count. * @@ -1015,7 +1021,7 @@ public static Parameters getDefaults(Context context) { int viewportHeight, boolean viewportOrientationMayChange, // Audio - @Nullable String preferredAudioLanguage, + String[] preferredAudioLanguage, int maxAudioChannelCount, int maxAudioBitrate, boolean exceedAudioConstraintsIfNecessary, @@ -1023,7 +1029,7 @@ public static Parameters getDefaults(Context context) { boolean allowAudioMixedSampleRateAdaptiveness, boolean allowAudioMixedChannelCountAdaptiveness, // Text - @Nullable String preferredTextLanguage, + String[] preferredTextLanguage, @C.RoleFlags int preferredTextRoleFlags, boolean selectUndeterminedTextLanguage, @C.SelectionFlags int disabledTextTrackSelectionFlags, @@ -2619,11 +2625,16 @@ public AudioTrackScore(Format format, Parameters parameters, @Capabilities int f this.language = normalizeUndeterminedLanguageToNull(format.language); isWithinRendererCapabilities = isSupported(formatSupport, /* allowExceedsCapabilities= */ false); - preferredLanguageScore = - getFormatLanguageScore( - format, - parameters.preferredAudioLanguage, - /* allowUndeterminedFormatLanguage= */ false); + int bestLanguageScore = 0; + for (int i = 0; i < parameters.preferredAudioLanguage.length; i++) { + int score = getFormatLanguageScore( + format, + parameters.preferredAudioLanguage[i], + /* allowUndeterminedFormatLanguage= */ false); + score = 1000 * score + parameters.preferredAudioLanguage.length - i; // Priorise the first items in array + bestLanguageScore = Math.max(bestLanguageScore, score); + } + preferredLanguageScore = bestLanguageScore; isDefaultSelectionFlag = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; channelCount = format.channelCount; sampleRate = format.sampleRate; @@ -2717,9 +2728,14 @@ public TextTrackScore( format.selectionFlags & ~parameters.disabledTextTrackSelectionFlags; isDefault = (maskedSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; - preferredLanguageScore = - getFormatLanguageScore( - format, parameters.preferredTextLanguage, parameters.selectUndeterminedTextLanguage); + int bestLanguageScore = 0; + for (int i = 0; i < parameters.preferredTextLanguage.length; i++) { + int score = getFormatLanguageScore( + format, parameters.preferredTextLanguage[i], parameters.selectUndeterminedTextLanguage); + score = 1000 * score + parameters.preferredTextLanguage.length - i; // Priorise the first items in array + bestLanguageScore = Math.max(bestLanguageScore, score); + } + preferredLanguageScore = bestLanguageScore; preferredRoleFlagsScore = Integer.bitCount(format.roleFlags & parameters.preferredTextRoleFlags); hasCaptionRoleFlags = @@ -2730,7 +2746,7 @@ public TextTrackScore( getFormatLanguageScore(format, selectedAudioLanguage, selectedAudioLanguageUndetermined); isWithinConstraints = preferredLanguageScore > 0 - || (parameters.preferredTextLanguage == null && preferredRoleFlagsScore > 0) + || (parameters.preferredTextLanguage.length == 0 && preferredRoleFlagsScore > 0) || isDefault || (isForced && selectedAudioLanguageScore > 0); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java index 3871a31a3be..ac8a84dbf52 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java @@ -19,12 +19,12 @@ import android.os.Looper; import android.os.Parcel; import android.os.Parcelable; -import android.text.TextUtils; import android.view.accessibility.CaptioningManager; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; import java.util.Locale; /** Constraint parameters for track selection. */ @@ -36,8 +36,8 @@ public class TrackSelectionParameters implements Parcelable { */ public static class Builder { - @Nullable /* package */ String preferredAudioLanguage; - @Nullable /* package */ String preferredTextLanguage; + /* package */ String[] preferredAudioLanguage; + /* package */ String[] preferredTextLanguage; @C.RoleFlags /* package */ int preferredTextRoleFlags; /* package */ boolean selectUndeterminedTextLanguage; @C.SelectionFlags /* package */ int disabledTextTrackSelectionFlags; @@ -59,8 +59,8 @@ public Builder(Context context) { */ @Deprecated public Builder() { - preferredAudioLanguage = null; - preferredTextLanguage = null; + preferredAudioLanguage = new String[0]; + preferredTextLanguage = new String[0]; preferredTextRoleFlags = 0; selectUndeterminedTextLanguage = false; disabledTextTrackSelectionFlags = 0; @@ -86,6 +86,14 @@ public Builder() { * @return This builder. */ public Builder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { + if (preferredAudioLanguage == null) { + return setPreferredAudioLanguage(new String[0]); + } else { + return setPreferredAudioLanguage(new String[] { preferredAudioLanguage }); + } + } + + public Builder setPreferredAudioLanguage(String[] preferredAudioLanguage) { this.preferredAudioLanguage = preferredAudioLanguage; return this; } @@ -115,6 +123,14 @@ public Builder setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings( * @return This builder. */ public Builder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { + if (preferredTextLanguage == null) { + return setPreferredTextLanguage(new String[0]); + } else { + return setPreferredTextLanguage(new String[]{preferredTextLanguage}); + } + } + + public Builder setPreferredTextLanguage(String[] preferredTextLanguage) { this.preferredTextLanguage = preferredTextLanguage; return this; } @@ -185,7 +201,7 @@ private void setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettingsV19( preferredTextRoleFlags = C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; Locale preferredLocale = captioningManager.getLocale(); if (preferredLocale != null) { - preferredTextLanguage = Util.getLocaleLanguageTag(preferredLocale); + preferredTextLanguage = new String[] { Util.getLocaleLanguageTag(preferredLocale) }; } } } @@ -222,13 +238,13 @@ public static TrackSelectionParameters getDefaults(Context context) { * {@code null} selects the default track, or the first track if there's no default. The default * value is {@code null}. */ - @Nullable public final String preferredAudioLanguage; + public final String[] preferredAudioLanguage; /** * The preferred language for text tracks as an IETF BCP 47 conformant tag. {@code null} selects * the default track if there is one, or no track otherwise. The default value is {@code null}, or * the language of the accessibility {@link CaptioningManager} if enabled. */ - @Nullable public final String preferredTextLanguage; + public final String[] preferredTextLanguage; /** * The preferred {@link C.RoleFlags} for text tracks. {@code 0} selects the default track if there * is one, or no track otherwise. The default value is {@code 0}, or {@link C#ROLE_FLAG_SUBTITLE} @@ -249,23 +265,37 @@ public static TrackSelectionParameters getDefaults(Context context) { @C.SelectionFlags public final int disabledTextTrackSelectionFlags; /* package */ TrackSelectionParameters( - @Nullable String preferredAudioLanguage, - @Nullable String preferredTextLanguage, + String[] preferredAudioLanguage, + String[] preferredTextLanguage, @C.RoleFlags int preferredTextRoleFlags, boolean selectUndeterminedTextLanguage, @C.SelectionFlags int disabledTextTrackSelectionFlags) { // Audio - this.preferredAudioLanguage = Util.normalizeLanguageCode(preferredAudioLanguage); + this.preferredAudioLanguage = new String[preferredAudioLanguage.length]; + for (int i = 0; i < preferredAudioLanguage.length; i++) { + this.preferredAudioLanguage[i] = Util.normalizeLanguageCode(preferredAudioLanguage[i]); + } // Text - this.preferredTextLanguage = Util.normalizeLanguageCode(preferredTextLanguage); + this.preferredTextLanguage = new String[preferredAudioLanguage.length]; + for (int i = 0; i < preferredTextLanguage.length; i++) { + this.preferredTextLanguage[i] = Util.normalizeLanguageCode(preferredTextLanguage[i]); + } this.preferredTextRoleFlags = preferredTextRoleFlags; this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; } /* package */ TrackSelectionParameters(Parcel in) { - this.preferredAudioLanguage = in.readString(); - this.preferredTextLanguage = in.readString(); + int preferredAudioLanguageSize = in.readInt(); + this.preferredAudioLanguage = new String[preferredAudioLanguageSize]; + for (int i = 0; i < preferredAudioLanguageSize; i++) { + preferredAudioLanguage[i] = in.readString(); + } + int preferredTextLanguageSize = in.readInt(); + this.preferredTextLanguage = new String[preferredTextLanguageSize]; + for (int i = 0; i < preferredTextLanguageSize; i++) { + preferredTextLanguage[i] = in.readString(); + } this.preferredTextRoleFlags = in.readInt(); this.selectUndeterminedTextLanguage = Util.readBoolean(in); this.disabledTextTrackSelectionFlags = in.readInt(); @@ -286,8 +316,8 @@ public boolean equals(@Nullable Object obj) { return false; } TrackSelectionParameters other = (TrackSelectionParameters) obj; - return TextUtils.equals(preferredAudioLanguage, other.preferredAudioLanguage) - && TextUtils.equals(preferredTextLanguage, other.preferredTextLanguage) + return Arrays.equals(preferredAudioLanguage, other.preferredAudioLanguage) + && Arrays.equals(preferredTextLanguage, other.preferredTextLanguage) && preferredTextRoleFlags == other.preferredTextRoleFlags && selectUndeterminedTextLanguage == other.selectUndeterminedTextLanguage && disabledTextTrackSelectionFlags == other.disabledTextTrackSelectionFlags; @@ -296,8 +326,8 @@ public boolean equals(@Nullable Object obj) { @Override public int hashCode() { int result = 1; - result = 31 * result + (preferredAudioLanguage == null ? 0 : preferredAudioLanguage.hashCode()); - result = 31 * result + (preferredTextLanguage == null ? 0 : preferredTextLanguage.hashCode()); + result = 31 * result + Arrays.hashCode(preferredAudioLanguage); + result = 31 * result + Arrays.hashCode(preferredTextLanguage); result = 31 * result + preferredTextRoleFlags; result = 31 * result + (selectUndeterminedTextLanguage ? 1 : 0); result = 31 * result + disabledTextTrackSelectionFlags; @@ -313,8 +343,14 @@ public int describeContents() { @Override public void writeToParcel(Parcel dest, int flags) { - dest.writeString(preferredAudioLanguage); - dest.writeString(preferredTextLanguage); + dest.writeInt(preferredAudioLanguage.length); + for (String s : preferredAudioLanguage) { + dest.writeString(s); + } + dest.writeInt(preferredTextLanguage.length); + for (String s : preferredTextLanguage) { + dest.writeString(s); + } dest.writeInt(preferredTextRoleFlags); Util.writeBoolean(dest, selectUndeterminedTextLanguage); dest.writeInt(disabledTextTrackSelectionFlags); From 7bfde6a5ea5402a11640087dc44cbec77aefe217 Mon Sep 17 00:00:00 2001 From: Corentin Zuber Date: Thu, 3 Sep 2020 10:19:55 +0200 Subject: [PATCH 008/693] Fix comments --- .../exoplayer2/offline/DownloadHelper.java | 4 +- .../trackselection/DefaultTrackSelector.java | 48 +++++----- .../TrackSelectionParameters.java | 96 +++++++++---------- .../offline/DownloadHelperTest.java | 12 +-- .../DefaultTrackSelectorTest.java | 24 ++--- 5 files changed, 89 insertions(+), 95 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 8cb619f2a28..e944e173b93 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 @@ -654,7 +654,7 @@ public void addAudioLanguagesToSelection(String... languages) { } } for (String language : languages) { - parametersBuilder.setPreferredAudioLanguage(language); + parametersBuilder.setPreferredAudioLanguages(language); addTrackSelection(periodIndex, parametersBuilder.build()); } } @@ -685,7 +685,7 @@ public void addTextLanguagesToSelection( } parametersBuilder.setSelectUndeterminedTextLanguage(selectUndeterminedTextLanguage); for (String language : languages) { - parametersBuilder.setPreferredTextLanguage(language); + parametersBuilder.setPreferredTextLanguages(language); addTrackSelection(periodIndex, parametersBuilder.build()); } } 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 9933a2b023c..8abc9b42f05 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.util.Assertions; import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ComparisonChain; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Ordering; import com.google.common.primitives.Ints; import java.util.ArrayList; @@ -445,14 +446,8 @@ public ParametersBuilder setViewportSize( // Audio @Override - public ParametersBuilder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { - super.setPreferredAudioLanguage(preferredAudioLanguage); - return this; - } - - @Override - public ParametersBuilder setPreferredAudioLanguage(String[] preferredAudioLanguage) { - super.setPreferredAudioLanguage(preferredAudioLanguage); + public ParametersBuilder setPreferredAudioLanguages(@Nullable String... preferredAudioLanguages) { + super.setPreferredAudioLanguages(preferredAudioLanguages); return this; } @@ -547,8 +542,8 @@ public ParametersBuilder setPreferredTextLanguageAndRoleFlagsToCaptioningManager } @Override - public ParametersBuilder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { - super.setPreferredTextLanguage(preferredTextLanguage); + public ParametersBuilder setPreferredTextLanguages(@Nullable String... preferredTextLanguages) { + super.setPreferredTextLanguages(preferredTextLanguages); return this; } @@ -773,7 +768,7 @@ public Parameters build() { viewportHeight, viewportOrientationMayChange, // Audio - preferredAudioLanguage, + preferredAudioLanguages, maxAudioChannelCount, maxAudioBitrate, exceedAudioConstraintsIfNecessary, @@ -781,7 +776,7 @@ public Parameters build() { allowAudioMixedSampleRateAdaptiveness, allowAudioMixedChannelCountAdaptiveness, // Text - preferredTextLanguage, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags, @@ -1021,7 +1016,7 @@ public static Parameters getDefaults(Context context) { int viewportHeight, boolean viewportOrientationMayChange, // Audio - String[] preferredAudioLanguage, + ImmutableList preferredAudioLanguage, int maxAudioChannelCount, int maxAudioBitrate, boolean exceedAudioConstraintsIfNecessary, @@ -1029,7 +1024,7 @@ public static Parameters getDefaults(Context context) { boolean allowAudioMixedSampleRateAdaptiveness, boolean allowAudioMixedChannelCountAdaptiveness, // Text - String[] preferredTextLanguage, + ImmutableList preferredTextLanguage, @C.RoleFlags int preferredTextRoleFlags, boolean selectUndeterminedTextLanguage, @C.SelectionFlags int disabledTextTrackSelectionFlags, @@ -2613,6 +2608,7 @@ protected static final class AudioTrackScore implements Comparable 0) { + bestLanguageIndex = i; + bestLanguageScore = score; + } } + preferredLanguageIndex = bestLanguageIndex; preferredLanguageScore = bestLanguageScore; isDefaultSelectionFlag = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; channelCount = format.channelCount; @@ -2677,6 +2677,10 @@ public int compareTo(AudioTrackScore other) { : FORMAT_VALUE_ORDERING.reverse(); return ComparisonChain.start() .compareFalseFirst(this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + .compare( + this.preferredLanguageIndex, + other.preferredLanguageIndex, + Ordering.natural().reverse()) .compare(this.preferredLanguageScore, other.preferredLanguageScore) .compareFalseFirst(this.isWithinConstraints, other.isWithinConstraints) .compare( @@ -2729,10 +2733,10 @@ public TextTrackScore( isDefault = (maskedSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; int bestLanguageScore = 0; - for (int i = 0; i < parameters.preferredTextLanguage.length; i++) { + for (int i = 0; i < parameters.preferredTextLanguages.size(); i++) { int score = getFormatLanguageScore( - format, parameters.preferredTextLanguage[i], parameters.selectUndeterminedTextLanguage); - score = 1000 * score + parameters.preferredTextLanguage.length - i; // Priorise the first items in array + format, parameters.preferredTextLanguages.get(i), parameters.selectUndeterminedTextLanguage); + score = 1000 * score + parameters.preferredTextLanguages.size() - i; // Priorise the first items in array bestLanguageScore = Math.max(bestLanguageScore, score); } preferredLanguageScore = bestLanguageScore; @@ -2746,7 +2750,7 @@ public TextTrackScore( getFormatLanguageScore(format, selectedAudioLanguage, selectedAudioLanguageUndetermined); isWithinConstraints = preferredLanguageScore > 0 - || (parameters.preferredTextLanguage.length == 0 && preferredRoleFlagsScore > 0) + || (parameters.preferredTextLanguages.size() == 0 && preferredRoleFlagsScore > 0) || isDefault || (isForced && selectedAudioLanguageScore > 0); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java index ac8a84dbf52..b4d54aba383 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java @@ -24,6 +24,8 @@ import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; import java.util.Arrays; import java.util.Locale; @@ -36,8 +38,8 @@ public class TrackSelectionParameters implements Parcelable { */ public static class Builder { - /* package */ String[] preferredAudioLanguage; - /* package */ String[] preferredTextLanguage; + /* package */ ImmutableList preferredAudioLanguages; + /* package */ ImmutableList preferredTextLanguages; @C.RoleFlags /* package */ int preferredTextRoleFlags; /* package */ boolean selectUndeterminedTextLanguage; @C.SelectionFlags /* package */ int disabledTextTrackSelectionFlags; @@ -59,8 +61,8 @@ public Builder(Context context) { */ @Deprecated public Builder() { - preferredAudioLanguage = new String[0]; - preferredTextLanguage = new String[0]; + preferredAudioLanguages = ImmutableList.of(); + preferredTextLanguages = ImmutableList.of(); preferredTextRoleFlags = 0; selectUndeterminedTextLanguage = false; disabledTextTrackSelectionFlags = 0; @@ -71,8 +73,8 @@ public Builder() { * the builder are obtained. */ /* package */ Builder(TrackSelectionParameters initialValues) { - preferredAudioLanguage = initialValues.preferredAudioLanguage; - preferredTextLanguage = initialValues.preferredTextLanguage; + preferredAudioLanguages = initialValues.preferredAudioLanguages; + preferredTextLanguages = initialValues.preferredTextLanguages; preferredTextRoleFlags = initialValues.preferredTextRoleFlags; selectUndeterminedTextLanguage = initialValues.selectUndeterminedTextLanguage; disabledTextTrackSelectionFlags = initialValues.disabledTextTrackSelectionFlags; @@ -81,20 +83,16 @@ public Builder() { /** * Sets the preferred language for audio and forced text tracks. * - * @param preferredAudioLanguage Preferred audio language as an IETF BCP 47 conformant tag, or + * @param preferredAudioLanguages Preferred audio language as an IETF BCP 47 conformant tag, or * {@code null} to select the default track, or the first track if there's no default. * @return This builder. */ - public Builder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { - if (preferredAudioLanguage == null) { - return setPreferredAudioLanguage(new String[0]); + public Builder setPreferredAudioLanguages(@Nullable String... preferredAudioLanguages) { + if (preferredAudioLanguages == null) { + this.preferredAudioLanguages = ImmutableList.of(); } else { - return setPreferredAudioLanguage(new String[] { preferredAudioLanguage }); + this.preferredAudioLanguages = ImmutableList.copyOf(preferredAudioLanguages); } - } - - public Builder setPreferredAudioLanguage(String[] preferredAudioLanguage) { - this.preferredAudioLanguage = preferredAudioLanguage; return this; } @@ -118,20 +116,16 @@ public Builder setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings( /** * Sets the preferred language for text tracks. * - * @param preferredTextLanguage Preferred text language as an IETF BCP 47 conformant tag, or + * @param preferredTextLanguages Preferred text language as an IETF BCP 47 conformant tag, or * {@code null} to select the default track if there is one, or no track otherwise. * @return This builder. */ - public Builder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { - if (preferredTextLanguage == null) { - return setPreferredTextLanguage(new String[0]); + public Builder setPreferredTextLanguages(@Nullable String... preferredTextLanguages) { + if (preferredTextLanguages == null) { + this.preferredTextLanguages = ImmutableList.of(); } else { - return setPreferredTextLanguage(new String[]{preferredTextLanguage}); + this.preferredTextLanguages = ImmutableList.copyOf(preferredTextLanguages); } - } - - public Builder setPreferredTextLanguage(String[] preferredTextLanguage) { - this.preferredTextLanguage = preferredTextLanguage; return this; } @@ -148,7 +142,7 @@ public Builder setPreferredTextRoleFlags(@C.RoleFlags int preferredTextRoleFlags /** * Sets whether a text track with undetermined language should be selected if no track with - * {@link #setPreferredTextLanguage(String)} is available, or if the preferred language is + * {@link #setPreferredTextLanguages(String...)} is available, or if the preferred language is * unset. * * @param selectUndeterminedTextLanguage Whether a text track with undetermined language should @@ -177,9 +171,9 @@ public Builder setDisabledTextTrackSelectionFlags( public TrackSelectionParameters build() { return new TrackSelectionParameters( // Audio - preferredAudioLanguage, + preferredAudioLanguages, // Text - preferredTextLanguage, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags); @@ -201,7 +195,7 @@ private void setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettingsV19( preferredTextRoleFlags = C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; Locale preferredLocale = captioningManager.getLocale(); if (preferredLocale != null) { - preferredTextLanguage = new String[] { Util.getLocaleLanguageTag(preferredLocale) }; + preferredTextLanguages = ImmutableList.of(Util.getLocaleLanguageTag(preferredLocale)); } } } @@ -238,13 +232,13 @@ public static TrackSelectionParameters getDefaults(Context context) { * {@code null} selects the default track, or the first track if there's no default. The default * value is {@code null}. */ - public final String[] preferredAudioLanguage; + public final ImmutableList preferredAudioLanguages; /** * The preferred language for text tracks as an IETF BCP 47 conformant tag. {@code null} selects * the default track if there is one, or no track otherwise. The default value is {@code null}, or * the language of the accessibility {@link CaptioningManager} if enabled. */ - public final String[] preferredTextLanguage; + public final ImmutableList preferredTextLanguages; /** * The preferred {@link C.RoleFlags} for text tracks. {@code 0} selects the default track if there * is one, or no track otherwise. The default value is {@code 0}, or {@link C#ROLE_FLAG_SUBTITLE} @@ -254,7 +248,7 @@ public static TrackSelectionParameters getDefaults(Context context) { @C.RoleFlags public final int preferredTextRoleFlags; /** * Whether a text track with undetermined language should be selected if no track with {@link - * #preferredTextLanguage} is available, or if {@link #preferredTextLanguage} is unset. The + * #preferredTextLanguages} is available, or if {@link #preferredTextLanguages} is unset. The * default value is {@code false}. */ public final boolean selectUndeterminedTextLanguage; @@ -265,21 +259,15 @@ public static TrackSelectionParameters getDefaults(Context context) { @C.SelectionFlags public final int disabledTextTrackSelectionFlags; /* package */ TrackSelectionParameters( - String[] preferredAudioLanguage, - String[] preferredTextLanguage, + ImmutableList preferredAudioLanguages, + ImmutableList preferredTextLanguages, @C.RoleFlags int preferredTextRoleFlags, boolean selectUndeterminedTextLanguage, @C.SelectionFlags int disabledTextTrackSelectionFlags) { // Audio - this.preferredAudioLanguage = new String[preferredAudioLanguage.length]; - for (int i = 0; i < preferredAudioLanguage.length; i++) { - this.preferredAudioLanguage[i] = Util.normalizeLanguageCode(preferredAudioLanguage[i]); - } + this.preferredAudioLanguages = preferredAudioLanguages; // Text - this.preferredTextLanguage = new String[preferredAudioLanguage.length]; - for (int i = 0; i < preferredTextLanguage.length; i++) { - this.preferredTextLanguage[i] = Util.normalizeLanguageCode(preferredTextLanguage[i]); - } + this.preferredTextLanguages = preferredTextLanguages; this.preferredTextRoleFlags = preferredTextRoleFlags; this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; @@ -287,15 +275,17 @@ public static TrackSelectionParameters getDefaults(Context context) { /* package */ TrackSelectionParameters(Parcel in) { int preferredAudioLanguageSize = in.readInt(); - this.preferredAudioLanguage = new String[preferredAudioLanguageSize]; + String[] preferredAudioLanguages = new String[preferredAudioLanguageSize]; for (int i = 0; i < preferredAudioLanguageSize; i++) { - preferredAudioLanguage[i] = in.readString(); + preferredAudioLanguages[i] = in.readString(); } + this.preferredAudioLanguages = ImmutableList.copyOf(preferredAudioLanguages); int preferredTextLanguageSize = in.readInt(); - this.preferredTextLanguage = new String[preferredTextLanguageSize]; + String[] preferredTextLanguages = new String[preferredTextLanguageSize]; for (int i = 0; i < preferredTextLanguageSize; i++) { - preferredTextLanguage[i] = in.readString(); + preferredTextLanguages[i] = in.readString(); } + this.preferredTextLanguages = ImmutableList.copyOf(preferredTextLanguages); this.preferredTextRoleFlags = in.readInt(); this.selectUndeterminedTextLanguage = Util.readBoolean(in); this.disabledTextTrackSelectionFlags = in.readInt(); @@ -316,8 +306,8 @@ public boolean equals(@Nullable Object obj) { return false; } TrackSelectionParameters other = (TrackSelectionParameters) obj; - return Arrays.equals(preferredAudioLanguage, other.preferredAudioLanguage) - && Arrays.equals(preferredTextLanguage, other.preferredTextLanguage) + return preferredAudioLanguages.equals(other.preferredAudioLanguages) + && preferredTextLanguages.equals(other.preferredTextLanguages) && preferredTextRoleFlags == other.preferredTextRoleFlags && selectUndeterminedTextLanguage == other.selectUndeterminedTextLanguage && disabledTextTrackSelectionFlags == other.disabledTextTrackSelectionFlags; @@ -326,8 +316,8 @@ public boolean equals(@Nullable Object obj) { @Override public int hashCode() { int result = 1; - result = 31 * result + Arrays.hashCode(preferredAudioLanguage); - result = 31 * result + Arrays.hashCode(preferredTextLanguage); + result = 31 * result + preferredAudioLanguages.hashCode(); + result = 31 * result + preferredTextLanguages.hashCode(); result = 31 * result + preferredTextRoleFlags; result = 31 * result + (selectUndeterminedTextLanguage ? 1 : 0); result = 31 * result + disabledTextTrackSelectionFlags; @@ -343,12 +333,12 @@ public int describeContents() { @Override public void writeToParcel(Parcel dest, int flags) { - dest.writeInt(preferredAudioLanguage.length); - for (String s : preferredAudioLanguage) { + dest.writeInt(preferredAudioLanguages.size()); + for (String s : preferredAudioLanguages) { dest.writeString(s); } - dest.writeInt(preferredTextLanguage.length); - for (String s : preferredTextLanguage) { + dest.writeInt(preferredTextLanguages.size()); + for (String s : preferredTextLanguages) { dest.writeString(s); } dest.writeInt(preferredTextRoleFlags); 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 76f92674307..b974fdca622 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 @@ -251,8 +251,8 @@ public void getTrackSelections_afterReplaceTrackSelections_returnsNewSelections( prepareDownloadHelper(downloadHelper); DefaultTrackSelector.Parameters parameters = new DefaultTrackSelector.ParametersBuilder(ApplicationProvider.getApplicationContext()) - .setPreferredAudioLanguage("ZH") - .setPreferredTextLanguage("ZH") + .setPreferredAudioLanguages("ZH") + .setPreferredTextLanguages("ZH") .setRendererDisabled(/* rendererIndex= */ 2, true) .build(); @@ -288,8 +288,8 @@ public void getTrackSelections_afterAddTrackSelections_returnsCombinedSelections // all video tracks to initial video single track selection. DefaultTrackSelector.Parameters parameters = new DefaultTrackSelector.ParametersBuilder(ApplicationProvider.getApplicationContext()) - .setPreferredAudioLanguage("ZH") - .setPreferredTextLanguage("US") + .setPreferredAudioLanguages("ZH") + .setPreferredTextLanguages("US") .build(); // Add only to one period selection to verify second period selection is untouched. @@ -392,8 +392,8 @@ public void getDownloadRequest_createsDownloadRequest_withAllSelectedTracks() th // also renderers without any track groups. DefaultTrackSelector.Parameters parameters = new DefaultTrackSelector.ParametersBuilder(ApplicationProvider.getApplicationContext()) - .setPreferredAudioLanguage("ZH") - .setPreferredTextLanguage("US") + .setPreferredAudioLanguages("ZH") + .setPreferredTextLanguages("US") .build(); downloadHelper.addTrackSelection(/* periodIndex= */ 0, parameters); byte[] data = new byte[10]; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java index 89dd62e9a6d..165aefe1f00 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java @@ -283,7 +283,7 @@ public void setParameterWithDefaultParametersDoesNotNotifyInvalidationListener() */ @Test public void setParameterWithNonDefaultParameterNotifyInvalidationListener() { - ParametersBuilder builder = defaultParameters.buildUpon().setPreferredAudioLanguage("eng"); + ParametersBuilder builder = defaultParameters.buildUpon().setPreferredAudioLanguages("eng"); trackSelector.setParameters(builder); verify(invalidationListener).onTrackSelectionsInvalidated(); } @@ -295,7 +295,7 @@ public void setParameterWithNonDefaultParameterNotifyInvalidationListener() { */ @Test public void setParameterWithSameParametersDoesNotNotifyInvalidationListenerAgain() { - ParametersBuilder builder = defaultParameters.buildUpon().setPreferredAudioLanguage("eng"); + ParametersBuilder builder = defaultParameters.buildUpon().setPreferredAudioLanguages("eng"); trackSelector.setParameters(builder); trackSelector.setParameters(builder); verify(invalidationListener, times(1)).onTrackSelectionsInvalidated(); @@ -369,7 +369,7 @@ public void selectTracksSelectPreferredAudioLanguage() throws Exception { Format enAudioFormat = formatBuilder.setLanguage("eng").build(); TrackGroupArray trackGroups = wrapFormats(frAudioFormat, enAudioFormat); - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguage("eng")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguages("eng")); TrackSelectorResult result = trackSelector.selectTracks( new RendererCapabilities[] {ALL_AUDIO_FORMAT_SUPPORTED_RENDERER_CAPABILITIES}, @@ -391,7 +391,7 @@ public void selectTracksSelectPreferredAudioLanguageOverSelectionFlag() throws E Format enNonDefaultFormat = formatBuilder.setLanguage("eng").setSelectionFlags(0).build(); TrackGroupArray trackGroups = wrapFormats(frDefaultFormat, enNonDefaultFormat); - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguage("eng")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguages("eng")); TrackSelectorResult result = trackSelector.selectTracks( new RendererCapabilities[] {ALL_AUDIO_FORMAT_SUPPORTED_RENDERER_CAPABILITIES}, @@ -511,7 +511,7 @@ public void selectTracksPreferTrackWithinCapabilitiesOverPreferredLanguage() thr RendererCapabilities mappedAudioRendererCapabilities = new FakeMappedRendererCapabilities(C.TRACK_TYPE_AUDIO, mappedCapabilities); - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguage("eng")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguages("eng")); TrackSelectorResult result = trackSelector.selectTracks( new RendererCapabilities[] {mappedAudioRendererCapabilities}, @@ -546,7 +546,7 @@ public void selectTracksPreferTrackWithinCapabilitiesOverSelectionFlagAndPreferr RendererCapabilities mappedAudioRendererCapabilities = new FakeMappedRendererCapabilities(C.TRACK_TYPE_AUDIO, mappedCapabilities); - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguage("eng")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguages("eng")); TrackSelectorResult result = trackSelector.selectTracks( new RendererCapabilities[] {mappedAudioRendererCapabilities}, @@ -841,7 +841,7 @@ public void textTrackSelectionFlags() throws ExoPlaybackException { // There is a preferred language, so a language-matching track flagged as default should // be selected, and the one without forced flag should be preferred. - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredTextLanguage("eng")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredTextLanguages("eng")); result = trackSelector.selectTracks(textRendererCapabilities, trackGroups, periodId, TIMELINE); assertFixedSelection(result.selections.get(0), trackGroups, defaultOnly); @@ -929,7 +929,7 @@ public void selectUndeterminedTextLanguageAsFallback() throws ExoPlaybackExcepti result = trackSelector.selectTracks(textRendererCapabilites, trackGroups, periodId, TIMELINE); assertFixedSelection(result.selections.get(0), trackGroups, undeterminedUnd); - ParametersBuilder builder = defaultParameters.buildUpon().setPreferredTextLanguage("spa"); + ParametersBuilder builder = defaultParameters.buildUpon().setPreferredTextLanguages("spa"); trackSelector.setParameters(builder); result = trackSelector.selectTracks(textRendererCapabilites, trackGroups, periodId, TIMELINE); assertFixedSelection(result.selections.get(0), trackGroups, spanish); @@ -984,13 +984,13 @@ public void selectPreferredTextTrackMultipleRenderers() throws Exception { assertNoSelection(result.selections.get(1)); // Explicit language preference for english. First renderer should be used. - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredTextLanguage("en")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredTextLanguages("en")); result = trackSelector.selectTracks(rendererCapabilities, trackGroups, periodId, TIMELINE); assertFixedSelection(result.selections.get(0), trackGroups, english); assertNoSelection(result.selections.get(1)); // Explicit language preference for German. Second renderer should be used. - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredTextLanguage("de")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredTextLanguages("de")); result = trackSelector.selectTracks(rendererCapabilities, trackGroups, periodId, TIMELINE); assertNoSelection(result.selections.get(0)); assertFixedSelection(result.selections.get(1), trackGroups, german); @@ -1290,13 +1290,13 @@ public void selectPreferredAudioTrackMultipleRenderers() throws Exception { assertNoSelection(result.selections.get(1)); // Explicit language preference for english. First renderer should be used. - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguage("en")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguages("en")); result = trackSelector.selectTracks(rendererCapabilities, trackGroups, periodId, TIMELINE); assertFixedSelection(result.selections.get(0), trackGroups, english); assertNoSelection(result.selections.get(1)); // Explicit language preference for German. Second renderer should be used. - trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguage("de")); + trackSelector.setParameters(defaultParameters.buildUpon().setPreferredAudioLanguages("de")); result = trackSelector.selectTracks(rendererCapabilities, trackGroups, periodId, TIMELINE); assertNoSelection(result.selections.get(0)); assertFixedSelection(result.selections.get(1), trackGroups, german); From 04f67e4adcced2bb6e3ebf63605bc49e7518b783 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 7 Sep 2020 18:17:21 +0100 Subject: [PATCH 009/693] Simplify DefaultMediaSourceFactory ad configuration - Use a setter, which is consistent with how other optional components are passed. - Remove nesting where a provider provides another provider. Since AdSupportProvider then only provides one thing, it can be renamed to AdsLoaderProvider, which more clearly expresses what it provides. PiperOrigin-RevId: 330396334 --- .../android/exoplayer2/demo/DemoUtil.java | 11 +- .../exoplayer2/demo/PlayerActivity.java | 56 ++++---- .../demo/SampleChooserActivity.java | 5 +- .../exoplayer2/ext/ima/ImaPlaybackTest.java | 3 +- .../exoplayer2/ext/media2/PlayerTestRule.java | 2 +- .../google/android/exoplayer2/ExoPlayer.java | 2 +- .../android/exoplayer2/ExoPlayerFactory.java | 4 +- .../android/exoplayer2/MetadataRetriever.java | 6 +- .../android/exoplayer2/SimpleExoPlayer.java | 2 +- .../exoplayer2/offline/DownloadHelper.java | 2 +- .../source/DefaultMediaSourceFactory.java | 135 ++++++++---------- .../source/DefaultMediaSourceFactoryTest.java | 60 +++----- .../dash/DefaultMediaSourceFactoryTest.java | 11 +- .../hls/DefaultMediaSourceFactoryTest.java | 11 +- .../DefaultMediaSourceFactoryTest.java | 13 +- 15 files changed, 142 insertions(+), 181 deletions(-) diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java index 669e09ed701..c3621879c52 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java @@ -50,6 +50,7 @@ public final class DemoUtil { private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions"; private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; + private static @MonotonicNonNull String userAgent; private static DataSource.@MonotonicNonNull Factory dataSourceFactory; private static HttpDataSource.@MonotonicNonNull Factory httpDataSourceFactory; private static @MonotonicNonNull DatabaseProvider databaseProvider; @@ -77,17 +78,23 @@ public static RenderersFactory buildRenderersFactory( .setExtensionRendererMode(extensionRendererMode); } + public static synchronized String getUserAgent(Context context) { + if (userAgent == null) { + userAgent = Util.getUserAgent(context, "ExoPlayerDemo"); + } + return userAgent; + } + 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); + getUserAgent(context)); } return httpDataSourceFactory; } 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 370db4ac707..c9af9f77bbc 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 @@ -47,6 +47,7 @@ import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; +import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; @@ -285,16 +286,18 @@ protected boolean initializePlayer() { intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false); RenderersFactory renderersFactory = DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders); + MediaSourceFactory mediaSourceFactory = + new DefaultMediaSourceFactory(dataSourceFactory) + .setDrmUserAgent(DemoUtil.getUserAgent(this)) + .setAdsLoaderProvider(this::getAdsLoader) + .setAdViewProvider(playerView); trackSelector = new DefaultTrackSelector(/* context= */ this); trackSelector.setParameters(trackSelectorParameters); lastSeenTrackGroupArray = null; - player = new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory) - .setMediaSourceFactory( - DefaultMediaSourceFactory.newInstance( - /* context= */ this, dataSourceFactory, new AdSupportProvider())) + .setMediaSourceFactory(mediaSourceFactory) .setTrackSelector(trackSelector) .build(); player.addListener(new PlayerEventListener()); @@ -361,6 +364,24 @@ private List createMediaItems(Intent intent) { return mediaItems; } + private AdsLoader getAdsLoader(Uri adTagUri) { + if (mediaItems.size() > 1) { + showToast(R.string.unsupported_ads_in_playlist); + releaseAdsLoader(); + return null; + } + if (!adTagUri.equals(loadedAdTagUri)) { + releaseAdsLoader(); + loadedAdTagUri = adTagUri; + } + // The ads loader is reused for multiple playbacks, so that ad playback can resume. + if (adsLoader == null) { + adsLoader = new ImaAdsLoader(/* context= */ PlayerActivity.this, adTagUri); + } + adsLoader.setPlayer(player); + return adsLoader; + } + protected void releasePlayer() { if (player != null) { updateTrackSelectorParameters(); @@ -517,33 +538,6 @@ public Pair getErrorMessage(@NonNull ExoPlaybackException e) { } } - private class AdSupportProvider implements DefaultMediaSourceFactory.AdSupportProvider { - - @Override - public AdsLoader getAdsLoader(Uri adTagUri) { - if (mediaItems.size() > 1) { - showToast(R.string.unsupported_ads_in_playlist); - releaseAdsLoader(); - return null; - } - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; - } - // The ads loader is reused for multiple playbacks, so that ad playback can resume. - if (adsLoader == null) { - adsLoader = new ImaAdsLoader(/* context= */ PlayerActivity.this, adTagUri); - } - adsLoader.setPlayer(player); - return adsLoader; - } - - @Override - public AdsLoader.AdViewProvider getAdViewProvider() { - return checkNotNull(playerView); - } - } - private static List createMediaItems(Intent intent, DownloadTracker downloadTracker) { List mediaItems = new ArrayList<>(); for (MediaItem item : IntentUtil.createMediaItemsFromIntent(intent)) { 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 267b97f8ec3..ea5b38ce8e8 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 @@ -53,7 +53,6 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSourceInputStream; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableList; @@ -276,9 +275,7 @@ private final class SampleListLoader extends AsyncTask doInBackground(String... uris) { List result = new ArrayList<>(); Context context = getApplicationContext(); - String userAgent = Util.getUserAgent(context, "ExoPlayerDemo"); - DataSource dataSource = - new DefaultDataSource(context, userAgent, /* allowCrossProtocolRedirects= */ false); + DataSource dataSource = DemoUtil.getDataSourceFactory(context).createDataSource(); for (String uri : uris) { DataSpec dataSpec = new DataSpec(Uri.parse(uri)); InputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); 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 31cd29de946..cd58e1f58b3 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 @@ -243,8 +243,7 @@ protected MediaSource buildSource( new DefaultDataSourceFactory( context, Util.getUserAgent(context, ImaPlaybackTest.class.getSimpleName())); MediaSource contentMediaSource = - DefaultMediaSourceFactory.newInstance(context) - .createMediaSource(MediaItem.fromUri(contentUri)); + new DefaultMediaSourceFactory(context).createMediaSource(MediaItem.fromUri(contentUri)); return new AdsMediaSource( contentMediaSource, dataSourceFactory, diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java index 345985f862c..f5518e0c7c7 100644 --- a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java @@ -80,7 +80,7 @@ protected void before() { exoPlayer = new SimpleExoPlayer.Builder(context) .setLooper(Looper.myLooper()) - .setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, null)) + .setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory)) .build(); sessionPlayerConnector = new SessionPlayerConnector(exoPlayer); }); 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 5211b3eaceb..b5489186bc8 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 @@ -182,7 +182,7 @@ public Builder(Context context, Renderer... renderers) { this( renderers, new DefaultTrackSelector(context), - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context)); } 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 dcdce894894..dfe96ffa322 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 @@ -194,7 +194,7 @@ public static SimpleExoPlayer newSimpleInstance( context, renderersFactory, trackSelector, - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), loadControl, bandwidthMeter, analyticsCollector, @@ -250,7 +250,7 @@ public static ExoPlayer newInstance( return new ExoPlayerImpl( renderers, trackSelector, - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), loadControl, bandwidthMeter, /* analyticsCollector= */ null, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java index c233845e0cc..72f6957865e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java @@ -43,8 +43,8 @@ private MetadataRetriever() {} /** * Retrieves the {@link TrackGroupArray} corresponding to a {@link MediaItem}. * - *

This is equivalent to using {@code - * retrieveMetadata(DefaultMediaSourceFactory.newInstance(context), mediaItem)}. + *

This is equivalent to using {@code retrieveMetadata(new DefaultMediaSourceFactory(context), + * mediaItem)}. * * @param context The {@link Context}. * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. @@ -52,7 +52,7 @@ private MetadataRetriever() {} */ public static ListenableFuture retrieveMetadata( Context context, MediaItem mediaItem) { - return retrieveMetadata(DefaultMediaSourceFactory.newInstance(context), mediaItem); + return retrieveMetadata(new DefaultMediaSourceFactory(context), mediaItem); } /** 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 a43973b31c9..787946d6a94 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 @@ -163,7 +163,7 @@ public Builder(Context context, RenderersFactory renderersFactory) { context, renderersFactory, new DefaultTrackSelector(context), - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), new AnalyticsCollector(Clock.DEFAULT)); 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 df2d10ae538..dd868f98225 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 @@ -889,7 +889,7 @@ private static MediaSource createMediaSourceInternal( MediaItem mediaItem, DataSource.Factory dataSourceFactory, @Nullable DrmSessionManager drmSessionManager) { - return new DefaultMediaSourceFactory(dataSourceFactory, /* adSupportProvider= */ null) + return new DefaultMediaSourceFactory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) .createMediaSource(mediaItem); } 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 566f7fb1c72..89b4ffb6a59 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 @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.StreamKey; 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.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; @@ -68,101 +69,62 @@ * the stream. * * - *

Ad support for media items with ad tag uri

+ *

Ad support for media items with ad tag URIs

* - *

For a media item with an ad tag uri, an {@link AdSupportProvider} needs to be passed to {@link - * #newInstance(Context, DataSource.Factory, AdSupportProvider)}. + *

To support media items with {@link MediaItem.PlaybackProperties#adTagUri ad tag URIs}, {@link + * #setAdsLoaderProvider} and {@link #setAdViewProvider} need to be called to configure the factory + * with the required providers. */ public final class DefaultMediaSourceFactory implements MediaSourceFactory { /** - * Provides {@link AdsLoader ads loaders} and an {@link AdsLoader.AdViewProvider} to created - * {@link AdsMediaSource AdsMediaSources}. + * Provides {@link AdsLoader} instances for media items that have {@link + * MediaItem.PlaybackProperties#adTagUri ad tag URIs}. */ - public interface AdSupportProvider { + public interface AdsLoaderProvider { /** - * Returns an {@link AdsLoader} for the given {@link Uri ad tag uri} or null if no ads loader is - * available for the given ad tag uri. + * Returns an {@link AdsLoader} for the given {@link MediaItem.PlaybackProperties#adTagUri ad + * tag URI}, or null if no ads loader is available for the given ad tag URI. * - *

This method is called for each media item for which a media source is created. + *

This method is called each time a {@link MediaSource} is created from a {@link MediaItem} + * that defines an {@link MediaItem.PlaybackProperties#adTagUri ad tag URI}. */ @Nullable AdsLoader getAdsLoader(Uri adTagUri); - - /** - * Returns an {@link AdsLoader.AdViewProvider} which is used to create {@link AdsMediaSource - * AdsMediaSources}. - */ - AdsLoader.AdViewProvider getAdViewProvider(); - } - - /** - * Creates a new instance with the given {@link Context}. - * - *

This is functionally equivalent with calling {@code #newInstance(Context, - * DefaultDataSourceFactory)}. - * - * @param context The {@link Context}. - * @return A new instance of {@link DefaultMediaSourceFactory}. - */ - public static DefaultMediaSourceFactory newInstance(Context context) { - return newInstance( - context, - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY))); - } - - /** - * Creates a new instance with the given {@link Context} and {@link DataSource.Factory}. - * - * @param context The {@link Context}. - * @param dataSourceFactory A {@link DataSource.Factory} to be used to create media sources. - * @return A new instance of {@link DefaultMediaSourceFactory}. - */ - public static DefaultMediaSourceFactory newInstance( - Context context, DataSource.Factory dataSourceFactory) { - return new DefaultMediaSourceFactory(dataSourceFactory, /* adSupportProvider= */ null) - .setDrmUserAgent(Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)); - } - - /** - * Creates a new instance with the given {@link Context} and {@link DataSource.Factory}. - * - * @param context The {@link Context}. - * @param dataSourceFactory A {@link DataSource.Factory} to be used to create media sources. - * @param adSupportProvider A {@link AdSupportProvider} to be used to create ad media sources. - * @return A new instance of {@link DefaultMediaSourceFactory}. - */ - public static DefaultMediaSourceFactory newInstance( - Context context, DataSource.Factory dataSourceFactory, AdSupportProvider adSupportProvider) { - return new DefaultMediaSourceFactory(dataSourceFactory, adSupportProvider) - .setDrmUserAgent(Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)); } private static final String TAG = "DefaultMediaSourceFactory"; private final MediaSourceDrmHelper mediaSourceDrmHelper; private final DataSource.Factory dataSourceFactory; - @Nullable private final AdSupportProvider adSupportProvider; private final SparseArray mediaSourceFactories; @C.ContentType private final int[] supportedTypes; + @Nullable private AdsLoaderProvider adsLoaderProvider; + @Nullable private AdViewProvider adViewProvider; @Nullable private DrmSessionManager drmSessionManager; @Nullable private List streamKeys; /** - * Creates a new instance with the {@link DataSource.Factory} for downloading media and an {@link - * AdSupportProvider} to create {@link AdsMediaSource AdsMediaSources}. + * Creates a new instance. * - * @param dataSourceFactory A {@link DataSource.Factory} to be used to create media sources. - * @param adSupportProvider An {@link AdSupportProvider} to get ads loaders and ad view providers - * to be used to create {@link AdsMediaSource AdsMediaSources}. + * @param context Any context. */ - public DefaultMediaSourceFactory( - DataSource.Factory dataSourceFactory, @Nullable AdSupportProvider adSupportProvider) { + public DefaultMediaSourceFactory(Context context) { + this( + new DefaultDataSourceFactory( + context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY))); + } + + /** + * Creates a new instance. + * + * @param dataSourceFactory A {@link DataSource.Factory} to create {@link DataSource} instances + * for requesting media data. + */ + public DefaultMediaSourceFactory(DataSource.Factory dataSourceFactory) { this.dataSourceFactory = dataSourceFactory; - this.adSupportProvider = adSupportProvider; mediaSourceDrmHelper = new MediaSourceDrmHelper(); mediaSourceFactories = loadDelegates(dataSourceFactory); supportedTypes = new int[mediaSourceFactories.size()]; @@ -171,6 +133,30 @@ public DefaultMediaSourceFactory( } } + /** + * Sets the {@link AdsLoaderProvider} that provides {@link AdsLoader} instances for media items + * that have {@link MediaItem.PlaybackProperties#adTagUri ad tag URIs}. + * + * @param adsLoaderProvider A provider for {@link AdsLoader} instances. + * @return This factory, for convenience. + */ + public DefaultMediaSourceFactory setAdsLoaderProvider( + @Nullable AdsLoaderProvider adsLoaderProvider) { + this.adsLoaderProvider = adsLoaderProvider; + return this; + } + + /** + * Sets the {@link AdViewProvider} that provides information about views for the ad playback UI. + * + * @param adViewProvider A provider for {@link AdsLoader} instances. + * @return This factory, for convenience. + */ + public DefaultMediaSourceFactory setAdViewProvider(@Nullable AdViewProvider adViewProvider) { + this.adViewProvider = adViewProvider; + return this; + } + @Override public DefaultMediaSourceFactory setDrmHttpDataSourceFactory( @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { @@ -279,24 +265,23 @@ private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource if (mediaItem.playbackProperties.adTagUri == null) { return mediaSource; } - if (adSupportProvider == null) { + AdsLoaderProvider adsLoaderProvider = this.adsLoaderProvider; + AdViewProvider adViewProvider = this.adViewProvider; + if (adsLoaderProvider == null || adViewProvider == null) { Log.w( TAG, - "Playing media without ads. Pass an AdsSupportProvider to the constructor for supporting" - + " media items with an ad tag uri."); + "Playing media without ads. Configure ad support by calling setAdsLoaderProvider and" + + " setAdViewProvider."); return mediaSource; } @Nullable - AdsLoader adsLoader = adSupportProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri); + AdsLoader adsLoader = adsLoaderProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri); if (adsLoader == null) { Log.w(TAG, "Playing media without ads. No AdsLoader for provided adTagUri"); return mediaSource; } return new AdsMediaSource( - mediaSource, - /* adMediaSourceFactory= */ this, - adsLoader, - adSupportProvider.getAdViewProvider()); + mediaSource, /* adMediaSourceFactory= */ this, adsLoader, adViewProvider); } private static SparseArray loadDelegates( 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 8dfe73f4adf..d02f04d0977 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 @@ -20,14 +20,12 @@ import android.content.Context; 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.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; import java.util.Collections; @@ -45,7 +43,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_fromMediaItem_returnsSameMediaItemInstance() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -56,7 +54,7 @@ public void createMediaSource_fromMediaItem_returnsSameMediaItemInstance() { @Test public void createMediaSource_withoutMimeType_progressiveSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -69,7 +67,7 @@ public void createMediaSource_withoutMimeType_progressiveSource() { public void createMediaSource_withTag_tagInSource_deprecated() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setTag(tag).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -80,7 +78,7 @@ public void createMediaSource_withTag_tagInSource_deprecated() { @Test public void createMediaSource_withPath_progressiveSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mp3").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -91,7 +89,7 @@ public void createMediaSource_withPath_progressiveSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); MediaSource mediaSource = @@ -107,7 +105,7 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void createMediaSource_withSubtitle_isMergingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); List subtitles = Arrays.asList( new MediaItem.Subtitle(Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "en"), @@ -124,7 +122,7 @@ public void createMediaSource_withSubtitle_isMergingMediaSource() { @SuppressWarnings("deprecation") // Testing deprecated MediaSource.getTag() still works. public void createMediaSource_withSubtitle_hasTag_deprecated() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); Object tag = new Object(); MediaItem mediaItem = new MediaItem.Builder() @@ -143,7 +141,7 @@ public void createMediaSource_withSubtitle_hasTag_deprecated() { @Test public void createMediaSource_withStartPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setClipStartPositionMs(1000L).build(); @@ -155,7 +153,7 @@ public void createMediaSource_withStartPosition_isClippingMediaSource() { @Test public void createMediaSource_withEndPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setClipEndPositionMs(1000L).build(); @@ -167,7 +165,7 @@ public void createMediaSource_withEndPosition_isClippingMediaSource() { @Test public void createMediaSource_relativeToDefaultPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setClipRelativeToDefaultPosition(true).build(); @@ -179,7 +177,7 @@ public void createMediaSource_relativeToDefaultPosition_isClippingMediaSource() @Test public void createMediaSource_defaultToEnd_isNotClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -194,7 +192,7 @@ public void createMediaSource_defaultToEnd_isNotClippingMediaSource() { @Test public void getSupportedTypes_coreModule_onlyOther() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER); @@ -202,14 +200,12 @@ public void getSupportedTypes_coreModule_onlyOther() { @Test public void createMediaSource_withAdTagUri_callsAdsLoader() { - Context applicationContext = ApplicationProvider.getApplicationContext(); Uri adTagUri = Uri.parse(URI_MEDIA); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(adTagUri).build(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance( - applicationContext, - new DefaultDataSourceFactory(applicationContext, /* userAgent= */ "ua"), - createAdSupportProvider(mock(AdsLoader.class), mock(AdsLoader.AdViewProvider.class))); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) + .setAdsLoaderProvider(ignoredAdTagUri -> mock(AdsLoader.class)) + .setAdViewProvider(mock(AdsLoader.AdViewProvider.class)); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -217,15 +213,11 @@ public void createMediaSource_withAdTagUri_callsAdsLoader() { } @Test - public void createMediaSource_withAdTagUriAdsLoaderNull_playsWithoutAdNoException() { - Context applicationContext = ApplicationProvider.getApplicationContext(); + public void createMediaSource_withAdTagUri_adProvidersNotSet_playsWithoutAdNoException() { MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance( - applicationContext, - new DefaultDataSourceFactory(applicationContext, /* userAgent= */ "ua"), - createAdSupportProvider(/* adsLoader= */ null, mock(AdsLoader.AdViewProvider.class))); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -239,24 +231,8 @@ public void createMediaSource_withAdTagUriProvidersNull_playsWithoutAdNoExceptio new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); MediaSource mediaSource = - DefaultMediaSourceFactory.newInstance(applicationContext).createMediaSource(mediaItem); + new DefaultMediaSourceFactory(applicationContext).createMediaSource(mediaItem); assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class); } - - private static DefaultMediaSourceFactory.AdSupportProvider createAdSupportProvider( - @Nullable AdsLoader adsLoader, AdsLoader.AdViewProvider adViewProvider) { - return new DefaultMediaSourceFactory.AdSupportProvider() { - @Nullable - @Override - public AdsLoader getAdsLoader(Uri adTagUri) { - return adsLoader; - } - - @Override - public AdsLoader.AdViewProvider getAdViewProvider() { - return adViewProvider; - } - }; - } } 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 4ed34b0164d..ab7f456c551 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 @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -36,7 +37,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withMimeType_dashSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_MPD).build(); @@ -49,7 +50,7 @@ public void createMediaSource_withMimeType_dashSource() { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -65,7 +66,7 @@ public void createMediaSource_withTag_tagInSource() { @Test public void createMediaSource_withPath_dashSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mpd").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -76,7 +77,7 @@ public void createMediaSource_withPath_dashSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mpd").build(); MediaSource mediaSource = @@ -92,7 +93,7 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void getSupportedTypes_dashModule_containsTypeDash() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_DASH); 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 d46da26ff24..54383ffe335 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 @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -36,7 +37,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withMimeType_hlsSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_M3U8).build(); @@ -49,7 +50,7 @@ public void createMediaSource_withMimeType_hlsSource() { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -65,7 +66,7 @@ public void createMediaSource_withTag_tagInSource() { @Test public void createMediaSource_withPath_hlsSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.m3u8").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -76,7 +77,7 @@ public void createMediaSource_withPath_hlsSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.m3u8").build(); MediaSource mediaSource = @@ -92,7 +93,7 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void getSupportedTypes_hlsModule_containsTypeHls() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_HLS); 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 016acdbf3d0..43c62071d39 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 @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -38,7 +39,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withMimeType_smoothstreamingSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_SS).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -49,7 +50,7 @@ public void createMediaSource_withMimeType_smoothstreamingSource() { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -65,7 +66,7 @@ public void createMediaSource_withTag_tagInSource() { @Test public void createMediaSource_withIsmPath_smoothstreamingSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.ism").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -76,7 +77,7 @@ public void createMediaSource_withIsmPath_smoothstreamingSource() { @Test public void createMediaSource_withManifestPath_smoothstreamingSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + ".ism/Manifest").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -87,7 +88,7 @@ public void createMediaSource_withManifestPath_smoothstreamingSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.ism").build(); MediaSource mediaSource = @@ -103,7 +104,7 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void getSupportedTypes_smoothstreamingModule_containsTypeSS() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_SS); From 312e260f71c4fc27256e26336b0a8f8874b57353 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 7 Sep 2020 21:14:32 +0100 Subject: [PATCH 010/693] Update release notes - Remove SampleQueue changes (they're being picked up in 2.12, but are sufficiently minor to not warrant a release note) - Update 2.12 estimated release date PiperOrigin-RevId: 330409443 --- RELEASENOTES.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e8efde7f4f6..7701c319ab8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,14 +2,10 @@ ### dev-v2 (not yet released) -* Core library: - * Add `SampleQueue.discardUpstreamFrom` so upstream samples can be - discarded by timestamp. - * Add `SampleQueue.getLargestReadTimestampUs`. * Track selection: * Add option to specify multiple preferred audio or text languages. -### 2.12.0 (not yet released - targeted for 2020-09-03) ### +### 2.12.0 (not yet released - targeted for 2020-09-11) ### * Core library: * `Player`: From 3110587fbeb526d307724ce21730f3b4bdf9ab7b Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 7 Sep 2020 21:17:29 +0100 Subject: [PATCH 011/693] Fix extension renderer test names + add FfmpegVideo case PiperOrigin-RevId: 330409635 --- .../ext/ffmpeg/DefaultRenderersFactoryTest.java | 13 +++++++++++-- .../ext/flac/DefaultRenderersFactoryTest.java | 2 +- .../ext/opus/DefaultRenderersFactoryTest.java | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java index a52d1b1d7ad..cc8ca5487e0 100644 --- a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java +++ b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java @@ -21,13 +21,22 @@ import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer}. */ +/** + * Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer} and {@link + * FfmpegVideoRenderer}. + */ @RunWith(AndroidJUnit4.class) public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesFfmpegAudioRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( FfmpegAudioRenderer.class, C.TRACK_TYPE_AUDIO); } + + @Test + public void createRenderers_instantiatesFfmpegVideoRenderer() { + DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( + FfmpegVideoRenderer.class, C.TRACK_TYPE_VIDEO); + } } diff --git a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java index fb20ff1114a..3fb8f2cece4 100644 --- a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java +++ b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java @@ -26,7 +26,7 @@ public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesFlacRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( LibflacAudioRenderer.class, C.TRACK_TYPE_AUDIO); } diff --git a/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java b/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java index e57ad84a416..9931f2d05fc 100644 --- a/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java +++ b/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java @@ -26,7 +26,7 @@ public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesOpusRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( LibopusAudioRenderer.class, C.TRACK_TYPE_AUDIO); } From bfe17aee3eac92f7e229ada361808d5ca826f7fd Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 8 Sep 2020 10:44:24 +0100 Subject: [PATCH 012/693] Support ExtractorFactory in DefaultMediaSourceFactory. This allows to customize extractor flags more easily when setting up the player. In addition, we need to provide a way to pass in the ExtractorFactory through the constructor chain starting in SimpleExoPlayer so that removing the DefaultExtractorsFactory is possible for R8. PiperOrigin-RevId: 330472935 --- .../android/exoplayer2/SimpleExoPlayer.java | 36 ++++++++++++++--- .../exoplayer2/offline/DownloadHelper.java | 3 +- .../source/DefaultMediaSourceFactory.java | 40 ++++++++++++------- .../exoplayer2/source/MediaSourceFactory.java | 10 +++-- .../extractor/ExtractorsFactory.java | 6 +++ 5 files changed, 71 insertions(+), 24 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 787946d6a94..ba3a375a377 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 @@ -38,6 +38,8 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.device.DeviceInfo; import com.google.android.exoplayer2.device.DeviceListener; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; @@ -52,6 +54,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Log; @@ -115,9 +118,11 @@ public static final class Builder { /** * Creates a builder. * - *

Use {@link #Builder(Context, RenderersFactory)} instead, if you intend to provide a custom - * {@link RenderersFactory}. This is to ensure that ProGuard or R8 can remove ExoPlayer's {@link - * DefaultRenderersFactory} from the APK. + *

Use {@link #Builder(Context, RenderersFactory)} or {@link #Builder(Context, + * RenderersFactory, ExtractorsFactory)} instead, if you intend to provide a custom {@link + * RenderersFactory} or a custom {@link ExtractorsFactory}. This is to ensure that ProGuard or + * R8 can remove ExoPlayer's {@link DefaultRenderersFactory} and {@link + * DefaultExtractorsFactory} from the APK. * *

The builder uses the following default values: * @@ -146,7 +151,7 @@ public static final class Builder { * @param context A {@link Context}. */ public Builder(Context context) { - this(context, new DefaultRenderersFactory(context)); + this(context, new DefaultRenderersFactory(context), new DefaultExtractorsFactory()); } /** @@ -159,11 +164,30 @@ public Builder(Context context) { * player. */ public Builder(Context context, RenderersFactory renderersFactory) { + this(context, renderersFactory, new DefaultExtractorsFactory()); + } + + /** + * Creates a builder with a custom {@link RenderersFactory} and {@link ExtractorsFactory}. + * + *

See {@link #Builder(Context)} for a list of default values. + * + * @param context A {@link Context}. + * @param renderersFactory A factory for creating {@link Renderer Renderers} to be used by the + * player. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public Builder( + Context context, RenderersFactory renderersFactory, ExtractorsFactory extractorsFactory) { this( context, renderersFactory, new DefaultTrackSelector(context), - new DefaultMediaSourceFactory(context), + new DefaultMediaSourceFactory( + new DefaultDataSourceFactory( + context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), + extractorsFactory), new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), new AnalyticsCollector(Clock.DEFAULT)); @@ -546,7 +570,7 @@ protected SimpleExoPlayer( Clock clock, Looper applicationLooper) { this( - new Builder(context, renderersFactory) + new Builder(context, renderersFactory, new DefaultExtractorsFactory()) .setTrackSelector(trackSelector) .setMediaSourceFactory(mediaSourceFactory) .setLoadControl(loadControl) 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 dd868f98225..ba8a799381a 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 @@ -34,6 +34,7 @@ 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.extractor.ExtractorsFactory; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; @@ -889,7 +890,7 @@ private static MediaSource createMediaSourceInternal( MediaItem mediaItem, DataSource.Factory dataSourceFactory, @Nullable DrmSessionManager drmSessionManager) { - return new DefaultMediaSourceFactory(dataSourceFactory) + return new DefaultMediaSourceFactory(dataSourceFactory, ExtractorsFactory.EMPTY) .setDrmSessionManager(drmSessionManager) .createMediaSource(mediaItem); } 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 89b4ffb6a59..df1f3e2cf04 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 @@ -23,13 +23,14 @@ import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.offline.StreamKey; 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.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.util.Assertions; @@ -64,9 +65,10 @@ *

  • {@link ProgressiveMediaSource.Factory} serves as a fallback if the item's {@link * MediaItem.PlaybackProperties#uri uri} doesn't match one of the above. It tries to infer the * required extractor by using the {@link - * com.google.android.exoplayer2.extractor.DefaultExtractorsFactory}. An {@link - * UnrecognizedInputFormatException} is thrown if none of the available extractors can read - * the stream. + * com.google.android.exoplayer2.extractor.DefaultExtractorsFactory} or the {@link + * ExtractorsFactory} provided in {@link #DefaultMediaSourceFactory(DataSource.Factory, + * ExtractorsFactory)}. An {@link UnrecognizedInputFormatException} is thrown if none of the + * available extractors can read the stream. * * *

    Ad support for media items with ad tag URIs

    @@ -105,6 +107,7 @@ public interface AdsLoaderProvider { @Nullable private AdViewProvider adViewProvider; @Nullable private DrmSessionManager drmSessionManager; @Nullable private List streamKeys; + @Nullable private LoadErrorHandlingPolicy loadErrorHandlingPolicy; /** * Creates a new instance. @@ -124,9 +127,22 @@ public DefaultMediaSourceFactory(Context context) { * for requesting media data. */ public DefaultMediaSourceFactory(DataSource.Factory dataSourceFactory) { + this(dataSourceFactory, new DefaultExtractorsFactory()); + } + + /** + * Creates a new instance. + * + * @param dataSourceFactory A {@link DataSource.Factory} to create {@link DataSource} instances + * for requesting media data. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public DefaultMediaSourceFactory( + DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { this.dataSourceFactory = dataSourceFactory; mediaSourceDrmHelper = new MediaSourceDrmHelper(); - mediaSourceFactories = loadDelegates(dataSourceFactory); + mediaSourceFactories = loadDelegates(dataSourceFactory, extractorsFactory); supportedTypes = new int[mediaSourceFactories.size()]; for (int i = 0; i < mediaSourceFactories.size(); i++) { supportedTypes[i] = mediaSourceFactories.keyAt(i); @@ -180,13 +196,7 @@ public DefaultMediaSourceFactory setDrmSessionManager( @Override public DefaultMediaSourceFactory setLoadErrorHandlingPolicy( @Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { - LoadErrorHandlingPolicy newLoadErrorHandlingPolicy = - loadErrorHandlingPolicy != null - ? loadErrorHandlingPolicy - : new DefaultLoadErrorHandlingPolicy(); - for (int i = 0; i < mediaSourceFactories.size(); i++) { - mediaSourceFactories.valueAt(i).setLoadErrorHandlingPolicy(newLoadErrorHandlingPolicy); - } + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; return this; } @@ -224,6 +234,7 @@ public MediaSource createMediaSource(MediaItem mediaItem) { !mediaItem.playbackProperties.streamKeys.isEmpty() ? mediaItem.playbackProperties.streamKeys : streamKeys); + mediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); MediaSource mediaSource = mediaSourceFactory.createMediaSource(mediaItem); @@ -285,7 +296,7 @@ private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource } private static SparseArray loadDelegates( - DataSource.Factory dataSourceFactory) { + DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { SparseArray factories = new SparseArray<>(); // LINT.IfChange try { @@ -320,7 +331,8 @@ private static SparseArray loadDelegates( // Expected if the app was built without the hls module. } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - factories.put(C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory)); + factories.put( + C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)); return factories; } } 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 4175121d385..204220e334e 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 @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import java.util.List; @@ -59,7 +60,8 @@ default MediaSourceFactory setStreamKeys(@Nullable List streamKeys) { * Sets the {@link DrmSessionManager} to use for all media items regardless of their {@link * MediaItem.DrmConfiguration}. * - * @param drmSessionManager The {@link DrmSessionManager}. + * @param drmSessionManager The {@link DrmSessionManager}, or {@code null} to use the {@link + * DefaultDrmSessionManager}. * @return This factory, for convenience. */ MediaSourceFactory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManager); @@ -85,7 +87,8 @@ MediaSourceFactory setDrmHttpDataSourceFactory( * #setDrmHttpDataSourceFactory(HttpDataSource.Factory)} or a {@link DrmSessionManager} has been * set by {@link #setDrmSessionManager(DrmSessionManager)}, this user agent is ignored. * - * @param userAgent The user agent to be used for DRM requests. + * @param userAgent The user agent to be used for DRM requests, or {@code null} to use the + * default. * @return This factory, for convenience. */ MediaSourceFactory setDrmUserAgent(@Nullable String userAgent); @@ -93,7 +96,8 @@ MediaSourceFactory setDrmHttpDataSourceFactory( /** * Sets an optional {@link LoadErrorHandlingPolicy}. * - * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}, or {@code null} to use the + * {@link DefaultLoadErrorHandlingPolicy}. * @return This factory, for convenience. */ MediaSourceFactory setLoadErrorHandlingPolicy( 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 d077b1b11ef..97ae74b9d23 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 @@ -22,6 +22,12 @@ /** Factory for arrays of {@link Extractor} instances. */ public interface ExtractorsFactory { + /** + * Extractor factory that returns an empty list of extractors. Can be used whenever {@link + * Extractor Extractors} are not required. + */ + ExtractorsFactory EMPTY = () -> new Extractor[] {}; + /** Returns an array of new {@link Extractor} instances. */ Extractor[] createExtractors(); From b2b08ade99ba9a88383586756eaad91575683211 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 8 Sep 2020 22:57:12 +0100 Subject: [PATCH 013/693] Make User-Agent optional PiperOrigin-RevId: 330593247 --- .../exoplayer2/gldemo/MainActivity.java | 7 +-- .../android/exoplayer2/demo/DemoUtil.java | 15 +---- .../exoplayer2/demo/PlayerActivity.java | 1 - .../exoplayer2/surfacedemo/MainActivity.java | 7 +-- .../ext/cronet/CronetDataSource.java | 12 ++++ .../ext/cronet/CronetDataSourceFactory.java | 60 +++++++++++++++---- .../ext/flac/FlacExtractorSeekTest.java | 3 +- .../exoplayer2/ext/flac/FlacPlaybackTest.java | 3 +- .../exoplayer2/ext/ima/ImaPlaybackTest.java | 6 +- .../exoplayer2/ext/media2/PlayerTestRule.java | 4 +- .../ext/okhttp/OkHttpDataSource.java | 18 ++++++ .../ext/okhttp/OkHttpDataSourceFactory.java | 20 +++++++ .../exoplayer2/ext/opus/OpusPlaybackTest.java | 3 +- .../exoplayer2/ext/vp9/VpxPlaybackTest.java | 3 +- .../exoplayer2/ExoPlayerLibraryInfo.java | 5 ++ .../source/DefaultMediaSourceFactory.java | 5 +- .../source/MediaSourceDrmHelper.java | 10 +--- .../upstream/DefaultDataSource.java | 15 +++++ .../upstream/DefaultDataSourceFactory.java | 7 +++ .../upstream/DefaultHttpDataSource.java | 25 +++++++- .../DefaultHttpDataSourceFactory.java | 25 +++++--- .../android/exoplayer2/ExoPlayerTest.java | 10 +--- .../extractor/amr/AmrExtractorSeekTest.java | 2 +- .../extractor/flac/FlacExtractorSeekTest.java | 3 +- .../mp3/ConstantBitrateSeekerTest.java | 2 +- .../extractor/mp3/IndexSeekerTest.java | 2 +- .../extractor/ts/AdtsExtractorSeekTest.java | 2 +- .../extractor/ts/PsExtractorSeekTest.java | 2 +- .../extractor/ts/TsExtractorSeekTest.java | 2 +- .../playbacktests/gts/DashTestRunner.java | 7 +-- .../exoplayer2/testutil/ExoHostedTest.java | 10 +--- 31 files changed, 196 insertions(+), 100 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 6944eb662db..dc0a8b990ac 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 @@ -139,13 +139,12 @@ private void initializePlayer() { ACTION_VIEW.equals(action) ? Assertions.checkNotNull(intent.getData()) : Uri.parse(DEFAULT_MEDIA_URI); - String userAgent = Util.getUserAgent(this, getString(R.string.application_name)); DrmSessionManager drmSessionManager; if (Util.SDK_INT >= 18 && intent.hasExtra(DRM_SCHEME_EXTRA)) { String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA)); String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA)); UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme)); - HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); + HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(); HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory); drmSessionManager = @@ -156,9 +155,7 @@ private void initializePlayer() { drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); } - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - this, Util.getUserAgent(this, getString(R.string.application_name))); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this); MediaSource mediaSource; @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA)); if (type == C.TYPE_DASH) { diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java index c3621879c52..2d15dfcbb48 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java @@ -34,7 +34,6 @@ import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; 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; @@ -50,7 +49,6 @@ public final class DemoUtil { private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions"; private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; - private static @MonotonicNonNull String userAgent; private static DataSource.@MonotonicNonNull Factory dataSourceFactory; private static HttpDataSource.@MonotonicNonNull Factory httpDataSourceFactory; private static @MonotonicNonNull DatabaseProvider databaseProvider; @@ -78,23 +76,12 @@ public static RenderersFactory buildRenderersFactory( .setExtensionRendererMode(extensionRendererMode); } - public static synchronized String getUserAgent(Context context) { - if (userAgent == null) { - userAgent = Util.getUserAgent(context, "ExoPlayerDemo"); - } - return userAgent; - } - public static synchronized HttpDataSource.Factory getHttpDataSourceFactory(Context context) { if (httpDataSourceFactory == null) { context = context.getApplicationContext(); CronetEngineWrapper cronetEngineWrapper = new CronetEngineWrapper(context); httpDataSourceFactory = - new CronetDataSourceFactory( - cronetEngineWrapper, - Executors.newSingleThreadExecutor(), - /* transferListener= */ null, - getUserAgent(context)); + new CronetDataSourceFactory(cronetEngineWrapper, Executors.newSingleThreadExecutor()); } return httpDataSourceFactory; } 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 c9af9f77bbc..49fe440101e 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 @@ -288,7 +288,6 @@ protected boolean initializePlayer() { DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders); MediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory) - .setDrmUserAgent(DemoUtil.getUserAgent(this)) .setAdsLoaderProvider(this::getAdsLoader) .setAdViewProvider(playerView); 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 1cd5c128c13..eb669ecf946 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 @@ -184,13 +184,12 @@ private void initializePlayer() { ACTION_VIEW.equals(action) ? Assertions.checkNotNull(intent.getData()) : Uri.parse(DEFAULT_MEDIA_URI); - String userAgent = Util.getUserAgent(this, getString(R.string.application_name)); DrmSessionManager drmSessionManager; if (intent.hasExtra(DRM_SCHEME_EXTRA)) { String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA)); String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA)); UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme)); - HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); + HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(); HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory); drmSessionManager = @@ -201,9 +200,7 @@ private void initializePlayer() { drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); } - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - this, Util.getUserAgent(this, getString(R.string.application_name))); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this); MediaSource mediaSource; @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA)); if (type == C.TYPE_DASH) { 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 1173b16a216..26a60d33324 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 @@ -150,6 +150,8 @@ public OpenException(String errorMessage, DataSpec dataSpec, int cronetConnectio private volatile long currentConnectTimeoutMs; /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -168,6 +170,8 @@ public CronetDataSource(CronetEngine cronetEngine, Executor executor) { } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -199,6 +203,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -233,6 +239,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -262,6 +270,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -301,6 +311,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java index 4086011b4f3..4590936ea5b 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.cronet; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.HttpDataSource; @@ -50,7 +52,7 @@ public final class CronetDataSourceFactory extends BaseFactory { private final HttpDataSource.Factory fallbackFactory; /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. @@ -79,7 +81,24 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. + * + *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. + * + *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + * cross-protocol redirects. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + */ + public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, Executor executor) { + this(cronetEngineWrapper, executor, DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link * DefaultHttpDataSourceFactory} will be used instead. @@ -93,9 +112,7 @@ public CronetDataSourceFactory( * @param userAgent A user agent used to create a fallback HttpDataSource if needed. */ public CronetDataSourceFactory( - CronetEngineWrapper cronetEngineWrapper, - Executor executor, - String userAgent) { + CronetEngineWrapper cronetEngineWrapper, Executor executor, String userAgent) { this( cronetEngineWrapper, executor, @@ -112,7 +129,7 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link * DefaultHttpDataSourceFactory} will be used instead. @@ -147,7 +164,7 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. @@ -178,7 +195,7 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. @@ -209,7 +226,28 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. + * + *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link + * DefaultHttpDataSourceFactory} will be used instead. + * + *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + * cross-protocol redirects. + * + * @param cronetEngineWrapper A {@link CronetEngineWrapper}. + * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. + * @param transferListener An optional listener. + */ + public CronetDataSourceFactory( + CronetEngineWrapper cronetEngineWrapper, + Executor executor, + @Nullable TransferListener transferListener) { + this(cronetEngineWrapper, executor, transferListener, DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link * DefaultHttpDataSourceFactory} will be used instead. @@ -244,7 +282,7 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link * DefaultHttpDataSourceFactory} will be used instead. @@ -277,7 +315,7 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

    If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java index ba9e69410dc..e6e66fbe29b 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java @@ -46,8 +46,7 @@ public final class FlacExtractorSeekTest { private FlacExtractor extractor = new FlacExtractor(); private FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); private DefaultDataSource dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") - .createDataSource(); + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()).createDataSource(); @Test public void flacExtractorReads_seekTable_returnSeekableSeekMap() throws IOException { 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 90ec83630b8..bbcc26fb64f 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 @@ -111,8 +111,7 @@ public void run() { player.addListener(this); MediaSource mediaSource = new ProgressiveMediaSource.Factory( - new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest"), - MatroskaExtractor.FACTORY) + new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY) .createMediaSource(MediaItem.fromUri(uri)); player.setMediaSource(mediaSource); player.prepare(); 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 cd58e1f58b3..88bc4e14c53 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 @@ -49,7 +49,6 @@ 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; @@ -235,13 +234,10 @@ public void onPositionDiscontinuity( @Override protected MediaSource buildSource( HostActivity host, - String userAgent, DrmSessionManager drmSessionManager, FrameLayout overlayFrameLayout) { Context context = host.getApplicationContext(); - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ImaPlaybackTest.class.getSimpleName())); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context); MediaSource contentMediaSource = new DefaultMediaSourceFactory(context).createMediaSource(MediaItem.fromUri(contentUri)); return new AdsMediaSource( diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java index f5518e0c7c7..df6963c2fc1 100644 --- a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java @@ -27,7 +27,6 @@ import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.List; import java.util.Map; @@ -128,8 +127,7 @@ private final class InstrumentingDataSourceFactory implements DataSource.Factory private final DefaultDataSourceFactory defaultDataSourceFactory; public InstrumentingDataSourceFactory(Context context) { - defaultDataSourceFactory = - new DefaultDataSourceFactory(context, Util.getUserAgent(context, "media2-test")); + defaultDataSourceFactory = new DefaultDataSourceFactory(context); } @Override 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 2f43ec4cf87..57fee20d04d 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 @@ -81,6 +81,18 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { private long bytesRead; /** + * Creates an instance. + * + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the source. + */ + public OkHttpDataSource(Call.Factory callFactory) { + this(callFactory, ExoPlayerLibraryInfo.DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -90,6 +102,8 @@ public OkHttpDataSource(Call.Factory callFactory, @Nullable String userAgent) { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -111,6 +125,8 @@ public OkHttpDataSource( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -135,6 +151,8 @@ public OkHttpDataSource( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java index f3d74f92330..728428c8118 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.okhttp; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; @@ -34,6 +36,18 @@ public final class OkHttpDataSourceFactory extends BaseFactory { @Nullable private final CacheControl cacheControl; /** + * Creates an instance. + * + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the sources created by the factory. + */ + public OkHttpDataSourceFactory(Call.Factory callFactory) { + this(callFactory, DEFAULT_USER_AGENT, /* listener= */ null, /* cacheControl= */ null); + } + + /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -43,6 +57,8 @@ public OkHttpDataSourceFactory(Call.Factory callFactory, @Nullable String userAg } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -54,6 +70,8 @@ public OkHttpDataSourceFactory( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -65,6 +83,8 @@ public OkHttpDataSourceFactory( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. 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 2d0b632c2e6..c964b0cc1cb 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 @@ -92,8 +92,7 @@ public void run() { player.addListener(this); MediaSource mediaSource = new ProgressiveMediaSource.Factory( - new DefaultDataSourceFactory(context, "ExoPlayerExtOpusTest"), - MatroskaExtractor.FACTORY) + new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY) .createMediaSource(MediaItem.fromUri(uri)); player.setMediaSource(mediaSource); player.prepare(); 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 aec52755ea8..823ce02cfe2 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 @@ -121,8 +121,7 @@ public void run() { player.addListener(this); MediaSource mediaSource = new ProgressiveMediaSource.Factory( - new DefaultDataSourceFactory(context, "ExoPlayerExtVp9Test"), - MatroskaExtractor.FACTORY) + new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY) .createMediaSource(MediaItem.fromUri(uri)); player .createMessage(videoRenderer) 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 4ee35cbdc3e..15c4bf1c1d2 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2; +import android.os.Build; import java.util.HashSet; /** @@ -45,6 +46,10 @@ public final class ExoPlayerLibraryInfo { // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. public static final int VERSION_INT = 2012000; + /** The default user agent for requests made by the library. */ + public static final String DEFAULT_USER_AGENT = + VERSION_SLASHY + " (Linux;Android " + Build.VERSION.RELEASE + ") " + VERSION_SLASHY; + /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} * checks enabled. 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 df1f3e2cf04..f4fc7e4afe7 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 @@ -20,7 +20,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.MediaItem; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; @@ -115,9 +114,7 @@ public interface AdsLoaderProvider { * @param context Any context. */ public DefaultMediaSourceFactory(Context context) { - this( - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY))); + this(new DefaultDataSourceFactory(context)); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java index 29325d789ed..7859254401f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java @@ -15,12 +15,11 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; import static com.google.android.exoplayer2.drm.DefaultDrmSessionManager.MODE_PLAYBACK; import static com.google.android.exoplayer2.util.Util.castNonNull; -import android.os.Build; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.DrmSessionManager; @@ -36,13 +35,6 @@ /** A helper to create a {@link DrmSessionManager} from a {@link MediaItem}. */ public final class MediaSourceDrmHelper { - private static final String DEFAULT_USER_AGENT = - ExoPlayerLibraryInfo.VERSION_SLASHY - + " (Linux;Android " - + Build.VERSION.RELEASE - + ") " - + ExoPlayerLibraryInfo.VERSION_SLASHY; - @Nullable private HttpDataSource.Factory drmHttpDataSourceFactory; @Nullable private String userAgent; 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 afef3e67617..7efa89eaa0d 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 @@ -18,6 +18,7 @@ import android.content.Context; import android.net.Uri; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; @@ -74,6 +75,20 @@ public final class DefaultDataSource implements DataSource { @Nullable private DataSource dataSource; + /** + * Constructs a new instance, optionally configured to follow cross-protocol redirects. + * + * @param context A context. + */ + public DefaultDataSource(Context context, boolean allowCrossProtocolRedirects) { + this( + context, + ExoPlayerLibraryInfo.DEFAULT_USER_AGENT, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + allowCrossProtocolRedirects); + } + /** * Constructs a new instance, optionally configured to follow cross-protocol redirects. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java index 6b1131a3bd4..3b7cfa9d49b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import android.content.Context; import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.DataSource.Factory; @@ -29,6 +31,11 @@ public final class DefaultDataSourceFactory implements Factory { @Nullable private final TransferListener listener; private final DataSource.Factory baseDataSourceFactory; + /** @param context A context. */ + public DefaultDataSourceFactory(Context context) { + this(context, DEFAULT_USER_AGENT, /* listener= */ null); + } + /** * @param context A context. * @param userAgent The User-Agent string that should be used. 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 66996c75404..d15804fd51d 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 @@ -23,6 +23,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; @@ -97,12 +98,26 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou private long bytesSkipped; private long bytesRead; - /** @param userAgent The User-Agent string that should be used. */ + /** Creates an instance. */ + public DefaultHttpDataSource() { + this( + ExoPlayerLibraryInfo.DEFAULT_USER_AGENT, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS); + } + + /** + * Creates an instance. + * + * @param userAgent The User-Agent string that should be used. + */ public DefaultHttpDataSource(String userAgent) { this(userAgent, DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS); } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is * interpreted as an infinite timeout. @@ -119,6 +134,8 @@ public DefaultHttpDataSource(String userAgent, int connectTimeoutMillis, int rea } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is * interpreted as an infinite timeout. Pass {@link #DEFAULT_CONNECT_TIMEOUT_MILLIS} to use the @@ -146,6 +163,8 @@ public DefaultHttpDataSource( } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -164,6 +183,8 @@ public DefaultHttpDataSource(String userAgent, @Nullable Predicate conte } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -192,6 +213,8 @@ public DefaultHttpDataSource( } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java index f5d7dbd24c0..0a0650a4b1c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; @@ -30,10 +32,18 @@ public final class DefaultHttpDataSourceFactory extends BaseFactory { private final boolean allowCrossProtocolRedirects; /** - * Constructs a DefaultHttpDataSourceFactory. Sets {@link - * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. + */ + public DefaultHttpDataSourceFactory() { + this(DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. * * @param userAgent The User-Agent string that should be used. */ @@ -42,10 +52,9 @@ public DefaultHttpDataSourceFactory(String userAgent) { } /** - * Constructs a DefaultHttpDataSourceFactory. Sets {@link - * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. * * @param userAgent The User-Agent string that should be used. * @param listener An optional listener. 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 66557753f45..4f9b36b702d 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 @@ -102,7 +102,6 @@ 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; @@ -5552,8 +5551,7 @@ public void setMediaSources_secondAdMediaSource_throws() throws Exception { AdsMediaSource adsMediaSource = new AdsMediaSource( new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), + new DefaultDataSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); Exception[] exception = {null}; @@ -5590,8 +5588,7 @@ public void setMediaSources_multipleMediaSourcesWithAd_throws() throws Exception AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), + new DefaultDataSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); final Exception[] exception = {null}; @@ -5630,8 +5627,7 @@ public void setMediaSources_addingMediaSourcesWithAdToNonEmptyPlaylist_throws() AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), + new DefaultDataSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); final Exception[] exception = {null}; diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java index 3d884e02dcf..534cb2572f1 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java @@ -51,7 +51,7 @@ public final class AmrExtractorSeekTest { @Before public void setUp() { dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java index e95c8cd7e8c..16f92e2b4bd 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java @@ -46,8 +46,7 @@ public class FlacExtractorSeekTest { private FlacExtractor extractor = new FlacExtractor(); private FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); private DefaultDataSource dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") - .createDataSource(); + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()).createDataSource(); @Test public void flacExtractorReads_seekTable_returnSeekableSeekMap() throws IOException { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java index b0250803f09..e3137a106dd 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java @@ -52,7 +52,7 @@ public void setUp() throws Exception { extractor = new Mp3Extractor(); extractorOutput = new FakeExtractorOutput(); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java index 72f42fb6014..24530c12f17 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java @@ -52,7 +52,7 @@ public void setUp() throws Exception { extractor = new Mp3Extractor(FLAG_ENABLE_INDEX_SEEKING); extractorOutput = new FakeExtractorOutput(); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java index 07bf5dea1f3..2770d4ef66b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java @@ -49,7 +49,7 @@ public final class AdtsExtractorSeekTest { @Before public void setUp() { dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java index 7dc787f842a..d2d76d6695b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java @@ -68,7 +68,7 @@ public void setUp() throws IOException { expectedTrackOutput = expectedOutput.trackOutputs.get(VIDEO_TRACK_ID); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); totalInputLength = readInputLength(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java index 4e710ec6325..a796f3c9940 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java @@ -62,7 +62,7 @@ public void setUp() throws IOException { .get(AUDIO_TRACK_ID); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } 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 3d37d18182a..b90b4ec6e31 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 @@ -258,12 +258,12 @@ protected DefaultTrackSelector buildTrackSelector(HostActivity host) { } @Override - protected DrmSessionManager buildDrmSessionManager(final String userAgent) { + protected DrmSessionManager buildDrmSessionManager() { if (widevineLicenseUrl == null) { return DrmSessionManager.getDummyDrmSessionManager(); } MediaDrmCallback drmCallback = - new HttpMediaDrmCallback(widevineLicenseUrl, new DefaultHttpDataSourceFactory(userAgent)); + new HttpMediaDrmCallback(widevineLicenseUrl, new DefaultHttpDataSourceFactory()); DefaultDrmSessionManager drmSessionManager = new DefaultDrmSessionManager.Builder() .setUuidAndExoMediaDrmProvider( @@ -301,13 +301,12 @@ protected SimpleExoPlayer buildExoPlayer( @Override protected MediaSource buildSource( HostActivity host, - String userAgent, DrmSessionManager drmSessionManager, FrameLayout overlayFrameLayout) { DataSource.Factory dataSourceFactory = this.dataSourceFactory != null ? this.dataSourceFactory - : new DefaultDataSourceFactory(host, userAgent); + : new DefaultDataSourceFactory(host); return new DashMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(MIN_LOADABLE_RETRY_COUNT)) 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 e66a30935e3..5eececd88e1 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 @@ -128,7 +128,6 @@ public final void onStart(HostActivity host, Surface surface, FrameLayout overla this.surface = surface; // Build the player. trackSelector = buildTrackSelector(host); - String userAgent = "ExoPlayerPlaybackTests"; player = buildExoPlayer(host, surface, trackSelector); player.play(); player.addAnalyticsListener(this); @@ -140,10 +139,8 @@ public final void onStart(HostActivity host, Surface surface, FrameLayout overla pendingSchedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null); pendingSchedule = null; } - DrmSessionManager drmSessionManager = buildDrmSessionManager(userAgent); - player.setMediaSource( - buildSource( - host, Util.getUserAgent(host, userAgent), drmSessionManager, overlayFrameLayout)); + DrmSessionManager drmSessionManager = buildDrmSessionManager(); + player.setMediaSource(buildSource(host, drmSessionManager, overlayFrameLayout)); player.prepare(); } @@ -232,7 +229,7 @@ private boolean stopTest() { return true; } - protected DrmSessionManager buildDrmSessionManager(String userAgent) { + protected DrmSessionManager buildDrmSessionManager() { // Do nothing. Interested subclasses may override. return DrmSessionManager.getDummyDrmSessionManager(); } @@ -256,7 +253,6 @@ protected SimpleExoPlayer buildExoPlayer( protected abstract MediaSource buildSource( HostActivity host, - String userAgent, DrmSessionManager drmSessionManager, FrameLayout overlayFrameLayout); From 222ba22b1090b33918a351ca266210e2bcbca974 Mon Sep 17 00:00:00 2001 From: insun Date: Wed, 9 Sep 2020 02:07:37 +0100 Subject: [PATCH 014/693] Fix not to show repeat button when its mode is NONE. PiperOrigin-RevId: 330627047 --- .../google/android/exoplayer2/ui/StyledPlayerControlView.java | 2 ++ 1 file changed, 2 insertions(+) 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 86a802323a2..97652ad01f7 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 @@ -716,6 +716,8 @@ public StyledPlayerControlView( controlViewLayoutManager.setShowButton(shuffleButton, showShuffleButton); controlViewLayoutManager.setShowButton(subtitleButton, showSubtitleButton); controlViewLayoutManager.setShowButton(vrButton, showVrButton); + controlViewLayoutManager.setShowButton( + repeatToggleButton, repeatToggleModes != RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE); addOnLayoutChangeListener(this::onLayoutChange); } From 9e42b24e26c3c08f4d86838344da3b5243ce8d5b Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 9 Sep 2020 17:21:23 +0100 Subject: [PATCH 015/693] Fix Javadoc for DefaultDataSourceFactory constructors PiperOrigin-RevId: 330736458 --- .../upstream/DefaultDataSourceFactory.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java index 3b7cfa9d49b..68ce25c47fc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java @@ -31,12 +31,18 @@ public final class DefaultDataSourceFactory implements Factory { @Nullable private final TransferListener listener; private final DataSource.Factory baseDataSourceFactory; - /** @param context A context. */ + /** + * Creates an instance. + * + * @param context A context. + */ public DefaultDataSourceFactory(Context context) { this(context, DEFAULT_USER_AGENT, /* listener= */ null); } /** + * Creates an instance. + * * @param context A context. * @param userAgent The User-Agent string that should be used. */ @@ -45,6 +51,8 @@ public DefaultDataSourceFactory(Context context, String userAgent) { } /** + * Creates an instance. + * * @param context A context. * @param userAgent The User-Agent string that should be used. * @param listener An optional listener. @@ -55,6 +63,8 @@ public DefaultDataSourceFactory( } /** + * Creates an instance. + * * @param context A context. * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} * for {@link DefaultDataSource}. @@ -65,6 +75,8 @@ public DefaultDataSourceFactory(Context context, DataSource.Factory baseDataSour } /** + * Creates an instance. + * * @param context A context. * @param listener An optional listener. * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} From 6abe6a676e68d5e734aa2b7e8097f3c4c666d91f Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 9 Sep 2020 17:23:05 +0100 Subject: [PATCH 016/693] Support android.resource URI scheme Issue: #7866 PiperOrigin-RevId: 330736774 --- RELEASENOTES.md | 3 + .../upstream/DefaultDataSource.java | 10 +- .../upstream/RawResourceDataSource.java | 92 +++++++++++++------ 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7701c319ab8..5ab125e9b6d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,6 +4,9 @@ * Track selection: * Add option to specify multiple preferred audio or text languages. +* Data sources: + * Add support for `android.resource` URI scheme in `RawResourceDataSource` + ([#7866](https://github.com/google/ExoPlayer/issues/7866)). ### 2.12.0 (not yet released - targeted for 2020-09-11) ### 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 7efa89eaa0d..12fea3898cb 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream; +import android.content.ContentResolver; import android.content.Context; import android.net.Uri; import androidx.annotation.Nullable; @@ -39,6 +40,9 @@ *

  • rawresource: For fetching data from a raw resource in the application's apk (e.g. * rawresource:///resourceId, where rawResourceId is the integer identifier of the raw * resource). + *
  • android.resource: For fetching data in the application's apk (e.g. + * android.resource:///resourceId or android.resource://resourceType/resourceName). See {@link + * RawResourceDataSource} for more information about the URI form. *
  • content: For fetching data from a content URI (e.g. content://authority/path/123). *
  • rtmp: For fetching data over RTMP. Only supported if the project using ExoPlayer has an * explicit dependency on ExoPlayer's RTMP extension. @@ -58,7 +62,9 @@ public final class DefaultDataSource implements DataSource { private static final String SCHEME_CONTENT = "content"; private static final String SCHEME_RTMP = "rtmp"; private static final String SCHEME_UDP = "udp"; + private static final String SCHEME_DATA = DataSchemeDataSource.SCHEME_DATA; private static final String SCHEME_RAW = RawResourceDataSource.RAW_RESOURCE_SCHEME; + private static final String SCHEME_ANDROID_RESOURCE = ContentResolver.SCHEME_ANDROID_RESOURCE; private final Context context; private final List transferListeners; @@ -182,9 +188,9 @@ public long open(DataSpec dataSpec) throws IOException { dataSource = getRtmpDataSource(); } else if (SCHEME_UDP.equals(scheme)) { dataSource = getUdpDataSource(); - } else if (DataSchemeDataSource.SCHEME_DATA.equals(scheme)) { + } else if (SCHEME_DATA.equals(scheme)) { dataSource = getDataSchemeDataSource(); - } else if (SCHEME_RAW.equals(scheme)) { + } else if (SCHEME_RAW.equals(scheme) || SCHEME_ANDROID_RESOURCE.equals(scheme)) { dataSource = getRawResourceDataSource(); } else { dataSource = baseDataSource; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java index 0595cb84bc5..7538cc67a49 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java @@ -18,6 +18,7 @@ import static com.google.android.exoplayer2.util.Util.castNonNull; import static java.lang.Math.min; +import android.content.ContentResolver; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.content.res.Resources; @@ -34,9 +35,20 @@ /** * A {@link DataSource} for reading a raw resource inside the APK. * - *

    URIs supported by this source are of the form {@code rawresource:///rawResourceId}, where - * rawResourceId is the integer identifier of a raw resource. {@link #buildRawResourceUri(int)} can - * be used to build {@link Uri}s in this format. + *

    URIs supported by this source are of one of the forms: + * + *

      + *
    • {@code rawresource:///id}, where {@code id} is the integer identifier of a raw resource. + *
    • {@code android.resource:///id}, where {@code id} is the integer identifier of a raw + * resource. + *
    • {@code android.resource://[package]/[type/]name}, where {@code package} is the name of the + * package in which the resource is located, {@code type} is the resource type and {@code + * name} is the resource name. The package and the type are optional. Their default value is + * the package of this application and "raw", respectively. Using the two other forms is more + * efficient. + *
    + * + *

    {@link #buildRawResourceUri(int)} can be used to build supported {@link Uri}s. */ public final class RawResourceDataSource extends BaseDataSource { @@ -67,6 +79,7 @@ public static Uri buildRawResourceUri(int rawResourceId) { public static final String RAW_RESOURCE_SCHEME = "rawresource"; private final Resources resources; + private final String packageName; @Nullable private Uri uri; @Nullable private AssetFileDescriptor assetFileDescriptor; @@ -80,33 +93,55 @@ public static Uri buildRawResourceUri(int rawResourceId) { public RawResourceDataSource(Context context) { super(/* isNetwork= */ false); this.resources = context.getResources(); + this.packageName = context.getPackageName(); } @Override public long open(DataSpec dataSpec) throws RawResourceDataSourceException { - try { - Uri uri = dataSpec.uri; - this.uri = uri; - if (!TextUtils.equals(RAW_RESOURCE_SCHEME, uri.getScheme())) { - throw new RawResourceDataSourceException("URI must use scheme " + RAW_RESOURCE_SCHEME); - } - - int resourceId; + Uri uri = dataSpec.uri; + this.uri = uri; + + int resourceId; + if (TextUtils.equals(RAW_RESOURCE_SCHEME, uri.getScheme()) + || (TextUtils.equals(ContentResolver.SCHEME_ANDROID_RESOURCE, uri.getScheme()) + && uri.getPathSegments().size() == 1 + && Assertions.checkNotNull(uri.getLastPathSegment()).matches("\\d+"))) { try { resourceId = Integer.parseInt(Assertions.checkNotNull(uri.getLastPathSegment())); } catch (NumberFormatException e) { throw new RawResourceDataSourceException("Resource identifier must be an integer."); } - - transferInitializing(dataSpec); - AssetFileDescriptor assetFileDescriptor = resources.openRawResourceFd(resourceId); - this.assetFileDescriptor = assetFileDescriptor; - if (assetFileDescriptor == null) { - throw new RawResourceDataSourceException("Resource is compressed: " + uri); + } else if (TextUtils.equals(ContentResolver.SCHEME_ANDROID_RESOURCE, uri.getScheme())) { + String path = Assertions.checkNotNull(uri.getPath()); + if (path.startsWith("/")) { + path = path.substring(1); + } + @Nullable String host = uri.getHost(); + String resourceName = (TextUtils.isEmpty(host) ? "" : (host + ":")) + path; + resourceId = + resources.getIdentifier( + resourceName, /* defType= */ "raw", /* defPackage= */ packageName); + if (resourceId == 0) { + throw new RawResourceDataSourceException("Resource not found."); } - FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); - this.inputStream = inputStream; + } else { + throw new RawResourceDataSourceException( + "URI must either use scheme " + + RAW_RESOURCE_SCHEME + + " or " + + ContentResolver.SCHEME_ANDROID_RESOURCE); + } + + transferInitializing(dataSpec); + AssetFileDescriptor assetFileDescriptor = resources.openRawResourceFd(resourceId); + this.assetFileDescriptor = assetFileDescriptor; + if (assetFileDescriptor == null) { + throw new RawResourceDataSourceException("Resource is compressed: " + uri); + } + FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); + this.inputStream = inputStream; + try { inputStream.skip(assetFileDescriptor.getStartOffset()); long skipped = inputStream.skip(dataSpec.position); if (skipped < dataSpec.position) { @@ -114,18 +149,21 @@ public long open(DataSpec dataSpec) throws RawResourceDataSourceException { // skip beyond the end of the data. throw new EOFException(); } - if (dataSpec.length != C.LENGTH_UNSET) { - bytesRemaining = dataSpec.length; - } else { - long assetFileDescriptorLength = assetFileDescriptor.getLength(); - // If the length is UNKNOWN_LENGTH then the asset extends to the end of the file. - bytesRemaining = assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH - ? C.LENGTH_UNSET : (assetFileDescriptorLength - dataSpec.position); - } } catch (IOException e) { throw new RawResourceDataSourceException(e); } + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + long assetFileDescriptorLength = assetFileDescriptor.getLength(); + // If the length is UNKNOWN_LENGTH then the asset extends to the end of the file. + bytesRemaining = + assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH + ? C.LENGTH_UNSET + : (assetFileDescriptorLength - dataSpec.position); + } + opened = true; transferStarted(dataSpec); From abc39088d07db8111f7f255ce4651feafda5e577 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 10 Sep 2020 11:31:40 +0100 Subject: [PATCH 017/693] Remove testutil dependency on Robolectric shadows Move shadow-related utils for end-to-end tests into core test. PiperOrigin-RevId: 330902696 --- .../google/android/exoplayer2/e2etest/Mp4PlaybackTest.java | 4 ++-- .../com/google/android/exoplayer2/e2etest/TsPlaybackTest.java | 4 ++-- .../android/exoplayer2/e2etest/util}/PlaybackOutput.java | 3 ++- .../exoplayer2/e2etest/util}/ShadowMediaCodecConfig.java | 2 +- .../com/google/android/exoplayer2/e2etest/util}/TeeCodec.java | 3 ++- testutils/build.gradle | 1 - 6 files changed, 9 insertions(+), 8 deletions(-) rename {testutils/src/main/java/com/google/android/exoplayer2/testutil => library/core/src/test/java/com/google/android/exoplayer2/e2etest/util}/PlaybackOutput.java (97%) rename {testutils/src/main/java/com/google/android/exoplayer2/testutil => library/core/src/test/java/com/google/android/exoplayer2/e2etest/util}/ShadowMediaCodecConfig.java (98%) rename {testutils/src/main/java/com/google/android/exoplayer2/testutil => library/core/src/test/java/com/google/android/exoplayer2/e2etest/util}/TeeCodec.java (96%) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java index 684399d8457..021a1b3f54d 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java @@ -23,10 +23,10 @@ import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.e2etest.util.PlaybackOutput; +import com.google.android.exoplayer2.e2etest.util.ShadowMediaCodecConfig; import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; import com.google.android.exoplayer2.testutil.DumpFileAsserts; -import com.google.android.exoplayer2.testutil.PlaybackOutput; -import com.google.android.exoplayer2.testutil.ShadowMediaCodecConfig; import com.google.android.exoplayer2.testutil.TestExoPlayer; import org.junit.Rule; import org.junit.Test; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java index c78e4cfe969..8956cd5dc74 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java @@ -22,10 +22,10 @@ import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.e2etest.util.PlaybackOutput; +import com.google.android.exoplayer2.e2etest.util.ShadowMediaCodecConfig; import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; import com.google.android.exoplayer2.testutil.DumpFileAsserts; -import com.google.android.exoplayer2.testutil.PlaybackOutput; -import com.google.android.exoplayer2.testutil.ShadowMediaCodecConfig; import com.google.android.exoplayer2.testutil.TestExoPlayer; import org.junit.Rule; import org.junit.Test; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/PlaybackOutput.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java similarity index 97% rename from testutils/src/main/java/com/google/android/exoplayer2/testutil/PlaybackOutput.java rename to library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java index 69429709a47..f9c32d34b56 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/PlaybackOutput.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.testutil; +package com.google.android.exoplayer2.e2etest.util; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.testutil.Dumper; import com.google.android.exoplayer2.util.Assertions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ShadowMediaCodecConfig.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java similarity index 98% rename from testutils/src/main/java/com/google/android/exoplayer2/testutil/ShadowMediaCodecConfig.java rename to library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java index d1b4e784b87..6d7f23107e7 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ShadowMediaCodecConfig.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.testutil; +package com.google.android.exoplayer2.e2etest.util; import android.media.MediaCodecInfo; import android.media.MediaFormat; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TeeCodec.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java similarity index 96% rename from testutils/src/main/java/com/google/android/exoplayer2/testutil/TeeCodec.java rename to library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java index fd9b374d467..a14787e959a 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TeeCodec.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.testutil; +package com.google.android.exoplayer2.e2etest.util; +import com.google.android.exoplayer2.testutil.Dumper; import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.collect.ImmutableList; import java.nio.ByteBuffer; diff --git a/testutils/build.gradle b/testutils/build.gradle index 8cd443e07ff..93b3acf53f4 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -24,7 +24,6 @@ dependencies { compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation project(modulePrefix + 'library-core') - implementation 'org.robolectric:robolectric:' + robolectricVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } From 06de13ecae71225c0ad9175c1727395216b63ba2 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 10 Sep 2020 12:04:01 +0100 Subject: [PATCH 018/693] Add a setter for ad error listeners This is useful because ImaAdsLoader.getAdsLoader() can now return null (before ads have been requested), and it avoids the app needing to get an AdsManager to attach its listener. PiperOrigin-RevId: 330907051 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 351ad43d2cc..ed40a17510a 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 @@ -124,6 +124,7 @@ public static final class Builder { private final Context context; @Nullable private ImaSdkSettings imaSdkSettings; + @Nullable private AdErrorListener adErrorListener; @Nullable private AdEventListener adEventListener; @Nullable private Set adUiElements; @Nullable private Collection companionAdSlots; @@ -165,6 +166,19 @@ public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) { return this; } + /** + * Sets a listener for ad errors that will be passed to {@link + * AdsLoader#addAdErrorListener(AdErrorListener)} and {@link + * AdsManager#addAdErrorListener(AdErrorListener)}. + * + * @param adErrorListener The ad error listener. + * @return This builder, for convenience. + */ + public Builder setAdErrorListener(AdErrorListener adErrorListener) { + this.adErrorListener = checkNotNull(adErrorListener); + return this; + } + /** * Sets a listener for ad events that will be passed to {@link * AdsManager#addAdEventListener(AdEventListener)}. @@ -316,6 +330,7 @@ public ImaAdsLoader buildForAdTag(Uri adTagUri) { playAdBeforeStartPosition, adUiElements, companionAdSlots, + adErrorListener, adEventListener, imaFactory); } @@ -341,6 +356,7 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { playAdBeforeStartPosition, adUiElements, companionAdSlots, + adErrorListener, adEventListener, imaFactory); } @@ -408,6 +424,7 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { private final int mediaBitrate; @Nullable private final Set adUiElements; @Nullable private final Collection companionAdSlots; + @Nullable private final AdErrorListener adErrorListener; @Nullable private final AdEventListener adEventListener; private final ImaFactory imaFactory; private final ImaSdkSettings imaSdkSettings; @@ -516,6 +533,7 @@ public ImaAdsLoader(Context context, Uri adTagUri) { /* playAdBeforeStartPosition= */ true, /* adUiElements= */ null, /* companionAdSlots= */ null, + /* adErrorListener= */ null, /* adEventListener= */ null, /* imaFactory= */ new DefaultImaFactory()); } @@ -534,6 +552,7 @@ private ImaAdsLoader( boolean playAdBeforeStartPosition, @Nullable Set adUiElements, @Nullable Collection companionAdSlots, + @Nullable AdErrorListener adErrorListener, @Nullable AdEventListener adEventListener, ImaFactory imaFactory) { checkArgument(adTagUri != null || adsResponse != null); @@ -548,6 +567,7 @@ private ImaAdsLoader( this.playAdBeforeStartPosition = playAdBeforeStartPosition; this.adUiElements = adUiElements; this.companionAdSlots = companionAdSlots; + this.adErrorListener = adErrorListener; this.adEventListener = adEventListener; this.imaFactory = imaFactory; if (imaSdkSettings == null) { @@ -629,6 +649,9 @@ public void requestAds(@Nullable ViewGroup adViewGroup) { } adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(componentListener); + if (adErrorListener != null) { + adsLoader.addAdErrorListener(adErrorListener); + } adsLoader.addAdsLoadedListener(componentListener); AdsRequest request = imaFactory.createAdsRequest(); if (adTagUri != null) { @@ -759,6 +782,9 @@ public void release() { if (adsLoader != null) { adsLoader.removeAdsLoadedListener(componentListener); adsLoader.removeAdErrorListener(componentListener); + if (adErrorListener != null) { + adsLoader.removeAdErrorListener(adErrorListener); + } } imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; @@ -1582,6 +1608,9 @@ private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { private void destroyAdsManager() { if (adsManager != null) { adsManager.removeAdErrorListener(componentListener); + if (adErrorListener != null) { + adsManager.removeAdErrorListener(adErrorListener); + } adsManager.removeAdEventListener(componentListener); if (adEventListener != null) { adsManager.removeAdEventListener(adEventListener); @@ -1642,6 +1671,9 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { pendingAdRequestContext = null; ImaAdsLoader.this.adsManager = adsManager; adsManager.addAdErrorListener(this); + if (adErrorListener != null) { + adsManager.addAdErrorListener(adErrorListener); + } adsManager.addAdEventListener(this); if (adEventListener != null) { adsManager.addAdEventListener(adEventListener); From 362d4f5b16ba317a9c018ecdf53005febf965e5b Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 10 Sep 2020 12:12:23 +0100 Subject: [PATCH 019/693] Add convenience constructor methods. When passing in ExtractorFactory instances to SimpleExoPlayer.Builder or DefaultMediaSourceFactory, we currently need to pass in one other instance (RenderersFactory or DataSource.Factory), that developers will often set to its default. To avoid specifying these defaults, these new convience methods allow to just set the ExtractorsFactory if required. PiperOrigin-RevId: 330908002 --- .../android/exoplayer2/SimpleExoPlayer.java | 31 ++++++++++++------- .../source/DefaultMediaSourceFactory.java | 16 ++++++++-- 2 files changed, 33 insertions(+), 14 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 ba3a375a377..39490d57090 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 @@ -54,7 +54,6 @@ import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Log; @@ -118,11 +117,11 @@ public static final class Builder { /** * Creates a builder. * - *

    Use {@link #Builder(Context, RenderersFactory)} or {@link #Builder(Context, - * RenderersFactory, ExtractorsFactory)} instead, if you intend to provide a custom {@link - * RenderersFactory} or a custom {@link ExtractorsFactory}. This is to ensure that ProGuard or - * R8 can remove ExoPlayer's {@link DefaultRenderersFactory} and {@link - * DefaultExtractorsFactory} from the APK. + *

    Use {@link #Builder(Context, RenderersFactory)}, {@link #Builder(Context, + * RenderersFactory)} or {@link #Builder(Context, RenderersFactory, ExtractorsFactory)} instead, + * if you intend to provide a custom {@link RenderersFactory} or a custom {@link + * ExtractorsFactory}. This is to ensure that ProGuard or R8 can remove ExoPlayer's {@link + * DefaultRenderersFactory} and {@link DefaultExtractorsFactory} from the APK. * *

    The builder uses the following default values: * @@ -167,6 +166,19 @@ public Builder(Context context, RenderersFactory renderersFactory) { this(context, renderersFactory, new DefaultExtractorsFactory()); } + /** + * Creates a builder with a custom {@link ExtractorsFactory}. + * + *

    See {@link #Builder(Context)} for a list of default values. + * + * @param context A {@link Context}. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public Builder(Context context, ExtractorsFactory extractorsFactory) { + this(context, new DefaultRenderersFactory(context), extractorsFactory); + } + /** * Creates a builder with a custom {@link RenderersFactory} and {@link ExtractorsFactory}. * @@ -184,10 +196,7 @@ public Builder( context, renderersFactory, new DefaultTrackSelector(context), - new DefaultMediaSourceFactory( - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), - extractorsFactory), + new DefaultMediaSourceFactory(context, extractorsFactory), new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), new AnalyticsCollector(Clock.DEFAULT)); @@ -570,7 +579,7 @@ protected SimpleExoPlayer( Clock clock, Looper applicationLooper) { this( - new Builder(context, renderersFactory, new DefaultExtractorsFactory()) + new Builder(context, renderersFactory) .setTrackSelector(trackSelector) .setMediaSourceFactory(mediaSourceFactory) .setLoadControl(loadControl) 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 f4fc7e4afe7..3f1c03d3b18 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 @@ -65,9 +65,8 @@ * MediaItem.PlaybackProperties#uri uri} doesn't match one of the above. It tries to infer the * required extractor by using the {@link * com.google.android.exoplayer2.extractor.DefaultExtractorsFactory} or the {@link - * ExtractorsFactory} provided in {@link #DefaultMediaSourceFactory(DataSource.Factory, - * ExtractorsFactory)}. An {@link UnrecognizedInputFormatException} is thrown if none of the - * available extractors can read the stream. + * ExtractorsFactory} provided in the constructor. An {@link UnrecognizedInputFormatException} + * is thrown if none of the available extractors can read the stream. * * *

    Ad support for media items with ad tag URIs

    @@ -117,6 +116,17 @@ public DefaultMediaSourceFactory(Context context) { this(new DefaultDataSourceFactory(context)); } + /** + * Creates a new instance. + * + * @param context Any context. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public DefaultMediaSourceFactory(Context context, ExtractorsFactory extractorsFactory) { + this(new DefaultDataSourceFactory(context), extractorsFactory); + } + /** * Creates a new instance. * From 19e6de778de0f150e24af2066a73b8d90ea454b3 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 10 Sep 2020 13:49:56 +0100 Subject: [PATCH 020/693] Introduce audio offload scheduling tests PiperOrigin-RevId: 330918396 --- .../android/exoplayer2/ExoPlayerTest.java | 162 ++++++++++++++++++ .../exoplayer2/testutil/TestExoPlayer.java | 28 +++ 2 files changed, 190 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 4f9b36b702d..b8f10a2dc2f 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 @@ -19,6 +19,7 @@ import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.android.exoplayer2.testutil.TestExoPlayer.playUntilStartOfWindow; import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilPlaybackState; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilReceiveOffloadSchedulingEnabledNewState; import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilTimelineChanged; import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; @@ -110,6 +111,7 @@ import java.util.HashSet; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -8208,6 +8210,109 @@ protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) assertThat(player.getCurrentWindowIndex()).isEqualTo(0); } + @Test + public void enableOffloadSchedulingWhileIdle_isToggled_isReported() throws Exception { + SimpleExoPlayer player = new TestExoPlayer.Builder(context).build(); + + player.experimentalSetOffloadSchedulingEnabled(true); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isTrue(); + + player.experimentalSetOffloadSchedulingEnabled(false); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } + + @Test + public void enableOffloadSchedulingWhilePlaying_isToggled_isReported() throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.prepare(); + player.play(); + + player.experimentalSetOffloadSchedulingEnabled(true); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isTrue(); + + player.experimentalSetOffloadSchedulingEnabled(false); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } + + @Test + public void enableOffloadSchedulingWhileSleepingForOffload_isDisabled_isReported() + throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + runUntilReceiveOffloadSchedulingEnabledNewState(player); + player.prepare(); + player.play(); + runMainLooperUntil(sleepRenderer::isSleeping); + + player.experimentalSetOffloadSchedulingEnabled(false); + + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } + + @Test + public void enableOffloadScheduling_isEnable_playerSleeps() throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + player.prepare(); + player.play(); + + sleepRenderer.sleepOnNextRender(); + + runMainLooperUntil(sleepRenderer::isSleeping); + // TODO(b/163303129): There is currently no way to check that the player is sleeping for + // offload, for now use a timeout to check that the renderer is never woken up. + final int renderTimeoutMs = 500; + assertThrows( + TimeoutException.class, + () -> + runMainLooperUntil(() -> !sleepRenderer.isSleeping(), renderTimeoutMs, Clock.DEFAULT)); + } + + @Test + public void + experimentalEnableOffloadSchedulingWhileSleepingForOffload_isDisabled_renderingResumes() + throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + player.prepare(); + player.play(); + runMainLooperUntil(sleepRenderer::isSleeping); + + player.experimentalSetOffloadSchedulingEnabled(false); // Force the player to exit offload sleep + + runMainLooperUntil(() -> !sleepRenderer.isSleeping()); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + + @Test + public void wakeupListenerWhileSleepingForOffload_isWokenUp_renderingResumes() throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + player.prepare(); + player.play(); + runMainLooperUntil(sleepRenderer::isSleeping); + + sleepRenderer.wakeup(); + + runMainLooperUntil(() -> !sleepRenderer.isSleeping()); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { @@ -8237,6 +8342,63 @@ private static void deliverBroadcast(Intent intent) { // Internal classes. + /* {@link FakeRenderer} that can sleep and be woken-up. */ + private static class FakeSleepRenderer extends FakeRenderer { + private static final long WAKEUP_DEADLINE_MS = 60 * C.MICROS_PER_SECOND; + private final AtomicBoolean sleepOnNextRender; + private final AtomicBoolean isSleeping; + private final AtomicReference wakeupListenerReceiver; + + public FakeSleepRenderer(int trackType) { + super(trackType); + sleepOnNextRender = new AtomicBoolean(false); + isSleeping = new AtomicBoolean(false); + wakeupListenerReceiver = new AtomicReference<>(); + } + + public void wakeup() { + wakeupListenerReceiver.get().onWakeup(); + } + + /** + * Call {@link Renderer.WakeupListener#onSleep(long)} on the next {@link #render(long, long)} + */ + public FakeSleepRenderer sleepOnNextRender() { + sleepOnNextRender.set(true); + return this; + } + + /** + * Returns whether {@link Renderer.WakeupListener#onSleep(long)} was called on the last {@link + * #render(long, long)} + */ + public boolean isSleeping() { + return isSleeping.get(); + } + + @Override + public void handleMessage(int what, @Nullable Object object) throws ExoPlaybackException { + if (what == MSG_SET_WAKEUP_LISTENER) { + assertThat(object).isNotNull(); + wakeupListenerReceiver.set((WakeupListener) object); + } + super.handleMessage(what, object); + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + super.render(positionUs, elapsedRealtimeUs); + if (sleepOnNextRender.compareAndSet(/* expect= */ true, /* update= */ false)) { + wakeupListenerReceiver.get().onSleep(WAKEUP_DEADLINE_MS); + // TODO(b/163303129): Use an actual message from the player instead of guessing that the + // player will always sleep for offload after calling `onSleep`. + isSleeping.set(true); + } else { + isSleeping.set(false); + } + } + } + private static final class CountingMessageTarget implements PlayerMessage.Target { public int messageCount; 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 6feea08a027..6b8f32ef01a 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 @@ -44,6 +44,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import org.checkerframework.checker.nullness.compatqual.NullableType; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** @@ -438,6 +439,33 @@ public void onPlayerError(ExoPlaybackException error) { return receivedError.get(); } + /** + * Runs tasks of the main {@link Looper} until a {@link + * Player.EventListener#onExperimentalOffloadSchedulingEnabledChanged} callback occurred. + * + * @param player The {@link Player}. + * @return The new offloadSchedulingEnabled state. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. + */ + public static boolean runUntilReceiveOffloadSchedulingEnabledNewState(Player player) + throws TimeoutException { + verifyMainTestThread(player); + AtomicReference<@NullableType Boolean> offloadSchedulingEnabledReceiver = + new AtomicReference<>(); + Player.EventListener listener = + new Player.EventListener() { + @Override + public void onExperimentalOffloadSchedulingEnabledChanged( + boolean offloadSchedulingEnabled) { + offloadSchedulingEnabledReceiver.set(offloadSchedulingEnabled); + } + }; + player.addListener(listener); + runMainLooperUntil(() -> offloadSchedulingEnabledReceiver.get() != null); + return Assertions.checkNotNull(offloadSchedulingEnabledReceiver.get()); + } + /** * Runs tasks of the main {@link Looper} until the {@link VideoListener#onRenderedFirstFrame} * callback has been called. From 033232784a8a59f91b1dbab0206817515a45aa8c Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 10 Sep 2020 13:52:23 +0100 Subject: [PATCH 021/693] Improve DEBUG VideoProgressUpdate logging PiperOrigin-RevId: 330918689 --- .../android/exoplayer2/ext/ima/ImaAdsLoader.java | 10 +++++++++- 1 file changed, 9 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 ed40a17510a..a5539248b68 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 @@ -1696,7 +1696,15 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { public VideoProgressUpdate getContentProgress() { VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); if (DEBUG) { - Log.d(TAG, "Content progress: " + videoProgressUpdate); + if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { + Log.d(TAG, "Content progress: not ready"); + } else { + Log.d( + TAG, + Util.formatInvariant( + "Content progress: %.1f of %.1f s", + videoProgressUpdate.getCurrentTime(), videoProgressUpdate.getDuration())); + } } if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { From 267cc537708edf799075bcf329e4e242ade9eb37 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 10 Sep 2020 18:01:01 +0100 Subject: [PATCH 022/693] Release player in e2e playback tests. Not releasing the player means the playback thread keeps running and also keeps its entire allocated playback buffer. PiperOrigin-RevId: 330958821 --- .../com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java | 1 + .../com/google/android/exoplayer2/e2etest/TsPlaybackTest.java | 1 + 2 files changed, 2 insertions(+) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java index 021a1b3f54d..f37610d982c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java @@ -55,6 +55,7 @@ public void h264VideoAacAudio() throws Exception { player.prepare(); player.play(); TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); DumpFileAsserts.assertOutput( ApplicationProvider.getApplicationContext(), diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java index 8956cd5dc74..d57f06ff52a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java @@ -55,6 +55,7 @@ public void mpegVideoMpegAudioScte35() throws Exception { player.prepare(); player.play(); TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); DumpFileAsserts.assertOutput( ApplicationProvider.getApplicationContext(), From 157dffdca93306307dea636aa1111c336e5ee974 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 10 Sep 2020 18:10:28 +0100 Subject: [PATCH 023/693] Don't keep 100MB static buffer in test. This may remove available memory from other tests running in the same process. Instead, create the huge buffer when needed so it can be GCed immediately. PiperOrigin-RevId: 330960844 --- .../android/exoplayer2/mediacodec/BatchBufferTest.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 a48dcf79453..d281b363caa 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 @@ -39,8 +39,6 @@ public final class BatchBufferTest { private static final byte[] TEST_ACCESS_UNIT = TestUtil.buildTestData(BUFFER_SIZE_MUCH_SMALLER_THAN_BATCH_SIZE_BYTES); - private static final byte[] TEST_HUGE_ACCESS_UNIT = - TestUtil.buildTestData(BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES); private final BatchBuffer batchBuffer = new BatchBuffer(); @@ -163,15 +161,16 @@ public void commitNextAccessUnit_whenAccessUnitIsHugeAndBatchBufferNotEmpty_isMa batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); batchBuffer.commitNextAccessUnit(); - batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_HUGE_ACCESS_UNIT.length); - batchBuffer.getNextAccessUnitBuffer().data.put(TEST_HUGE_ACCESS_UNIT); + byte[] hugeAccessUnit = TestUtil.buildTestData(BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES); + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(hugeAccessUnit.length); + batchBuffer.getNextAccessUnitBuffer().data.put(hugeAccessUnit); batchBuffer.commitNextAccessUnit(); batchBuffer.batchWasConsumed(); batchBuffer.flip(); assertThat(batchBuffer.getAccessUnitCount()).isEqualTo(1); - assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(TEST_HUGE_ACCESS_UNIT)); + assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(hugeAccessUnit)); } @Test From a3bac6b63e2500b28a83a4299610dd1513d9c3fd Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 10 Sep 2020 23:08:13 +0100 Subject: [PATCH 024/693] fix typo PiperOrigin-RevId: 331025924 --- .../java/com/google/android/exoplayer2/SimpleExoPlayer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 39490d57090..6652cbb03d0 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 @@ -345,7 +345,7 @@ public Builder setPriorityTaskManager(@Nullable PriorityTaskManager priorityTask * IllegalArgumentException}. * * @param audioAttributes {@link AudioAttributes}. - * @param handleAudioFocus Whether the player should hanlde audio focus. + * @param handleAudioFocus Whether the player should handle audio focus. * @return This builder. * @throws IllegalStateException If {@link #build()} has already been called. */ From b9ed9ee37977d603a5f11f8a2cd1b8573cca4902 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 10 Sep 2020 23:17:17 +0100 Subject: [PATCH 025/693] Fix incorrect type when creating ExoPlaybackException PiperOrigin-RevId: 331027732 --- .../com/google/android/exoplayer2/ExoPlaybackException.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4f22bbbe286..93fb4b01186 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 @@ -218,7 +218,7 @@ public static ExoPlaybackException createForOutOfMemory(OutOfMemoryError cause) public static ExoPlaybackException createForTimeout( TimeoutException cause, @TimeoutOperation int timeoutOperation) { return new ExoPlaybackException( - TYPE_OUT_OF_MEMORY, + TYPE_TIMEOUT, cause, /* customMessage= */ null, /* rendererName= */ null, From be68d13ba56019395e32c934ae35618ccf6c6bac Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 11 Sep 2020 11:57:50 +0100 Subject: [PATCH 026/693] Throw RuntimeException instead of Error from ExoHostedTest Throwing Error forces a test to catch Throwable (e.g. DashWidevineOfflineTest#widevineOfflineReleasedV22), which will also catch AssertionError meaning the fail() call at the end of the try block won't work. The DashWidevineOfflineTest have been broken since https://github.com/google/ExoPlayer/commit/91185500a1242b99b86b18bc9f3449d3dac1fa01 PiperOrigin-RevId: 331120894 --- .../com/google/android/exoplayer2/testutil/ExoHostedTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5eececd88e1..fbdd9590aa8 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 @@ -157,7 +157,7 @@ public final boolean forceStop() { @Override public final void onFinished() { if (failOnPlayerError && playerError != null) { - throw new Error(playerError); + throw new RuntimeException(playerError); } logMetrics(audioDecoderCounters, videoDecoderCounters); if (expectedPlayingTimeMs != EXPECTED_PLAYING_TIME_UNSET) { From f7ff5d59a452eb7e2ff67be33a2a2bc88e90764a Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 11 Sep 2020 12:28:00 +0100 Subject: [PATCH 027/693] Make BatchBufferTest allocate less memory Setting to 2x BATCH_SIZE_BYTES PiperOrigin-RevId: 331124129 --- .../google/android/exoplayer2/mediacodec/BatchBufferTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d281b363caa..6579e8ee06e 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 @@ -33,7 +33,7 @@ public final class BatchBufferTest { /** Bigger than {@code BatchBuffer.BATCH_SIZE_BYTES} */ - private static final int BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES = 100 * 1000 * 1000; + private static final int BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES = 6 * 1000 * 1024; /** Smaller than {@code BatchBuffer.BATCH_SIZE_BYTES} */ private static final int BUFFER_SIZE_MUCH_SMALLER_THAN_BATCH_SIZE_BYTES = 100; From cdcb30ed216312bfafecc3d121a4dc56df8e079d Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 11 Sep 2020 15:59:20 +0100 Subject: [PATCH 028/693] Exclude Guava transitive annotation dependencies PiperOrigin-RevId: 331148067 --- demos/main/build.gradle | 8 +++++++- extensions/cronet/build.gradle | 8 +++++++- extensions/ima/build.gradle | 24 +++++++++++++++++++++--- extensions/media2/build.gradle | 8 +++++++- extensions/okhttp/build.gradle | 8 +++++++- extensions/workmanager/build.gradle | 8 +++++++- library/common/build.gradle | 8 +++++++- library/core/build.gradle | 24 +++++++++++++++++++++--- library/dash/build.gradle | 8 +++++++- library/extractor/build.gradle | 8 +++++++- library/hls/build.gradle | 8 +++++++- library/ui/build.gradle | 8 +++++++- 12 files changed, 112 insertions(+), 16 deletions(-) diff --git a/demos/main/build.gradle b/demos/main/build.gradle index 0c628be8799..3a3b7a4a454 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -70,7 +70,13 @@ dependencies { implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion implementation 'androidx.multidex:multidex:' + androidxMultidexVersion implementation 'com.google.android.material:material:1.2.0' - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-dash') implementation project(modulePrefix + 'library-hls') diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index ed8f4f59261..0dd1d42d724 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -17,7 +17,13 @@ dependencies { api "com.google.android.gms:play-services-cronet:17.0.0" implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'library') diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index ee7ceec6db6..f7b2b3f77c0 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -29,17 +29,35 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion 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.google.guava:guava:' + guavaVersion + androidTestImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } androidTestCompileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion testImplementation project(modulePrefix + 'testutils') - testImplementation 'com.google.guava:guava:' + guavaVersion + testImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/extensions/media2/build.gradle b/extensions/media2/build.gradle index 5c52cc2f332..cedb4cd55fd 100644 --- a/extensions/media2/build.gradle +++ b/extensions/media2/build.gradle @@ -19,7 +19,13 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation 'androidx.collection:collection:' + androidxCollectionVersion implementation 'androidx.concurrent:concurrent-futures:1.0.0' - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } api 'androidx.media2:media2-session:1.0.3' compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index 217bfa76cd7..f16e382aa1b 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -16,7 +16,13 @@ apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') diff --git a/extensions/workmanager/build.gradle b/extensions/workmanager/build.gradle index f30461d3795..8eab503d234 100644 --- a/extensions/workmanager/build.gradle +++ b/extensions/workmanager/build.gradle @@ -21,7 +21,13 @@ dependencies { // 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 + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion } diff --git a/library/common/build.gradle b/library/common/build.gradle index d81201ccc13..2888b7e24c5 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -17,7 +17,13 @@ android.buildTypes.debug.testCoverageEnabled true dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/library/core/build.gradle b/library/core/build.gradle index 6dc3dd647f5..ddeb734947c 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -37,19 +37,37 @@ dependencies { api project(modulePrefix + 'library-common') api project(modulePrefix + 'library-extractor') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version compileOnly 'org.checkerframework:checker-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 + androidTestImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } androidTestImplementation 'com.linkedin.dexmaker:dexmaker:' + dexmakerVersion androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:' + dexmakerVersion androidTestImplementation(project(modulePrefix + 'testutils')) { exclude module: modulePrefix.substring(1) + 'library-core' } - testImplementation 'com.google.guava:guava:' + guavaVersion + testImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion testImplementation project(modulePrefix + 'testutils') diff --git a/library/dash/build.gradle b/library/dash/build.gradle index 82e17607f93..e6cb20d9334 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -25,7 +25,13 @@ android { dependencies { implementation project(modulePrefix + 'library-core') - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/extractor/build.gradle b/library/extractor/build.gradle index ffc1ce141e3..82c2309c5ff 100644 --- a/library/extractor/build.gradle +++ b/library/extractor/build.gradle @@ -26,7 +26,13 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation project(modulePrefix + 'library-common') - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/hls/build.gradle b/library/hls/build.gradle index 80ef65117ba..df3b6d35866 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -25,7 +25,13 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/ui/build.gradle b/library/ui/build.gradle index 3825a15d927..f63e55b3b3d 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -20,7 +20,13 @@ dependencies { api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.recyclerview:recyclerview:' + androidxRecyclerViewVersion - implementation 'com.google.guava:guava:' + guavaVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') From 156166f57cb66fcaf4c6a30db3f4f028149ea501 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 11 Sep 2020 16:09:32 +0100 Subject: [PATCH 029/693] Fix handling of empty ad groups at non-integer cue points Issue: #7889 PiperOrigin-RevId: 331149688 --- RELEASENOTES.md | 2 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 38 ++++++++++--------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 31 ++++++++++++--- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5ab125e9b6d..0e5ec04ac13 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -325,6 +325,8 @@ * Add missing notification of `VideoAdPlayerCallback.onLoaded`. * Fix handling of incompatible VPAID ads ([#7832](https://github.com/google/ExoPlayer/issues/7832)). + * Fix handling of empty ads at non-integer cue points + ([#7889](https://github.com/google/ExoPlayer/issues/7889)). * Demo app: * Replace the `extensions` variant with `decoderExtensions` and update the demo app use the Cronet and IMA extensions by default. 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 a5539248b68..88b0daac493 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 @@ -1072,12 +1072,11 @@ private void handleAdEvent(AdEvent adEvent) { if (DEBUG) { Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); } - int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString); + double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString); int adGroupIndex = - adGroupTimeSeconds == -1 + adGroupTimeSeconds == -1.0 ? adPlaybackState.adGroupCount - 1 - : Util.linearSearch( - adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); + : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds); handleAdGroupFetchError(adGroupIndex); break; case CONTENT_PAUSE_REQUESTED: @@ -1514,20 +1513,8 @@ private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { return adPlaybackState.adGroupCount - 1; } - // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. 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++) { - long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex]; - if (adGroupTimeUs != C.TIME_END_OF_SOURCE - && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { - return adGroupIndex; - } - } - throw new IllegalStateException("Failed to find cue point"); + // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. + return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset()); } /** @@ -1547,6 +1534,21 @@ private int getLoadingAdGroupIndex() { return adGroupIndex; } + private int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) { + // We receive initial 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). + long adPodTimeUs = Math.round((float) cuePointTimeSeconds * C.MICROS_PER_SECOND); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex]; + if (adGroupTimeUs != C.TIME_END_OF_SOURCE + && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { + return adGroupIndex; + } + } + 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 : "") + "]"; 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 ee0ea41e47e..e32a1992006 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 @@ -23,6 +23,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -113,7 +114,6 @@ public final class ImaAdsLoaderTest { @Mock private ImaFactory mockImaFactory; @Mock private AdPodInfo mockAdPodInfo; @Mock private Ad mockPrerollSingleAd; - @Mock private AdEvent mockPostrollFetchErrorAdEvent; private ViewGroup adViewGroup; private AdsLoader.AdViewProvider adViewProvider; @@ -290,8 +290,33 @@ public void playback_withPrerollAd_marksAdAsPlayed() { .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } + @Test + public void playback_withMidrollFetchError_marksAdAsInErrorState() { + AdEvent mockMidrollFetchErrorAdEvent = mock(AdEvent.class); + when(mockMidrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockMidrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "20.5")); + setupPlayback(CONTENT_TIMELINE, ImmutableList.of(20.5f)); + + // Simulate loading an empty midroll ad. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + adEventListener.onAdEvent(mockMidrollFetchErrorAdEvent); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 20_500_000) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + @Test public void playback_withPostrollFetchError_marksAdAsInErrorState() { + AdEvent mockPostrollFetchErrorAdEvent = mock(AdEvent.class); + when(mockPostrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockPostrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "-1")); setupPlayback(CONTENT_TIMELINE, ImmutableList.of(-1f)); // Simulate loading an empty postroll ad. @@ -808,10 +833,6 @@ private void setupMocks() { 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) { From 99cdf2ca4dab320c7d0abe4b637397986ffca897 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 11 Sep 2020 16:48:09 +0100 Subject: [PATCH 030/693] MediaItemify the IMA extension README and the ads page in dev guide PiperOrigin-RevId: 331155539 --- extensions/ima/README.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/extensions/ima/README.md b/extensions/ima/README.md index 0a9bf1aa5ec..54a70bd5fbf 100644 --- a/extensions/ima/README.md +++ b/extensions/ima/README.md @@ -26,25 +26,30 @@ locally. Instructions for doing this can be found in ExoPlayer's ## Using the extension ## -To play ads alongside a single-window content `MediaSource`, prepare the player -with an `AdsMediaSource` constructed using an `ImaAdsLoader`, the content -`MediaSource` and an overlay `ViewGroup` on top of the player. Pass an ad tag -URI from your ad campaign when creating the `ImaAdsLoader`. The IMA -documentation includes some [sample ad tags][] for testing. Note that the IMA -extension only supports players which are accessed on the application's main +To play a media item with an ad tag URI, you need to customize the +`DefaultMediaSourceFactory` as +[documented in the Developer Guide](https://exoplayer.dev/media-sources.html#customizing-media-source-creation). +This way the player will build an `AdsMediaSource` and configure it with the +`ImaAdsLoader` and an `AdViewProvider` automatically. + +[Pass an ad tag URI](https://exoplayer.dev/media-items.html#ad-insertion) from +your ad campaign to the `MediaItem.Builder` when building your media item. The +IMA documentation includes some [sample ad tags][] for testing. Note that the +IMA extension only supports players which are accessed on the application's main thread. Resuming the player after entering the background requires some special handling when playing ads. The player and its media source are released on entering the background, and are recreated when the player returns to the foreground. When playing ads it is necessary to persist ad playback state while in the background -by keeping a reference to the `ImaAdsLoader`. Reuse it when resuming playback of -the same content/ads by passing it in when constructing the new -`AdsMediaSource`. It is also important to persist the player position when -entering the background by storing the value of `player.getContentPosition()`. -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. +by keeping a reference to the `ImaAdsLoader`. Reuse this instance when your +callback `AdsLoaderProvider.getAdsLoader(Uri adTagUri)` is called by the player +to get an `ImaAdsLoader` for the same content/ads to be resumed. It is also +important to persist the player position when entering the background by storing +the value of `player.getContentPosition()`. 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, which has test content in the "IMA sample ad tags" section of the sample chooser. The demo app's From bff7ac0dbebabe37bf6b4f86a233d20e8dcae419 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 11 Sep 2020 17:27:56 +0100 Subject: [PATCH 031/693] Clean up some lint warnings PiperOrigin-RevId: 331162350 --- demos/cast/build.gradle | 2 +- demos/main/build.gradle | 2 +- .../exoplayer2/demo/DownloadTracker.java | 90 ++++++++++--------- demos/main/src/main/res/values/strings.xml | 6 -- extensions/media2/build.gradle | 2 +- extensions/workmanager/build.gradle | 2 +- 6 files changed, 53 insertions(+), 51 deletions(-) diff --git a/demos/cast/build.gradle b/demos/cast/build.gradle index b26112e15a7..868e3c7b438 100644 --- a/demos/cast/build.gradle +++ b/demos/cast/build.gradle @@ -60,7 +60,7 @@ dependencies { 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' + implementation 'com.google.android.material:material:1.2.1' } apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' diff --git a/demos/main/build.gradle b/demos/main/build.gradle index 3a3b7a4a454..716b3c1f998 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -69,7 +69,7 @@ dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion implementation 'androidx.multidex:multidex:' + androidxMultidexVersion - implementation 'com.google.android.material:material:1.2.0' + implementation 'com.google.android.material:material:1.2.1' implementation ('com.google.guava:guava:' + guavaVersion) { exclude group: 'com.google.code.findbugs', module: 'jsr305' exclude group: 'org.checkerframework', module: 'checker-compat-qual' 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 b448dd40deb..07f4dd2f6ee 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 @@ -222,7 +222,11 @@ public void onPrepared(@NonNull DownloadHelper helper) { } widevineOfflineLicenseFetchTask = new WidevineOfflineLicenseFetchTask( - format, mediaItem.playbackProperties.drmConfiguration.licenseUri, this, helper); + format, + mediaItem.playbackProperties.drmConfiguration.licenseUri, + httpDataSourceFactory, + /* dialogHelper= */ this, + helper); widevineOfflineLicenseFetchTask.execute(); } @@ -271,6 +275,32 @@ public void onDismiss(DialogInterface dialogInterface) { // Internal methods. + /** + * Returns the first {@link Format} with a non-null {@link Format#drmInitData} found in the + * content's tracks, or null if none is found. + */ + @Nullable + private Format getFirstFormatWithDrmInitData(DownloadHelper helper) { + for (int periodIndex = 0; periodIndex < helper.getPeriodCount(); periodIndex++) { + MappedTrackInfo mappedTrackInfo = helper.getMappedTrackInfo(periodIndex); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + for (int trackGroupIndex = 0; trackGroupIndex < trackGroups.length; trackGroupIndex++) { + TrackGroup trackGroup = trackGroups.get(trackGroupIndex); + for (int formatIndex = 0; formatIndex < trackGroup.length; formatIndex++) { + Format format = trackGroup.getFormat(formatIndex); + if (format.drmInitData != null) { + return format; + } + } + } + } + } + return null; + } + private void onOfflineLicenseFetched(DownloadHelper helper, byte[] keySetId) { this.keySetId = keySetId; onDownloadPrepared(helper); @@ -309,6 +339,19 @@ private void onDownloadPrepared(DownloadHelper helper) { trackSelectionDialog.show(fragmentManager, /* tag= */ null); } + /** + * Returns whether any the {@link DrmInitData.SchemeData} contained in {@code drmInitData} has + * non-null {@link DrmInitData.SchemeData#data}. + */ + private boolean hasSchemaData(DrmInitData drmInitData) { + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + if (drmInitData.get(i).hasData()) { + return true; + } + } + return false; + } + private void startDownload() { startDownload(buildDownloadRequest()); } @@ -327,9 +370,11 @@ private DownloadRequest buildDownloadRequest() { /** Downloads a Widevine offline license in a background thread. */ @RequiresApi(18) - private final class WidevineOfflineLicenseFetchTask extends AsyncTask { + private static final class WidevineOfflineLicenseFetchTask extends AsyncTask { + private final Format format; private final Uri licenseUri; + private final HttpDataSource.Factory httpDataSourceFactory; private final StartDownloadDialogHelper dialogHelper; private final DownloadHelper downloadHelper; @@ -339,10 +384,12 @@ private final class WidevineOfflineLicenseFetchTask extends AsyncTaskPlayback failed - Unrecognized ABR algorithm - - Unrecognized stereo mode - DRM content not supported on API levels below 18 This device does not support the required DRM scheme - An unknown DRM error occurred - This device does not provide a decoder for %1$s This device does not provide a secure decoder for %1$s diff --git a/extensions/media2/build.gradle b/extensions/media2/build.gradle index cedb4cd55fd..744d79980b2 100644 --- a/extensions/media2/build.gradle +++ b/extensions/media2/build.gradle @@ -18,7 +18,7 @@ android.defaultConfig.minSdkVersion 19 dependencies { implementation project(modulePrefix + 'library-core') implementation 'androidx.collection:collection:' + androidxCollectionVersion - implementation 'androidx.concurrent:concurrent-futures:1.0.0' + implementation 'androidx.concurrent:concurrent-futures:1.1.0' implementation ('com.google.guava:guava:' + guavaVersion) { exclude group: 'com.google.code.findbugs', module: 'jsr305' exclude group: 'org.checkerframework', module: 'checker-compat-qual' diff --git a/extensions/workmanager/build.gradle b/extensions/workmanager/build.gradle index 8eab503d234..1882ebac81f 100644 --- a/extensions/workmanager/build.gradle +++ b/extensions/workmanager/build.gradle @@ -17,7 +17,7 @@ apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') - implementation 'androidx.work:work-runtime:2.3.4' + implementation 'androidx.work:work-runtime:2.4.0' // Guava & Gradle interact badly, and this prevents // "cannot access ListenableFuture" errors [internal b/157225611]. // More info: https://blog.gradle.org/guava From 91a491ea31703262f33cb7a0e26c2ad6e3dcab90 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 11 Sep 2020 21:27:43 +0100 Subject: [PATCH 032/693] Add getMediaItemCount() and getMediaItemAt(int) PiperOrigin-RevId: 331211708 --- RELEASENOTES.md | 2 ++ .../java/com/google/android/exoplayer2/BasePlayer.java | 10 ++++++++++ .../java/com/google/android/exoplayer2/Player.java | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0e5ec04ac13..d725efb591e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,8 @@ ### dev-v2 (not yet released) +* `Player`: + * add `getMediaItemCount()` and `getMediaItemAt(int)`. * Track selection: * Add option to specify multiple preferred audio or text languages. * Data sources: 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 893c512bd78..9d7af2dce61 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 @@ -185,6 +185,16 @@ public final MediaItem getCurrentMediaItem() { : timeline.getWindow(getCurrentWindowIndex(), window).mediaItem; } + @Override + public int getMediaItemCount() { + return getCurrentTimeline().getWindowCount(); + } + + @Override + public MediaItem getMediaItemAt(int index) { + return getCurrentTimeline().getWindow(index, window).mediaItem; + } + @Override @Nullable public final Object getCurrentManifest() { 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 9d9d9cdc2d5..7a52aae7387 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 @@ -1284,6 +1284,12 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { @Nullable MediaItem getCurrentMediaItem(); + /** Returns the number of {@link MediaItem media items} in the playlist. */ + int getMediaItemCount(); + + /** Returns the {@link MediaItem} at the given index. */ + MediaItem getMediaItemAt(int index); + /** * Returns the duration of the current content window or ad in milliseconds, or {@link * C#TIME_UNSET} if the duration is not known. From b3904faf307ca9d4f7ed1c4520dc4e645a3427e9 Mon Sep 17 00:00:00 2001 From: olly Date: Sat, 12 Sep 2020 00:07:08 +0100 Subject: [PATCH 033/693] Add playlist query methods to 2.12 PiperOrigin-RevId: 331242049 --- RELEASENOTES.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d725efb591e..f42fbb640f5 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,15 +2,13 @@ ### dev-v2 (not yet released) -* `Player`: - * add `getMediaItemCount()` and `getMediaItemAt(int)`. * Track selection: * Add option to specify multiple preferred audio or text languages. * Data sources: * Add support for `android.resource` URI scheme in `RawResourceDataSource` ([#7866](https://github.com/google/ExoPlayer/issues/7866)). -### 2.12.0 (not yet released - targeted for 2020-09-11) ### +### 2.12.0 (2020-09-11) ### * Core library: * `Player`: @@ -18,8 +16,9 @@ ([#6161](https://github.com/google/ExoPlayer/issues/6161)). The new methods for playlist manipulation are `setMediaItem(s)`, `addMediaItem(s)`, `moveMediaItem(s)`, `removeMediaItem(s)` and - `clearMediaItems`. This API should be used instead of - `ConcatenatingMediaSource` in most cases. + `clearMediaItems`. The playlist can be queried using + `getMediaItemCount` and `getMediaItemAt`. This API should be used + instead of `ConcatenatingMediaSource` in most cases. * Add `getCurrentMediaItem` for getting the currently playing item in the playlist. * Add `EventListener.onMediaItemTransition` to report when From 5a009ab4764fae3a71aeb65f1efe4ee9e3722df3 Mon Sep 17 00:00:00 2001 From: olly Date: Sat, 12 Sep 2020 19:08:22 +0100 Subject: [PATCH 034/693] Remove references to cross-protocol redirects for Cronet There's no option to enable them. This is probably a copy/paste error from DefaultHttpDataSourceFactory. PiperOrigin-RevId: 331334263 --- .../ext/cronet/CronetDataSourceFactory.java | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java index 4590936ea5b..85c9d09a791 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java @@ -58,8 +58,7 @@ public final class CronetDataSourceFactory extends BaseFactory { * fallback {@link HttpDataSource.Factory} will be used instead. * *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -87,8 +86,7 @@ public CronetDataSourceFactory( * DefaultHttpDataSourceFactory} will be used instead. * *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -104,8 +102,7 @@ public CronetDataSourceFactory(CronetEngineWrapper cronetEngineWrapper, Executor * DefaultHttpDataSourceFactory} will be used instead. * *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -201,8 +198,7 @@ public CronetDataSourceFactory( * fallback {@link HttpDataSource.Factory} will be used instead. * *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -232,8 +228,7 @@ public CronetDataSourceFactory( * DefaultHttpDataSourceFactory} will be used instead. * *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -253,8 +248,7 @@ public CronetDataSourceFactory( * DefaultHttpDataSourceFactory} will be used instead. * *

    Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. From b999977c9d024ac54d5b770185eaa72828cd7cec Mon Sep 17 00:00:00 2001 From: olly Date: Sat, 12 Sep 2020 22:57:39 +0100 Subject: [PATCH 035/693] Dev guide: Copy editing Also changed the links that describe configuring the player for ad insertion to link to the ads page. PiperOrigin-RevId: 331349846 --- extensions/ima/README.md | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/extensions/ima/README.md b/extensions/ima/README.md index 54a70bd5fbf..c67dfdbb5d5 100644 --- a/extensions/ima/README.md +++ b/extensions/ima/README.md @@ -26,39 +26,30 @@ locally. Instructions for doing this can be found in ExoPlayer's ## Using the extension ## -To play a media item with an ad tag URI, you need to customize the -`DefaultMediaSourceFactory` as -[documented in the Developer Guide](https://exoplayer.dev/media-sources.html#customizing-media-source-creation). -This way the player will build an `AdsMediaSource` and configure it with the -`ImaAdsLoader` and an `AdViewProvider` automatically. - -[Pass an ad tag URI](https://exoplayer.dev/media-items.html#ad-insertion) from -your ad campaign to the `MediaItem.Builder` when building your media item. The -IMA documentation includes some [sample ad tags][] for testing. Note that the -IMA extension only supports players which are accessed on the application's main +To use the extension, follow the instructions on the +[Ad insertion page](https://exoplayer.dev/ad-insertion.html#declarative-ad-support) +of the developer guide. The `AdsLoaderProvider` passed to the player's +`DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA +extension only supports players which are accessed on the application's main thread. Resuming the player after entering the background requires some special handling when playing ads. The player and its media source are released on entering the -background, and are recreated when the player returns to the foreground. When -playing ads it is necessary to persist ad playback state while in the background -by keeping a reference to the `ImaAdsLoader`. Reuse this instance when your -callback `AdsLoaderProvider.getAdsLoader(Uri adTagUri)` is called by the player -to get an `ImaAdsLoader` for the same content/ads to be resumed. It is also -important to persist the player position when entering the background by storing -the value of `player.getContentPosition()`. 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. +background, and are recreated when returning to the foreground. When playing ads +it is necessary to persist ad playback state while in the background by keeping +a reference to the `ImaAdsLoader`. When re-entering the foreground, pass the +same instance back when `AdsLoaderProvider.getAdsLoader(Uri adTagUri)` is called +to restore the state. It is also important to persist the player position when +entering the background by storing the value of `player.getContentPosition()`. +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 has finished and will not be resumed. 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 - ## Links ## * [ExoPlayer documentation on ad insertion][] From 12a29e3026b2efa289a44bfe08fd61b2d11b7a68 Mon Sep 17 00:00:00 2001 From: olly Date: Sun, 13 Sep 2020 00:01:14 +0100 Subject: [PATCH 036/693] Fix release notes PiperOrigin-RevId: 331354102 --- RELEASENOTES.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index f42fbb640f5..89eed1ecb15 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -158,8 +158,7 @@ * Add support for default [text](https://www.w3.org/TR/webvtt1/#default-text-color) and [background](https://www.w3.org/TR/webvtt1/#default-text-background) - colors ([PR #4178](https://github.com/google/ExoPlayer/pull/4178), - [issue #6581](https://github.com/google/ExoPlayer/issues/6581)). + colors ([#6581](https://github.com/google/ExoPlayer/issues/6581)). * Update position alignment parsing to recognise `line-left`, `center` and `line-right`. * Implement steps 4-10 of the @@ -217,7 +216,7 @@ ([#7308](https://github.com/google/ExoPlayer/issues/7308)). * Matroska: * Support Dolby Vision - ([#7267](https://github.com/google/ExoPlayer/issues/7267). + ([#7267](https://github.com/google/ExoPlayer/issues/7267)). * Populate `Format.label` with track titles. * Remove support for the `Invisible` block header flag. * MPEG-TS: Add support for MPEG-4 Part 2 and H.263 From 7c8779111d57e030edb51955f021679e72b93482 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 14 Sep 2020 15:03:55 +0100 Subject: [PATCH 037/693] Add release note entry for DRM-protected downloads PiperOrigin-RevId: 331539036 --- RELEASENOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 89eed1ecb15..fddeb6a5d77 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -342,6 +342,8 @@ * Remove support for media tunneling, random ABR and playback of spherical video. Developers wishing to experiment with these features can enable them by modifying the demo app source code. + * Add support for downloading DRM-protected content using offline + Widevine licenses. ### 2.11.8 (2020-08-25) ### From 6be879c21b263278b68ea56720a5052c33b055c7 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 14 Sep 2020 19:30:48 +0100 Subject: [PATCH 038/693] Clean up experimental offload Javadoc PiperOrigin-RevId: 331591005 --- .../java/com/google/android/exoplayer2/ExoPlayer.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 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 b5489186bc8..ccb67866a41 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 @@ -16,11 +16,13 @@ package com.google.android.exoplayer2; import android.content.Context; +import android.media.AudioTrack; 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.AudioSink; import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; @@ -622,14 +624,13 @@ public ExoPlayer build() { * the following: * *

      - *
    • audio offload rendering is enabled in {@link + *
    • 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#DefaultAudioSink(AudioCapabilities, * DefaultAudioSink.AudioProcessorChain, boolean, boolean, boolean)}. - *
    • an audio track is playing in a format which the device supports offloading (for example, + *
    • An audio track is playing in a format that 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}. + *
    • The {@link AudioSink} is playing with an offload {@link AudioTrack}. *
    * *

    This method is experimental, and will be renamed or removed in a future release. From f387574140181c5024778da21019ff3aa7c15143 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 15 Sep 2020 13:34:49 +0100 Subject: [PATCH 039/693] Fix OOM-is-prevented test OOM-ing :) This test is intended to check that DefaultLoadControl will cause playback to fail as "stuck buffering" rather than OOM-ing, in the case that its target buffer size is reached and playback still hasn't started. Unfortunately, the target buffer size is ~130MB, and when running on some setups an OOM actually ends up happening before this much memory is allocated. This change makes the target buffer size much smaller to avoid the problem. PiperOrigin-RevId: 331748208 --- .../java/com/google/android/exoplayer2/ExoPlayerTest.java | 5 +++++ 1 file changed, 5 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 b8f10a2dc2f..444640256f6 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 @@ -7330,6 +7330,10 @@ public void run(SimpleExoPlayer player) { @Test public void infiniteLoading_withSmallAllocations_oomIsPreventedByLoadControl_andThrowsStuckBufferingIllegalStateException() { + DefaultLoadControl loadControl = + new DefaultLoadControl.Builder() + .setTargetBufferBytes(10 * C.DEFAULT_BUFFER_SEGMENT_SIZE) + .build(); MediaSource continuouslyAllocatingMediaSource = new FakeMediaSource( new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) { @@ -7387,6 +7391,7 @@ public boolean continueLoading(long positionUs) { new ExoPlayerTestRunner.Builder(context) .setActionSchedule(actionSchedule) .setMediaSources(continuouslyAllocatingMediaSource) + .setLoadControl(loadControl) .build(); ExoPlaybackException exception = From 2f5d6a65414b211c0dd24f63eb7329f5d1b6deb8 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 15 Sep 2020 16:12:39 +0100 Subject: [PATCH 040/693] Add missing release note Issue: #7902 PiperOrigin-RevId: 331771187 --- RELEASENOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index fddeb6a5d77..9404a113429 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -262,6 +262,9 @@ to `DownloadManager.Listener.onDownloadChanged`. * Support multiple non-overlapping write locks for the same key in `SimpleCache`. + * Remove `CacheUtil`. Equivalent functionality is provided by a new + `CacheWriter` class, `Cache.getCachedBytes`, `Cache.removeResource` and + `CacheKeyFactory.DEFAULT`. * DRM: * Remove previously deprecated APIs to inject `DrmSessionManager` into `Renderer` instances. `DrmSessionManager` must now be injected into From fa2a77107bd221cfd50b31b08b6a98066dddb7db Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 15 Sep 2020 16:39:57 +0100 Subject: [PATCH 041/693] Depend on robolectric 4.4, which has now been released Issue: #7906 PiperOrigin-RevId: 331775990 --- constants.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constants.gradle b/constants.gradle index f6136769538..c2b00003680 100644 --- a/constants.gradle +++ b/constants.gradle @@ -24,7 +24,7 @@ project.ext { guavaVersion = '27.1-android' mockitoVersion = '2.28.2' mockWebServerVersion = '3.12.0' - robolectricVersion = '4.4-SNAPSHOT' + robolectricVersion = '4.4' checkerframeworkVersion = '3.3.0' checkerframeworkCompatVersion = '2.5.0' jsr305Version = '3.0.2' From 1bc99c2f031de29efa3dc79e769b0e5368416220 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 15 Sep 2020 19:08:50 +0100 Subject: [PATCH 042/693] Parse availabilityTimeOffset from DASH manifest. This value is needed to figure out the last available segment for low-latency live streaming. It may be present in each BaseURL tag and each SegmentList or SegmentTemplate, with the latter one taking precedence. The value is saved as part of MultiSegmentBase where it will be used to retrieve the last available segment index in future changes. PiperOrigin-RevId: 331809871 --- .../dash/manifest/DashManifestParser.java | 177 ++++++++++++++++-- .../source/dash/manifest/Representation.java | 3 +- .../source/dash/manifest/SegmentBase.java | 34 +++- .../dash/manifest/DashManifestParserTest.java | 100 ++++++++++ .../sample_mpd_availabilityTimeOffset_baseUrl | 40 ++++ ...ple_mpd_availabilityTimeOffset_segmentList | 46 +++++ ...mpd_availabilityTimeOffset_segmentTemplate | 46 +++++ 7 files changed, 421 insertions(+), 25 deletions(-) create mode 100644 testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_baseUrl create mode 100644 testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentList create mode 100644 testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentTemplate 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 e9e9c66df2b..ede5df90c48 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 @@ -40,11 +40,11 @@ import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.XmlPullParserUtil; import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableList; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.regex.Matcher; @@ -115,6 +115,7 @@ protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, ProgramInformation programInformation = null; UtcTimingElement utcTiming = null; Uri location = null; + long baseUrlAvailabilityTimeOffsetUs = dynamic ? 0 : C.TIME_UNSET; List periods = new ArrayList<>(); long nextPeriodStartMs = dynamic ? C.TIME_UNSET : 0; @@ -124,6 +125,8 @@ protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -134,7 +137,8 @@ protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, } else if (XmlPullParserUtil.isStartTag(xpp, "Location")) { location = Uri.parse(xpp.nextText()); } else if (XmlPullParserUtil.isStartTag(xpp, "Period") && !seenEarlyAccessPeriod) { - Pair periodWithDurationMs = parsePeriod(xpp, baseUrl, nextPeriodStartMs); + Pair periodWithDurationMs = + parsePeriod(xpp, baseUrl, nextPeriodStartMs, baseUrlAvailabilityTimeOffsetUs); Period period = periodWithDurationMs.first; if (period.startMs == C.TIME_UNSET) { if (dynamic) { @@ -221,7 +225,8 @@ protected UtcTimingElement buildUtcTimingElement(String schemeIdUri, String valu return new UtcTimingElement(schemeIdUri, value); } - protected Pair parsePeriod(XmlPullParser xpp, String baseUrl, long defaultStartMs) + protected Pair parsePeriod( + XmlPullParser xpp, String baseUrl, long defaultStartMs, long baseUrlAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { @Nullable String id = xpp.getAttributeValue(null, "id"); long startMs = parseDuration(xpp, "start", defaultStartMs); @@ -231,23 +236,50 @@ protected Pair parsePeriod(XmlPullParser xpp, String baseUrl, long List adaptationSets = new ArrayList<>(); List eventStreams = new ArrayList<>(); boolean seenFirstBaseUrl = false; + long segmentBaseAvailabilityTimeOffsetUs = C.TIME_UNSET; do { xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } } else if (XmlPullParserUtil.isStartTag(xpp, "AdaptationSet")) { - adaptationSets.add(parseAdaptationSet(xpp, baseUrl, segmentBase, durationMs)); + adaptationSets.add( + parseAdaptationSet( + xpp, + baseUrl, + segmentBase, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs)); } else if (XmlPullParserUtil.isStartTag(xpp, "EventStream")) { eventStreams.add(parseEventStream(xpp)); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { - segmentBase = parseSegmentBase(xpp, null); + segmentBase = parseSegmentBase(xpp, /* parent= */ null); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, null, durationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentList( + xpp, + /* parent= */ null, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { - segmentBase = parseSegmentTemplate(xpp, null, Collections.emptyList(), durationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentTemplate( + xpp, + /* parent= */ null, + ImmutableList.of(), + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "AssetIdentifier")) { assetIdentifier = parseDescriptor(xpp, "AssetIdentifier"); } else { @@ -271,7 +303,12 @@ protected Period buildPeriod( // AdaptationSet parsing. protected AdaptationSet parseAdaptationSet( - XmlPullParser xpp, String baseUrl, @Nullable SegmentBase segmentBase, long periodDurationMs) + XmlPullParser xpp, + String baseUrl, + @Nullable SegmentBase segmentBase, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { int id = parseInt(xpp, "id", AdaptationSet.ID_UNSET); int contentType = parseContentType(xpp); @@ -299,6 +336,8 @@ protected AdaptationSet parseAdaptationSet( xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -341,7 +380,9 @@ protected AdaptationSet parseAdaptationSet( essentialProperties, supplementalProperties, segmentBase, - periodDurationMs); + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); contentType = checkContentTypeConsistency( contentType, MimeTypes.getTrackType(representationInfo.format.sampleMimeType)); @@ -349,11 +390,26 @@ protected AdaptationSet parseAdaptationSet( } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase, periodDurationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); segmentBase = parseSegmentTemplate( - xpp, (SegmentTemplate) segmentBase, supplementalProperties, periodDurationMs); + xpp, + (SegmentTemplate) segmentBase, + supplementalProperties, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); } else if (XmlPullParserUtil.isStartTag(xpp, "Label")) { @@ -514,7 +570,9 @@ protected RepresentationInfo parseRepresentation( List adaptationSetEssentialProperties, List adaptationSetSupplementalProperties, @Nullable SegmentBase segmentBase, - long periodDurationMs) + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); @@ -538,6 +596,8 @@ protected RepresentationInfo parseRepresentation( xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -546,14 +606,26 @@ protected RepresentationInfo parseRepresentation( } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase, periodDurationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); segmentBase = parseSegmentTemplate( xpp, (SegmentTemplate) segmentBase, adaptationSetSupplementalProperties, - periodDurationMs); + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { Pair contentProtection = parseContentProtection(xpp); if (contentProtection.first != null) { @@ -718,7 +790,11 @@ protected SingleSegmentBase buildSingleSegmentBase(RangedUri initialization, lon } protected SegmentList parseSegmentList( - XmlPullParser xpp, @Nullable SegmentList parent, long periodDurationMs) + XmlPullParser xpp, + @Nullable SegmentList parent, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -726,6 +802,9 @@ protected SegmentList parseSegmentList( parent != null ? parent.presentationTimeOffset : 0); long duration = parseLong(xpp, "duration", parent != null ? parent.duration : C.TIME_UNSET); long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); RangedUri initialization = null; List timeline = null; @@ -753,8 +832,15 @@ protected SegmentList parseSegmentList( segments = segments != null ? segments : parent.mediaSegments; } - return buildSegmentList(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, segments); + return buildSegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments); } protected SegmentList buildSegmentList( @@ -764,16 +850,26 @@ protected SegmentList buildSegmentList( long startNumber, long duration, @Nullable List timeline, + long availabilityTimeOffsetUs, @Nullable List segments) { - return new SegmentList(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, segments); + return new SegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments); } protected SegmentTemplate parseSegmentTemplate( XmlPullParser xpp, @Nullable SegmentTemplate parent, List adaptationSetSupplementalProperties, - long periodDurationMs) + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", @@ -782,6 +878,9 @@ protected SegmentTemplate parseSegmentTemplate( long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); long endNumber = parseLastSegmentNumberSupplementalProperty(adaptationSetSupplementalProperties); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); UrlTemplate mediaTemplate = parseUrlTemplate(xpp, "media", parent != null ? parent.mediaTemplate : null); @@ -815,6 +914,7 @@ protected SegmentTemplate parseSegmentTemplate( endNumber, duration, timeline, + availabilityTimeOffsetUs, initializationTemplate, mediaTemplate); } @@ -827,6 +927,7 @@ protected SegmentTemplate buildSegmentTemplate( long endNumber, long duration, List timeline, + long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, @Nullable UrlTemplate mediaTemplate) { return new SegmentTemplate( @@ -837,6 +938,7 @@ protected SegmentTemplate buildSegmentTemplate( endNumber, duration, timeline, + availabilityTimeOffsetUs, initializationTemplate, mediaTemplate); } @@ -1151,6 +1253,27 @@ protected String parseBaseUrl(XmlPullParser xpp, String parentBaseUrl) return UriUtil.resolve(parentBaseUrl, parseText(xpp, "BaseURL")); } + /** + * Parses the availabilityTimeOffset value and returns the parsed value or the parent value if it + * doesn't exist. + * + * @param xpp The parser from which to read. + * @param parentAvailabilityTimeOffsetUs The availability time offset of a parent element in + * microseconds. + * @return The parsed availabilityTimeOffset in microseconds. + */ + protected long parseAvailabilityTimeOffsetUs( + XmlPullParser xpp, long parentAvailabilityTimeOffsetUs) { + String value = xpp.getAttributeValue(/* namespace= */ null, "availabilityTimeOffset"); + if (value == null) { + return parentAvailabilityTimeOffsetUs; + } + if ("INF".equals(value)) { + return Long.MAX_VALUE; + } + return (long) (Float.parseFloat(value) * C.MICROS_PER_SECOND); + } + // AudioChannelConfiguration parsing. protected int parseAudioChannelConfiguration(XmlPullParser xpp) @@ -1569,6 +1692,20 @@ protected static long parseLastSegmentNumberSupplementalProperty( return C.INDEX_UNSET; } + private static long getFinalAvailabilityTimeOffset( + long baseUrlAvailabilityTimeOffsetUs, long segmentBaseAvailabilityTimeOffsetUs) { + long availabilityTimeOffsetUs = segmentBaseAvailabilityTimeOffsetUs; + if (availabilityTimeOffsetUs == C.TIME_UNSET) { + // Fall back to BaseURL values if no SegmentBase specifies an offset. + availabilityTimeOffsetUs = baseUrlAvailabilityTimeOffsetUs; + } + if (availabilityTimeOffsetUs == Long.MAX_VALUE) { + // Replace INF value with TIME_UNSET to specify that all segments are available immediately. + availabilityTimeOffsetUs = C.TIME_UNSET; + } + return availabilityTimeOffsetUs; + } + /** A parsed Representation element. */ protected static final class RepresentationInfo { diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index 80ad15cd8f5..03151631d3b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -17,6 +17,7 @@ import android.net.Uri; 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.source.dash.DashSegmentIndex; @@ -275,7 +276,7 @@ public String getCacheKey() { public static class MultiSegmentRepresentation extends Representation implements DashSegmentIndex { - private final MultiSegmentBase segmentBase; + @VisibleForTesting /* package */ final MultiSegmentBase segmentBase; /** * @param revisionId Identifies the revision of the content. diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java index d6206c1c0d3..5de2814b297 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java @@ -120,6 +120,15 @@ public abstract static class MultiSegmentBase extends SegmentBase { /* package */ final long duration; @Nullable /* package */ final List segmentTimeline; + /** + * Offset to the current realtime at which segments become available, in microseconds, or {@link + * C#TIME_UNSET} if all segments are available immediately. + * + *

    Segments will be available once their end time ≤ currentRealTime + + * availabilityTimeOffset. + */ + /* package */ final long availabilityTimeOffsetUs; + /** * @param initialization A {@link RangedUri} corresponding to initialization data, if such data * exists. @@ -133,6 +142,8 @@ public abstract static class MultiSegmentBase extends SegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. */ public MultiSegmentBase( @Nullable RangedUri initialization, @@ -140,11 +151,13 @@ public MultiSegmentBase( long presentationTimeOffset, long startNumber, long duration, - @Nullable List segmentTimeline) { + @Nullable List segmentTimeline, + long availabilityTimeOffsetUs) { super(initialization, timescale, presentationTimeOffset); this.startNumber = startNumber; this.duration = duration; this.segmentTimeline = segmentTimeline; + this.availabilityTimeOffsetUs = availabilityTimeOffsetUs; } /** @see DashSegmentIndex#getSegmentNum(long, long) */ @@ -255,6 +268,8 @@ public static final class SegmentList extends MultiSegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. * @param mediaSegments A list of {@link RangedUri}s indicating the locations of the segments. */ public SegmentList( @@ -264,9 +279,16 @@ public SegmentList( long startNumber, long duration, @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, @Nullable List mediaSegments) { - super(initialization, timescale, presentationTimeOffset, startNumber, duration, - segmentTimeline); + super( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + segmentTimeline, + availabilityTimeOffsetUs); this.mediaSegments = mediaSegments; } @@ -311,6 +333,8 @@ public static final class SegmentTemplate extends MultiSegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. * @param initializationTemplate A template defining the location of initialization data, if * such data exists. If non-null then the {@code initialization} parameter is ignored. If * null then {@code initialization} will be used. @@ -324,6 +348,7 @@ public SegmentTemplate( long endNumber, long duration, @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, @Nullable UrlTemplate mediaTemplate) { super( @@ -332,7 +357,8 @@ public SegmentTemplate( presentationTimeOffset, startNumber, duration, - segmentTimeline); + segmentTimeline, + availabilityTimeOffsetUs); this.initializationTemplate = initializationTemplate; this.mediaTemplate = mediaTemplate; this.endNumber = endNumber; 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 c2ea12bcd75..496dd9575d5 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 @@ -51,6 +51,12 @@ public class DashManifestParserTest { private static final String SAMPLE_MPD_ASSET_IDENTIFIER = "media/mpd/sample_mpd_asset_identifier"; private static final String SAMPLE_MPD_TEXT = "media/mpd/sample_mpd_text"; private static final String SAMPLE_MPD_TRICK_PLAY = "media/mpd/sample_mpd_trick_play"; + private static final String SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_BASE_URL = + "media/mpd/sample_mpd_availabilityTimeOffset_baseUrl"; + private static final String SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_TEMPLATE = + "media/mpd/sample_mpd_availabilityTimeOffset_segmentTemplate"; + private static final String SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_LIST = + "media/mpd/sample_mpd_availabilityTimeOffset_segmentList"; private static final String NEXT_TAG_NAME = "Next"; private static final String NEXT_TAG = "<" + NEXT_TAG_NAME + "/>"; @@ -469,6 +475,91 @@ public void parsePeriodAssetIdentifier() throws IOException { assertThat(assetIdentifier.id).isEqualTo("uniqueId"); } + @Test + public void availabilityTimeOffset_staticManifest_setToTimeUnset() throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), SAMPLE_MPD_TEXT)); + + assertThat(manifest.getPeriodCount()).isEqualTo(1); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + assertThat(adaptationSets).hasSize(3); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(0))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(1))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(2))).isEqualTo(C.TIME_UNSET); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInBaseUrl_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_BASE_URL)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(5_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(4_321_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(9_876_543); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(0); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInSegmentTemplate_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_TEMPLATE)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(2_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(3_210_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(1_230_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(100_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(9_999_000); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInSegmentList_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_LIST)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(2_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(3_210_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(1_230_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(100_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(9_999_000); + } + private static List buildCea608AccessibilityDescriptors(String value) { return Collections.singletonList(new Descriptor("urn:scte:dash:cc:cea-608:2015", value, null)); } @@ -482,4 +573,13 @@ private static void assertNextTag(XmlPullParser xpp) throws Exception { assertThat(xpp.getEventType()).isEqualTo(XmlPullParser.START_TAG); assertThat(xpp.getName()).isEqualTo(NEXT_TAG_NAME); } + + private static long getAvailabilityTimeOffsetUs(AdaptationSet adaptationSet) { + assertThat(adaptationSet.representations).isNotEmpty(); + Representation representation = adaptationSet.representations.get(0); + assertThat(representation).isInstanceOf(Representation.MultiSegmentRepresentation.class); + SegmentBase.MultiSegmentBase segmentBase = + ((Representation.MultiSegmentRepresentation) representation).segmentBase; + return segmentBase.availabilityTimeOffsetUs; + } } diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_baseUrl b/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_baseUrl new file mode 100644 index 00000000000..188b2778a05 --- /dev/null +++ b/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_baseUrl @@ -0,0 +1,40 @@ + + + + http://video.com/baseUrl + + + + + + http://video.com/baseUrl + + + + + + + http://video.com/baseUrl + + + + + http://video.com/baseUrl + + http://video.com/baseUrl + + + + + + + + + + + diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentList b/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentList new file mode 100644 index 00000000000..364756a4aac --- /dev/null +++ b/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentList @@ -0,0 +1,46 @@ + + + http://video.com/baseUrl + + http://video.com/baseUrl + + + + http://video.com/baseUrl + + + + + http://video.com/baseUrl + + + + + + + http://video.com/baseUrl + + + + + http://video.com/baseUrl + + + http://video.com/baseUrl + + + + + + + + + + + + diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentTemplate b/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentTemplate new file mode 100644 index 00000000000..3c1dc78ae12 --- /dev/null +++ b/testdata/src/test/assets/media/mpd/sample_mpd_availabilityTimeOffset_segmentTemplate @@ -0,0 +1,46 @@ + + + http://video.com/baseUrl + + http://video.com/baseUrl + + + + http://video.com/baseUrl + + + + + http://video.com/baseUrl + + + + + + + http://video.com/baseUrl + + + + + http://video.com/baseUrl + + + http://video.com/baseUrl + + + + + + + + + + + + From 7b5b8b2d797d2151987e68d95f155d0642a8d15b Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 16 Sep 2020 09:35:07 +0100 Subject: [PATCH 043/693] Fix bug in offline DRM downloads PiperOrigin-RevId: 331955966 --- .../android/exoplayer2/demo/PlayerActivity.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 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 49fe440101e..eae302887e0 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 @@ -543,7 +543,19 @@ private static List createMediaItems(Intent intent, DownloadTracker d @Nullable DownloadRequest downloadRequest = downloadTracker.getDownloadRequest(checkNotNull(item.playbackProperties).uri); - mediaItems.add(downloadRequest != null ? downloadRequest.toMediaItem() : item); + if (downloadRequest != null) { + MediaItem.Builder builder = item.buildUpon(); + builder + .setMediaId(downloadRequest.id) + .setUri(downloadRequest.uri) + .setCustomCacheKey(downloadRequest.customCacheKey) + .setMimeType(downloadRequest.mimeType) + .setStreamKeys(downloadRequest.streamKeys) + .setDrmKeySetId(downloadRequest.keySetId); + mediaItems.add(builder.build()); + } else { + mediaItems.add(item); + } } return mediaItems; } From b866c5e424152209c93f0da44b0e24a60c2e4717 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 16 Sep 2020 16:33:09 +0100 Subject: [PATCH 044/693] Fix the FFmpeg extension build Since gradle 4.0, CMake imported libraries are bundled in the APK, so manually bundling them causes a duplication which breaks the build. Issue: #7906 PiperOrigin-RevId: 332012375 --- extensions/ffmpeg/build.gradle | 7 ------- extensions/ffmpeg/src/main/jni/CMakeLists.txt | 2 -- 2 files changed, 9 deletions(-) diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle index bf715b77609..a9edeaff6bd 100644 --- a/extensions/ffmpeg/build.gradle +++ b/extensions/ffmpeg/build.gradle @@ -13,13 +13,6 @@ // limitations under the License. apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - sourceSets.main { - // The directory from which to pick the ffmpeg binaries. - jniLibs.srcDir 'src/main/libs' - } -} - // Configure the native build only if ffmpeg is present to avoid gradle sync // failures if ffmpeg hasn't been built according to the README instructions. if (project.file('src/main/jni/ffmpeg').exists()) { diff --git a/extensions/ffmpeg/src/main/jni/CMakeLists.txt b/extensions/ffmpeg/src/main/jni/CMakeLists.txt index 4f16509721c..b60af4fa18a 100644 --- a/extensions/ffmpeg/src/main/jni/CMakeLists.txt +++ b/extensions/ffmpeg/src/main/jni/CMakeLists.txt @@ -7,7 +7,6 @@ project(libffmpeg_jni C CXX) set(ffmpeg_location "${CMAKE_CURRENT_SOURCE_DIR}/ffmpeg") set(ffmpeg_binaries "${ffmpeg_location}/android-libs/${ANDROID_ABI}") -set(ffmpeg_output_dir "${CMAKE_CURRENT_SOURCE_DIR}/../libs/${ANDROID_ABI}") foreach(ffmpeg_lib avutil swresample avcodec) set(ffmpeg_lib_filename lib${ffmpeg_lib}.so) @@ -20,7 +19,6 @@ foreach(ffmpeg_lib avutil swresample avcodec) ${ffmpeg_lib} PROPERTIES IMPORTED_LOCATION ${ffmpeg_lib_file_path}) - file(COPY ${ffmpeg_lib_file_path} DESTINATION ${ffmpeg_output_dir}) endforeach() include_directories(${ffmpeg_location}) From a3cd36beb14cec70531fe9cbdedf4e59ba01f399 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 16 Sep 2020 16:35:26 +0100 Subject: [PATCH 045/693] Update the FFmpeg extension readme to use symlinking PiperOrigin-RevId: 332012857 --- extensions/ffmpeg/README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md index ed5d2aba324..f3001427b8a 100644 --- a/extensions/ffmpeg/README.md +++ b/extensions/ffmpeg/README.md @@ -41,26 +41,29 @@ NDK_PATH="" HOST_PLATFORM="linux-x86_64" ``` -* Fetch FFmpeg: +* Fetch FFmpeg and checkout an appropriate branch. We cannot guarantee + compatibility with all versions of FFmpeg. We currently recommend version 4.2: ``` -cd "${FFMPEG_EXT_PATH}/jni" && \ -git clone git://source.ffmpeg.org/ffmpeg ffmpeg +cd "" && \ +git clone git://source.ffmpeg.org/ffmpeg && \ +cd ffmpeg && \ +git checkout release/4.2 && \ +FFMPEG_PATH="$(pwd)" ``` -* Checkout an appropriate branch of FFmpeg. We cannot guarantee compatibility - with all versions of FFmpeg. We currently recommend version 4.2: +* Configure the decoders to include. See the [Supported formats][] page for + details of the available decoders, and which formats they support. ``` -cd "${FFMPEG_EXT_PATH}/jni/ffmpeg" && \ -git checkout release/4.2 +ENABLED_DECODERS=(vorbis opus flac) ``` -* Configure the decoders to include. See the [Supported formats][] page for - details of the available decoders, and which formats they support. +* Add a link to the FFmpeg source code in the FFmpeg extension `jni` directory. ``` -ENABLED_DECODERS=(vorbis opus flac) +cd "${FFMPEG_EXT_PATH}/jni" && \ +ln -s "$FFMPEG_PATH" ffmpeg ``` * Execute `build_ffmpeg.sh` to build FFmpeg for `armeabi-v7a`, `arm64-v8a`, From f16f8e59ff947731f0d2e0e7494f869f795ae2a3 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 16 Sep 2020 16:43:45 +0100 Subject: [PATCH 046/693] Replace duration strings with plurals PiperOrigin-RevId: 332014290 --- .../exoplayer2/ui/StyledPlayerControlView.java | 13 ++++++++----- library/ui/src/main/res/values/strings.xml | 10 ++++++++-- library/ui/src/main/res/values/styles.xml | 2 -- 3 files changed, 16 insertions(+), 9 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 97652ad01f7..8bb9babeb0b 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 @@ -1159,13 +1159,14 @@ private void updateRewindButton() { if (controlDispatcher instanceof DefaultControlDispatcher) { rewindMs = ((DefaultControlDispatcher) controlDispatcher).getRewindIncrementMs(); } - long rewindSec = rewindMs / 1_000; + int rewindSec = (int) (rewindMs / 1_000); if (rewindButtonTextView != null) { rewindButtonTextView.setText(String.valueOf(rewindSec)); } if (rewindButton != null) { rewindButton.setContentDescription( - resources.getString(R.string.exo_controls_rewind_by_amount_description, rewindSec)); + resources.getQuantityString( + R.plurals.exo_controls_rewind_by_amount_description, rewindSec, rewindSec)); } } @@ -1173,14 +1174,16 @@ private void updateFastForwardButton() { if (controlDispatcher instanceof DefaultControlDispatcher) { fastForwardMs = ((DefaultControlDispatcher) controlDispatcher).getFastForwardIncrementMs(); } - long fastForwardSec = fastForwardMs / 1_000; + int fastForwardSec = (int) (fastForwardMs / 1_000); if (fastForwardButtonTextView != null) { fastForwardButtonTextView.setText(String.valueOf(fastForwardSec)); } if (fastForwardButton != null) { fastForwardButton.setContentDescription( - resources.getString( - R.string.exo_controls_fastforward_by_amount_description, fastForwardSec)); + resources.getQuantityString( + R.plurals.exo_controls_fastforward_by_amount_description, + fastForwardSec, + fastForwardSec)); } } diff --git a/library/ui/src/main/res/values/strings.xml b/library/ui/src/main/res/values/strings.xml index a65d81e2b12..a11d04073f6 100644 --- a/library/ui/src/main/res/values/strings.xml +++ b/library/ui/src/main/res/values/strings.xml @@ -43,11 +43,17 @@ Rewind - Rewind %d seconds + + Rewind %d second + Rewind %d seconds + Fast forward - Fast forward %d seconds + + Fast forward %d second + Fast forward %d seconds + Repeat none diff --git a/library/ui/src/main/res/values/styles.xml b/library/ui/src/main/res/values/styles.xml index 76ea27ef5a9..f903441698a 100644 --- a/library/ui/src/main/res/values/styles.xml +++ b/library/ui/src/main/res/values/styles.xml @@ -90,14 +90,12 @@ - From f2c51560c21bdd757c30678223345fa8f59fb82b Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 22 Sep 2020 09:43:23 +0100 Subject: [PATCH 061/693] Don't assume FakeSampleStream is ended without end of stream signal PiperOrigin-RevId: 333029935 --- .../android/exoplayer2/testutil/FakeSampleStream.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 7d63e129dbc..4f4aee96754 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 @@ -190,7 +190,12 @@ public void seekTo(long timeUs) { } } sampleItemIndex = fakeSampleStreamItems.size(); - readEOSBuffer = true; + if (!fakeSampleStreamItems.isEmpty()) { + FakeSampleStreamItem lastItem = Iterables.getLast(fakeSampleStreamItems); + readEOSBuffer = + lastItem.sampleInfo != null + && ((lastItem.sampleInfo.flags & C.BUFFER_FLAG_END_OF_STREAM) != 0); + } } /** From ebfeb2f77af9259987eaafd78e7f8f58335cb467 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 22 Sep 2020 09:56:14 +0100 Subject: [PATCH 062/693] Guava-ify https://github.com/google/ExoPlayer/commit/f2c51560c21bdd757c30678223345fa8f59fb82b PiperOrigin-RevId: 333031301 --- .../exoplayer2/testutil/FakeSampleStream.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 4f4aee96754..eaa2fb52bb0 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 @@ -190,12 +190,13 @@ public void seekTo(long timeUs) { } } sampleItemIndex = fakeSampleStreamItems.size(); - if (!fakeSampleStreamItems.isEmpty()) { - FakeSampleStreamItem lastItem = Iterables.getLast(fakeSampleStreamItems); - readEOSBuffer = - lastItem.sampleInfo != null - && ((lastItem.sampleInfo.flags & C.BUFFER_FLAG_END_OF_STREAM) != 0); - } + @Nullable + FakeSampleStreamItem lastItem = + Iterables.getLast(fakeSampleStreamItems, /* defaultValue= */ null); + readEOSBuffer = + lastItem != null + && lastItem.sampleInfo != null + && ((lastItem.sampleInfo.flags & C.BUFFER_FLAG_END_OF_STREAM) != 0); } /** From 32194def672e0d2458984edca9c7c5839910bc87 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 22 Sep 2020 09:57:16 +0100 Subject: [PATCH 063/693] Exclude PC devices from H.265 GTS tests PiperOrigin-RevId: 333031399 --- .../playbacktests/gts/DashStreamingTest.java | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java index a2f557ca0de..c3e82ec33da 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashStreamingTest.java @@ -18,6 +18,7 @@ import static com.google.android.exoplayer2.playbacktests.gts.GtsTestUtil.shouldSkipWidevineTest; import static com.google.common.truth.Truth.assertThat; +import android.content.pm.PackageManager; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.rule.ActivityTestRule; import com.google.android.exoplayer2.ExoPlayer; @@ -168,7 +169,7 @@ public void h264AdaptiveWithRendererDisabling() throws Exception { @Test public void h265FixedV23() throws Exception { - if (Util.SDK_INT < 23) { + if (Util.SDK_INT < 23 || isPc()) { // Pass. return; } @@ -183,7 +184,7 @@ public void h265FixedV23() throws Exception { @Test public void h265AdaptiveV24() throws Exception { - if (Util.SDK_INT < 24) { + if (Util.SDK_INT < 24 || isPc()) { // Pass. return; } @@ -199,7 +200,7 @@ public void h265AdaptiveV24() throws Exception { @Test public void h265AdaptiveWithSeekingV24() throws Exception { - if (Util.SDK_INT < 24) { + if (Util.SDK_INT < 24 || isPc()) { // Pass. return; } @@ -216,7 +217,7 @@ public void h265AdaptiveWithSeekingV24() throws Exception { @Test public void h265AdaptiveWithRendererDisablingV24() throws Exception { - if (Util.SDK_INT < 24) { + if (Util.SDK_INT < 24 || isPc()) { // Pass. return; } @@ -435,7 +436,7 @@ public void widevineH264AdaptiveWithRendererDisablingV18() throws Exception { @Test public void widevineH265FixedV23() throws Exception { - if (Util.SDK_INT < 23 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity())) { + if (Util.SDK_INT < 23 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity()) || isPc()) { // Pass. return; } @@ -452,7 +453,7 @@ public void widevineH265FixedV23() throws Exception { @Test public void widevineH265AdaptiveV24() throws Exception { - if (Util.SDK_INT < 24 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity())) { + if (Util.SDK_INT < 24 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity()) || isPc()) { // Pass. return; } @@ -469,7 +470,7 @@ public void widevineH265AdaptiveV24() throws Exception { @Test public void widevineH265AdaptiveWithSeekingV24() throws Exception { - if (Util.SDK_INT < 24 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity())) { + if (Util.SDK_INT < 24 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity()) || isPc()) { // Pass. return; } @@ -487,7 +488,7 @@ public void widevineH265AdaptiveWithSeekingV24() throws Exception { @Test public void widevineH265AdaptiveWithRendererDisablingV24() throws Exception { - if (Util.SDK_INT < 24 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity())) { + if (Util.SDK_INT < 24 || GtsTestUtil.shouldSkipWidevineTest(testRule.getActivity()) || isPc()) { // Pass. return; } @@ -644,7 +645,7 @@ public void decoderInfoH264() throws Exception { @Test public void decoderInfoH265V24() throws Exception { - if (Util.SDK_INT < 24) { + if (Util.SDK_INT < 24 || isPc()) { // Pass. return; } @@ -670,6 +671,11 @@ public void decoderInfoVP9V24() throws Exception { // Internal. + private boolean isPc() { + // See [internal b/162990153]. + return testRule.getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_PC); + } + private static boolean shouldSkipAdaptiveTest(String mimeType) throws DecoderQueryException { MediaCodecInfo decoderInfo = MediaCodecUtil.getDecoderInfo(mimeType, /* secure= */ false, /* tunneling= */ false); From 12e887438b827313aa11f576f4de9f822a0a88eb Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 22 Sep 2020 11:38:05 +0100 Subject: [PATCH 064/693] Add available segment logic to SegmentIndex This allows to use the same logic from multiple places without duplicating it, encapsulates in its logical place, and allows to change the available segments based on the new availabilityTimeOffset value. The overall effect of this change is a no-op. PiperOrigin-RevId: 333044186 --- .../source/dash/DashSegmentIndex.java | 44 ++++++---- .../source/dash/DashWrappingSegmentIndex.java | 10 +++ .../source/dash/DefaultDashChunkSource.java | 49 +++-------- .../dash/manifest/DashManifestParser.java | 37 +++++++-- .../source/dash/manifest/Representation.java | 83 +++++++++++++++++-- .../dash/manifest/SingleSegmentIndex.java | 10 +++ 6 files changed, 171 insertions(+), 62 deletions(-) diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java index 9d45bc726ee..3f95d8c5a14 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java @@ -64,38 +64,54 @@ public interface DashSegmentIndex { */ RangedUri getSegmentUrl(long segmentNum); + /** Returns the segment number of the first defined segment in the index. */ + long getFirstSegmentNum(); + /** - * Returns the segment number of the first segment. + * Returns the segment number of the first available segment in the index. * - * @return The segment number of the first segment. + * @param periodDurationUs The duration of the enclosing period in microseconds, or {@link + * C#TIME_UNSET} if the period's duration is not yet known. + * @param nowUnixTimeUs The current time in milliseconds since the Unix epoch. + * @return The number of the first available segment. */ - long getFirstSegmentNum(); + long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs); /** - * Returns the number of segments in the index, or {@link #INDEX_UNBOUNDED}. - *

    - * An unbounded index occurs if a dynamic manifest uses SegmentTemplate elements without a + * Returns the number of segments defined in the index, or {@link #INDEX_UNBOUNDED}. + * + *

    An unbounded index occurs if a dynamic manifest uses SegmentTemplate elements without a * SegmentTimeline element, and if the period duration is not yet known. In this case the caller - * must manually determine the window of currently available segments. + * can query the available segment using {@link #getFirstAvailableSegmentNum(long, long)} and + * {@link #getAvailableSegmentCount(long, long)}. * - * @param periodDurationUs The duration of the enclosing period in microseconds, or - * {@link C#TIME_UNSET} if the period's duration is not yet known. + * @param periodDurationUs The duration of the enclosing period in microseconds, or {@link + * C#TIME_UNSET} if the period's duration is not yet known. * @return The number of segments in the index, or {@link #INDEX_UNBOUNDED}. */ int getSegmentCount(long periodDurationUs); + /** + * Returns the number of available segments in the index. + * + * @param periodDurationUs The duration of the enclosing period in microseconds, or {@link + * C#TIME_UNSET} if the period's duration is not yet known. + * @param nowUnixTimeUs The current time in milliseconds since the Unix epoch. + * @return The number of available segments in the index. + */ + int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs); + /** * Returns true if segments are defined explicitly by the index. - *

    - * If true is returned, each segment is defined explicitly by the index data, and all of the + * + *

    If true is returned, each segment is defined explicitly by the index data, and all of the * listed segments are guaranteed to be available at the time when the index was obtained. - *

    - * If false is returned then segment information was derived from properties such as a fixed + * + *

    If false is returned then segment information was derived from properties such as a fixed * segment duration. If the presentation is dynamic, it's possible that only a subset of the * segments are available. * * @return Whether segments are defined explicitly by the index. */ boolean isExplicit(); - } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java index 3eca7892c45..723fb747395 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java @@ -41,11 +41,21 @@ public long getFirstSegmentNum() { return 0; } + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + return 0; + } + @Override public int getSegmentCount(long periodDurationUs) { return chunkIndex.length; } + @Override + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + return chunkIndex.length; + } + @Override public long getTimeUs(long segmentNum) { return chunkIndex.timesUs[(int) segmentNum] - timeOffsetUs; 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 01e51c3f6ce..d21d15bea5f 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 @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.source.dash; -import static java.lang.Math.max; import static java.lang.Math.min; import android.net.Uri; @@ -288,9 +287,9 @@ public void getNextChunk( chunkIterators[i] = MediaChunkIterator.EMPTY; } else { long firstAvailableSegmentNum = - representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); + representationHolder.getFirstAvailableSegmentNum(nowUnixTimeUs); long lastAvailableSegmentNum = - representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); + representationHolder.getLastAvailableSegmentNum(nowUnixTimeUs); long segmentNum = getSegmentNum( representationHolder, @@ -342,10 +341,8 @@ public void getNextChunk( return; } - long firstAvailableSegmentNum = - representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); - long lastAvailableSegmentNum = - representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); + long firstAvailableSegmentNum = representationHolder.getFirstAvailableSegmentNum(nowUnixTimeUs); + long lastAvailableSegmentNum = representationHolder.getLastAvailableSegmentNum(nowUnixTimeUs); updateLiveEdgeTimeUs(representationHolder, lastAvailableSegmentNum); @@ -739,6 +736,11 @@ public long getFirstSegmentNum() { return segmentIndex.getFirstSegmentNum() + segmentNumShift; } + public long getFirstAvailableSegmentNum(long nowUnixTimeUs) { + return segmentIndex.getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs) + + segmentNumShift; + } + public int getSegmentCount() { return segmentIndex.getSegmentCount(periodDurationUs); } @@ -760,35 +762,10 @@ public RangedUri getSegmentUrl(long segmentNum) { return segmentIndex.getSegmentUrl(segmentNum - segmentNumShift); } - public long getFirstAvailableSegmentNum( - DashManifest manifest, int periodIndex, long nowUnixTimeUs) { - if (getSegmentCount() == DashSegmentIndex.INDEX_UNBOUNDED - && manifest.timeShiftBufferDepthMs != C.TIME_UNSET) { - // The index is itself unbounded. We need to use the current time to calculate the range of - // available segments. - long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs); - long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs); - long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs; - long bufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs); - return max(getFirstSegmentNum(), getSegmentNum(liveEdgeTimeInPeriodUs - bufferDepthUs)); - } - return getFirstSegmentNum(); - } - - public long getLastAvailableSegmentNum( - DashManifest manifest, int periodIndex, long nowUnixTimeUs) { - int availableSegmentCount = getSegmentCount(); - if (availableSegmentCount == DashSegmentIndex.INDEX_UNBOUNDED) { - // The index is itself unbounded. We need to use the current time to calculate the range of - // available segments. - long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs); - long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs); - long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs; - // getSegmentNum(liveEdgeTimeInPeriodUs) will not be completed yet, so subtract one to get - // the index of the last completed segment. - return getSegmentNum(liveEdgeTimeInPeriodUs) - 1; - } - return getFirstSegmentNum() + availableSegmentCount - 1; + public long getLastAvailableSegmentNum(long nowUnixTimeUs) { + return getFirstAvailableSegmentNum(nowUnixTimeUs) + + segmentIndex.getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs) + - 1; } @Nullable 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 ede5df90c48..1a24d82ddf4 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 @@ -138,7 +138,13 @@ protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, location = Uri.parse(xpp.nextText()); } else if (XmlPullParserUtil.isStartTag(xpp, "Period") && !seenEarlyAccessPeriod) { Pair periodWithDurationMs = - parsePeriod(xpp, baseUrl, nextPeriodStartMs, baseUrlAvailabilityTimeOffsetUs); + parsePeriod( + xpp, + baseUrl, + nextPeriodStartMs, + baseUrlAvailabilityTimeOffsetUs, + availabilityStartTime, + timeShiftBufferDepthMs); Period period = periodWithDurationMs.first; if (period.startMs == C.TIME_UNSET) { if (dynamic) { @@ -226,10 +232,17 @@ protected UtcTimingElement buildUtcTimingElement(String schemeIdUri, String valu } protected Pair parsePeriod( - XmlPullParser xpp, String baseUrl, long defaultStartMs, long baseUrlAvailabilityTimeOffsetUs) + XmlPullParser xpp, + String baseUrl, + long defaultStartMs, + long baseUrlAvailabilityTimeOffsetUs, + long availabilityStartTimeMs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { @Nullable String id = xpp.getAttributeValue(null, "id"); long startMs = parseDuration(xpp, "start", defaultStartMs); + long periodStartUnixTimeMs = + availabilityStartTimeMs != C.TIME_UNSET ? availabilityStartTimeMs + startMs : C.TIME_UNSET; long durationMs = parseDuration(xpp, "duration", C.TIME_UNSET); @Nullable SegmentBase segmentBase = null; @Nullable Descriptor assetIdentifier = null; @@ -254,7 +267,9 @@ protected Pair parsePeriod( segmentBase, durationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs)); + segmentBaseAvailabilityTimeOffsetUs, + periodStartUnixTimeMs, + timeShiftBufferDepthMs)); } else if (XmlPullParserUtil.isStartTag(xpp, "EventStream")) { eventStreams.add(parseEventStream(xpp)); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { @@ -308,7 +323,9 @@ protected AdaptationSet parseAdaptationSet( @Nullable SegmentBase segmentBase, long periodDurationMs, long baseUrlAvailabilityTimeOffsetUs, - long segmentBaseAvailabilityTimeOffsetUs) + long segmentBaseAvailabilityTimeOffsetUs, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { int id = parseInt(xpp, "id", AdaptationSet.ID_UNSET); int contentType = parseContentType(xpp); @@ -428,7 +445,9 @@ protected AdaptationSet parseAdaptationSet( label, drmSchemeType, drmSchemeDatas, - inbandEventStreams)); + inbandEventStreams, + periodStartUnixTimeMs, + timeShiftBufferDepthMs)); } return buildAdaptationSet( @@ -725,7 +744,9 @@ protected Representation buildRepresentation( @Nullable String label, @Nullable String extraDrmSchemeType, ArrayList extraDrmSchemeDatas, - ArrayList extraInbandEventStreams) { + ArrayList extraInbandEventStreams, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs) { Format.Builder formatBuilder = representationInfo.format.buildUpon(); if (label != null) { formatBuilder.setLabel(label); @@ -747,7 +768,9 @@ protected Representation buildRepresentation( formatBuilder.build(), representationInfo.baseUrl, representationInfo.segmentBase, - inbandEventStreams); + inbandEventStreams, + periodStartUnixTimeMs, + timeShiftBufferDepthMs); } // SegmentBase, SegmentList and SegmentTemplate parsing. diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index 03151631d3b..2438835be66 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.dash.manifest; +import static java.lang.Math.max; + import android.net.Uri; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -71,7 +73,14 @@ public abstract class Representation { */ public static Representation newInstance( long revisionId, Format format, String baseUrl, SegmentBase segmentBase) { - return newInstance(revisionId, format, baseUrl, segmentBase, /* inbandEventStreams= */ null); + return newInstance( + revisionId, + format, + baseUrl, + segmentBase, + /* inbandEventStreams= */ null, + /* periodStartUnixTimeMs= */ C.TIME_UNSET, + /* timeShiftBufferDepthMs= */ C.TIME_UNSET); } /** @@ -82,6 +91,9 @@ public static Representation newInstance( * @param baseUrl The base URL. * @param segmentBase A segment base element for the representation. * @param inbandEventStreams The in-band event streams in the representation. May be null. + * @param periodStartUnixTimeMs The start time of the enclosing {@link Period} in milliseconds + * since the Unix epoch, or {@link C#TIME_UNSET} is not applicable. + * @param timeShiftBufferDepthMs The {@link DashManifest#timeShiftBufferDepthMs}. * @return The constructed instance. */ public static Representation newInstance( @@ -89,9 +101,18 @@ public static Representation newInstance( Format format, String baseUrl, SegmentBase segmentBase, - @Nullable List inbandEventStreams) { + @Nullable List inbandEventStreams, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs) { return newInstance( - revisionId, format, baseUrl, segmentBase, inbandEventStreams, /* cacheKey= */ null); + revisionId, + format, + baseUrl, + segmentBase, + inbandEventStreams, + periodStartUnixTimeMs, + timeShiftBufferDepthMs, + /* cacheKey= */ null); } /** @@ -102,6 +123,9 @@ public static Representation newInstance( * @param baseUrl The base URL of the representation. * @param segmentBase A segment base element for the representation. * @param inbandEventStreams The in-band event streams in the representation. May be null. + * @param periodStartUnixTimeMs The start time of the enclosing {@link Period} in milliseconds + * since the Unix epoch, or {@link C#TIME_UNSET} is not applicable. + * @param timeShiftBufferDepthMs The {@link DashManifest#timeShiftBufferDepthMs}. * @param cacheKey An optional key to be returned from {@link #getCacheKey()}, or null. This * parameter is ignored if {@code segmentBase} consists of multiple segments. * @return The constructed instance. @@ -112,6 +136,8 @@ public static Representation newInstance( String baseUrl, SegmentBase segmentBase, @Nullable List inbandEventStreams, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs, @Nullable String cacheKey) { if (segmentBase instanceof SingleSegmentBase) { return new SingleSegmentRepresentation( @@ -124,7 +150,13 @@ public static Representation newInstance( C.LENGTH_UNSET); } else if (segmentBase instanceof MultiSegmentBase) { return new MultiSegmentRepresentation( - revisionId, format, baseUrl, (MultiSegmentBase) segmentBase, inbandEventStreams); + revisionId, + format, + baseUrl, + (MultiSegmentBase) segmentBase, + inbandEventStreams, + periodStartUnixTimeMs, + timeShiftBufferDepthMs); } else { throw new IllegalArgumentException("segmentBase must be of type SingleSegmentBase or " + "MultiSegmentBase"); @@ -277,22 +309,33 @@ public static class MultiSegmentRepresentation extends Representation implements DashSegmentIndex { @VisibleForTesting /* package */ final MultiSegmentBase segmentBase; + private final long periodStartUnixTimeUs; + private final long timeShiftBufferDepthUs; /** + * Creates the multi-segment Representation. + * * @param revisionId Identifies the revision of the content. * @param format The format of the representation. * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. * @param inbandEventStreams The in-band event streams in the representation. May be null. + * @param periodStartUnixTimeMs The start time of the enclosing {@link Period} in milliseconds + * since the Unix epoch, or {@link C#TIME_UNSET} is not applicable. + * @param timeShiftBufferDepthMs The {@link DashManifest#timeShiftBufferDepthMs}. */ public MultiSegmentRepresentation( long revisionId, Format format, String baseUrl, MultiSegmentBase segmentBase, - @Nullable List inbandEventStreams) { + @Nullable List inbandEventStreams, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs) { super(revisionId, format, baseUrl, segmentBase, inbandEventStreams); this.segmentBase = segmentBase; + this.periodStartUnixTimeUs = C.msToUs(periodStartUnixTimeMs); + this.timeShiftBufferDepthUs = C.msToUs(timeShiftBufferDepthMs); } @Override @@ -339,11 +382,41 @@ public long getFirstSegmentNum() { return segmentBase.getFirstSegmentNum(); } + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + long segmentCount = segmentBase.getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED || timeShiftBufferDepthUs == C.TIME_UNSET) { + return segmentBase.getFirstSegmentNum(); + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + long timeShiftBufferStartInPeriodUs = liveEdgeTimeInPeriodUs - timeShiftBufferDepthUs; + long timeShiftBufferStartSegmentNum = + getSegmentNum(timeShiftBufferStartInPeriodUs, periodDurationUs); + return max(getFirstSegmentNum(), timeShiftBufferStartSegmentNum); + } + @Override public int getSegmentCount(long periodDurationUs) { return segmentBase.getSegmentCount(periodDurationUs); } + @Override + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + int segmentCount = segmentBase.getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED) { + return segmentCount; + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + // getSegmentNum(liveEdgeTimeInPeriodUs) will not be completed yet. + long firstIncompleteSegmentNum = getSegmentNum(liveEdgeTimeInPeriodUs, periodDurationUs); + long firstAvailableSegmentNum = getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + return (int) (firstIncompleteSegmentNum - firstAvailableSegmentNum); + } + @Override public boolean isExplicit() { return segmentBase.isExplicit(); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java index a56a11fe50e..7c6c8a7aa90 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java @@ -56,11 +56,21 @@ public long getFirstSegmentNum() { return 0; } + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + return 0; + } + @Override public int getSegmentCount(long periodDurationUs) { return 1; } + @Override + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + return 1; + } + @Override public boolean isExplicit() { return true; From 25e31743d3f4221d60a7c00c1e61e1f7f7ec8d92 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 22 Sep 2020 12:23:24 +0100 Subject: [PATCH 065/693] Don't require the existence of the next period to wait for its stream. We have a workaround for uneven sample stream durarions in playlists that assumes a renderer allows playback if it's reading ahead or waiting for the next stream. https://github.com/google/ExoPlayer/commit/652c2f9c188bf9d9d6e323ff5333e5026454a082 changed this logic to no longer require to wait until the next stream is prepared due to a change in how we advance media periods in the queue. However, the code falsely still requires the next stream to exist (even if it's not prepared). This can cause a stuck buffering state when the difference in the duration of the streams is more than what we buffer ahead because we never create the next stream in such a case. Note: DefaultMediaClock.shouldUseStandaloneClock has roughly the same logic and also doesn't require the next stream to be present. Also fix a test that seemed to rely on this stuck buffering case to test stuck buffering detection. Changed the test to not read the end of stream to ensure it runs into the desired stuck buffering case. Issue:#7943 PiperOrigin-RevId: 333050285 --- RELEASENOTES.md | 4 ++++ .../android/exoplayer2/ExoPlayerImplInternal.java | 5 +---- .../google/android/exoplayer2/ExoPlayerTest.java | 15 +++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ffdfc1c1e7f..2bae8256fe2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,10 @@ ### dev-v2 (not yet released) +* Core library: + * Fix bug where streams with highly uneven durations may get stuck in a + buffering state + ([#7943](https://github.com/google/ExoPlayer/issues/7943)). * Track selection: * Add option to specify multiple preferred audio or text languages. * Data sources: 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 9739680e79a..e33b93ac0ef 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 @@ -897,10 +897,7 @@ private void doSomeWork() throws ExoPlaybackException, IOException { // tracks in the current period have uneven durations and are still being read by another // renderer. See: https://github.com/google/ExoPlayer/issues/1874. boolean isReadingAhead = playingPeriodHolder.sampleStreams[i] != renderer.getStream(); - boolean isWaitingForNextStream = - !isReadingAhead - && playingPeriodHolder.getNext() != null - && renderer.hasReadStreamToEnd(); + boolean isWaitingForNextStream = !isReadingAhead && renderer.hasReadStreamToEnd(); boolean allowsPlayback = isReadingAhead || isWaitingForNextStream || renderer.isReady() || renderer.isEnded(); renderersAllowPlayback = renderersAllowPlayback && allowsPlayback; 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 444640256f6..7934298df08 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 @@ -7334,6 +7334,8 @@ public void run(SimpleExoPlayer player) { new DefaultLoadControl.Builder() .setTargetBufferBytes(10 * C.DEFAULT_BUFFER_SEGMENT_SIZE) .build(); + // Return no end of stream signal to prevent playback from ending. + FakeMediaPeriod.TrackDataFactory trackDataWithoutEos = (format, periodId) -> ImmutableList.of(); MediaSource continuouslyAllocatingMediaSource = new FakeMediaSource( new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) { @@ -7348,8 +7350,11 @@ protected FakeMediaPeriod createFakeMediaPeriod( @Nullable TransferListener transferListener) { return new FakeMediaPeriod( trackGroupArray, - TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, - mediaSourceEventDispatcher) { + trackDataWithoutEos, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false) { private final List allocations = new ArrayList<>(); @@ -7382,14 +7387,8 @@ public boolean continueLoading(long positionUs) { }; } }; - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - // Prevent player from ever assuming it finished playing. - .setRepeatMode(Player.REPEAT_MODE_ALL) - .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder(context) - .setActionSchedule(actionSchedule) .setMediaSources(continuouslyAllocatingMediaSource) .setLoadControl(loadControl) .build(); From a196fb07782099300e893082a55eedfabfe11230 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 22 Sep 2020 12:27:58 +0100 Subject: [PATCH 066/693] Remove unused MP4 atom type PiperOrigin-RevId: 333051018 --- .../java/com/google/android/exoplayer2/extractor/mp4/Atom.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index e86a873ed56..91b26562ca8 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -274,9 +274,6 @@ @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_TTML = 0x54544d4c; - @SuppressWarnings("ConstantCaseForConstants") - public static final int TYPE_vmhd = 0x766d6864; - @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_mp4v = 0x6d703476; From 4c6c1a130fb03b69ccb64e3c5a1764ec0aea6db8 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 22 Sep 2020 13:50:45 +0100 Subject: [PATCH 067/693] Tweak shrinking guide and update release notes. The shrinking didn't mention that users of the existing ProgressiveMediaSource need to pass in ExtractorsFactory.EMPTY to the SimpleExoPlayer.Builder as well. Also updated the release notes to mention the changed shrinking guidance. Issue: #7937 PiperOrigin-RevId: 333060452 --- RELEASENOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2bae8256fe2..caa9829f046 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -77,6 +77,10 @@ To learn more about what's new in 2.12, read the corresponding playable `MediaSource` instances. A `DefaultMediaSourceFactory` is used by default. `Builder.setMediaSourceFactory` allows setting a custom factory. + * Update [APK shrinking guide](https://exoplayer.dev/shrinking.html) + to explain how shrinking works with the new `MediaItem` and + `DefaultMediaSourceFactory` implementations + ([#7937](https://github.com/google/ExoPlayer/issues/7937)). * Add additional options to `Builder` that were previously only accessible via setters. * Add opt-in to verify correct thread usage with From 7b51797d4bb0c680b3f3540b0c5d305c81516447 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 22 Sep 2020 15:49:42 +0100 Subject: [PATCH 068/693] Add availabilityTimeOffset to last available segment calculation. This allows the player to load the unfinished segment of a low-latency DASH stream. PiperOrigin-RevId: 333077153 --- .../source/dash/manifest/Representation.java | 5 +- .../dash/manifest/RepresentationTest.java | 151 ++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index 2438835be66..b3845623768 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -411,8 +411,9 @@ public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { // The index is itself unbounded. We need to use the current time to calculate the range of // available segments. long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; - // getSegmentNum(liveEdgeTimeInPeriodUs) will not be completed yet. - long firstIncompleteSegmentNum = getSegmentNum(liveEdgeTimeInPeriodUs, periodDurationUs); + long availabilityEndTimeUs = liveEdgeTimeInPeriodUs + segmentBase.availabilityTimeOffsetUs; + // getSegmentNum(availabilityEndTimeUs) will not be completed yet. + long firstIncompleteSegmentNum = getSegmentNum(availabilityEndTimeUs, periodDurationUs); long firstAvailableSegmentNum = getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); return (int) (firstIncompleteSegmentNum - firstAvailableSegmentNum); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java new file mode 100644 index 00000000000..d22071cefac --- /dev/null +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java @@ -0,0 +1,151 @@ +/* + * 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.dash.manifest; + +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 org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link Representation}. */ +@RunWith(AndroidJUnit4.class) +public final class RepresentationTest { + + @Test + public void getFirstAvailableSegmentNum_multiSegmentRepresentationWithUnboundedTemplate() { + long periodStartUnixTimeUs = 123_000_000_000_000L; + SegmentBase.SegmentTemplate segmentTemplate = + new SegmentBase.SegmentTemplate( + /* initialization= */ null, + /* timescale= */ 1000, + /* presentationTimeOffset= */ 0, + /* startNumber= */ 42, + /* endNumber= */ C.INDEX_UNSET, + /* duration= */ 2000, + /* segmentTimeline= */ null, + /* availabilityTimeOffsetUs= */ 500_000, + /* initializationTemplate= */ null, + /* mediaTemplate= */ null); + Representation.MultiSegmentRepresentation representation = + new Representation.MultiSegmentRepresentation( + /* revisionId= */ 0, + new Format.Builder().build(), + /* baseUrl= */ "https://baseUrl/", + segmentTemplate, + /* inbandEventStreams= */ null, + /* periodStartUnixTimeMs= */ C.usToMs(periodStartUnixTimeUs), + /* timeShiftBufferDepthMs= */ 6_000); + + assertThat( + representation.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) + .isEqualTo(42); + assertThat( + representation.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) + .isEqualTo(42); + assertThat( + representation.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_999_999)) + .isEqualTo(42); + assertThat( + representation.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 8_000_000)) + .isEqualTo(43); + assertThat( + representation.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 9_999_999)) + .isEqualTo(43); + assertThat( + representation.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 10_000_000)) + .isEqualTo(44); + } + + @Test + public void getAvailableSegmentCount_multiSegmentRepresentationWithUnboundedTemplate() { + long periodStartUnixTimeUs = 123_000_000_000_000L; + SegmentBase.SegmentTemplate segmentTemplate = + new SegmentBase.SegmentTemplate( + /* initialization= */ null, + /* timescale= */ 1000, + /* presentationTimeOffset= */ 0, + /* startNumber= */ 42, + /* endNumber= */ C.INDEX_UNSET, + /* duration= */ 2000, + /* segmentTimeline= */ null, + /* availabilityTimeOffsetUs= */ 500_000, + /* initializationTemplate= */ null, + /* mediaTemplate= */ null); + Representation.MultiSegmentRepresentation representation = + new Representation.MultiSegmentRepresentation( + /* revisionId= */ 0, + new Format.Builder().build(), + /* baseUrl= */ "https://baseUrl/", + segmentTemplate, + /* inbandEventStreams= */ null, + /* periodStartUnixTimeMs= */ C.usToMs(periodStartUnixTimeUs), + /* timeShiftBufferDepthMs= */ 6_000); + + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) + .isEqualTo(0); + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) + .isEqualTo(0); + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_499_999)) + .isEqualTo(0); + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_500_000)) + .isEqualTo(1); + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_499_999)) + .isEqualTo(3); + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_500_000)) + .isEqualTo(4); + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_999_999)) + .isEqualTo(4); + assertThat( + representation.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 8_000_000)) + .isEqualTo(3); + } +} From 69ca49f939623a8ab22cf65e2becb642f6fde8cc Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 23 Sep 2020 13:22:27 +0100 Subject: [PATCH 069/693] Add support for 'mett' sample description PiperOrigin-RevId: 333272292 --- .../android/exoplayer2/extractor/mp4/Atom.java | 3 +++ .../exoplayer2/extractor/mp4/AtomParsers.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index 91b26562ca8..58f3a75b874 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -355,6 +355,9 @@ @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_camm = 0x63616d6d; + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mett = 0x6d657474; + @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_alac = 0x616c6163; 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 6eed09760e0..0ab126367b0 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 @@ -891,6 +891,8 @@ private static StsdData parseStsd( || childAtomType == Atom.TYPE_c608) { parseTextSampleEntry(stsd, childAtomType, childStartPosition, childAtomSize, trackId, language, out); + } else if (childAtomType == Atom.TYPE_mett) { + parseMetaDataSampleEntry(stsd, childAtomType, childStartPosition, trackId, out); } else if (childAtomType == Atom.TYPE_camm) { out.format = new Format.Builder() @@ -1097,6 +1099,18 @@ private static void parseVideoSampleEntry( .build(); } + private static void parseMetaDataSampleEntry( + ParsableByteArray parent, int atomType, int position, int trackId, StsdData out) { + parent.setPosition(position + Atom.HEADER_SIZE + StsdData.STSD_HEADER_SIZE); + if (atomType == Atom.TYPE_mett) { + parent.readNullTerminatedString(); // Skip optional content_encoding + @Nullable String mimeType = parent.readNullTerminatedString(); + if (mimeType != null) { + out.format = new Format.Builder().setId(trackId).setSampleMimeType(mimeType).build(); + } + } + } + /** * Parses the edts atom (defined in ISO/IEC 14496-12 subsection 8.6.5). * From d3639a2b20217e33caa05947def2b6cfba702530 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 23 Sep 2020 16:27:24 +0100 Subject: [PATCH 070/693] Add Japanese subtitle examples to the demo app These are from https://medium.com/google-exoplayer/improved-japanese-subtitle-support-7598fee12cf4 PiperOrigin-RevId: 333296789 --- demos/main/src/main/assets/media.exolist.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index ce1854db852..24213918f50 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -527,6 +527,20 @@ { "name": "MPEG-4 Timed Text (tx3g, mov_text)", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/dizzy-with-tx3g.mp4" + }, + { + "name": "Japanese features (vertical + rubies) [TTML]", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ttml/japanese-ttml.xml", + "subtitle_mime_type": "application/ttml+xml", + "subtitle_language": "ja" + }, + { + "name": "Japanese features (vertical + rubies) [WebVTT]", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/webvtt/japanese.vtt", + "subtitle_mime_type": "text/vtt", + "subtitle_language": "ja" } ] }, From c76bc43de6f00091f32ac54f064d55fc63f17140 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 24 Sep 2020 10:00:46 +0100 Subject: [PATCH 071/693] Delete sample_cbs.adts test asset This seems to be an exact copy of sample.adts. Update the test to use the same sample but just output to a different dump file. PiperOrigin-RevId: 333469714 --- .../extractor/ts/AdtsExtractorTest.java | 6 +++++- .../src/test/assets/media/ts/sample_cbs.adts | Bin 31805 -> 0 bytes 2 files changed, 5 insertions(+), 1 deletion(-) delete mode 100644 testdata/src/test/assets/media/ts/sample_cbs.adts 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 e8bc727222e..dca8ba99383 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.extractor.ts; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import com.google.android.exoplayer2.testutil.ExtractorAsserts.AssertionConfig; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -49,7 +50,10 @@ public void sample_with_id3() throws Exception { public void sample_withSeeking() throws Exception { ExtractorAsserts.assertBehavior( () -> new AdtsExtractor(/* flags= */ AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING), - "media/ts/sample_cbs.adts", + "media/ts/sample.adts", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/ts/sample_cbs.adts") + .build(), simulationConfig); } diff --git a/testdata/src/test/assets/media/ts/sample_cbs.adts b/testdata/src/test/assets/media/ts/sample_cbs.adts deleted file mode 100644 index abbaad0daf95cf2bce299b161a2ea6d658b587e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31805 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∈f1 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 From 3be2463a94145477b1aeaaf8c65f90e2b7cc05cc Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 10:19:11 +0100 Subject: [PATCH 072/693] Fix offload buffer full detection after setEndOfStream This issue has been observed on a test app stress testing setEndOfStream. The issue has not been observed on ExoPlayer, probably due to timing differences, but it is fixed preventively. #exo-offload PiperOrigin-RevId: 333472136 --- .../exoplayer2/audio/DefaultAudioSink.java | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 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 41e76440c11..478eb0d04b2 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 @@ -336,6 +336,7 @@ public long getSkippedOutputFrameCount() { private boolean tunneling; private long lastFeedElapsedRealtimeMs; private boolean offloadDisabledUntilNextConfiguration; + private boolean isWaitingForOffloadEndOfStreamHandled; /** * Creates a new default audio sink. @@ -712,6 +713,7 @@ public boolean handleBuffer( audioTrack.setOffloadEndOfStream(); audioTrack.setOffloadDelayPadding( configuration.inputFormat.encoderDelay, configuration.inputFormat.encoderPadding); + isWaitingForOffloadEndOfStreamHandled = true; } } // Re-apply playback parameters. @@ -932,13 +934,26 @@ private void writeBuffer(ByteBuffer buffer, long avSyncPresentationTimeUs) throw throw new WriteException(bytesWritten); } - if (playing - && listener != null - && bytesWritten < bytesRemaining - && isOffloadedPlayback(audioTrack)) { - long pendingDurationMs = - audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); - listener.onOffloadBufferFull(pendingDurationMs); + if (isOffloadedPlayback(audioTrack)) { + // After calling AudioTrack.setOffloadEndOfStream, the AudioTrack internally stops and + // restarts during which AudioTrack.write will return 0. This situation must be detected to + // prevent reporting the buffer as full even though it is not which could lead ExoPlayer to + // sleep forever waiting for a onDataRequest that will never come. + if (writtenEncodedFrames > 0) { + isWaitingForOffloadEndOfStreamHandled = false; + } + + // Consider the offload buffer as full if the AudioTrack is playing and AudioTrack.write could + // not write all the data provided to it. This relies on the assumption that AudioTrack.write + // always writes as much as possible. + if (playing + && listener != null + && bytesWritten < bytesRemaining + && !isWaitingForOffloadEndOfStreamHandled) { + long pendingDurationMs = + audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); + listener.onOffloadBufferFull(pendingDurationMs); + } } if (configuration.outputMode == OUTPUT_MODE_PCM) { @@ -1221,6 +1236,7 @@ private void resetSinkStateForFlush() { submittedEncodedFrames = 0; writtenPcmBytes = 0; writtenEncodedFrames = 0; + isWaitingForOffloadEndOfStreamHandled = false; framesPerEncodedSample = 0; mediaPositionParameters = new MediaPositionParameters( From 66636f9ec0b66a50c501abe3e5e29576a892fc8c Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 24 Sep 2020 11:28:24 +0100 Subject: [PATCH 073/693] Switch SntpClient to time.android.com and allow to set host. PiperOrigin-RevId: 333480727 --- .../android/exoplayer2/util/SntpClient.java | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java b/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java index 19159ede6ed..03336fdeba8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java @@ -27,6 +27,7 @@ import java.net.DatagramSocket; import java.net.InetAddress; import java.util.Arrays; +import java.util.ConcurrentModificationException; /** * Static utility to retrieve the device time offset using SNTP. @@ -37,6 +38,9 @@ */ public final class SntpClient { + /** The default NTP host address used to retrieve {@link #getElapsedRealtimeOffsetMs()}. */ + public static final String DEFAULT_NTP_HOST = "time.android.com"; + /** Callback for calls to {@link #initialize(Loader, InitializationCallback)}. */ public interface InitializationCallback { @@ -51,7 +55,6 @@ public interface InitializationCallback { void onInitializationFailed(IOException error); } - private static final String NTP_HOST = "pool.ntp.org"; private static final int TIMEOUT_MS = 10_000; private static final int ORIGINATE_TIME_OFFSET = 24; @@ -80,8 +83,37 @@ public interface InitializationCallback { @GuardedBy("valueLock") private static long elapsedRealtimeOffsetMs; + @GuardedBy("valueLock") + private static String ntpHost = DEFAULT_NTP_HOST; + private SntpClient() {} + /** Returns the NTP host address used to retrieve {@link #getElapsedRealtimeOffsetMs()}. */ + public static String getNtpHost() { + synchronized (valueLock) { + return ntpHost; + } + } + + /** + * Sets the NTP host address used to retrieve {@link #getElapsedRealtimeOffsetMs()}. + * + *

    The default is {@link #DEFAULT_NTP_HOST}. + * + *

    If the new host address is different from the previous one, the NTP client will be {@link + * #isInitialized()} uninitialized} again. + * + * @param ntpHost The NTP host address. + */ + public static void setNtpHost(String ntpHost) { + synchronized (valueLock) { + if (!SntpClient.ntpHost.equals(ntpHost)) { + SntpClient.ntpHost = ntpHost; + isInitialized = false; + } + } + } + /** * Returns whether the device time offset has already been loaded. * @@ -129,7 +161,7 @@ public static void initialize( } private static long loadNtpTimeOffsetMs() throws IOException { - InetAddress address = InetAddress.getByName(NTP_HOST); + InetAddress address = InetAddress.getByName(getNtpHost()); try (DatagramSocket socket = new DatagramSocket()) { socket.setSoTimeout(TIMEOUT_MS); byte[] buffer = new byte[NTP_PACKET_SIZE]; @@ -282,9 +314,14 @@ public NtpTimeCallback(@Nullable InitializationCallback callback) { @Override public void onLoadCompleted(Loadable loadable, long elapsedRealtimeMs, long loadDurationMs) { - Assertions.checkState(SntpClient.isInitialized()); if (callback != null) { - callback.onInitialized(); + if (!SntpClient.isInitialized()) { + // This may happen in the unlikely edge case of someone calling setNtpHost between the end + // of the load method and this callback. + callback.onInitializationFailed(new IOException(new ConcurrentModificationException())); + } else { + callback.onInitialized(); + } } } From 294ae10ef15460f29f70769ed3893024b6ef92b0 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 24 Sep 2020 12:37:02 +0100 Subject: [PATCH 074/693] Change default of throwsWhenUsingWrongThread to true Apps can still opt out for now, but this option will be removed in the future. Issue: #4463 PiperOrigin-RevId: 333489424 --- RELEASENOTES.md | 4 ++++ .../java/com/google/android/exoplayer2/SimpleExoPlayer.java | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index caa9829f046..6c94ec1d867 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -6,6 +6,10 @@ * Fix bug where streams with highly uneven durations may get stuck in a buffering state ([#7943](https://github.com/google/ExoPlayer/issues/7943)). + * Verify correct thread usage in `SimpleExoPlayer` by default. Opt-out is + still possible until the next major release using + `setThrowsWhenUsingWrongThread(false)` + ([#4463](https://github.com/google/ExoPlayer/issues/4463)). * Track selection: * Add option to specify multiple preferred audio or text languages. * Data sources: 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 6652cbb03d0..dd41d8e2bc1 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 @@ -618,6 +618,7 @@ protected SimpleExoPlayer(Builder builder) { audioVolume = 1; audioSessionId = C.AUDIO_SESSION_ID_UNSET; currentCues = Collections.emptyList(); + throwsWhenUsingWrongThread = true; // Build the player and associated objects. player = @@ -1948,7 +1949,7 @@ public void setDeviceMuted(boolean muted) { * Sets whether the player should throw an {@link IllegalStateException} when methods are called * from a thread other than the one associated with {@link #getApplicationLooper()}. * - *

    The default is {@code false}, but will change to {@code true} in the future. + *

    The default is {@code true} and this method will be removed in the future. * * @param throwsWhenUsingWrongThread Whether to throw when methods are called from a wrong thread. */ From f37d79a4dd9de7977fce2ed10f35385fdb88bdd8 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 13:50:57 +0100 Subject: [PATCH 075/693] Callback when sleeping for offload and existing from it #exo-offload PiperOrigin-RevId: 333497538 --- .../android/exoplayer2/ExoPlayerImpl.java | 9 +++ .../exoplayer2/ExoPlayerImplInternal.java | 14 ++-- .../android/exoplayer2/PlaybackInfo.java | 65 +++++++++++++++---- .../com/google/android/exoplayer2/Player.java | 6 ++ .../android/exoplayer2/ExoPlayerTest.java | 38 ++--------- .../exoplayer2/MediaPeriodQueueTest.java | 3 +- .../exoplayer2/testutil/TestExoPlayer.java | 28 +++++++- 7 files changed, 115 insertions(+), 48 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 b1f57364658..1b0b34bd7bd 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 @@ -1353,6 +1353,7 @@ private static final class PlaybackInfoUpdate implements Runnable { private final boolean isPlayingChanged; private final boolean playbackParametersChanged; private final boolean offloadSchedulingEnabledChanged; + private final boolean sleepingForOffloadChanged; public PlaybackInfoUpdate( PlaybackInfo playbackInfo, @@ -1394,6 +1395,8 @@ public PlaybackInfoUpdate( !previousPlaybackInfo.playbackParameters.equals(playbackInfo.playbackParameters); offloadSchedulingEnabledChanged = previousPlaybackInfo.offloadSchedulingEnabled != playbackInfo.offloadSchedulingEnabled; + sleepingForOffloadChanged = + previousPlaybackInfo.sleepingForOffload != playbackInfo.sleepingForOffload; } @SuppressWarnings("deprecation") @@ -1476,6 +1479,12 @@ public void run() { listener.onExperimentalOffloadSchedulingEnabledChanged( playbackInfo.offloadSchedulingEnabled)); } + if (sleepingForOffloadChanged) { + invokeAll( + listenerSnapshot, + listener -> + listener.onExperimentalSleepingForOffloadChanged(playbackInfo.sleepingForOffload)); + } } private static boolean isPlaying(PlaybackInfo playbackInfo) { 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 e33b93ac0ef..0752c089494 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 @@ -960,14 +960,18 @@ && isLoadingPossible()) { playbackInfo = playbackInfo.copyWithOffloadSchedulingEnabled(offloadSchedulingEnabled); } + boolean sleepingForOffload = false; if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); + sleepingForOffload = !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); } + if (playbackInfo.sleepingForOffload != sleepingForOffload) { + playbackInfo = playbackInfo.copyWithSleepingForOffload(sleepingForOffload); + } requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); @@ -978,12 +982,13 @@ private void scheduleNextWork(long thisOperationStartTimeMs, long intervalMs) { handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } - private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { + private boolean maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { if (offloadSchedulingEnabled && requestForRendererSleep) { - return; + return false; } scheduleNextWork(operationStartTimeMs, intervalMs); + return true; } private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { @@ -1308,7 +1313,8 @@ private void resetInternal( startPositionUs, /* totalBufferedDurationUs= */ 0, startPositionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + /* sleepingForOffload= */ false); if (releaseMediaSourceList) { mediaSourceList.release(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java index 9fb65630058..e7f200d8b7d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java @@ -67,6 +67,8 @@ public final PlaybackParameters playbackParameters; /** Whether offload scheduling is enabled for the main player loop. */ public final boolean offloadSchedulingEnabled; + /** Whether the main player loop is sleeping, while using offload scheduling. */ + public final boolean sleepingForOffload; /** * Position up to which media is buffered in {@link #loadingMediaPeriodId) relative to the start @@ -109,7 +111,8 @@ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorRes /* bufferedPositionUs= */ 0, /* totalBufferedDurationUs= */ 0, /* positionUs= */ 0, - /* offloadSchedulingEnabled= */ false); + /* offloadSchedulingEnabled= */ false, + /* sleepingForOffload= */ false); } /** @@ -131,6 +134,7 @@ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorRes * @param totalBufferedDurationUs See {@link #totalBufferedDurationUs}. * @param positionUs See {@link #positionUs}. * @param offloadSchedulingEnabled See {@link #offloadSchedulingEnabled}. + * @param sleepingForOffload See {@link #sleepingForOffload}. */ public PlaybackInfo( Timeline timeline, @@ -148,7 +152,8 @@ public PlaybackInfo( long bufferedPositionUs, long totalBufferedDurationUs, long positionUs, - boolean offloadSchedulingEnabled) { + boolean offloadSchedulingEnabled, + boolean sleepingForOffload) { this.timeline = timeline; this.periodId = periodId; this.requestedContentPositionUs = requestedContentPositionUs; @@ -165,6 +170,7 @@ public PlaybackInfo( this.totalBufferedDurationUs = totalBufferedDurationUs; this.positionUs = positionUs; this.offloadSchedulingEnabled = offloadSchedulingEnabled; + this.sleepingForOffload = sleepingForOffload; } /** Returns a placeholder period id for an empty timeline. */ @@ -209,7 +215,8 @@ public PlaybackInfo copyWithNewPosition( bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -236,7 +243,8 @@ public PlaybackInfo copyWithTimeline(Timeline timeline) { bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -263,7 +271,8 @@ public PlaybackInfo copyWithPlaybackState(int playbackState) { bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -290,7 +299,8 @@ public PlaybackInfo copyWithPlaybackError(@Nullable ExoPlaybackException playbac bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -317,7 +327,8 @@ public PlaybackInfo copyWithIsLoading(boolean isLoading) { bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -344,7 +355,8 @@ public PlaybackInfo copyWithLoadingMediaPeriodId(MediaPeriodId loadingMediaPerio bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -375,7 +387,8 @@ public PlaybackInfo copyWithPlayWhenReady( bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -402,7 +415,8 @@ public PlaybackInfo copyWithPlaybackParameters(PlaybackParameters playbackParame bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -430,6 +444,35 @@ public PlaybackInfo copyWithOffloadSchedulingEnabled(boolean offloadSchedulingEn bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled); + offloadSchedulingEnabled, + sleepingForOffload); + } + + /** + * Copies playback info with new sleepingForOffload. + * + * @param sleepingForOffload New main player loop sleeping state. See {@link #sleepingForOffload}. + * @return Copied playback info with new main player loop sleeping state. + */ + @CheckResult + public PlaybackInfo copyWithSleepingForOffload(boolean sleepingForOffload) { + return new PlaybackInfo( + timeline, + periodId, + requestedContentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + playWhenReady, + playbackSuppressionReason, + playbackParameters, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } } 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 7a52aae7387..89a00eb4758 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 @@ -604,6 +604,12 @@ default void onSeekProcessed() {} *

    This method is experimental, and will be renamed or removed in a future release. */ default void onExperimentalOffloadSchedulingEnabledChanged(boolean offloadSchedulingEnabled) {} + /** + * Called when the player has started or finished sleeping for offload. + * + *

    This method is experimental, and will be renamed or removed in a future release. + */ + default void onExperimentalSleepingForOffloadChanged(boolean sleepingForOffload) {} } /** 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 7934298df08..a1b3b9014d9 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 @@ -20,6 +20,7 @@ import static com.google.android.exoplayer2.testutil.TestExoPlayer.playUntilStartOfWindow; import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilPlaybackState; import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilReceiveOffloadSchedulingEnabledNewState; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilSleepingForOffload; import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilTimelineChanged; import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; @@ -111,7 +112,6 @@ import java.util.HashSet; import java.util.List; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -8249,16 +8249,14 @@ public void enableOffloadSchedulingWhileSleepingForOffload_isDisabled_isReported Timeline timeline = new FakeTimeline(/* windowCount= */ 1); player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); player.experimentalSetOffloadSchedulingEnabled(true); - runUntilReceiveOffloadSchedulingEnabledNewState(player); player.prepare(); player.play(); - runMainLooperUntil(sleepRenderer::isSleeping); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); player.experimentalSetOffloadSchedulingEnabled(false); assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); } - @Test public void enableOffloadScheduling_isEnable_playerSleeps() throws Exception { FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO); @@ -8271,14 +8269,7 @@ public void enableOffloadScheduling_isEnable_playerSleeps() throws Exception { sleepRenderer.sleepOnNextRender(); - runMainLooperUntil(sleepRenderer::isSleeping); - // TODO(b/163303129): There is currently no way to check that the player is sleeping for - // offload, for now use a timeout to check that the renderer is never woken up. - final int renderTimeoutMs = 500; - assertThrows( - TimeoutException.class, - () -> - runMainLooperUntil(() -> !sleepRenderer.isSleeping(), renderTimeoutMs, Clock.DEFAULT)); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); } @Test @@ -8292,11 +8283,11 @@ public void enableOffloadScheduling_isEnable_playerSleeps() throws Exception { player.experimentalSetOffloadSchedulingEnabled(true); player.prepare(); player.play(); - runMainLooperUntil(sleepRenderer::isSleeping); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); player.experimentalSetOffloadSchedulingEnabled(false); // Force the player to exit offload sleep - runMainLooperUntil(() -> !sleepRenderer.isSleeping()); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ false); runUntilPlaybackState(player, Player.STATE_ENDED); } @@ -8309,11 +8300,11 @@ public void wakeupListenerWhileSleepingForOffload_isWokenUp_renderingResumes() t player.experimentalSetOffloadSchedulingEnabled(true); player.prepare(); player.play(); - runMainLooperUntil(sleepRenderer::isSleeping); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); sleepRenderer.wakeup(); - runMainLooperUntil(() -> !sleepRenderer.isSleeping()); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ false); runUntilPlaybackState(player, Player.STATE_ENDED); } @@ -8350,13 +8341,11 @@ private static void deliverBroadcast(Intent intent) { private static class FakeSleepRenderer extends FakeRenderer { private static final long WAKEUP_DEADLINE_MS = 60 * C.MICROS_PER_SECOND; private final AtomicBoolean sleepOnNextRender; - private final AtomicBoolean isSleeping; private final AtomicReference wakeupListenerReceiver; public FakeSleepRenderer(int trackType) { super(trackType); sleepOnNextRender = new AtomicBoolean(false); - isSleeping = new AtomicBoolean(false); wakeupListenerReceiver = new AtomicReference<>(); } @@ -8372,14 +8361,6 @@ public FakeSleepRenderer sleepOnNextRender() { return this; } - /** - * Returns whether {@link Renderer.WakeupListener#onSleep(long)} was called on the last {@link - * #render(long, long)} - */ - public boolean isSleeping() { - return isSleeping.get(); - } - @Override public void handleMessage(int what, @Nullable Object object) throws ExoPlaybackException { if (what == MSG_SET_WAKEUP_LISTENER) { @@ -8394,11 +8375,6 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx super.render(positionUs, elapsedRealtimeUs); if (sleepOnNextRender.compareAndSet(/* expect= */ true, /* update= */ false)) { wakeupListenerReceiver.get().onSleep(WAKEUP_DEADLINE_MS); - // TODO(b/163303129): Use an actual message from the player instead of guessing that the - // player will always sleep for offload after calling `onSleep`. - isSleeping.set(true); - } else { - isSleeping.set(false); } } } 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 20be8fe12b9..ff4cdb7340f 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 @@ -439,7 +439,8 @@ private void setupTimeline(Timeline timeline) { /* bufferedPositionUs= */ 0, /* totalBufferedDurationUs= */ 0, /* positionUs= */ 0, - /* offloadSchedulingEnabled= */ false); + /* offloadSchedulingEnabled= */ false, + /* sleepingForOffload= */ false); } private void advance() { 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 6b8f32ef01a..1fe606facad 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 @@ -466,6 +466,32 @@ public void onExperimentalOffloadSchedulingEnabledChanged( return Assertions.checkNotNull(offloadSchedulingEnabledReceiver.get()); } + /** + * Runs tasks of the main {@link Looper} until a {@link + * Player.EventListener#onExperimentalSleepingForOffloadChanged(boolean)} callback occurred. + * + * @param player The {@link Player}. + * @param expectedSleepForOffload The expected sleep of offload state. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. + */ + public static void runUntilSleepingForOffload(Player player, boolean expectedSleepForOffload) + throws TimeoutException { + verifyMainTestThread(player); + AtomicBoolean receiverCallback = new AtomicBoolean(false); + Player.EventListener listener = + new Player.EventListener() { + @Override + public void onExperimentalSleepingForOffloadChanged(boolean sleepingForOffload) { + if (sleepingForOffload == expectedSleepForOffload) { + receiverCallback.set(true); + } + } + }; + player.addListener(listener); + runMainLooperUntil(receiverCallback::get); + } + /** * Runs tasks of the main {@link Looper} until the {@link VideoListener#onRenderedFirstFrame} * callback has been called. @@ -504,7 +530,7 @@ public static void playUntilPosition(ExoPlayer player, int windowIndex, long pos verifyMainTestThread(player); Handler testHandler = Util.createHandlerForCurrentOrMainLooper(); - AtomicBoolean messageHandled = new AtomicBoolean(); + AtomicBoolean messageHandled = new AtomicBoolean(false); player .createMessage( (messageType, payload) -> { From dd99d2362160da70a7d1caefd00767c80a6af81e Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 14:06:30 +0100 Subject: [PATCH 076/693] Remove unnecessary AudioTrack alias Those aliases were introduced when the class was also called AudioTrack. PiperOrigin-RevId: 333499360 --- .../exoplayer2/audio/DefaultAudioSink.java | 43 +++++-------------- 1 file changed, 11 insertions(+), 32 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 478eb0d04b2..5ced4afd7da 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 @@ -18,7 +18,6 @@ import static java.lang.Math.max; import static java.lang.Math.min; -import android.annotation.SuppressLint; import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; @@ -235,28 +234,6 @@ public long getSkippedOutputFrameCount() { /** To avoid underruns on some devices (e.g., Broadcom 7271), scale up the AC3 buffer duration. */ private static final int AC3_BUFFER_MULTIPLICATION_FACTOR = 2; - /** - * @see AudioTrack#ERROR_BAD_VALUE - */ - private static final int ERROR_BAD_VALUE = AudioTrack.ERROR_BAD_VALUE; - /** - * @see AudioTrack#MODE_STATIC - */ - private static final int MODE_STATIC = AudioTrack.MODE_STATIC; - /** - * @see AudioTrack#MODE_STREAM - */ - private static final int MODE_STREAM = AudioTrack.MODE_STREAM; - /** - * @see AudioTrack#STATE_INITIALIZED - */ - private static final int STATE_INITIALIZED = AudioTrack.STATE_INITIALIZED; - /** - * @see AudioTrack#WRITE_NON_BLOCKING - */ - @SuppressLint("InlinedApi") - private static final int WRITE_NON_BLOCKING = AudioTrack.WRITE_NON_BLOCKING; - private static final String TAG = "AudioTrack"; /** @@ -1542,7 +1519,7 @@ private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) channelConfig, encoding, bufferSize, - MODE_STATIC, + AudioTrack.MODE_STATIC, audioSessionId); } @@ -1636,7 +1613,7 @@ private static int getFramesPerEncodedSample(@C.Encoding int encoding, ByteBuffe @RequiresApi(21) private static int writeNonBlockingV21(AudioTrack audioTrack, ByteBuffer buffer, int size) { - return audioTrack.write(buffer, size, WRITE_NON_BLOCKING); + return audioTrack.write(buffer, size, AudioTrack.WRITE_NON_BLOCKING); } @RequiresApi(21) @@ -1644,7 +1621,8 @@ private int writeNonBlockingWithAvSyncV21( AudioTrack audioTrack, ByteBuffer buffer, int size, long presentationTimeUs) { if (Util.SDK_INT >= 26) { // The underlying platform AudioTrack writes AV sync headers directly. - return audioTrack.write(buffer, size, WRITE_NON_BLOCKING, presentationTimeUs * 1000); + return audioTrack.write( + buffer, size, AudioTrack.WRITE_NON_BLOCKING, presentationTimeUs * 1000); } if (avSyncHeader == null) { avSyncHeader = ByteBuffer.allocate(16); @@ -1659,7 +1637,8 @@ private int writeNonBlockingWithAvSyncV21( } int avSyncHeaderBytesRemaining = avSyncHeader.remaining(); if (avSyncHeaderBytesRemaining > 0) { - int result = audioTrack.write(avSyncHeader, avSyncHeaderBytesRemaining, WRITE_NON_BLOCKING); + int result = + audioTrack.write(avSyncHeader, avSyncHeaderBytesRemaining, AudioTrack.WRITE_NON_BLOCKING); if (result < 0) { bytesUntilNextAvSync = 0; return result; @@ -1910,7 +1889,7 @@ public AudioTrack buildAudioTrack( } int state = audioTrack.getState(); - if (state != STATE_INITIALIZED) { + if (state != AudioTrack.STATE_INITIALIZED) { try { audioTrack.release(); } catch (Exception e) { @@ -1957,7 +1936,7 @@ private AudioTrack createAudioTrackV21( getAudioTrackAttributesV21(audioAttributes, tunneling), getAudioFormat(outputSampleRate, outputChannelConfig, outputEncoding), bufferSize, - MODE_STREAM, + AudioTrack.MODE_STREAM, audioSessionId); } @@ -1970,7 +1949,7 @@ private AudioTrack createAudioTrackV9(AudioAttributes audioAttributes, int audio outputChannelConfig, outputEncoding, bufferSize, - MODE_STREAM); + AudioTrack.MODE_STREAM); } else { // Re-attach to the same audio session. return new AudioTrack( @@ -1979,7 +1958,7 @@ private AudioTrack createAudioTrackV9(AudioAttributes audioAttributes, int audio outputChannelConfig, outputEncoding, bufferSize, - MODE_STREAM, + AudioTrack.MODE_STREAM, audioSessionId); } } @@ -2013,7 +1992,7 @@ private int getEncodedDefaultBufferSize(long bufferDurationUs) { private int getPcmDefaultBufferSize(float maxAudioTrackPlaybackSpeed) { int minBufferSize = AudioTrack.getMinBufferSize(outputSampleRate, outputChannelConfig, outputEncoding); - Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); + Assertions.checkState(minBufferSize != AudioTrack.ERROR_BAD_VALUE); int multipliedBufferSize = minBufferSize * BUFFER_MULTIPLICATION_FACTOR; int minAppBufferSize = (int) durationUsToFrames(MIN_BUFFER_DURATION_US) * outputPcmFrameSize; int maxAppBufferSize = From 3b14b05d93b018ada8c5ed1d7a1d262c4a6f9209 Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 24 Sep 2020 14:50:43 +0100 Subject: [PATCH 077/693] Remove MediaCodecOperationMode Remove MediaCodecOperationMode and replace it with a boolean flag to enable/disable asynchronous queueing. PiperOrigin-RevId: 333504817 --- .../exoplayer2/DefaultRenderersFactory.java | 52 ++----------- .../mediacodec/MediaCodecAdapter.java | 3 +- .../mediacodec/MediaCodecRenderer.java | 74 ++----------------- 3 files changed, 17 insertions(+), 112 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 3558a319bac..5d130442b32 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 @@ -28,7 +28,6 @@ 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; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.metadata.MetadataRenderer; @@ -92,8 +91,7 @@ public class DefaultRenderersFactory implements RenderersFactory { private long allowedVideoJoiningTimeMs; private boolean enableDecoderFallback; private MediaCodecSelector mediaCodecSelector; - private @MediaCodecRenderer.MediaCodecOperationMode int audioMediaCodecOperationMode; - private @MediaCodecRenderer.MediaCodecOperationMode int videoMediaCodecOperationMode; + private boolean enableAsyncQueueing; private boolean enableFloatOutput; private boolean enableAudioTrackPlaybackParams; private boolean enableOffload; @@ -104,8 +102,6 @@ public DefaultRenderersFactory(Context context) { extensionRendererMode = EXTENSION_RENDERER_MODE_OFF; allowedVideoJoiningTimeMs = DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; mediaCodecSelector = MediaCodecSelector.DEFAULT; - audioMediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; - videoMediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; } /** @@ -151,48 +147,16 @@ public DefaultRenderersFactory setExtensionRendererMode( } /** - * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} of {@link MediaCodecAudioRenderer} - * instances. + * Enable asynchronous buffer queueing for both {@link MediaCodecAudioRenderer} and {@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. + * @param enabled Whether asynchronous queueing is enabled. * @return This factory, for convenience. */ - public DefaultRenderersFactory experimentalSetAudioMediaCodecOperationMode( - @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 experimentalSetVideoMediaCodecOperationMode( - @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 experimentalSetMediaCodecOperationMode( - @MediaCodecRenderer.MediaCodecOperationMode int mode) { - experimentalSetAudioMediaCodecOperationMode(mode); - experimentalSetVideoMediaCodecOperationMode(mode); + public DefaultRenderersFactory experimentalEnableAsynchronousBufferQueueing(boolean enabled) { + enableAsyncQueueing = enabled; return this; } @@ -372,7 +336,7 @@ protected void buildVideoRenderers( eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); - videoRenderer.experimentalSetMediaCodecOperationMode(videoMediaCodecOperationMode); + videoRenderer.experimentalEnableAsynchronousBufferQueueing(enableAsyncQueueing); out.add(videoRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { @@ -497,7 +461,7 @@ protected void buildAudioRenderers( eventHandler, eventListener, audioSink); - audioRenderer.experimentalSetMediaCodecOperationMode(audioMediaCodecOperationMode); + audioRenderer.experimentalEnableAsynchronousBufferQueueing(enableAsyncQueueing); out.add(audioRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { 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 69875f23674..78bdeade81b 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 @@ -22,13 +22,12 @@ import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.decoder.CryptoInfo; -import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.MediaCodecOperationMode; /** * Abstracts {@link MediaCodec} operations. * *

    {@code MediaCodecAdapter} offers a common interface to interact with a {@link MediaCodec} - * regardless of the {@link MediaCodecOperationMode} the {@link MediaCodec} is operating in. + * regardless of the mode the {@link MediaCodec} is operating in. */ public interface MediaCodecAdapter { 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 de3f595976f..c01d43872e1 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 @@ -54,10 +54,8 @@ import com.google.android.exoplayer2.util.TraceUtil; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayDeque; @@ -69,44 +67,6 @@ */ public abstract class MediaCodecRenderer extends BaseRenderer { - /** - * The modes to operate the {@link MediaCodec}. - * - *

    Allowed values: - * - *

      - *
    • {@link #OPERATION_MODE_SYNCHRONOUS} - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING} - *
    - */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) - @IntDef({ - OPERATION_MODE_SYNCHRONOUS, - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD, - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_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 a dedicated thread. - */ - public static final int OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD = 2; - /** - * 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; - /** Thrown when a failure occurs instantiating a decoder. */ public static class DecoderInitializationException extends Exception { @@ -408,7 +368,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { private boolean outputStreamEnded; private boolean waitingForFirstSampleInFormat; private boolean pendingOutputEndOfStream; - @MediaCodecOperationMode private int mediaCodecOperationMode; + private boolean enableAsynchronousBufferQueueing; @Nullable private ExoPlaybackException pendingPlaybackException; protected DecoderCounters decoderCounters; private long outputStreamStartPositionUs; @@ -441,7 +401,6 @@ public MediaCodecRenderer( decodeOnlyPresentationTimestamps = new ArrayList<>(); outputBufferInfo = new MediaCodec.BufferInfo(); operatingRate = 1f; - 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]; @@ -452,28 +411,16 @@ public MediaCodecRenderer( } /** - * Set the mode of operation of the underlying {@link MediaCodec}. + * Enable asynchronous input buffer queueing. + * + *

    Operates the underlying {@link MediaCodec} in asynchronous mode and submits input buffers + * from a separate thread to unblock the playback thread. * *

    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 mode The mode of the MediaCodec. The supported modes are: - *

      - *
    • {@link #OPERATION_MODE_SYNCHRONOUS}: The {@link MediaCodec} will operate in - * synchronous mode. - *
    • {@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_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. - *
    - * By default, the operation mode is set to {@link - * MediaCodecRenderer#OPERATION_MODE_SYNCHRONOUS}. */ - public void experimentalSetMediaCodecOperationMode(@MediaCodecOperationMode int mode) { - mediaCodecOperationMode = mode; + public void experimentalEnableAsynchronousBufferQueueing(boolean enabled) { + enableAsynchronousBufferQueueing = enabled; } @Override @@ -1108,12 +1055,7 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce codecInitializingTimestamp = SystemClock.elapsedRealtime(); TraceUtil.beginSection("createCodec:" + codecName); codec = MediaCodec.createByCodecName(codecName); - if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD - && Util.SDK_INT >= 23) { - codecAdapter = new AsynchronousMediaCodecAdapter(codec, getTrackType()); - } else if (mediaCodecOperationMode - == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING - && Util.SDK_INT >= 23) { + if (enableAsynchronousBufferQueueing && Util.SDK_INT >= 23) { codecAdapter = new AsynchronousMediaCodecAdapter( codec, /* enableAsynchronousQueueing= */ true, getTrackType()); From cf30ee504e52630662c737f5bb1c80cf6b75eea0 Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 24 Sep 2020 14:51:02 +0100 Subject: [PATCH 078/693] Util.getStringForTime() prefixes negative times Fix bug to place the negative sign in the beginning of the returned String. PiperOrigin-RevId: 333504868 --- .../java/com/google/android/exoplayer2/util/Util.java | 8 ++++++-- .../java/com/google/android/exoplayer2/util/UtilTest.java | 8 ++++++++ 2 files changed, 14 insertions(+), 2 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 cfc7b5a5f32..0c13900330c 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 @@ -17,6 +17,7 @@ import static android.content.Context.UI_MODE_SERVICE; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.abs; import static java.lang.Math.max; import static java.lang.Math.min; @@ -1847,13 +1848,16 @@ public static String getStringForTime(StringBuilder builder, Formatter formatter if (timeMs == C.TIME_UNSET) { timeMs = 0; } + String prefix = timeMs < 0 ? "-" : ""; + timeMs = abs(timeMs); long totalSeconds = (timeMs + 500) / 1000; long seconds = totalSeconds % 60; long minutes = (totalSeconds / 60) % 60; long hours = totalSeconds / 3600; builder.setLength(0); - return hours > 0 ? formatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() - : formatter.format("%02d:%02d", minutes, seconds).toString(); + return hours > 0 + ? formatter.format("%s%d:%02d:%02d", prefix, hours, minutes, seconds).toString() + : formatter.format("%s%02d:%02d", prefix, minutes, seconds).toString(); } /** 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 162dcbae9d0..cda9e054f16 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 @@ -19,6 +19,7 @@ import static com.google.android.exoplayer2.util.Util.binarySearchFloor; import static com.google.android.exoplayer2.util.Util.escapeFileName; import static com.google.android.exoplayer2.util.Util.getCodecsOfType; +import static com.google.android.exoplayer2.util.Util.getStringForTime; import static com.google.android.exoplayer2.util.Util.parseXsDateTime; import static com.google.android.exoplayer2.util.Util.parseXsDuration; import static com.google.android.exoplayer2.util.Util.unescapeFileName; @@ -37,6 +38,7 @@ import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Arrays; +import java.util.Formatter; import java.util.Random; import java.util.zip.Deflater; import org.junit.Test; @@ -1082,6 +1084,12 @@ public void tableExists_withNonExistingTable() { assertThat(Util.tableExists(database, "table")).isFalse(); } + @Test + public void getStringForTime_withNegativeTime_setsNegativePrefix() { + assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ -35000)) + .isEqualTo("-00:35"); + } + private static void assertEscapeUnescapeFileName(String fileName, String escapedFileName) { assertThat(escapeFileName(fileName)).isEqualTo(escapedFileName); assertThat(unescapeFileName(escapedFileName)).isEqualTo(fileName); From d97af76280b834e4279bcddac966e21153430740 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 15:11:06 +0100 Subject: [PATCH 079/693] Retry after offload playback failure Do that by adding a recoverable state to the ExoPlaybackException marking when it is needed to recreate the renderers. PiperOrigin-RevId: 333507849 --- RELEASENOTES.md | 2 + .../android/exoplayer2/BaseRenderer.java | 15 +++- .../exoplayer2/ExoPlaybackException.java | 70 +++++++++++++++---- .../exoplayer2/ExoPlayerImplInternal.java | 39 ++++++++++- .../android/exoplayer2/audio/AudioSink.java | 44 +++++++----- .../audio/DecoderAudioRenderer.java | 13 ++-- .../exoplayer2/audio/DefaultAudioSink.java | 19 +++-- .../audio/MediaCodecAudioRenderer.java | 11 ++- 8 files changed, 166 insertions(+), 47 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 6c94ec1d867..c7555d768b6 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -17,6 +17,8 @@ ([#7866](https://github.com/google/ExoPlayer/issues/7866)). * Text: * Add support for `\h` SSA/ASS style override code (non-breaking space). +* Audio: + * Retry playback after some types of `AudioTrack` error. ### 2.12.0 (2020-09-11) ### 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 351f6c50f21..315431c6e81 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 @@ -341,6 +341,19 @@ protected final int getIndex() { */ protected final ExoPlaybackException createRendererException( Exception cause, @Nullable Format format) { + return createRendererException(cause, format, /* isRecoverable= */ false); + } + + /** + * Creates an {@link ExoPlaybackException} of type {@link ExoPlaybackException#TYPE_RENDERER} for + * this renderer. + * + * @param cause The cause of the exception. + * @param format The current format used by the renderer. May be null. + * @param isRecoverable If the error is recoverable by disabling and re-enabling the renderer. + */ + protected final ExoPlaybackException createRendererException( + Exception cause, @Nullable Format format, boolean isRecoverable) { @FormatSupport int formatSupport = RendererCapabilities.FORMAT_HANDLED; if (format != null && !throwRendererExceptionIsExecuting) { // Prevent recursive re-entry from subclass supportsFormat implementations. @@ -354,7 +367,7 @@ protected final ExoPlaybackException createRendererException( } } return ExoPlaybackException.createForRenderer( - cause, getName(), getIndex(), format, formatSupport); + cause, getName(), getIndex(), format, formatSupport, isRecoverable); } /** 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 93fb4b01186..d69b747f8dc 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 @@ -29,9 +29,7 @@ import java.lang.annotation.RetentionPolicy; import java.util.concurrent.TimeoutException; -/** - * Thrown when a non-recoverable playback failure occurs. - */ +/** Thrown when a non locally recoverable playback failure occurs. */ public final class ExoPlaybackException extends Exception { /** @@ -138,6 +136,17 @@ public final class ExoPlaybackException extends Exception { */ @Nullable public final MediaSource.MediaPeriodId mediaPeriodId; + /** + * Whether the error may be recoverable. + * + *

    This is only used internally by ExoPlayer to try to recover from some errors and should not + * be used by apps. + * + *

    If the {@link #type} is {@link #TYPE_RENDERER}, it may be possible to recover from the error + * by disabling and re-enabling the renderers. + */ + /* package */ final boolean isRecoverable; + @Nullable private final Throwable cause; /** @@ -167,6 +176,34 @@ public static ExoPlaybackException createForRenderer( int rendererIndex, @Nullable Format rendererFormat, @FormatSupport int rendererFormatSupport) { + return createForRenderer( + cause, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + /* isRecoverable= */ false); + } + + /** + * Creates an instance of type {@link #TYPE_RENDERER}. + * + * @param cause The cause of the failure. + * @param rendererIndex The index of the renderer in which the failure occurred. + * @param rendererFormat The {@link Format} the renderer was using at the time of the exception, + * or null if the renderer wasn't using a {@link Format}. + * @param rendererFormatSupport The {@link FormatSupport} of the renderer for {@code + * rendererFormat}. Ignored if {@code rendererFormat} is null. + * @param isRecoverable If the failure can be recovered by disabling and re-enabling the renderer. + * @return The created instance. + */ + public static ExoPlaybackException createForRenderer( + Exception cause, + String rendererName, + int rendererIndex, + @Nullable Format rendererFormat, + @FormatSupport int rendererFormatSupport, + boolean isRecoverable) { return new ExoPlaybackException( TYPE_RENDERER, cause, @@ -175,7 +212,8 @@ public static ExoPlaybackException createForRenderer( rendererIndex, rendererFormat, rendererFormat == null ? RendererCapabilities.FORMAT_HANDLED : rendererFormatSupport, - TIMEOUT_OPERATION_UNDEFINED); + TIMEOUT_OPERATION_UNDEFINED, + isRecoverable); } /** @@ -225,7 +263,8 @@ public static ExoPlaybackException createForTimeout( /* rendererIndex= */ C.INDEX_UNSET, /* rendererFormat= */ null, /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, - timeoutOperation); + timeoutOperation, + /* isRecoverable= */ false); } private ExoPlaybackException(@Type int type, Throwable cause) { @@ -237,7 +276,8 @@ private ExoPlaybackException(@Type int type, Throwable cause) { /* rendererIndex= */ C.INDEX_UNSET, /* rendererFormat= */ null, /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, - TIMEOUT_OPERATION_UNDEFINED); + TIMEOUT_OPERATION_UNDEFINED, + /* isRecoverable= */ false); } private ExoPlaybackException(@Type int type, String message) { @@ -249,7 +289,8 @@ private ExoPlaybackException(@Type int type, String message) { /* rendererIndex= */ C.INDEX_UNSET, /* rendererFormat= */ null, /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, - /* timeoutOperation= */ TIMEOUT_OPERATION_UNDEFINED); + /* timeoutOperation= */ TIMEOUT_OPERATION_UNDEFINED, + /* isRecoverable= */ false); } private ExoPlaybackException( @@ -260,7 +301,8 @@ private ExoPlaybackException( int rendererIndex, @Nullable Format rendererFormat, @FormatSupport int rendererFormatSupport, - @TimeoutOperation int timeoutOperation) { + @TimeoutOperation int timeoutOperation, + boolean isRecoverable) { this( deriveMessage( type, @@ -277,7 +319,8 @@ private ExoPlaybackException( rendererFormatSupport, /* mediaPeriodId= */ null, timeoutOperation, - /* timestampMs= */ SystemClock.elapsedRealtime()); + /* timestampMs= */ SystemClock.elapsedRealtime(), + isRecoverable); } private ExoPlaybackException( @@ -290,7 +333,8 @@ private ExoPlaybackException( @FormatSupport int rendererFormatSupport, @Nullable MediaSource.MediaPeriodId mediaPeriodId, @TimeoutOperation int timeoutOperation, - long timestampMs) { + long timestampMs, + boolean isRecoverable) { super(message, cause); this.type = type; this.cause = cause; @@ -301,6 +345,7 @@ private ExoPlaybackException( this.mediaPeriodId = mediaPeriodId; this.timeoutOperation = timeoutOperation; this.timestampMs = timestampMs; + this.isRecoverable = isRecoverable; } /** @@ -360,7 +405,7 @@ public TimeoutException getTimeoutException() { * @return The copied exception. */ @CheckResult - /* package= */ ExoPlaybackException copyWithMediaPeriodId( + /* package */ ExoPlaybackException copyWithMediaPeriodId( @Nullable MediaSource.MediaPeriodId mediaPeriodId) { return new ExoPlaybackException( getMessage(), @@ -372,7 +417,8 @@ public TimeoutException getTimeoutException() { rendererFormatSupport, mediaPeriodId, timeoutOperation, - timestampMs); + timestampMs, + isRecoverable); } @Nullable 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 0752c089494..45af6d601f3 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 @@ -142,6 +142,7 @@ public interface PlaybackInfoUpdateListener { private static final int MSG_PLAYLIST_UPDATE_REQUESTED = 22; private static final int MSG_SET_PAUSE_AT_END_OF_WINDOW = 23; private static final int MSG_SET_OFFLOAD_SCHEDULING_ENABLED = 24; + private static final int MSG_ATTEMPT_ERROR_RECOVERY = 25; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; @@ -196,6 +197,7 @@ public interface PlaybackInfoUpdateListener { private long rendererPositionUs; private int nextPendingMessageIndexHint; private boolean deliverPendingMessageAtStartPositionRequired; + @Nullable private ExoPlaybackException pendingRecoverableError; private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; @@ -525,6 +527,9 @@ public boolean handleMessage(Message msg) { case MSG_SET_OFFLOAD_SCHEDULING_ENABLED: setOffloadSchedulingEnabledInternal(msg.arg1 == 1); break; + case MSG_ATTEMPT_ERROR_RECOVERY: + attemptErrorRecovery((ExoPlaybackException) msg.obj); + break; case MSG_RELEASE: releaseInternal(); // Return immediately to not send playback info updates after release. @@ -542,9 +547,22 @@ public boolean handleMessage(Message msg) { e = e.copyWithMediaPeriodId(readingPeriod.info.id); } } - Log.e(TAG, "Playback error", e); - stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false); - playbackInfo = playbackInfo.copyWithPlaybackError(e); + if (e.isRecoverable && pendingRecoverableError == null) { + Log.w(TAG, "Recoverable playback error", e); + pendingRecoverableError = e; + Message message = handler.obtainMessage(MSG_ATTEMPT_ERROR_RECOVERY, e); + // Given that the player is now in an unhandled exception state, the error needs to be + // recovered or the player stopped before any other message is handled. + message.getTarget().sendMessageAtFrontOfQueue(message); + } else { + if (pendingRecoverableError != null) { + e.addSuppressed(pendingRecoverableError); + pendingRecoverableError = null; + } + Log.e(TAG, "Playback error", e); + stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false); + playbackInfo = playbackInfo.copyWithPlaybackError(e); + } maybeNotifyPlaybackInfoChanged(); } catch (IOException e) { ExoPlaybackException error = ExoPlaybackException.createForSource(e); @@ -572,6 +590,19 @@ public boolean handleMessage(Message msg) { // Private methods. + private void attemptErrorRecovery(ExoPlaybackException exceptionToRecoverFrom) + throws ExoPlaybackException { + Assertions.checkArgument( + exceptionToRecoverFrom.isRecoverable + && exceptionToRecoverFrom.type == ExoPlaybackException.TYPE_RENDERER); + try { + seekToCurrentPosition(/* sendDiscontinuity= */ true); + } catch (Exception e) { + exceptionToRecoverFrom.addSuppressed(e); + throw exceptionToRecoverFrom; + } + } + /** * Blocks the current thread until a condition becomes true. * @@ -929,6 +960,7 @@ private void doSomeWork() throws ExoPlaybackException, IOException { } else if (playbackInfo.playbackState == Player.STATE_BUFFERING && shouldTransitionToReadyState(renderersAllowPlayback)) { setState(Player.STATE_READY); + pendingRecoverableError = null; // Any pending error was successfully recovered from. if (shouldPlayWhenReady()) { startRenderers(); } @@ -1318,6 +1350,7 @@ private void resetInternal( if (releaseMediaSourceList) { mediaSourceList.release(); } + pendingRecoverableError = null; } private Pair getPlaceholderFirstMediaPeriodPosition(Timeline timeline) { 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 b7d375fd9dc..4f9e007d860 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 @@ -136,34 +136,41 @@ public ConfigurationException(String message) { } - /** - * Thrown when a failure occurs initializing the sink. - */ + /** Thrown when a failure occurs initializing the sink. */ final class InitializationException extends Exception { - /** - * The underlying {@link AudioTrack}'s state, if applicable. - */ + /** The underlying {@link AudioTrack}'s state. */ public final int audioTrackState; + /** If the exception can be recovered by recreating the sink. */ + public final boolean isRecoverable; /** - * @param audioTrackState The underlying {@link AudioTrack}'s state, if applicable. * @param sampleRate The requested sample rate in Hz. * @param channelConfig The requested channel configuration. * @param bufferSize The requested buffer size in bytes. + * @param audioTrackException Exception thrown during the creation of the {@link AudioTrack}. */ - public InitializationException(int audioTrackState, int sampleRate, int channelConfig, - int bufferSize) { - super("AudioTrack init failed: " + audioTrackState + ", Config(" + sampleRate + ", " - + channelConfig + ", " + bufferSize + ")"); + public InitializationException( + int audioTrackState, + int sampleRate, + int channelConfig, + int bufferSize, + boolean isRecoverable, + @Nullable Exception audioTrackException) { + super( + "AudioTrack init failed " + + audioTrackState + + " " + + ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")") + + (isRecoverable ? " (recoverable)" : ""), + audioTrackException); this.audioTrackState = audioTrackState; + this.isRecoverable = isRecoverable; } } - /** - * Thrown when a failure occurs writing to the sink. - */ + /** Thrown when a failure occurs writing to the sink. */ final class WriteException extends Exception { /** @@ -173,12 +180,13 @@ final class WriteException extends Exception { * Otherwise, the meaning of the error code depends on the sink implementation. */ public final int errorCode; + /** If the exception can be recovered by recreating the sink. */ + public final boolean isRecoverable; - /** - * @param errorCode The error value returned from the sink implementation. - */ - public WriteException(int errorCode) { + /** @param errorCode The error value returned from the sink implementation. */ + public WriteException(int errorCode, boolean isRecoverable) { super("AudioTrack write failed: " + errorCode); + this.isRecoverable = isRecoverable; this.errorCode = errorCode; } 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 1c1e593e223..0391fc95c99 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 @@ -257,7 +257,7 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx try { audioSink.playToEndOfStream(); } catch (AudioSink.WriteException e) { - throw createRendererException(e, inputFormat); + throw createRendererException(e, inputFormat, e.isRecoverable); } return; } @@ -296,11 +296,12 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx while (drainOutputBuffer()) {} while (feedInputBuffer()) {} TraceUtil.endSection(); - } catch (DecoderException - | AudioSink.ConfigurationException - | AudioSink.InitializationException - | AudioSink.WriteException e) { + } catch (DecoderException | AudioSink.ConfigurationException e) { throw createRendererException(e, inputFormat); + } catch (AudioSink.InitializationException e) { + throw createRendererException(e, inputFormat, e.isRecoverable); + } catch (AudioSink.WriteException e) { + throw createRendererException(e, inputFormat, e.isRecoverable); } decoderCounters.ensureUpdated(); } @@ -383,7 +384,7 @@ private boolean drainOutputBuffer() try { processEndOfStream(); } catch (AudioSink.WriteException e) { - throw createRendererException(e, getOutputFormat(decoder)); + throw createRendererException(e, getOutputFormat(decoder), e.isRecoverable); } } return 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 5ced4afd7da..78a62ed8b31 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 @@ -908,7 +908,7 @@ private void writeBuffer(ByteBuffer buffer, long avSyncPresentationTimeUs) throw if (isRecoverable) { maybeDisableOffload(); } - throw new WriteException(bytesWritten); + throw new WriteException(bytesWritten, isRecoverable); } if (isOffloadedPlayback(audioTrack)) { @@ -1883,9 +1883,14 @@ public AudioTrack buildAudioTrack( AudioTrack audioTrack; try { audioTrack = createAudioTrack(tunneling, audioAttributes, audioSessionId); - } catch (UnsupportedOperationException e) { + } catch (UnsupportedOperationException | IllegalArgumentException e) { throw new InitializationException( - AudioTrack.STATE_UNINITIALIZED, outputSampleRate, outputChannelConfig, bufferSize); + AudioTrack.STATE_UNINITIALIZED, + outputSampleRate, + outputChannelConfig, + bufferSize, + /* isRecoverable= */ outputModeIsOffload(), + e); } int state = audioTrack.getState(); @@ -1896,7 +1901,13 @@ public AudioTrack buildAudioTrack( // The track has already failed to initialize, so it wouldn't be that surprising if // release were to fail too. Swallow the exception. } - throw new InitializationException(state, outputSampleRate, outputChannelConfig, bufferSize); + throw new InitializationException( + state, + outputSampleRate, + outputChannelConfig, + bufferSize, + /* isRecoverable= */ outputModeIsOffload(), + /* audioTrackException= */ null); } return audioTrack; } 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 2d034335c8d..75bc7d3b1a6 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 @@ -37,6 +37,8 @@ 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.InitializationException; +import com.google.android.exoplayer2.audio.AudioSink.WriteException; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; @@ -616,8 +618,10 @@ && getLargestQueuedPresentationTimeUs() != C.TIME_UNSET) { boolean fullyConsumed; try { fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount); - } catch (AudioSink.InitializationException | AudioSink.WriteException e) { - throw createRendererException(e, format); + } catch (InitializationException e) { + throw createRendererException(e, format, e.isRecoverable); + } catch (WriteException e) { + throw createRendererException(e, format, e.isRecoverable); } if (fullyConsumed) { @@ -637,7 +641,8 @@ protected void renderToEndOfStream() throws ExoPlaybackException { audioSink.playToEndOfStream(); } catch (AudioSink.WriteException e) { @Nullable Format outputFormat = getOutputFormat(); - throw createRendererException(e, outputFormat != null ? outputFormat : getInputFormat()); + throw createRendererException( + e, outputFormat != null ? outputFormat : getInputFormat(), e.isRecoverable); } } From aa7309cdea0cf51951db583cb166b757fd44ac3b Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 15:43:53 +0100 Subject: [PATCH 080/693] Release wakelock when sleeping for offload #exo-offload PiperOrigin-RevId: 333512383 --- .../com/google/android/exoplayer2/SimpleExoPlayer.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 dd41d8e2bc1..baa1400143d 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 @@ -2413,5 +2413,15 @@ public void onPlayWhenReadyChanged( boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { updateWakeAndWifiLock(); } + + @Override + public void onExperimentalSleepingForOffloadChanged(boolean sleepingForOffload) { + if (sleepingForOffload) { + // The wifi lock is not released to avoid interrupting downloads. + wakeLockManager.setStayAwake(false); + } else { + updateWakeAndWifiLock(); + } + } } } From fad2846d1c4e98e1a782ccaa42f03b85ef74d46c Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 15:51:28 +0100 Subject: [PATCH 081/693] Workaround AudioTrack incorrect error code #exo-offload PiperOrigin-RevId: 333513385 --- .../exoplayer2/audio/DefaultAudioSink.java | 15 ++++++++++++++- 1 file changed, 14 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 78a62ed8b31..ee6dba839c7 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 @@ -234,6 +234,18 @@ public long getSkippedOutputFrameCount() { /** To avoid underruns on some devices (e.g., Broadcom 7271), scale up the AC3 buffer duration. */ private static final int AC3_BUFFER_MULTIPLICATION_FACTOR = 2; + /** + * Native error code equivalent of {@link AudioTrack#ERROR_DEAD_OBJECT} to workaround missing + * error code translation on some devices. + * + *

    On some devices, AudioTrack native error codes are not always converted to their SDK + * equivalent. + * + *

    For example: {@link AudioTrack#write(byte[], int, int)} can return -32 instead of {@link + * AudioTrack#ERROR_DEAD_OBJECT}. + */ + private static final int ERROR_NATIVE_DEAD_OBJECT = -32; + private static final String TAG = "AudioTrack"; /** @@ -966,7 +978,8 @@ private void maybeDisableOffload() { } private static boolean isAudioTrackDeadObject(int status) { - return Util.SDK_INT >= 24 && status == AudioTrack.ERROR_DEAD_OBJECT; + return (Util.SDK_INT >= 24 && status == AudioTrack.ERROR_DEAD_OBJECT) + || status == ERROR_NATIVE_DEAD_OBJECT; } private boolean drainToEndOfStream() throws WriteException { From 55a13d8871ec982ab261c2d0078e1f406576915a Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 16:32:49 +0100 Subject: [PATCH 082/693] Callback on audio track failure Intended for statistics now that all errors are not surfaced to the app. PiperOrigin-RevId: 333519898 --- .../analytics/AnalyticsCollector.java | 8 ++++ .../analytics/AnalyticsListener.java | 11 +++++ .../audio/AudioRendererEventListener.java | 32 ++++++++++++++ .../android/exoplayer2/audio/AudioSink.java | 24 +++++++++++ .../audio/DecoderAudioRenderer.java | 5 +++ .../exoplayer2/audio/DefaultAudioSink.java | 30 ++++++++----- .../audio/MediaCodecAudioRenderer.java | 5 +++ .../audio/MediaCodecAudioRendererTest.java | 42 ++++++++++++++++++- 8 files changed, 145 insertions(+), 12 deletions(-) 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 35f3099dc94..30321c59728 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 @@ -258,6 +258,14 @@ public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { } } + @Override + public void onAudioSinkError(Exception audioSinkError) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioSinkError(eventTime, audioSinkError); + } + } + @Override public void onVolumeChanged(float audioVolume) { EventTime eventTime = generateReadingMediaPeriodEventTime(); 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 2e26019541e..7e5abbd803e 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,6 +28,7 @@ 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; @@ -525,6 +526,16 @@ default void onAudioAttributesChanged(EventTime eventTime, AudioAttributes audio */ default void onSkipSilenceEnabledChanged(EventTime eventTime, boolean skipSilenceEnabled) {} + /** + * Called when {@link AudioSink} has encountered an error. These errors are just for informational + * purposes and the player may recover. + * + * @param eventTime The event time. + * @param audioSinkError Either a {@link AudioSink.InitializationException} or a {@link + * AudioSink.WriteException} describing the error. + */ + default void onAudioSinkError(EventTime eventTime, Exception audioSinkError) {} + /** * Called when the volume changes. * 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 f921141f24e..e51948725b0 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 @@ -17,11 +17,14 @@ import static com.google.android.exoplayer2.util.Util.castNonNull; +import android.media.AudioTrack; import android.os.Handler; import android.os.SystemClock; 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.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.util.Assertions; @@ -98,6 +101,28 @@ default void onAudioDisabled(DecoderCounters counters) {} */ default void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) {} + /** + * Called when {@link AudioSink} has encountered an error. + * + *

    If the sink writes to a platform {@link AudioTrack}, this will called for all {@link + * AudioTrack} errors. + * + *

    This method being called does not indicate that playback has failed, or that it will fail. + * The player may be able to recover from the error (for example by recreating the AudioTrack, + * possibly with different settings) and continue. Hence applications should not + * implement this method to display a user visible error or initiate an application level retry + * ({@link Player.EventListener#onPlayerError} is the appropriate place to implement such + * behavior). This method is called to provide the application with an opportunity to log the + * error if it wishes to do so. + * + *

    Fatal errors that cannot be recovered will be reported wrapped in a {@link + * ExoPlaybackException} by {@link Player.EventListener#onPlayerError(ExoPlaybackException)}. + * + * @param audioSinkError Either an {@link AudioSink.InitializationException} or a {@link + * AudioSink.WriteException} describing the error. + */ + default void onAudioSinkError(Exception audioSinkError) {} + /** Dispatches events to an {@link AudioRendererEventListener}. */ final class EventDispatcher { @@ -184,5 +209,12 @@ public void skipSilenceEnabledChanged(boolean skipSilenceEnabled) { handler.post(() -> castNonNull(listener).onSkipSilenceEnabledChanged(skipSilenceEnabled)); } } + + /** Invokes {@link AudioRendererEventListener#onAudioSinkError(Exception)}. */ + public void audioSinkError(Exception audioSinkError) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onAudioSinkError(audioSinkError)); + } + } } } 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 4f9e007d860..c3351ffbad3 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 @@ -19,8 +19,10 @@ import androidx.annotation.IntDef; 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.PlaybackParameters; +import com.google.android.exoplayer2.Player; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -113,6 +115,28 @@ default void onOffloadBufferEmptying() {} * #onOffloadBufferEmptying()} will be called. */ default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {} + + /** + * Called when {@link AudioSink} has encountered an error. + * + *

    If the sink writes to a platform {@link AudioTrack}, this will called for all {@link + * AudioTrack} errors. + * + *

    This method being called does not indicate that playback has failed, or that it will fail. + * The player may be able to recover from the error (for example by recreating the AudioTrack, + * possibly with different settings) and continue. Hence applications should not + * implement this method to display a user visible error or initiate an application level retry + * ({@link Player.EventListener#onPlayerError} is the appropriate place to implement such + * behavior). This method is called to provide the application with an opportunity to log the + * error if it wishes to do so. + * + *

    Fatal errors that cannot be recovered will be reported wrapped in a {@link + * ExoPlaybackException} by {@link Player.EventListener#onPlayerError(ExoPlaybackException)}. + * + * @param audioSinkError Either an {@link AudioSink.InitializationException} or a {@link + * AudioSink.WriteException} describing the error. + */ + default void onAudioSinkError(Exception audioSinkError) {} } /** 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 0391fc95c99..c8f3d958d6d 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 @@ -724,5 +724,10 @@ public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastF public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); } + + @Override + public void onAudioSinkError(Exception audioSinkError) { + eventDispatcher.audioSinkError(audioSinkError); + } } } 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 ee6dba839c7..7701cd2b18b 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 @@ -827,6 +827,9 @@ private AudioTrack buildAudioTrack() throws InitializationException { .buildAudioTrack(tunneling, audioAttributes, audioSessionId); } catch (InitializationException e) { maybeDisableOffload(); + if (listener != null) { + listener.onAudioSinkError(e); + } throw e; } } @@ -892,36 +895,43 @@ private void writeBuffer(ByteBuffer buffer, long avSyncPresentationTimeUs) throw } } int bytesRemaining = buffer.remaining(); - int bytesWritten = 0; + int bytesWrittenOrError = 0; // Error if negative if (Util.SDK_INT < 21) { // outputMode == OUTPUT_MODE_PCM. // Work out how many bytes we can write without the risk of blocking. int bytesToWrite = audioTrackPositionTracker.getAvailableBufferSize(writtenPcmBytes); if (bytesToWrite > 0) { bytesToWrite = min(bytesRemaining, bytesToWrite); - bytesWritten = audioTrack.write(preV21OutputBuffer, preV21OutputBufferOffset, bytesToWrite); - if (bytesWritten > 0) { - preV21OutputBufferOffset += bytesWritten; - buffer.position(buffer.position() + bytesWritten); + bytesWrittenOrError = + audioTrack.write(preV21OutputBuffer, preV21OutputBufferOffset, bytesToWrite); + if (bytesWrittenOrError > 0) { // No error + preV21OutputBufferOffset += bytesWrittenOrError; + buffer.position(buffer.position() + bytesWrittenOrError); } } } else if (tunneling) { Assertions.checkState(avSyncPresentationTimeUs != C.TIME_UNSET); - bytesWritten = + bytesWrittenOrError = writeNonBlockingWithAvSyncV21( audioTrack, buffer, bytesRemaining, avSyncPresentationTimeUs); } else { - bytesWritten = writeNonBlockingV21(audioTrack, buffer, bytesRemaining); + bytesWrittenOrError = writeNonBlockingV21(audioTrack, buffer, bytesRemaining); } lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); - if (bytesWritten < 0) { - boolean isRecoverable = isAudioTrackDeadObject(bytesWritten); + if (bytesWrittenOrError < 0) { + int error = bytesWrittenOrError; + boolean isRecoverable = isAudioTrackDeadObject(error); if (isRecoverable) { maybeDisableOffload(); } - throw new WriteException(bytesWritten, isRecoverable); + WriteException e = new WriteException(error, isRecoverable); + if (listener != null) { + listener.onAudioSinkError(e); + } + throw e; } + int bytesWritten = bytesWrittenOrError; if (isOffloadedPlayback(audioTrack)) { // After calling AudioTrack.setOffloadEndOfStream, the AudioTrack internally stops and 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 75bc7d3b1a6..e051aa1a3fb 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 @@ -862,5 +862,10 @@ public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) { wakeupListener.onSleep(bufferEmptyingDeadlineMs); } } + + @Override + public void onAudioSinkError(Exception audioSinkError) { + eventDispatcher.audioSinkError(audioSinkError); + } } } 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 922431d2108..d3423485fb2 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 @@ -22,10 +22,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; import android.media.MediaFormat; +import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; @@ -46,6 +50,7 @@ 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; @@ -71,6 +76,7 @@ public class MediaCodecAudioRendererTest { private MediaCodecSelector mediaCodecSelector; @Mock private AudioSink audioSink; + @Mock private AudioRendererEventListener audioRendererEventListener; @Before public void setUp() throws Exception { @@ -94,13 +100,15 @@ public void setUp() throws Exception { /* forceDisableAdaptive= */ false, /* forceSecure= */ false)); + Handler eventHandler = new Handler(Looper.getMainLooper()); + mediaCodecAudioRenderer = new MediaCodecAudioRenderer( ApplicationProvider.getApplicationContext(), mediaCodecSelector, /* enableDecoderFallback= */ false, - /* eventHandler= */ null, - /* eventListener= */ null, + eventHandler, + audioRendererEventListener, audioSink); } @@ -279,6 +287,36 @@ protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaF exceptionThrowingRenderer.render(/* positionUs= */ 750, SystemClock.elapsedRealtime() * 1000); } + @Test + public void + render_callsAudioRendererEventListener_whenAudioSinkListenerOnAudioSessionIdIsCalled() { + final ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AudioSink.Listener.class); + verify(audioSink, atLeastOnce()).setListener(listenerCaptor.capture()); + AudioSink.Listener audioSinkListener = listenerCaptor.getValue(); + + int audioSessionId = 2; + audioSinkListener.onAudioSessionId(audioSessionId); + + shadowOf(Looper.getMainLooper()).idle(); + verify(audioRendererEventListener).onAudioSessionId(audioSessionId); + } + + @Test + public void + render_callsAudioRendererEventListener_whenAudioSinkListenerOnAudioSinkErrorIsCalled() { + final ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AudioSink.Listener.class); + verify(audioSink, atLeastOnce()).setListener(listenerCaptor.capture()); + AudioSink.Listener audioSinkListener = listenerCaptor.getValue(); + + Exception error = new AudioSink.WriteException(/* errorCode= */ 1, /* isRecoverable= */ true); + audioSinkListener.onAudioSinkError(error); + + shadowOf(Looper.getMainLooper()).idle(); + verify(audioRendererEventListener).onAudioSinkError(error); + } + private static Format getAudioSinkFormat(Format inputFormat) { return new Format.Builder() .setSampleMimeType(MimeTypes.AUDIO_RAW) From 56cb327f1e714fc7caf83d21ce82fcc3288b58e0 Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 17:41:22 +0100 Subject: [PATCH 083/693] Clarify offload stream event callback impl #exo-offload PiperOrigin-RevId: 333532900 --- .../android/exoplayer2/audio/DefaultAudioSink.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 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 7701cd2b18b..3a18ff8af89 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 @@ -1707,17 +1707,19 @@ public StreamEventCallbackV29() { @Override public void onDataRequest(AudioTrack track, int size) { - Assertions.checkState(track == DefaultAudioSink.this.audioTrack); - if (listener != null) { + Assertions.checkState(track == audioTrack); + if (listener != null && playing) { + // Do not signal that the buffer is emptying if not playing as it is a transient state. listener.onOffloadBufferEmptying(); } } @Override public void onTearDown(@NonNull AudioTrack track) { + Assertions.checkState(track == audioTrack); if (listener != null && playing) { - // A new Audio Track needs to be created and it's buffer filled, which will be done on the - // next handleBuffer call. + // The audio track was destroyed while in use. Thus a new AudioTrack needs to be created + // and its buffer filled, which will be done on the next handleBuffer call. // Request this call explicitly in case ExoPlayer is sleeping waiting for a data request. listener.onOffloadBufferEmptying(); } From 7dfdde9246b28ad977ebe712e485033ee688652d Mon Sep 17 00:00:00 2001 From: krocard Date: Thu, 24 Sep 2020 18:00:47 +0100 Subject: [PATCH 084/693] Retry AudioTrack init and write before throwing Retry AudioTrack init and write for 100ms before giving up and aborting playback. This was tested by throwing every 2 init/write and making sure playback did not stopped. #exo-offload PiperOrigin-RevId: 333536841 --- .../exoplayer2/audio/DefaultAudioSink.java | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 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 3a18ff8af89..19fbcb6d6a6 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 @@ -246,6 +246,12 @@ public long getSkippedOutputFrameCount() { */ private static final int ERROR_NATIVE_DEAD_OBJECT = -32; + /** + * The duration for which failed attempts to initialize or write to the audio track may be retried + * before throwing an exception, in milliseconds. + */ + private static final int AUDIO_TRACK_RETRY_DURATION_MS = 100; + private static final String TAG = "AudioTrack"; /** @@ -279,6 +285,9 @@ public long getSkippedOutputFrameCount() { private final boolean enableAudioTrackPlaybackParams; private final boolean enableOffload; @MonotonicNonNull private StreamEventCallbackV29 offloadStreamEventCallbackV29; + private final PendingExceptionHolder + initializationExceptionPendingExceptionHolder; + private final PendingExceptionHolder writeExceptionPendingExceptionHolder; @Nullable private Listener listener; /** @@ -425,6 +434,10 @@ public DefaultAudioSink( activeAudioProcessors = new AudioProcessor[0]; outputBuffers = new ByteBuffer[0]; mediaPositionParametersCheckpoints = new ArrayDeque<>(); + initializationExceptionPendingExceptionHolder = + new PendingExceptionHolder<>(AUDIO_TRACK_RETRY_DURATION_MS); + writeExceptionPendingExceptionHolder = + new PendingExceptionHolder<>(AUDIO_TRACK_RETRY_DURATION_MS); } // AudioSink implementation. @@ -710,8 +723,17 @@ public boolean handleBuffer( } if (!isAudioTrackInitialized()) { - initializeAudioTrack(); + try { + initializeAudioTrack(); + } catch (InitializationException e) { + if (e.isRecoverable) { + throw e; // Do not delay the exception if it can be recovered at higher level. + } + initializationExceptionPendingExceptionHolder.throwExceptionIfDeadlineIsReached(e); + return false; + } } + initializationExceptionPendingExceptionHolder.clear(); if (startMediaTimeUsNeedsInit) { startMediaTimeUs = max(0, presentationTimeUs); @@ -929,8 +951,14 @@ private void writeBuffer(ByteBuffer buffer, long avSyncPresentationTimeUs) throw if (listener != null) { listener.onAudioSinkError(e); } - throw e; + if (e.isRecoverable) { + throw e; // Do not delay the exception if it can be recovered at higher level. + } + writeExceptionPendingExceptionHolder.throwExceptionIfDeadlineIsReached(e); + return; } + writeExceptionPendingExceptionHolder.clear(); + int bytesWritten = bytesWrittenOrError; if (isOffloadedPlayback(audioTrack)) { @@ -1182,6 +1210,8 @@ public void run() { } }.start(); } + writeExceptionPendingExceptionHolder.clear(); + initializationExceptionPendingExceptionHolder.clear(); } @Override @@ -1193,6 +1223,9 @@ public void experimentalFlushWithoutAudioTrackRelease() { return; } + writeExceptionPendingExceptionHolder.clear(); + initializationExceptionPendingExceptionHolder.clear(); + if (!isAudioTrackInitialized()) { return; } @@ -2065,4 +2098,37 @@ public boolean outputModeIsOffload() { return outputMode == OUTPUT_MODE_OFFLOAD; } } + + private static final class PendingExceptionHolder { + + private final long throwDelayMs; + + @Nullable private T pendingException; + private long throwDeadlineMs; + + public PendingExceptionHolder(long throwDelayMs) { + this.throwDelayMs = throwDelayMs; + } + + public void throwExceptionIfDeadlineIsReached(T exception) throws T { + long nowMs = SystemClock.elapsedRealtime(); + if (pendingException == null) { + pendingException = exception; + throwDeadlineMs = nowMs + throwDelayMs; + } + if (nowMs >= throwDeadlineMs) { + if (pendingException != exception) { + // All retry exception are probably the same, thus only save the last one to save memory. + pendingException.addSuppressed(exception); + } + T pendingException = this.pendingException; + clear(); + throw pendingException; + } + } + + public void clear() { + pendingException = null; + } + } } From 006658649915edec7cc515e4ed9745e96eca9575 Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 25 Sep 2020 11:18:09 +0100 Subject: [PATCH 085/693] Remove SynchronousMediaCodecBufferEnqueuer Remove the SynchronousMediaCodecBufferEnqueuer interface since we only keep the AsynchronousMediaCodecBufferEnqueuer implementation. PiperOrigin-RevId: 333701115 --- .../AsynchronousMediaCodecAdapter.java | 34 +------ .../AsynchronousMediaCodecBufferEnqueuer.java | 95 ++++++++++--------- .../MediaCodecInputBufferEnqueuer.java | 56 ----------- .../mediacodec/MediaCodecRenderer.java | 5 +- .../SynchronousMediaCodecBufferEnqueuer.java | 59 ------------ .../AsynchronousMediaCodecAdapterTest.java | 1 - ...nchronousMediaCodecBufferEnqueuerTest.java | 31 +++++- 7 files changed, 87 insertions(+), 194 deletions(-) delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInputBufferEnqueuer.java delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecBufferEnqueuer.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 index 84ff985495b..eb4754c50f0 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 @@ -69,59 +69,33 @@ private long pendingFlushCount; private @State int state; - private final MediaCodecInputBufferEnqueuer bufferEnqueuer; + private final AsynchronousMediaCodecBufferEnqueuer bufferEnqueuer; @GuardedBy("lock") @Nullable private IllegalStateException internalException; - /** - * Creates an instance that wraps the specified {@link MediaCodec}. Instances created with this - * constructor will queue input buffers to the {@link MediaCodec} 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 */ AsynchronousMediaCodecAdapter(MediaCodec codec, int trackType) { - this( - codec, - /* enableAsynchronousQueueing= */ false, - trackType, - new HandlerThread(createThreadLabel(trackType))); - } - /** * Creates an 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 */ AsynchronousMediaCodecAdapter( - MediaCodec codec, boolean enableAsynchronousQueueing, int trackType) { - this( - codec, - enableAsynchronousQueueing, - trackType, - new HandlerThread(createThreadLabel(trackType))); + /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, int trackType) { + this(codec, trackType, new HandlerThread(createThreadLabel(trackType))); } @VisibleForTesting /* package */ AsynchronousMediaCodecAdapter( MediaCodec codec, - boolean enableAsynchronousQueueing, int trackType, HandlerThread handlerThread) { this.lock = new Object(); this.mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); this.codec = codec; this.handlerThread = handlerThread; - this.bufferEnqueuer = - enableAsynchronousQueueing - ? new AsynchronousMediaCodecBufferEnqueuer(codec, trackType) - : new SynchronousMediaCodecBufferEnqueuer(this.codec); + this.bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, trackType); this.state = STATE_CREATED; } 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 dd9a0864461..6b2ec4e6996 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 @@ -37,13 +37,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** - * A {@link MediaCodecInputBufferEnqueuer} that defers queueing operations on a background thread. + * Performs {@link MediaCodec} input buffer queueing on a background thread. * *

    The implementation of this class assumes that its public methods will be called from the same * thread. */ @RequiresApi(23) -class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnqueuer { +class AsynchronousMediaCodecBufferEnqueuer { private static final int MSG_QUEUE_INPUT_BUFFER = 0; private static final int MSG_QUEUE_SECURE_INPUT_BUFFER = 1; @@ -85,7 +85,11 @@ public AsynchronousMediaCodecBufferEnqueuer(MediaCodec codec, int trackType) { needsSynchronizationWorkaround = needsSynchronizationWorkaround(); } - @Override + /** + * Starts this instance. + * + *

    Call this method after creating an instance and before queueing input buffers. + */ public void start() { if (!started) { handlerThread.start(); @@ -100,7 +104,11 @@ public void handleMessage(Message msg) { } } - @Override + /** + * Submits an input buffer for decoding. + * + * @see android.media.MediaCodec#queueInputBuffer + */ public void queueInputBuffer( int index, int offset, int size, long presentationTimeUs, int flags) { maybeThrowException(); @@ -111,7 +119,15 @@ public void queueInputBuffer( message.sendToTarget(); } - @Override + /** + * Submits an input buffer that potentially contains encrypted data for decoding. + * + *

    Note: This method behaves as {@link MediaCodec#queueSecureInputBuffer} with the difference + * that {@code info} is of type {@link CryptoInfo} and not {@link + * android.media.MediaCodec.CryptoInfo}. + * + * @see android.media.MediaCodec#queueSecureInputBuffer + */ public void queueSecureInputBuffer( int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { maybeThrowException(); @@ -123,7 +139,7 @@ public void queueSecureInputBuffer( message.sendToTarget(); } - @Override + /** Flushes the instance. */ public void flush() { if (started) { try { @@ -137,7 +153,7 @@ public void flush() { } } - @Override + /** Shut down the instance. Make sure to call this method to release its internal resources. */ public void shutdown() { if (started) { flush(); @@ -146,36 +162,8 @@ public void shutdown() { started = false; } - private void doHandleMessage(Message msg) { - MessageParams params = null; - switch (msg.what) { - case MSG_QUEUE_INPUT_BUFFER: - params = (MessageParams) msg.obj; - doQueueInputBuffer( - params.index, params.offset, params.size, params.presentationTimeUs, params.flags); - break; - case MSG_QUEUE_SECURE_INPUT_BUFFER: - params = (MessageParams) msg.obj; - doQueueSecureInputBuffer( - params.index, - params.offset, - params.cryptoInfo, - params.presentationTimeUs, - params.flags); - break; - case MSG_FLUSH: - conditionVariable.open(); - break; - default: - setPendingRuntimeException(new IllegalStateException(String.valueOf(msg.what))); - } - if (params != null) { - recycleMessageParams(params); - } - } - private void maybeThrowException() { - RuntimeException exception = pendingRuntimeException.getAndSet(null); + @Nullable RuntimeException exception = pendingRuntimeException.getAndSet(null); if (exception != null) { throw exception; } @@ -202,6 +190,34 @@ private void flushHandlerThread() throws InterruptedException { pendingRuntimeException.set(exception); } + private void doHandleMessage(Message msg) { + @Nullable MessageParams params = null; + switch (msg.what) { + case MSG_QUEUE_INPUT_BUFFER: + params = (MessageParams) msg.obj; + doQueueInputBuffer( + params.index, params.offset, params.size, params.presentationTimeUs, params.flags); + break; + case MSG_QUEUE_SECURE_INPUT_BUFFER: + params = (MessageParams) msg.obj; + doQueueSecureInputBuffer( + params.index, + params.offset, + params.cryptoInfo, + params.presentationTimeUs, + params.flags); + break; + case MSG_FLUSH: + conditionVariable.open(); + break; + default: + setPendingRuntimeException(new IllegalStateException(String.valueOf(msg.what))); + } + if (params != null) { + recycleMessageParams(params); + } + } + private void doQueueInputBuffer( int index, int offset, int size, long presentationTimeUs, int flag) { try { @@ -226,13 +242,6 @@ private void doQueueSecureInputBuffer( } } - @VisibleForTesting - /* package */ static int getInstancePoolSize() { - synchronized (MESSAGE_PARAMS_INSTANCE_POOL) { - return MESSAGE_PARAMS_INSTANCE_POOL.size(); - } - } - private static MessageParams getMessageParams() { synchronized (MESSAGE_PARAMS_INSTANCE_POOL) { if (MESSAGE_PARAMS_INSTANCE_POOL.isEmpty()) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInputBufferEnqueuer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInputBufferEnqueuer.java deleted file mode 100644 index 34a1ccc6bab..00000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInputBufferEnqueuer.java +++ /dev/null @@ -1,56 +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.mediacodec; - -import android.media.MediaCodec; -import com.google.android.exoplayer2.decoder.CryptoInfo; - -/** Abstracts operations to enqueue input buffer on a {@link android.media.MediaCodec}. */ -interface MediaCodecInputBufferEnqueuer { - - /** - * Starts this instance. - * - *

    Call this method after creating an instance. - */ - void start(); - - /** - * Submits an input buffer for decoding. - * - * @see android.media.MediaCodec#queueInputBuffer - */ - void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags); - - /** - * Submits an input buffer that potentially contains encrypted data for decoding. - * - *

    Note: This method behaves as {@link MediaCodec#queueSecureInputBuffer} with the difference - * that {@code info} is of type {@link CryptoInfo} and not {@link - * android.media.MediaCodec.CryptoInfo}. - * - * @see android.media.MediaCodec#queueSecureInputBuffer - */ - void queueSecureInputBuffer( - int index, int offset, CryptoInfo info, long presentationTimeUs, int flags); - - /** Flushes the instance. */ - void flush(); - - /** Shut down the instance. Make sure to call this method to release its internal resources. */ - void shutdown(); -} 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 c01d43872e1..4d5164f551c 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 @@ -1056,13 +1056,10 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce TraceUtil.beginSection("createCodec:" + codecName); codec = MediaCodec.createByCodecName(codecName); if (enableAsynchronousBufferQueueing && Util.SDK_INT >= 23) { - codecAdapter = - new AsynchronousMediaCodecAdapter( - codec, /* enableAsynchronousQueueing= */ true, getTrackType()); + codecAdapter = new AsynchronousMediaCodecAdapter(codec, getTrackType()); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec); } - TraceUtil.endSection(); TraceUtil.beginSection("configureCodec"); configureCodec(codecInfo, codecAdapter, inputFormat, crypto, codecOperatingRate); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecBufferEnqueuer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecBufferEnqueuer.java deleted file mode 100644 index f16748f8fc5..00000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecBufferEnqueuer.java +++ /dev/null @@ -1,59 +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.mediacodec; - -import android.media.MediaCodec; -import com.google.android.exoplayer2.decoder.CryptoInfo; - -/** - * A {@link MediaCodecInputBufferEnqueuer} that forwards queueing methods directly to {@link - * MediaCodec}. - */ -class SynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnqueuer { - private final MediaCodec codec; - - /** - * Creates an instance that queues input buffers on the specified {@link MediaCodec}. - * - * @param codec The {@link MediaCodec} to submit input buffers to. - */ - SynchronousMediaCodecBufferEnqueuer(MediaCodec codec) { - this.codec = codec; - } - - @Override - public void start() {} - - @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 void flush() {} - - @Override - public void shutdown() {} -} 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 dc32ce65a14..0c023d38415 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 @@ -48,7 +48,6 @@ public void setUp() throws IOException { adapter = new AsynchronousMediaCodecAdapter( codec, - /* enableAsynchronousQueueing= */ false, /* trackType= */ C.TRACK_TYPE_VIDEO, handlerThread); bufferInfo = new MediaCodec.BufferInfo(); 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 37d31569c35..e27c428a941 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 @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doAnswer; +import static org.robolectric.Shadows.shadowOf; import android.media.MediaCodec; import android.media.MediaFormat; @@ -28,6 +29,7 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; import com.google.android.exoplayer2.util.ConditionVariable; import java.io.IOException; +import java.nio.ByteBuffer; import java.util.concurrent.atomic.AtomicLong; import org.junit.After; import org.junit.Before; @@ -66,6 +68,33 @@ public void tearDown() { assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); } + @Test + public void queueInputBuffer_queuesInputBufferOnMediaCodec() { + enqueuer.start(); + int inputBufferIndex = codec.dequeueInputBuffer(0); + assertThat(inputBufferIndex).isAtLeast(0); + byte[] inputData = new byte[] {0, 1, 2, 3}; + codec.getInputBuffer(inputBufferIndex).put(inputData); + + enqueuer.queueInputBuffer( + inputBufferIndex, + /* offset= */ 0, + /* size= */ 4, + /* presentationTimeUs= */ 0, + /* flags= */ 0); + shadowOf(handlerThread.getLooper()).idle(); + + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + assertThat(codec.dequeueOutputBuffer(bufferInfo, 0)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(codec.dequeueOutputBuffer(bufferInfo, 0)).isEqualTo(inputBufferIndex); + ByteBuffer outputBuffer = codec.getOutputBuffer(inputBufferIndex); + assertThat(outputBuffer.limit()).isEqualTo(4); + byte[] outputData = new byte[4]; + outputBuffer.get(outputData); + assertThat(outputData).isEqualTo(inputData); + } + @Test public void queueInputBuffer_withPendingCryptoExceptionSet_throwsCryptoException() { enqueuer.setPendingRuntimeException( @@ -111,7 +140,7 @@ public void queueSecureInputBuffer_withPendingCryptoException_throwsCryptoExcept enqueuer.queueSecureInputBuffer( /* index= */ 0, /* offset= */ 0, - /* info= */ info, + info, /* presentationTimeUs= */ 0, /* flags= */ 0)); } From 851ca20cc02636c024fa431fde93d43aadcadc1f Mon Sep 17 00:00:00 2001 From: samrobinson Date: Fri, 25 Sep 2020 12:36:10 +0100 Subject: [PATCH 086/693] Add support for mp2 boxes. Issue: #7967 PiperOrigin-RevId: 333709003 --- RELEASENOTES.md | 3 +++ .../java/com/google/android/exoplayer2/extractor/mp4/Atom.java | 3 +++ .../google/android/exoplayer2/extractor/mp4/AtomParsers.java | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c7555d768b6..7846978d305 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -19,6 +19,9 @@ * Add support for `\h` SSA/ASS style override code (non-breaking space). * Audio: * Retry playback after some types of `AudioTrack` error. +* Extractors: + * Add support for .mp2 boxes in the `AtomParsers` + ([#7967](https://github.com/google/ExoPlayer/issues/7967)). ### 2.12.0 (2020-09-11) ### diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index 58f3a75b874..325dc24aeca 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -115,6 +115,9 @@ @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_mp4a = 0x6d703461; + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE__mp2 = 0x2e6d7032; + @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE__mp3 = 0x2e6d7033; 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 0ab126367b0..573451ef6a2 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 @@ -878,6 +878,7 @@ private static StsdData parseStsd( || childAtomType == Atom.TYPE_lpcm || childAtomType == Atom.TYPE_sowt || childAtomType == Atom.TYPE_twos + || childAtomType == Atom.TYPE__mp2 || childAtomType == Atom.TYPE__mp3 || childAtomType == Atom.TYPE_alac || childAtomType == Atom.TYPE_alaw @@ -1243,7 +1244,7 @@ private static void parseAudioSampleEntry( } else if (atomType == Atom.TYPE_twos) { mimeType = MimeTypes.AUDIO_RAW; pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN; - } else if (atomType == Atom.TYPE__mp3) { + } else if (atomType == Atom.TYPE__mp2 || atomType == Atom.TYPE__mp3) { mimeType = MimeTypes.AUDIO_MPEG; } else if (atomType == Atom.TYPE_alac) { mimeType = MimeTypes.AUDIO_ALAC; From 9b39268e0b05b622ec2eb29d83a749863d1342e0 Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 25 Sep 2020 13:09:47 +0100 Subject: [PATCH 087/693] Mark DataSpec with FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED Set the FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED on live load DataSpecs for segments that are not yet fully available. PiperOrigin-RevId: 333712684 --- .../exoplayer2/source/dash/DashUtil.java | 8 +++- .../source/dash/DefaultDashChunkSource.java | 45 ++++++++++++++----- 2 files changed, 41 insertions(+), 12 deletions(-) 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 5dc6662d4f5..1aee832a37f 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 @@ -50,14 +50,18 @@ public final class DashUtil { * * @param representation The {@link Representation} to which the request belongs. * @param requestUri The {@link RangedUri} of the data to request. + * @param flags Flags to be set on the returned {@link DataSpec}. See {@link + * DataSpec.Builder#setFlags(int)}. * @return The {@link DataSpec}. */ - public static DataSpec buildDataSpec(Representation representation, RangedUri requestUri) { + public static DataSpec buildDataSpec( + Representation representation, RangedUri requestUri, int flags) { return new DataSpec.Builder() .setUri(requestUri.resolveUri(representation.baseUrl)) .setPosition(requestUri.start) .setLength(requestUri.length) .setKey(representation.getCacheKey()) + .setFlags(flags) .build(); } @@ -194,7 +198,7 @@ private static void loadInitializationData( ChunkExtractor chunkExtractor, RangedUri requestUri) throws IOException { - DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri); + DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri, /* flags= */ 0); InitializationChunk initializationChunk = new InitializationChunk( dataSource, 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 d21d15bea5f..60161289cc5 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 @@ -302,7 +302,7 @@ public void getNextChunk( } else { chunkIterators[i] = new RepresentationSegmentIterator( - representationHolder, segmentNum, lastAvailableSegmentNum); + representationHolder, segmentNum, lastAvailableSegmentNum, nowUnixTimeUs); } } } @@ -394,7 +394,8 @@ public void getNextChunk( trackSelection.getSelectionData(), segmentNum, maxSegmentCount, - seekTimeUs); + seekTimeUs, + nowUnixTimeUs); } @Override @@ -516,7 +517,7 @@ protected Chunk newInitializationChunk( } else { requestUri = indexUri; } - DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri); + DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri, /* flags= */ 0); return new InitializationChunk( dataSource, dataSpec, @@ -535,14 +536,19 @@ protected Chunk newMediaChunk( Object trackSelectionData, long firstSegmentNum, int maxSegmentCount, - long seekTimeUs) { + long seekTimeUs, + long nowUnixTimeUs) { Representation representation = representationHolder.representation; long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); String baseUrl = representation.baseUrl; if (representationHolder.chunkExtractor == null) { long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum); - DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri); + int flags = + representationHolder.isSegmentAvailableAtFullNetworkSpeed(firstSegmentNum, nowUnixTimeUs) + ? 0 + : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; + DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri, flags); return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason, trackSelectionData, startTimeUs, endTimeUs, firstSegmentNum, trackType, trackFormat); } else { @@ -557,13 +563,18 @@ protected Chunk newMediaChunk( segmentUri = mergedSegmentUri; segmentCount++; } - long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum + segmentCount - 1); + long segmentNum = firstSegmentNum + segmentCount - 1; + long endTimeUs = representationHolder.getSegmentEndTimeUs(segmentNum); long periodDurationUs = representationHolder.periodDurationUs; long clippedEndTimeUs = periodDurationUs != C.TIME_UNSET && periodDurationUs <= endTimeUs ? periodDurationUs : C.TIME_UNSET; - DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri); + int flags = + representationHolder.isSegmentAvailableAtFullNetworkSpeed(segmentNum, nowUnixTimeUs) + ? 0 + : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; + DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri, flags); long sampleOffsetUs = -representation.presentationTimeOffsetUs; return new ContainerMediaChunk( dataSource, @@ -588,6 +599,7 @@ protected Chunk newMediaChunk( protected static final class RepresentationSegmentIterator extends BaseMediaChunkIterator { private final RepresentationHolder representationHolder; + private final long currentUnixTimeUs; /** * Creates iterator. @@ -595,20 +607,29 @@ protected static final class RepresentationSegmentIterator extends BaseMediaChun * @param representation The {@link RepresentationHolder} to wrap. * @param firstAvailableSegmentNum The number of the first available segment. * @param lastAvailableSegmentNum The number of the last available segment. + * @param currentUnixTimeUs The current time in microseconds since the epoch used for + * calculating if segments are available at full network speed. */ public RepresentationSegmentIterator( RepresentationHolder representation, long firstAvailableSegmentNum, - long lastAvailableSegmentNum) { + long lastAvailableSegmentNum, + long currentUnixTimeUs) { super(/* fromIndex= */ firstAvailableSegmentNum, /* toIndex= */ lastAvailableSegmentNum); this.representationHolder = representation; + this.currentUnixTimeUs = currentUnixTimeUs; } @Override public DataSpec getDataSpec() { checkInBounds(); - RangedUri segmentUri = representationHolder.getSegmentUrl(getCurrentIndex()); - return DashUtil.buildDataSpec(representationHolder.representation, segmentUri); + long currentIndex = getCurrentIndex(); + RangedUri segmentUri = representationHolder.getSegmentUrl(currentIndex); + int flags = + representationHolder.isSegmentAvailableAtFullNetworkSpeed(currentIndex, currentUnixTimeUs) + ? 0 + : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; + return DashUtil.buildDataSpec(representationHolder.representation, segmentUri, flags); } @Override @@ -768,6 +789,10 @@ public long getLastAvailableSegmentNum(long nowUnixTimeUs) { - 1; } + public boolean isSegmentAvailableAtFullNetworkSpeed(long segmentNum, long nowUnixTimeUs) { + return getSegmentEndTimeUs(segmentNum) <= nowUnixTimeUs; + } + @Nullable private static ChunkExtractor createChunkExtractor( int trackType, From 397fe8f305a01c5921267fd31f9a900205eba3d5 Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 25 Sep 2020 13:10:35 +0100 Subject: [PATCH 088/693] Bring back setRenderTimeLimitMs PiperOrigin-RevId: 333712782 --- .../mediacodec/MediaCodecRenderer.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 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 4d5164f551c..ecaa4e64005 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 @@ -323,6 +323,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { @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; @@ -401,6 +402,7 @@ public MediaCodecRenderer( decodeOnlyPresentationTimestamps = new ArrayList<>(); outputBufferInfo = new MediaCodec.BufferInfo(); operatingRate = 1f; + renderTimeLimitMs = C.TIME_UNSET; 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]; @@ -410,6 +412,19 @@ public MediaCodecRenderer( resetCodecStateForRelease(); } + /** + * Set a limit on the time a single {@link #render(long, long)} call can spend draining and + * filling the decoder. + * + *

    This method should be called right after creating an instance of this class. + * + * @param renderTimeLimitMs The render time limit in milliseconds, or {@link C#TIME_UNSET} for no + * limit. + */ + public void setRenderTimeLimitMs(long renderTimeLimitMs) { + this.renderTimeLimitMs = renderTimeLimitMs; + } + /** * Enable asynchronous input buffer queueing. * @@ -784,9 +799,11 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx while (bypassRender(positionUs, elapsedRealtimeUs)) {} TraceUtil.endSection(); } else if (codec != null) { + long renderStartTimeMs = SystemClock.elapsedRealtime(); TraceUtil.beginSection("drainAndFeed"); - while (drainOutputBuffer(positionUs, elapsedRealtimeUs)) {} - while (feedInputBuffer()) {} + while (drainOutputBuffer(positionUs, elapsedRealtimeUs) + && shouldContinueRendering(renderStartTimeMs)) {} + while (feedInputBuffer() && shouldContinueRendering(renderStartTimeMs)) {} TraceUtil.endSection(); } else { decoderCounters.skippedInputBufferCount += skipSource(positionUs); @@ -1110,6 +1127,11 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce 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 c107017a4bd623aece55bfe959fddcb297911c83 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 25 Sep 2020 13:30:36 +0100 Subject: [PATCH 089/693] Ensure implicit manifest updates arrives asap PiperOrigin-RevId: 333714978 --- .../source/dash/DashMediaSource.java | 43 ++++++- .../source/dash/DashSegmentIndex.java | 12 ++ .../source/dash/DashWrappingSegmentIndex.java | 6 + .../dash/manifest/DashManifestParser.java | 76 ++++++++---- .../source/dash/manifest/Representation.java | 74 ++---------- .../source/dash/manifest/SegmentBase.java | 109 +++++++++++++---- .../dash/manifest/SingleSegmentIndex.java | 6 + ...entationTest.java => SegmentBaseTest.java} | 112 ++++++++++++------ 8 files changed, 287 insertions(+), 151 deletions(-) rename library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/{RepresentationTest.java => SegmentBaseTest.java} (55%) 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 2f5b169e30d..1473547ab3b 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 @@ -50,6 +50,8 @@ import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; +import com.google.android.exoplayer2.source.dash.manifest.Period; +import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; @@ -68,10 +70,12 @@ import com.google.android.exoplayer2.util.SntpClient; import com.google.android.exoplayer2.util.Util; import com.google.common.base.Charsets; +import com.google.common.math.LongMath; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.math.RoundingMode; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Collections; @@ -441,7 +445,7 @@ public int[] getSupportedTypes() { * MediaSourceCaller#onSourceInfoRefreshed(MediaSource, Timeline)} when the source's {@link * Timeline} is changing dynamically (for example, for incomplete live streams). */ - private static final int NOTIFY_MANIFEST_INTERVAL_MS = 5000; + private static final long DEFAULT_NOTIFY_MANIFEST_INTERVAL_MS = 5000; /** * The minimum default start position for live streams, relative to the start of the live window. */ @@ -1106,7 +1110,10 @@ private void processManifest(boolean scheduleRefresh) { handler.removeCallbacks(simulateManifestRefreshRunnable); // If the window is changing implicitly, post a simulated manifest refresh to update it. if (windowChangingImplicitly) { - handler.postDelayed(simulateManifestRefreshRunnable, NOTIFY_MANIFEST_INTERVAL_MS); + handler.postDelayed( + simulateManifestRefreshRunnable, + getIntervalUntilNextManifestRefreshMs( + manifest, Util.getNowUnixTimeMs(elapsedRealtimeOffsetMs))); } if (manifestLoadPending) { startLoadingManifest(); @@ -1165,6 +1172,38 @@ private void startLoading(ParsingLoadable loadable, loadable.type); } + private static long getIntervalUntilNextManifestRefreshMs( + DashManifest manifest, long nowUnixTimeMs) { + int periodIndex = manifest.getPeriodCount() - 1; + Period period = manifest.getPeriod(periodIndex); + long periodStartUs = C.msToUs(period.startMs); + long periodDurationUs = manifest.getPeriodDurationUs(periodIndex); + long nowUnixTimeUs = C.msToUs(nowUnixTimeMs); + long availabilityStartTimeUs = C.msToUs(manifest.availabilityStartTimeMs); + long intervalUs = C.msToUs(DEFAULT_NOTIFY_MANIFEST_INTERVAL_MS); + for (int i = 0; i < period.adaptationSets.size(); i++) { + List representations = period.adaptationSets.get(i).representations; + if (representations.isEmpty()) { + continue; + } + @Nullable DashSegmentIndex index = representations.get(0).getIndex(); + if (index != null) { + long nextSegmentShiftUnixTimeUs = + availabilityStartTimeUs + + periodStartUs + + index.getNextSegmentAvailableTimeUs(periodDurationUs, nowUnixTimeUs); + long requiredIntervalUs = nextSegmentShiftUnixTimeUs - nowUnixTimeUs; + // Avoid multiple refreshes within a very small amount of time. + if (requiredIntervalUs < intervalUs - 100_000 + || (requiredIntervalUs > intervalUs && requiredIntervalUs < intervalUs + 100_000)) { + intervalUs = requiredIntervalUs; + } + } + } + // Round up to compensate for a potential loss in the us to ms conversion. + return LongMath.divide(intervalUs, 1000, RoundingMode.CEILING); + } + private static final class PeriodSeekInfo { public static PeriodSeekInfo createPeriodSeekInfo( diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java index 3f95d8c5a14..527ed6ce82b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java @@ -101,6 +101,18 @@ public interface DashSegmentIndex { */ int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs); + /** + * Returns the time, in microseconds, at which a new segment becomes available, or {@link + * C#TIME_UNSET} if not applicable. + * + * @param periodDurationUs The duration of the enclosing period in microseconds, or {@link + * C#TIME_UNSET} if the period's duration is not yet known. + * @param nowUnixTimeUs The current time in milliseconds since the Unix epoch. + * @return The time, in microseconds, at which a new segment becomes available, or {@link + * C#TIME_UNSET} if not applicable. + */ + long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs); + /** * Returns true if segments are defined explicitly by the index. * diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java index 723fb747395..4c771cdcbf8 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.dash; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.source.dash.manifest.RangedUri; @@ -56,6 +57,11 @@ public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { return chunkIndex.length; } + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return C.TIME_UNSET; + } + @Override public long getTimeUs(long segmentNum) { return chunkIndex.timesUs[(int) segmentNum] - timeOffsetUs; 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 e784c7d4890..19fcc321cbb 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 @@ -291,9 +291,11 @@ protected Pair parsePeriod( parseSegmentList( xpp, /* parent= */ null, + periodStartUnixTimeMs, durationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs); + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { segmentBaseAvailabilityTimeOffsetUs = parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); @@ -302,9 +304,11 @@ protected Pair parsePeriod( xpp, /* parent= */ null, ImmutableList.of(), + periodStartUnixTimeMs, durationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs); + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "AssetIdentifier")) { assetIdentifier = parseDescriptor(xpp, "AssetIdentifier"); } else { @@ -407,9 +411,11 @@ protected AdaptationSet parseAdaptationSet( essentialProperties, supplementalProperties, segmentBase, + periodStartUnixTimeMs, periodDurationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs); + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); contentType = checkContentTypeConsistency( contentType, MimeTypes.getTrackType(representationInfo.format.sampleMimeType)); @@ -423,9 +429,11 @@ protected AdaptationSet parseAdaptationSet( parseSegmentList( xpp, (SegmentList) segmentBase, + periodStartUnixTimeMs, periodDurationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs); + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { segmentBaseAvailabilityTimeOffsetUs = parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); @@ -434,9 +442,11 @@ protected AdaptationSet parseAdaptationSet( xpp, (SegmentTemplate) segmentBase, supplementalProperties, + periodStartUnixTimeMs, periodDurationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs); + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); } else if (XmlPullParserUtil.isStartTag(xpp, "Label")) { @@ -455,9 +465,7 @@ protected AdaptationSet parseAdaptationSet( label, drmSchemeType, drmSchemeDatas, - inbandEventStreams, - periodStartUnixTimeMs, - timeShiftBufferDepthMs)); + inbandEventStreams)); } return buildAdaptationSet( @@ -599,9 +607,11 @@ protected RepresentationInfo parseRepresentation( List adaptationSetEssentialProperties, List adaptationSetSupplementalProperties, @Nullable SegmentBase segmentBase, + long periodStartUnixTimeMs, long periodDurationMs, long baseUrlAvailabilityTimeOffsetUs, - long segmentBaseAvailabilityTimeOffsetUs) + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); @@ -641,9 +651,11 @@ protected RepresentationInfo parseRepresentation( parseSegmentList( xpp, (SegmentList) segmentBase, + periodStartUnixTimeMs, periodDurationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs); + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { segmentBaseAvailabilityTimeOffsetUs = parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); @@ -652,9 +664,11 @@ protected RepresentationInfo parseRepresentation( xpp, (SegmentTemplate) segmentBase, adaptationSetSupplementalProperties, + periodStartUnixTimeMs, periodDurationMs, baseUrlAvailabilityTimeOffsetUs, - segmentBaseAvailabilityTimeOffsetUs); + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { Pair contentProtection = parseContentProtection(xpp); if (contentProtection.first != null) { @@ -754,9 +768,7 @@ protected Representation buildRepresentation( @Nullable String label, @Nullable String extraDrmSchemeType, ArrayList extraDrmSchemeDatas, - ArrayList extraInbandEventStreams, - long periodStartUnixTimeMs, - long timeShiftBufferDepthMs) { + ArrayList extraInbandEventStreams) { Format.Builder formatBuilder = representationInfo.format.buildUpon(); if (label != null) { formatBuilder.setLabel(label); @@ -778,9 +790,7 @@ protected Representation buildRepresentation( formatBuilder.build(), representationInfo.baseUrl, representationInfo.segmentBase, - inbandEventStreams, - periodStartUnixTimeMs, - timeShiftBufferDepthMs); + inbandEventStreams); } // SegmentBase, SegmentList and SegmentTemplate parsing. @@ -825,9 +835,11 @@ protected SingleSegmentBase buildSingleSegmentBase(RangedUri initialization, lon protected SegmentList parseSegmentList( XmlPullParser xpp, @Nullable SegmentList parent, + long periodStartUnixTimeMs, long periodDurationMs, long baseUrlAvailabilityTimeOffsetUs, - long segmentBaseAvailabilityTimeOffsetUs) + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -873,7 +885,9 @@ protected SegmentList parseSegmentList( duration, timeline, availabilityTimeOffsetUs, - segments); + segments, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); } protected SegmentList buildSegmentList( @@ -884,7 +898,9 @@ protected SegmentList buildSegmentList( long duration, @Nullable List timeline, long availabilityTimeOffsetUs, - @Nullable List segments) { + @Nullable List segments, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { return new SegmentList( initialization, timescale, @@ -893,16 +909,20 @@ protected SegmentList buildSegmentList( duration, timeline, availabilityTimeOffsetUs, - segments); + segments, + C.msToUs(timeShiftBufferDepthMs), + C.msToUs(periodStartUnixTimeMs)); } protected SegmentTemplate parseSegmentTemplate( XmlPullParser xpp, @Nullable SegmentTemplate parent, List adaptationSetSupplementalProperties, + long periodStartUnixTimeMs, long periodDurationMs, long baseUrlAvailabilityTimeOffsetUs, - long segmentBaseAvailabilityTimeOffsetUs) + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", @@ -949,7 +969,9 @@ protected SegmentTemplate parseSegmentTemplate( timeline, availabilityTimeOffsetUs, initializationTemplate, - mediaTemplate); + mediaTemplate, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); } protected SegmentTemplate buildSegmentTemplate( @@ -962,7 +984,9 @@ protected SegmentTemplate buildSegmentTemplate( List timeline, long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, - @Nullable UrlTemplate mediaTemplate) { + @Nullable UrlTemplate mediaTemplate, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { return new SegmentTemplate( initialization, timescale, @@ -973,7 +997,9 @@ protected SegmentTemplate buildSegmentTemplate( timeline, availabilityTimeOffsetUs, initializationTemplate, - mediaTemplate); + mediaTemplate, + C.msToUs(timeShiftBufferDepthMs), + C.msToUs(periodStartUnixTimeMs)); } /** diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index b3845623768..c0b1dceec53 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer2.source.dash.manifest; -import static java.lang.Math.max; - import android.net.Uri; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -73,14 +71,7 @@ public abstract class Representation { */ public static Representation newInstance( long revisionId, Format format, String baseUrl, SegmentBase segmentBase) { - return newInstance( - revisionId, - format, - baseUrl, - segmentBase, - /* inbandEventStreams= */ null, - /* periodStartUnixTimeMs= */ C.TIME_UNSET, - /* timeShiftBufferDepthMs= */ C.TIME_UNSET); + return newInstance(revisionId, format, baseUrl, segmentBase, /* inbandEventStreams= */ null); } /** @@ -91,9 +82,6 @@ public static Representation newInstance( * @param baseUrl The base URL. * @param segmentBase A segment base element for the representation. * @param inbandEventStreams The in-band event streams in the representation. May be null. - * @param periodStartUnixTimeMs The start time of the enclosing {@link Period} in milliseconds - * since the Unix epoch, or {@link C#TIME_UNSET} is not applicable. - * @param timeShiftBufferDepthMs The {@link DashManifest#timeShiftBufferDepthMs}. * @return The constructed instance. */ public static Representation newInstance( @@ -101,17 +89,13 @@ public static Representation newInstance( Format format, String baseUrl, SegmentBase segmentBase, - @Nullable List inbandEventStreams, - long periodStartUnixTimeMs, - long timeShiftBufferDepthMs) { + @Nullable List inbandEventStreams) { return newInstance( revisionId, format, baseUrl, segmentBase, inbandEventStreams, - periodStartUnixTimeMs, - timeShiftBufferDepthMs, /* cacheKey= */ null); } @@ -123,9 +107,6 @@ public static Representation newInstance( * @param baseUrl The base URL of the representation. * @param segmentBase A segment base element for the representation. * @param inbandEventStreams The in-band event streams in the representation. May be null. - * @param periodStartUnixTimeMs The start time of the enclosing {@link Period} in milliseconds - * since the Unix epoch, or {@link C#TIME_UNSET} is not applicable. - * @param timeShiftBufferDepthMs The {@link DashManifest#timeShiftBufferDepthMs}. * @param cacheKey An optional key to be returned from {@link #getCacheKey()}, or null. This * parameter is ignored if {@code segmentBase} consists of multiple segments. * @return The constructed instance. @@ -136,8 +117,6 @@ public static Representation newInstance( String baseUrl, SegmentBase segmentBase, @Nullable List inbandEventStreams, - long periodStartUnixTimeMs, - long timeShiftBufferDepthMs, @Nullable String cacheKey) { if (segmentBase instanceof SingleSegmentBase) { return new SingleSegmentRepresentation( @@ -150,13 +129,7 @@ public static Representation newInstance( C.LENGTH_UNSET); } else if (segmentBase instanceof MultiSegmentBase) { return new MultiSegmentRepresentation( - revisionId, - format, - baseUrl, - (MultiSegmentBase) segmentBase, - inbandEventStreams, - periodStartUnixTimeMs, - timeShiftBufferDepthMs); + revisionId, format, baseUrl, (MultiSegmentBase) segmentBase, inbandEventStreams); } else { throw new IllegalArgumentException("segmentBase must be of type SingleSegmentBase or " + "MultiSegmentBase"); @@ -309,8 +282,6 @@ public static class MultiSegmentRepresentation extends Representation implements DashSegmentIndex { @VisibleForTesting /* package */ final MultiSegmentBase segmentBase; - private final long periodStartUnixTimeUs; - private final long timeShiftBufferDepthUs; /** * Creates the multi-segment Representation. @@ -320,22 +291,15 @@ public static class MultiSegmentRepresentation extends Representation * @param baseUrl The base URL of the representation. * @param segmentBase The segment base underlying the representation. * @param inbandEventStreams The in-band event streams in the representation. May be null. - * @param periodStartUnixTimeMs The start time of the enclosing {@link Period} in milliseconds - * since the Unix epoch, or {@link C#TIME_UNSET} is not applicable. - * @param timeShiftBufferDepthMs The {@link DashManifest#timeShiftBufferDepthMs}. */ public MultiSegmentRepresentation( long revisionId, Format format, String baseUrl, MultiSegmentBase segmentBase, - @Nullable List inbandEventStreams, - long periodStartUnixTimeMs, - long timeShiftBufferDepthMs) { + @Nullable List inbandEventStreams) { super(revisionId, format, baseUrl, segmentBase, inbandEventStreams); this.segmentBase = segmentBase; - this.periodStartUnixTimeUs = C.msToUs(periodStartUnixTimeMs); - this.timeShiftBufferDepthUs = C.msToUs(timeShiftBufferDepthMs); } @Override @@ -384,17 +348,7 @@ public long getFirstSegmentNum() { @Override public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { - long segmentCount = segmentBase.getSegmentCount(periodDurationUs); - if (segmentCount != INDEX_UNBOUNDED || timeShiftBufferDepthUs == C.TIME_UNSET) { - return segmentBase.getFirstSegmentNum(); - } - // The index is itself unbounded. We need to use the current time to calculate the range of - // available segments. - long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; - long timeShiftBufferStartInPeriodUs = liveEdgeTimeInPeriodUs - timeShiftBufferDepthUs; - long timeShiftBufferStartSegmentNum = - getSegmentNum(timeShiftBufferStartInPeriodUs, periodDurationUs); - return max(getFirstSegmentNum(), timeShiftBufferStartSegmentNum); + return segmentBase.getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); } @Override @@ -404,18 +358,12 @@ public int getSegmentCount(long periodDurationUs) { @Override public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { - int segmentCount = segmentBase.getSegmentCount(periodDurationUs); - if (segmentCount != INDEX_UNBOUNDED) { - return segmentCount; - } - // The index is itself unbounded. We need to use the current time to calculate the range of - // available segments. - long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; - long availabilityEndTimeUs = liveEdgeTimeInPeriodUs + segmentBase.availabilityTimeOffsetUs; - // getSegmentNum(availabilityEndTimeUs) will not be completed yet. - long firstIncompleteSegmentNum = getSegmentNum(availabilityEndTimeUs, periodDurationUs); - long firstAvailableSegmentNum = getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); - return (int) (firstIncompleteSegmentNum - firstAvailableSegmentNum); + return segmentBase.getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + } + + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return segmentBase.getNextSegmentAvailableTimeUs(periodDurationUs, nowUnixTimeUs); } @Override diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java index 5de2814b297..495f288805c 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java @@ -15,9 +15,12 @@ */ package com.google.android.exoplayer2.source.dash.manifest; +import static com.google.android.exoplayer2.source.dash.DashSegmentIndex.INDEX_UNBOUNDED; +import static java.lang.Math.max; import static java.lang.Math.min; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; import com.google.android.exoplayer2.util.Util; @@ -119,6 +122,8 @@ public abstract static class MultiSegmentBase extends SegmentBase { /* package */ final long startNumber; /* package */ final long duration; @Nullable /* package */ final List segmentTimeline; + private final long timeShiftBufferDepthUs; + private final long periodStartUnixTimeUs; /** * Offset to the current realtime at which segments become available, in microseconds, or {@link @@ -127,7 +132,7 @@ public abstract static class MultiSegmentBase extends SegmentBase { *

    Segments will be available once their end time ≤ currentRealTime + * availabilityTimeOffset. */ - /* package */ final long availabilityTimeOffsetUs; + @VisibleForTesting /* package */ final long availabilityTimeOffsetUs; /** * @param initialization A {@link RangedUri} corresponding to initialization data, if such data @@ -144,6 +149,9 @@ public abstract static class MultiSegmentBase extends SegmentBase { * parameter. * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become * available in microseconds, or {@link C#TIME_UNSET} if not applicable. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. */ public MultiSegmentBase( @Nullable RangedUri initialization, @@ -152,15 +160,19 @@ public MultiSegmentBase( long startNumber, long duration, @Nullable List segmentTimeline, - long availabilityTimeOffsetUs) { + long availabilityTimeOffsetUs, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { super(initialization, timescale, presentationTimeOffset); this.startNumber = startNumber; this.duration = duration; this.segmentTimeline = segmentTimeline; this.availabilityTimeOffsetUs = availabilityTimeOffsetUs; + this.timeShiftBufferDepthUs = timeShiftBufferDepthUs; + this.periodStartUnixTimeUs = periodStartUnixTimeUs; } - /** @see DashSegmentIndex#getSegmentNum(long, long) */ + /** See {@link DashSegmentIndex#getSegmentNum(long, long)}. */ public long getSegmentNum(long timeUs, long periodDurationUs) { final long firstSegmentNum = getFirstSegmentNum(); final long segmentCount = getSegmentCount(periodDurationUs); @@ -174,7 +186,7 @@ public long getSegmentNum(long timeUs, long periodDurationUs) { // Ensure we stay within bounds. return segmentNum < firstSegmentNum ? firstSegmentNum - : segmentCount == DashSegmentIndex.INDEX_UNBOUNDED + : segmentCount == INDEX_UNBOUNDED ? segmentNum : min(segmentNum, firstSegmentNum + segmentCount - 1); } else { @@ -196,21 +208,21 @@ public long getSegmentNum(long timeUs, long periodDurationUs) { } } - /** @see DashSegmentIndex#getDurationUs(long, long) */ + /** See {@link DashSegmentIndex#getDurationUs(long, long)}. */ public final long getSegmentDurationUs(long sequenceNumber, long periodDurationUs) { if (segmentTimeline != null) { long duration = segmentTimeline.get((int) (sequenceNumber - startNumber)).duration; return (duration * C.MICROS_PER_SECOND) / timescale; } else { int segmentCount = getSegmentCount(periodDurationUs); - return segmentCount != DashSegmentIndex.INDEX_UNBOUNDED - && sequenceNumber == (getFirstSegmentNum() + segmentCount - 1) + return segmentCount != INDEX_UNBOUNDED + && sequenceNumber == (getFirstSegmentNum() + segmentCount - 1) ? (periodDurationUs - getSegmentTimeUs(sequenceNumber)) : ((duration * C.MICROS_PER_SECOND) / timescale); } } - /** @see DashSegmentIndex#getTimeUs(long) */ + /** See {@link DashSegmentIndex#getTimeUs(long)}. */ public final long getSegmentTimeUs(long sequenceNumber) { long unscaledSegmentTime; if (segmentTimeline != null) { @@ -227,27 +239,66 @@ public final long getSegmentTimeUs(long sequenceNumber) { * Returns a {@link RangedUri} defining the location of a segment for the given index in the * given representation. * - * @see DashSegmentIndex#getSegmentUrl(long) + *

    See {@link DashSegmentIndex#getSegmentUrl(long)}. */ public abstract RangedUri getSegmentUrl(Representation representation, long index); - /** @see DashSegmentIndex#getFirstSegmentNum() */ + /** See {@link DashSegmentIndex#getFirstSegmentNum()}. */ public long getFirstSegmentNum() { return startNumber; } - /** - * @see DashSegmentIndex#getSegmentCount(long) - */ - public abstract int getSegmentCount(long periodDurationUs); + /** See {@link DashSegmentIndex#getFirstAvailableSegmentNum(long, long)}. */ + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + long segmentCount = getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED || timeShiftBufferDepthUs == C.TIME_UNSET) { + return getFirstSegmentNum(); + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + long timeShiftBufferStartInPeriodUs = liveEdgeTimeInPeriodUs - timeShiftBufferDepthUs; + long timeShiftBufferStartSegmentNum = + getSegmentNum(timeShiftBufferStartInPeriodUs, periodDurationUs); + return max(getFirstSegmentNum(), timeShiftBufferStartSegmentNum); + } - /** - * @see DashSegmentIndex#isExplicit() - */ + /** See {@link DashSegmentIndex#getAvailableSegmentCount(long, long)}. */ + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + int segmentCount = getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED) { + return segmentCount; + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + long availabilityTimeOffsetUs = liveEdgeTimeInPeriodUs + this.availabilityTimeOffsetUs; + // getSegmentNum(availabilityTimeOffsetUs) will not be completed yet. + long firstIncompleteSegmentNum = getSegmentNum(availabilityTimeOffsetUs, periodDurationUs); + long firstAvailableSegmentNum = getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + return (int) (firstIncompleteSegmentNum - firstAvailableSegmentNum); + } + + /** See {@link DashSegmentIndex#getNextSegmentAvailableTimeUs(long, long)}. */ + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + if (segmentTimeline != null) { + return C.TIME_UNSET; + } + long firstIncompleteSegmentNum = + getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs) + + getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + return getSegmentTimeUs(firstIncompleteSegmentNum) + + getSegmentDurationUs(firstIncompleteSegmentNum, periodDurationUs) + - availabilityTimeOffsetUs; + } + + /** See {@link DashSegmentIndex#isExplicit()} */ public boolean isExplicit() { return segmentTimeline != null; } + /** See {@link DashSegmentIndex#getSegmentCount(long)}. */ + public abstract int getSegmentCount(long periodDurationUs); } /** A {@link MultiSegmentBase} that uses a SegmentList to define its segments. */ @@ -271,6 +322,9 @@ public static final class SegmentList extends MultiSegmentBase { * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become * available in microseconds, or {@link C#TIME_UNSET} if not applicable. * @param mediaSegments A list of {@link RangedUri}s indicating the locations of the segments. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. */ public SegmentList( RangedUri initialization, @@ -280,7 +334,9 @@ public SegmentList( long duration, @Nullable List segmentTimeline, long availabilityTimeOffsetUs, - @Nullable List mediaSegments) { + @Nullable List mediaSegments, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { super( initialization, timescale, @@ -288,7 +344,9 @@ public SegmentList( startNumber, duration, segmentTimeline, - availabilityTimeOffsetUs); + availabilityTimeOffsetUs, + timeShiftBufferDepthUs, + periodStartUnixTimeUs); this.mediaSegments = mediaSegments; } @@ -339,6 +397,9 @@ public static final class SegmentTemplate extends MultiSegmentBase { * such data exists. If non-null then the {@code initialization} parameter is ignored. If * null then {@code initialization} will be used. * @param mediaTemplate A template defining the location of each media segment. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. */ public SegmentTemplate( RangedUri initialization, @@ -350,7 +411,9 @@ public SegmentTemplate( @Nullable List segmentTimeline, long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, - @Nullable UrlTemplate mediaTemplate) { + @Nullable UrlTemplate mediaTemplate, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { super( initialization, timescale, @@ -358,7 +421,9 @@ public SegmentTemplate( startNumber, duration, segmentTimeline, - availabilityTimeOffsetUs); + availabilityTimeOffsetUs, + timeShiftBufferDepthUs, + periodStartUnixTimeUs); this.initializationTemplate = initializationTemplate; this.mediaTemplate = mediaTemplate; this.endNumber = endNumber; @@ -399,7 +464,7 @@ public int getSegmentCount(long periodDurationUs) { long durationUs = (duration * C.MICROS_PER_SECOND) / timescale; return (int) Util.ceilDivide(periodDurationUs, durationUs); } else { - return DashSegmentIndex.INDEX_UNBOUNDED; + return INDEX_UNBOUNDED; } } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java index 7c6c8a7aa90..523bc2d0719 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.dash.manifest; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; /** @@ -71,6 +72,11 @@ public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { return 1; } + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return C.TIME_UNSET; + } + @Override public boolean isExplicit() { return true; diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBaseTest.java similarity index 55% rename from library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java rename to library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBaseTest.java index d22071cefac..dd442a91f45 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/RepresentationTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBaseTest.java @@ -19,16 +19,15 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link Representation}. */ +/** Unit test for {@link SegmentBase}. */ @RunWith(AndroidJUnit4.class) -public final class RepresentationTest { +public final class SegmentBaseTest { @Test - public void getFirstAvailableSegmentNum_multiSegmentRepresentationWithUnboundedTemplate() { + public void getFirstAvailableSegmentNum_unboundedSegmentTemplate() { long periodStartUnixTimeUs = 123_000_000_000_000L; SegmentBase.SegmentTemplate segmentTemplate = new SegmentBase.SegmentTemplate( @@ -41,50 +40,43 @@ public void getFirstAvailableSegmentNum_multiSegmentRepresentationWithUnboundedT /* segmentTimeline= */ null, /* availabilityTimeOffsetUs= */ 500_000, /* initializationTemplate= */ null, - /* mediaTemplate= */ null); - Representation.MultiSegmentRepresentation representation = - new Representation.MultiSegmentRepresentation( - /* revisionId= */ 0, - new Format.Builder().build(), - /* baseUrl= */ "https://baseUrl/", - segmentTemplate, - /* inbandEventStreams= */ null, - /* periodStartUnixTimeMs= */ C.usToMs(periodStartUnixTimeUs), - /* timeShiftBufferDepthMs= */ 6_000); + /* mediaTemplate= */ null, + /* timeShiftBufferDepthUs= */ 6_000_000, + /* periodStartUnixTimeUs= */ periodStartUnixTimeUs); assertThat( - representation.getFirstAvailableSegmentNum( + segmentTemplate.getFirstAvailableSegmentNum( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) .isEqualTo(42); assertThat( - representation.getFirstAvailableSegmentNum( + segmentTemplate.getFirstAvailableSegmentNum( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) .isEqualTo(42); assertThat( - representation.getFirstAvailableSegmentNum( + segmentTemplate.getFirstAvailableSegmentNum( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_999_999)) .isEqualTo(42); assertThat( - representation.getFirstAvailableSegmentNum( + segmentTemplate.getFirstAvailableSegmentNum( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 8_000_000)) .isEqualTo(43); assertThat( - representation.getFirstAvailableSegmentNum( + segmentTemplate.getFirstAvailableSegmentNum( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 9_999_999)) .isEqualTo(43); assertThat( - representation.getFirstAvailableSegmentNum( + segmentTemplate.getFirstAvailableSegmentNum( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 10_000_000)) .isEqualTo(44); } @Test - public void getAvailableSegmentCount_multiSegmentRepresentationWithUnboundedTemplate() { + public void getAvailableSegmentCount_unboundedSegmentTemplate() { long periodStartUnixTimeUs = 123_000_000_000_000L; SegmentBase.SegmentTemplate segmentTemplate = new SegmentBase.SegmentTemplate( @@ -97,55 +89,97 @@ public void getAvailableSegmentCount_multiSegmentRepresentationWithUnboundedTemp /* segmentTimeline= */ null, /* availabilityTimeOffsetUs= */ 500_000, /* initializationTemplate= */ null, - /* mediaTemplate= */ null); - Representation.MultiSegmentRepresentation representation = - new Representation.MultiSegmentRepresentation( - /* revisionId= */ 0, - new Format.Builder().build(), - /* baseUrl= */ "https://baseUrl/", - segmentTemplate, - /* inbandEventStreams= */ null, - /* periodStartUnixTimeMs= */ C.usToMs(periodStartUnixTimeUs), - /* timeShiftBufferDepthMs= */ 6_000); + /* mediaTemplate= */ null, + /* timeShiftBufferDepthUs= */ 6_000_000, + /* periodStartUnixTimeUs= */ periodStartUnixTimeUs); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) .isEqualTo(0); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) .isEqualTo(0); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_499_999)) .isEqualTo(0); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_500_000)) .isEqualTo(1); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_499_999)) .isEqualTo(3); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_500_000)) .isEqualTo(4); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_999_999)) .isEqualTo(4); assertThat( - representation.getAvailableSegmentCount( + segmentTemplate.getAvailableSegmentCount( /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs + 8_000_000)) .isEqualTo(3); } + + @Test + public void getNextSegmentShiftTimeUse_unboundedSegmentTemplate() { + long periodStartUnixTimeUs = 123_000_000_000_000L; + SegmentBase.SegmentTemplate segmentTemplate = + new SegmentBase.SegmentTemplate( + /* initialization= */ null, + /* timescale= */ 1000, + /* presentationTimeOffset= */ 0, + /* startNumber= */ 42, + /* endNumber= */ C.INDEX_UNSET, + /* duration= */ 2000, + /* segmentTimeline= */ null, + /* availabilityTimeOffsetUs= */ 500_000, + /* initializationTemplate= */ null, + /* mediaTemplate= */ null, + /* timeShiftBufferDepthUs= */ 6_000_000, + /* periodStartUnixTimeUs= */ periodStartUnixTimeUs); + + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) + .isEqualTo(1_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) + .isEqualTo(1_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_499_999)) + .isEqualTo(1_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_500_000)) + .isEqualTo(3_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 17_499_999)) + .isEqualTo(17_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 17_500_000)) + .isEqualTo(19_500_000); + } } From d1416aeb98eb4845ee95db0d1588cdc69c06688b Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 25 Sep 2020 14:20:29 +0100 Subject: [PATCH 090/693] Align live window to available DASH segments PiperOrigin-RevId: 333720336 --- .../source/dash/DashMediaSource.java | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 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 1473547ab3b..bd6824e5599 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 @@ -1032,20 +1032,21 @@ private void processManifest(boolean scheduleRefresh) { // Update the window. boolean windowChangingImplicitly = false; int lastPeriodIndex = manifest.getPeriodCount() - 1; - PeriodSeekInfo firstPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo(manifest.getPeriod(0), - manifest.getPeriodDurationUs(0)); - PeriodSeekInfo lastPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo( - manifest.getPeriod(lastPeriodIndex), manifest.getPeriodDurationUs(lastPeriodIndex)); + Period lastPeriod = manifest.getPeriod(lastPeriodIndex); + long lastPeriodDurationUs = manifest.getPeriodDurationUs(lastPeriodIndex); + long nowUnixTimeUs = C.msToUs(Util.getNowUnixTimeMs(elapsedRealtimeOffsetMs)); + PeriodSeekInfo firstPeriodSeekInfo = + PeriodSeekInfo.createPeriodSeekInfo( + manifest.getPeriod(0), manifest.getPeriodDurationUs(0), nowUnixTimeUs); + PeriodSeekInfo lastPeriodSeekInfo = + PeriodSeekInfo.createPeriodSeekInfo(lastPeriod, lastPeriodDurationUs, nowUnixTimeUs); // Get the period-relative start/end times. long currentStartTimeUs = firstPeriodSeekInfo.availableStartTimeUs; long currentEndTimeUs = lastPeriodSeekInfo.availableEndTimeUs; if (manifest.dynamic && !lastPeriodSeekInfo.isIndexExplicit) { // The manifest describes an incomplete live stream. Update the start/end times to reflect the // live stream duration and the manifest's time shift buffer depth. - long nowUnixTimeUs = C.msToUs(Util.getNowUnixTimeMs(elapsedRealtimeOffsetMs)); - long liveStreamDurationUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs); - long liveStreamEndPositionInLastPeriodUs = liveStreamDurationUs - - C.msToUs(manifest.getPeriod(lastPeriodIndex).startMs); + long liveStreamEndPositionInLastPeriodUs = currentEndTimeUs - C.msToUs(lastPeriod.startMs); currentEndTimeUs = min(liveStreamEndPositionInLastPeriodUs, currentEndTimeUs); if (manifest.timeShiftBufferDepthMs != C.TIME_UNSET) { long timeShiftBufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs); @@ -1098,7 +1099,7 @@ private void processManifest(boolean scheduleRefresh) { windowStartTimeMs, elapsedRealtimeOffsetMs, firstPeriodId, - currentStartTimeUs, + /* offsetInFirstPeriodUs= */ currentStartTimeUs, windowDurationUs, windowDefaultStartPositionUs, manifest, @@ -1207,7 +1208,7 @@ private static long getIntervalUntilNextManifestRefreshMs( private static final class PeriodSeekInfo { public static PeriodSeekInfo createPeriodSeekInfo( - com.google.android.exoplayer2.source.dash.manifest.Period period, long durationUs) { + Period period, long periodDurationUs, long nowUnixTimeUs) { int adaptationSetCount = period.adaptationSets.size(); long availableStartTimeUs = 0; long availableEndTimeUs = Long.MAX_VALUE; @@ -1225,32 +1226,37 @@ public static PeriodSeekInfo createPeriodSeekInfo( for (int i = 0; i < adaptationSetCount; i++) { AdaptationSet adaptationSet = period.adaptationSets.get(i); + List representations = adaptationSet.representations; // Exclude text adaptation sets from duration calculations, if we have at least one audio // or video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029 - if (haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) { + if ((haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) + || representations.isEmpty()) { continue; } - DashSegmentIndex index = adaptationSet.representations.get(0).getIndex(); + @Nullable DashSegmentIndex index = representations.get(0).getIndex(); if (index == null) { - return new PeriodSeekInfo(true, 0, durationUs); + return new PeriodSeekInfo( + /* isIndexExplicit= */ true, + /* availableStartTimeUs= */ 0, + /* availableEndTimeUs= */ periodDurationUs); } isIndexExplicit |= index.isExplicit(); - int segmentCount = index.getSegmentCount(durationUs); - if (segmentCount == 0) { + int availableSegmentCount = index.getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + if (availableSegmentCount == 0) { seenEmptyIndex = true; availableStartTimeUs = 0; availableEndTimeUs = 0; } else if (!seenEmptyIndex) { - long firstSegmentNum = index.getFirstSegmentNum(); - long adaptationSetAvailableStartTimeUs = index.getTimeUs(firstSegmentNum); + long firstAvailableSegmentNum = + index.getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + long adaptationSetAvailableStartTimeUs = index.getTimeUs(firstAvailableSegmentNum); availableStartTimeUs = max(availableStartTimeUs, adaptationSetAvailableStartTimeUs); - if (segmentCount != DashSegmentIndex.INDEX_UNBOUNDED) { - long lastSegmentNum = firstSegmentNum + segmentCount - 1; - long adaptationSetAvailableEndTimeUs = index.getTimeUs(lastSegmentNum) - + index.getDurationUs(lastSegmentNum, durationUs); - availableEndTimeUs = min(availableEndTimeUs, adaptationSetAvailableEndTimeUs); - } + long lastAvailableSegmentNum = firstAvailableSegmentNum + availableSegmentCount - 1; + long adaptationSetAvailableEndTimeUs = + index.getTimeUs(lastAvailableSegmentNum) + + index.getDurationUs(lastAvailableSegmentNum, periodDurationUs); + availableEndTimeUs = min(availableEndTimeUs, adaptationSetAvailableEndTimeUs); } } return new PeriodSeekInfo(isIndexExplicit, availableStartTimeUs, availableEndTimeUs); From ea57e5d28b95d51b6a20c33bab4d101035e8a987 Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 25 Sep 2020 15:08:08 +0100 Subject: [PATCH 091/693] Always pass true for ongoing with the first notification ISSUE: #7977 PiperOrigin-RevId: 333726625 --- .../android/exoplayer2/ui/PlayerNotificationManager.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 e23c91cd16c..b52a3e6f82b 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 @@ -989,7 +989,6 @@ private void startOrUpdateNotification(Player player, @Nullable Bitmap bitmap) { Notification notification = builder.build(); notificationManager.notify(notificationId, notification); if (!isNotificationStarted) { - isNotificationStarted = true; context.registerReceiver(notificationBroadcastReceiver, intentFilter); if (notificationListener != null) { notificationListener.onNotificationStarted(notificationId, notification); @@ -997,8 +996,12 @@ private void startOrUpdateNotification(Player player, @Nullable Bitmap bitmap) { } @Nullable NotificationListener listener = notificationListener; if (listener != null) { - listener.onNotificationPosted(notificationId, notification, ongoing); + // Always pass true for ongoing with the first notification to tell a service to go into + // foreground even when paused. + listener.onNotificationPosted( + notificationId, notification, ongoing || !isNotificationStarted); } + isNotificationStarted = true; } // We're calling a deprecated listener method that we still want to notify. From 908785b701819dca61e143d9c0095c7c931d8f7e Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 25 Sep 2020 16:01:54 +0100 Subject: [PATCH 092/693] Parse TLEN duration in Mp3Extractor Issue: #7949 PiperOrigin-RevId: 333733615 --- RELEASENOTES.md | 2 ++ .../exoplayer2/extractor/mp3/MlltSeeker.java | 17 +++++++++------ .../extractor/mp3/Mp3Extractor.java | 21 +++++++++++++++++-- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7846978d305..f183fbb31e6 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -22,6 +22,8 @@ * Extractors: * Add support for .mp2 boxes in the `AtomParsers` ([#7967](https://github.com/google/ExoPlayer/issues/7967)). + * Use TLEN ID3 tag to compute the duration in Mp3Extractor + ([#7949](https://github.com/google/ExoPlayer/issues/7949)). ### 2.12.0 (2020-09-11) ### diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java index 1b627483f08..f30b8302497 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java @@ -29,9 +29,11 @@ * * @param firstFramePosition The position of the start of the first frame in the stream. * @param mlltFrame The MLLT frame with seeking metadata. + * @param durationUs The stream duration in microseconds, or {@link C#TIME_UNSET} if it is + * unknown. * @return An {@link MlltSeeker} for seeking in the stream. */ - public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame) { + public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame, long durationUs) { int referenceCount = mlltFrame.bytesDeviations.length; long[] referencePositions = new long[1 + referenceCount]; long[] referenceTimesMs = new long[1 + referenceCount]; @@ -45,19 +47,22 @@ public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame) { referencePositions[i] = position; referenceTimesMs[i] = timeMs; } - return new MlltSeeker(referencePositions, referenceTimesMs); + return new MlltSeeker(referencePositions, referenceTimesMs, durationUs); } private final long[] referencePositions; private final long[] referenceTimesMs; private final long durationUs; - private MlltSeeker(long[] referencePositions, long[] referenceTimesMs) { + private MlltSeeker(long[] referencePositions, long[] referenceTimesMs, long durationUs) { this.referencePositions = referencePositions; this.referenceTimesMs = referenceTimesMs; - // Use the last reference point as the duration, as extrapolating variable bitrate at the end of - // the stream may give a large error. - durationUs = C.msToUs(referenceTimesMs[referenceTimesMs.length - 1]); + // Use the last reference point as the duration if it is unknown, as extrapolating variable + // bitrate at the end of the stream may give a large error. + this.durationUs = + durationUs != C.TIME_UNSET + ? durationUs + : C.msToUs(referenceTimesMs[referenceTimesMs.length - 1]); } @Override diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java index 59d128ab9b7..c2aba6d7bd6 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -35,6 +35,7 @@ import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.metadata.id3.Id3Decoder.FramePredicate; import com.google.android.exoplayer2.metadata.id3.MlltFrame; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; @@ -432,7 +433,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException { @Nullable Seeker resultSeeker = null; if ((flags & FLAG_ENABLE_INDEX_SEEKING) != 0) { - long durationUs = C.TIME_UNSET; + long durationUs; long dataEndPosition = C.POSITION_UNSET; if (metadataSeeker != null) { durationUs = metadataSeeker.getDurationUs(); @@ -440,6 +441,8 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException { } else if (seekFrameSeeker != null) { durationUs = seekFrameSeeker.getDurationUs(); dataEndPosition = seekFrameSeeker.getDataEndPosition(); + } else { + durationUs = getId3TlenUs(metadata); } resultSeeker = new IndexSeeker( @@ -554,10 +557,24 @@ private static MlltSeeker maybeHandleSeekMetadata( for (int i = 0; i < length; i++) { Metadata.Entry entry = metadata.get(i); if (entry instanceof MlltFrame) { - return MlltSeeker.create(firstFramePosition, (MlltFrame) entry); + return MlltSeeker.create(firstFramePosition, (MlltFrame) entry, getId3TlenUs(metadata)); } } } return null; } + + private static long getId3TlenUs(@Nullable Metadata metadata) { + if (metadata != null) { + int length = metadata.length(); + for (int i = 0; i < length; i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof TextInformationFrame + && ((TextInformationFrame) entry).id.equals("TLEN")) { + return C.msToUs(Long.parseLong(((TextInformationFrame) entry).value)); + } + } + } + return C.TIME_UNSET; + } } From 34ef9b204231fc509de0b9e532b346af1f387c4b Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 26 Sep 2020 15:30:58 +0800 Subject: [PATCH 093/693] Add tests for the FlvExtractor seek map. Fix an EOF seeking issue. --- .../extractor/flv/FlvExtractor.java | 4 + .../exoplayer2/extractor/flv/SeekMapTest.java | 265 ++++++++++++++++++ .../assets/media/flv/sample-with-metadata.flv | Bin 0 -> 70474 bytes 3 files changed, 269 insertions(+) create mode 100644 library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/SeekMapTest.java create mode 100644 testdata/src/test/assets/media/flv/sample-with-metadata.flv diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java index eccd74fc820..124fc7ae4fa 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java @@ -322,6 +322,10 @@ private SeekMap buildSeekMap(List times, List filePositions, lon return new SeekMap.Unseekable(durationUs); } int keyFrameSize = times.size(); + if ((long) (times.get(times.size() - 1) * C.MICROS_PER_SECOND) == durationUs) { + // the last keyframe has no sample data followed (AVC_PACKET_TYPE_END_OF_SEQUENCE) + keyFrameSize = keyFrameSize - 1; + } int[] sizes = new int[keyFrameSize]; long[] offsets = new long[keyFrameSize]; long[] durationsUs = new long[keyFrameSize]; diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/SeekMapTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/SeekMapTest.java new file mode 100644 index 00000000000..f1ed29fc780 --- /dev/null +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/SeekMapTest.java @@ -0,0 +1,265 @@ +/* + * 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.extractor.flv; + +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.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.SeekMap; +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.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.util.List; +import java.util.Random; + +import static com.google.android.exoplayer2.testutil.TestUtil.extractAllSamplesFromFile; +import static com.google.android.exoplayer2.testutil.TestUtil.getExtractorInputFromPosition; +import static com.google.common.truth.Truth.assertThat; + +/** Unit test for SeekMap in {@link FlvExtractor}. */ +@RunWith(AndroidJUnit4.class) +public class SeekMapTest { + + // the test file was made by yamdi (https://github.com/ioppermann/yamdi) + // yamdi -i media/flv/source.flv -o media/flv/sample-with-metadata.flv + private static final String TEST_FILE_WITH_SEEK_TABLE = "media/flv/sample-with-metadata.flv"; + private static final long TEST_FILE_WITH_SEEK_TABLE_DURATION = 20_000_000; + private static final int TAG_TYPE_VIDEO = 9; // from FlvExtractor + + private static final Random random = new Random(System.currentTimeMillis()); + + private FlvExtractor extractor; + private FakeExtractorOutput extractorOutput; + private DefaultDataSource dataSource; + + @Before + public void setUp() throws Exception { + extractor = new FlvExtractor(); + extractorOutput = new FakeExtractorOutput(); + dataSource = + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) + .createDataSource(); + } + + @Test + public void flvExtractorReads_returnsSeekableSeekMap() throws Exception { + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE_WITH_SEEK_TABLE); + + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + + assertThat(seekMap.isSeekable()).isTrue(); + } + + @Test + public void flvExtractorReads_correctDuration() throws Exception { + FakeExtractorOutput extractorOutput = + extractAllSamplesFromFile( + extractor, ApplicationProvider.getApplicationContext(), TEST_FILE_WITH_SEEK_TABLE); + + SeekMap seekMap = extractorOutput.seekMap; + + assertThat(seekMap.getDurationUs()).isEqualTo(TEST_FILE_WITH_SEEK_TABLE_DURATION); + } + + @Test + public void seeking_handlesSeekToZero() throws Exception { + String fileName = TEST_FILE_WITH_SEEK_TABLE; + Uri fileUri = TestUtil.buildAssetUri(fileName); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(TAG_TYPE_VIDEO); + + long targetSeekTimeUs = 0; + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(C.INDEX_UNSET); + FakeTrackOutput expectedTrackOutput = getTrackOutput(fileName, TAG_TYPE_VIDEO); + assertFirstFrameAfterSeekHasCorrectData(trackOutput, extractedFrameIndex, expectedTrackOutput); + } + + @Test + public void seeking_handlesSeekToEof() throws Exception { + String fileName = TEST_FILE_WITH_SEEK_TABLE; + Uri fileUri = TestUtil.buildAssetUri(fileName); + SeekMap seekMap = extractSeekMapAndFillFormat( + extractor, extractorOutput, dataSource, fileUri, TAG_TYPE_VIDEO); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(TAG_TYPE_VIDEO); + + long targetSeekTimeUs = seekMap.getDurationUs(); + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(C.INDEX_UNSET); + FakeTrackOutput expectedTrackOutput = getTrackOutput(fileName, TAG_TYPE_VIDEO); + assertFirstFrameAfterSeekHasCorrectData(trackOutput, extractedFrameIndex, expectedTrackOutput); + } + + @Test + public void seeking_handlesSeekingBackward() throws Exception { + String fileName = TEST_FILE_WITH_SEEK_TABLE; + Uri fileUri = TestUtil.buildAssetUri(fileName); + + SeekMap seekMap = extractSeekMapAndFillFormat( + extractor, extractorOutput, dataSource, fileUri, TAG_TYPE_VIDEO); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(TAG_TYPE_VIDEO); + + long firstSeekTimeUs = seekMap.getDurationUs() * 2 / 3; + TestUtil.seekToTimeUs(extractor, seekMap, firstSeekTimeUs, dataSource, trackOutput, fileUri); + long targetSeekTimeUs = seekMap.getDurationUs() / 3; + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(C.INDEX_UNSET); + FakeTrackOutput expectedTrackOutput = getTrackOutput(fileName, TAG_TYPE_VIDEO); + assertFirstFrameAfterSeekHasCorrectData(trackOutput, extractedFrameIndex, expectedTrackOutput); + } + + @Test + public void seeking_handlesSeekingForward() throws Exception { + String fileName = TEST_FILE_WITH_SEEK_TABLE; + Uri fileUri = TestUtil.buildAssetUri(fileName); + SeekMap seekMap = extractSeekMapAndFillFormat( + extractor, extractorOutput, dataSource, fileUri, TAG_TYPE_VIDEO); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(TAG_TYPE_VIDEO); + + long firstSeekTimeUs = seekMap.getDurationUs() / 3; + TestUtil.seekToTimeUs(extractor, seekMap, firstSeekTimeUs, dataSource, trackOutput, fileUri); + long targetSeekTimeUs = seekMap.getDurationUs() * 2 / 3; + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(C.INDEX_UNSET); + FakeTrackOutput expectedTrackOutput = getTrackOutput(fileName, TAG_TYPE_VIDEO); + assertFirstFrameAfterSeekHasCorrectData(trackOutput, extractedFrameIndex, expectedTrackOutput); + } + + @Test + public void seeking_handlesRandomSeeks() throws IOException { + String fileName = TEST_FILE_WITH_SEEK_TABLE; + Uri fileUri = TestUtil.buildAssetUri(fileName); + + SeekMap seekMap = extractSeekMapAndFillFormat + (extractor, extractorOutput, dataSource, fileUri, TAG_TYPE_VIDEO); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(TAG_TYPE_VIDEO); + FakeTrackOutput expectedTrackOutput = getTrackOutput(fileName, TAG_TYPE_VIDEO); + + long numSeek = 7; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = random.nextInt((int)seekMap.getDurationUs() + 1); + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(C.INDEX_UNSET); + assertFirstFrameAfterSeekHasCorrectData( + trackOutput, extractedFrameIndex, expectedTrackOutput); + } + } + + private static void assertFirstFrameAfterSeekHasCorrectData( + FakeTrackOutput trackOutput, int firstFrameIndexAfterSeek, FakeTrackOutput expectedTrackOutput) { + long foundTimeUs = trackOutput.getSampleTimeUs(firstFrameIndexAfterSeek); + int foundFrameIndex = getFrameIndex(expectedTrackOutput, foundTimeUs); + + trackOutput.assertSample( + firstFrameIndexAfterSeek, + expectedTrackOutput.getSampleData(foundFrameIndex), + expectedTrackOutput.getSampleTimeUs(foundFrameIndex), + expectedTrackOutput.getSampleFlags(foundFrameIndex), + expectedTrackOutput.getSampleCryptoData(foundFrameIndex)); + } + + private static FakeTrackOutput getTrackOutput(String fileName, int trackId) throws IOException { + return extractAllSamplesFromFile( + new FlvExtractor(), + ApplicationProvider.getApplicationContext(), + fileName) + .trackOutputs + .get(trackId); + } + + private static int getFrameIndex(FakeTrackOutput trackOutput, long targetSeekTimeUs) { + List frameTimes = trackOutput.getSampleTimesUs(); + return Util.binarySearchFloor( + frameTimes, targetSeekTimeUs, /* inclusive= */ true, /* stayInBounds= */ false); + } + + public static SeekMap extractSeekMapAndFillFormat( + Extractor extractor, FakeExtractorOutput output, DataSource dataSource, + Uri uri, int trackId) + throws IOException { + ExtractorInput input = getExtractorInputFromPosition(dataSource, /* position= */ 0, uri); + extractor.init(output); + PositionHolder positionHolder = new PositionHolder(); + int readResult = Extractor.RESULT_CONTINUE; + while (true) { + try { + // Keep reading until we can get the seek map and the format + while (readResult == Extractor.RESULT_CONTINUE + && (output.seekMap == null + || !output.tracksEnded + || output.trackOutputs == null + || output.trackOutputs.get(trackId) == null + || output.trackOutputs.get(trackId).lastFormat == null)) { + readResult = extractor.read(input, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + + if (readResult == Extractor.RESULT_SEEK) { + input = getExtractorInputFromPosition(dataSource, positionHolder.position, uri); + readResult = Extractor.RESULT_CONTINUE; + } else if (readResult == Extractor.RESULT_END_OF_INPUT) { + if (output.seekMap == null) { + throw new IOException("EOF encountered without seekmap"); + } + if (output.trackOutputs == null) { + throw new IOException("EOF encountered without track"); + } + if (output.trackOutputs.get(trackId) == null) { + throw new IOException("EOF encountered without track with id " + trackId); + } + if (output.trackOutputs.get(trackId).lastFormat == null) { + throw new IOException("EOF encountered without format"); + } + } + if (output.seekMap != null) { + return output.seekMap; + } + } + } +} diff --git a/testdata/src/test/assets/media/flv/sample-with-metadata.flv b/testdata/src/test/assets/media/flv/sample-with-metadata.flv new file mode 100644 index 0000000000000000000000000000000000000000..8e5a054c3461a2e3e8426c209fa5c758f73f9852 GIT binary patch literal 70474 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

    @U%=2IlFEWWDi%;&c#w^ zp{6=J_JnJ}mBaF>c1&=$Dk06wxB^*)*SEBbA}4^jC@=*p2{(FmOW!AuILP_oB;>|T`VBxK{&<0<;&tYA)a{xu7=FL zJ=R9JduaR)q8`>}#Hl-sui&Ti!qsOo)S(!X`nI=YC_Y74%fnW9Z2$E*CH$|Xp zGM2<&CB=OOt#9aXC@r4L=xg1SzqyV>2PR?Ak|=M(2e@nlY2FU92t#ftm>Q>~Q}a!T zBaLOQ-dK2ibi}Ydb4HaD!6KI!0p(>wgp;o+&v^o3Fp}9v+Kx9j{Wmr4%C9?l{6|k^ z2)F#pOMBKOkUkL_DEE^<1=BCut(bizAwX!~=L?R1WvrV}JVfLc>kj$H7!>^^7xrx^ zxOa-tBJ*IO*MNr;t>ioMYUjCnr>yuVleY>)J95NU8zEINNF~$PJQ5gfGW&nT%_d|? zL@r=q?wJbxQgY#up}nn_M$=B*RCH|0>T=c^H4P^L>i+T+-X+t+PPoudf3d=|!!sV1=Fah6!R zb@l4~TL^dL4i#^|SF1`#n`=)qCNu>(sk+wob|NavP}A`c%Y6yw~*Ru-3+bC_C}+Za)<`)4wbA2!M7SfQT>%=cVEt z>`b}Db5Q}%dfqp2YkM&%ZOK7zj<}E|OcX32hW+f+sjE_Vd)Sf@k`jVvvH9JJSots| zatf$P15+`9aBhLi7e`77k;?*<2s}*bA?66MQM^~jQ!dqR**qrJMTXEFkDBpO@AuhL0zbZsG`Ec;_>?Sok}RaW zHhOsz+u|r~v|k=a-}anKI5p3I&Vp*F3~WKX*r zsx(B%yg;U3He;-=_JR2$S^TVxMeyYr+jS)F$=VTyKZry?E&zih>ALpp&e{^H}Z;@=iDvcP1_AlzJFju*I6LF79B^{)qX$9O=m zebgG)QYuS4gIL^DHra23%lZ-<9)4sy(YZnEc@3xE%EzdK5x`C z@}4{a(;XVD?rgHhtMqdw9M9J#b!24TjtY5}W--Gam(p53Jo5cF$P8$gYy9Nx=uS*X z9z8wA4a1i2HMnC)6p(WhK5=$6j`c_}4FoAXYIlpb*u9_579C2Iox-$iI-0)C$wLc- zR~idCuXLK;QN>D9#+b6J(R0=r&2NnltVACuxl_uSyPflQVX3S1G81KGGo2%v9y9XX zMlFd5{Zo4ZR~z;^pHaoZif%l^h=Q$L)#WOY)iWzC%T5E)k}WoxfDChF5(k&PP^nzQ z3Wr+@C20<&vCH}kB)RxY=LP*Qxx#MWHhypY_hI%#YpGL-}eaaN1|cv}V$kx_;}!iGq$!J#X+`OnJ*|c;=2zu)ax3w6_vo5{DmZz6%lREVhP`iY&je+7Q(iuhjyMy zuX1hom+Xr0&nVXCfE_g#UfyUY!@Mcieg+6@I#EIzLRhWiK4obKsq|}tn!wDw`}y*& z=z^Sc(`!gJq2=zW>^1RD@>-^scVAwW z`HH!abSVd6lKW{`%p4NW+sY+2xE(v#QC7UZFHa$uDL@f27G}chyX;6Xd-T@ySF`?Z63MbuQr$06jv8OQcVH-mWOJgc--#|~hvUj0^XqrHc0|?D{<0p1 z0{W+R|7i;2U#)k0gKwPY{}|^`%w?l4yt&4MaTo9RMA^R10TDPc#Fd*378Lie@{Urw z)z&$@{;aT{|4>LB?s|GUf%FaPI4En6uk&F3_pqXmx_ONfcSQwv{mjxPMM z>tT4P%^=18Fb2;cG^6p9^j7;F`$hH$^M|`#~aO4lczR#yBYL8jN zR?Rh6E{fJNSyV!>mu?aP=QV7|RPL#7`~Y4C^iO!uUdcxJz&`Xv5%)J?I`3;?b2@D+?R$ zG7;v{OgUt==-lp|DeO+hNPvbOL~vA(Qebya_Ay4W=*LW7-3@3fSijZZ(#rp9?-ozY zjXv#?$K##|hGh5`-(2b@PLkVzS<|(L9wZQQb8L#!MTmjYm10cK*ZYj{(Z}*%*8o$4t1Ngy_%{E{4~8&{cn&ET!uM<|1{iDCm|=2J z%6v2alp0b*uUR(jrAPfGI)946-%2zzP$hQ*3gW;dY#{ukf0KYmT8Nxi;4^DJ^f7!C z%dk4sjM=4nGw~R4Q+tMEsZ3s{Uny|tF~D0uaA!7J5I(S${2y{jAaX>(hx+oTHfY%f zT^=jWKR|Bhgnz>~xX+ut+q5ngH9 z64ch_Oa)J$h(D6ELz&$@b)^Me?ZH6x+b<94uHhq07(^9jBA!Nml{jk)Xl!6qCixwf z8Kw_2+*R8Ob)WBKvhSg>PzWQc{TQ@BShk4V`qu3R)epJ`$FO$Jn3C;=!FCtC_Im?| zPr54ANYkT~RmPf$-VOI__ZBs?e2Uy9O;L1J6R47`&QB3LeTBA+ia)SpG43h9i44ueX_!|z7vCZ_&^e^&8F~tM!(Fpcj*AE@o zQu(fDoeB4T$Wcw&Br}2htE68dD-aES3evxxxtoxo#vU{Lps_eP3%k z2K{54uysdaGGW*ApgoKs3&T`n%LfJ*ZS+7vMZd1vD&LEqlsHk9gvne9N{FR(>Mj%L zbOZ4W;D#SWfc_6R;J||o;AX+lyT-l)Ja=$)~q&JYxBF*%?Frg&*V1TTf#48T1A$7X;BzMc{=(5cSBmsJZ)^Vr#W zv}}OyFLeiKzP~uW0ujUlm;XW!h(j<*+awS7JhOmKM>W@4#ee1rNp^I`8ZTF!;Um%{ z#?$?k+OoQT_rly=j*A^%c{dU>R)p?ZzQKaO)qt@fi80wueBrSOYTVcK22Z3)P;<+K ztxrjI$~Aoadzr;jb|XjmlSjHqeEi8zCS^*rN55+Q zSf7quNwl|;`f-k}A;&s&*n>S=6+bXi5ZN#3NZcch?Yg+5O>J!anY)Z=cFF1NF7=XG zA()zUr5nWrf9{OXgC$~Oj6XvTM!DheVa-9}&_(liSvxm+d{WF+G0Ak4hUdAC_q37Q zoi0{+?@VlZk!6?uQScmm8DTv1tj1j?#2xE`l&ZF_mbEduBSQR?)S!;cb@I?2)9ib= z94d0%*8EEgMxnv_KTz1lBIwThD=l>}BF*Q6K|Q*BiB%&};YEt`=jV{6_=WBhwy?tn zk+|m8xy-u-eqWf1D-nGr-NVs`MOYX~(AN8%D4&a?x%?nlg1aUxMj%v1m>-h7#W)ws zg)}qc+Zd{2cmHU)zA@h&3+d06UbZH7TZe}tIg;f2$SAJ7K@S&> zpFU_7Bi=U7f3sjc#6rV%DB2*0;RG}A%v+Xdz?R~W`FP+Z;MDe8$3J69NMb}3h$A2X zTu8%jq44%x5FkyD<1- zef=0`bpQx~;|Nv|0pQvHgA;fJxUga{m=AnD;jJOlv~KwtPK;stA{A zbAJqu8^ED61&9z4tn@F00J=_<006$BYd}(3L9|e*lO@c4aKZQh_Q61J0f;LQA@@Id zunz{X`#*+PEOUF9|9YChF{JyGv~%qcd+clnntqOCci?U}1 zU80FJ+u%F;41*H|KITW-eS$uNdsczJv*~8f&9Lt|#(b&B5B+bTs zvx2W`xE9fw>7N-M*Mks|h=Bh%7~!IOYFu*il&4YS?lritN#6~zkEiRs#TCdtr}gz3 z3O~WL_wl0CLhLL0WingigyiB04+$3rw#ceq^w)T*el_0a`+~>Ex@^*Fp>#OZ(cm0E zTB~Rv>34~CmVF!WFvf74z<-o6RH&b9>gDu;UBdsbOq26JKKL(o)$He8-=~j_884jA zO;FAq!weE*2Dtr6ck4{%q zah1t&8FQQGH_LGblC z&WfNnau?P|s^nH1>9ZP_k?)4>)y$pTO}u*xht5ZXd_T3{1u*WlB>pjh1Bfak6Wo#* z*L+F6mxSAeKO|Yr>xgYyJ5=hUGQyy{YjS!cc7IGwEod5C@8X=GLlfi^z%MMne($M*WEGpH!t@$R}3Cp>litgSD>)|@ySCh>p z{fjulQV!-RTukS$sc#I~xaY0VAej>C(M90~eoM6^J+9&tKs8lsqrkN;^YnyDcG_`< z@pD@8h@9OlqNe)hF?=W_%PXT;3=_hwP*pZ9?pa#$KdY?2TPTT!YFv=DeZejf^gq~u zBM4ys2Bw9ru1a-G=ET}sY+GRN1j7{rFp8*m;Pg_Vd5K%C2`7d;U zg#C{q2117}_gejW*!kNC6F((qgvXVGaQbCC3!>Gx4nc1}UF4L>c%@9`nNEn9ue?Jj z|5jdmVshNNPlQ_g!q70!2Aw{B%{TPJ8AZd&Bh77P#p+UG zLwZ@pvB?S2@3eRYS@WdlQ0V4yimj%^@KFx#Zm!}0WjH^GQ>*RYPF9RZ=FKHg-taA> z!c5i|I%y%h^b;JVEVECkX`Eg2p=MSSvsC!#;evN3HtgOZMvgin`Cr;)G7W9z&-%5k`>*`iTwEQ4BO7KTTrZ+OMI;E7#sHU#!L$M4e>!5t;YAD(| zs;Lc0>Y+4j$lUp%G|Haa7vX7e&lVErHVBMK-Wxv{c^BFIp24%k739FoA6MA?N_YyN zPFS@t{<(gwgJ;>rqxapDpp}`^ov>}QJZXT>xZxc>GOZib>(Nm={bh7zE_40_U*Sl% zSjG}FERHsMTMy#HXtp)n)5)YcP698<@#aFhV+aZYMd$hy{HAhT!c77?$>BlUV=fsP z$G$f@o@5rb*LJkfT2q1xf+2=QnOeTDCoyd)b}1mZR#}^HGJ1$OhV6gZ+?Rm8k6 z*i|y-K7816Z6#>Cz*}CSN&P`U0`VSsPWGhwl_CmEBl(jCyHC~UVf#Xev0K-Vgo9we zObB3#7ew@u@cnON_m@SMzDyJd$mEzQ%D$_M{-iUUV_mi#to3TM%6TOv4iy?=XfCJ@ z@-rBi;Ef$8i0CEl3x*m4m^}tu@{R^_@Ns|y%6QYC2r^K|BpvW(hOUvms9n)USnhY- zG8938Fi?XJA_oyYfffIS6hLnZS1__E+S^X^q@(mBqB6Ipp(nu^6>x8gX%O*CauMv6 zhyP=bVFKr_yofD#a!G|{vupWn)?HEkYzTW}pg684(|TwUoC1=oT6+? zn4CI+>QT6n^f>N1*~}o*=c}!O9Zc|#QRLw|>yOZ<@}<$7C#^(h+@0bDT(f?VB3etF zHO$9ys6C7HQ*h0n5TU%uJM)Y?iQM@8C_4st}+>dd`U`JiBe&`My5mWO>WJ4A^oBfCE2>_$Av5 z1|{2nT(QhqkA7vmkgF>pI<$+fX+*uDnexZkN6k|KSD$6OL0^YK* zfQWs8%fF}skJMm-7=Y#WPh=6+X(^tE5#z|4lWnxEo{??u-mCzznxWq)HXl(^}B|*YP7;{MzABIZX8C;D95ezL*l4HT zU3}@xyeqhkpSXGdQ@l6uQ@;xRF2va78{}C^99p-_(j1H6xV=uv$9Q45^E2W#n5Eeh zBIb*Ob|Zv8Wk!1EwMnWF=pUur?W#wY68Z3EyMz&^rqz1a706$&ID9I^UaVxm)g$lU zsnz%%$)qel%7Nc?6yF!CN~CZKXC+sSU%=Rs?Y!#bAd8SG%9b}@J+HpkL#$3rji&9$ zxPBZGE!4q>LD>v1oxf9#f?xnsVm~s``29k0lF`dF^c5@$yQ&v6wb|z5u?9)<`Nih2 z31%Ui5K_kie641{WMVEwrm!sv@~u!4n=eLfui$S`i)WBRM@=RD`gwGPc8#Ll=F+;} z_vt2Bz|7!891n>220+1In1RCs;MCn;diP$URQ%LJ)y>9Rmd>XMwRMKi4aea2&7*tTV*xJ$^n_rL@+5$l=X`h4A!hU*x-Q^Y zh->*I06&9~O|L-eo8;x2!ew;zc3vuJDozM;D#O$b=oBwbQ23vsvxng6>I~I0qR}dd zu~{89#@oE zOQhZVIS#Y!oO;#mjrWSy$}4XP+3MSkwko-)qNP*+dtiw#2#_(1x}X1 z2FKEs!-F!RYHAh z5+KV5v!`0U8{dc{Jx5S~=!p>%CZ zVWx<(-5Lgkm|9oJI^wg@wy~72#xM;odu~W{P=p2>+8MB1uo#|(7AebX8!xU}<*!EP zE<6{|8QJ5wh6b2BUTf0}tjLYK!I9PS9#9X@m|ePbuf3iZ@HL;YTIPi8w`oJ?j`9Cf zskJeY>%aO_!jXcKer?z5x6{!Z)>-5V0q20_;pq7UiQKb=)JhMZZ<>tqs^z=L2S7Yo zxH6VVh?T0f7#*`TPo3{5QRzX5Dq659)=OMQVyCZ`Q&l7BRaVCL`vSYi=SJZTw{o$- zl+}P82c3Pv`h1B&WvyY=Y6zSx5xyp;e#s~cjl33%h0cXS+5#zi9sNz{_356?S)gW% zZT!=7IRnT94kAA~QUW$^cQ}+)xEbQt@CFIrmxO|&Z;;P(8!Sy~bv0UNIs?!Zq3@?I zD&-eWW50J1v5%P}kD`K_iK0nhFe^#Nd*X_yK;+hh36*u`#u`d|OKwmN8{?B{?!0gX zoJh?$bot|dAiZR0$6VXKoBp8 z^d$-VU;Ox&UA4MT`{XsP%>F5^e9PRFMMj<6x8+V%uq{N}lg?juY{54vl!?!Z0)PcL zexwDFI{t$acmycXzufAMrjRb1pRH4$PIacVf!s)rqI|q`5Fh|h`#>91(wA&3c!fs% zk4L>pp#-ntKpRwu9T4eD-VKbl)qkYvnjv{R?*ml-o-y$T0?kEFe`Qm9hJ1kE&vz%) z{iwepw|a73O&G^cw3$xiW>KMHpMFWFeaE-k>>VKwW1ZYmS0RLAK24?Scjb)%n&fzW zxBDq81SXCn%4n#towmtDY9zJJ=3b*c)hAC>L)h0t7qXuxxMk4-t_9O8cCXU@0?KrP`%>Gr z;o+c#90bK|{{2e>@nLmcF{A6}j0(iCge!X65h&sNX7P@Ph`2J&!@Hlw2N8n$n4DUe zml&<|1=Tgrn#XO#Gt@`Sno6;p*dD;k3s3pdUh|kIe<1lW(`E2Qw;Jon3jw92P^B1Y zzSo8@JJKO7CKS0p^xNGR-8sR?bjuFbs&)Ol!Od!3x}YB|h6YEz%CAj7mN!kdw9B`> zUzm<7eLrTMf=Rsb8y{+>#u=@IX+{}7H2 z%O`o4b;A0I$UG@j=w4!5AhFqmq7aLyG-pI4^O7aYanmQ&H0LVesa}T6>JaBd9Kz-j zR6?y^X=r==z>Rualz#I&aMfc#n6(BJ(-gzxNU<_F>Vuu{8arSrMptPtRj;M~%V|Y* zU06vMK7XrQqZywTp|FkHV@j>5ZvIw5%ZkwYhzc@rM{ED=hxV|VrFUI%U+* z)4nq848cCJ3fAF&zg_yyNYO**7GAja!vZr{OIfP={n>hriy$eSPZ}$)!{dsqJzD4t+pD;WBbXGJC%G?dVU5&vpkqscE?VwCRT2-R?Ua(giAH(L*`HP zyrOY53py6BTQxEGT)1L65ZRHcZn~qROG(SvOQ+y05gv;*>KB!oU?)9uigQev(l(RT z>aBhK^dM!%zW)q2KFl|epJ`M62p{Vc?4*d{=eAym7{92ID8SRW*~BX`7JUNPsRghu z1Bfi?pY6cTjr@N_tXF5XWI0S%hq_fQVKC53)`Vt*@1jB;-}C@7;DkR5hzz{q{Y3?M z1eR4WMR_eaBPV-YdCy^m)El+r3EhT5&d?6D%)%3tEnnycxoh-oN}E z-g->r-DkM*B;{;DNZ(LD#H~A$e3Mou{sr&!lIhEZceSgKJXo1*kb-n_lwT}jhI`z?mCK1(M%l@ugJi|ACq_G^AfN+UFJW!pc~qvPvNC44LU&yc97#=EB#@_V)l$2W&w)ERqv@+aH9H??;6V4@{62K`PpDC z=6-!smUOV-8*P6vJj!nF)kSo{Y!Yk%@`M6*&}MSeG74jBpH~y#>a6qke{U?j>Kgx*m|@+@S*<2 zwBw<`t`+1EZH%Ps{MLC4&9O*pvfhC~`Vuj>z=>cmn1?Om*AsMPo2$sHk&|;=b~3<_ zzX0>IfyiIdj9|zCZ!ZILjav6coylShA9Y$kCGc%;w9>Ro3=fmY({Nr3Lhl1F7Xc)} z3?T!E-0UATz#~xI0j@fI_U=Nk~;4?YGpH@-h%76)X&fF%HtzoZwz zYbpAF%;GV|U7fhDk5yF;aE}^TQ3FBbFBvE>^gRA?i9f<;j9TgIVHcA}o%lPhjcQPi z)*yJ5?FqhVm(b>@dJZMTCD>0)Ce7k0@@T7A{tkEZou4Vs)LHOh4v_BdwepiwM4jk= zH#RHrwa?;J17R-jGa%ZdUu(D6+L-%JG&#c&3s`{gTT+3II9?i!sPkabB17Jz)>-%TSsh~g!Y_uo8! zX}jYgWZSI7sdg=TR?L~y=mDi{*Gw5AXFnGz_DH?h2%#E47JS%)4n*;i*8_eMc+UwY z?T%5fd5KHEo6nHcK0@!zYMi6QwZy`~;sX-kJtsei!r;Fz1A)NnA>04ByXPtYY9|>O z>o?p1lXrU$UQW^5<=8RrcgW|`TQ3Tu355*!8!zB3f1TV43#*t?KfjGzG@5VgFIyik zHtP)6gTYyfNgxhSdC%^DjzirZdNs9CK1;NUmWdk-)piRDO6eKUtIU);PMh>g8^I#V zgJu@Jdsmt(V{*+v%nC1aBu_O7FE6(p!dD6-leg;ru_JbPFE7wI>T}-D=(1*Mh$mMfTM>%H@Zgth3L@dI%@@OkWuf>zu36 zBdj>hjWnr^ssgH>ZfX9mbLa$9_ifG&ZRqrfZEys5WMFQK8AS1t1q1sDs(%!jQI|wy zZ@%$%F`tJ@FA0B*4=;ofWRGHvxEDD23l4H9KoqtAEC3z>Y70z@J+jG;LLTK%m(U5` zb}a10FaTfmw>LZ)h+^TNJlHwl{zrw)`>Yd`FJFHckOJFkg z<3{_?MUdrnTtolMyYF5np}5D4`a+sDnEn095d;Xb7|yYUV=~bv=YTHey`MS@_h~{G zi@Y*i8#O#W+J*e)aeE6_Clr*Y+!+RH;-Qpp+%L|=T;j!7`FtYGOS>_jcFRt)Ka5RXstifY#-yLnr#QnQ zjQP>JHPjdsnCZ-N7||L@UsI#?Yh>@^WKf&fYOA=w!GtVTEm4IN@VLMyRhU7PFIl4h z+4e7?bd>@vH7+3`W8cGVQmFS?HZ(csqQdrDPLMTZF!1!jwxtG9YW=encm&=p11=DJ zw4;GpknVHU-{98kJl)aR`^D3q1bhq}zye;}LJ8J>2{?ckw}2}G53qEq2^N?AE>f0W zi-A7|M8AV5Uy=oYZ9yad_@di<)>gwvNr%MP*Cv7Fu`)_mI&(r2)z62_3!B_kff$pc zV!_^XJGQi*H`PmSI1MEa&vHy`tCFgY*!6?a#y53W334+`p>yvuNTrx7r-X1M)^JVapy>r$NCE=>)F+A~)mglG3*RMoO?h)WB zq@#TW{4vY_J^5MS^2MV9TZg}8Zr3Q}92PRK3m?^nnV41dLl$TGt(skW-CD|kxOfZqgO0ra<$4LWz5oK$&4a5TIE zCczxSM!D|0!_XfM!Sb1yeYMPP9W+XzP+zP`jQ2^8P;G9^zF&Pr3OBEl5Ic;I2S)Y~ zlPQ1bnNj~guD&{;s-Wrn(jlO9Dc#*&7wJyv?v#+0knZjjMUay24(V=CIs^gfuJ2rY z7c z!Rf)kfn3-RWrT%vbe%b!3MBS%c9elJ>_6VLzOUktouAZb<{)wQef1`2MrM{44F;YC z*~9k+;W#_IS7eh4&8Z~h_qWLYi9#{)k>RScj{;t={89nNR)8=C5QQv|e6m<^aC!l7 z{FTO{WmI3+d6zv$GOwtJ1Z?VybhdGA??~}UYa$czxDkWLKI>jk9b zlKjIbS@l!yorc0jh5$n?fH?<<;t3yy)Px?K-Ur+iu^syHf#%T6W^pZF!Q1qWh<4%7 zz}t7W`*Yke^F)c`yC3r1WFamE;>%P(6#s)$N&`1J;~@!Go)1>4MxKF6)KJaZ$s#YO zMp#dp+x@m&&>OjL%ebB;zRqm<%_%~>$k_X0!oSG)IC7N5HOr(1UD#KC2iAEq>8Bqc zKo1bW4SabAB%d093Am>K5D@I;4)+O}_gRVP4|{1}oNsRTA9{OIXCzRGzPNJEp$p|r zv5XBRi38^V1WOA~<}uXA`T0+E3Y&WOfCjKPpr z-B`uEBQpDBK*iVB4?KXpCDb6wCuj^pOa#s-1Bi{+w)BG2L)YggG1#6-e~0#$#Cywu z5)lVbKwd<05M?KjeA0>loH2%nSXLl~BmT=ppvuM->jGrQ6n3bbFa%IR-Yr59G7=!C|3G?8y!7ZcW>&&K42+voeI z%$)@~4j~^7KdIBt^by<5d(*w=dvM@`YN1tlZB1rBRj&n^x&@wnTYGdN?v13~fib_t z>*3t49Kz00S0j)2tjaWfQhpP9?yx&FW|T=)<|OW`7H{@9X+1dQI@>1dipdaU9u7m= zG1J(G;jfDGR|vL)WY`N=RcH$1n5Kp;^Vf4IieS4jDa5RQYauNomR`x;`&aQx)Z!_9 zFVaky8M4>(e%vhSn<2UIHJ~OJWe~GMh zWLJB{jIhC~*CWyM&U{pshS}rRn{4#=cFR_X($B)r$(rGPTipvaHa(ymZkQp)!JXruZXeK@QkV_5oW$_=5srS#rP!4k+rz{a z&7JG|wOyQ}ki;Bk#hhJyx$3hCW`fSfA#Xn}?qI{{DR(PaaKM5CZPxO=fxTv^5lk9u zzzEsURk!13LB&GE1X0KFEhe`a|0zx=bhw77OU$XymkozE46b&%?zJBI8i_ni*4Y8$ zog`B;xIz|wCrw^L%RL=1?gekp!MG35m*;!3vl|~(E$Qi7k_roRm(af`FnD#${9#Dv zlfe|O$cAmwr_oDzhv$`a-;C~%-@I#4AuWj>B4!WYfwaX>1c%Wb%TGr3qKdqNPj)4q zcJPWnmw~t4>$=(jv8CXM9fcxPeksME(&4Mr%`UAN!k(lZz`68{a4+-{Rrq zBFnPQVAyTAd10nNwG%W2py*T)GX--^I22OoItpu#310BB!P+f|GEfgU`mC0(^17LN zni5*Jd46SBtac3JlxygvkO~`L08{N|+|w;uSy=X$AZu+>``Jzwyvd`ctSGa%daIPw zAExf?iAHI%9~jS$MYIc2DlvvgW#}zst4{gAhr*l^?dOfW_ykk61`@&e^3}!SXpB&V zl334K$g-YeUn|Q5)nuNVZ*-eJgSE&-zZIk{FAKN4u`CIE=(awv8GkT@zSUzv6{+l< z)Ej8$NLcaudg4xZJ%Jouvd@TbPl@%}c$JLHAyx3XRH$z=W)`2tky9-a43ET3PNI$y zZ5XOzHS4>LNJg8^wNlp6w)YL2d}{i+NdEWZ%IfLtEbG&-Kcu5S;VDhSr*A%!)? zQKWV0ClYEaBJbWYd?=fbbWe5C+Psp~mHRzBEj0Uzt%x?|XrYkI9v@w`bcQswoQP;| z1Lm7h?!eq^b0A}}T2vODK863HnSuV>hg9T1|Fu+4K`%4gFb%3-^MCp&I+u#`v}tS{ zo!>&IBDBh050#!x*CC7KonRce*dq8Wgb2q{-V~xVjD2(vrSfdMbuFMcKaZ!k8S|I! z;E{lqFnAr>^ixfLL3q*OFo2)U?kqe(cWa|!t8olxQ>AH};A&{9bOp!NT!^jG_yUEO zE`N1aGkR8NI&59cWt9B4HIk^SYg}UMkQiv$(%BAyaPUG&$od%lBT1Qe{pN@U?fAz* zwu~u%jINb;(cy%deN_2>-f@T1jM|+ZX+G0Z?~F*jQy)jjB1+4dvO!ZBh*_peZywp2 zJhP93PaPB9L}26}?Nv>S5940;E zBvHjzUdTUYU{~Zt=ioD(y3nnNx?7FVO(Y`LYUI-V!Ko)yL=&ocgl@U?z;l>qW|}36 zI1$C;4c1T4tSeO3WAw9ZO4}yQFYya>4MbhI!hB^6SQ;g*pEu z7u?WrUkjN=|IDI2F74kxUe&gKRagej#q|WnDGU%51oLYByc*Jt;7Qe$sPFf z3Ltm^qO$r&0GZ+T>k$IFhJBV{xSFuLGUBNKMer_-hTv;O$Gpmg8xhK z1pWPoL*M@u@r3dHhhXRba;W`Z5kMpbQS!fZSoufrmje(a{ckO3o&cr)5Xk)>4m9ll zIQ*>z5cvEz2P_cH6FLOp@a!?nSq43G)xZV*58Zec2Hh7`+oOl2ZZkv${SIy~ z6ix?O1|N6~iB_pWbksodQ3psr6M@qkfLV$71N8drC{>MDHr)1y4bO_Iw%}HQ%b`1f z0uq3dfasL}m4XPu1*b0nZHV(zr(5Ui`~^&@a4EV-#ylNocE76L?a-6ehCm0XB(t<2pwj$XtM|EeF2@7WT>{C+N@2vpB-A=VEB-2k#f4*~9 z*&zVYKh2^ba)Ebd9Uz6xG4UXM_mXGPVHL=PyfZi;`u~|Ip@P$40YVuAQ=}Ryh>C!9 zy@&%b5CTaMB-#h6{2~-`q%z`ziI;YsoR|^VjL!zsj?_x2Hw)Ql3moK7#<*t}SzqgO znp#VBiln8as**V)RRrbZEcSdkvqW9Gfu)?lx{sZ#N3s&?hNmuPNJ`W0Ht2Rc-|4FG z;2evol7aBHuxMlR(pbN(qd#9K`TG}5UHH)B+|wi^vF?_RKAaxKs*;T`$>)jFxG!q% z<1q>!ml6mgpj1B+v)W@Jv5LpsyA>)ESUKiGMCv-sAix$&1*T2?%Zo)a|*^I2#S|6s_^ z`8*BFzBm}tdhcRMv?4yE^fSO0{H;U4-z|MNFgl@7E;q?%#5#(@?NXT@S#1$!gs+={ zRlt0kIK;fTsGC-ynT4q$rY|aBIZ9Wf)!Lwk-!(=K>_gyssjUvv`s`l710h#vDs~!E zTP1j)^A4xSoRgpDdRve$!X`^^F}J51qYyD%t6Vr`_l{=uEvIVE*Ev(HP01JJuLXa6Iz4Po>&M7v?{v^| z9@7oj@*ImWyEAAUH>0kXr`t`1nzynXMr3v-7n}`P5+JHQ&v2}@+^0KmTz79!tku?v zp#Ekagb~1a_=?cZP%lyN%7tA)Ih|lKWZTy>?7Tpm=JbP4#CLKo+4n(mYcpptVOH!6 zH{wcD`N@)75$t{AGb?`FMs5)XCyjDR-ERop)haLE$`z%cb)0)WOIZAdLt#fBq?XqR zA8u%07BReSj*C_Px|V+D2Uj*l7|M+=Q&qyyPey-*#7U&3y%&!333dVGORvYyj2khI3MqElG--$8(GHs3@}H@eIbu)8Ml|~2<64%; zC9^J)4|%D_nKRtRgqPQiavVkGcWR3+;#titvamO8#SmXA)#)tT?4avXXd>Tbt)1vo z#51%QusmcOxIE9FPhwGY)a)wDMdW%FUXtz)hmU8?PLjH-gp|Wp%aYlim=Ttxy1%=g z7vMHp@;y|cx2C`nH(7bykT5#ref@#L2~jy4hny@T7k{$(@JpHxY~FaNdnf&hjuW!z zrN7sr-*7@lbL=KGSB2rUfdo*-^55V;_*2^FW|x z&|q?LVO-&-TR#Nr5(0jS96)mlml4=S}`JJs(o`lDk~fV30bazL24se4_h4*lQfCL z{?$Vo)Va!fhY0LIV-7KU=ipuCe#93&)UaXL2QfRsxonRqY<8A;ADke>4XcOJBc;fr zn&pFBv~3=L!K#J9IY%8m)$Oe7=SH?!*^Qiuw6cdq1!69GBCN0iH#P{t%f=Pi{X zx0pHa!=Ma{vIi?qy6#?MDbr_vHyeXBiOOzGxbY2VZ-%`1` zY|pYSC^D^@nd(qqR*t7(fbFFKouR)=&K;$dipMYhX7&eKJ3J*(y=Vy&#@JmH>OX2B zNy>H4yvqf|MnDU8Nf%SAeXXR()^MMoHxx1%+iO!sr=eaqIH>mpa8@XN;lm`QcrmPf zGSR`n&F$xC{}lu)6vt%86c9;9An^st8tI@7FLsnFVS24u68>43SZhHtvI(@OuY7c7 z$xP?Mc#6c=cZD>bK16HZ$0ZC2KGD!Wv(VLksBF}6q(~+(YJUBi+$h$J?1~C)$pY!t z&K|60TZi)Pz2A=S;r>LJeddP5(va2Udc^5wuS{cEJX3BN>_&Jfr!+gZchm;q!LzkA z8GFXo5oODjiW}2S4oZZjaA?E@$8?)Hc`gM8Py&V@zgQ2vPQ@tL$VnV%<&f8yjP|l9MJpbgH>{_`_>fG}%G$Uie zr`Pvl2{(#t?n9}|CdQB|iGu(f%yYYB<8drizL{Rb3sE7I)hm+iUSU|oXXC%nC@7eV zwzv}CCn)m*_iV2GjMF*5Ov@Tv0xNk761GbY)slF6X&f1CD1rHSgk8|yyK2Zpdr0JE z5=dUvQuwKu5K~V@B_s{wd?=0G;FXU55h^?2is^zD2rkqHwH z!_VnoRGnmGCZ%n^vcH-h(GXa7eg8@_FnD`xtFM#z?7%7sOo}{}%INYX1LYlQbt*!m9s?&;3Cvyd0Y_m*qY22HOU*pBTa*brLAHQRKKbfFs0clFOUaQjw9 z5p(2MANcMHvGr?Zv--zq@HJ%I8$_n+Sc74X-pB^iN2sMNA?zOMKX2%e`t{a)A|kV6tcufP(+6MbxLpD$bs;F!MpIaP{uBLC)ZN>hA0Al=H1 zvCVax+OfqNM4d_Kr^?PMygn0>V84BLYx3SVXyIj=^l?hUkP990pZXnQQ{0H!H1qX- zHOCgjY4sfR5+e7x?|D2Umsby5Y3SqC&T10470-+p-I+WaNMB-|>XV$jRFv|zn`@M! zE!bVfr%{`34`Qut8b3bYI3k9Rb9G+uM(-6m>2Fkw6s}cUJ;7mJpS30-Ux8YJitqnX z=Mnh&wiM32(rtnW*9Y_H!#*3n7iaizpmV~a)=F5B5no)=n`Ic$&s3wO1r^V^dnPhA z;MWOHNI$$NZ+u2%Tc=Mj+oke7ei&+<&{YiPN0ly|N+qmcD?6*9+Zq`arnVk9`?bcs z!NYnM%N6^FNu%NC1z?70-jz2&xUmzLU*Rs{*7ggR2(WaVLj>vN^woHTy|%^6AE;|I zZ5tiG2XAMuMf^^V%;~r&E%!~PuUwPBO7oP_aF;vfD;cUWJXsx4Ohz&9z4>VGEK;G9 z|7kJ;ojrA)hB4rWUlOnWYTmH~#z&bw?-~b7l-T5asm);8()QfA9@Y=*Dla|Xnti@F z&l^>ixNiGu5S|cXBCIgN#`tc|nj;w-7o{UakAF_`K>pxQR8}qgG=3eD;(mQ0R) zdo#TKVS3|eMAgvxksPxJM1HGN%0sKxE0&mMHn6~`ZF6-YOPfZbt0;9*mm>i8wl^YUCIy4Cen%sED^DIRzfxyt{(X1XTt0A`CgRcw_3NCZBGHgT=1P`VE9>7_ z16C}uEINyF^E?)?Sy+wLl4TxtDJYKvYFMD0cjc20Kmp5{QWcdmu}6BDFHOHj`Mac( zhQGy~YBYU|-|2aGaFOJ@SA&TKmPGi113TV8_y_gu^PM}JoRqEyEc4Ml3}ZHQ-D(@V z!02ymO@ePok?>Hal8LS_g|^|j-bzuVD4!07`F|H(9nG1i<)XRu)@mEeISX6M&i>Rh zkA-oCTxODW+@5|$vaeA(`)SDk`b~3*YB+`_AGL-757MzGdbB{M0;~Au{!gtnMmNvi z7hH70$Lv%sOPIT__*|+;Il!2!#M%Uolc6kw>H8!7MAjyHZ;N!#50tIxoe>D-8*8Rv zBzrYK!xk+GHXbu8B)^ButefWJx*y^ zkLdvaQ1}%e@w>GlY{d_hdaGB@m0Nn;b`j0~d?m4r^E;Z;xj6hteIWxipJxB5p(uW9 zb-iqj&we2G`rQ}b0_AHb3J>8sre%6st)A=5yD@&q z9Ug7pyFumx=P|wffK8QOn4Oq~ZOMxD`K^Jvxz#fD4CqsQj`$Sj1VVbp=r0@JgDn(d z?GazCMGhY$WRdq2vOzb?=O41Lw#qx!Ft(h@RaV}RseXxUdri;n2Fu&+LOMALUl`-iYv$JkVV#N^ z^l;l0cea;Jy|r0$`4<5!BJ&Z%V0c=8h2XgWi1COAskbDGJAb=kL$V&VVCX5MUcv?<{Kq|=KY*TC1*<%T}72qH6eFA_Q zGdwMy0$C8$IN+W9!=nfYYK#tI#D?VmL-K&i91Fzw1Z$50dC$P<;SdlH z0Hz_3?O(8z(F#aDvVbUo1kU~n91orIh)n-Qt{LP15kP(c8NCo4{>cDz_`k?@KST#e zGeC5}0Wm%y+y80GAz*t2G8H!fuS4K*NK>MNn4UwJ|5rtJ|C?WFLYs zw*6%RsR%BJ>3`sD0Fpxk5JdpQe?gRd@c}3Vf#fhiOp*Tt6#xkU8)p4K05sDR#ti}L zk$|sefh%8g9-;of?ZLG2j{_}`K!nP1`0qByeZ)8aRt19Q0eUz=+T0g}XskRZ0^~sS zFaj~N1Ifn|VPnxfW?-L^kdUZxPkMfLCUD-6M$O*6+wD0Y(Yd8q>3w*T-#0pUKZA)dXFusXXNmMxd784&2l9Jj*cuz?1)V$N>eoYv>om zz7hewWI!x1K=QGIklqJ_b1ucv8GUbzf1I`YqzN?>)xIY}KT~#1ET1Hn`>uUZs&$S? zojh>U}pnj zd0JDT1@ercvhjhA@EpcuqoQOcOvbgQ|ABqyRx>_Jc$fITuv?sc+$@qL_jZTxjOj|e*=R46G;jAbXc^)%ipqMVj{R6-qI^R zT@23_z39AfKkh7*l3&@CM*kud=2e{Ji!`h9_oppeXeao%@jG2ot0|cpikZer2L9*% z!S8&l;<8(Vn3jy7&#zvjd9cZN8d_@1eCrqZy#Efppj(y&S9i!?JG?{EQ_-Bdu>;U5 z8_;k9#PYO00 zM8v{kyZc2;xl5)IhBwpaJi99rMX8>HCNm_Q+%%!0?YztDRRmpXC(*S4wy*Leanw%Z zZzQM9#q>BM<`gZIl5E2-YaJ%HSFa@4FpapI@zdYOuBW5Va9_io3QvW2kv%NLe)RwW8X(rEr6h>QR)EU( z0Ze(`cxs^R&4g)G&I|s5PLeNV)U9i8=3ma8C>{S~3HoIc%n(=PX*lQXZEB72dGM;V zBfrtLkfiljaC%kT5C?2T#sJ#h0mSP0uT>!K&H|kM z0pjkCm0Y9rlD;eEM!q8s34~kvoZX2aodiA9`uW{uqI6$Tu-5Ty=?1=umGYezUEV9` z*&N(6kIFQT?lp?uKc93H(O`e^aXYY5o{ps6$WCaIYz}#C^QfkgX@X?hiOgdT0a0Fn zco7im)0!Pb4ZtaW1Z-%a-r0*70z_uEE)yT@O`XfqRsf!}leIC!sva$n2i$D?39zKvI5^XA;IcFcv*9s~x7pDQ-s6JG zv!1Vg{NX-^*2;|dHX!$3C3gS}9(M}1fmknqRs5ZuW4_&VeJ+~o0z1|$!xn0Z0T0`E-{p1d;fzjJLJ#8=T&pR! zH)^=m_aRl{W3X&FQ(!M>g2Hkm?`Yp=ONb?eYPEu{mRqGtEa2x`_pM-N(AVm71|J3# zxgaM5XqBQXd+NM|mg#%F7l`QE9p*#|fuDRNzS6+52}SXI;Ba=A@MVsBVY_Cuh#_5C zDq*7T#U5)6iTMVAD!GB!NdMIa;w$XI+26&nfgYA0x%SI2#$-q`I4*T57G!nP`Rvkk zu3&}+!+r0=jqZ?a5g~Bfy!3{V^>qp23*+B}REiXV+6P(DxQej&E<4K}-`}~fv>w3d z9*ZGpHf~S2zd1ST4sLpA-4wBJ=ST9xa4f^O7Xc*tKlTjU)2b=z-4fR1)*KI!JO z(e754#IGnO^ctqSAYFpmh2AZP3$vgqExB<~gF0=kVyps6hyq+BL2U0JrT;~lLQvUT zK>xPCSzAWGe3KFsKoeiUWV*JEb5`aL9mL`*WM{&D89;)Fj0QW3j3~+ncqyrq#4Vp# zV7FEjG7G>-1@H`%AhxGPRKPPpy#%N4KK8fd^p#fmn?ao=)#j)K%AN(_+ih<`^8%N7 z3bJb}f;E_3Mb)CuQ$8=Jn{cZm=GsA?#?&1sZMIUqX1oZA@g7nmq)GN0bNSKCSS$w) zTc|V7>^Ojz*BQ zk>J9k!p(yMlmu`Uh;mqpLAVKtDUDXskYzVg053fI|4QpCR207nlmeG>C2^BHE zuH^}@E@P2HVoM+dgqi@cKTS#@h6a3G!Te}w>VtP^W$#oF3%?)tGo5Kw+I)fGq5US; zv3yxPsaKp(s1nMNejXZ$B==n@(Freowy>=&$m-fJXs4#6E<_pwlp*(Gewlc|;o9d_ zvE-n~BpT8Q8Orx|BQs`1TUjnUjddB>OIzf@Xd5>|cz)n=J+^U$Kemw(5?5ig()()p z9A_Zutgg=lDYvYS=r1D}7gr+GF@=TfGm!imdlc%H_HxrwSrT*%8$&{Gd%_sirj+*X zvJhU&#Ke4|p3U47f7WE6o~kV&8XIOfoqa`SYPO+B9y$u|Q`$Ybl|;Qqh3aYeGS@Hn zHaXsM{G|3t%!5G7*`(atk!lQ)Yp`;eWz zUk(LX^<8aZCmXf(VXAQ&n*8i#XdUZyNYNW&wk_pA(8Ij8>kMNw7UH)SGSTt>QR60(wpPcG}dYBpY7&yd;9Z8>~Ny6sBQ zcZKN&T0Lpm$4=h_brchCZEmMEB-afE_j3eP(O2>h@&!g`x3Q=1xnqc@r@ix#ol+?` zarEJJjAETVq1C_furtmDRQe34sR?3#0yZG(DnjMh0{W2OlpMjXWc1dq@Hc>+*5_P~2I$Lr&~Jl~2*@jYSKF}>l>#Y}}eL9uPDx6-QS z`0MyqdpdiBXL3QO;E1iTg6hjlL@83?pO9@hO28|`2V&m^lK;kLC{}QW^W!V@hFP5` z83pE8yZ=+QJX;|Hk<4aW$8V`9lVB%qR}4DYt@o@R`yiW50l z=0ceQA4EhdUr~OM5#SII7-&xUG(;hbUR7r_d@0z)SAq4Q6dm!fXZv$o1jVal&7yeP z3-?VSR()YlNp!FKSa=7w+LG_rb@mAg73nnRxVWz#{Mug)HMBR#m#aQ{LX{WIy9kRC zE-Kb+iqHc0OEFQ$4`wfK^&F#e;Jyu~c(9=VzIGlyeYRkfPQQxVgojA)DF7ZtWnF z>%g^|u!Z(hV8aBqTK)O!)&xT?CIWZgBH}IBXubc+`J&yXay`&&k3Kmgub4VCrMT_I zJ=Sny|EV@IP3Y1{^Y=g5YAMag<2%1w0Im=RE&<|r!mxlWkRVh}Ibdz=-8w5+nx4*< zS+))JuFcRe+25Vz;)eV(O`AeL7>GN`dM6eP4+{SMakXQ-S?z_x}|PmYK(mp^Xhi0!AkS58FmlCH)^8wspq7SDy^C|~8x+u116 zGb9&%i*?Mas*)CbAa9!W%w$OW>QOy#OyVyQlXIUG55;M3#jyd#L0<3a8TYPK8}iB2 zGEM9K(fT?^8hIl<;iT&(RBW)8p;*+Tg^Xz~^LPYRr2W_HF)Z==cQcbc;UDb<`McvQES zu~8d8;j&pn0#VyE9mq6-2;|-Edn;O%yjWuMt~MF2X>Vy4`(rNlQG+OcWd6y2q121= zl14;K^(Zls zMASGoNf`#`2P$sNE_~$1z}IBH(`lA-4xpLc04p#DaXdkn5F^$D=S+)ZGd~=DYbip? zMJr(nLy{4lltwD^cU!WY-a-tx7i%P=9?S)g3Kb-rnkOlj!UXe#Wlu&V$A%D2TYiw> z*tQGDETO3=coqM8R@(qUB>#|KM8&FjSlo{d;yoP!q0%6ZO(6MbdXQfcsGL*4o~_EZ zduk|M)c2Ywd=N&JUCo)Qm(2F4zftU1xM&Q;e3*l-~3)n%sv&Xuv+c{sI|q8zh#u<>cBJ(sNWxy5AG(cSohxE zrnfX|Ygx$iNJmlR^3c>UC)ibY2|tL9Zh|Q058(8AZdvk7f0JfO_KmD*_9wSEdk`Fq zBi%eOCbZFBstWD$vcW4K=8xRcmUygu4JKQ_!s<>JwljgC?m2fDj@3z5 zJOO`3?`JEFIS7*7d#eo0F@m1760`v5e-I}oQakbD}RZNS<1kLFT1{3^ec+A>VZ z2NR#GY=g3VQDUyA0`^l7_b<=#;S7gp9Lb%x6JNV_L867~8*c(;k`j0&%$(Inxgi>kFtL58`}+uOX_4 zL1ps*#$!98dH>ESn-ZTU-T31+(r=G~HL&}!K1JqAvQpR0^&w)vW6F>g@fGF$b86k) zUs+MdGJev(KSO;U>SyzW46>R4ZxImZ6Y@<9(|aJ!Wgz))s0FP9DB9nl z76tKVJh(K%es25D{Y6AYU*~?^qDkh1T%vKI&2bmG zBSB1Yl8Cd_V~tzOe2uAx*ekQDRZdlPNydrQt_MwER~NJZd}Ng@9fu(OolSgd?%Cpa z@C_7$^mQd_tTdxsAdSw2wG}b)eHZcbg=fD)M7p;o5dtyY&vb>x7I&z^R?Cb!F)a_5 z(%B#Q*~Lp=BnJ8c)x7|!F88=?`)}1De&sa;UxUn<6_#EwkNpBsS0y-u=#*wcrwx+>L?_T%k??s4e~ z{a-ZAMDes}T>SdmxutxUslZmlk=J>6g!&~@M%wnu8E3gs)0!{B@{i6E9i3vT_VS)C z!pT^X9CYN43p}byE(;#j(z%>hL7q`)wf&_G-jM+dr^0 zptj%NoXQT=FSX;SpWd;;O`rmL!~!~jL0pi9fk&Mn036R>0M6KN5itDQNsSvJz0cGWY5fKB#^|ZPG84-bn zGm}TlF=Hq-^n?~A`uKSzf^~KUaS(Tp?DzKrSE2R0u@OmjI0K#+?sx(Lskj9;I2r>!Iiz0S0At9fPZjo0Kz6C?{3^?7Gbaav_U{Pf#gqIN{fSMk`ol znZe>hkzA#NZvuU%sXQ0l`aXG~eg3igVWKg`qlyjhc)c9v@Hxkc*vj*uOQiVj4k?o! z!L-IK-sY&x8QEU#!jjIf&@C)<_g5Ts#I|PTz7*Eb-0-3FQV!3R+|Xmlb`8qLdroc9 zmEoq-_@oLj*U;t6KZQ%}_+^SDEsnlj@!FEhj{dN(JhOVhtnp{4W8v-ZA78!-Y{l%o zRy#RL0vZFd1QiJ4D*xBu5c~3ipl;Xr4$`Ce;b*&!XB;n`@|YP0d7MEJ-MRFt;)ZkJB*8do7(j9f4Ox&Dwg85)Us|8Y9IJG=Ld+70aUUAan1iz3G!?B z7l|{GXO#SOrAv#9Za~!O@!91NNt<{^+$5)?S5UbQQU!iufSryzM18HM1x&n6O#vD20J&Nq?x*z@ zi0vu;h3pJI3sE~fH<%&(`s47JlGCKbY3GeA{0zzbk_R<=pF`}uvIo={Dxt=QgI7GM z)4f||r3-|qiQ%L3`=+Ys4=m6zg1t&qWkdw`kjaQCAZHoG{j~f78UAMefqHK`C$d@6 zSYkU*2jTsdXKM7uZ45}ME*mNZ1lEZupnhRGdVFI*6X8<{Nm8ed6ybl}h*!`&Dxs(s z(McL&Ab+)@|2wH4JHJc|Tl0d5=koGWI!r}Rx%MUL`A$($-Tq;9hwlMa{UVkgLCL*2 zg2EPs+AX~gp?;Oi+mARX5Tf8Sj5>eL%IvG%&HlBn>x=f>;X5UJW%t_K#_VI`nuzk%AC zgSg)S$)}MH*#7eu^izs#v)dkkxyy)6zZ8)qo=}aUGg?(x-^04Wn{s2Ih#DIfzPaL2 z*}dYtyH4h@S0o56ZpIh##y|QAt?JfCseMuxkz;3Kg@!9aBmUb)fFIR1ztRs71PP2} zK-`ezlWPP2#rX75H`2)$j46z|%c3(e6C03QEXW%Cb@L0Nhp)d1;~UoER!b3K7aav$ z$=cgu;16md*k}+(rKw(*g;vzp$U)S@lg0FJ&-m^!(b5JCfk< zUupOkIXFoC!@~kQthYXqBNh|~D9dqJn5_=WzxLhyHR+&I=WgfuQ(rf1zPXdEcl*dQ z!m90M`ktuT#V~`&^qm1@q)9L%{n^C{Svpx)yJ^U7(WM^vXy^(=eZ3n~y zuM5;O5clLil>ifig8mO?XduxEoWEe%L;EU`N}x}36p8O`6e+iE;>@9v4iu#W)mVvU zsk(?7opOrCObC6ont&lPr_{?i<`$e-_g=>~U?+nG7ZRFJ*{p;AYmQKK6OC61N@Qlp zm>+I8VzA^CXP368Y3Z-3Gloc4Gf7ikjILrW8C|i+&h^z(kJI?z=1r;_$tZ`Sj_$?Q zHO>xHMP16eb}cubmt6~q4toZ3FDKQbqLQuipaonxAw1`hxEAlNk^ky8(5(5K;Kg|C z`vW6st)JzXec`XUU9b*9t5_7-CO1NFUW+XnvB+39G*&+(;YfZiVA*0AIQ?Py%`MXM zTMK#r;;%S*$VW?1I6!Bag1DcSiy*#D=N~eta8ug-Hd;?Ecqk3N$(GhUq_aL-+JTF9 z8+B=Oqqre!VkajW8-5Pz)E|~`j3kmu8`a3E0zN}@H3>^hF@EC{_Y7I-DBh;iL}mRi zQCf003;ia5{aZk!IEd$I0SVG60MGz|2wUwe&^0+Y3&W1*`(}rs`M2FGK&wU*_n{TG z?~SvwhoC3S>tsA4(xCn@j^=*yd4)6*pa$gulm~zYkO7DX@-jV*iEsXbfZ|?tl+l(0 zIl5x*N1RaI&AAt?l`^^*idVMB9DlCDIz#O|NHI9CRa(0_ZBSWRJdh6$53j7%4r)4D ztcyaOKI&PV+Krf2aOQmOopG;1OaUZV1JX7@JWtCo5byN!Kgghg&FURPDrcSw@mEj2 zXMW7t68g-v7>Hva>9{90S4Pv0ohktveuLt()fDFF351nO)7 z;(1#8ff$PZBUlCuf<4TM-V(w(q0oLyL(S`q5(|>^5=1SdeM z6o_XSNIp3!;lJ3I?l}Uwt7$?fjXzB8;4=j%+Jkge4&`j~Wio;jo??wbkAt-^oA`al zNj;rw!%>rzB|F4I!vws727r|+z*Zi_^R#9J8OFr^VqMz357%+lpWB>N72>~4;QaiW ze)8?h%aGacGjf%(nBQp$+P;mt?hZ;lUmi#b`bleZFE=L07k5%^$A#tKA_DM%#Pwf6 zyre+#QDtx7HwT>I2!vCR4LF8;NB%6ncJ?+dH3B;5wQ!rWj<#XB+Oimm#N6NIlWDKb z>cNEF0oGh!f(sh!zNj6HWP7XqsDAZ_2DqfDcPgi(DAC{~bMZUTE*7KT60&sbGWSXs zK9-*ZH&mfa><7&OYecQ1=DRD0B@`hxt<9Ga$fWmf=&!_(aExLSPen4wqK=bfMv(f7 zFy=4nXWpv_8Ltu?0xDJkY6pRMW&WuRX_EI)IkCWWTK`ERJ2O_Nxx^8rRi{#WL-%vZn~moJ*HurQWBc&Rjb0g zb*VblDxi$Ytxin+|iP7?FNQL}V-5$22tOF`TBgOGu0+Aut1~>LaVF zW2B)`ipeFlwr8qYRzwWvSy|XhMN3XTid0;S3%BW3z zyTcC~9r}YYIwCrC=Y!@&FRbkbuifw!%dhS*&~ZTy;hy>)3>l?EHTIT`=ePMVS}S&I zPI>P|aYm!VY@2_ti8zS27ZCR3{s5Oc33Q%TWf1PyWdVG@Ol5u=8G>$?KA6>msAArF z9#3om7=;7UeC)0e#0h)n)lcqRM2gRz3!Ghm+W|g+ONC+r@jk8O08S1HIDusjvI1|Q zUlXM1Dw0fsO{xDPeK!pT!ytOv*sfMA;#`Uf7nN&3ptImbAM;LluQ0L~fz$|1;#nGQ ztVg-0rUQ^An1l=^J1N{|O^oF) zF}vN?aUHeGgQBH|>cl> zs$BQW@di!;rA0%FWQ*VCs!KF!1?+ziolk%R8VYg(jUtHeX;BK|EhWG?=Z_2Zir{)& zW5h&ZJr`v*Vf6qg*20?#73RhhJ>xfrre7@2R#rQs9p%M!!hedyld@5h<}hN{A;bob zxn*PabN~|)kT$^G62vD4B%i#Z0aP|NFoFiX-*Xp!Q2z*b&k=Ad_fck6@Y_ic-6Gh2 z`MO2KU9oHq!7n(}ibXb(+@iwf)^%S z8LFc^V`E1NiDZz2SeY{*Rtv=Uv|a*n5kT;d3s_ljqWAfOjW&x1)>UEPrPun9v6($F z=MKfyN&nRg0uJ-f#ylUe9`qzxUCVp?D&&6r>TL}EQ>37}&EhYr4^`1{PlfrCa9fRY z7$WCy@XwbDBp<5>8D4?l|0QHXYM`&+lA$&%J1tqReUUz~g%HGB`Tx3l>$s|(=W+N@ z(jeX4-6GAUkq}Ty2`K^TQYi^Z326nSk(7{-?h;T0N$KuJP*C96!}}+%@8|jJ?%kc4 zotf>KJv$@EUUBtaA&IK0>Q#1fWPiH9F9bHPS}5Dh(!9jdR2>P{L;pQ&ZL&-&hAJ7f z%a5e>l-$11=o8I%!!kXg+5UxiH~G^xC*p=G$Kbl)%8n+&zVr~xx2KUYMandui9_5E zi>Fk^_L>@QDY?lO5Ug3N6`+WPm);^^^1Z=~f;X!MeZ!QJW?_%~R+yNS>QfGZ0`WIIUVi9Zu$>7buHY5%UekCpJ zg;GJJi(gqUly?(*+`~eqN6chIOdh?Gvd+~xd7vZX_~A4maQp3oe^!iF557J2k4P4h zeEXAMl45;azwfT({_zUY)y!FUjRCZb0DV(P{MrWt*Q1A&ZU{O{V8L>He82G$N#tE; zFMI~AE`r4SZDpt8LOjAvg!9Y|9yS-7X{#gr`s$+;$14+*EOK+XdXN1)=ivd`=;iEYbirYV3~Vd6i|k@MmmthwWERqUAnWJfIuudCi?qpN(xCn(O=shw|19iM*tTQG{yyV`PbB^@Ob z5QDEDxk3`sAbG7WTSSWSRp&sC88@-*5*9mmhlnzIzj{*GQMP?pcipMBx_y z7$Y;s3ZVdD>9%I6gCzXT_c2+@dtbC1Q56sq zfh6Ef{Z(7Q69P9;Ws_tBH0^4R0%nE{Y_u>1RY*dT*QR>73L!*F z`&EmfBnC(cvJi)9(z_1si9YXnl0?&1IAJ|c@vO{!QR&^@m_C#Dy_=-%n}+@XO&O4q zgCuW*NSy`x}Lv+>&6AryacAlbP_|}>?^=`#V*iU>bou8|P zPUz=~$P$mw_A5WYh7JutPymv=Hgdzfu_IE!=ld-Zru)eQL4pO8$A7gqqKm4nKE$1h z7Af29PsLkG|0HZqlLfGn0ACQ2ytYuo_#B8-7J!eEGdn~xiTNe3RHpK#JF96`3l?gY zo>-y5n1sM>TjH?1k}3c!1qe_=lGPx2EfIKVKnZ@}fOE|eU(&H`(D)J*!VeA5Lz349 zX1FFYM5;9aG`U-%JxZhy@6P;F+g|g__;XOi1;AOasQ*V72B$%!h5>M79P??@9n2?x z_g8WsmKYFK=SFOAZUq1UID`NvBqa!vSIoeBX9h%S5jnBKfsE&bs^1>{xBfqx2l~~o z$WRi#Br}kHDtimhoFV{`1d{q6nE(6yhn#%Hdp~95hPUxlGptj3n>G>9#5)(;SX}*F9j$escZWiEFI9=4*Ze> zzxX~^2=`aiLJ^P&YmE$&zBZV_mEs{%@UJ97_58_R?*eiduPT)02Fd?ZVLI>sR!9s< zUz>>F3c-zhez-yLshQ(}OtK^%4v z+=TFpeqxZ!^${IL;76pAf$n#IO5n3>1uMQYcc?yR?U;hrbE%qwlnTG!DnatiA54f0Kzn3#`L*aMwnErn+XLuYbO&B~tsY?$xRDcc=KHnxI zt^YU@6Qhr{MZt>35mHK&AJp{ba4s>(L6O=!^hL`YYu)h;{mTd5eC~OVJJ?;K--#O7 zuy!7*yOy8F6?~K`{yZKbK$onKq^r@QZ)n}k*X3M=zjeoK3YBEpOR3*kzFZh5?NI)a zvO^zYx7GA;X#M32r0Ce9jq0@8>Qf{rq>syBo&)CbH2vu%6Z&*|@|4Hw02v-ixm zWsS$6X;Vfu_D~3wmiEr*Z!Q>UR3^}dAX%t)^uO9yNu#76Ui)?5-^GLau-jt!SWZM{ zMfO2%eQ)=w<&BSz&|lcQBa!VyJErYP;Z|_H$C|wS6FSq9;E>G|+d(t)0<9s*iSlhE zb!pgPK{CpV=$j3EagwA20FE+2u~t z^2p?K$sIf@v6kLB3`bHlO*B)nt+cvZ;Ab+*%o^jg_~5V7U%W#Rl75V~C)9HYA|8|&+qlXd-viw7;8KLU{Y%?E3BM64|mD{}nfp!K2%{o;6Aw2zr*&6RvN z>!nF?6rFVscz%gJO&pvIF^V;3D0=6@I(HT|a`Pz8a1)y+)XwXhpI2v#anhcJJLaN~ zoWiFYx$+pBw9-gIB5Fa&NDD^Ieh++<j?i!m0KTELW=fPHuli*!qzQ44^&oe z`&!SZ)(_)12o@#ee)7L4;MSVTrySDebgqkcx!Z1@jQ`?tzVOs+vxeK#6unQUpYA?c z0_99hC%Z!ZUjJJc&nFtJW@U$_B%ywMm#(WbB+(Bh8PmQweyexOGg|ECj~+%W8r`4b zD;D=OHq1km(d{!Q)P8!PdjMW4o(vW2?oyD-MUu(Up`XOf>N+Tz)G;W$b4p~s@hNA` zNi)Tc&1B(42?zG$=jYJLoD^1^duU1k-vgRM zQsG*X^JyYma9G+y69s2sdg`56&Y<^-+G;3IR)#~TYmid^mi?4ujo_XAtDxAJIkn!s zVU{CpmFaE~PcNbzF!E}Z%E%pWO9w~l<+Ml>?-F5th^dTlPDXAt#`r0oxF69g(PQv- zvCtD%o=@A4@Kr_Cf>(VKEr!-5Q`$L}vtCmOB#iJ1k}zOR?hy@V_`2y4#7J?M5F8q5 z{Jw94u20?hOoX}r?vVt4@xF+{;P=k1h&}qV-)leawJdz|$PEabtZW=i8KZkb3%=?z zMaf~)YE_=1aOv+oySY%r);gVSE=Mf$-pYMP_4b>L9vp4X%ZB<_LqZ5XDMTbPsTgXq6HF{zBj#sHcamxP4>F#3%ZE zayISzF(!3Pdazzl@$477(!fK6GD70jF>5Z?y zQ}3oc@^fE%%9=u9J0`+u=RHQ-23JmSsHfw7?BIVp$>x851gHe&3c<_@20 z$?3VaoVSs2v8_0j>)VGt_R$c#c~=Ose0n()b%}$_y5Kdx`z@h2cL$1#y`yZ>iCi1~ zle5Czadd3cg$fO=r1a}JBU&|2Q$!d!{imQ26WSh(Ge@pS8U{%9vkPa2zgJ(~Tnoi8 zyG8r^x&Xa*Vi-~n6#ssFa5G?invk=5_E6g97yBzLf1+i&u)o#>gm3&XMq8}o{vsWp z?207kW)xoxJmo>PTj9G;7RO$)j~Bf~`c{zpiS8nvr~R{rtYwR+jccAKXgpwZ_Y+rLzO__jm7j zzx+O^$oo)@GovMGKHjB7sgszc*5~818Y7a{qlyu2x=Xw5yIJMsd?f{si2V61yNzGY zCIk`eB_A5ZP}(LcbkBZ#SYr9Sf9E2f)K#+V&p=g$Z8}X^Je8u=gUbt9H39niA6WTH zzEORN`9IL)tSK%ov2VYpiG1zY%;oEI0dbwYKE=_V_h@dhbqtQevi`Ze^cu1C;2xzOG1aR)AMs-kbt;tpzTDRh^*7IyDQ}#-VIF*VUMgu` zAMVc*CtT3Td{M?~KuXcr>QkqA56{^6md*V(Q|W511g)?=r`z)Kq4(PI{KvD1;t>`? zDL)Dhm3Y-Pf~ty~rFp}8S;o>Le*`)YY=5>2P7&oWN^9QW~mw9>QOlc3Pw=|GPt>QuAB1PY(eivRG^@ zF%|7?!2P+Bz{qC{Xm9xJbW0%DdvoGE%5GA> zb6s!rc+)LiaZxMw^Ww&R%I+r~#G{0QBviLOw#^%ZC*_PHol5x_I&OwoEd9t}bez?l z!A>OIbIqGhM+&&dJgW_k1dSyUjjHbc{!;X4GJE}oivvDp4Rdm_gZ@{tM3VFm2fOu)VkKjl>C)TCTq%EJ0O1&!Ln))guFBeTj;q#p(ew1Sc|`8MEYrHR zBZ|#p=3S*JKj$Zu47OPox|$?I(eH`hk*pQHFo})EZPcqPrAsYi{`E|b$F9s>WgU%1#dOJs6X?wK6gbc^C>wPU}ZTvV0+0# zcKh&CFUez_A$pEwn|7)%{c3;IIhN3Aa*ds+?HuK;ulViD{ys=jF6Pxo{Al4qJEZBe6GujF}cCMS_!5&Z}AHoCLGV-kwP}{6_uv7y{L9 z%G~3+>`^~BI40X9P;i@}HISNY>RmMyu+}+7jyq1hwxPX*CtAK#NOXns>5!Zhl>RtD24c zP0Q5%+~|6TzRwjPT02!cO|@jc-*(>buiP{~0}9SCOOejeXe zety``I9u0M{)Ok{iO_4J0gn+zrd*`S62So#G%h`|qUy9q z3^H@Qb-ZDs@UxF}knI0>;Yi_kYc($W-!=Cp-8x^VN89cPQ9R+JFeh-)+<|1>VS@0r zY9Kul0*f2d zreq09%h6hp6Xod?vR?Biv_-w@sn_C=5Il0GrcF7XcbclzA3JO%H8Xqn(HXZ~Zh-e9 zE!BEGknIS6Tp~83xa6GY$eu6EObA$@n0Q)D?Z27@%geiuj1|R1{iG@T?d;3kg7lI5 z4OOScia`0U;R=Perb;HUB-Rb23S2&lVRS|5Eq>Es(d5w#v~Qy)4Nui76G|q7s$IIs z-%k9#>ADdaqD#wlNi7uAu)P#1de|;CLgN*Mbf1H^Lunp$ zV$-g_$ESfQt%b*M=O-}LAi-Bylpxu(e~N|86jE@+LhzmCWK`&CyVU5@irWvpBsH5S z#YF>U+lKs9k5}sD+}*vMNd37lgqQjm{PqQyn+wO+jB}iWN3t(tOV*W40A*yLeg~NS)r1Ey2!c5UG zXntl&nB)rVxP-6I!i&b=yg^{O$JAWLZ{thyp$TWCQt+6wH9tj;>81K%#a>EEo}nKs z|HpZ&Xzk~N&4QWz(nGC86A_#wuD>c#n%zWvD5xK|H9zQ1M-~+CU_EH9_!8_o&4?=F z(OdPRacsN5oAKaq29^8%d3Apn2Qn&@NonFiTKx|5_o&)v;yy=VEK~d` z5+L5g-8jO|$=C_Vv+Py~v0A(_E(soWWjB=0ouA;@%5orU`DZ=bG*B6j@VaW6pC2_dIsw(Wrq13A^1Uo;Vmd!2PF89JHX-TFNemN~a-x7FRbLRj{m zqm(BfaawXK5@BmSc4-Nv20s`)EhVNe8BEOgHpX?bNz0XHl#VmxiX@68wLg+Xid;Z-53Pge}-@*{$ z+A#*VG}l#RKOVpKm-3BIH^n!qlHctFx*zJ0Pmiqm797(MCdF^txVd+ee29zy;4**& z3nWMUuMOZ|YB;=~#^u+xd7eZ`v{V|c0A>QyiG)^(3R&aJndW~ zEYlA=tUX1eA%Sv2K5y2Jjo?UyUk#joRW@Z%o1)97p1{kHM-9Q_g2J=btl`eE7k zZhFUFngPk@Wb{c2UU@MT8uG8N$wuZH?juzn+s3~%I5t=3l58!i=}Km=Ww)`e5+C)Z zBO@18SZ+lRjP?8JR@Zd9EwAD8KnNpd!uPf4HT}1XD)MvV&ZAm~k-Cxweafl#bLuhK z=rF&<06czpZD0ruI4MG=w7T@>YcI*#7tjt!Sp`{piI%;n?pRtewG-}=OlY*Sz**$;4qiOmg1JGY@ zZYVu5(wSA8!|fGnn&XM|znA{DX=?bTjP$3fTH~MjH~k(Z{#kBQ9*SG}xr!sXn4kWg z)Y%v}`xMQrjR4W4^IRM8)>au;%}6>c16 zAG&P?YNdXDVa2=aq4Hjz6*J-Wv@rEI1b`F=*i4Z8wJQ!}fz>7BH&AbrUt96`3ePzC z6KW^@^v12*z8tV`2wq*-AO#E<^55zLLPMSbwf5!RaC}=ooVy)Jq{bTBQ0=IsX=7>o zlmI{q0<3&U;o2PscmH%m>Lsky%h>h7ig|my&tJyg9XyC?9-m_V5u7iSk9Dtb<8rVV zZ+K>>(``LOOr@g}wKBfl4aesR7I{ZvZI<{O%v;*W2Wag5IL$V`E2@_vnjs1)3I%J% zCv=JZZj1Z8uZL4vTAR+~p2d$2KSW_wjd`T=%!AwZmgL_l?+!k9%kutLdagsp214oxDj$)Gt4z_6_Qpb;} zuI@|B&BSy zz~6h%=?lRT%-Hgeb=aIUpu>4aq}2;ouujMClRw5(S>41}A8PocYqLjXa>vJL%~XRwLe1>EhLKR$a+W~p zo6mFrRUVMxf)ua)oG>{?r1VEXvo%_Y+M;YyT$)$YVuHHwXHS_=V-?UXxiPm|)E->X(I+k%)!P=}iAb@A)!g!qLiJA?QsF3hy`{a-pg|I%>p_K-o(Gip^fqx2AJ2Po;-Hj2?rb$B zYY#^zRY5WAunmQjt{t{;2YrD^DZbKNTHemEDSmyitUHabK#po*nU56ipqBZ%WvrRP+fegnwMNL~+d zCPaEK+#NrkV%zLo#5w7kt-CZ9A_qm8B+@>orgC~`!qW=CMFA=;q~!Sz75)X$4cCG4 zk_$&}O%QxJXNlLwfVe+{0yz2u6Qp$I{rxv>f^GaJP*_%L>x(MiG^WS43T;tl(LN7q z*){+MJkiQSO8;Y!5J#l!{im-~0vAPGVWopxQS6h8H=m@8wc0s9>-A%DxnUwD6;$ys zI{LQDd5xth@2uDS*ZMUYe?UPC)W{=z(<@7w#%e9J$6mz!j`ks%f&!{ava7 zSN_*v5`L^~&9OJU%)sj~1*A;>4;TIgVWhc%uvIJXp0|X4mrjH`1~Y)8gp{w%4jdql z04W2$f8dlw&pC~-b86Up1Tx{90i=+!8%SO=gNsPz2jZb!;tt0DDFy`$0EiV*zCJy} zof};J&<6Ky$S%{Bj?Mdq)S~&)1fKk?O?io*{A(VbKw%>ZJ*0enI);(J^%7Tbv&Ix% zh*|85eID`9ITmjjnUX9hg0GiwLdyFfdCds8DH2bPeQDIZq;#*CShRHAb^_^R57HPf zUQLGYK(=R87669F04=0)eKv;c0dYsF$nh_OdJ7}z)JEOMFTUv`p5FuoaQjn0D%XcW zUyyebkvax$Zyfm{eiFGlsj|XzH>^KuNCobiSKPn@3xsSv2Kr0uBz~lf^OS_XD@besg)M1lr7IZ(}q1aj5^B# zg}gg7enu*lYuf>*X~VL*V_tA3pyAs|<6s-U^nO>wBg^Fp&G(qD(QKI>=Jt`L>x+0j zzG6w4`;jY}u{473D_JNfXrGkWy?!d=yAy2U;bP$UMa;?NaZdIQ6m`ADxu) zd8uGf{c%GsdGWzdX>W#5{f~$G^ zp7?N|=}U*yp%38;ADL3uJUc7=9#>7Ujj@iR5ey2O*#Gw z@l8vJNIdKFwOQFy?u^~8`I9AJ;unTu;+u&UVi3~XXCe&rgFSsB@g`l#T;cu%96A4e z=i}~%^Tfs1$JPq(&bI}W_IB=6-nv8mu+eGs$7SZ1q#Ddm@tL`-uV~+VRPCU@%+jAr z5vXjn3J9+vdgv(Z+3DjQbLTk+}og=Lgi%HL{9zsmL zo`EM;LbcBwCqum?Q747I{fV7@F@1_nx&4x+45F&zHmW<=%&;X>aoTLBO%y;s*D)Z! zvz3`cxY;nSzIXl?6ZKB&EVhf zZLI59gIi`Xn9-e+T->viIO=aZc2pY{%Z;sxzEFotNTQVUyz$t6rs}q)CDRut6^&F% z_fDP4b<=vjN6_?XxO8rMB`K!@g!fI!WRLi1XT0o*kVwP=)uZJcPd;fU6J|8DUK)xQ zwyHiAV(k{F{$fJ|RM-Y9q!IXBJf8VD`;H+UEP=NrbfpM!TdBJ1s&P&Nk`oUae=Kvy zayW@TbMBUJnF*D8^NxexC^!6$mO=c1Q}`A#{afop{*yjJDGzCi?z^`JiVepjHmse$ zDyT<#Q+5ri1Yolh*qUdiOcE}%3r*-iyv_Wz4^^~~I9JPied|?=Y}Jk$W+-e5+t!QkAgile*#Pr@ppE3hXkLQFlc#L`zbHXF{mCC^P8|UO9$E zQE>b1tKtpw!i8O~anjlV-AnuCNS%#4jZ@D%Ey6*H^2t zl$-A(I2zv?bDe&1n5V{G>!FfRnR#b(V?nP%u5*zqvYoNaf+n(Bi@j;=Zoy>=wPJS4 zwnzY<&F4Oz2N8`t>wW~Ln|f-P+}-v~-!Jf0OGRV~Q2IRDnQpj~VU#CePDsC%?>5>x z{QjFF1Yu*eq!29g0q5^2KFb+{@!X~8o4JCzK1_lA*&bXmh{d4#heEXRk-yj0YM*p- zUsR7_p$rvR(zH|N`;;_;pJClx3TS(v`?Y-Uu$Xq(CE3*=Cg-4{wJ1`7_l-o-`ben$ zQ5mnG{3w$T&r5_gfA%(;N~bN)82&(osLqHvJ@>FKLGtsYA;XRX2cqV)A}#Sl5KdL*=Z-4gC0Xzd{ttf^R>+GL|VSC@agl)Q3Cs6B{%hnD456ec^}1 zz>0MFn097KsRC!ItnBtS%Bv>&$k7|NR2B713C+~~-%$v&1Jg82h>Rld)kfO4TLqDF zS#+EAvWnlGfAdb|rk5VGyd{lSS%$)m#<4rGn2q5tpQA++ScSzrde(7gZx8U z8Rny}^)cn#6ZMjh7Z&=n=?XG3bA3y3)eTRzyC@G|}`41E} z%7BNmr1Ttjxs0(Od+-OoP)Y!){*OHlyjA`F ziChMSHYkVRq9%t_uT5@nE#Mw$KJZVW`5_PDab6xEKo)$D6o4&*LZ8abZ0(Y4mfD9WccX~g-M;DkJ0iOVe10nTC!Rwcue{=au>zf1zj`OnPLUmC|JO&=CP4D4Zul1$DdRif|82~o^WS}#+5h^M z`n5p;-Z5h$WsCp;mUG~43HL2pNd4MW09Oe1>0_`2b2MLxn}}<8&H7Q2MLvK6c%M!J zsq2E|Rh4iL0xfU`q#&Sk{Te0#{=+JxgVe8W05CyPL^?T;;Mr_OljwbO`7a|ep~%4! z{-6Z7R$+>TAbBkW4pO=(5Moa2J!v`-$cHHc7!N4*A7%^Ce?^v#d0sjw%4N|{8 za>KQPz>5;F7|MdD=9>0dL|N^eN9rvs>7Ns#zW)$+1`x2}jU3YWpHnv&IHs^X8KqRa zhIpsO01O@xG?2z!ki235Ze?Oby6@G9m}V)-N5;ZfdxC+#3i9Egkz|kt+XB YM2&@)JOQ^REGpXn10%cYHK3pVe}2yw761SM literal 0 HcmV?d00001 From 1a63f9e3d296103f502fb0fc1c253b3280e45383 Mon Sep 17 00:00:00 2001 From: Phil Cluff Date: Tue, 8 Sep 2020 11:47:56 +0100 Subject: [PATCH 094/693] Update HTTP status codes expected in failover scenarios. --- .../exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java | 5 ++++- .../upstream/DefaultLoadErrorHandlingPolicyTest.java | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 366bd6509e9..dc5aefac6dc 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 @@ -72,9 +72,12 @@ 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. + return responseCode == 403 // HTTP 403 Forbidden. + || responseCode == 404 // HTTP 404 Not Found. || responseCode == 410 // HTTP 410 Gone. || responseCode == 416 // HTTP 416 Range Not Satisfiable. + || responseCode == 500 // HTTP 500 Internal Server Error. + || responseCode == 503 // HTTP 503 Service Unavailable. ? DEFAULT_TRACK_BLACKLIST_MS : C.TIME_UNSET; } 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 50b06c14db4..5ca434761a1 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 @@ -77,8 +77,8 @@ public void getExclusionDurationMsFor_responseCode410() { public void getExclusionDurationMsFor_dontExcludeUnexpectedHttpCodes() { InvalidResponseCodeException exception = new InvalidResponseCodeException( - 500, - "Internal Server Error", + 418, + "I'm a teapot", Collections.emptyMap(), new DataSpec(Uri.EMPTY), /* responseBody= */ Util.EMPTY_BYTE_ARRAY); From 151a3d3bf5ab08e453723d3504a0bb1915a4747a Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 28 Sep 2020 10:27:18 +0100 Subject: [PATCH 095/693] Fix position reporting with fetch errors On receiving a fetch error for an ad that would otherwise play based on an initial/seek position, the pending content position wasn't cleared which meant that position reporting was broken after a fetch error. Fix this by always clearing the pending position (if there was a pending position that will have triggered the fetch error). Also deduplicate the code for handling empty ad groups (fetch errors) and ad group load errors. Issue: #7956 PiperOrigin-RevId: 334113131 --- RELEASENOTES.md | 3 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 41 ++++++------------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 26 ++++++++++++ 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index de48e5d90e6..eb7272a7012 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -28,6 +28,9 @@ * Add the option to sort tracks by `Format` in `TrackSelectionView` and `TrackSelectionDialogBuilder` ([#7709](https://github.com/google/ExoPlayer/issues/7709)). +* IMA extension: + * Fix position reporting after fetch errors + ([#7956](https://github.com/google/ExoPlayer/issues/7956)). ### 2.12.0 (2020-09-11) ### 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 88b0daac493..cf8d487ede0 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 @@ -1077,7 +1077,7 @@ private void handleAdEvent(AdEvent adEvent) { adGroupTimeSeconds == -1.0 ? adPlaybackState.adGroupCount - 1 : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds); - handleAdGroupFetchError(adGroupIndex); + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); break; case CONTENT_PAUSE_REQUESTED: // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads @@ -1364,35 +1364,20 @@ private void stopAdInternal(AdMediaInfo adMediaInfo) { } } - private void handleAdGroupFetchError(int adGroupIndex) { - AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, 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); - } - } - updateAdPlaybackState(); - } - private void handleAdGroupLoadError(Exception error) { - if (player == null) { - return; - } - - // TODO: Once IMA signals which ad group failed to load, remove this call. int adGroupIndex = getLoadingAdGroupIndex(); if (adGroupIndex == C.INDEX_UNSET) { Log.w(TAG, "Unable to determine ad group index for ad group load error", error); return; } + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex); + } + } + private void markAdGroupInErrorStateAndClearPendingContentPosition(int adGroupIndex) { + // Update the ad playback state so all ads in the ad group are in the error state. AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length)); @@ -1407,9 +1392,7 @@ private void handleAdGroupLoadError(Exception error) { } } updateAdPlaybackState(); - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex); - } + // Clear any pending content position that triggered attempting to load the ad group. pendingContentPositionMs = C.TIME_UNSET; fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; } @@ -1522,8 +1505,10 @@ private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { * no such ad group. */ private int getLoadingAdGroupIndex() { - long playerPositionUs = - C.msToUs(getContentPeriodPositionMs(checkNotNull(player), timeline, period)); + if (player == null) { + return C.INDEX_UNSET; + } + long playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period)); int adGroupIndex = adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs)); if (adGroupIndex == C.INDEX_UNSET) { 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 e32a1992006..c2cc3848886 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,6 +48,7 @@ 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; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.MediaItem; @@ -311,6 +312,31 @@ public void playback_withMidrollFetchError_marksAdAsInErrorState() { .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); } + @Test + public void playback_withMidrollFetchError_updatesContentProgress() { + AdEvent mockMidrollFetchErrorAdEvent = mock(AdEvent.class); + when(mockMidrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockMidrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "5.5")); + setupPlayback(CONTENT_TIMELINE, ImmutableList.of(5.5f)); + + // Simulate loading an empty midroll ad and advancing the player position. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + adEventListener.onAdEvent(mockMidrollFetchErrorAdEvent); + long playerPositionUs = CONTENT_DURATION_US - C.MICROS_PER_SECOND; + long playerPositionInPeriodUs = + playerPositionUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long periodDurationUs = + CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; + fakeExoPlayer.setPlayingContentPosition(C.usToMs(playerPositionUs)); + + // Verify the content progress is updated to reflect the new player position. + assertThat(contentProgressProvider.getContentProgress()) + .isEqualTo( + new VideoProgressUpdate( + C.usToMs(playerPositionInPeriodUs), C.usToMs(periodDurationUs))); + } + @Test public void playback_withPostrollFetchError_marksAdAsInErrorState() { AdEvent mockPostrollFetchErrorAdEvent = mock(AdEvent.class); From c95e43d9bdbafbe86dd3de82ea60ceb830bc49a0 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 28 Sep 2020 11:45:00 +0100 Subject: [PATCH 096/693] Do not require subtitleButton in custom layouts of StyledPlayerView Every other subtitleButton has an if not null check, but does not force non null. Issue: #7962 PiperOrigin-RevId: 334124323 --- RELEASENOTES.md | 3 +++ .../exoplayer2/ui/StyledPlayerControlView.java | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index eb7272a7012..06dc79607e1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -17,6 +17,9 @@ ([#7866](https://github.com/google/ExoPlayer/issues/7866)). * Text: * Add support for `\h` SSA/ASS style override code (non-breaking space). +* UI: + * Do not require subtitleButton in custom layouts of StyledPlayerView + ([#7962](https://github.com/google/ExoPlayer/issues/7962)). * Audio: * Retry playback after some types of `AudioTrack` error. * Extractors: 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 8bb9babeb0b..c3add8f8af9 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 @@ -2005,11 +2005,13 @@ public void init( break; } } - checkNotNull(subtitleButton) - .setImageDrawable(subtitleIsOn ? subtitleOnButtonDrawable : subtitleOffButtonDrawable); - checkNotNull(subtitleButton) - .setContentDescription( - subtitleIsOn ? subtitleOnContentDescription : subtitleOffContentDescription); + + if (subtitleButton != null) { + subtitleButton.setImageDrawable( + subtitleIsOn ? subtitleOnButtonDrawable : subtitleOffButtonDrawable); + subtitleButton.setContentDescription( + subtitleIsOn ? subtitleOnContentDescription : subtitleOffContentDescription); + } this.rendererIndices = rendererIndices; this.tracks = trackInfos; this.mappedTrackInfo = mappedTrackInfo; From 6b13640eeb3cd16f1ae7529a5c719ded3a3e820e Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 28 Sep 2020 15:15:26 +0100 Subject: [PATCH 097/693] Fix position ramping behavior with AudioTrack speed params Non-realtime AudioTrack playback speed was not taken into account when extrapolating the old mode's position, causing the position not to advance smoothly. This should be a no-op when not using AudioTrack playback params for speed adjustment. Issue: #7982 PiperOrigin-RevId: 334151163 --- RELEASENOTES.md | 3 +++ .../android/exoplayer2/audio/AudioTrackPositionTracker.java | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 06dc79607e1..f32120b675f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -22,6 +22,9 @@ ([#7962](https://github.com/google/ExoPlayer/issues/7962)). * Audio: * Retry playback after some types of `AudioTrack` error. + * Fix the default audio sink position not advancing correctly when using + `AudioTrack`-based speed adjustment + ([#7982](https://github.com/google/ExoPlayer/issues/7982)). * Extractors: * Add support for .mp2 boxes in the `AtomParsers` ([#7967](https://github.com/google/ExoPlayer/issues/7967)). 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 540ee098ee6..8891a6d8d1e 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 @@ -289,7 +289,10 @@ public long getCurrentPositionUs(boolean sourceEnded) { 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; + long previousModeProjectedPositionUs = + previousModePositionUs + + Util.getMediaDurationForPlayoutDuration( + elapsedSincePreviousModeUs, audioTrackPlaybackSpeed); // A ramp consisting of 1000 points distributed over MODE_SWITCH_SMOOTHING_DURATION_US. long rampPoint = (elapsedSincePreviousModeUs * 1000) / MODE_SWITCH_SMOOTHING_DURATION_US; positionUs *= rampPoint; From 88999da3bee669132cbaf9d7d529668160f3cb4f Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 30 Sep 2020 09:27:13 +0100 Subject: [PATCH 098/693] Add playback tests for more TS assets PiperOrigin-RevId: 334549894 --- .../exoplayer2/e2etest/TsPlaybackTest.java | 44 +- .../e2etest/util/ShadowMediaCodecConfig.java | 2 +- .../playbackdumps/ts/bbb_2500ms.ts.dump | 156 ++ .../playbackdumps/ts/elephants_dream.mpg.dump | 2052 +++++++++++++++++ .../assets/playbackdumps/ts/sample.ac3.dump | 0 .../assets/playbackdumps/ts/sample.ac4.dump | 0 .../assets/playbackdumps/ts/sample.adts.dump | 147 ++ .../assets/playbackdumps/ts/sample.eac3.dump | 0 .../playbackdumps/ts/sample_ac3.ts.dump | 0 .../playbackdumps/ts/sample_ac4.ts.dump | 0 .../playbackdumps/ts/sample_ait.ts.dump | 10 + .../ts/sample_cbs_truncated.adts.dump | 146 ++ .../playbackdumps/ts/sample_eac3.ts.dump | 0 .../playbackdumps/ts/sample_eac3joc.ec3.dump | 0 .../playbackdumps/ts/sample_eac3joc.ts.dump | 0 .../ts/sample_h262_mpeg_audio.ps.dump | 12 + .../ts/sample_h262_mpeg_audio.ts.dump | 12 + .../playbackdumps/ts/sample_h263.ts.dump | 0 .../ts/sample_h264_dts_audio.ts.dump | 5 + .../ts/sample_h264_mpeg_audio.ts.dump | 12 + ...ple_h264_no_access_unit_delimiters.ts.dump | 5 + .../playbackdumps/ts/sample_h265.ts.dump | 0 .../playbackdumps/ts/sample_latm.ts.dump | 8 + .../ts/sample_with_id3.adts.dump | 153 ++ .../playbackdumps/ts/sample_with_junk.dump | 12 + 25 files changed, 2770 insertions(+), 6 deletions(-) create mode 100644 testdata/src/test/assets/playbackdumps/ts/bbb_2500ms.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/elephants_dream.mpg.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample.ac3.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample.ac4.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample.adts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample.eac3.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_ac3.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_ac4.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_ait.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_cbs_truncated.adts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_eac3.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ec3.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ps.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_h263.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_h264_dts_audio.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_h264_mpeg_audio.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_h264_no_access_unit_delimiters.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_h265.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_latm.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_with_id3.adts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_with_junk.dump diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java index d57f06ff52a..edc546897af 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java @@ -18,7 +18,6 @@ import android.graphics.SurfaceTexture; import android.view.Surface; import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; @@ -27,23 +26,58 @@ import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; import com.google.android.exoplayer2.testutil.DumpFileAsserts; import com.google.android.exoplayer2.testutil.TestExoPlayer; +import com.google.common.collect.ImmutableList; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; import org.robolectric.annotation.Config; /** End-to-end tests using TS samples. */ // TODO(b/143232359): Remove once https://issuetracker.google.com/143232359 is resolved. @Config(sdk = 29) -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public class TsPlaybackTest { + @Parameters(name = "{0}") + public static ImmutableList params() { + return ImmutableList.of( + new String[] {"bbb_2500ms.ts"}, + new String[] {"elephants_dream.mpg"}, + new String[] {"sample.ac3"}, + new String[] {"sample_ac3.ts"}, + new String[] {"sample.ac4"}, + new String[] {"sample_ac4.ts"}, + new String[] {"sample.adts"}, + new String[] {"sample_ait.ts"}, + new String[] {"sample_cbs_truncated.adts"}, + new String[] {"sample.eac3"}, + new String[] {"sample_eac3joc.ec3"}, + new String[] {"sample_eac3joc.ts"}, + new String[] {"sample_eac3.ts"}, + new String[] {"sample_h262_mpeg_audio.ps"}, + new String[] {"sample_h262_mpeg_audio.ts"}, + new String[] {"sample_h263.ts"}, + new String[] {"sample_h264_dts_audio.ts"}, + new String[] {"sample_h264_mpeg_audio.ts"}, + new String[] {"sample_h264_no_access_unit_delimiters.ts"}, + new String[] {"sample_h265.ts"}, + new String[] {"sample_latm.ts"}, + new String[] {"sample_scte35.ts"}, + new String[] {"sample_with_id3.adts"}, + new String[] {"sample_with_junk"}); + } + + @Parameter public String inputFile; + @Rule public ShadowMediaCodecConfig mediaCodecConfig = ShadowMediaCodecConfig.forAllSupportedMimeTypes(); @Test - public void mpegVideoMpegAudioScte35() throws Exception { + public void test() throws Exception { SimpleExoPlayer player = new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) .setClock(new AutoAdvancingFakeClock()) @@ -51,7 +85,7 @@ public void mpegVideoMpegAudioScte35() throws Exception { player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); PlaybackOutput playbackOutput = PlaybackOutput.register(player, mediaCodecConfig); - player.setMediaItem(MediaItem.fromUri("asset:///media/ts/sample_scte35.ts")); + player.setMediaItem(MediaItem.fromUri("asset:///media/ts/" + inputFile)); player.prepare(); player.play(); TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); @@ -60,6 +94,6 @@ public void mpegVideoMpegAudioScte35() throws Exception { DumpFileAsserts.assertOutput( ApplicationProvider.getApplicationContext(), playbackOutput, - "playbackdumps/ts/sample_scte35.ts.dump"); + "playbackdumps/ts/" + inputFile + ".dump"); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java index 6d7f23107e7..89e120e2e80 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java @@ -120,7 +120,7 @@ private void configureCodec( ShadowMediaCodec.addDecoder( codecName, new ShadowMediaCodec.CodecConfig( - /* inputBufferSize= */ 50_000, /* outputBufferSize= */ 50_000, codec)); + /* inputBufferSize= */ 100_000, /* outputBufferSize= */ 100_000, codec)); codecsByMimeType.put(mimeType, codec); } diff --git a/testdata/src/test/assets/playbackdumps/ts/bbb_2500ms.ts.dump b/testdata/src/test/assets/playbackdumps/ts/bbb_2500ms.ts.dump new file mode 100644 index 00000000000..90fc237af89 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/bbb_2500ms.ts.dump @@ -0,0 +1,156 @@ +MediaCodec (audio/mpeg-L2): + buffers.length = 94 + buffers[0] = length 1253, hash 2267F8F + buffers[1] = length 1254, hash F01E8708 + buffers[2] = length 1254, hash EA289D9A + buffers[3] = length 1254, hash C0F6FEA7 + buffers[4] = length 1254, hash 9390CF5D + buffers[5] = length 1254, hash 7C4F0796 + buffers[6] = length 1254, hash 6F92C8D5 + buffers[7] = length 1254, hash 825CA1A8 + buffers[8] = length 1253, hash 9E4407E2 + buffers[9] = length 1254, hash C2C99A3D + buffers[10] = length 1254, hash 41143E9C + buffers[11] = length 1254, hash BCB13BE1 + buffers[12] = length 1254, hash F924421D + buffers[13] = length 1254, hash A603ED9A + buffers[14] = length 1254, hash 78DD7C27 + buffers[15] = length 1254, hash A7B7A895 + buffers[16] = length 1253, hash BD3FF06 + buffers[17] = length 1254, hash A2CFE3D8 + buffers[18] = length 1254, hash 6F4C0000 + buffers[19] = length 1254, hash 770A4854 + buffers[20] = length 1254, hash BEB88F8C + buffers[21] = length 1254, hash 65482CC0 + buffers[22] = length 1254, hash B305EE8B + buffers[23] = length 1254, hash B77F692D + buffers[24] = length 1253, hash DC6523BE + buffers[25] = length 1254, hash F75B7E60 + buffers[26] = length 1254, hash BE8F862A + buffers[27] = length 1254, hash F888C1FF + buffers[28] = length 1254, hash 7AE088AC + buffers[29] = length 1254, hash 5D26DFE0 + buffers[30] = length 1254, hash 93064427 + buffers[31] = length 1254, hash 691A85E9 + buffers[32] = length 1253, hash 656BECF9 + buffers[33] = length 1254, hash 8138802F + buffers[34] = length 1254, hash 3D4203AF + buffers[35] = length 1254, hash D20B40AF + buffers[36] = length 1254, hash 1701AFBA + buffers[37] = length 1254, hash E8FD52D6 + buffers[38] = length 1254, hash 5E2959A8 + buffers[39] = length 1254, hash 7C92F3CB + buffers[40] = length 1253, hash 5C660AE8 + buffers[41] = length 1254, hash E9D9E83F + buffers[42] = length 1254, hash 8FBD4E7 + buffers[43] = length 1254, hash EBE00969 + buffers[44] = length 1254, hash FC6B17D1 + buffers[45] = length 1254, hash C3D3FCA7 + buffers[46] = length 1254, hash D13919FE + buffers[47] = length 1254, hash 36C2C44A + buffers[48] = length 1254, hash 60A54819 + buffers[49] = length 1253, hash FBE942D + buffers[50] = length 1254, hash 6052F8DE + buffers[51] = length 1254, hash 304E80C + buffers[52] = length 1254, hash 3E948D6E + buffers[53] = length 1254, hash E6E15A96 + buffers[54] = length 1254, hash C4CBCAB4 + buffers[55] = length 1254, hash DEB54B52 + buffers[56] = length 1254, hash 5F93A88C + buffers[57] = length 1253, hash 54CADC2E + buffers[58] = length 1254, hash 9AD17FDF + buffers[59] = length 1254, hash 3FF8D267 + buffers[60] = length 1254, hash 353AD264 + buffers[61] = length 1254, hash 9094C43F + buffers[62] = length 1254, hash D0E14D33 + buffers[63] = length 1254, hash 8C89B9A1 + buffers[64] = length 1254, hash BC99E39E + buffers[65] = length 1253, hash 863388C0 + buffers[66] = length 1254, hash AF86B66D + buffers[67] = length 1254, hash C116381C + buffers[68] = length 1254, hash 4E4F4AF3 + buffers[69] = length 1254, hash 206E2FE1 + buffers[70] = length 1254, hash 6970D4AB + buffers[71] = length 1254, hash F78FFF5A + buffers[72] = length 1254, hash 39928A7D + buffers[73] = length 1253, hash 969764D4 + buffers[74] = length 1254, hash 23DDEAF1 + buffers[75] = length 1254, hash 5F062D1E + buffers[76] = length 1254, hash 45843785 + buffers[77] = length 1254, hash 32A71BDF + buffers[78] = length 1254, hash A11CE73B + buffers[79] = length 1254, hash 12EA041D + buffers[80] = length 1254, hash 246B5AF9 + buffers[81] = length 1253, hash 75934C8C + buffers[82] = length 1254, hash F71EBDD5 + buffers[83] = length 1254, hash 5BA46B73 + buffers[84] = length 1254, hash ABC1276B + buffers[85] = length 1254, hash A2715CA1 + buffers[86] = length 1254, hash 1511D4C6 + buffers[87] = length 1254, hash E0A419B5 + buffers[88] = length 1254, hash A31959C2 + buffers[89] = length 1253, hash 29A0675A + buffers[90] = length 1254, hash C6EE9D9F + buffers[91] = length 1254, hash B74BCB59 + buffers[92] = length 1254, hash 1D10AC24 + buffers[93] = length 0, hash 1 +MediaCodec (video/mpeg2): + buffers.length = 58 + buffers[0] = length 32732, hash 7B7C01FD + buffers[1] = length 1302, hash CE206BF9 + buffers[2] = length 923, hash 94689DE8 + buffers[3] = length 863, hash 9DBD2339 + buffers[4] = length 33035, hash A5DBB62C + buffers[5] = length 16569, hash DB7154A2 + buffers[6] = length 33091, hash 311F300C + buffers[7] = length 5614, hash 36C7BD73 + buffers[8] = length 33119, hash 2BE0E21E + buffers[9] = length 32462, hash F205F165 + buffers[10] = length 35255, hash 7B30DB97 + buffers[11] = length 32475, hash B2D70670 + buffers[12] = length 32255, hash 30BC4FED + buffers[13] = length 34086, hash 3CEE5C4E + buffers[14] = length 32543, hash EBD2C446 + buffers[15] = length 32287, hash 8A43F4A5 + buffers[16] = length 34184, hash F2BDE8F3 + buffers[17] = length 34412, hash 9F04D208 + buffers[18] = length 34744, hash DC420E09 + buffers[19] = length 33439, hash B795AB08 + buffers[20] = length 34020, hash 8651CE78 + buffers[21] = length 33897, hash DC6971CA + buffers[22] = length 34332, hash A3CF1879 + buffers[23] = length 33247, hash D403A2B + buffers[24] = length 33840, hash 18423E5E + buffers[25] = length 34095, hash 71919E4E + buffers[26] = length 34141, hash 2AE6CD07 + buffers[27] = length 32961, hash E67E4ABE + buffers[28] = length 33025, hash 49E7B130 + buffers[29] = length 33904, hash EB045080 + buffers[30] = length 34441, hash 4FCB48CF + buffers[31] = length 34058, hash 74F7D057 + buffers[32] = length 34073, hash 5E0AA001 + buffers[33] = length 34414, hash E942B3BA + buffers[34] = length 34261, hash FF6FF642 + buffers[35] = length 34265, hash F29051FF + buffers[36] = length 34394, hash CA10CAEE + buffers[37] = length 34501, hash EA9AF4D5 + buffers[38] = length 34453, hash 7868C8D4 + buffers[39] = length 35899, hash C0F358B7 + buffers[40] = length 33691, hash CA6F9416 + buffers[41] = length 34621, hash C28FCECF + buffers[42] = length 34751, hash EF4AF32A + buffers[43] = length 34602, hash 8315A687 + buffers[44] = length 35195, hash DD0657D8 + buffers[45] = length 35140, hash B55418F3 + buffers[46] = length 35308, hash 7F559C85 + buffers[47] = length 35236, hash 1EFAC3E6 + buffers[48] = length 35657, hash 2D7254A8 + buffers[49] = length 35654, hash AFB5A582 + buffers[50] = length 35557, hash B0B5E93B + buffers[51] = length 40678, hash 8E0DBB1D + buffers[52] = length 2012, hash 63CB3DF4 + buffers[53] = length 2045, hash 3CAA184 + buffers[54] = length 2195, hash 65E63F42 + buffers[55] = length 2404, hash BA5E2CEA + buffers[56] = length 2738, hash 8F8FDE0A + buffers[57] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/elephants_dream.mpg.dump b/testdata/src/test/assets/playbackdumps/ts/elephants_dream.mpg.dump new file mode 100644 index 00000000000..c7860493419 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/elephants_dream.mpg.dump @@ -0,0 +1,2052 @@ +MediaCodec (audio/mpeg-L2): + buffers.length = 1150 + buffers[0] = length 1253, hash 2267F8F + buffers[1] = length 1254, hash E64C3DE8 + buffers[2] = length 1254, hash E64C3DE8 + buffers[3] = length 1254, hash E64C3DE8 + buffers[4] = length 1254, hash E64C3DE8 + buffers[5] = length 1254, hash E64C3DE8 + buffers[6] = length 1254, hash E64C3DE8 + buffers[7] = length 1254, hash E64C3DE8 + buffers[8] = length 1253, hash 2267F8F + buffers[9] = length 1254, hash E64C3DE8 + buffers[10] = length 1254, hash E64C3DE8 + buffers[11] = length 1254, hash E64C3DE8 + buffers[12] = length 1254, hash E64C3DE8 + buffers[13] = length 1254, hash E64C3DE8 + buffers[14] = length 1254, hash E64C3DE8 + buffers[15] = length 1254, hash E64C3DE8 + buffers[16] = length 1253, hash 2267F8F + buffers[17] = length 1254, hash E64C3DE8 + buffers[18] = length 1254, hash CCE34B2C + buffers[19] = length 1254, hash 600F5A64 + buffers[20] = length 1254, hash 97C8D994 + buffers[21] = length 1254, hash 8E727573 + buffers[22] = length 1254, hash C61DE4AB + buffers[23] = length 1254, hash 65B0D159 + buffers[24] = length 1253, hash CDB056A2 + buffers[25] = length 1254, hash 8DD4ED22 + buffers[26] = length 1254, hash 64A0F10A + buffers[27] = length 1254, hash FE686F88 + buffers[28] = length 1254, hash 987242F3 + buffers[29] = length 1254, hash 46373383 + buffers[30] = length 1254, hash BBD33D1B + buffers[31] = length 1254, hash 43326087 + buffers[32] = length 1253, hash 6B811F24 + buffers[33] = length 1254, hash 738DDEFB + buffers[34] = length 1254, hash E1CAE728 + buffers[35] = length 1254, hash EF28013C + buffers[36] = length 1254, hash E2FE3CB1 + buffers[37] = length 1254, hash 87657D04 + buffers[38] = length 1254, hash 129BEF89 + buffers[39] = length 1254, hash 8601A900 + buffers[40] = length 1253, hash 90CE0C78 + buffers[41] = length 1254, hash 14E80D8E + buffers[42] = length 1254, hash C2FC8886 + buffers[43] = length 1254, hash 48867FCE + buffers[44] = length 1254, hash E22E18BC + buffers[45] = length 1254, hash 37A27813 + buffers[46] = length 1254, hash 2B87569C + buffers[47] = length 1254, hash DD17182D + buffers[48] = length 1254, hash 66391658 + buffers[49] = length 1253, hash 2E243A30 + buffers[50] = length 1254, hash 737B648E + buffers[51] = length 1254, hash 4C6B7CB0 + buffers[52] = length 1254, hash 2E85FD2E + buffers[53] = length 1254, hash E4D14351 + buffers[54] = length 1254, hash 649FF055 + buffers[55] = length 1254, hash E79ADFDA + buffers[56] = length 1254, hash 12F7A0C9 + buffers[57] = length 1253, hash 5848D02C + buffers[58] = length 1254, hash 6C21AB11 + buffers[59] = length 1254, hash C3C3E597 + buffers[60] = length 1254, hash F15C11B0 + buffers[61] = length 1254, hash 77BADE8D + buffers[62] = length 1254, hash 29A2B7DA + buffers[63] = length 1254, hash 388F3A7F + buffers[64] = length 1254, hash F8294E36 + buffers[65] = length 1253, hash 82CC6E43 + buffers[66] = length 1254, hash D2311223 + buffers[67] = length 1254, hash CDA349EA + buffers[68] = length 1254, hash D46B5E3C + buffers[69] = length 1254, hash 10EC7566 + buffers[70] = length 1254, hash E88BF451 + buffers[71] = length 1254, hash A6DFCEE7 + buffers[72] = length 1254, hash 11CCAD52 + buffers[73] = length 1253, hash A81E699B + buffers[74] = length 1254, hash CF34AEE5 + buffers[75] = length 1254, hash 2AC46ECB + buffers[76] = length 1254, hash 507B0890 + buffers[77] = length 1254, hash 4531A06C + buffers[78] = length 1254, hash 338B866 + buffers[79] = length 1254, hash A9554CCF + buffers[80] = length 1254, hash A0D31705 + buffers[81] = length 1253, hash CBE8EE18 + buffers[82] = length 1254, hash 9A2484CE + buffers[83] = length 1254, hash AD518CB4 + buffers[84] = length 1254, hash DDE5FDB0 + buffers[85] = length 1254, hash F61E2E94 + buffers[86] = length 1254, hash 1795617E + buffers[87] = length 1254, hash A526B90E + buffers[88] = length 1254, hash EB826024 + buffers[89] = length 1253, hash 3759745B + buffers[90] = length 1254, hash FDD5DA01 + buffers[91] = length 1254, hash 449472AE + buffers[92] = length 1254, hash CF62C847 + buffers[93] = length 1254, hash BB3CB66C + buffers[94] = length 1254, hash 9C54AFC4 + buffers[95] = length 1254, hash E872D66E + buffers[96] = length 1254, hash 2D0D6FDB + buffers[97] = length 1254, hash 9E99B881 + buffers[98] = length 1253, hash B3295E76 + buffers[99] = length 1254, hash B7C48252 + buffers[100] = length 1254, hash 94D706D5 + buffers[101] = length 1254, hash 8056B036 + buffers[102] = length 1254, hash 23298229 + buffers[103] = length 1254, hash DF155EBD + buffers[104] = length 1254, hash 1CCA56F1 + buffers[105] = length 1254, hash A1F18F35 + buffers[106] = length 1253, hash A99F7909 + buffers[107] = length 1254, hash 5A7B8982 + buffers[108] = length 1254, hash 6330D803 + buffers[109] = length 1254, hash E92574BF + buffers[110] = length 1254, hash B723BD97 + buffers[111] = length 1254, hash 5CF9B2B7 + buffers[112] = length 1254, hash 52343151 + buffers[113] = length 1254, hash 81308871 + buffers[114] = length 1253, hash D8B24EE7 + buffers[115] = length 1254, hash A50BD5D2 + buffers[116] = length 1254, hash C8DE8AA5 + buffers[117] = length 1254, hash 27AC25C7 + buffers[118] = length 1254, hash A0B2A8FA + buffers[119] = length 1254, hash 9DF3CA48 + buffers[120] = length 1254, hash DC0467EC + buffers[121] = length 1254, hash 10502A3C + buffers[122] = length 1253, hash 3D155D11 + buffers[123] = length 1254, hash 27F3A338 + buffers[124] = length 1254, hash 11FB78FD + buffers[125] = length 1254, hash 7054D20B + buffers[126] = length 1254, hash 347C0908 + buffers[127] = length 1254, hash FB472BD4 + buffers[128] = length 1254, hash 1069380A + buffers[129] = length 1254, hash 3341A303 + buffers[130] = length 1253, hash 5455D4A2 + buffers[131] = length 1254, hash B17897C + buffers[132] = length 1254, hash AE270B + buffers[133] = length 1254, hash A45256FB + buffers[134] = length 1254, hash C63D87DE + buffers[135] = length 1254, hash 6C77F38E + buffers[136] = length 1254, hash 58F8A91C + buffers[137] = length 1254, hash E2FEE1F3 + buffers[138] = length 1253, hash 1E103E5 + buffers[139] = length 1254, hash E234D220 + buffers[140] = length 1254, hash D71A2D10 + buffers[141] = length 1254, hash AB4B7DA + buffers[142] = length 1254, hash D026360C + buffers[143] = length 1254, hash BE02C134 + buffers[144] = length 1254, hash 80143FBD + buffers[145] = length 1254, hash 7EA7CB6D + buffers[146] = length 1254, hash FA93755 + buffers[147] = length 1253, hash C8646AC5 + buffers[148] = length 1254, hash 32035DD9 + buffers[149] = length 1254, hash 17070030 + buffers[150] = length 1254, hash 9117AD9F + buffers[151] = length 1254, hash 80FFE474 + buffers[152] = length 1254, hash 87AEB7F8 + buffers[153] = length 1254, hash B4D9B3A9 + buffers[154] = length 1254, hash 4219DED0 + buffers[155] = length 1253, hash BA79CADE + buffers[156] = length 1254, hash 851C5B1 + buffers[157] = length 1254, hash 880E4A5F + buffers[158] = length 1254, hash 71D428BC + buffers[159] = length 1254, hash 5BEF30DD + buffers[160] = length 1254, hash CB8279B3 + buffers[161] = length 1254, hash 96545DF2 + buffers[162] = length 1254, hash 91EBB57 + buffers[163] = length 1253, hash BD18040D + buffers[164] = length 1254, hash 44F6631A + buffers[165] = length 1254, hash 8DAA8271 + buffers[166] = length 1254, hash 7AB78D20 + buffers[167] = length 1254, hash 17BA1FD6 + buffers[168] = length 1254, hash 80016706 + buffers[169] = length 1254, hash 488378 + buffers[170] = length 1254, hash FE16AD7F + buffers[171] = length 1253, hash D0168C31 + buffers[172] = length 1254, hash 8AD8E76F + buffers[173] = length 1254, hash 5C529AFA + buffers[174] = length 1254, hash 561A75C2 + buffers[175] = length 1254, hash AFF5195A + buffers[176] = length 1254, hash 324077D8 + buffers[177] = length 1254, hash 24E99065 + buffers[178] = length 1254, hash 5DE6BE2C + buffers[179] = length 1253, hash 1395B83B + buffers[180] = length 1254, hash D685532D + buffers[181] = length 1254, hash 9DFCBBF7 + buffers[182] = length 1254, hash E7FC4B9F + buffers[183] = length 1254, hash D2B9928A + buffers[184] = length 1254, hash 1C7507E7 + buffers[185] = length 1254, hash 575B8130 + buffers[186] = length 1254, hash 202E66DF + buffers[187] = length 1253, hash 3EB8F975 + buffers[188] = length 1254, hash 55142476 + buffers[189] = length 1254, hash F6015D73 + buffers[190] = length 1254, hash A3623789 + buffers[191] = length 1254, hash AFBCB736 + buffers[192] = length 1254, hash D23DF7F2 + buffers[193] = length 1254, hash E416F279 + buffers[194] = length 1254, hash 8086FD00 + buffers[195] = length 1254, hash 441A2066 + buffers[196] = length 1253, hash F0687FB9 + buffers[197] = length 1254, hash 8121C925 + buffers[198] = length 1254, hash 42919E0C + buffers[199] = length 1254, hash 421AF0F8 + buffers[200] = length 1254, hash F59BD66D + buffers[201] = length 1254, hash A1139372 + buffers[202] = length 1254, hash 880BFC53 + buffers[203] = length 1254, hash E7DEA27F + buffers[204] = length 1253, hash 79D99C25 + buffers[205] = length 1254, hash D526354E + buffers[206] = length 1254, hash B09B8C02 + buffers[207] = length 1254, hash 5FD351B4 + buffers[208] = length 1254, hash C62FBA1A + buffers[209] = length 1254, hash 90CEB46B + buffers[210] = length 1254, hash B31CD1F7 + buffers[211] = length 1254, hash A8C3276 + buffers[212] = length 1253, hash D55B0F14 + buffers[213] = length 1254, hash C6FA43BB + buffers[214] = length 1254, hash 856199A5 + buffers[215] = length 1254, hash 2072E03A + buffers[216] = length 1254, hash EBCF6A71 + buffers[217] = length 1254, hash 378EDC72 + buffers[218] = length 1254, hash BE6584EB + buffers[219] = length 1254, hash 33EFEF03 + buffers[220] = length 1253, hash B713BE9B + buffers[221] = length 1254, hash 8BB82053 + buffers[222] = length 1254, hash 73DE909D + buffers[223] = length 1254, hash 599F9248 + buffers[224] = length 1254, hash F75FA87C + buffers[225] = length 1254, hash 91F443DE + buffers[226] = length 1254, hash D11E3BC7 + buffers[227] = length 1254, hash 1EDEA146 + buffers[228] = length 1253, hash C72B33FA + buffers[229] = length 1254, hash 3D4CC304 + buffers[230] = length 1254, hash 45D69C29 + buffers[231] = length 1254, hash F7292AD2 + buffers[232] = length 1254, hash 8566A8AD + buffers[233] = length 1254, hash D588EB99 + buffers[234] = length 1254, hash A4A60964 + buffers[235] = length 1254, hash EEF87EB3 + buffers[236] = length 1253, hash 71203C41 + buffers[237] = length 1254, hash 1D8CD604 + buffers[238] = length 1254, hash 4F34FA64 + buffers[239] = length 1254, hash 90B82025 + buffers[240] = length 1254, hash 6D910220 + buffers[241] = length 1254, hash B50DB3FE + buffers[242] = length 1254, hash 92321B61 + buffers[243] = length 1254, hash 8D2F69B8 + buffers[244] = length 1254, hash B5121219 + buffers[245] = length 1253, hash FA8998F4 + buffers[246] = length 1254, hash 4CD7FC68 + buffers[247] = length 1254, hash 8EFDF6B0 + buffers[248] = length 1254, hash B3B20226 + buffers[249] = length 1254, hash B6EC0FA6 + buffers[250] = length 1254, hash F019813B + buffers[251] = length 1254, hash 49411201 + buffers[252] = length 1254, hash 6D618C5 + buffers[253] = length 1253, hash 929F8A47 + buffers[254] = length 1254, hash 7D87E894 + buffers[255] = length 1254, hash F1F55D2C + buffers[256] = length 1254, hash 1B10AA4C + buffers[257] = length 1254, hash AE218552 + buffers[258] = length 1254, hash BA435DB5 + buffers[259] = length 1254, hash DFA5431B + buffers[260] = length 1254, hash AB3A7036 + buffers[261] = length 1253, hash F3D76E91 + buffers[262] = length 1254, hash 4C397933 + buffers[263] = length 1254, hash E987C285 + buffers[264] = length 1254, hash FB02C293 + buffers[265] = length 1254, hash 6F96731 + buffers[266] = length 1254, hash B2294161 + buffers[267] = length 1254, hash 4C5A6264 + buffers[268] = length 1254, hash B097E3E9 + buffers[269] = length 1253, hash C61E3C15 + buffers[270] = length 1254, hash 8454DCA3 + buffers[271] = length 1254, hash 4D5DE36E + buffers[272] = length 1254, hash 552AADC6 + buffers[273] = length 1254, hash 1B3891E7 + buffers[274] = length 1254, hash 32FDBF09 + buffers[275] = length 1254, hash D1B4F5B6 + buffers[276] = length 1254, hash 95D3B1C2 + buffers[277] = length 1253, hash E2EE2004 + buffers[278] = length 1254, hash 75314720 + buffers[279] = length 1254, hash 956079BD + buffers[280] = length 1254, hash 70BCB97 + buffers[281] = length 1254, hash CA194E63 + buffers[282] = length 1254, hash 2E9A1C3F + buffers[283] = length 1254, hash 3430FBE3 + buffers[284] = length 1254, hash A85DD6AF + buffers[285] = length 1253, hash E4CE1B59 + buffers[286] = length 1254, hash F5D51A1C + buffers[287] = length 1254, hash 68347659 + buffers[288] = length 1254, hash EB208F94 + buffers[289] = length 1254, hash F785FC1C + buffers[290] = length 1254, hash 62453AB8 + buffers[291] = length 1254, hash F1F71DFD + buffers[292] = length 1254, hash D797BC72 + buffers[293] = length 1254, hash B1041ECB + buffers[294] = length 1253, hash 5638447A + buffers[295] = length 1254, hash 227C8087 + buffers[296] = length 1254, hash F64B2B69 + buffers[297] = length 1254, hash 821FA3A0 + buffers[298] = length 1254, hash 50B0C1FA + buffers[299] = length 1254, hash 4B56AEAE + buffers[300] = length 1254, hash 1E1EA325 + buffers[301] = length 1254, hash EFED5B8C + buffers[302] = length 1253, hash 56BF8947 + buffers[303] = length 1254, hash B7293243 + buffers[304] = length 1254, hash B06C3B60 + buffers[305] = length 1254, hash 95B1F46B + buffers[306] = length 1254, hash B199C508 + buffers[307] = length 1254, hash FF4ADFAB + buffers[308] = length 1254, hash 386F3159 + buffers[309] = length 1254, hash CE020A35 + buffers[310] = length 1253, hash BEAE7612 + buffers[311] = length 1254, hash 4BFF7202 + buffers[312] = length 1254, hash 66C253CE + buffers[313] = length 1254, hash 6071856D + buffers[314] = length 1254, hash 5FF67F52 + buffers[315] = length 1254, hash FEB631E + buffers[316] = length 1254, hash 9F10FF23 + buffers[317] = length 1254, hash 37E2903B + buffers[318] = length 1253, hash F3F912F6 + buffers[319] = length 1254, hash B9A6DC8 + buffers[320] = length 1254, hash 70D9B67A + buffers[321] = length 1254, hash 8A1A2787 + buffers[322] = length 1254, hash 32FBAA66 + buffers[323] = length 1254, hash 5E2883AB + buffers[324] = length 1254, hash 56587C56 + buffers[325] = length 1254, hash E31DB3F6 + buffers[326] = length 1253, hash 12C0E208 + buffers[327] = length 1254, hash 261B5023 + buffers[328] = length 1254, hash D682A383 + buffers[329] = length 1254, hash 6FB4C977 + buffers[330] = length 1254, hash 4492DBBC + buffers[331] = length 1254, hash 147A5B07 + buffers[332] = length 1254, hash AE23AAFF + buffers[333] = length 1254, hash A687FD2F + buffers[334] = length 1253, hash 19D418BB + buffers[335] = length 1254, hash ED1BF627 + buffers[336] = length 1254, hash E3E68AB1 + buffers[337] = length 1254, hash 50980BFA + buffers[338] = length 1254, hash 8DCCEE69 + buffers[339] = length 1254, hash 34B5D619 + buffers[340] = length 1254, hash CC3FE4FF + buffers[341] = length 1254, hash 90D9D80 + buffers[342] = length 1254, hash 71A5F711 + buffers[343] = length 1253, hash EAA8C3D1 + buffers[344] = length 1254, hash 4729BC91 + buffers[345] = length 1254, hash 245EF00A + buffers[346] = length 1254, hash B3505559 + buffers[347] = length 1254, hash 8211EE65 + buffers[348] = length 1254, hash E600201 + buffers[349] = length 1254, hash FF5A2071 + buffers[350] = length 1254, hash 3EE38851 + buffers[351] = length 1253, hash F25EBDD5 + buffers[352] = length 1254, hash A24FA775 + buffers[353] = length 1254, hash EBEB63A6 + buffers[354] = length 1254, hash 221B6E89 + buffers[355] = length 1254, hash F62E4914 + buffers[356] = length 1254, hash 5FDA6E60 + buffers[357] = length 1254, hash 1DC86E1A + buffers[358] = length 1254, hash 72F91A54 + buffers[359] = length 1253, hash D3E73EB7 + buffers[360] = length 1254, hash 1030094C + buffers[361] = length 1254, hash EB30346B + buffers[362] = length 1254, hash 1458E877 + buffers[363] = length 1254, hash ED6D6FDE + buffers[364] = length 1254, hash E552CC8C + buffers[365] = length 1254, hash 11A2F9F7 + buffers[366] = length 1254, hash 3655E4A1 + buffers[367] = length 1253, hash 72F44341 + buffers[368] = length 1254, hash C1CA6896 + buffers[369] = length 1254, hash 7E5964F2 + buffers[370] = length 1254, hash AF156031 + buffers[371] = length 1254, hash 255BC138 + buffers[372] = length 1254, hash E9F9724A + buffers[373] = length 1254, hash F5921DC4 + buffers[374] = length 1254, hash 530F23FE + buffers[375] = length 1253, hash AE70F588 + buffers[376] = length 1254, hash 5893D03B + buffers[377] = length 1254, hash 72D4AB7C + buffers[378] = length 1254, hash 83E40C9E + buffers[379] = length 1254, hash 3F030532 + buffers[380] = length 1254, hash 26CB6B78 + buffers[381] = length 1254, hash 2A8C8A7E + buffers[382] = length 1254, hash DC719516 + buffers[383] = length 1253, hash 7B36D64F + buffers[384] = length 1254, hash CBB98D4C + buffers[385] = length 1254, hash B59A58E0 + buffers[386] = length 1254, hash CE149503 + buffers[387] = length 1254, hash 8CA69732 + buffers[388] = length 1254, hash F9B1049C + buffers[389] = length 1254, hash FE9CC2CE + buffers[390] = length 1254, hash F32C8098 + buffers[391] = length 1254, hash 3CA816F1 + buffers[392] = length 1253, hash B5653387 + buffers[393] = length 1254, hash F0B97CE7 + buffers[394] = length 1254, hash E7B6A7C9 + buffers[395] = length 1254, hash 48B90132 + buffers[396] = length 1254, hash 822CD082 + buffers[397] = length 1254, hash D7297B56 + buffers[398] = length 1254, hash 903621BF + buffers[399] = length 1254, hash C2D1E9C8 + buffers[400] = length 1253, hash FF4A6B46 + buffers[401] = length 1254, hash 6BBA1197 + buffers[402] = length 1254, hash 1F8E6AD5 + buffers[403] = length 1254, hash B33C7778 + buffers[404] = length 1254, hash A0836491 + buffers[405] = length 1254, hash 11FC56E0 + buffers[406] = length 1254, hash 1464B96D + buffers[407] = length 1254, hash 7A219F91 + buffers[408] = length 1253, hash F40FC5DB + buffers[409] = length 1254, hash 6E4ACBFA + buffers[410] = length 1254, hash ACD616F7 + buffers[411] = length 1254, hash 941911C2 + buffers[412] = length 1254, hash 8CB7E6EA + buffers[413] = length 1254, hash 384ABB10 + buffers[414] = length 1254, hash 317C7D5B + buffers[415] = length 1254, hash CFB28AFA + buffers[416] = length 1253, hash 585C265D + buffers[417] = length 1254, hash 35E494D3 + buffers[418] = length 1254, hash F1356424 + buffers[419] = length 1254, hash 9B8F6933 + buffers[420] = length 1254, hash F49B114E + buffers[421] = length 1254, hash 895DA068 + buffers[422] = length 1254, hash 29BA49B + buffers[423] = length 1254, hash 4732C206 + buffers[424] = length 1253, hash B559075C + buffers[425] = length 1254, hash AC4491F7 + buffers[426] = length 1254, hash 158D2337 + buffers[427] = length 1254, hash 46E4411C + buffers[428] = length 1254, hash FB79B84A + buffers[429] = length 1254, hash 1AE432A3 + buffers[430] = length 1254, hash CBE9F93F + buffers[431] = length 1254, hash 9ABBD006 + buffers[432] = length 1253, hash C52FB10D + buffers[433] = length 1254, hash D20621A3 + buffers[434] = length 1254, hash 857BA48F + buffers[435] = length 1254, hash B5F7A90 + buffers[436] = length 1254, hash BB740AC8 + buffers[437] = length 1254, hash E8DFF74D + buffers[438] = length 1254, hash DB6641EB + buffers[439] = length 1254, hash E3B99FA8 + buffers[440] = length 1254, hash F7FDF341 + buffers[441] = length 1253, hash 88E066C5 + buffers[442] = length 1254, hash 720096B5 + buffers[443] = length 1254, hash 651C456A + buffers[444] = length 1254, hash F4C085ED + buffers[445] = length 1254, hash 392FBDD5 + buffers[446] = length 1254, hash 4F3DFA9A + buffers[447] = length 1254, hash DA4F60EA + buffers[448] = length 1254, hash 34C83CDB + buffers[449] = length 1253, hash 97803915 + buffers[450] = length 1254, hash 87292B9A + buffers[451] = length 1254, hash CFBB86DD + buffers[452] = length 1254, hash F033E2ED + buffers[453] = length 1254, hash 453EB6D4 + buffers[454] = length 1254, hash 981FEC9 + buffers[455] = length 1254, hash E6CF9C12 + buffers[456] = length 1254, hash CB448612 + buffers[457] = length 1253, hash 2D335598 + buffers[458] = length 1254, hash 80A81B56 + buffers[459] = length 1254, hash D81FF516 + buffers[460] = length 1254, hash 856FE5 + buffers[461] = length 1254, hash 69D7AA20 + buffers[462] = length 1254, hash 31C58D2 + buffers[463] = length 1254, hash C041DC7 + buffers[464] = length 1254, hash 50C1D31D + buffers[465] = length 1253, hash F58DEABA + buffers[466] = length 1254, hash 753A312 + buffers[467] = length 1254, hash 22AA508A + buffers[468] = length 1254, hash 58DF421A + buffers[469] = length 1254, hash DA604244 + buffers[470] = length 1254, hash B66265BE + buffers[471] = length 1254, hash D29E6E18 + buffers[472] = length 1254, hash 80E62C23 + buffers[473] = length 1253, hash D41B8469 + buffers[474] = length 1254, hash C6E8A9AC + buffers[475] = length 1254, hash 47C40DA + buffers[476] = length 1254, hash 7714208D + buffers[477] = length 1254, hash B7DC6C3C + buffers[478] = length 1254, hash 219FD2C4 + buffers[479] = length 1254, hash E99748DE + buffers[480] = length 1254, hash C64CF474 + buffers[481] = length 1253, hash 48C1AE6C + buffers[482] = length 1254, hash 42B15543 + buffers[483] = length 1254, hash DB308095 + buffers[484] = length 1254, hash 90EFB928 + buffers[485] = length 1254, hash 78B75445 + buffers[486] = length 1254, hash 6E842884 + buffers[487] = length 1254, hash 36934BF7 + buffers[488] = length 1254, hash 550173D9 + buffers[489] = length 1254, hash F29E8E3A + buffers[490] = length 1253, hash 44255F2B + buffers[491] = length 1254, hash 1F55EC66 + buffers[492] = length 1254, hash A76D839C + buffers[493] = length 1254, hash 7AD834B4 + buffers[494] = length 1254, hash D57862F5 + buffers[495] = length 1254, hash 7FAD4232 + buffers[496] = length 1254, hash 320DE24D + buffers[497] = length 1254, hash B8BF63F9 + buffers[498] = length 1253, hash 7BDF02E + buffers[499] = length 1254, hash 83899A81 + buffers[500] = length 1254, hash F6CF0335 + buffers[501] = length 1254, hash B77A50B + buffers[502] = length 1254, hash C006076F + buffers[503] = length 1254, hash 44561CA9 + buffers[504] = length 1254, hash F10CF130 + buffers[505] = length 1254, hash B1FA26AD + buffers[506] = length 1253, hash BC951EED + buffers[507] = length 1254, hash 462C6DE9 + buffers[508] = length 1254, hash 29A03B46 + buffers[509] = length 1254, hash 7B64505 + buffers[510] = length 1254, hash 7DFC471B + buffers[511] = length 1254, hash D1201F8C + buffers[512] = length 1254, hash 119EC20C + buffers[513] = length 1254, hash 888AE777 + buffers[514] = length 1253, hash 7C04517F + buffers[515] = length 1254, hash ABF09EB3 + buffers[516] = length 1254, hash F01E3638 + buffers[517] = length 1254, hash F272F3F5 + buffers[518] = length 1254, hash DB4709EC + buffers[519] = length 1254, hash B3FFA951 + buffers[520] = length 1254, hash 7AAF2F0D + buffers[521] = length 1254, hash 675ED2C5 + buffers[522] = length 1253, hash 2CBE35BF + buffers[523] = length 1254, hash A233036 + buffers[524] = length 1254, hash D8BBB80B + buffers[525] = length 1254, hash 45C427CC + buffers[526] = length 1254, hash FCA2C5EB + buffers[527] = length 1254, hash 48808B1 + buffers[528] = length 1254, hash EB49A2E7 + buffers[529] = length 1254, hash 5E8C2D89 + buffers[530] = length 1253, hash 2DAD3F15 + buffers[531] = length 1254, hash E0537E5B + buffers[532] = length 1254, hash CCB89BFD + buffers[533] = length 1254, hash 860C79E9 + buffers[534] = length 1254, hash 45E1578E + buffers[535] = length 1254, hash B311CD76 + buffers[536] = length 1254, hash 66D23D84 + buffers[537] = length 1254, hash F0DA95 + buffers[538] = length 1254, hash 16E2D247 + buffers[539] = length 1253, hash D50C98CD + buffers[540] = length 1254, hash 3DDE29AD + buffers[541] = length 1254, hash 31B4FEE0 + buffers[542] = length 1254, hash 1BB59455 + buffers[543] = length 1254, hash 42315EA3 + buffers[544] = length 1254, hash 211C472E + buffers[545] = length 1254, hash 44BE7CB2 + buffers[546] = length 1254, hash 6D210CA + buffers[547] = length 1253, hash 6324B3A3 + buffers[548] = length 1254, hash 92A72D9A + buffers[549] = length 1254, hash B5D8ED2 + buffers[550] = length 1254, hash D5F5FBB8 + buffers[551] = length 1254, hash 2A3010C9 + buffers[552] = length 1254, hash C0064C25 + buffers[553] = length 1254, hash 7E28308B + buffers[554] = length 1254, hash 8846E34 + buffers[555] = length 1253, hash 7137694E + buffers[556] = length 1254, hash 1DD0EA2A + buffers[557] = length 1254, hash 9D542646 + buffers[558] = length 1254, hash E9A4B0B5 + buffers[559] = length 1254, hash B1009049 + buffers[560] = length 1254, hash F25ABFC1 + buffers[561] = length 1254, hash CD4DEA8F + buffers[562] = length 1254, hash 397B8B33 + buffers[563] = length 1253, hash 4D6368EF + buffers[564] = length 1254, hash E9B863F7 + buffers[565] = length 1254, hash 3AE3723B + buffers[566] = length 1254, hash 3C955774 + buffers[567] = length 1254, hash ECE96511 + buffers[568] = length 1254, hash F4A985AF + buffers[569] = length 1254, hash 25212E60 + buffers[570] = length 1254, hash BF0723AB + buffers[571] = length 1253, hash E06A3B7A + buffers[572] = length 1254, hash CBE95161 + buffers[573] = length 1254, hash 4DA5F817 + buffers[574] = length 1254, hash F8F6D56D + buffers[575] = length 1254, hash 9103AEFB + buffers[576] = length 1254, hash 1E35B3D5 + buffers[577] = length 1254, hash 3E3ED10 + buffers[578] = length 1254, hash 5A4ACC40 + buffers[579] = length 1253, hash 2BCF8E28 + buffers[580] = length 1254, hash 6630D563 + buffers[581] = length 1254, hash 24DE2336 + buffers[582] = length 1254, hash 52ED1607 + buffers[583] = length 1254, hash 6C21815A + buffers[584] = length 1254, hash BB03A405 + buffers[585] = length 1254, hash 6B911E14 + buffers[586] = length 1254, hash 654F706 + buffers[587] = length 1254, hash B6CE8D30 + buffers[588] = length 1253, hash D46A6BD5 + buffers[589] = length 1254, hash DB8AC32 + buffers[590] = length 1254, hash F0F4771F + buffers[591] = length 1254, hash 86BD8114 + buffers[592] = length 1254, hash 50B2FE9E + buffers[593] = length 1254, hash 80B84C50 + buffers[594] = length 1254, hash 6E675D79 + buffers[595] = length 1254, hash 42323F50 + buffers[596] = length 1253, hash 50115E47 + buffers[597] = length 1254, hash C41C7DCE + buffers[598] = length 1254, hash 4044591A + buffers[599] = length 1254, hash 48B3C309 + buffers[600] = length 1254, hash DA31BA7C + buffers[601] = length 1254, hash BB6D60E7 + buffers[602] = length 1254, hash 5153C8ED + buffers[603] = length 1254, hash EBF4D57C + buffers[604] = length 1253, hash 62C55531 + buffers[605] = length 1254, hash 42AC6C37 + buffers[606] = length 1254, hash 8460E62F + buffers[607] = length 1254, hash 33AF170F + buffers[608] = length 1254, hash EDE39D65 + buffers[609] = length 1254, hash CE7A9E1D + buffers[610] = length 1254, hash 60294324 + buffers[611] = length 1254, hash D30DFC28 + buffers[612] = length 1253, hash 543F61A1 + buffers[613] = length 1254, hash BF1D945B + buffers[614] = length 1254, hash 5C1AA2D4 + buffers[615] = length 1254, hash 85B2F902 + buffers[616] = length 1254, hash 6649A9C0 + buffers[617] = length 1254, hash 2933580D + buffers[618] = length 1254, hash 947B99F8 + buffers[619] = length 1254, hash 7BDC1F1B + buffers[620] = length 1253, hash 7B28422F + buffers[621] = length 1254, hash B3FF7619 + buffers[622] = length 1254, hash 75962064 + buffers[623] = length 1254, hash 5F5C0F5A + buffers[624] = length 1254, hash B4CB0D55 + buffers[625] = length 1254, hash DA0E25C7 + buffers[626] = length 1254, hash CE137DE3 + buffers[627] = length 1254, hash 536CCB90 + buffers[628] = length 1253, hash B1DF8F1E + buffers[629] = length 1254, hash 9D57B806 + buffers[630] = length 1254, hash 991702EF + buffers[631] = length 1254, hash 86D3364D + buffers[632] = length 1254, hash C2D56E9C + buffers[633] = length 1254, hash 7038BEC9 + buffers[634] = length 1254, hash 82455FBB + buffers[635] = length 1254, hash 560C826F + buffers[636] = length 1254, hash 2D92DBF3 + buffers[637] = length 1253, hash 84E463FF + buffers[638] = length 1254, hash 28577729 + buffers[639] = length 1254, hash BD7E4B38 + buffers[640] = length 1254, hash 6D566956 + buffers[641] = length 1254, hash F45670B8 + buffers[642] = length 1254, hash 68AC12EA + buffers[643] = length 1254, hash 628177B2 + buffers[644] = length 1254, hash 4B09F9AF + buffers[645] = length 1253, hash 9D3FE8F2 + buffers[646] = length 1254, hash 6ED3C78 + buffers[647] = length 1254, hash 9EB9F2AE + buffers[648] = length 1254, hash E12475E + buffers[649] = length 1254, hash 817DFF6A + buffers[650] = length 1254, hash 3F6C398D + buffers[651] = length 1254, hash B5EBCEDF + buffers[652] = length 1254, hash 25A1AEE4 + buffers[653] = length 1253, hash 775C1904 + buffers[654] = length 1254, hash FE3BD0B8 + buffers[655] = length 1254, hash BAC2AC41 + buffers[656] = length 1254, hash 2F83475B + buffers[657] = length 1254, hash 4463E513 + buffers[658] = length 1254, hash EBD304E2 + buffers[659] = length 1254, hash D4D8887C + buffers[660] = length 1254, hash C7A3E292 + buffers[661] = length 1253, hash 32EFD7A1 + buffers[662] = length 1254, hash 82D00CB6 + buffers[663] = length 1254, hash 6CF50B00 + buffers[664] = length 1254, hash 159F0087 + buffers[665] = length 1254, hash 20D5E145 + buffers[666] = length 1254, hash CD4A151C + buffers[667] = length 1254, hash 66A08F02 + buffers[668] = length 1254, hash 684A809E + buffers[669] = length 1253, hash DC4913A6 + buffers[670] = length 1254, hash 79C15470 + buffers[671] = length 1254, hash DCF34F1 + buffers[672] = length 1254, hash C6CDC575 + buffers[673] = length 1254, hash 9813E74B + buffers[674] = length 1254, hash FB74357F + buffers[675] = length 1254, hash A3D70120 + buffers[676] = length 1254, hash 1836E21C + buffers[677] = length 1253, hash 437356F + buffers[678] = length 1254, hash 83E4D8A4 + buffers[679] = length 1254, hash EAE600F + buffers[680] = length 1254, hash 7278A47D + buffers[681] = length 1254, hash 241E771D + buffers[682] = length 1254, hash EBB2D792 + buffers[683] = length 1254, hash 56C6FA1A + buffers[684] = length 1254, hash 1220FE95 + buffers[685] = length 1254, hash 83D686B0 + buffers[686] = length 1253, hash 3610BF5F + buffers[687] = length 1254, hash 956B5174 + buffers[688] = length 1254, hash 78A76139 + buffers[689] = length 1254, hash D57F859 + buffers[690] = length 1254, hash 91E1B16D + buffers[691] = length 1254, hash DD84B0F1 + buffers[692] = length 1254, hash FB6C872A + buffers[693] = length 1254, hash 79BCDBD8 + buffers[694] = length 1253, hash 279D0155 + buffers[695] = length 1254, hash 12F757B5 + buffers[696] = length 1254, hash F02DA253 + buffers[697] = length 1254, hash 84C9DC99 + buffers[698] = length 1254, hash 5A5691DB + buffers[699] = length 1254, hash 1180E4E8 + buffers[700] = length 1254, hash CFB5151E + buffers[701] = length 1254, hash EBE17809 + buffers[702] = length 1253, hash 6C39C396 + buffers[703] = length 1254, hash 2B555756 + buffers[704] = length 1254, hash 3723C994 + buffers[705] = length 1254, hash C06408CB + buffers[706] = length 1254, hash 99951951 + buffers[707] = length 1254, hash DF037EED + buffers[708] = length 1254, hash 3F669FE9 + buffers[709] = length 1254, hash 9F484654 + buffers[710] = length 1253, hash 8193B86D + buffers[711] = length 1254, hash C9622C44 + buffers[712] = length 1254, hash 999DA0D8 + buffers[713] = length 1254, hash 20991944 + buffers[714] = length 1254, hash EE6FB7C6 + buffers[715] = length 1254, hash 84CC28AC + buffers[716] = length 1254, hash 79B2F3EC + buffers[717] = length 1254, hash 151BC09A + buffers[718] = length 1253, hash 9AAFB268 + buffers[719] = length 1254, hash 58005D95 + buffers[720] = length 1254, hash 326D16DE + buffers[721] = length 1254, hash 81F457AD + buffers[722] = length 1254, hash E7EF9203 + buffers[723] = length 1254, hash 2DC224C0 + buffers[724] = length 1254, hash 16C1F08 + buffers[725] = length 1254, hash A2261759 + buffers[726] = length 1253, hash 67AD0066 + buffers[727] = length 1254, hash 99AE7E43 + buffers[728] = length 1254, hash A33DD42 + buffers[729] = length 1254, hash B447F568 + buffers[730] = length 1254, hash 831B306C + buffers[731] = length 1254, hash E8D5AA23 + buffers[732] = length 1254, hash EA00E4D4 + buffers[733] = length 1254, hash 972968B6 + buffers[734] = length 1254, hash CCEFEE27 + buffers[735] = length 1253, hash 6569F6C4 + buffers[736] = length 1254, hash 18A9E6 + buffers[737] = length 1254, hash E184892D + buffers[738] = length 1254, hash 8A696331 + buffers[739] = length 1254, hash BB3F9C9D + buffers[740] = length 1254, hash 71F99217 + buffers[741] = length 1254, hash 1C9A9475 + buffers[742] = length 1254, hash 87CA2015 + buffers[743] = length 1253, hash 28F2403A + buffers[744] = length 1254, hash 2FD91205 + buffers[745] = length 1254, hash F65352A2 + buffers[746] = length 1254, hash A02630DB + buffers[747] = length 1254, hash 26986A75 + buffers[748] = length 1254, hash 74B6D662 + buffers[749] = length 1254, hash 8A961F06 + buffers[750] = length 1254, hash AA508D4 + buffers[751] = length 1253, hash 2EF75D99 + buffers[752] = length 1254, hash A2A4E76A + buffers[753] = length 1254, hash 6A636C22 + buffers[754] = length 1254, hash C7AEEA33 + buffers[755] = length 1254, hash 4E857F7E + buffers[756] = length 1254, hash 34C3DAC3 + buffers[757] = length 1254, hash 176358CA + buffers[758] = length 1254, hash 8A22F6FC + buffers[759] = length 1253, hash 5DA19AE8 + buffers[760] = length 1254, hash 2E96F942 + buffers[761] = length 1254, hash 906A40F6 + buffers[762] = length 1254, hash C5707AB6 + buffers[763] = length 1254, hash DF65CB07 + buffers[764] = length 1254, hash 71122F3E + buffers[765] = length 1254, hash 544ED4A8 + buffers[766] = length 1254, hash 98B3FCAA + buffers[767] = length 1253, hash D705F461 + buffers[768] = length 1254, hash 41A020CA + buffers[769] = length 1254, hash 230DCE58 + buffers[770] = length 1254, hash 773AA296 + buffers[771] = length 1254, hash 57A13997 + buffers[772] = length 1254, hash 2383FD5F + buffers[773] = length 1254, hash B06F8A78 + buffers[774] = length 1254, hash 97D00EA4 + buffers[775] = length 1253, hash 53E849E2 + buffers[776] = length 1254, hash 301BB564 + buffers[777] = length 1254, hash 277B95AD + buffers[778] = length 1254, hash 75B293A0 + buffers[779] = length 1254, hash E83014A8 + buffers[780] = length 1254, hash 466E8FB + buffers[781] = length 1254, hash 927398E4 + buffers[782] = length 1254, hash 9B988594 + buffers[783] = length 1254, hash A6599AF4 + buffers[784] = length 1253, hash A7105E95 + buffers[785] = length 1254, hash 495F0BAE + buffers[786] = length 1254, hash CAFB7951 + buffers[787] = length 1254, hash 4E8ED80F + buffers[788] = length 1254, hash 78B1334D + buffers[789] = length 1254, hash E857315 + buffers[790] = length 1254, hash CCE127BD + buffers[791] = length 1254, hash 2B146367 + buffers[792] = length 1253, hash C12D74FB + buffers[793] = length 1254, hash F5D85F86 + buffers[794] = length 1254, hash AED8B884 + buffers[795] = length 1254, hash AA8DA88D + buffers[796] = length 1254, hash C33E85E6 + buffers[797] = length 1254, hash F47C171F + buffers[798] = length 1254, hash AA953297 + buffers[799] = length 1254, hash 182FFFC4 + buffers[800] = length 1253, hash 625C609C + buffers[801] = length 1254, hash DE7F7BEB + buffers[802] = length 1254, hash F2DE995A + buffers[803] = length 1254, hash A2063D68 + buffers[804] = length 1254, hash 4CB843BE + buffers[805] = length 1254, hash DB2C7295 + buffers[806] = length 1254, hash 5D228700 + buffers[807] = length 1254, hash 628074A3 + buffers[808] = length 1253, hash 7FBB2F83 + buffers[809] = length 1254, hash 23E45703 + buffers[810] = length 1254, hash FDC98244 + buffers[811] = length 1254, hash E6E24EE9 + buffers[812] = length 1254, hash C8D999FB + buffers[813] = length 1254, hash B321D458 + buffers[814] = length 1254, hash 690DB1C2 + buffers[815] = length 1254, hash 236B1DC0 + buffers[816] = length 1253, hash 8C49447E + buffers[817] = length 1254, hash 58B946AF + buffers[818] = length 1254, hash F515D274 + buffers[819] = length 1254, hash 42AB33A7 + buffers[820] = length 1254, hash F62E9536 + buffers[821] = length 1254, hash 45B2E075 + buffers[822] = length 1254, hash 1803DA6F + buffers[823] = length 1254, hash E4D44A46 + buffers[824] = length 1253, hash 1C0BD9B + buffers[825] = length 1254, hash 69AD8FBC + buffers[826] = length 1254, hash 18E7728E + buffers[827] = length 1254, hash 6AA71B2A + buffers[828] = length 1254, hash FE581684 + buffers[829] = length 1254, hash D7DC0ED5 + buffers[830] = length 1254, hash 378BE84F + buffers[831] = length 1254, hash DAA6FD74 + buffers[832] = length 1254, hash BEF5DD1F + buffers[833] = length 1253, hash 4D5BEF8E + buffers[834] = length 1254, hash 655FBD79 + buffers[835] = length 1254, hash 918DE2CC + buffers[836] = length 1254, hash DAE2D6DA + buffers[837] = length 1254, hash F321036C + buffers[838] = length 1254, hash 48871DE2 + buffers[839] = length 1254, hash E6214230 + buffers[840] = length 1254, hash 6E2D3492 + buffers[841] = length 1253, hash 54DBF93E + buffers[842] = length 1254, hash 86A94EC4 + buffers[843] = length 1254, hash 4FA56978 + buffers[844] = length 1254, hash 3A56F2FA + buffers[845] = length 1254, hash 366AE0DC + buffers[846] = length 1254, hash F02DC7CE + buffers[847] = length 1254, hash 74876B7F + buffers[848] = length 1254, hash D895ABFD + buffers[849] = length 1253, hash D85D6005 + buffers[850] = length 1254, hash 988D03C4 + buffers[851] = length 1254, hash E0DC55D0 + buffers[852] = length 1254, hash 55ADDCCF + buffers[853] = length 1254, hash 28F13DF + buffers[854] = length 1254, hash 20298007 + buffers[855] = length 1254, hash FFAE01C9 + buffers[856] = length 1254, hash 38108E8D + buffers[857] = length 1253, hash B3FAC338 + buffers[858] = length 1254, hash 9B8FB5E3 + buffers[859] = length 1254, hash E921A81A + buffers[860] = length 1254, hash 5919EE03 + buffers[861] = length 1254, hash 55F077E8 + buffers[862] = length 1254, hash 78768CC3 + buffers[863] = length 1254, hash 6744EB66 + buffers[864] = length 1254, hash 21FFFC42 + buffers[865] = length 1253, hash 664A437A + buffers[866] = length 1254, hash DFE8BFB8 + buffers[867] = length 1254, hash 7BED4F6F + buffers[868] = length 1254, hash FFE9A0DD + buffers[869] = length 1254, hash 357F2D9 + buffers[870] = length 1254, hash 9927DD5B + buffers[871] = length 1254, hash CB31F7F0 + buffers[872] = length 1254, hash 314AD818 + buffers[873] = length 1253, hash FE1E0CBC + buffers[874] = length 1254, hash 76DDA6B2 + buffers[875] = length 1254, hash 6637F2D8 + buffers[876] = length 1254, hash 5EF9AF9D + buffers[877] = length 1254, hash 36E48699 + buffers[878] = length 1254, hash AD52AF0F + buffers[879] = length 1254, hash 3118C2FB + buffers[880] = length 1254, hash 6D7DC0BA + buffers[881] = length 1254, hash B11A5275 + buffers[882] = length 1253, hash 4A3B836F + buffers[883] = length 1254, hash DFD716E6 + buffers[884] = length 1254, hash C20F42B0 + buffers[885] = length 1254, hash BED30F80 + buffers[886] = length 1254, hash CDDCBF1C + buffers[887] = length 1254, hash 6F8BBAEC + buffers[888] = length 1254, hash 9B1CFFB7 + buffers[889] = length 1254, hash 9FC46F88 + buffers[890] = length 1253, hash 76B6076F + buffers[891] = length 1254, hash FE622FDF + buffers[892] = length 1254, hash FD122D34 + buffers[893] = length 1254, hash CAC8EA0D + buffers[894] = length 1254, hash BE007B8F + buffers[895] = length 1254, hash 90E47077 + buffers[896] = length 1254, hash D3B59E9B + buffers[897] = length 1254, hash 5F99F4DC + buffers[898] = length 1253, hash 395B0D1C + buffers[899] = length 1254, hash 455B2D + buffers[900] = length 1254, hash AD2D2F6B + buffers[901] = length 1254, hash D6607611 + buffers[902] = length 1254, hash 2934823C + buffers[903] = length 1254, hash 55CC02E5 + buffers[904] = length 1254, hash AC06541D + buffers[905] = length 1254, hash D38D7736 + buffers[906] = length 1253, hash AC6EE1B5 + buffers[907] = length 1254, hash 16878362 + buffers[908] = length 1254, hash E6B274C + buffers[909] = length 1254, hash B8B302A4 + buffers[910] = length 1254, hash F232F8B1 + buffers[911] = length 1254, hash 529F8F77 + buffers[912] = length 1254, hash 5FB804C4 + buffers[913] = length 1254, hash F540A6BC + buffers[914] = length 1253, hash 5FE6744D + buffers[915] = length 1254, hash B7444A34 + buffers[916] = length 1254, hash 6BBC6449 + buffers[917] = length 1254, hash ECABF942 + buffers[918] = length 1254, hash CBB826F0 + buffers[919] = length 1254, hash DE020915 + buffers[920] = length 1254, hash A41275E7 + buffers[921] = length 1254, hash F08D0CCF + buffers[922] = length 1253, hash 8ABCA110 + buffers[923] = length 1254, hash 30489909 + buffers[924] = length 1254, hash BA9488AA + buffers[925] = length 1254, hash 8EE32DEC + buffers[926] = length 1254, hash DC960C9F + buffers[927] = length 1254, hash 6B40B747 + buffers[928] = length 1254, hash 37F3C05A + buffers[929] = length 1254, hash 9C0C7C0B + buffers[930] = length 1254, hash D8D75E9B + buffers[931] = length 1253, hash C6C5D74 + buffers[932] = length 1254, hash 12229283 + buffers[933] = length 1254, hash C85AC12A + buffers[934] = length 1254, hash 3C54636B + buffers[935] = length 1254, hash 7F6E528A + buffers[936] = length 1254, hash 52CD151E + buffers[937] = length 1254, hash 8C38A5F2 + buffers[938] = length 1254, hash 88B66492 + buffers[939] = length 1253, hash F87412B0 + buffers[940] = length 1254, hash 751DC61B + buffers[941] = length 1254, hash F61E3EA5 + buffers[942] = length 1254, hash 432B09D4 + buffers[943] = length 1254, hash 19A9E02D + buffers[944] = length 1254, hash 8039737 + buffers[945] = length 1254, hash B1CB3E14 + buffers[946] = length 1254, hash 2098A9D7 + buffers[947] = length 1253, hash F1FF3B90 + buffers[948] = length 1254, hash 62F860C0 + buffers[949] = length 1254, hash E2599B82 + buffers[950] = length 1254, hash 159FD5DC + buffers[951] = length 1254, hash 84F58F42 + buffers[952] = length 1254, hash BEEDD01 + buffers[953] = length 1254, hash 37F1BD9D + buffers[954] = length 1254, hash C9E9EE2 + buffers[955] = length 1253, hash 3364F4B9 + buffers[956] = length 1254, hash B20E48D8 + buffers[957] = length 1254, hash E630DEF2 + buffers[958] = length 1254, hash 485FDD62 + buffers[959] = length 1254, hash 21F2F561 + buffers[960] = length 1254, hash 43B84344 + buffers[961] = length 1254, hash D796E2B5 + buffers[962] = length 1254, hash EC5A9AC4 + buffers[963] = length 1253, hash 5BFD80E9 + buffers[964] = length 1254, hash C6A21321 + buffers[965] = length 1254, hash 79E474FA + buffers[966] = length 1254, hash B64FC15B + buffers[967] = length 1254, hash 4E91C420 + buffers[968] = length 1254, hash DC3C2566 + buffers[969] = length 1254, hash 5DD03F86 + buffers[970] = length 1254, hash DD2B3E7 + buffers[971] = length 1253, hash CA1906 + buffers[972] = length 1254, hash B5767CFA + buffers[973] = length 1254, hash FBD49A8D + buffers[974] = length 1254, hash 69BEE64D + buffers[975] = length 1254, hash 8FA633AE + buffers[976] = length 1254, hash C6559905 + buffers[977] = length 1254, hash 91743FF2 + buffers[978] = length 1254, hash 8299179 + buffers[979] = length 1254, hash 63A01EFF + buffers[980] = length 1253, hash 81E08A95 + buffers[981] = length 1254, hash 5988F27A + buffers[982] = length 1254, hash E99A6D72 + buffers[983] = length 1254, hash 83AEA016 + buffers[984] = length 1254, hash C90934CA + buffers[985] = length 1254, hash F3F050C3 + buffers[986] = length 1254, hash 123295E3 + buffers[987] = length 1254, hash 47D5094D + buffers[988] = length 1253, hash 3F7F9FF4 + buffers[989] = length 1254, hash E8CB1FD3 + buffers[990] = length 1254, hash 12F3277B + buffers[991] = length 1254, hash 71C8B035 + buffers[992] = length 1254, hash 176DA3FB + buffers[993] = length 1254, hash EC871EDF + buffers[994] = length 1254, hash D962E93F + buffers[995] = length 1254, hash CCF5FE8C + buffers[996] = length 1253, hash 72D46466 + buffers[997] = length 1254, hash 60025A12 + buffers[998] = length 1254, hash 3DA16B74 + buffers[999] = length 1254, hash B981B5B3 + buffers[1000] = length 1254, hash B01AD02A + buffers[1001] = length 1254, hash 87B142A0 + buffers[1002] = length 1254, hash 275945A4 + buffers[1003] = length 1254, hash 9C3AE39A + buffers[1004] = length 1253, hash 7329B3E5 + buffers[1005] = length 1254, hash E250EA0 + buffers[1006] = length 1254, hash 1A7B3DF8 + buffers[1007] = length 1254, hash D58BA911 + buffers[1008] = length 1254, hash 72FBFD31 + buffers[1009] = length 1254, hash 3BAD25DB + buffers[1010] = length 1254, hash 250BF2BF + buffers[1011] = length 1254, hash B1E88D3D + buffers[1012] = length 1253, hash 36A0CC09 + buffers[1013] = length 1254, hash D535ED47 + buffers[1014] = length 1254, hash 89595BD8 + buffers[1015] = length 1254, hash 113DABD3 + buffers[1016] = length 1254, hash 35EFD501 + buffers[1017] = length 1254, hash 7BD68AF9 + buffers[1018] = length 1254, hash B0F119FB + buffers[1019] = length 1254, hash DD25737B + buffers[1020] = length 1253, hash 972E251D + buffers[1021] = length 1254, hash B8B5849 + buffers[1022] = length 1254, hash A9847738 + buffers[1023] = length 1254, hash AF319BF5 + buffers[1024] = length 1254, hash C64D54DF + buffers[1025] = length 1254, hash 46030813 + buffers[1026] = length 1254, hash 7BA5F82F + buffers[1027] = length 1254, hash 2DB888F4 + buffers[1028] = length 1254, hash 8494B486 + buffers[1029] = length 1253, hash C12BA387 + buffers[1030] = length 1254, hash BA452251 + buffers[1031] = length 1254, hash 42A6ADA0 + buffers[1032] = length 1254, hash C2C2D7A8 + buffers[1033] = length 1254, hash 47CC8CB8 + buffers[1034] = length 1254, hash 92A9E193 + buffers[1035] = length 1254, hash 33AEE490 + buffers[1036] = length 1254, hash 66F4A136 + buffers[1037] = length 1253, hash D052A41C + buffers[1038] = length 1254, hash 40A92E0C + buffers[1039] = length 1254, hash 743BB827 + buffers[1040] = length 1254, hash B93E0BDD + buffers[1041] = length 1254, hash A8E1BA80 + buffers[1042] = length 1254, hash 182D9849 + buffers[1043] = length 1254, hash C6118974 + buffers[1044] = length 1254, hash D0C98523 + buffers[1045] = length 1253, hash EEBE2ABA + buffers[1046] = length 1254, hash EE238CC3 + buffers[1047] = length 1254, hash 7F6C2518 + buffers[1048] = length 1254, hash 7FBD0FF1 + buffers[1049] = length 1254, hash A609612A + buffers[1050] = length 1254, hash 9045EDC3 + buffers[1051] = length 1254, hash F23448AA + buffers[1052] = length 1254, hash C1ACD2BD + buffers[1053] = length 1253, hash ECD880C3 + buffers[1054] = length 1254, hash FB2D86C1 + buffers[1055] = length 1254, hash 21EC4D73 + buffers[1056] = length 1254, hash 85E5C878 + buffers[1057] = length 1254, hash 345A172E + buffers[1058] = length 1254, hash 9583B658 + buffers[1059] = length 1254, hash 7ADA8A71 + buffers[1060] = length 1254, hash F72DCE3A + buffers[1061] = length 1253, hash 6C5739AC + buffers[1062] = length 1254, hash 827747 + buffers[1063] = length 1254, hash B7DF169E + buffers[1064] = length 1254, hash A877A239 + buffers[1065] = length 1254, hash 2D400FF5 + buffers[1066] = length 1254, hash 735A41F + buffers[1067] = length 1254, hash 9DB0823C + buffers[1068] = length 1254, hash C5B0F0B6 + buffers[1069] = length 1253, hash ED03174D + buffers[1070] = length 1254, hash 8184726 + buffers[1071] = length 1254, hash 4DF0AF16 + buffers[1072] = length 1254, hash 8A5273F + buffers[1073] = length 1254, hash 33834479 + buffers[1074] = length 1254, hash 30814D2F + buffers[1075] = length 1254, hash D2EAE9F6 + buffers[1076] = length 1254, hash E713632C + buffers[1077] = length 1254, hash 58888862 + buffers[1078] = length 1253, hash 4BCEB174 + buffers[1079] = length 1254, hash 30D39119 + buffers[1080] = length 1254, hash 2521CD6F + buffers[1081] = length 1254, hash 947B4998 + buffers[1082] = length 1254, hash 26B7E150 + buffers[1083] = length 1254, hash 453EC3CE + buffers[1084] = length 1254, hash AE5920E1 + buffers[1085] = length 1254, hash 324027E7 + buffers[1086] = length 1253, hash EEE7C3AB + buffers[1087] = length 1254, hash 3FAF98F4 + buffers[1088] = length 1254, hash 2C1BB020 + buffers[1089] = length 1254, hash B87B5E38 + buffers[1090] = length 1254, hash ADDF8E66 + buffers[1091] = length 1254, hash 71AAAC4D + buffers[1092] = length 1254, hash 26E49E69 + buffers[1093] = length 1254, hash CA4934E4 + buffers[1094] = length 1253, hash E9B91414 + buffers[1095] = length 1254, hash 9A7FF001 + buffers[1096] = length 1254, hash B87653D0 + buffers[1097] = length 1254, hash A3FDE617 + buffers[1098] = length 1254, hash B80C96BF + buffers[1099] = length 1254, hash 8DE99B34 + buffers[1100] = length 1254, hash 7319FBF9 + buffers[1101] = length 1254, hash 620D049 + buffers[1102] = length 1253, hash 9416F9C + buffers[1103] = length 1254, hash 57990257 + buffers[1104] = length 1254, hash 7AAC130E + buffers[1105] = length 1254, hash 4BFD01C6 + buffers[1106] = length 1254, hash 3BA40CD4 + buffers[1107] = length 1254, hash D099B3FC + buffers[1108] = length 1254, hash 9B50A040 + buffers[1109] = length 1254, hash D4A5D59D + buffers[1110] = length 1253, hash BA44A086 + buffers[1111] = length 1254, hash 54EF721A + buffers[1112] = length 1254, hash 2AB6A9F8 + buffers[1113] = length 1254, hash F3E4B893 + buffers[1114] = length 1254, hash 77A4C199 + buffers[1115] = length 1254, hash 9129733C + buffers[1116] = length 1254, hash 11BFF1A0 + buffers[1117] = length 1254, hash 300B0434 + buffers[1118] = length 1253, hash A581DF85 + buffers[1119] = length 1254, hash 21DD1A61 + buffers[1120] = length 1254, hash 53A82BAF + buffers[1121] = length 1254, hash 653634A2 + buffers[1122] = length 1254, hash 125B1752 + buffers[1123] = length 1254, hash EB833B18 + buffers[1124] = length 1254, hash DDBD6CF5 + buffers[1125] = length 1254, hash 4AEA3874 + buffers[1126] = length 1254, hash 8D72AA58 + buffers[1127] = length 1253, hash F58C8EB0 + buffers[1128] = length 1254, hash C53D4133 + buffers[1129] = length 1254, hash 7B1B3C5F + buffers[1130] = length 1254, hash BC94DC99 + buffers[1131] = length 1254, hash B960167C + buffers[1132] = length 1254, hash D4B3A19B + buffers[1133] = length 1254, hash 82474AA6 + buffers[1134] = length 1254, hash 5AD3B110 + buffers[1135] = length 1253, hash 7B869C18 + buffers[1136] = length 1254, hash 10DBB627 + buffers[1137] = length 1254, hash B03DB931 + buffers[1138] = length 1254, hash E704700D + buffers[1139] = length 1254, hash BFC36F5E + buffers[1140] = length 1254, hash 80AD693F + buffers[1141] = length 1254, hash 780278E5 + buffers[1142] = length 1254, hash F1DA25FE + buffers[1143] = length 1253, hash ED5BAB29 + buffers[1144] = length 1254, hash DBCE013E + buffers[1145] = length 1254, hash 29351CA2 + buffers[1146] = length 1254, hash 6A3C9E5 + buffers[1147] = length 1254, hash 3955496B + buffers[1148] = length 1254, hash A05AD3CE + buffers[1149] = length 0, hash 1 +MediaCodec (video/mpeg2): + buffers.length = 898 + buffers[0] = length 5297, hash 3AB32925 + buffers[1] = length 288, hash F11B8CE4 + buffers[2] = length 288, hash 47E6FC24 + buffers[3] = length 288, hash D5A32C64 + buffers[4] = length 288, hash EC068403 + buffers[5] = length 288, hash 79C2B443 + buffers[6] = length 288, hash D08E2383 + buffers[7] = length 288, hash 5E4A53C3 + buffers[8] = length 288, hash 74ADAB62 + buffers[9] = length 288, hash 269DBA2 + buffers[10] = length 288, hash 59354AE2 + buffers[11] = length 288, hash E6F17B22 + buffers[12] = length 5480, hash C52765AB + buffers[13] = length 317, hash 8C0F3556 + buffers[14] = length 5835, hash 76F8CFF0 + buffers[15] = length 5286, hash 22B351EA + buffers[16] = length 5806, hash B246599E + buffers[17] = length 5921, hash B2204106 + buffers[18] = length 386, hash 40D84B43 + buffers[19] = length 6318, hash DF5F2820 + buffers[20] = length 6757, hash 8BC46007 + buffers[21] = length 6846, hash 7ADD1576 + buffers[22] = length 6919, hash 8F1BACD2 + buffers[23] = length 523, hash C8809801 + buffers[24] = length 7377, hash 74782B62 + buffers[25] = length 7078, hash 2F833FFB + buffers[26] = length 11063, hash 6683DB66 + buffers[27] = length 7779, hash 8373DF49 + buffers[28] = length 727, hash 3F7B6D14 + buffers[29] = length 9740, hash FF084014 + buffers[30] = length 7312, hash 8F864595 + buffers[31] = length 8753, hash 7EE3C886 + buffers[32] = length 7900, hash E23C3007 + buffers[33] = length 862, hash D2FAA092 + buffers[34] = length 9480, hash E18BB20E + buffers[35] = length 9399, hash C180ED6B + buffers[36] = length 10410, hash 7B619DBA + buffers[37] = length 9820, hash 1F93F46D + buffers[38] = length 16650, hash A6C7458D + buffers[39] = length 11314, hash 28812EC9 + buffers[40] = length 10056, hash 27A67CCD + buffers[41] = length 11200, hash 83A2ED20 + buffers[42] = length 11932, hash 95A16908 + buffers[43] = length 1264, hash 587D14FA + buffers[44] = length 19282, hash 1E83F041 + buffers[45] = length 7648, hash 113EA144 + buffers[46] = length 10806, hash 28A3C43E + buffers[47] = length 11656, hash 16A9CB04 + buffers[48] = length 1312, hash D8C5744B + buffers[49] = length 12090, hash 4C7CC16E + buffers[50] = length 22200, hash A56A36C8 + buffers[51] = length 11880, hash 27626CC5 + buffers[52] = length 13741, hash AF6377 + buffers[53] = length 1430, hash 7BABC4BC + buffers[54] = length 12960, hash 6768191A + buffers[55] = length 14263, hash 923DEBCE + buffers[56] = length 13551, hash 404DE489 + buffers[57] = length 16048, hash C2C3E7E1 + buffers[58] = length 1811, hash B345D40A + buffers[59] = length 27809, hash E7CA28B9 + buffers[60] = length 8913, hash 4FD0B0AA + buffers[61] = length 12096, hash 7D598C1A + buffers[62] = length 26937, hash 2946257F + buffers[63] = length 3252, hash D295AFF6 + buffers[64] = length 9941, hash 82C0D883 + buffers[65] = length 9689, hash 4ADA40CD + buffers[66] = length 10616, hash 591AB4B2 + buffers[67] = length 9882, hash 2A05E92D + buffers[68] = length 1318, hash 3130680B + buffers[69] = length 9616, hash 2C344B71 + buffers[70] = length 9234, hash F035E59F + buffers[71] = length 9079, hash 873B2492 + buffers[72] = length 8932, hash 64939BEA + buffers[73] = length 1263, hash 6B68BFEF + buffers[74] = length 28352, hash B3CDE15C + buffers[75] = length 8058, hash FC4E6981 + buffers[76] = length 8246, hash 92BFECD8 + buffers[77] = length 8375, hash F4928346 + buffers[78] = length 1075, hash 55B7A1EF + buffers[79] = length 7909, hash CC349F82 + buffers[80] = length 7842, hash 3FF52C71 + buffers[81] = length 8522, hash B8038733 + buffers[82] = length 8391, hash 9218412 + buffers[83] = length 1192, hash 555A38B8 + buffers[84] = length 8469, hash 764B7AE3 + buffers[85] = length 8524, hash A03AE3BD + buffers[86] = length 27712, hash 38B431D5 + buffers[87] = length 9291, hash 9D0E6384 + buffers[88] = length 1199, hash 466ED782 + buffers[89] = length 25616, hash D4E820D6 + buffers[90] = length 7858, hash 764CCD7F + buffers[91] = length 8161, hash 317F3C15 + buffers[92] = length 8817, hash 24004A5F + buffers[93] = length 1147, hash 1025B52D + buffers[94] = length 8237, hash BF0B97E1 + buffers[95] = length 8650, hash 76F65771 + buffers[96] = length 9102, hash D708FB30 + buffers[97] = length 8847, hash BC2B7C9E + buffers[98] = length 28255, hash 89580F70 + buffers[99] = length 8980, hash A7B227B9 + buffers[100] = length 8557, hash A04152D4 + buffers[101] = length 8804, hash E50930D4 + buffers[102] = length 8177, hash A47182C9 + buffers[103] = length 1122, hash 33A95F4 + buffers[104] = length 26652, hash EE450482 + buffers[105] = length 8450, hash 8C1103B3 + buffers[106] = length 8138, hash ED069919 + buffers[107] = length 8800, hash 24E59065 + buffers[108] = length 1217, hash 9C648DD + buffers[109] = length 8638, hash BA750121 + buffers[110] = length 28670, hash E4A8739C + buffers[111] = length 9363, hash 5A324633 + buffers[112] = length 9025, hash F90492B5 + buffers[113] = length 1165, hash 941030F + buffers[114] = length 8478, hash 4F5A245D + buffers[115] = length 8902, hash 71E8F20F + buffers[116] = length 9154, hash 9D5DE4CE + buffers[117] = length 9206, hash C93A090A + buffers[118] = length 1166, hash 104FE5D6 + buffers[119] = length 27368, hash 53DAEC37 + buffers[120] = length 8557, hash 79F095D9 + buffers[121] = length 8462, hash 51385A33 + buffers[122] = length 29303, hash 7130A57E + buffers[123] = length 3710, hash 9E9BADD + buffers[124] = length 9124, hash 8DD83DA0 + buffers[125] = length 8952, hash 6CAFB0EA + buffers[126] = length 8798, hash 4173A50E + buffers[127] = length 8682, hash 3C5D6F82 + buffers[128] = length 1157, hash 9E18E1A0 + buffers[129] = length 9315, hash C295E4BD + buffers[130] = length 9664, hash 896222B9 + buffers[131] = length 9722, hash 5F772113 + buffers[132] = length 9328, hash 4C2ED099 + buffers[133] = length 1197, hash 5FBC2EE + buffers[134] = length 30109, hash 80E64710 + buffers[135] = length 9200, hash 56E57C44 + buffers[136] = length 9418, hash 413A5D18 + buffers[137] = length 9920, hash 888E7E0F + buffers[138] = length 1302, hash 78C4E6A8 + buffers[139] = length 9572, hash 8F6C2800 + buffers[140] = length 9228, hash C3565AD4 + buffers[141] = length 9924, hash 201A4E94 + buffers[142] = length 10018, hash 26C1CBF + buffers[143] = length 1262, hash D0A72EA6 + buffers[144] = length 10269, hash CAE3BFC1 + buffers[145] = length 10146, hash 95D7C470 + buffers[146] = length 29331, hash E58A6E73 + buffers[147] = length 10924, hash B41B018D + buffers[148] = length 1352, hash 8E73C96D + buffers[149] = length 29521, hash 89B7CFF7 + buffers[150] = length 9141, hash C7C9A80F + buffers[151] = length 9625, hash B2660AD1 + buffers[152] = length 9778, hash 22761F43 + buffers[153] = length 1353, hash EB16FF78 + buffers[154] = length 9186, hash 8BD37FE2 + buffers[155] = length 9402, hash 7973B43F + buffers[156] = length 9343, hash 4B1B7877 + buffers[157] = length 9379, hash 6FEFECD8 + buffers[158] = length 28657, hash 391F8D1C + buffers[159] = length 10243, hash A6FC7266 + buffers[160] = length 9430, hash 23DB8493 + buffers[161] = length 9181, hash C62572A1 + buffers[162] = length 9508, hash E23BEF30 + buffers[163] = length 1271, hash FE209091 + buffers[164] = length 28411, hash 53FA0B47 + buffers[165] = length 8859, hash 3824BC20 + buffers[166] = length 8989, hash 548AD63D + buffers[167] = length 8762, hash FF79B89C + buffers[168] = length 1204, hash 49BDF931 + buffers[169] = length 9077, hash 1036A0BE + buffers[170] = length 28555, hash 4D13DCC0 + buffers[171] = length 9521, hash 27C1AD98 + buffers[172] = length 9310, hash DB6822D3 + buffers[173] = length 1212, hash 11C5B6C8 + buffers[174] = length 9257, hash B3E2D3A1 + buffers[175] = length 9123, hash 45AB48BD + buffers[176] = length 8744, hash A94FDE14 + buffers[177] = length 8875, hash 6C576D47 + buffers[178] = length 1178, hash F2B863CA + buffers[179] = length 27458, hash 54FA2B0E + buffers[180] = length 8064, hash F2FB9720 + buffers[181] = length 8174, hash C595C655 + buffers[182] = length 27754, hash 12E2CC6 + buffers[183] = length 3624, hash D4E3867 + buffers[184] = length 8352, hash A74293A6 + buffers[185] = length 8866, hash 3B717CBA + buffers[186] = length 9323, hash E0BF041F + buffers[187] = length 8561, hash 84D5479E + buffers[188] = length 1160, hash 430506C9 + buffers[189] = length 8234, hash E6DD8ECC + buffers[190] = length 8857, hash FCFCACDA + buffers[191] = length 8363, hash 3D9E2B23 + buffers[192] = length 8892, hash 64B56C07 + buffers[193] = length 1192, hash 728C9EB4 + buffers[194] = length 28269, hash B69CA10E + buffers[195] = length 7239, hash D88C0A6A + buffers[196] = length 7814, hash B5C76777 + buffers[197] = length 8066, hash BA6E54D8 + buffers[198] = length 1058, hash 54E1A55E + buffers[199] = length 8022, hash 1513ADC0 + buffers[200] = length 8250, hash 9995D744 + buffers[201] = length 8662, hash B5AE3C78 + buffers[202] = length 8364, hash 2084694 + buffers[203] = length 1097, hash D5B54521 + buffers[204] = length 8730, hash A3E906E2 + buffers[205] = length 7952, hash 6DEA54E2 + buffers[206] = length 27309, hash 582AE044 + buffers[207] = length 9084, hash C8D1FC46 + buffers[208] = length 1258, hash B5BD83BA + buffers[209] = length 26722, hash 589719D1 + buffers[210] = length 7991, hash FEBB07F6 + buffers[211] = length 8167, hash 71FF31A9 + buffers[212] = length 8218, hash 542B67D9 + buffers[213] = length 1084, hash 70A7BD6D + buffers[214] = length 8358, hash 61394491 + buffers[215] = length 9103, hash 436B13DD + buffers[216] = length 9026, hash 2500416A + buffers[217] = length 9401, hash 3BEC4375 + buffers[218] = length 26862, hash 83CB47E1 + buffers[219] = length 9602, hash FB9EA2ED + buffers[220] = length 8812, hash 9059A7C1 + buffers[221] = length 8755, hash AAAE03B2 + buffers[222] = length 8684, hash 779B04F3 + buffers[223] = length 1162, hash 93BFCD65 + buffers[224] = length 27023, hash 3BED888D + buffers[225] = length 7692, hash 195378F8 + buffers[226] = length 8712, hash 3B614487 + buffers[227] = length 9267, hash 7F8390C0 + buffers[228] = length 1290, hash 1BF28155 + buffers[229] = length 9068, hash 7D1C6790 + buffers[230] = length 27099, hash EF5739EF + buffers[231] = length 9988, hash EACDE3E1 + buffers[232] = length 9083, hash C565F65E + buffers[233] = length 1053, hash E28E782B + buffers[234] = length 9034, hash EFD6939E + buffers[235] = length 9381, hash 573DE40E + buffers[236] = length 9534, hash 68F9C133 + buffers[237] = length 8825, hash 4035180C + buffers[238] = length 1063, hash 5C63B6F4 + buffers[239] = length 26791, hash C8CEFB28 + buffers[240] = length 7905, hash 674C15B5 + buffers[241] = length 7769, hash 9ABBF14A + buffers[242] = length 26174, hash 45253D12 + buffers[243] = length 3392, hash AB554053 + buffers[244] = length 8504, hash D0B8573F + buffers[245] = length 7812, hash CFE899B7 + buffers[246] = length 8373, hash E7BF0095 + buffers[247] = length 8766, hash B2BACD09 + buffers[248] = length 1082, hash 2FC23427 + buffers[249] = length 8562, hash AAB1E30E + buffers[250] = length 7987, hash 7D56BF96 + buffers[251] = length 7862, hash 62C8729D + buffers[252] = length 8274, hash C71B418 + buffers[253] = length 24881, hash AA20EAC1 + buffers[254] = length 25533, hash 63BEE2A9 + buffers[255] = length 7246, hash 60D4E9CF + buffers[256] = length 6736, hash F6B0E609 + buffers[257] = length 7886, hash 1DCA4BE2 + buffers[258] = length 8048, hash 87DC99BA + buffers[259] = length 976, hash 2D8105EF + buffers[260] = length 8771, hash BE6CF803 + buffers[261] = length 8430, hash 8CA36576 + buffers[262] = length 8819, hash 1662560A + buffers[263] = length 8675, hash 71C37CDB + buffers[264] = length 1020, hash 7CF92167 + buffers[265] = length 9184, hash 45E33CD1 + buffers[266] = length 26143, hash B5EA9B8F + buffers[267] = length 10234, hash 10CC297B + buffers[268] = length 26531, hash 8B9B5FE5 + buffers[269] = length 2032, hash 80356CBF + buffers[270] = length 7651, hash 3F727FAE + buffers[271] = length 8199, hash 6F2CD15D + buffers[272] = length 9423, hash 168F0BA3 + buffers[273] = length 8699, hash DCC67273 + buffers[274] = length 1083, hash 77A4200D + buffers[275] = length 9000, hash 963624E6 + buffers[276] = length 9544, hash E5EA4F95 + buffers[277] = length 8760, hash E9688248 + buffers[278] = length 26679, hash 5D159E41 + buffers[279] = length 3430, hash EB739A7D + buffers[280] = length 9006, hash 30AAAFD9 + buffers[281] = length 9262, hash 73628742 + buffers[282] = length 9129, hash 31394017 + buffers[283] = length 26342, hash DDEC80AD + buffers[284] = length 2016, hash 99FDACD1 + buffers[285] = length 6645, hash 6D713CEF + buffers[286] = length 7072, hash 7868BE16 + buffers[287] = length 8235, hash 80E47EE1 + buffers[288] = length 8339, hash 4F4E7B26 + buffers[289] = length 1043, hash B7C82A54 + buffers[290] = length 26129, hash 849DC01E + buffers[291] = length 8819, hash 38EB846D + buffers[292] = length 8447, hash 432FAE9B + buffers[293] = length 7910, hash 1E85FF48 + buffers[294] = length 1054, hash 3AA62CEE + buffers[295] = length 8089, hash B2BBAFEE + buffers[296] = length 8857, hash BED226B7 + buffers[297] = length 8715, hash 9665156B + buffers[298] = length 25553, hash 171E4E98 + buffers[299] = length 2032, hash 69BDBC13 + buffers[300] = length 7585, hash A5B0272F + buffers[301] = length 8486, hash 60E651D3 + buffers[302] = length 26058, hash 9D83F704 + buffers[303] = length 9275, hash DBA7945 + buffers[304] = length 1070, hash A906F0D + buffers[305] = length 8108, hash 9306FD79 + buffers[306] = length 7853, hash C2BA2D0C + buffers[307] = length 7606, hash 9CDFADC9 + buffers[308] = length 8112, hash 46F1A19B + buffers[309] = length 1025, hash 2F212033 + buffers[310] = length 7087, hash 6FC13212 + buffers[311] = length 8308, hash 5585D218 + buffers[312] = length 7055, hash E0633CF9 + buffers[313] = length 25567, hash C7F796B8 + buffers[314] = length 26509, hash 48C2AC53 + buffers[315] = length 6899, hash 921712C0 + buffers[316] = length 6377, hash C64D70CB + buffers[317] = length 6589, hash 4144DCEB + buffers[318] = length 6564, hash 4EB4D719 + buffers[319] = length 925, hash 36682ABC + buffers[320] = length 6890, hash 4D91CAF6 + buffers[321] = length 6438, hash A22A67E9 + buffers[322] = length 7324, hash B2472788 + buffers[323] = length 6705, hash 4AF047D2 + buffers[324] = length 933, hash 9F31919C + buffers[325] = length 6627, hash CBC7C644 + buffers[326] = length 26039, hash 5773A4A6 + buffers[327] = length 7917, hash 6CBD8E9E + buffers[328] = length 25282, hash A3779315 + buffers[329] = length 2149, hash E72F53AA + buffers[330] = length 6096, hash 55C7925C + buffers[331] = length 6083, hash 62FB8E16 + buffers[332] = length 7354, hash EDCAA809 + buffers[333] = length 7479, hash C5A984CC + buffers[334] = length 1050, hash 8E58C3D0 + buffers[335] = length 7559, hash 43B8688D + buffers[336] = length 7964, hash 936B797 + buffers[337] = length 6741, hash EDAD37BC + buffers[338] = length 26478, hash 812830F5 + buffers[339] = length 2949, hash 417AC61D + buffers[340] = length 6791, hash 45626765 + buffers[341] = length 7195, hash 3874B6E5 + buffers[342] = length 7478, hash 23E39EEA + buffers[343] = length 25723, hash DE8C7C9B + buffers[344] = length 2247, hash 838B001C + buffers[345] = length 6621, hash 8CC2A247 + buffers[346] = length 6474, hash FF843006 + buffers[347] = length 7599, hash 5F774017 + buffers[348] = length 7691, hash D46427AA + buffers[349] = length 1087, hash 9EA859C3 + buffers[350] = length 27437, hash F1503394 + buffers[351] = length 8293, hash 78E44DA6 + buffers[352] = length 8026, hash DD4D2162 + buffers[353] = length 8134, hash 6CD2959C + buffers[354] = length 1058, hash E8127B13 + buffers[355] = length 8089, hash 9CCD2CA0 + buffers[356] = length 8000, hash 3024801F + buffers[357] = length 8055, hash C772F8C1 + buffers[358] = length 27049, hash 88802D06 + buffers[359] = length 2313, hash 72B5E0C3 + buffers[360] = length 6831, hash 727291BE + buffers[361] = length 7532, hash BC6CCBD7 + buffers[362] = length 28534, hash EC0F98CF + buffers[363] = length 9153, hash CD5A914 + buffers[364] = length 1212, hash F9ADF648 + buffers[365] = length 7916, hash 6C619A02 + buffers[366] = length 8062, hash C0DFDD07 + buffers[367] = length 8034, hash A42B456C + buffers[368] = length 8027, hash 1B39D128 + buffers[369] = length 1146, hash 8BE5E763 + buffers[370] = length 8463, hash CE1CCA46 + buffers[371] = length 8221, hash E5033283 + buffers[372] = length 8811, hash 373C367F + buffers[373] = length 28096, hash 41EF07B5 + buffers[374] = length 29323, hash 5F2D1336 + buffers[375] = length 7912, hash FFFEA92E + buffers[376] = length 6817, hash 8016A04A + buffers[377] = length 8109, hash DD39E67D + buffers[378] = length 8161, hash 115F8E0B + buffers[379] = length 1106, hash 47A8C318 + buffers[380] = length 7691, hash 384E503 + buffers[381] = length 7951, hash D5D403A9 + buffers[382] = length 8101, hash C657DF0B + buffers[383] = length 7702, hash F2E6C3CC + buffers[384] = length 1052, hash CAF65271 + buffers[385] = length 7827, hash 863E88EF + buffers[386] = length 28944, hash 75F27BF7 + buffers[387] = length 8926, hash E4A59D3C + buffers[388] = length 29053, hash 2BA08557 + buffers[389] = length 2337, hash 1B849A5F + buffers[390] = length 6814, hash 49B17233 + buffers[391] = length 6655, hash AE3DBBFB + buffers[392] = length 8252, hash C325EA9D + buffers[393] = length 8585, hash DDF32247 + buffers[394] = length 1140, hash 5F7964EC + buffers[395] = length 7492, hash A2A85C42 + buffers[396] = length 8028, hash 6C8BA499 + buffers[397] = length 8835, hash ACF6DFFC + buffers[398] = length 29956, hash 6E168EBA + buffers[399] = length 3714, hash 16E641FC + buffers[400] = length 8522, hash 511F994D + buffers[401] = length 9007, hash 46EF46EC + buffers[402] = length 8839, hash E7E802F4 + buffers[403] = length 29785, hash B4638B2 + buffers[404] = length 2338, hash 182808F8 + buffers[405] = length 7823, hash E3D7A93 + buffers[406] = length 7486, hash A2C9D45E + buffers[407] = length 8379, hash 6F0BE875 + buffers[408] = length 8276, hash 4759BF8B + buffers[409] = length 1134, hash BF5FA419 + buffers[410] = length 30488, hash 7A366220 + buffers[411] = length 9914, hash B53AD49C + buffers[412] = length 8745, hash 1B6FB80F + buffers[413] = length 8316, hash 4E0AC54B + buffers[414] = length 1038, hash D641465C + buffers[415] = length 8869, hash 1480673 + buffers[416] = length 9162, hash 1E7B087E + buffers[417] = length 9333, hash D6194CC2 + buffers[418] = length 31233, hash A6D8E91F + buffers[419] = length 2515, hash D74296E + buffers[420] = length 7549, hash 4B7345C3 + buffers[421] = length 7833, hash 7A14A6EA + buffers[422] = length 31855, hash 86DF2251 + buffers[423] = length 10079, hash DA202DF1 + buffers[424] = length 1309, hash 7FF1119D + buffers[425] = length 9241, hash 43569D7E + buffers[426] = length 8703, hash 75AB1DBB + buffers[427] = length 9509, hash 7B9CE7D2 + buffers[428] = length 9784, hash 771BCF37 + buffers[429] = length 1266, hash EB7C094C + buffers[430] = length 9697, hash 9A1F3B5D + buffers[431] = length 10070, hash 7F02B031 + buffers[432] = length 10387, hash EB89DBD6 + buffers[433] = length 32550, hash A6A5FDC8 + buffers[434] = length 33286, hash 76F62E42 + buffers[435] = length 9108, hash 467206A4 + buffers[436] = length 8672, hash E86936FA + buffers[437] = length 10000, hash 1497B562 + buffers[438] = length 8856, hash 52226406 + buffers[439] = length 1178, hash A4027CE + buffers[440] = length 9161, hash CE606A2A + buffers[441] = length 9866, hash 5A76BC38 + buffers[442] = length 9210, hash 58138847 + buffers[443] = length 9343, hash DD10D4F + buffers[444] = length 1214, hash 64629F67 + buffers[445] = length 9634, hash F30620E0 + buffers[446] = length 32459, hash 66536C86 + buffers[447] = length 10745, hash F1C86535 + buffers[448] = length 32885, hash 7F444F46 + buffers[449] = length 2587, hash F12C4342 + buffers[450] = length 8235, hash 5C2286B4 + buffers[451] = length 8007, hash 26A0FB61 + buffers[452] = length 9504, hash 59D8077F + buffers[453] = length 9842, hash B8F6B23D + buffers[454] = length 1346, hash C76AD456 + buffers[455] = length 9926, hash 3F7692EB + buffers[456] = length 10176, hash 78EB9EAB + buffers[457] = length 9402, hash 88FC937D + buffers[458] = length 32832, hash 46199B52 + buffers[459] = length 4201, hash 5AC3D75F + buffers[460] = length 9806, hash 100AB356 + buffers[461] = length 9598, hash F58966A4 + buffers[462] = length 10833, hash BA62F087 + buffers[463] = length 33491, hash 872E42F + buffers[464] = length 2793, hash 8175E78A + buffers[465] = length 8347, hash B088E236 + buffers[466] = length 8709, hash A5668CBD + buffers[467] = length 10256, hash B29B16CA + buffers[468] = length 9345, hash B42F6013 + buffers[469] = length 1318, hash BCC3A5A + buffers[470] = length 32954, hash 3685A8B + buffers[471] = length 10071, hash 60910EC4 + buffers[472] = length 9890, hash CDD40904 + buffers[473] = length 9592, hash 77AB4192 + buffers[474] = length 1258, hash 6DE60F7 + buffers[475] = length 9397, hash FF50D407 + buffers[476] = length 9913, hash 9C448EEF + buffers[477] = length 9900, hash B85C0AA7 + buffers[478] = length 32723, hash ABA92D41 + buffers[479] = length 2680, hash 461E2B59 + buffers[480] = length 7963, hash A52704C1 + buffers[481] = length 7724, hash 26E89374 + buffers[482] = length 32563, hash C0D5C47C + buffers[483] = length 10681, hash CB84B81E + buffers[484] = length 1344, hash 7940B2C7 + buffers[485] = length 9004, hash FBFC1297 + buffers[486] = length 8955, hash C438F668 + buffers[487] = length 9502, hash A3CD74C7 + buffers[488] = length 9755, hash 19C11214 + buffers[489] = length 1184, hash 2AB4FDDE + buffers[490] = length 9499, hash 72549E94 + buffers[491] = length 9549, hash A7D0EB18 + buffers[492] = length 9465, hash 494A4A3B + buffers[493] = length 32339, hash 55781B62 + buffers[494] = length 33350, hash 58FE7DB1 + buffers[495] = length 9366, hash EC3CDB39 + buffers[496] = length 7613, hash 20ADEF4C + buffers[497] = length 9117, hash C0A43B31 + buffers[498] = length 9893, hash 89879CC2 + buffers[499] = length 1240, hash 5269BE58 + buffers[500] = length 9640, hash 708BB6A2 + buffers[501] = length 9698, hash E68F180C + buffers[502] = length 8954, hash 1271D463 + buffers[503] = length 9627, hash 55A76694 + buffers[504] = length 9679, hash 59DAFDA9 + buffers[505] = length 1256, hash 60BF33CF + buffers[506] = length 32146, hash 6675C38E + buffers[507] = length 10474, hash CDB3A712 + buffers[508] = length 31988, hash D42EDC9E + buffers[509] = length 8710, hash 535FA56F + buffers[510] = length 1297, hash E629D403 + buffers[511] = length 8426, hash C99ECAC0 + buffers[512] = length 8448, hash C2D45B0C + buffers[513] = length 9263, hash E88002A6 + buffers[514] = length 9844, hash 555586A5 + buffers[515] = length 1140, hash 8DAA4BD6 + buffers[516] = length 9155, hash A9B01276 + buffers[517] = length 8858, hash E4AF7586 + buffers[518] = length 30343, hash 408B6B71 + buffers[519] = length 9798, hash EB4E42D1 + buffers[520] = length 1252, hash DE6A8AC1 + buffers[521] = length 8749, hash A2E473F7 + buffers[522] = length 8805, hash 799A9641 + buffers[523] = length 29127, hash 7A975021 + buffers[524] = length 7698, hash 319BF80E + buffers[525] = length 1225, hash E252D6F0 + buffers[526] = length 6624, hash F5768772 + buffers[527] = length 8008, hash F332BA4C + buffers[528] = length 8114, hash 1C33B164 + buffers[529] = length 8356, hash A3E1DD9A + buffers[530] = length 28777, hash AB2BA0C5 + buffers[531] = length 8778, hash 93E8E858 + buffers[532] = length 7728, hash 98223FEB + buffers[533] = length 7918, hash 35FC07F4 + buffers[534] = length 1941, hash 15610F63 + buffers[535] = length 554, hash EFD4F458 + buffers[536] = length 9282, hash 5C10F76A + buffers[537] = length 7787, hash 620A2FDF + buffers[538] = length 26634, hash 1C647526 + buffers[539] = length 7812, hash FCF65839 + buffers[540] = length 1097, hash 653D726E + buffers[541] = length 7250, hash E452B304 + buffers[542] = length 27020, hash 2902DA5D + buffers[543] = length 8409, hash C036A047 + buffers[544] = length 7229, hash EE7604F0 + buffers[545] = length 965, hash B798D64A + buffers[546] = length 7637, hash 5553C0DB + buffers[547] = length 7148, hash 524D87B9 + buffers[548] = length 7121, hash EC6907C + buffers[549] = length 7166, hash 81126AEF + buffers[550] = length 950, hash F7058CDD + buffers[551] = length 7289, hash 2FAB9F1C + buffers[552] = length 7240, hash E90180BE + buffers[553] = length 23626, hash A120BCA3 + buffers[554] = length 24793, hash 382BC417 + buffers[555] = length 3278, hash 6B309F74 + buffers[556] = length 5339, hash 605C4E9 + buffers[557] = length 5990, hash DDD22450 + buffers[558] = length 6271, hash C9058779 + buffers[559] = length 6033, hash 7E2151A6 + buffers[560] = length 849, hash 85D5B9D4 + buffers[561] = length 6489, hash 37103053 + buffers[562] = length 6579, hash 92142B7C + buffers[563] = length 6238, hash E1916239 + buffers[564] = length 6635, hash 8DD838F6 + buffers[565] = length 905, hash 86847412 + buffers[566] = length 24047, hash ACB35D82 + buffers[567] = length 7643, hash FEDE2141 + buffers[568] = length 22466, hash DEBA0ED0 + buffers[569] = length 5986, hash A81783A1 + buffers[570] = length 1051, hash E37A824 + buffers[571] = length 5319, hash D1B91008 + buffers[572] = length 6181, hash 3BFFA98F + buffers[573] = length 6294, hash 4A55328E + buffers[574] = length 6309, hash DFC4173 + buffers[575] = length 1011, hash 4D07B542 + buffers[576] = length 6725, hash 6EF27DD3 + buffers[577] = length 6393, hash 99861CD2 + buffers[578] = length 23303, hash A4C71430 + buffers[579] = length 6926, hash 2F1E346 + buffers[580] = length 883, hash 576915C9 + buffers[581] = length 6345, hash 76D7CBB8 + buffers[582] = length 5751, hash 67C2C828 + buffers[583] = length 21247, hash 91570CF9 + buffers[584] = length 5358, hash 17421630 + buffers[585] = length 982, hash 2EEF9817 + buffers[586] = length 4581, hash D24C6D0D + buffers[587] = length 5552, hash 11CDBB45 + buffers[588] = length 6302, hash 2A6596BE + buffers[589] = length 6185, hash 191366F8 + buffers[590] = length 22497, hash 5066F448 + buffers[591] = length 6245, hash 97A34680 + buffers[592] = length 5670, hash 96768A75 + buffers[593] = length 6054, hash 4C47BD6B + buffers[594] = length 5877, hash 4968106D + buffers[595] = length 901, hash BB75D3E8 + buffers[596] = length 5863, hash 93BCCF41 + buffers[597] = length 6226, hash 91E8DCA1 + buffers[598] = length 20255, hash E1DC9786 + buffers[599] = length 5301, hash 8343C02 + buffers[600] = length 910, hash 1246AE71 + buffers[601] = length 4882, hash 67276DFB + buffers[602] = length 21767, hash A89AF545 + buffers[603] = length 6687, hash D5E1F425 + buffers[604] = length 5507, hash F37117B3 + buffers[605] = length 882, hash E2D8ACDF + buffers[606] = length 5718, hash 5631F44 + buffers[607] = length 5364, hash 66F43385 + buffers[608] = length 5915, hash 7F4C065B + buffers[609] = length 6310, hash 1071C4DC + buffers[610] = length 877, hash F2C2602B + buffers[611] = length 5880, hash 2C6FD0ED + buffers[612] = length 6032, hash 11540755 + buffers[613] = length 19063, hash 8B8EB889 + buffers[614] = length 20396, hash D4042415 + buffers[615] = length 2668, hash 8364E788 + buffers[616] = length 4093, hash 1CA3F681 + buffers[617] = length 4906, hash 4E941655 + buffers[618] = length 5228, hash B4910AFF + buffers[619] = length 5033, hash 980BE58 + buffers[620] = length 826, hash 6625A3DB + buffers[621] = length 5325, hash FB2CE21B + buffers[622] = length 4919, hash 618F48B5 + buffers[623] = length 5835, hash 269D9F4D + buffers[624] = length 4927, hash 3B1D5464 + buffers[625] = length 725, hash 5E017E4B + buffers[626] = length 19492, hash E8CD3D36 + buffers[627] = length 5755, hash D4DF17A6 + buffers[628] = length 17463, hash 6168DA1F + buffers[629] = length 4171, hash B5E437CD + buffers[630] = length 876, hash E6DA7FF8 + buffers[631] = length 3522, hash 529447A + buffers[632] = length 4030, hash 867466B8 + buffers[633] = length 4525, hash F47A0AE4 + buffers[634] = length 5323, hash 16943FBB + buffers[635] = length 750, hash 63A42064 + buffers[636] = length 4833, hash 112EB11B + buffers[637] = length 4149, hash 627A0E6A + buffers[638] = length 18458, hash D968FA38 + buffers[639] = length 5385, hash 9823BF51 + buffers[640] = length 777, hash BD4D0B94 + buffers[641] = length 4490, hash 96932E91 + buffers[642] = length 4499, hash 2C3C805F + buffers[643] = length 15933, hash FB516101 + buffers[644] = length 3655, hash 4654F3F4 + buffers[645] = length 644, hash FEAEBE4A + buffers[646] = length 3346, hash B1BD770F + buffers[647] = length 4526, hash D4433E95 + buffers[648] = length 4184, hash 29C56888 + buffers[649] = length 4653, hash B1D57660 + buffers[650] = length 17941, hash 8515301C + buffers[651] = length 5331, hash 8CF6AEF3 + buffers[652] = length 4293, hash 958D6A64 + buffers[653] = length 4017, hash A2632808 + buffers[654] = length 3796, hash 128F8039 + buffers[655] = length 628, hash E13821D2 + buffers[656] = length 4507, hash 24458724 + buffers[657] = length 4782, hash 8396C91D + buffers[658] = length 14854, hash 1B123679 + buffers[659] = length 3759, hash 8EB16AF0 + buffers[660] = length 656, hash 7500F06E + buffers[661] = length 2601, hash EE0259AF + buffers[662] = length 16788, hash 194A3ECA + buffers[663] = length 4029, hash 42B4DACF + buffers[664] = length 4149, hash F8A77576 + buffers[665] = length 616, hash F539846A + buffers[666] = length 4393, hash 8A44B216 + buffers[667] = length 3846, hash E58238FB + buffers[668] = length 3457, hash 8241F05C + buffers[669] = length 3785, hash ED5EAABE + buffers[670] = length 621, hash 1A8C9892 + buffers[671] = length 3836, hash EDAEB321 + buffers[672] = length 3674, hash BED5B2D4 + buffers[673] = length 13385, hash FCC77489 + buffers[674] = length 15872, hash 8B425A86 + buffers[675] = length 1726, hash D647FB9C + buffers[676] = length 2412, hash 7A93020 + buffers[677] = length 2891, hash D01A3311 + buffers[678] = length 3188, hash 19D59772 + buffers[679] = length 3536, hash 1B836AEB + buffers[680] = length 568, hash EF4797F6 + buffers[681] = length 3238, hash D9D3201C + buffers[682] = length 3137, hash CA12009 + buffers[683] = length 3505, hash 6CC093B0 + buffers[684] = length 3112, hash CE488E3 + buffers[685] = length 580, hash C66EAC23 + buffers[686] = length 15078, hash 50318AFA + buffers[687] = length 3755, hash 8FD71989 + buffers[688] = length 12101, hash D790C6A2 + buffers[689] = length 2664, hash D2D4D348 + buffers[690] = length 518, hash 4536C477 + buffers[691] = length 2057, hash 72147F97 + buffers[692] = length 2650, hash C400C9BB + buffers[693] = length 2767, hash 51EC6ED9 + buffers[694] = length 3123, hash DFB490FB + buffers[695] = length 521, hash BE7261D0 + buffers[696] = length 3188, hash 87B2329D + buffers[697] = length 2996, hash 3CAAC12C + buffers[698] = length 14535, hash 50D7946B + buffers[699] = length 3136, hash 17C5B969 + buffers[700] = length 575, hash E420E5E4 + buffers[701] = length 3213, hash 824554BF + buffers[702] = length 3308, hash C30ACB49 + buffers[703] = length 11193, hash 22C0EB43 + buffers[704] = length 2529, hash F047832C + buffers[705] = length 555, hash 4BBA08EE + buffers[706] = length 2559, hash B0931A08 + buffers[707] = length 3193, hash D9BA8288 + buffers[708] = length 2510, hash CB3E3D2 + buffers[709] = length 2998, hash 2A85362 + buffers[710] = length 14250, hash 7A22987E + buffers[711] = length 3680, hash C4B902A8 + buffers[712] = length 3250, hash 263912B9 + buffers[713] = length 2879, hash 60656973 + buffers[714] = length 2628, hash EB9BDF3B + buffers[715] = length 503, hash C4EFCF0D + buffers[716] = length 2262, hash 37ED957 + buffers[717] = length 2694, hash AC853166 + buffers[718] = length 10807, hash 5D3C8118 + buffers[719] = length 2426, hash 47EE66E + buffers[720] = length 532, hash 4023B622 + buffers[721] = length 1874, hash 81B6FF92 + buffers[722] = length 14043, hash B7A4BBDC + buffers[723] = length 2951, hash E01DAD80 + buffers[724] = length 2618, hash 332AF0BF + buffers[725] = length 481, hash 202C017 + buffers[726] = length 2277, hash 92CE20C5 + buffers[727] = length 2196, hash 2FB3A07C + buffers[728] = length 2271, hash F6D48C7F + buffers[729] = length 2361, hash BDD01BFA + buffers[730] = length 433, hash 23444DFA + buffers[731] = length 2346, hash 555CFAFE + buffers[732] = length 2591, hash 79378729 + buffers[733] = length 10890, hash C5C9F762 + buffers[734] = length 13927, hash 3881CA1B + buffers[735] = length 1392, hash B1104EBB + buffers[736] = length 2988, hash 4ADB2486 + buffers[737] = length 2907, hash FCB5F9EC + buffers[738] = length 2860, hash AD64C453 + buffers[739] = length 5041, hash 4BBCD435 + buffers[740] = length 672, hash 80660C1 + buffers[741] = length 6407, hash A1B5188 + buffers[742] = length 9847, hash 4257DF0E + buffers[743] = length 12092, hash 7D8B3C48 + buffers[744] = length 11035, hash F3F3254A + buffers[745] = length 1246, hash DED3ACE6 + buffers[746] = length 16465, hash 9EA59325 + buffers[747] = length 7861, hash D9820CB5 + buffers[748] = length 12674, hash F4F09AF7 + buffers[749] = length 6390, hash 48EE048E + buffers[750] = length 6879, hash 627F3518 + buffers[751] = length 799, hash C2F9B0CE + buffers[752] = length 7922, hash 4B0CF7FB + buffers[753] = length 8557, hash 12961613 + buffers[754] = length 8085, hash 9F9C3687 + buffers[755] = length 7135, hash B9D97D3A + buffers[756] = length 874, hash 30527FCA + buffers[757] = length 5405, hash 40B208BC + buffers[758] = length 18428, hash 77484263 + buffers[759] = length 7476, hash C62E44B3 + buffers[760] = length 6705, hash 8A3AF613 + buffers[761] = length 896, hash 7C4668FF + buffers[762] = length 5848, hash CB7AA73C + buffers[763] = length 13023, hash A60794BD + buffers[764] = length 3284, hash 7D88288C + buffers[765] = length 2225, hash E231E8FA + buffers[766] = length 448, hash 550866DA + buffers[767] = length 1897, hash 4FC30FF8 + buffers[768] = length 1326, hash A44E0AFB + buffers[769] = length 989, hash BE490FFC + buffers[770] = length 12639, hash ABDEB9F + buffers[771] = length 1182, hash D0DC9487 + buffers[772] = length 15568, hash 553041BE + buffers[773] = length 5866, hash 1FC4912E + buffers[774] = length 8433, hash 21C171CE + buffers[775] = length 10391, hash 85DCB46 + buffers[776] = length 1129, hash D5D63985 + buffers[777] = length 12279, hash 6F1C01F3 + buffers[778] = length 12930, hash FF9B04D7 + buffers[779] = length 14232, hash 83F2554A + buffers[780] = length 12744, hash A87D2A19 + buffers[781] = length 1188, hash F10EEFD + buffers[782] = length 9627, hash 2A13E462 + buffers[783] = length 8090, hash 84CF4DB9 + buffers[784] = length 22152, hash 8303AC71 + buffers[785] = length 5872, hash B6E21656 + buffers[786] = length 878, hash C3AA34F2 + buffers[787] = length 19545, hash F1F49A69 + buffers[788] = length 5419, hash 90712BE9 + buffers[789] = length 4929, hash 4E60B2D5 + buffers[790] = length 4097, hash C41BB1BD + buffers[791] = length 708, hash 9E23E9E6 + buffers[792] = length 4869, hash EC601B84 + buffers[793] = length 5074, hash 79B672B3 + buffers[794] = length 4979, hash AB39A93D + buffers[795] = length 5200, hash 5E9F5EA3 + buffers[796] = length 20560, hash D840714 + buffers[797] = length 5425, hash 68377194 + buffers[798] = length 4817, hash 910A8619 + buffers[799] = length 5862, hash A8680CC0 + buffers[800] = length 8708, hash 2785E12C + buffers[801] = length 1016, hash 1152F5F6 + buffers[802] = length 17064, hash FB9434AA + buffers[803] = length 9648, hash 2057A36 + buffers[804] = length 11257, hash B7C6CA5F + buffers[805] = length 11668, hash C9DF0B51 + buffers[806] = length 1188, hash A3061EB5 + buffers[807] = length 10137, hash 3C98B3E6 + buffers[808] = length 22684, hash 60C6B27 + buffers[809] = length 8746, hash 3AD496AD + buffers[810] = length 6472, hash FAC8417F + buffers[811] = length 977, hash C287A939 + buffers[812] = length 4232, hash 62021C8E + buffers[813] = length 3968, hash 34A8FA11 + buffers[814] = length 3537, hash 4C6A8F71 + buffers[815] = length 35928, hash 6A884CB7 + buffers[816] = length 4771, hash 3E9A0D99 + buffers[817] = length 4122, hash 8A1C2159 + buffers[818] = length 6368, hash FE5BFE7B + buffers[819] = length 9987, hash 27ACCF44 + buffers[820] = length 37137, hash 19B134DB + buffers[821] = length 5171, hash 21B38E26 + buffers[822] = length 20403, hash C91985AF + buffers[823] = length 22850, hash 43107714 + buffers[824] = length 19428, hash F10DCD1F + buffers[825] = length 15824, hash F6F5FCBE + buffers[826] = length 1746, hash 1ED7087F + buffers[827] = length 18982, hash 8E992B24 + buffers[828] = length 21743, hash BBCF6B0E + buffers[829] = length 21160, hash ED4EA796 + buffers[830] = length 40509, hash B7FB1478 + buffers[831] = length 3274, hash E821A4AD + buffers[832] = length 41739, hash BF81F27D + buffers[833] = length 26284, hash 55503050 + buffers[834] = length 26360, hash 7911994B + buffers[835] = length 23496, hash C19029D0 + buffers[836] = length 2039, hash BD698058 + buffers[837] = length 23525, hash C10BDB17 + buffers[838] = length 31170, hash 3BAF3732 + buffers[839] = length 41390, hash 144C667D + buffers[840] = length 34529, hash A4B57349 + buffers[841] = length 3286, hash 4C77F25B + buffers[842] = length 29125, hash B3FC36E5 + buffers[843] = length 33734, hash D05DAEB5 + buffers[844] = length 43753, hash 568C7596 + buffers[845] = length 37779, hash 4D85997F + buffers[846] = length 2714, hash 729C17D4 + buffers[847] = length 26925, hash 9B7C9235 + buffers[848] = length 26755, hash A18A414A + buffers[849] = length 30858, hash 74452A61 + buffers[850] = length 37367, hash 95300E2E + buffers[851] = length 3307, hash DE99C4BD + buffers[852] = length 34430, hash BEE7C446 + buffers[853] = length 28166, hash 512F6D2C + buffers[854] = length 30189, hash 46E2C6F0 + buffers[855] = length 38230, hash 85A1B77 + buffers[856] = length 40725, hash 3FC54AE3 + buffers[857] = length 43846, hash 2824466C + buffers[858] = length 38516, hash 7BE39D9 + buffers[859] = length 41603, hash 3E06B3D0 + buffers[860] = length 44469, hash 6B1995EE + buffers[861] = length 3429, hash D663341 + buffers[862] = length 35309, hash B346CFE7 + buffers[863] = length 42375, hash 617F44D8 + buffers[864] = length 49631, hash 9FA16803 + buffers[865] = length 72560, hash 893A12A7 + buffers[866] = length 7301, hash 29C7D375 + buffers[867] = length 44158, hash 33134B36 + buffers[868] = length 66919, hash 2B0284B + buffers[869] = length 45233, hash 4EEECDED + buffers[870] = length 44618, hash E7907176 + buffers[871] = length 3755, hash 118DBFE7 + buffers[872] = length 42838, hash 49430A62 + buffers[873] = length 42010, hash 65580A93 + buffers[874] = length 41744, hash 2259D65F + buffers[875] = length 43368, hash 1A365B00 + buffers[876] = length 3780, hash F7B07177 + buffers[877] = length 46234, hash EBB5ABBD + buffers[878] = length 51580, hash 598FBA60 + buffers[879] = length 51788, hash 7835C40B + buffers[880] = length 62430, hash B0E633CC + buffers[881] = length 9494, hash E6EE616E + buffers[882] = length 61690, hash D9439B6D + buffers[883] = length 60261, hash 97152898 + buffers[884] = length 62684, hash F2866690 + buffers[885] = length 59080, hash 4FA6BF2C + buffers[886] = length 5689, hash 644D3029 + buffers[887] = length 61734, hash 4993BA45 + buffers[888] = length 64540, hash 3027DEB + buffers[889] = length 63808, hash 571CA446 + buffers[890] = length 63711, hash 72B59574 + buffers[891] = length 5677, hash 23F760EA + buffers[892] = length 70361, hash FB0A3F64 + buffers[893] = length 67921, hash DE21D7F1 + buffers[894] = length 71948, hash 6EEB68B5 + buffers[895] = length 80310, hash 9109E8A8 + buffers[896] = length 6374, hash 202E99CF + buffers[897] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample.ac3.dump b/testdata/src/test/assets/playbackdumps/ts/sample.ac3.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample.ac4.dump b/testdata/src/test/assets/playbackdumps/ts/sample.ac4.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample.adts.dump b/testdata/src/test/assets/playbackdumps/ts/sample.adts.dump new file mode 100644 index 00000000000..af1577d5b06 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample.adts.dump @@ -0,0 +1,147 @@ +MediaCodec (audio/mp4a-latm): + buffers.length = 145 + buffers[0] = length 23, hash 47DE9131 + buffers[1] = length 6, hash 31CF3A46 + buffers[2] = length 6, hash 31CF3A46 + buffers[3] = length 6, hash 31CF3A46 + buffers[4] = length 6, hash 31EC5206 + buffers[5] = length 171, hash 4F6478F6 + buffers[6] = length 202, hash AF4068A3 + buffers[7] = length 210, hash E4C10618 + buffers[8] = length 217, hash 9ECCD0D9 + buffers[9] = length 212, hash 6BAC2CD9 + buffers[10] = length 223, hash 188B6010 + buffers[11] = length 222, hash C1A04D0C + buffers[12] = length 220, hash D65F9768 + buffers[13] = length 227, hash B96C9E14 + buffers[14] = length 229, hash 9FB09972 + buffers[15] = length 220, hash 2271F053 + buffers[16] = length 226, hash 5EDD2F4F + buffers[17] = length 239, hash 957510E0 + buffers[18] = length 224, hash 718A8F47 + buffers[19] = length 225, hash 5E11E293 + buffers[20] = length 227, hash FCE50D27 + buffers[21] = length 212, hash 77908C40 + buffers[22] = length 227, hash 34C4EB32 + buffers[23] = length 231, hash 95488307 + buffers[24] = length 226, hash 97F12D6F + buffers[25] = length 236, hash 91A9D9A2 + buffers[26] = length 227, hash 27A608F9 + buffers[27] = length 229, hash 57DAAE4 + buffers[28] = length 235, hash ED30AC34 + buffers[29] = length 227, hash BD3D6280 + buffers[30] = length 233, hash 694B1087 + buffers[31] = length 232, hash 1EDFE047 + buffers[32] = length 228, hash E2A831F4 + buffers[33] = length 231, hash 757E6012 + buffers[34] = length 223, hash 4003D791 + buffers[35] = length 232, hash 3CF9A07C + buffers[36] = length 228, hash 25AC3FF7 + buffers[37] = length 220, hash 2C1824CE + buffers[38] = length 229, hash 46FDD8FB + buffers[39] = length 237, hash F6988018 + buffers[40] = length 242, hash 60436B6B + buffers[41] = length 275, hash 90EDFA8E + buffers[42] = length 242, hash 5C86EFCB + buffers[43] = length 233, hash E0A51B82 + buffers[44] = length 235, hash 590DF14F + buffers[45] = length 238, hash 69AF4E6E + buffers[46] = length 235, hash E745AE8D + buffers[47] = length 223, hash 295F2A13 + buffers[48] = length 228, hash E2F47B21 + buffers[49] = length 229, hash 262C3CFE + buffers[50] = length 232, hash 4B5BF5E8 + buffers[51] = length 233, hash F3D80836 + buffers[52] = length 237, hash 32E0A11E + buffers[53] = length 228, hash E1B89F13 + buffers[54] = length 237, hash 8BDD9E38 + buffers[55] = length 235, hash 3C84161F + buffers[56] = length 227, hash A47E1789 + buffers[57] = length 228, hash 869FDFD3 + buffers[58] = length 233, hash 272ECE2 + buffers[59] = length 227, hash DB6B9618 + buffers[60] = length 212, hash 63214325 + buffers[61] = length 221, hash 9BA588A1 + buffers[62] = length 225, hash 21EFD50C + buffers[63] = length 231, hash F3AD0BF + buffers[64] = length 224, hash 822C9210 + buffers[65] = length 195, hash D4EF53EE + buffers[66] = length 195, hash A816647A + buffers[67] = length 184, hash 9A2B7E6 + buffers[68] = length 210, hash 956E3600 + buffers[69] = length 234, hash 35CFDA0A + buffers[70] = length 239, hash 9E15AC1E + buffers[71] = length 228, hash F3B70641 + buffers[72] = length 237, hash 124E3194 + buffers[73] = length 231, hash 950CD7C8 + buffers[74] = length 236, hash A12E49AF + buffers[75] = length 242, hash 43BC9C24 + buffers[76] = length 241, hash DCF0B17 + buffers[77] = length 251, hash C0B99968 + buffers[78] = length 245, hash 9B38ED1C + buffers[79] = length 238, hash 1BA69079 + buffers[80] = length 233, hash 44C8C6BF + buffers[81] = length 231, hash EABBEE02 + buffers[82] = length 226, hash D09C44FB + buffers[83] = length 235, hash BE6A6608 + buffers[84] = length 235, hash 2735F454 + buffers[85] = length 238, hash B160DFE7 + buffers[86] = length 232, hash 1B217D2E + buffers[87] = length 251, hash D1C14CEA + buffers[88] = length 256, hash 97C87F08 + buffers[89] = length 237, hash 6645DB3 + buffers[90] = length 235, hash 727A1C82 + buffers[91] = length 234, hash 5015F8B5 + buffers[92] = length 241, hash 9102144B + buffers[93] = length 224, hash 64E0D807 + buffers[94] = length 228, hash 1922B852 + buffers[95] = length 224, hash 953502D8 + buffers[96] = length 214, hash 92B87FE7 + buffers[97] = length 213, hash BB0C8D86 + buffers[98] = length 206, hash 9AD21017 + buffers[99] = length 209, hash C479FE94 + buffers[100] = length 220, hash 3033DCE1 + buffers[101] = length 217, hash 7D589C94 + buffers[102] = length 216, hash AAF6C183 + buffers[103] = length 206, hash 1EE1207F + buffers[104] = length 204, hash 4BEB1210 + buffers[105] = length 213, hash 21A841C9 + buffers[106] = length 207, hash B80B0424 + buffers[107] = length 212, hash 4785A1C3 + buffers[108] = length 205, hash 59BF7229 + buffers[109] = length 208, hash FA313DDE + buffers[110] = length 211, hash 190D85FD + buffers[111] = length 211, hash BA050052 + buffers[112] = length 211, hash F3080F10 + buffers[113] = length 210, hash F41B7BE7 + buffers[114] = length 207, hash 2176C97E + buffers[115] = length 220, hash 32087455 + buffers[116] = length 213, hash 4E5649A8 + buffers[117] = length 213, hash 5F12FDCF + buffers[118] = length 204, hash 1E895C2A + buffers[119] = length 219, hash 45382270 + buffers[120] = length 205, hash D66C6A1D + buffers[121] = length 204, hash 467AD01F + buffers[122] = length 211, hash F0435574 + buffers[123] = length 206, hash 8C96B75F + buffers[124] = length 200, hash 82553248 + buffers[125] = length 180, hash 1E51E6CE + buffers[126] = length 196, hash 33151DC4 + buffers[127] = length 197, hash 1E62A7D6 + buffers[128] = length 206, hash 6A6C4CC9 + buffers[129] = length 209, hash A72FABAA + buffers[130] = length 217, hash BA33B985 + buffers[131] = length 235, hash 9919CFD9 + buffers[132] = length 236, hash A22C7267 + buffers[133] = length 213, hash 3D57C901 + buffers[134] = length 205, hash 47F68FDE + buffers[135] = length 210, hash 9A756E9C + buffers[136] = length 210, hash BD45C31F + buffers[137] = length 207, hash 8774FF7B + buffers[138] = length 149, hash 4678C0E5 + buffers[139] = length 161, hash E991035D + buffers[140] = length 197, hash C3013689 + buffers[141] = length 208, hash E6C0237 + buffers[142] = length 232, hash A330F188 + buffers[143] = length 174, hash 2B69C34E + buffers[144] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample.eac3.dump b/testdata/src/test/assets/playbackdumps/ts/sample.eac3.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_ac3.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_ac3.ts.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_ac4.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_ac4.ts.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_ait.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_ait.ts.dump new file mode 100644 index 00000000000..e56ce241125 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_ait.ts.dump @@ -0,0 +1,10 @@ +MetadataOutput: + Metadata[0]: + entry[0] = AppInfoTable + entry[1] = AppInfoTable + Metadata[1]: + entry[0] = AppInfoTable + entry[1] = AppInfoTable + Metadata[2]: + entry[0] = AppInfoTable + entry[1] = AppInfoTable diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_cbs_truncated.adts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_cbs_truncated.adts.dump new file mode 100644 index 00000000000..a55133f482c --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_cbs_truncated.adts.dump @@ -0,0 +1,146 @@ +MediaCodec (audio/mp4a-latm): + buffers.length = 144 + buffers[0] = length 23, hash 47DE9131 + buffers[1] = length 6, hash 31CF3A46 + buffers[2] = length 6, hash 31CF3A46 + buffers[3] = length 6, hash 31CF3A46 + buffers[4] = length 6, hash 31EC5206 + buffers[5] = length 171, hash 4F6478F6 + buffers[6] = length 202, hash AF4068A3 + buffers[7] = length 210, hash E4C10618 + buffers[8] = length 217, hash 9ECCD0D9 + buffers[9] = length 212, hash 6BAC2CD9 + buffers[10] = length 223, hash 188B6010 + buffers[11] = length 222, hash C1A04D0C + buffers[12] = length 220, hash D65F9768 + buffers[13] = length 227, hash B96C9E14 + buffers[14] = length 229, hash 9FB09972 + buffers[15] = length 220, hash 2271F053 + buffers[16] = length 226, hash 5EDD2F4F + buffers[17] = length 239, hash 957510E0 + buffers[18] = length 224, hash 718A8F47 + buffers[19] = length 225, hash 5E11E293 + buffers[20] = length 227, hash FCE50D27 + buffers[21] = length 212, hash 77908C40 + buffers[22] = length 227, hash 34C4EB32 + buffers[23] = length 231, hash 95488307 + buffers[24] = length 226, hash 97F12D6F + buffers[25] = length 236, hash 91A9D9A2 + buffers[26] = length 227, hash 27A608F9 + buffers[27] = length 229, hash 57DAAE4 + buffers[28] = length 235, hash ED30AC34 + buffers[29] = length 227, hash BD3D6280 + buffers[30] = length 233, hash 694B1087 + buffers[31] = length 232, hash 1EDFE047 + buffers[32] = length 228, hash E2A831F4 + buffers[33] = length 231, hash 757E6012 + buffers[34] = length 223, hash 4003D791 + buffers[35] = length 232, hash 3CF9A07C + buffers[36] = length 228, hash 25AC3FF7 + buffers[37] = length 220, hash 2C1824CE + buffers[38] = length 229, hash 46FDD8FB + buffers[39] = length 237, hash F6988018 + buffers[40] = length 242, hash 60436B6B + buffers[41] = length 275, hash 90EDFA8E + buffers[42] = length 242, hash 5C86EFCB + buffers[43] = length 233, hash E0A51B82 + buffers[44] = length 235, hash 590DF14F + buffers[45] = length 238, hash 69AF4E6E + buffers[46] = length 235, hash E745AE8D + buffers[47] = length 223, hash 295F2A13 + buffers[48] = length 228, hash E2F47B21 + buffers[49] = length 229, hash 262C3CFE + buffers[50] = length 232, hash 4B5BF5E8 + buffers[51] = length 233, hash F3D80836 + buffers[52] = length 237, hash 32E0A11E + buffers[53] = length 228, hash E1B89F13 + buffers[54] = length 237, hash 8BDD9E38 + buffers[55] = length 235, hash 3C84161F + buffers[56] = length 227, hash A47E1789 + buffers[57] = length 228, hash 869FDFD3 + buffers[58] = length 233, hash 272ECE2 + buffers[59] = length 227, hash DB6B9618 + buffers[60] = length 212, hash 63214325 + buffers[61] = length 221, hash 9BA588A1 + buffers[62] = length 225, hash 21EFD50C + buffers[63] = length 231, hash F3AD0BF + buffers[64] = length 224, hash 822C9210 + buffers[65] = length 195, hash D4EF53EE + buffers[66] = length 195, hash A816647A + buffers[67] = length 184, hash 9A2B7E6 + buffers[68] = length 210, hash 956E3600 + buffers[69] = length 234, hash 35CFDA0A + buffers[70] = length 239, hash 9E15AC1E + buffers[71] = length 228, hash F3B70641 + buffers[72] = length 237, hash 124E3194 + buffers[73] = length 231, hash 950CD7C8 + buffers[74] = length 236, hash A12E49AF + buffers[75] = length 242, hash 43BC9C24 + buffers[76] = length 241, hash DCF0B17 + buffers[77] = length 251, hash C0B99968 + buffers[78] = length 245, hash 9B38ED1C + buffers[79] = length 238, hash 1BA69079 + buffers[80] = length 233, hash 44C8C6BF + buffers[81] = length 231, hash EABBEE02 + buffers[82] = length 226, hash D09C44FB + buffers[83] = length 235, hash BE6A6608 + buffers[84] = length 235, hash 2735F454 + buffers[85] = length 238, hash B160DFE7 + buffers[86] = length 232, hash 1B217D2E + buffers[87] = length 251, hash D1C14CEA + buffers[88] = length 256, hash 97C87F08 + buffers[89] = length 237, hash 6645DB3 + buffers[90] = length 235, hash 727A1C82 + buffers[91] = length 234, hash 5015F8B5 + buffers[92] = length 241, hash 9102144B + buffers[93] = length 224, hash 64E0D807 + buffers[94] = length 228, hash 1922B852 + buffers[95] = length 224, hash 953502D8 + buffers[96] = length 214, hash 92B87FE7 + buffers[97] = length 213, hash BB0C8D86 + buffers[98] = length 206, hash 9AD21017 + buffers[99] = length 209, hash C479FE94 + buffers[100] = length 220, hash 3033DCE1 + buffers[101] = length 217, hash 7D589C94 + buffers[102] = length 216, hash AAF6C183 + buffers[103] = length 206, hash 1EE1207F + buffers[104] = length 204, hash 4BEB1210 + buffers[105] = length 213, hash 21A841C9 + buffers[106] = length 207, hash B80B0424 + buffers[107] = length 212, hash 4785A1C3 + buffers[108] = length 205, hash 59BF7229 + buffers[109] = length 208, hash FA313DDE + buffers[110] = length 211, hash 190D85FD + buffers[111] = length 211, hash BA050052 + buffers[112] = length 211, hash F3080F10 + buffers[113] = length 210, hash F41B7BE7 + buffers[114] = length 207, hash 2176C97E + buffers[115] = length 220, hash 32087455 + buffers[116] = length 213, hash 4E5649A8 + buffers[117] = length 213, hash 5F12FDCF + buffers[118] = length 204, hash 1E895C2A + buffers[119] = length 219, hash 45382270 + buffers[120] = length 205, hash D66C6A1D + buffers[121] = length 204, hash 467AD01F + buffers[122] = length 211, hash F0435574 + buffers[123] = length 206, hash 8C96B75F + buffers[124] = length 200, hash 82553248 + buffers[125] = length 180, hash 1E51E6CE + buffers[126] = length 196, hash 33151DC4 + buffers[127] = length 197, hash 1E62A7D6 + buffers[128] = length 206, hash 6A6C4CC9 + buffers[129] = length 209, hash A72FABAA + buffers[130] = length 217, hash BA33B985 + buffers[131] = length 235, hash 9919CFD9 + buffers[132] = length 236, hash A22C7267 + buffers[133] = length 213, hash 3D57C901 + buffers[134] = length 205, hash 47F68FDE + buffers[135] = length 210, hash 9A756E9C + buffers[136] = length 210, hash BD45C31F + buffers[137] = length 207, hash 8774FF7B + buffers[138] = length 149, hash 4678C0E5 + buffers[139] = length 161, hash E991035D + buffers[140] = length 197, hash C3013689 + buffers[141] = length 208, hash E6C0237 + buffers[142] = length 232, hash A330F188 + buffers[143] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_eac3.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_eac3.ts.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ec3.dump b/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ec3.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ts.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ps.dump b/testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ps.dump new file mode 100644 index 00000000000..c27d6d383db --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ps.dump @@ -0,0 +1,12 @@ +MediaCodec (audio/mpeg-L2): + buffers.length = 5 + buffers[0] = length 417, hash 5C710F78 + buffers[1] = length 418, hash 79CF71F8 + buffers[2] = length 418, hash 79CF71F8 + buffers[3] = length 418, hash 79CF71F8 + buffers[4] = length 0, hash 1 +MediaCodec (video/mpeg2): + buffers.length = 3 + buffers[0] = length 20646, hash 576390B + buffers[1] = length 17831, hash 5C5A57F5 + buffers[2] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ts.dump new file mode 100644 index 00000000000..7c9cff84d1d --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_h262_mpeg_audio.ts.dump @@ -0,0 +1,12 @@ +MediaCodec (audio/mpeg-L2): + buffers.length = 5 + buffers[0] = length 1253, hash 727FD1C6 + buffers[1] = length 1254, hash 73FB07B8 + buffers[2] = length 1254, hash 73FB07B8 + buffers[3] = length 1254, hash 73FB07B8 + buffers[4] = length 0, hash 1 +MediaCodec (video/mpeg2): + buffers.length = 3 + buffers[0] = length 20711, hash 34341E8 + buffers[1] = length 18112, hash EC44B35B + buffers[2] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_h263.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_h263.ts.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_h264_dts_audio.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_h264_dts_audio.ts.dump new file mode 100644 index 00000000000..5148c819dc0 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_h264_dts_audio.ts.dump @@ -0,0 +1,5 @@ +MediaCodec (video/avc): + buffers.length = 3 + buffers[0] = length 12394, hash A39F5311 + buffers[1] = length 813, hash 99F7B4FA + buffers[2] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_h264_mpeg_audio.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_h264_mpeg_audio.ts.dump new file mode 100644 index 00000000000..fd18315acd9 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_h264_mpeg_audio.ts.dump @@ -0,0 +1,12 @@ +MediaCodec (audio/mpeg-L2): + buffers.length = 5 + buffers[0] = length 1253, hash 727FD1C6 + buffers[1] = length 1254, hash 73FB07B8 + buffers[2] = length 1254, hash 73FB07B8 + buffers[3] = length 1254, hash 73FB07B8 + buffers[4] = length 0, hash 1 +MediaCodec (video/avc): + buffers.length = 3 + buffers[0] = length 12394, hash A39F5311 + buffers[1] = length 813, hash 99F7B4FA + buffers[2] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_h264_no_access_unit_delimiters.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_h264_no_access_unit_delimiters.ts.dump new file mode 100644 index 00000000000..aaa7935a467 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_h264_no_access_unit_delimiters.ts.dump @@ -0,0 +1,5 @@ +MediaCodec (video/avc): + buffers.length = 3 + buffers[0] = length 11672, hash 476AEFF9 + buffers[1] = length 524, hash 184416EF + buffers[2] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_h265.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_h265.ts.dump new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_latm.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_latm.ts.dump new file mode 100644 index 00000000000..bf418b0da86 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_latm.ts.dump @@ -0,0 +1,8 @@ +MediaCodec (audio/mp4a-latm): + buffers.length = 6 + buffers[0] = length 279, hash 79BF9F9B + buffers[1] = length 279, hash C96F4684 + buffers[2] = length 279, hash 65670B86 + buffers[3] = length 280, hash 1AF29BCE + buffers[4] = length 279, hash C96F4684 + buffers[5] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_with_id3.adts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_with_id3.adts.dump new file mode 100644 index 00000000000..b372b47d367 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_with_id3.adts.dump @@ -0,0 +1,153 @@ +MediaCodec (audio/mp4a-latm): + buffers.length = 145 + buffers[0] = length 23, hash 47DE9131 + buffers[1] = length 6, hash 31CF3A46 + buffers[2] = length 6, hash 31CF3A46 + buffers[3] = length 6, hash 31CF3A46 + buffers[4] = length 6, hash 31EC5206 + buffers[5] = length 171, hash 4F6478F6 + buffers[6] = length 202, hash AF4068A3 + buffers[7] = length 210, hash E4C10618 + buffers[8] = length 217, hash 9ECCD0D9 + buffers[9] = length 212, hash 6BAC2CD9 + buffers[10] = length 223, hash 188B6010 + buffers[11] = length 222, hash C1A04D0C + buffers[12] = length 220, hash D65F9768 + buffers[13] = length 227, hash B96C9E14 + buffers[14] = length 229, hash 9FB09972 + buffers[15] = length 220, hash 2271F053 + buffers[16] = length 226, hash 5EDD2F4F + buffers[17] = length 239, hash 957510E0 + buffers[18] = length 224, hash 718A8F47 + buffers[19] = length 225, hash 5E11E293 + buffers[20] = length 227, hash FCE50D27 + buffers[21] = length 212, hash 77908C40 + buffers[22] = length 227, hash 34C4EB32 + buffers[23] = length 231, hash 95488307 + buffers[24] = length 226, hash 97F12D6F + buffers[25] = length 236, hash 91A9D9A2 + buffers[26] = length 227, hash 27A608F9 + buffers[27] = length 229, hash 57DAAE4 + buffers[28] = length 235, hash ED30AC34 + buffers[29] = length 227, hash BD3D6280 + buffers[30] = length 233, hash 694B1087 + buffers[31] = length 232, hash 1EDFE047 + buffers[32] = length 228, hash E2A831F4 + buffers[33] = length 231, hash 757E6012 + buffers[34] = length 223, hash 4003D791 + buffers[35] = length 232, hash 3CF9A07C + buffers[36] = length 228, hash 25AC3FF7 + buffers[37] = length 220, hash 2C1824CE + buffers[38] = length 229, hash 46FDD8FB + buffers[39] = length 237, hash F6988018 + buffers[40] = length 242, hash 60436B6B + buffers[41] = length 275, hash 90EDFA8E + buffers[42] = length 242, hash 5C86EFCB + buffers[43] = length 233, hash E0A51B82 + buffers[44] = length 235, hash 590DF14F + buffers[45] = length 238, hash 69AF4E6E + buffers[46] = length 235, hash E745AE8D + buffers[47] = length 223, hash 295F2A13 + buffers[48] = length 228, hash E2F47B21 + buffers[49] = length 229, hash 262C3CFE + buffers[50] = length 232, hash 4B5BF5E8 + buffers[51] = length 233, hash F3D80836 + buffers[52] = length 237, hash 32E0A11E + buffers[53] = length 228, hash E1B89F13 + buffers[54] = length 237, hash 8BDD9E38 + buffers[55] = length 235, hash 3C84161F + buffers[56] = length 227, hash A47E1789 + buffers[57] = length 228, hash 869FDFD3 + buffers[58] = length 233, hash 272ECE2 + buffers[59] = length 227, hash DB6B9618 + buffers[60] = length 212, hash 63214325 + buffers[61] = length 221, hash 9BA588A1 + buffers[62] = length 225, hash 21EFD50C + buffers[63] = length 231, hash F3AD0BF + buffers[64] = length 224, hash 822C9210 + buffers[65] = length 195, hash D4EF53EE + buffers[66] = length 195, hash A816647A + buffers[67] = length 184, hash 9A2B7E6 + buffers[68] = length 210, hash 956E3600 + buffers[69] = length 234, hash 35CFDA0A + buffers[70] = length 239, hash 9E15AC1E + buffers[71] = length 228, hash F3B70641 + buffers[72] = length 237, hash 124E3194 + buffers[73] = length 231, hash 950CD7C8 + buffers[74] = length 236, hash A12E49AF + buffers[75] = length 242, hash 43BC9C24 + buffers[76] = length 241, hash DCF0B17 + buffers[77] = length 251, hash C0B99968 + buffers[78] = length 245, hash 9B38ED1C + buffers[79] = length 238, hash 1BA69079 + buffers[80] = length 233, hash 44C8C6BF + buffers[81] = length 231, hash EABBEE02 + buffers[82] = length 226, hash D09C44FB + buffers[83] = length 235, hash BE6A6608 + buffers[84] = length 235, hash 2735F454 + buffers[85] = length 238, hash B160DFE7 + buffers[86] = length 232, hash 1B217D2E + buffers[87] = length 251, hash D1C14CEA + buffers[88] = length 256, hash 97C87F08 + buffers[89] = length 237, hash 6645DB3 + buffers[90] = length 235, hash 727A1C82 + buffers[91] = length 234, hash 5015F8B5 + buffers[92] = length 241, hash 9102144B + buffers[93] = length 224, hash 64E0D807 + buffers[94] = length 228, hash 1922B852 + buffers[95] = length 224, hash 953502D8 + buffers[96] = length 214, hash 92B87FE7 + buffers[97] = length 213, hash BB0C8D86 + buffers[98] = length 206, hash 9AD21017 + buffers[99] = length 209, hash C479FE94 + buffers[100] = length 220, hash 3033DCE1 + buffers[101] = length 217, hash 7D589C94 + buffers[102] = length 216, hash AAF6C183 + buffers[103] = length 206, hash 1EE1207F + buffers[104] = length 204, hash 4BEB1210 + buffers[105] = length 213, hash 21A841C9 + buffers[106] = length 207, hash B80B0424 + buffers[107] = length 212, hash 4785A1C3 + buffers[108] = length 205, hash 59BF7229 + buffers[109] = length 208, hash FA313DDE + buffers[110] = length 211, hash 190D85FD + buffers[111] = length 211, hash BA050052 + buffers[112] = length 211, hash F3080F10 + buffers[113] = length 210, hash F41B7BE7 + buffers[114] = length 207, hash 2176C97E + buffers[115] = length 220, hash 32087455 + buffers[116] = length 213, hash 4E5649A8 + buffers[117] = length 213, hash 5F12FDCF + buffers[118] = length 204, hash 1E895C2A + buffers[119] = length 219, hash 45382270 + buffers[120] = length 205, hash D66C6A1D + buffers[121] = length 204, hash 467AD01F + buffers[122] = length 211, hash F0435574 + buffers[123] = length 206, hash 8C96B75F + buffers[124] = length 200, hash 82553248 + buffers[125] = length 180, hash 1E51E6CE + buffers[126] = length 196, hash 33151DC4 + buffers[127] = length 197, hash 1E62A7D6 + buffers[128] = length 206, hash 6A6C4CC9 + buffers[129] = length 209, hash A72FABAA + buffers[130] = length 217, hash BA33B985 + buffers[131] = length 235, hash 9919CFD9 + buffers[132] = length 236, hash A22C7267 + buffers[133] = length 213, hash 3D57C901 + buffers[134] = length 205, hash 47F68FDE + buffers[135] = length 210, hash 9A756E9C + buffers[136] = length 210, hash BD45C31F + buffers[137] = length 207, hash 8774FF7B + buffers[138] = length 149, hash 4678C0E5 + buffers[139] = length 161, hash E991035D + buffers[140] = length 197, hash C3013689 + buffers[141] = length 208, hash E6C0237 + buffers[142] = length 232, hash A330F188 + buffers[143] = length 174, hash 2B69C34E + buffers[144] = length 0, hash 1 +MetadataOutput: + Metadata[0]: + entry[0] = ApicFrame + Metadata[1]: + entry[0] = CommentFrame + entry[1] = ApicFrame diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_with_junk.dump b/testdata/src/test/assets/playbackdumps/ts/sample_with_junk.dump new file mode 100644 index 00000000000..7c9cff84d1d --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_with_junk.dump @@ -0,0 +1,12 @@ +MediaCodec (audio/mpeg-L2): + buffers.length = 5 + buffers[0] = length 1253, hash 727FD1C6 + buffers[1] = length 1254, hash 73FB07B8 + buffers[2] = length 1254, hash 73FB07B8 + buffers[3] = length 1254, hash 73FB07B8 + buffers[4] = length 0, hash 1 +MediaCodec (video/mpeg2): + buffers.length = 3 + buffers[0] = length 20711, hash 34341E8 + buffers[1] = length 18112, hash EC44B35B + buffers[2] = length 0, hash 1 From 8728706c6e1adb6a3324b1513fba9967573e850b Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 30 Sep 2020 11:08:04 +0100 Subject: [PATCH 099/693] Use Builder in ImaAdsLoader constructor PiperOrigin-RevId: 334562209 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 94 ++++--------------- 1 file changed, 18 insertions(+), 76 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 cf8d487ede0..157fab938c6 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 @@ -142,7 +142,7 @@ public static final class Builder { * @param context The context; */ public Builder(Context context) { - this.context = checkNotNull(context); + this.context = checkNotNull(context).getApplicationContext(); adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS; vastLoadTimeoutMs = TIMEOUT_UNSET; mediaLoadTimeoutMs = TIMEOUT_UNSET; @@ -318,21 +318,7 @@ public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) { */ public ImaAdsLoader buildForAdTag(Uri adTagUri) { return new ImaAdsLoader( - context, - adTagUri, - imaSdkSettings, - /* adsResponse= */ null, - adPreloadTimeoutMs, - vastLoadTimeoutMs, - mediaLoadTimeoutMs, - mediaBitrate, - focusSkipButtonWhenAvailable, - playAdBeforeStartPosition, - adUiElements, - companionAdSlots, - adErrorListener, - adEventListener, - imaFactory); + /* builder= */ this, /* adTagUri= */ adTagUri, /* adsResponse= */ null); } /** @@ -343,22 +329,7 @@ public ImaAdsLoader buildForAdTag(Uri adTagUri) { * @return The new {@link ImaAdsLoader}. */ public ImaAdsLoader buildForAdsResponse(String adsResponse) { - return new ImaAdsLoader( - context, - /* adTagUri= */ null, - imaSdkSettings, - adsResponse, - adPreloadTimeoutMs, - vastLoadTimeoutMs, - mediaLoadTimeoutMs, - mediaBitrate, - focusSkipButtonWhenAvailable, - playAdBeforeStartPosition, - adUiElements, - companionAdSlots, - adErrorListener, - adEventListener, - imaFactory); + return new ImaAdsLoader(/* builder= */ this, /* adTagUri= */ null, adsResponse); } } @@ -520,56 +491,27 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { * more information. */ public ImaAdsLoader(Context context, Uri adTagUri) { - this( - context, - adTagUri, - /* imaSdkSettings= */ null, - /* adsResponse= */ null, - /* adPreloadTimeoutMs= */ Builder.DEFAULT_AD_PRELOAD_TIMEOUT_MS, - /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaBitrate= */ BITRATE_UNSET, - /* focusSkipButtonWhenAvailable= */ true, - /* playAdBeforeStartPosition= */ true, - /* adUiElements= */ null, - /* companionAdSlots= */ null, - /* adErrorListener= */ null, - /* adEventListener= */ null, - /* imaFactory= */ new DefaultImaFactory()); + this(new Builder(context), adTagUri, /* adsResponse= */ null); } @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) - private ImaAdsLoader( - Context context, - @Nullable Uri adTagUri, - @Nullable ImaSdkSettings imaSdkSettings, - @Nullable String adsResponse, - long adPreloadTimeoutMs, - int vastLoadTimeoutMs, - int mediaLoadTimeoutMs, - int mediaBitrate, - boolean focusSkipButtonWhenAvailable, - boolean playAdBeforeStartPosition, - @Nullable Set adUiElements, - @Nullable Collection companionAdSlots, - @Nullable AdErrorListener adErrorListener, - @Nullable AdEventListener adEventListener, - ImaFactory imaFactory) { + private ImaAdsLoader(Builder builder, @Nullable Uri adTagUri, @Nullable String adsResponse) { checkArgument(adTagUri != null || adsResponse != null); - this.context = context.getApplicationContext(); + this.context = builder.context.getApplicationContext(); this.adTagUri = adTagUri; this.adsResponse = adsResponse; - this.adPreloadTimeoutMs = adPreloadTimeoutMs; - this.vastLoadTimeoutMs = vastLoadTimeoutMs; - this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; - this.mediaBitrate = mediaBitrate; - this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; - this.playAdBeforeStartPosition = playAdBeforeStartPosition; - this.adUiElements = adUiElements; - this.companionAdSlots = companionAdSlots; - this.adErrorListener = adErrorListener; - this.adEventListener = adEventListener; - this.imaFactory = imaFactory; + this.adPreloadTimeoutMs = builder.adPreloadTimeoutMs; + this.vastLoadTimeoutMs = builder.vastLoadTimeoutMs; + this.mediaLoadTimeoutMs = builder.mediaLoadTimeoutMs; + this.mediaBitrate = builder.mediaBitrate; + this.focusSkipButtonWhenAvailable = builder.focusSkipButtonWhenAvailable; + this.playAdBeforeStartPosition = builder.playAdBeforeStartPosition; + this.adUiElements = builder.adUiElements; + this.companionAdSlots = builder.companionAdSlots; + this.adErrorListener = builder.adErrorListener; + this.adEventListener = builder.adEventListener; + this.imaFactory = builder.imaFactory; + @Nullable ImaSdkSettings imaSdkSettings = builder.imaSdkSettings; if (imaSdkSettings == null) { imaSdkSettings = imaFactory.createImaSdkSettings(); if (DEBUG) { From c35787a08f343c9f665be51544882a3aed8898fd Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 30 Sep 2020 11:54:01 +0100 Subject: [PATCH 100/693] Add ImaUtil for IMA extension utilities PiperOrigin-RevId: 334567234 --- .../ext/ima/AdPlaybackStateFactory.java | 56 -------- .../exoplayer2/ext/ima/ImaAdsLoader.java | 77 ++--------- .../android/exoplayer2/ext/ima/ImaUtil.java | 128 ++++++++++++++++++ .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 28 ++-- 4 files changed, 155 insertions(+), 134 deletions(-) delete mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java create mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.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 deleted file mode 100644 index a97307a4195..00000000000 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java +++ /dev/null @@ -1,56 +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.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++] = Math.round(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 157fab938c6..592920bfc48 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 @@ -33,7 +33,6 @@ import androidx.annotation.VisibleForTesting; 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; import com.google.ads.interactivemedia.v3.api.AdErrorEvent; import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; import com.google.ads.interactivemedia.v3.api.AdEvent; @@ -134,7 +133,7 @@ public static final class Builder { private int mediaBitrate; private boolean focusSkipButtonWhenAvailable; private boolean playAdBeforeStartPosition; - private ImaFactory imaFactory; + private ImaUtil.ImaFactory imaFactory; /** * Creates a new builder for {@link ImaAdsLoader}. @@ -303,7 +302,7 @@ public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) { } @VisibleForTesting - /* package */ Builder setImaFactory(ImaFactory imaFactory) { + /* package */ Builder setImaFactory(ImaUtil.ImaFactory imaFactory) { this.imaFactory = checkNotNull(imaFactory); return this; } @@ -397,7 +396,7 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { @Nullable private final Collection companionAdSlots; @Nullable private final AdErrorListener adErrorListener; @Nullable private final AdEventListener adEventListener; - private final ImaFactory imaFactory; + private final ImaUtil.ImaFactory imaFactory; private final ImaSdkSettings imaSdkSettings; private final Timeline.Period period; private final Handler handler; @@ -677,7 +676,7 @@ public void start(EventListener eventListener, AdViewProvider adViewProvider) { adsManager.resume(); } } else if (adsManager != null) { - adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); + adPlaybackState = ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); updateAdPlaybackState(); } else { // Ads haven't loaded yet, so request them. @@ -688,7 +687,7 @@ public void start(EventListener eventListener, AdViewProvider adViewProvider) { adDisplayContainer.registerFriendlyObstruction( imaFactory.createFriendlyObstruction( overlayInfo.view, - getFriendlyObstructionPurpose(overlayInfo.purpose), + ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), overlayInfo.reasonDetail)); } } @@ -1481,21 +1480,6 @@ private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; } - private static FriendlyObstructionPurpose getFriendlyObstructionPurpose( - @OverlayInfo.Purpose int purpose) { - switch (purpose) { - case OverlayInfo.PURPOSE_CONTROLS: - return FriendlyObstructionPurpose.VIDEO_CONTROLS; - case OverlayInfo.PURPOSE_CLOSE_AD: - return FriendlyObstructionPurpose.CLOSE_AD; - case OverlayInfo.PURPOSE_NOT_VISIBLE: - return FriendlyObstructionPurpose.NOT_VISIBLE; - case OverlayInfo.PURPOSE_OTHER: - default: - return FriendlyObstructionPurpose.OTHER; - } - } - private static DataSpec getAdsDataSpec(@Nullable Uri adTagUri) { return new DataSpec(adTagUri != null ? adTagUri : Uri.EMPTY); } @@ -1509,13 +1493,6 @@ private static long getContentPeriodPositionMs( : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } - 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. - return adError.getErrorCode() == AdErrorCode.VAST_LINEAR_ASSET_MISMATCH - || 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. @@ -1549,38 +1526,6 @@ private void destroyAdsManager() { } } - /** Factory for objects provided by the IMA SDK. */ - @VisibleForTesting - /* package */ interface ImaFactory { - /** Creates {@link ImaSdkSettings} for configuring the IMA SDK. */ - ImaSdkSettings createImaSdkSettings(); - /** - * Creates {@link AdsRenderingSettings} for giving the {@link AdsManager} parameters that - * control rendering of ads. - */ - AdsRenderingSettings createAdsRenderingSettings(); - /** - * Creates an {@link AdDisplayContainer} to hold the player for video ads, a container for - * non-linear ads, and slots for companion ads. - */ - AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player); - /** Creates an {@link AdDisplayContainer} to hold the player for audio ads. */ - AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player); - /** - * Creates a {@link FriendlyObstruction} to describe an obstruction considered "friendly" for - * viewability measurement purposes. - */ - FriendlyObstruction createFriendlyObstruction( - View view, - FriendlyObstructionPurpose friendlyObstructionPurpose, - @Nullable String reasonDetail); - /** Creates an {@link AdsRequest} to contain the data used to request ads. */ - AdsRequest createAdsRequest(); - /** Creates an {@link AdsLoader} for requesting ads using the specified settings. */ - AdsLoader createAdsLoader( - Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); - } - private final class ComponentListener implements AdsLoadedListener, ContentProgressProvider, @@ -1610,7 +1555,8 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { if (player != null) { // If a player is attached already, start playback immediately. try { - adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); + adPlaybackState = + ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); hasAdPlaybackState = true; updateAdPlaybackState(); } catch (RuntimeException e) { @@ -1680,7 +1626,7 @@ public void onAdError(AdErrorEvent adErrorEvent) { adPlaybackState = AdPlaybackState.NONE; hasAdPlaybackState = true; updateAdPlaybackState(); - } else if (isAdGroupLoadError(error)) { + } else if (ImaUtil.isAdGroupLoadError(error)) { try { handleAdGroupLoadError(error); } catch (RuntimeException e) { @@ -1795,8 +1741,11 @@ public String toString() { } } - /** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */ - private static final class DefaultImaFactory implements ImaFactory { + /** + * Default {@link ImaUtil.ImaFactory} for non-test usage, which delegates to {@link + * ImaSdkFactory}. + */ + private static final class DefaultImaFactory implements ImaUtil.ImaFactory { @Override public ImaSdkSettings createImaSdkSettings() { return ImaSdkFactory.getInstance().createImaSdkSettings(); diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java new file mode 100644 index 00000000000..c4b2c3dca37 --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java @@ -0,0 +1,128 @@ +/* + * 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 android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdError; +import com.google.ads.interactivemedia.v3.api.AdsLoader; +import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.AdsRequest; +import com.google.ads.interactivemedia.v3.api.FriendlyObstruction; +import com.google.ads.interactivemedia.v3.api.FriendlyObstructionPurpose; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.source.ads.AdsLoader.OverlayInfo; +import java.util.Arrays; +import java.util.List; + +/** Utilities for working with IMA SDK and IMA extension data types. */ +/* package */ final class ImaUtil { + + /** Factory for objects provided by the IMA SDK. */ + public interface ImaFactory { + /** Creates {@link ImaSdkSettings} for configuring the IMA SDK. */ + ImaSdkSettings createImaSdkSettings(); + /** + * Creates {@link AdsRenderingSettings} for giving the {@link AdsManager} parameters that + * control rendering of ads. + */ + AdsRenderingSettings createAdsRenderingSettings(); + /** + * Creates an {@link AdDisplayContainer} to hold the player for video ads, a container for + * non-linear ads, and slots for companion ads. + */ + AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player); + /** Creates an {@link AdDisplayContainer} to hold the player for audio ads. */ + AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player); + /** + * Creates a {@link FriendlyObstruction} to describe an obstruction considered "friendly" for + * viewability measurement purposes. + */ + FriendlyObstruction createFriendlyObstruction( + View view, + FriendlyObstructionPurpose friendlyObstructionPurpose, + @Nullable String reasonDetail); + /** Creates an {@link AdsRequest} to contain the data used to request ads. */ + AdsRequest createAdsRequest(); + /** Creates an {@link AdsLoader} for requesting ads using the specified settings. */ + AdsLoader createAdsLoader( + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); + } + + /** + * Returns the IMA {@link FriendlyObstructionPurpose} corresponding to the given {@link + * OverlayInfo#purpose}. + */ + public static FriendlyObstructionPurpose getFriendlyObstructionPurpose( + @OverlayInfo.Purpose int purpose) { + switch (purpose) { + case OverlayInfo.PURPOSE_CONTROLS: + return FriendlyObstructionPurpose.VIDEO_CONTROLS; + case OverlayInfo.PURPOSE_CLOSE_AD: + return FriendlyObstructionPurpose.CLOSE_AD; + case OverlayInfo.PURPOSE_NOT_VISIBLE: + return FriendlyObstructionPurpose.NOT_VISIBLE; + case OverlayInfo.PURPOSE_OTHER: + default: + return FriendlyObstructionPurpose.OTHER; + } + } + + /** + * Returns an initial {@link AdPlaybackState} with ad groups at the provided {@code cuePoints}. + * + * @param cuePoints The cue points of the ads in seconds. + * @return The {@link AdPlaybackState}. + */ + public static AdPlaybackState getInitialAdPlaybackStateForCuePoints(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++] = Math.round(C.MICROS_PER_SECOND * cuePoint); + } + } + // Cue points may be out of order, so sort them. + Arrays.sort(adGroupTimesUs, 0, adGroupIndex); + return new AdPlaybackState(adGroupTimesUs); + } + + /** Returns whether the ad error indicates that an entire ad group failed to load. */ + public 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. + return adError.getErrorCode() == AdError.AdErrorCode.VAST_LINEAR_ASSET_MISMATCH + || adError.getErrorCode() == AdError.AdErrorCode.UNKNOWN_ERROR; + } + + private ImaUtil() {} +} 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 c2cc3848886..98610654540 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 @@ -55,7 +55,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.ext.ima.ImaUtil.ImaFactory; import com.google.android.exoplayer2.source.MaskingMediaSource.PlaceholderTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; @@ -378,7 +378,7 @@ public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -402,7 +402,7 @@ public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -424,7 +424,7 @@ public void resumePlaybackBeforeMidroll_playsPreroll() { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -448,7 +448,7 @@ public void resumePlaybackAtMidroll_skipsPreroll() { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -473,7 +473,7 @@ public void resumePlaybackAfterMidroll_skipsPreroll() { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -500,7 +500,7 @@ public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -531,7 +531,7 @@ public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -563,7 +563,7 @@ public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPr .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -595,7 +595,7 @@ public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPrerol .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -622,7 +622,7 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid verify(mockAdsManager).destroy(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withSkippedAdGroup(/* adGroupIndex= */ 1)); @@ -663,7 +663,7 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -702,7 +702,7 @@ public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skips .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -761,7 +761,7 @@ public double getTimeOffset() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI) From eaff9d510575a5b543d83e99091e17cf22421312 Mon Sep 17 00:00:00 2001 From: yqritc Date: Fri, 2 Oct 2020 14:27:13 +0900 Subject: [PATCH 101/693] apply setOutputSurface workaround for more devices --- .../exoplayer2/video/MediaCodecVideoRenderer.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 5b265882440..299597c9b48 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 @@ -1583,6 +1583,8 @@ protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { case "1601": case "1713": case "1714": + case "601LV": + case "602LV": case "A10-70F": case "A10-70L": case "A1601": @@ -1594,6 +1596,7 @@ protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { case "AquaPowerM": case "ASUS_X00AD_2": case "Aura_Note_2": + case "b5": case "BLACK-1X": case "BRAVIA_ATV2": case "BRAVIA_ATV3_4K": @@ -1601,18 +1604,24 @@ protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { case "ComioS1": case "CP8676_I02": case "CPH1609": + case "CPH1715": case "CPY83_I00": case "cv1": case "cv3": case "deb": + case "DM-01K": case "E5643": case "ELUGA_A3_Pro": case "ELUGA_Note": case "ELUGA_Prim": case "ELUGA_Ray_X": case "EverStar_S": + case "F01H": + case "F01J": case "F02H": case "F03H": + case "F04H": + case "F04J": case "F3111": case "F3113": case "F3116": @@ -1650,6 +1659,7 @@ protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { case "l5460": case "le_x6": case "LS-5017": + case "M04": case "M5c": case "manning": case "marino_f": @@ -1685,6 +1695,7 @@ protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { case "Q5": case "QM16XE_U": case "QX1": + case "RAIJIN": case "santoni": case "Slate_Pro": case "SVP-DTV15": From a0d99a6ac8c670c70417d5d5d40dd04d04388ae2 Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 30 Sep 2020 13:42:20 +0100 Subject: [PATCH 102/693] Fix flaky unit tests PiperOrigin-RevId: 334580007 --- .../AsynchronousMediaCodecAdapter.java | 25 ++++++--- .../AsynchronousMediaCodecBufferEnqueuer.java | 22 ++------ .../AsynchronousMediaCodecAdapterTest.java | 51 ++++++++++--------- 3 files changed, 50 insertions(+), 48 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 eb4754c50f0..cb3acc0362f 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 @@ -83,19 +83,24 @@ * labelling the internal thread accordingly. */ /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, int trackType) { - this(codec, trackType, new HandlerThread(createThreadLabel(trackType))); + this( + codec, + trackType, + new HandlerThread(createCallbackThreadLabel(trackType)), + new HandlerThread(createQueueingThreadLabel(trackType))); } @VisibleForTesting /* package */ AsynchronousMediaCodecAdapter( MediaCodec codec, int trackType, - HandlerThread handlerThread) { + HandlerThread callbackThread, + HandlerThread enqueueingThread) { this.lock = new Object(); this.mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); this.codec = codec; - this.handlerThread = handlerThread; - this.bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, trackType); + this.handlerThread = callbackThread; + this.bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, enqueueingThread); this.state = STATE_CREATED; } @@ -276,8 +281,16 @@ private void maybeThrowInternalException() { } } - private static String createThreadLabel(int trackType) { - StringBuilder labelBuilder = new StringBuilder("ExoPlayer:MediaCodecAsyncAdapter:"); + private static String createCallbackThreadLabel(int trackType) { + return createThreadLabel(trackType, /* prefix= */ "ExoPlayer:MediaCodecAsyncAdapter:"); + } + + private static String createQueueingThreadLabel(int trackType) { + return createThreadLabel(trackType, /* prefix= */ "ExoPlayer:MediaCodecQueueingThread:"); + } + + private static String createThreadLabel(int trackType, String prefix) { + StringBuilder labelBuilder = new StringBuilder(prefix); if (trackType == C.TRACK_TYPE_AUDIO) { labelBuilder.append("Audio"); } else if (trackType == C.TRACK_TYPE_VIDEO) { 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 6b2ec4e6996..10d59d347c4 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 @@ -26,7 +26,6 @@ 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.ConditionVariable; import com.google.android.exoplayer2.util.Util; @@ -66,13 +65,10 @@ class AsynchronousMediaCodecBufferEnqueuer { * Creates a new instance that submits input buffers on the specified {@link MediaCodec}. * * @param codec The {@link MediaCodec} to submit input buffers to. - * @param trackType The type of stream (used for debug logs). + * @param queueingThread The {@link HandlerThread} to use for queueing buffers. */ - public AsynchronousMediaCodecBufferEnqueuer(MediaCodec codec, int trackType) { - this( - codec, - new HandlerThread(createThreadLabel(trackType)), - /* conditionVariable= */ new ConditionVariable()); + public AsynchronousMediaCodecBufferEnqueuer(MediaCodec codec, HandlerThread queueingThread) { + this(codec, queueingThread, /* conditionVariable= */ new ConditionVariable()); } @VisibleForTesting @@ -291,18 +287,6 @@ private static boolean needsSynchronizationWorkaround() { return manufacturer.contains("samsung") || manufacturer.contains("motorola"); } - private static String createThreadLabel(int trackType) { - StringBuilder labelBuilder = new StringBuilder("ExoPlayer:MediaCodecBufferEnqueuer:"); - 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(); - } - /** Performs a deep copy of {@code cryptoInfo} to {@code frameworkCryptoInfo}. */ private static void copy( CryptoInfo cryptoInfo, android.media.MediaCodec.CryptoInfo frameworkCryptoInfo) { 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 0c023d38415..0128b77adda 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 @@ -38,26 +38,27 @@ public class AsynchronousMediaCodecAdapterTest { private AsynchronousMediaCodecAdapter adapter; private MediaCodec codec; - private TestHandlerThread handlerThread; + private TestHandlerThread callbackThread; + private HandlerThread queueingThread; private MediaCodec.BufferInfo bufferInfo; @Before public void setUp() throws IOException { codec = MediaCodec.createByCodecName("h264"); - handlerThread = new TestHandlerThread("TestHandlerThread"); + callbackThread = new TestHandlerThread("TestCallbackThread"); + queueingThread = new HandlerThread("TestQueueingThread"); adapter = new AsynchronousMediaCodecAdapter( - codec, - /* trackType= */ C.TRACK_TYPE_VIDEO, - handlerThread); + codec, /* trackType= */ C.TRACK_TYPE_VIDEO, callbackThread, queueingThread); bufferInfo = new MediaCodec.BufferInfo(); } @After public void tearDown() { adapter.shutdown(); + codec.release(); - assertThat(handlerThread.hasQuit()).isTrue(); + assertThat(callbackThread.hasQuit()).isTrue(); } @Test @@ -66,7 +67,7 @@ public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { 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(); + shadowOf(callbackThread.getLooper()).pause(); adapter.start(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); @@ -79,7 +80,7 @@ public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { adapter.start(); // 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(); + shadowOf(callbackThread.getLooper()).idle(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } @@ -92,7 +93,7 @@ public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { // 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 shadowLooper = shadowOf(callbackThread.getLooper()); shadowLooper.idle(); shadowLooper.pause(); adapter.flush(); @@ -107,7 +108,7 @@ public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInpu adapter.start(); // 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 shadowLooper = shadowOf(callbackThread.getLooper()); shadowLooper.idle(); adapter.flush(); @@ -123,7 +124,7 @@ public void dequeueInputBufferIndex_withMediaCodecError_throwsException() throws 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(); + shadowOf(callbackThread.getLooper()).pause(); adapter.start(); // Set an error directly on the adapter (not through the looper). @@ -140,7 +141,7 @@ public void dequeueInputBufferIndex_afterShutdown_returnsTryAgainLater() { // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we // progress the adapter's looper. We progress the looper so that we call shutdown() on a // non-empty adapter. - shadowOf(handlerThread.getLooper()).idle(); + shadowOf(callbackThread.getLooper()).idle(); adapter.shutdown(); @@ -155,7 +156,7 @@ public void dequeueOutputBufferIndex_withoutOutputBuffer_returnsTryAgainLater() adapter.start(); // 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(); + shadowOf(callbackThread.getLooper()).idle(); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); @@ -171,13 +172,17 @@ public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { adapter.start(); // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we // progress the adapter's looper. - ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); - shadowLooper.idle(); + ShadowLooper callbackShadowLooper = shadowOf(callbackThread.getLooper()); + callbackShadowLooper.idle(); int index = adapter.dequeueInputBufferIndex(); adapter.queueInputBuffer(index, 0, 0, 0, 0); - // Progress the looper so that the ShadowMediaCodec processes the input buffer. - shadowLooper.idle(); + // Progress the queueuing looper first so the asynchronous enqueuer submits the input buffer, + // the ShadowMediaCodec processes the input buffer and produces an output buffer. Then, progress + // the callback looper so that the available output buffer callback is handled and the output + // buffer reaches the adapter. + shadowOf(queueingThread.getLooper()).idle(); + callbackShadowLooper.idle(); // The ShadowMediaCodec will first offer an output format and then the output buffer. assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) @@ -194,7 +199,7 @@ public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { adapter.start(); // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we // progress the adapter's looper. - ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + ShadowLooper shadowLooper = shadowOf(callbackThread.getLooper()); shadowLooper.idle(); // Flush enqueues a task in the looper, but we will pause the looper to leave flush() @@ -211,7 +216,7 @@ public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() throw // 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(); + shadowOf(callbackThread.getLooper()).pause(); adapter.start(); // Set an error directly on the adapter. @@ -227,7 +232,7 @@ public void dequeueOutputBufferIndex_afterShutdown_returnsTryAgainLater() { adapter.start(); // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we // progress the adapter's looper. - ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + ShadowLooper shadowLooper = shadowOf(callbackThread.getLooper()); shadowLooper.idle(); int index = adapter.dequeueInputBufferIndex(); @@ -246,7 +251,7 @@ public void getOutputFormat_withoutFormatReceived_throwsException() { 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(); + shadowOf(callbackThread.getLooper()).pause(); adapter.start(); assertThrows(IllegalStateException.class, () -> adapter.getOutputFormat()); @@ -259,7 +264,7 @@ public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { adapter.start(); // After start(), the ShadowMediaCodec offers an output format, which is available only if we // progress the adapter's looper. - shadowOf(handlerThread.getLooper()).idle(); + shadowOf(callbackThread.getLooper()).idle(); // Add another format directly on the adapter. adapter.onOutputFormatChanged(codec, createMediaFormat("format2")); @@ -283,7 +288,7 @@ public void getOutputFormat_afterFlush_returnsPreviousFormat() { adapter.start(); // After start(), the ShadowMediaCodec offers an output format, which is available only if we // progress the adapter's looper. - ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + ShadowLooper shadowLooper = shadowOf(callbackThread.getLooper()); shadowLooper.idle(); adapter.dequeueOutputBufferIndex(bufferInfo); From ae0d9b135992bdff28b0acb2b1b924414415c5a8 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 30 Sep 2020 16:15:31 +0100 Subject: [PATCH 103/693] Preserve limit when resetting ParsableByteArray in OggPacket#populate When I moved ParsableByteArray#data behind a getter I replaced some assignments with calls to reset(byte[]): https://github.com/google/ExoPlayer/commit/ce2e6e2fd625db787b1f400614adcd7458144bbd reset(byte[]) deliberately sets `limit` to `data.length`, in order to handle cases that were reassigning `data` but not updating `limit`. However OggPacket was already using `limit` to track where to write 'new' data into the array, so changing `limit` to `data.length` caused us to try and write new data beyond the end of the array. I looked at other uses of reset(byte[]) in https://github.com/google/ExoPlayer/commit/ce2e6e2fd625db787b1f400614adcd7458144bbd and condluded the only other usage in MatroskaExtractor is legit and shouldn't be updated like this (because MatroskaExtractor previously *wasn't* correctly updating/maintaining `limit`). Issue: #7992 PiperOrigin-RevId: 334601586 --- RELEASENOTES.md | 2 + .../exoplayer2/extractor/ogg/OggPacket.java | 7 +- .../ogg/OggExtractorParameterizedTest.java | 20 +- ...bear_vorbis_with_large_metadata.ogg.0.dump | 740 ++++++++++++++++++ ...bear_vorbis_with_large_metadata.ogg.1.dump | 456 +++++++++++ ...bear_vorbis_with_large_metadata.ogg.2.dump | 216 +++++ ...bear_vorbis_with_large_metadata.ogg.3.dump | 20 + ...ith_large_metadata.ogg.unknown_length.dump | 737 +++++++++++++++++ .../ogg/bear_vorbis_with_large_metadata.ogg | Bin 0 -> 111383 bytes 9 files changed, 2194 insertions(+), 4 deletions(-) create mode 100644 testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.0.dump create mode 100644 testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.1.dump create mode 100644 testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.2.dump create mode 100644 testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.3.dump create mode 100644 testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.unknown_length.dump create mode 100644 testdata/src/test/assets/media/ogg/bear_vorbis_with_large_metadata.ogg diff --git a/RELEASENOTES.md b/RELEASENOTES.md index f32120b675f..c04ea1114d0 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -30,6 +30,8 @@ ([#7967](https://github.com/google/ExoPlayer/issues/7967)). * Use TLEN ID3 tag to compute the duration in Mp3Extractor ([#7949](https://github.com/google/ExoPlayer/issues/7949)). + * Fix regression for Ogg files with packets that span multiple pages + ([#7992](https://github.com/google/ExoPlayer/issues/7992)). * UI * Add the option to sort tracks by `Format` in `TrackSelectionView` and `TrackSelectionDialogBuilder` 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 450bff4a369..c7718e7fa9f 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 @@ -88,7 +88,9 @@ public boolean populate(ExtractorInput input) throws IOException { int segmentIndex = currentSegmentIndex + segmentCount; if (size > 0) { if (packetArray.capacity() < packetArray.limit() + size) { - packetArray.reset(Arrays.copyOf(packetArray.getData(), packetArray.limit() + size)); + packetArray.reset( + Arrays.copyOf(packetArray.getData(), packetArray.limit() + size), + /* limit= */ packetArray.limit()); } input.readFully(packetArray.getData(), packetArray.limit(), size); packetArray.setLimit(packetArray.limit() + size); @@ -131,7 +133,8 @@ public void trimPayload() { } packetArray.reset( Arrays.copyOf( - packetArray.getData(), max(OggPageHeader.MAX_PAGE_PAYLOAD, packetArray.limit()))); + packetArray.getData(), max(OggPageHeader.MAX_PAGE_PAYLOAD, packetArray.limit())), + /* limit= */ packetArray.limit()); } /** 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 cc78d59bf48..0731cfd95e5 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 @@ -60,11 +60,27 @@ public void vorbis() throws Exception { OggExtractor::new, "media/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. + /** + * 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. + * + *

    https://github.com/google/ExoPlayer/issues/7230 + */ @Test public void vorbisWithGapBeforeSecondPage() throws Exception { ExtractorAsserts.assertBehavior( OggExtractor::new, "media/ogg/bear_vorbis_gap.ogg", simulationConfig); } + + /** + * Use some very large Vorbis Comment metadata to create a packet that is larger than a single Ogg + * page. + * + *

    https://github.com/google/ExoPlayer/issues/7992 + */ + @Test + public void vorbisWithPacketSpanningBetweenPages() throws Exception { + ExtractorAsserts.assertBehavior( + OggExtractor::new, "media/ogg/bear_vorbis_with_large_metadata.ogg", simulationConfig); + } } diff --git a/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.0.dump b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.0.dump new file mode 100644 index 00000000000..92aec373b5e --- /dev/null +++ b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.0.dump @@ -0,0 +1,740 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=84969]] + getPosition(1) = [[timeUs=1, position=84969]] + getPosition(1370500) = [[timeUs=1370500, position=84969]] + getPosition(2741000) = [[timeUs=2741000, position=84969]] +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/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.1.dump b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.1.dump new file mode 100644 index 00000000000..1a71ebbb10c --- /dev/null +++ b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.1.dump @@ -0,0 +1,456 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=84969]] + getPosition(1) = [[timeUs=1, position=84969]] + getPosition(1370500) = [[timeUs=1370500, position=84969]] + getPosition(2741000) = [[timeUs=2741000, position=84969]] +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 = 905333 + flags = 1 + data = length 195, hash 2722159A + sample 1: + time = 926666 + flags = 1 + data = length 199, hash 10CEE97A + sample 2: + time = 948000 + flags = 1 + data = length 191, hash 2CF9FB3F + sample 3: + time = 969333 + flags = 1 + data = length 197, hash A725DA0 + sample 4: + time = 990666 + flags = 1 + data = length 211, hash D4E5DB9E + sample 5: + time = 1012000 + flags = 1 + data = length 189, hash 1A90F496 + sample 6: + time = 1033333 + flags = 1 + data = length 187, hash 44DB2689 + sample 7: + time = 1054666 + flags = 1 + data = length 197, hash 6D3E5117 + sample 8: + time = 1076000 + flags = 1 + data = length 208, hash 5B57B288 + sample 9: + time = 1097333 + flags = 1 + data = length 198, hash D5FC05 + sample 10: + time = 1118666 + flags = 1 + data = length 192, hash 350BBA45 + sample 11: + time = 1140000 + flags = 1 + data = length 195, hash 5F96F2A8 + sample 12: + time = 1161333 + flags = 1 + data = length 202, hash 61D7CC33 + sample 13: + time = 1182666 + flags = 1 + data = length 202, hash 49D335F2 + sample 14: + time = 1204000 + flags = 1 + data = length 192, hash 2FE9CB1A + sample 15: + time = 1225333 + flags = 1 + data = length 201, hash BF0763B2 + sample 16: + time = 1246666 + flags = 1 + data = length 184, hash AD047421 + sample 17: + time = 1268000 + flags = 1 + data = length 196, hash F9088F14 + sample 18: + time = 1289333 + flags = 1 + data = length 190, hash AC6D38FD + sample 19: + time = 1310666 + flags = 1 + data = length 195, hash 8D1A66D2 + sample 20: + time = 1332000 + flags = 1 + data = length 197, hash B46BFB6B + sample 21: + time = 1353333 + flags = 1 + data = length 195, hash D9761F23 + sample 22: + time = 1374666 + flags = 1 + data = length 204, hash 3391B617 + sample 23: + time = 1396000 + flags = 1 + data = length 42, hash 33A1FB52 + sample 24: + time = 1408000 + flags = 1 + data = length 44, hash 408B146E + sample 25: + time = 1410666 + flags = 1 + data = length 44, hash 171C7E0D + sample 26: + time = 1413333 + flags = 1 + data = length 54, hash 6307E16C + sample 27: + time = 1416000 + flags = 1 + data = length 53, hash 4A319572 + sample 28: + time = 1418666 + flags = 1 + data = length 215, hash BA9C445C + sample 29: + time = 1430666 + flags = 1 + data = length 201, hash 3120D234 + sample 30: + time = 1452000 + flags = 1 + data = length 187, hash DB44993C + sample 31: + time = 1473333 + flags = 1 + data = length 196, hash CF2002D7 + sample 32: + time = 1494666 + flags = 1 + data = length 185, hash E03B5D7 + sample 33: + time = 1516000 + flags = 1 + data = length 187, hash DA399A2C + sample 34: + time = 1537333 + flags = 1 + data = length 191, hash 292AA0DB + sample 35: + time = 1558666 + flags = 1 + data = length 201, hash 221910E0 + sample 36: + time = 1580000 + flags = 1 + data = length 194, hash F4ED7821 + sample 37: + time = 1601333 + flags = 1 + data = length 43, hash FDDA515E + sample 38: + time = 1613333 + flags = 1 + data = length 42, hash F3571C0A + sample 39: + time = 1616000 + flags = 1 + data = length 38, hash 39F910B3 + sample 40: + time = 1618666 + flags = 1 + data = length 41, hash 2D189531 + sample 41: + time = 1621333 + flags = 1 + data = length 43, hash 1F7574DB + sample 42: + time = 1624000 + flags = 1 + data = length 43, hash 644D15E5 + sample 43: + time = 1626666 + flags = 1 + data = length 49, hash E8A0878 + sample 44: + time = 1629333 + flags = 1 + data = length 55, hash DFF2046D + sample 45: + time = 1632000 + flags = 1 + data = length 49, hash 9FB8A23 + sample 46: + time = 1634666 + flags = 1 + data = length 41, hash E3E15E3B + sample 47: + time = 1637333 + flags = 1 + data = length 42, hash E5D17A32 + sample 48: + time = 1640000 + flags = 1 + data = length 42, hash F308B653 + sample 49: + time = 1642666 + flags = 1 + data = length 55, hash BB750D76 + sample 50: + time = 1645333 + flags = 1 + data = length 51, hash 96772ABF + sample 51: + time = 1648000 + flags = 1 + data = length 197, hash E4524346 + sample 52: + time = 1660000 + flags = 1 + data = length 188, hash AC3E1BB5 + sample 53: + time = 1681333 + flags = 1 + data = length 195, hash F56DB8A5 + sample 54: + time = 1702666 + flags = 1 + data = length 198, hash C8970FF7 + sample 55: + time = 1724000 + flags = 1 + data = length 202, hash AF425C68 + sample 56: + time = 1745333 + flags = 1 + data = length 196, hash 4215D839 + sample 57: + time = 1766666 + flags = 1 + data = length 204, hash DB9BE8E3 + sample 58: + time = 1788000 + flags = 1 + data = length 206, hash E5B20AB8 + sample 59: + time = 1809333 + flags = 1 + data = length 209, hash D7F47B95 + sample 60: + time = 1830666 + flags = 1 + data = length 193, hash FB54FB05 + sample 61: + time = 1852000 + flags = 1 + data = length 199, hash D99C3106 + sample 62: + time = 1873333 + flags = 1 + data = length 206, hash 253885B9 + sample 63: + time = 1894666 + flags = 1 + data = length 191, hash FBDD8162 + sample 64: + time = 1916000 + flags = 1 + data = length 183, hash 7290332F + sample 65: + time = 1937333 + flags = 1 + data = length 189, hash 1A9DC3DE + sample 66: + time = 1958666 + flags = 1 + data = length 201, hash 5D936764 + sample 67: + time = 1980000 + flags = 1 + data = length 193, hash 6B03E75E + sample 68: + time = 2001333 + flags = 1 + data = length 199, hash 8A21BA83 + sample 69: + time = 2022666 + flags = 1 + data = length 41, hash E6362210 + sample 70: + time = 2034666 + flags = 1 + data = length 43, hash 36A57B44 + sample 71: + time = 2037333 + flags = 1 + data = length 43, hash E51797D5 + sample 72: + time = 2040000 + flags = 1 + data = length 43, hash 1F336C72 + sample 73: + time = 2042666 + flags = 1 + data = length 42, hash 201AD367 + sample 74: + time = 2045333 + flags = 1 + data = length 50, hash 606CCD6 + sample 75: + time = 2048000 + flags = 1 + data = length 56, hash B15EBD7A + sample 76: + time = 2050666 + flags = 1 + data = length 212, hash 273B8D22 + sample 77: + time = 2062666 + flags = 1 + data = length 194, hash 44F9CE1 + sample 78: + time = 2084000 + flags = 1 + data = length 195, hash EDF9EBA1 + sample 79: + time = 2105333 + flags = 1 + data = length 194, hash CE9F2D26 + sample 80: + time = 2126666 + flags = 1 + data = length 192, hash 204F8A23 + sample 81: + time = 2148000 + flags = 1 + data = length 206, hash DFA57E67 + sample 82: + time = 2169333 + flags = 1 + data = length 196, hash 3CF084AB + sample 83: + time = 2190666 + flags = 1 + data = length 202, hash 2AF75C08 + sample 84: + time = 2212000 + flags = 1 + data = length 203, hash 748EAF7 + sample 85: + time = 2233333 + flags = 1 + data = length 205, hash ED82379D + sample 86: + time = 2254666 + flags = 1 + data = length 193, hash 61F26F22 + sample 87: + time = 2276000 + flags = 1 + data = length 189, hash 85EF1D20 + sample 88: + time = 2297333 + flags = 1 + data = length 187, hash 25E41FBF + sample 89: + time = 2318666 + flags = 1 + data = length 199, hash F365808 + sample 90: + time = 2340000 + flags = 1 + data = length 197, hash 94205329 + sample 91: + time = 2361333 + flags = 1 + data = length 201, hash FA2B2055 + sample 92: + time = 2382666 + flags = 1 + data = length 194, hash AF95381F + sample 93: + time = 2404000 + flags = 1 + data = length 201, hash 923D3534 + sample 94: + time = 2425333 + flags = 1 + data = length 198, hash 35F84C2E + sample 95: + time = 2446666 + flags = 1 + data = length 204, hash 6642CA40 + sample 96: + time = 2468000 + flags = 1 + data = length 183, hash 3E2DC6BE + sample 97: + time = 2489333 + flags = 1 + data = length 197, hash B1E458CE + sample 98: + time = 2510666 + flags = 1 + data = length 193, hash E9218C84 + sample 99: + time = 2532000 + flags = 1 + data = length 192, hash FEF08D4B + sample 100: + time = 2553333 + flags = 1 + data = length 201, hash FC411147 + sample 101: + time = 2574666 + flags = 1 + data = length 218, hash 86893464 + sample 102: + time = 2596000 + flags = 1 + data = length 226, hash 31C5320 + sample 103: + time = 2617333 + flags = 1 + data = length 233, hash 9432BEE5 + sample 104: + time = 2638666 + flags = 1 + data = length 213, hash B3FCC53E + sample 105: + time = 2660000 + flags = 1 + data = length 204, hash D70DD5A2 + sample 106: + time = 2681333 + flags = 1 + data = length 212, hash A4EF1B69 + sample 107: + time = 2702666 + flags = 1 + data = length 203, hash 8B0748B5 + sample 108: + time = 2724000 + flags = 1 + data = length 149, hash E455335B +tracksEnded = true diff --git a/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.2.dump b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.2.dump new file mode 100644 index 00000000000..50b21ade226 --- /dev/null +++ b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.2.dump @@ -0,0 +1,216 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=84969]] + getPosition(1) = [[timeUs=1, position=84969]] + getPosition(1370500) = [[timeUs=1370500, position=84969]] + getPosition(2741000) = [[timeUs=2741000, position=84969]] +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/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.3.dump b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.3.dump new file mode 100644 index 00000000000..1d76d892d38 --- /dev/null +++ b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.3.dump @@ -0,0 +1,20 @@ +seekMap: + isSeekable = true + duration = 2741000 + getPosition(0) = [[timeUs=0, position=84969]] + getPosition(1) = [[timeUs=1, position=84969]] + getPosition(1370500) = [[timeUs=1370500, position=84969]] + getPosition(2741000) = [[timeUs=2741000, position=84969]] +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/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.unknown_length.dump b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.ogg.unknown_length.dump new file mode 100644 index 00000000000..9830a08357d --- /dev/null +++ b/testdata/src/test/assets/extractordumps/ogg/bear_vorbis_with_large_metadata.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 diff --git a/testdata/src/test/assets/media/ogg/bear_vorbis_with_large_metadata.ogg b/testdata/src/test/assets/media/ogg/bear_vorbis_with_large_metadata.ogg new file mode 100644 index 0000000000000000000000000000000000000000..1dab9e5258f29ca10e11aaf115fd06b5616e4ab0 GIT binary patch literal 111383 zcmeF)cT^Ky!zlVm0RjRB2pF0M2%#gvfD}vUp$dT@Rp}k1+Dm|dAr$E#0@8(01VpML zU8G4DL3mJ6ie0g{JNUfs_rB-+bM9K-Ip@3UyEAK;?CCSVy|ZV}%--RUudgit2maBu z#LaVT1--s*tA*`^9X}TC;UBRz0Z#AO`Tzj>P1v8$R@kAfHG$v%=(g6}n&aZ)BJ|H` z|9|~N{O!gEa#*+@_tDT&C26P<)zmn_3EvI?fSswGh0%WFW5@K}!=WK000}?>kN_kA z2|xmn03-kjKmw2eBmfCO0+0YC00}?>kN_kA2|xmn03-kjKmw2eBmfCO0+0YC00}?> zkN_kA2|xmn03-kjKmw2eBmfCO0+0YC00}?>kN_kA2|xmn03-kjKmw2eB=CP<;5*p* zU5{#KYHJ5Ie}|g>LjsThBmfCO0+0YC00}?>kN_kA2|xmn03-kjKmw2eBmfCO0+0YC z00}?>kN_kA2|xmn03-kjKmw2eBmfCO0+0YC00}?>kN_kA2|xmn03-kjKmw2eBmfCO z0+0YC00}?>kN_kA3H+A{Z2j|hJqvxBCDi);zbp>^@Biolv`0t)5`Y9C0Z0H6fCL}` zNB|Om1Rw!O01|)%AOT1K5`Y9C0Z0H6fCL}`NB|Om1Rw!O01|)%AOT1K5`Y9C0Z0H6 zfCL}`NB|Om1Rw!O01|)%AOT1K5`Y9C0Z0H6fCL}`NB|Om1pdD!@C$7HZf!(0K18)N zqCzd;p%(Cv03-kjKmw2eBmfCO0+0YC00}?>kN_kA2|xmn03-kjKmw2eBmfCO0+0YC z00}?>kN_kA2|xmn03-kjKmw2eBmfCO0+0YC00}?>kN_kA2|xmn03-kjKmw2eBmfCO z0+0YC00}?>|78L{!4~iqhYa-WOb=N>4dDOFqT&De40C9V7q=Kmw2eBmfCO0+0YC00}?>kN_kA2|xmn03-kj zKmw2eBmfCO0+0YC00}?>kN_kA2|xmn03-kjKmw2eBmfCO0+0YC00}?>kN_kA2|xmn z03-kjKmw2eBmfCO0+0YC015oJ5D*62TYHCi9`o`J-*4f5+($!8m879cR8xa-96uKB z;U6KZZ(t7t$N+$kz$zM-M~E=cV*1viPbKun0$+;iTL{ERs9QJ>%SNP1l@;kTo#ms) zQ)$4~3K#WGVi+tvloUgDze9Sg+^*^gq0KnRY*C-6b^=CaV06-OKvC17-<$kPHW(tZCUjql|0DuH{Lo%@4XOd}n z-;Va6Ie6jUdgp9!(Lbw$hUiIupDIqK8bXxwPdtE!K^I`D_0j){Ovjq>mvGl=|CRr= z$dNzKtU+5cpK|k5`W052_G*5_*uCfZ#4#{blJCfy2QIgo-&f?w^LI$D;naAZ;?}gV zv30k6fdMc?s6CaMmwXcpan$;!J{^?X%SnS`ZL(*Uzf-i+8V9A-Ijf2 zydMl58dzV-NB_qnf0p}$44y9;9iQy}NC7pu>W4EH@DNj9*ZaJt#w`|`ufH*|=2w=f zgObe(qngS64Gss$D5*FMmGaMl2U98@8$P6PttiG!v8u8pNqt!P#>k(6&dZt`{JOlv zKD2>SZ6DP(SYy^9@U`}8UTl-SQ()~)s+mH0CY9%p9}cwF=M?oywb|OT2UFo%emIX} z<=>pRt*8NjL+>Rf1SJ2}zmvExAZsHC_9|2A8dJ7Lv?%Wf*-?%y=bY!_TtA;%1p6;h z?he`Bj{igd_o;(FXn+F~``;&`Fo2C1(O$!6qW^iCV1xpp7?VHg&9o73K8YWlR6g{9 zbVz8w4aR7SM4J$Hwb>hNqv<`R;cBbtO0y3>oE_}k9JP?+z3}10GV^^8_9YipYl{I9dDX0>!0p>D!cM>-umT2MfRUX0QA-( zTOF(7B&+HqtMX*Z8Z+JRa&~1^p3+do%l5h#|Eqr6p9GEvfJnSaINpScH==@pKe}|@ zEnSITBHk!md3cia_c=}o?FT^*NB*5@{38MYfcIWKW_UuvQc!k=szS3>nV}lZ*wX%2 zo#hnCQV4|Q22x=_D%w^>Wgw3`5$`b>P+jE7OMO}1KvDF)*)l|o!`?h1b}`cieWnWU z$_5Ik;1c&q zQY^GlE}?6WB~#i^po05fYyEo&%$c#F;00W*nD;-9NsHwT1L#k+vcWPz%2Njs0C32b zWdVSjW1c*i(f{YV|8a+c022;`%7Z(++n~G*?h${l5y<8k-$$>2foB6QEWdSh^?J4* zVRSI#hXXbMz}erRY$;@vUgn=C$HJRV?K_0=&s#lJ=-=~U-_~iD_~oIEY)F>575;2R zHA@;;`mhabVz&)}z+W+iy%FdD&<%(lf_~)_N z?7D4+hcMtaT^~e6uO^iEv)R?{s%Xot%|*F7a&8?V`^AttnFemj^>8+9Yn2!6jl9;h z2jKi{Z(wUoc^FjZmjD2GQc*^dVq*OSW7~Fev6K>=E)M`**b0p8kQza%3@tW*XJGUR zvgMOhHm;0HRS;&_nJLPaW>NzbXlSZJRVI(VqHGow0Oq0*;9s1z-=GCz0lX=mTil>jHvUg+$>&uNO#hAm_Pu|4W`WVT zMbB1zAPklZENL{?M&&=28|`XaY#__uXzi*&&Rc5UqRq8njDTh?52CyuW=AV`)V-W< z1c0|lE}#f5Zf~NcAhu1JTRkw2sV8c?_9{%iFARY(Oaoc$@G{o8;8_(szLz8svZ*Xc2xaBu@-6h;S= zdl$AG8^NouP;Q3;V<6JbDX~GHNnFL3O*;kkM~vyoOJ&V^C>KWX>nkYq+k4t5FsM{B zm1c)Tv*OZps#)!fLlUv7Y}P5UcEBUaRH13wDUm256J%L+b1tB|0qYs4sM3V>OzKA+ z^$Jp{m=E9^K*=~OtIRkBstsTlofFHO^Ad>%zRXjViL)*Ma3U4~7{(;;slgWrr*Q5N zYZL@PHyj#FBw(5WQ$-myNgGTR{Q9V3J8;^ckW^;UR_gh4|3ftcr~L_~QaAn)LYpZR zY^||%i=QgqGI9_k~??dq@-o= zU^Wi?$+2;C0Ezs&M71@JME?0rGN9}BpY?&Q-z3{A1D&?KUu&TvzlS(=t9z6nOCS(Q zd+y%8+c|dUc4yCx-dnwQdgbNhh$PbdK;PY40|SGyN(6$Ax`M2Nyn>vDn#NFfb94W# z&Y|)8JI!}`JG$>P#x7%=-i>N^;a(Z>pxqW;28{24jqbR>3jqy{DqPOeCe@C{dsm+f@2OES~epuceL-;crp5=y&+PMm#c8x%Rbo zIy~=sbrlA7S((kMbf_*~q%MZs4{1^Par)>nf4wLr9sg>>0I8#o;1_=9@N8Nf_pLkJ zC?4T_?4DQEOh{OLm4a)qZJGb%tMs!U+l8;oxLPh$g(R>KU+5gWp_(tF(}EYR?we7= zH#W*RN=hcU6#EsItFPBpr!(Ry?BSH~{tx5b` zALM~Nf@*1Rw*(K~Z(oLTB0_n%AzZndZCnqJV;$P}PAf($$r@XVld?an%#w%4Fr_nkJJTKM`_^fY2~@HSyz2>0MYDNj3Tl>(etxl2NfGFwJ}eJwlK2K8)b z+5Tky{p-Syyf~Llm|WZ|LW)m`2AdDUo(HR;4||uh&ku8H?)vcLF%~w+JermLtu8C! ziQ{Dcvv@V^Nm~mMI!=l+=?8XnC%qfCNetpR-%>{BJOdOpge1WDJ^|#X!3Koe_Z&q$ z$K08YBObIgZhjvMh4hM8y+=L_J}K>pw7it!)Ep}yXvhnGk9xn6UT-hKbkF7KSnD|0{LjdIhdiKsD-QUI;dBwfECVnSX^ zMTq(>Gol8pftO~#yXQZ&I9Moj}wn*%yz+6EIzF& z`!*i*Jp(7t%mYHn;|^w+gDM9b{A(w)dM{j)G?dB#9+~~}d~N@JSHz0|Tc55nGL3J* z~1sU`egaF z+48_j{hk%8?{CkKHEnz*rUj5^V7NnTJU*<2}&zTE83zQ*73?4>oSJ(wyjmYfiFE2U5@&PZkX zQ>gJ}_zw%$#do?|V9?I4+^idH4;l^GV65ym z?G#n-(cc|2??(8Sj}oJYd15yVwR@SFMr_! zOI7dJ`W<&{;{_x8WbkQR?8AJrn$eZ{IOG-^ zSD8IQVP+L1!s0rg6&@cLr(B!=(XLh|zIUHOVAygckUOGA&p|PDknkeFl$-!tYE3dF zOc^-B@<^(lllS_HYk=gv)t3lLtFR-_lvmkoY3U6&w*QUd3gL@sNTU0#QX;>M)_l|c zyp4zUwDpOy70WxA$D#%$xYnzjT|vZD zjv}}DqUvbk0>3!%DtE|yFJdgs;u11^qT*C;i6S(NuLpC$B&C(*qope5VH_PaNyFm%wvWEg+L>jPEf`U{s`yUe<#i7X zb$&P4vl&V|eyvBz5J0AwVhxo~2+uHh+`A|QN%}}CC*!-I&DwE=y@orFK9jBNJ~X&g zSFjD`YWKL=E}X%4#dvf|dR^dK!&qVL+f-nIydb*Apj>on2fe#i6vbe&y-IIzNbC*5 zkPw}%eN?&XVV5NBCLT+X9ET0BsN6*2HT&Xs?v~r(o)3ne5PY-;9B4F-zQ``L#TBfG zhMff$QMn{Q42RNY81_)G*uyRUJZ7>SGsmk<4xz*jBp9}&rKgrIUO#(lb^diddg}D= zqQ%{63X;Oo>-J)nXQ>_^F8@ktJ~#XH)@yu^S=5^4@Oh-$&u6iAIq6EpoU-cMkSk|& zPSrf*VQ7&z(U*Ks%`>zLCw(aQ}Q=7lX^&r*;Lje~%kU)FLo`hQEq)0jQgm;_BUedSicGrbe>0i>?H$0)>@fVwBoV= zPpT{Iqx+4E3qcsV#qpgOGtiMlw{)B%N%0)GS7T*7+s)#%BwU`kH<;%zQB<)K{j!+r zWks&Hwo*cabOXDB7Mx3ncM6g&V!CM<3ELX-I-4;nnM|N`fyE9nWLcABYwH-dJM&fz zZC*ThT$^#@`|Wpe`xYMW{8|!v^q5T)<}~Gk+~wq3y!vJSC!Fzq+GR8z^2B_7VMLLW z4qU-z>Gz$Z`-O9O4_tege)LL$?-_L0M8^8|cVb#P=*ZlUBRhJS4Ev|eMtxpZp5)jX ztvSGbgjUXhCx6@))9!4yWM=;p#zB^dw6oUQ|sC{(1^|t0{ zlj}KjO1bbQFSRy$y6fDO>{|9H9_{Bww?Mt(*tu8xs2Y0PLzg@23~f4q|nB`Smbv-4iL+zdY>$N9^o|IgEFt z`;+(_Tj5<6x-L`8vEtG4WE=tuJJLFBY^>e@kQ7`3WMnQo0D1WWZVfKaBob+Os$5b$ z>>al>6>Ulq+rasd$W}n(EBb zUexVTH@Q$Ky+nq<-Nl1Y~|VqAW&c3a0S-Fc2t z?=u(|%FbqEjER?@l(hAw<)W8;7ma~I&c)@l`ehdH+3O)Nd|5jryoSx@5fs|R`A+XFZ?p|wPzX%^4Gk>gC#G0)X?_WA zcua2Bz#D@Owq14mFV}#hYgg|Veb!_e{RrqqS^k!CbTySno4)S7sMT4_L`!S4*VCG| zhqic2!D1Z9k+<;m@27iD?<)OzRCvlp!J0K09f{jTczeWEHSW&Yvcx>VBB)Sf_6j|Ws#L3jXj$yh9&7VGFEvNL= z()@{?&Q9%ScH^~_P?h1SkH4E_#_}&=8(@){!K4Lp-{u>PU&uNqrYO!hM@_uvS7nc; zxDP;!AB{Ew?>-{OgzGhdFu7qGg1zFoixQfTW!Be=N=eUrqWPYll zL~`<_6WI7PM%ot#0fb<*OhZY0j82o~RB(F0l>o$|p?(&7e(F^ur7ryP64THPmfj_- zY)1Ply-dx`%R}0QF|y{{BhBf~WNa$(qjOzUG(NA^3=o4dxOQQsAAZky$@Aba{RR5qJiUHrvAe=tNU9q+>mEO>65iyc zTIB|*JR!9XPhGUrV6!oc`by@9`6kbuk$PUfUr?oYFmYemosE+EBjti&rY2AD`aCubL)bB_zhb8}=<( zB5;xu=3ViIe!cXRxRal0yr{(-pJr4r;I<(l3EqM$B3)G0PiIWBrR(3owmAz^fr8cL_s-1#2 z@66=-J*H`nN<5r=$5F!I-TyWEZbYOjyO(ct^R1HIRA^lMxr_U2nock9R*rr0xKDbd zzGvW=>?@~n8;iRI2%dc<4x#rWT(>O2v1E1DPN zJ*&y@*0d{V4rm36?AF^)yfh1uY1z|O@Th%W9m&%`MhYZWhD z!4~Ubn`V937g*rJ(S+L6-z}Ajrsn%omy@d6#1a0fmyKdwx01~&VN2b-{n0^#!21Y?p6gRQ=y$PC2aU2oo$MM!54wqr zglHUlXiKA$?a-O-U)eSM?IftCl1pn$W=KEV0bX#vhtbj{+&p;a=x?}1?bUQy-Y z^j42HEO;VYHM^SkCr{>{2UBWBRnpZ3v?7gInAC^t|D!9h>V}6sFjYs4qwKm7|9tc%36uH|r z9u>Qr@^hxVs6&WpNu0}J6o_}WZVJS`jG>B=lvU3eN_7-va`Hte2Vd#S)xe2(jH6`z;?yQ}erp#~f5Zcx31Uta_sGpRkA!t|5geEnc`zNBDn#+3&^FPz)K_pZ1tLPN+?GcE5;tK>Ad!?m%s zdDt#wTsC*VjdiO$X}waO!;ImdPczp;8oGsNjMA6$S;X0Hm-tFSs?xmClUV695%`-&**{%& z8ytQ?`uX9!NX!`j%NvgbaSKfaEgjNiTiGXo#G(4Ik~6&_=%V6Xk-(8wQoQjqByW6t zPdSmKxV?x-TB>m{rr*d74j*w;1}{Ermi(M#Xeee%!2PZg0ad~+!R^`Mv~GXDdB$#` znU<;PYN?SGV0>!)(V7UZ#LRFb>g@;GjyKPP(1|a(&lapdp%nGYHSQq!g>Saw1t>I9 z0rH}y*?U;D&GCncDtU!BR7LV9YV`}B0YFk%!xOvE1WEDGZy=@V#@h-$L0l%*R9$#;o@ zqwmprftm&Eg{xCJu_Ct9-6grq-1r2Z3WsSQqD8fbP!v`ux{DuEq;^Eh127NJSbr#D zL%BIq1=u{p|3EF4V$RZT7Hc;PtwyVLFS}qS{dN4Qw1SO_a(#}0XnM0U3|=emo(>Ob zy!MSMTk}^!aZ~XnD^^80K7lQ}E}N@lVZASqaaWh~MFU5~8OK$9we-EtgFHWqP*^P- zBM5zSkGBVaJ1K$_XGp~NifXnX^=Bt6++qgWWYLW&K5!w@`RVCds-3x>&B|nAW`^?c z{EBj-;|Psr)L<{{N$Yd7L=a!uH{@ydc1NlSa)Xt!m^+#!lLsUjK@Xy~~>wS@AcZFSXrMY@atVQ`q9Ot;8laS!zdAP0m`3a4w7yE(P zgCCjXAPwSykEE*%gjk!BUSoqyY8xs&y7A-kEd&Lc*qRUr}@Y- zKz%nyD!+s>uxWM<`zq_3T611|7K*zJj!7cMSlzjD_(Du*_qJ1SJfu2CDquBc!bY+b z=PWj+s#SNxOEI+)fQcE_I>-z}BO6CIHj@BVf!0sj52O2lM?+Fl5lX_^o-a14n2a*E zy^8UQ^F&FOx)YZw^>CC%31-llm+yz!%EMpaU)|hNx?Qln30GMCd!1S7jO$KeCgr}= z1oI8pfo{~a#p`!ijf^w<1`2_x?0Ue@qRi%chS>{w|I?d(PL|v66)9S>7g&9L$cZm= z^;7F*>;O{ya3S%W`?N*z2V)J}wObSHooil2{3#Wsv_AIB>Q)-2;MHVsUbD&ll#up< z0E70Sw5m@DkFM={X<^*pe(bP1i|+=l!%!VvR66668*@rQAM;R?|0RlQ68|-D@`=G0=CB(J#-LGr6~ zmNv$S7s7Z*XB#U9m3fM(Rg8LN&5*`h_k?c*(W@0FQT7-a=>ey10zZF>h>}O8zk{S~ z8qV4f#zMjx+iln}@!_X;H_?i{n`;9U7ZuA(UaIYgp(IYe_57|_jWzU8Z&lT|Hmwdc z8ZdleaQ8@u%k>l8Q|&5@$8Exjgm|A%h$L?RQh4_bt$oR6NO8$(+P(;n_b&)6iqt&% zn0$@Q55%y}BKj20nlH*8gCcp2>B%BA#=*oLV+nVU` zWZxUg&n?Ho?d==btS4FIG{yu@Y?nAAy4=u|LSiN+B!=-e5E};gF{pkMk1K`fa{1@l zUU{-2s%ez8`_y9HyswDgLjDti2}j`pDRxgvQG$xD=Z%KRRo03Ud-hHqOeXhpkS1!$ zN{w97n~U2nmt5A`)^V(dEP;zWTRk|tH}Ub1RB@!PXi8A)M7gfV;_jd^mEF%0;iGqD z`Hvs$)olHwaTGwpn<7fvRDsUcY16~GJd|;-u&Ru@Pz;dwNMx=%cQ`AMY)_l1$HtF# z8}@bEDbstHB?-(qHQw5;1TLZt(-yzenFbbXsWW)8lO)PK`|&r6ySH}bE9?%%y-zTN zONH2yUX41bT`OEWG-oRq;v&>&U^yoB*?;H#aeH44=g-!N$i~alEi-mP0cj*QML~yN z7&s726;!n+*l{|!)qaA-v66Y3;(ao6nQl8`d^lz=-h%m&0U3Aaa`53IW+KkPns-}r zqPf61CW1#KFFzT4H>7$O8&Ss=Qxr57VuTUHi^u|UOsC>lL#CL3d{U0Y4hJ;PWM#!V zj6K)8S&&C=GFi{9qwGBzf9lp!*gRdv&}V#nrpxl+*rm6~z!9!#9+a8;;{;0aD%(B)9ww`^Zs^B4tUS zn;hHsc~6YY-bjSDy&w0lR*Z$oYIljeF=?MF$DWDv_43#Wu(X@)&3)t{Asf7}QnHz%TNbHn3z49AKjLG4Q)4!40>rZvWKLnK!^}mXwsdJ^2~ABbz^@`#~~c zve@;YlWFt^S1Z%?BM%<9mEsy9kCs^VWh(D@a(Mq{D_n`mYr~JWltZ2+tWj$Uymu>Fu@>B ze9_XK)mg_=B@4=GC-scBP2>${zL+iqWL(TAyi#LOr(XZgDgJd^g;x8C7H0ajVC|Rd zo6DQBnfxB~J&l2K{ujG`TkV5wfxU0INt%P&}Dd!d?1~(}d}O)T>d-Z4J<&T(c=) z{+dy(BN&*-^GaW@qJ|ll@yUznG`4uoyw&{q^4l@)q{(w$ttuT$!-J8jsfwmN@*H=E z8q4p|BC}_V`U$p2qg#X4OH@y{-XEkfoxSw0_NL0OIs-Tsczx?;%GRTa!im26p`^_IA(}9Ef+CZELp^1t z2_tQo1IE3SzJwyR^)!Z4b5=_8dYO($u!*e<%L(!?ic9)JVmHEz-FFr*J}vh`rV9(C4IL^fmj_PhR}+@I%F5A0Tb@B)aw ztx0Ri)yaz8Q{!nf5}f@_eHY)U<2LP*2hWgT_hX-Ti9Jdg2(CY_S48_fQD2&8Acx4t zohXtN57$FD?EoLoWqD6?_c3e@o~qlYrEvqv*Y5QwmdZPh4AE#=T?ukx16VocB|;Kk z>`9X{KXgJuf(&Pr&VE{{Z}qF=r<=>Ie||O|RDAYo->Ud*#J7)eecLW^Gx*%P61{m* zK!e(Hzmz7WG3j)IMUN5+>&Lq+$aP}En1}8Ld%4qHTuQ|!*k83K4_UZ=j>aXxMVWlH z8(E@V5>}fp_5Yj{cOzCC|-M>XCZm7`Q*ohKCdpx&b^wyM}t(7{&E)xna8i! zHD8V>yZc@~Vig(qfg(pg`y=? zJnH)G*IovS@kW=%&2of_C%_aI`Od=89Nr|YP%zVvJdzDQpW{n3U-jk=M2v@Of|dSj zRvPmUgI@#b20VdjDA=bY_`(lviZ+xG_Y^$DJJHH$2xA$RGl=kume))dG=pt}oxR>9 zJ$OU=)QK*a^S+*FY*&6_>cmq*_N#;r;-hfY@!~u>#p;OJbv>k}lriA)*q$k3Sgngh zx&La?fNht<*z0xf?3V;sP(~P1%>|RH*HGhDU|UY`#J_l9?eRBNk0Fb zGcEA!`TZBK2c+4efv}%V2J9UtMm8*Wb21aBT;yhZqMlWf@7TEPznyew`x$zQ^*On7 zxI;^akN6-|S`G>$Z>3he%#}g(it!pDFhX?R3OOy_M&Y`u9?tWvBr0YtfSDH;-&>wm zLUuNoR?a_mg)<|+UwPP@xa#nX&0_M^YU|c6zWI`k& z`r`rnxC7d&vv*HEUsFRm0WmR=MDW=FC&wL}rZK2~Td#XJGg*MocW;=QueMV5u%Zdu z#xdb8M;MV`lCwB=Zro7FIJQsASkEHWuLh^d`%DdmDxa!lG*ejt?WE#))!^?XwQ6b+2?(Q(iDycE0xaVZ_}$;&ibB!`>**Uob_~@9VzP z9*D;k^_PzBjFftt6VbV1oZ6GOMg@xcc}8}+kuSx;_eq5LCgz#Rh{H`hJ4>l(-?_`q zd(pr?iEZF17;k+|MRJyyU2t;m0hpt`_OWpAo?5JLfP-6d#}hxY#N?}on)}a?Xn9t0 z1xNu79%(5axPq~iUOqw!#f{pXQgxMWreD5By?fTBM&X8@P=#NNiS&CVuH-vMl}YD5 zN0qc7lj-Ywz=k->iq)zey#a$%BL(i6Jw!1IIZ$xvAeHOMapgMp$-pDNhlV*8Wu$d0 zbXOMh@3r4s>0J_NT@>AAVGGwnO-^UO(hKFx)J2549d6jlVdz_V{%aM5BCtAV^5-Vs zWIs*EbZ=r#zw+TYjK7Xpz+S#YnsQ2jj#KvpDowsYug~2jD5H7aZm*@YO^OozVEarH z)r|5YQ|Ph!t(i@%P07^j7l*2w%ce+~)FYerJa=XzjFsNV^M14A-Q~vYHx*1a%lDb@ z=UM!6XRg`uZAt9&WcU^I{u;?(RgD1#HPPsRo`uPj8LNADDB;1L$GOo&@^BL`f9k=7 zA`aZ6!)uN%CeP3E(O1xw+dp$IwP4^}rvN^&Z`u0Gxa~!Hy=^!;A4%Ki8Fn~axXMF| z=19XIf>jLwMpcS=OI#S5i^=ihbnf?w@D{dEUSHGY-YHS+ z%=!n!QgMsj&v{Y&yttzlGw{Ew)RE8p{hvO!s?=>&xLb7#4|cQG2|pMeH$6xxAMiFo9oD$!O)5tR&;0m{5hAu!tGR%?8LP?xEJp1 zHig|}bC+Ek2tF>A3e}+-v#Bk5{#d`Ci8tYet)#85*I0tNr=c78^?Eo;@ZEYvTgK*e zM{^h84jAk8d_jdpOM|x$xVr>FaU_z+^Q5F$B2Zu6a-N{b&d_YKlWLzJi3anT(RN_! zCb`lNIcYq;m@;-Q)8D$Gb8UQJ)+XO|?Q=);a`KzAHVCnl+DcpFfVx9=;t@~gfRDkF zcLN6AU)|L(Us!?^Mn=`c7c_D*o3#93@QpXTk0N2&cRfO`4d&Gv?Z}4*4Yr=8Kbqk^ z@HI)$glX`7oZPjt$4o*>^_2kvw%5E-`&i?xA??IA@QMPy#}}yfXSs`E4W+3b(#_oE zenBqSKBjz6u_w2kPL=%>ll=*aH|!}sZSl45!nyIkXf(qXXZF0ykh7k2Tu7?(xP){C zDtQm*?1Tt+T#0k*JDVRNgChqb5hPx-u4u|OHzfzwz^<-Fe&(^6INyAXrXKxOdh@2Ee|qy3 zeOeao+Cm}xyv3uAX3Nj{vJMUj@c}RP@BVb6>PNfEha7(WDf?^5nNAX2mmE0!t&?3@ z597|K*i7LARB!elyF@miFWopr%zPmc>egHG0RDlZGsACas22NrrQ!6wc`1%qn?VexaV@ zIN5z96Be;lU?xvaX0pz=uZQx#+i<{JyVBU**U$d!z)QW|F{4{t;!AciRm`$3u;=;Q z=s)@O@mt>Tecv<^Pf(UV;x(X##cSx zKzGnfO3m$K5UtQ~fLmDVK+-@T1D)J86VTbA>)A?8)BhsjcYCIuFk3$Fac80SV{`B+ zzY4Q`k{r03-+!^4X<%k`8|H!yJ5hajEM)v<@sj%iY0jJz8us$%Z^n;LE%R}v9tgf8 zZkjfpe*Wia6_@&-r!A-V?tweE2$s|v$V-pE(w7Vy(?Y!b9F@~lDYJ6GElGL+b(`Zr zTcjA_q{Wv0HTRgi3?yHKklPJqrQv4|;4@B2pPHnlO*dFoN~Ab9*C9rY2wobc(^~zI zHS|_B98NMhAkVtkO{nC!p6$?r{^|wEM9c!4d1q|Tr_&E~Pg7OzOkLTw67(wY>cF~r zmWWL#=~(9a{3kd$So1%7Vtu7Ed}3T<7h{UcbKbOYwy{QSr@s;qsvt{>#ZX zMG}KMiBkBV#-mqqdC?yt%p*_S%!?)?zQNFR_d8dOX2I(_+$fLhbgHJR>TPn`^XQ#r z$w$@o_{g&uat~g-ixZx^;sU-o*O2xQXyCLXN|)HHaeCIH4Y@JN_<94lsf14E1~v}J zT*?&i|1GtfZ6;?_PF3ivT<_$4x?1b*Va0DNJpc9QBD;()i&gKnqu8z_eS^Xg{7_(L zPRGNEsA?+#vJ|$n_r~{KOk&a#@s^!R7iPo3if_A`^ZhTPUVaGqp$g2G>~xvFyxVOI zzC4XkODG8HcHilEMn?$=$HMf5$M|=)MN$coli;HP^j2tdeCr{B=CJ0&Vh?*|DlHZ; zktqFQD2Qy7vLH<`CFRA0c5u1};c<9r4ENJCVH&k+BRyu%)#0C#qXMv^+3bVcRF0Py zPwLSGIqu4#vs23CL_zib|}Wb=ZE%v)hu8%eq77RKe4IYym|ch z@vA#Xdfzr)M|EA3aVdCmdh~r?rwqTfE8G8?dnd8J`0Askc55*uV`GE?7xPu!XTBgA z4wGmR04_bM!LhSVIi~-a*KvR^ZCKjD#ma|kTx~}eepr*JByz)rM&r}q>zHYZ#U8gS z?Hfp_^mr=79h`|n#iq&&Aoxc<%m+Dli=JUTm*3k-cUBYnTFoiwY8w4Mc7VNP#9!{v zBPDbr=i8IkAldf5hRW? z*ln+6kOkN9;k__19^+54PIRmAyW+0Au z(DZBiqIhF}=blUxF&?M%{_4d;4%%L7blc*~ii}x~U*J6+5-c$uP%YRI%?;*5L03%e zRab|G6zyq5Ua*E>S4E1nhp{A{R~k0Kl%B}Nf!Bzef}SsNxSs2uzMQ+bVzb+t`J%17 zZO#6SMaq%NTz{-m>7(tJGaBY9uB0n1IdjCytNOiGO??m|m%X#~lRl!gURffji++Q> zx*4+v{e{0NF0S*`TJBrRy3N)5v6bn1Qm7cer9yp=-;xEBMM|6;$BicsEsnl6(8Eok z#GP>9i#rQWuB$-kc^5)#>@L7B*HbOl`yoMT*dxVJxSEZvaCdsi<`hx(!3*3q(#IrR zx|D#f^e^^j7lTzX*Hbf%7t97>v*Nsqv)&9Lemx=KloP_WjN{@|zhXOQyB%jbOvB8F z^MwMg8xA*nK0c}XL|*l@QQ7UEQJZUaXRCp+N-Bv(M5CU^yvw>)KdMu?-2q>wMJ%d7 zV>B0Tz5GF1p$znWinlO7EDq!gYF^fI^6X6+ITgZh&8s6$TvHVTi*9)w1OR-xSL(A> zN{=9H<(}0JBaK@{Tql($l_CZ-m3XNMibaheOHGfIG-n9b^9n^<;sprW)~_f2|I3;5O22VB&`nU25xXCTxnLsmOcd$>)}-pJSkA#Eigh~zGlCm8GH zwh9Wp87H6gH`tShl$zyX>7kPR2i;;IH{RaK!8dA;mYI5m+*;0F=86Py`FByA$4-y!iJsXhW zY4+f!UFp#vRMY+1=;HY+k=ESwwA>q=w)uy3blj)RQtM$R_DN#19fM-D&eqIy@WGzE z$XKGef@39udtab---teQVml&;0n? zHBl93Yq~4O89(1QECCDAIN^D#9BaksMh_CPmA91}1J@qrTz@^8Q~X-}nA4GNuHYd2 ztZDhdL7f`%+;W%8L&K5+9p4TNrdY3Ro*yl?vn?r&qX6e2cvU8c*jOZFXz}F3WiRbS zqH}*f*z!$H?5diWxVUGGS~mKk%|$%UA{9pv)FK>kPomMQk9Hx{Jf&~-T(`x@*JrWAH;VO zyXQF_5cR}jZ70s=7DlGXDM=yvz-`&Grok7v0O=PTSV4+{54Zba*-z<~v@~f041JK? z6|k50CBFz)$Gj1bLRnitRX|j>PNdH}n-VE2x{bWGVbirx6TbyXFTz>MzM)eM0_g!p`6oS;=c4g3d#TuKaChFc&CYmWxsvmNz1}!!zoH~;0d|9bvAN@0^nJRmR$VOM^ zk+L;XLB-MPOFu@&o^v7(kL(ZOZHzzpsNVSyO^`Dio!AhtUa@A(UMSTm*r3d-oywoQ z(=_9s5k3^lWr;wDt>HN~1_RyJ)Aqp&%E*#md6oC4-vw(A@&4qy@=SSqy+mNfGj=Ma zksOb#U^?`N=u~QH&D&XOu&m4VeEmteC?N|;J_+L|IDQVgo!vD{nyo@X))!f@g#?hjJ zk+jP%HTvyc&ZwveCtJk{_WwUMop)SPZ`}7m5y2JCoP{_tG(*EZLd8XDE=)_{a!EyK?{&7ywZW=Yj9dOIh^GPMz)&thz#I7Kf*n1ZMNfwd=M_gMP6 z0_${OFTs@AmkP@s8e7jayfzu4a(WpzB5jjWM-Zu@-n_%ZhSBzXGK$W6+DbrIB1uNH z(RR6ZMpfSAZiR%^nMbVywu}+I>r>)5LtasG_X8}whk{GvU9Q0C{J9d9-kkhHhA*A^ zSE0xHMbFvqQ!kQ@P%WtrL32_0YSphc$Lt(Ux5i}NFKJlNa^pj{-tI4O-%Cb6s5zv4 zC|x-Q`i~lz?sY8Ue5Z}++_y~gJs#s?>_n%HI&+tAQC4QtT5aWLmgk7!X zmSrv86b^1E08o$G`D(OpA)|m9&#Uj&07=rMC7TK-LK0;$YZ-FouO(Zb8ctC|av*Za zemWkc;7jy)afvIo3>3H1AIr3rTZcofVw#7f5Ib!5!4w65FUipOqhr^1=Tmn@*~{-w zG&!3j54uj|T?nJuNhBD`3BA@1h~kh3nRZEdsIN4?--s^=n*AmD8-$c|;R*>dqOo)} z)pf{4iJt^E>P*_WqC1!k*z0VhQ7t3(IB;qQMAge7#X)hzv4MdWGOI6)K27)KJ4w_I z{A4ZXW-wwX^+`$3<&6p6XquaB7)nTrQx~d76h@wOw73@e&V5e!e!a52r_1GIHAO&_ zreFL8F&Z9zmiXvVI#%YFUSM9KDeh zKcEm~CPZkZE2mK1qSX)})FI)B-yAB?ch0LC00WLc)`%QC@=!tyU zb>ss$Zcqd~UQ{eN0K~#!k?XDqv@VyMAh)HXZL~MbY2Fce=cUT*d)=+w5n)9c!}NW4 zq4+XKQb>I*bl$e{riI`gwhx2+ZyGjwU~GnlMGOo6Cza=5>TY+*gU9PlK!FzUx2pu# z+!N=vSR(J|QcKfL4UnYmB8CgS@AdBCn9*iZ%* z9x)1GAQ5)!*>IkK$DNf{?17*%*Fe(>o^=*L7Z(qN7h?`~lWuVo6p`5D8YsXU3SkvU zjCa-NV?N`F!Eg;8B2zdF#l@nR+kKW9yIDED`PN4L^qGvTz#U#{whjd&!B%su|#$tDAH9np9ccb z)DEt!2{(s4;yBbkPM|J&Y2O#UTNaN@gQPK;naBXM2Kt(uok9|R0I8H$?bWSmjen8s zzF{qSS?AfA>ab<8-Gs=wl%1U4?{Al#77XaH5btK$3q>t%`09FS?VNp~{4~7Aq~W9m zbw{|jhO}!KikB)q6HLl{Dx$!pvnaq zf{S~JL#MrCZ%DdH>8&?ep#-gJZ7-6ObbV}s?#Q1s5)spLWvvu^iQ+Biq7w9IR$xMV zj%2Pua$#?woGFZy_QA#>*hu_Cqay7K|Ew zpm7+g+4=CTi&I^pUkHA|FXWphR=P=^u&0&2wkf1*opL)5it7H$_3vms7%IQB_Xo@_ zKs@ux%C;?Fq&woAa>i+cRAR;OZtrtAS>?i3b+hl)E1wFtZvC8xzAI2()BR%Ev=!By zkfif*HZ)X9IlDK9!Nx22HJ9Ww>jPj1qyo#N>xKTO$c5X4r=i_8Dy6^VzF7G6bcVYv z$LWCOo(8c|>U^OQO4$$Ce@AhBMOiKF8lkHm+*Yv*J^D6f&;H9|T;s2n($=d+?dwzhTmOhm0rSD(>VYO^1E-(F+`iC?n&Qm%c! zVcQ_ksic7eby0{xY+|$@+&I2cITUW{O*lo?oX>5av8&PRkNkKmyGsfT^k2K(z4_)O zF86R(Ic$3qdq`99{)RRmt+(a_|FfIA=BW?v9J&?K|degq%W zd1&J3h~M$HHX)EUCl}~4@MKXK{laA$?cL>vPPgJG!@kx2f%@?{e6-#FaL85K^4+yL zm?*!gJTmZ!Sb0WzG6@8h)TtF(LmZ?I81vV2y||QlsrN-k*p=!N{`8UP-$BGFlcyqV z$|rvMdb*ygzmx?Tm4sD$;y{iDFlrh%klbYc>-{JwdV2h;D0cMUpGflTLXQ!NZ5*|% zVR{k@JYiCNfe1)o9@zk!!jzRB&P2j0l46z2J9efmtCC{tvge}ZtKE0LEZ*J<3b=?F z8Hr5-eSDO!;r6k}A8{pNOE!dtT`*A z{IwPJ|G~|*F0@Wn-I@z$lO%ZkjI7%CqrxzKzvVsP*j6^xYasp-F`T=Ude=|@b|q_sVzZimz&F$l%#2sg>YI2h*@RQkl;yQ5v}~5rYW2Sbwy4Juy&{ zITU^H&e|*X1RE-!&2`yTUo_acq*s0@LUK2MCvYv}ZrSDg2C?3)jkoaBAN$)k+-Lgld4fMMd+Krjf^|)*^vq=52qI zTSJhbkF=RImm90bs;lE%6G|7wI}BA)YQnFh0(o+9X|Yy%W!_(3%Ma31g!)78&~idz z0ee3OzmDF^zWSw6-)HQvTn#EB2+pIor^yWS=hf1&y;z9>u9Gt%pYvY3usle=KDOy3 z{UPc#y<|m!;KK&1HgNjUjh_5`&GUvzpXv`hX&QG`Xn?JXgana^ zsajT@ul7!3?~mn~|{6I2rUF4`}Ce}B%?AI@fFTz7H@A(>XxC^q8RI$5IX zRE*d|%Y4IuoU#u8YxemqsaF}K`$X~uW^cF)PQ`07ds;zfN?U+s%y<+rqX z_%4Z5zo&!HZ@Blv-%fg>1xCu@HxKXb&*6MLuJJDJtz}5WMs0s*C+i*fPhh((r~!*x zA>h@HSRA`3*V?Q9e+;>Pr{9Czyk|KHfVYO|_#w2=V80tb6Pwz{<1UUXHjl&_g}Dfv zgy0qW$Un5?W`uHfwRKCiYNI(;d@tw2l;$O8YiXiPGzty+*$$y(RnP9kS{&1nFYzxT zR4@oN+`&{{deeIyw-pW$$IworaG78g7tS;m{R8PS^lP9crmJux?i|8VRW!7y?)dlR_e3w-{6!u4c z_hPY9>gTN$IlJAOC^%OSeZR{2C7HRxv@-^vFh3UK0sq-dhEXBy08NZg3OV+ES!>S# zk-=lGQ2!K)v<62QpKyZNFi)(wmGC4zhUv^1>@mgl4KJ}dxTI@JIo`JBlo$XZa&gpk zuYsfzu&RXPP@F8x_=8yPg0U7bAJT;E(i3Um)YP$UZ`ZE{G~uZmoRu$QJjwIVnu>us zOMTLWZ=st#h&IRrQEqZwPh`r{(&L-j;jl#7jI_?$F3J2jRzQGo`v3fAH-t1hgrk z%8GF5HOLM2cyzIA{#sAZTY*Bx1-8L5FL0^49IhTTYC!W$BD7l-bM_sNc^B{haZS~P z8}8Apf4{!@Gt0dWCWTE&4-{yy%Ev0i1cR9XeiXDobr=tFAQIoYmv}>f-Yks|-f)Oi zn#OZycJ{fJm1QTj@3%9^^;J$=)4q5itDXcuZTZf~c{{tRdvpJpMv6&X52Xi2-8{mU zyfG0ZlB>vtGcCr?t7{q7XLdwyx$?T6b@=j5)rl40QzsEg11)E|NYSzb0Tr>t1BBOzzRS(B?Fg8!|IJkCY6l zvdYatyZtG562*4;RerA@<2>^$N16i9XQ5=FED`9|0CAK^mGrbHl~r6HAto9ch$!Po zXpQiJ(fTj3vz0}*W*(O8RUA^)OzwR?j-=HxMY4Jo3nrKUL(1R~qSMOh7p$s4<9!?5$^t$FP>*z9HD9Jhp$|+xJ<_LIo+I<;w@F3F}Juto&03>4s4%k zN3;CtD3W(cji+|THp#+=L5E%;k#NTN<*C#pOVB5aVymOYElJiWz@wI}QRdcfEvdph zcl8LT0kz4C9yNQ_zj>FER@T2Mg&o!%<1MT3Wf|mRKgo2&{=V}tY-81M9))?WuD^Ai zTNrkK-ofC`N#)y}9wgZ%7SZBk`QzL8BG6Po7^tOLV8G5n(Yg}Nhc}O8F?50_2oeo5 zs$<({W!Be`s+B>)`j7zxmQlH%(@uBJ4{N*9+ha?pk|V|qU5-DLrki7!MY2YC>fX`QrJ) zp3SfAjcFsS>nO{JrtSw5u-xd~XMdmT=93~M4ujwH-KkkS=2U!7@m>kT&5&kfRVRB` z^V$x4)eW&~SGnDDvoWG)+k!s*;P@B?a)Ye@$Vv{xJt~rxYX5;Xc8N^ve^c&9?H_mA zq@X#BzMH)(ya42u&JYbM1fY?Yl98t}0A9lUWwrR&kRx$^D$!oA!P3!@nP~~9Vq{eX z%7Lm+Vq!9=NhxipJlEn9TCmaIfZkY*-kE{yt~gzF%SsVxDZup#a+% zx%(~-V-{#qCgYs=EHn~r%IXb&w9mW!GUw)iu2Y)$?8T?wT7yUOTKP)skoSdr=h~l; z2kgQHT-+gQA5movdq&&+1?AtUS_XY1ZLA`qV0LpZiN^?q$G|>79EVWihde_X+z50$FOD6Jbw-fAmVQj;5!xh(M8>xw(@^5yNL8OR)?An$7FJ>d+K(e| zXI#$S@I_O^*bIr6Ni(@)xQ-mC=@V&%4@RYLPCjA@>M*f?c=mDqpO-?km#PkR~2D9>&qa|?&1pZ{Ox376zM0*1Q|6smMa@&Hpl$x z!=rTmBggvW52mk0N=W=Kw#gG~1vtjc)Q5JzS0)jqIdYnO8d{1hkn`mxA*{i^Ca;6L zSbo0A!-qY8!Tn5M)4Dv+#$4gc#e`ChM=wX$MoLu{FkzWCYw+Sojkzapza=9gaB?24 z_ACn>b1dQVFCK1ZFuShTv3;<+?>a4+_}RFvVWG~$nqWoR=aPL1dROq&&8{nhp-|l&eTLwCT1o(;Elv7Ywm)w=SiG9Wr54REzR+` zLxKsw%}+mgwyoja4Gne~0#m6HtO|!14}N9B6>!o?4`F^vpOIDjvH#(xApe2?8I*zk z$C3=NYXjL8y!t2yz)`1U6|qx~pFHk;);B!z^ifyGVE@qLp@Gqs{ztt-3#*&cqf;Z9 zkPFPwf8YD{6LP4s3^tmauin$roI@2AoGdwB*`6DCyeVnIuatk4uz+!Qga(ON&pPXH9jz6P2v z3YFMSh+?3rymE(wkpW?C{#;{Q z!N+hog(4^`?8#m&a%=+Z5Pav{8kK0IYYk1y#Kw4^V1dEbWhNJD+McEf)^l7hkJ;Vc z#>ceN^gDc13=LQIj(vA;Iq~`w9sX2KPwmO8z6(2cRjB#XqVx(|&Y_wwP!(3KvUDeV zhh^9Or&r$Vu%{pQWQlz?^ON)DUDCZNodW_#HQVzF;`L}VERpm$Jn7elF~2dBHRyC| zJEl>=Bkv&xrP}8`E5f&4{o3{778fyM&fYaZ^^qdV4ghA!W3_=U;I&BAWQ}7k3d4Y* z8RMVCmnBwd|*Pgk~y!SGT1_uTaT@^nl4gyPWaCTQ4>yr z7CkbSKe;XdcpM))5bFTigG}{pFxFeW0-*Ym>j8Oi92Y7uQBIG`70V^1pG0Tk*QJ`$ zGGhu%mI1L!&b>NS^n4=BbV*2mwszO@DBc;R3y)}y=p4O!O!ZHER_)rnrESlI^X9MZ zE8oup4)@lbNuNERN8`qL@-BRaw}2q9`7=^0foVM&C5o+isYKAm{#3kcro!WjCWLjQ zR=7y$WwE$hly8rP)JVR**=VuR^p|o>*d1ZkG&BBsLDICi9FtYID}@-VUItFh9QQ^ByzAa3X;|XBQm{eSpSE!|28w5=@8J9sd}Ll^((JU;?ho=@$a>IbBAB!am5M%l z&!xr^wlwzc_DedR1s$ak^`kA^zXG%0o+8}DmwD#D5chTa`u3P#ZoEg<6eT)wM-!IMFbH++`a5@ptYgz+%i&UDgqkJK<(!GRBx;$ID%zFI+yls+}$aeI(Cw6Xaq@AnG<;cB+ z?yea7rTCosYWD{-&Wn+bILZTRi~!}*%9cM*NpFYgscX;|g-I-jq#9TIC$pI%0Ig4g z0#)eFe@CZ(RD?_Lq^Qe~xhDWckjw(HW-9yD<_-4Z$gCR~H&R#=sP1m=>t@1Yp~7u? z)a4ukW4h6LeQuR^AwU0hTHDpcM=vs|Cx+Bj4|E?xuSvI28yh=JV zD*r-hT~R=AYeuEFW`>?kx%7o40mhUM$%A8JM5WF{wlMc zId*fHv%$X=O^s$VvKB5diA2O^9@iJkKsb86h(cCjRs!Zr+q63S9p|mAsQjsdh^qk^b>>V7ZG1<6RwvHc0_(Nx6w>B@Kzcp z%xRd8@b-%$KfqpP)*-Pvppke#+$cvrALvMD^QQ+XwEjI9qDNiev54aarZk`NBAlyJ zT}`os$@>k&swg_?qBOQ4Ii^YkLQz5nPLe7CNd#hPNL(f!ZZcSjzS?tIV2isIs#?-EtZR7lH-` zdt9-ysM1P&(0DFE5SIDSmojQZ%OZVS_mC$HKk^Xj(kHt^tDy?*NNJkq#rfrob@mYY( zY>I5ku(~SU8}SV1=LmrXnms4bX|_I14XCe=5kI6s(VfEKMc=cg15M0hw0{4zesk0h z;!p39vf@Qh(xsJa&|EL;>#|Ab#mzvmT?37Hg{Ky_a@FbYkESGMwZ<$3s$7xZr>76X z7V;aPr-$S2kf>n+Z1IoH16Tt6?h+k!I9#aoR<4JEMqCKMuS1}~a2srC4&;n3Lgnl@ z74G0xK<214=H{uC4A8D~7y)BrenG)DS(+pttpG>B5`;|*tFkS+=dq+le+F^B+C}jq z?-awbs^aDZtgl6Eq45Jd<~Y|r+}7$;?mHC8EIlYxT2f1HC7N>R8!eUd>|R1uT^tkF zd+R52|0_Gu(1$f(OEnI49Sk0}7&?#1d@)k)QO0rCGh=w6x5+ix=4i%Lyp?ursr(H5 zvcRafD#nx(cs+*?`lfYhe)>|SC~{KwTl=&$ zgJ>q;)x7DCyj*%usMz|dhRI;)wwVFkvAbHV_(Cx}E6{>c{2qFw83a&%UyOjV^kn*l z>mL8YIHT;~Ri>lEB!fY~>a7D^>lAJQG5%*wdUFX`lNNA9?QMk^85lM}rTM$0hWV4_Y1jwsE3Pi&Ic>b%F30 zmJy`kOqrO=aTm2@?rlZCN|F8mHI?_@z(C38gzU>0Lt*~un}I&sX3gx$7+Lyfkp@3O z_eoD;ZaqXP45hBiU}n^^3dCBi$4KJYG9fxzjTX;aXBykdw({^2S@7;9qRV+y-TN~p zVt>#5_3e%K?gRf^@TN29vx%hsROVCfy*r0$N1=()4=UffVm|~QX!th?MwoVQ2}5b_ zA-bQ;lYhRN412blg7s#azsKEJ8=-2n$9$;Ry-#${e$D5SKy9IZZwnn=>O)&!0egVL zs#QMq7ng7R%DWZz`X4?6We=pxtiAeoqC{0*3z02qR*-+HA%BS&XX(g-QldtyFDJp> z#l`r=E(q6mB8QupEQnaW;)=lLtdCSDX>!dDmKs^rSv2*uHOAh0ABkU`$x>??P$wy6 z1kPCeWGO$YyUIA{XZ^8F@kN`#`4aRzn!v$-@j{H3mhcen*$nmf!v=E9&&0cgx#;alc^WIFxAji?QIakZfV~??Ju;-RqF1!c_F; z%!)tfNh8W-X@!ZOdk9xW@EktP2eSTdfD$lCaE4=86fy)@7~1nA***!CyXmv*MT1bZ zzG;KTPM6tAsgjW8`_e}GPm3LC{D@NTJ`Y#WAel%5Z zxxQkY3hPOpP`baGn1#e%dHl1fZuQ~)oI;^wPs6(&$A6ARndg2mXt<2L8cmRX8~Kb< z$IUVi*MooRB1?7_#QlL*=DcG$w7Am!>0$6p^DD`hgIvHw$jr_2Ax*FSI{adl1Dq*Q z&K2giC=53jv;+}}i6czI^%RNtPfbiFbJzd@`6Rs#VVWGPv%5g-AY1$F;M;gJY+FMZ zwO0xit&*o|cTF~&&$M)P8wktVDM>HA+qjU}DkhM1TYExvWQ5B~3{zl>gx$+EQ#JfM z7sfgu#i7spD9US$y>o)j6lr7DmemUNuap)sq86>5f~?>C`TUu8o7?d4d7;;h@Zu0$ z8nX|!2YM_gbI+%CXF|`S$L}_98{pkYFn9O`zJfANzNvtYBcVb7CsP;>TJ->y|N04k z6>;%BXjspDv|uW(Ku%WF6lUJhhy||gifvBXpVghjwBw&%!nQ7+&rM5xXWu56!>e=O znk+R>Hf?J1NH_>-n*As@T_L#|OS{%tdau_6>f)_~_)q4?z13f(H*3m@$U|w$2{k#@ z8>o<4xvHJY)AJIS_>Fi?FPbH5O^334WoJ1XZB1!f_gZwyl2TtY%ja6;Il6yA{)z6y z0QnSDF1m39bfOb76Wb07;BjQ}(Z38*f6Zc$otw)&>R{K!U^w;p5}RswylV@n8%%dT zBtTs98*~^K-kYCFLLXfd(3bNJ-~M`!XQKLZh4z^j=P)tH_(dX=Y@>MCr3>TLeJ8HP z<*c8rPMX1na5l)>YJ9a*J&Ixx2(`)A{toz7RuC#^qU48#Yr?jl|@P10(}L z8|OemyZ&iV0RiC;evSgKY){@bX#|SOV7jvAZU0crlC0NG-kcF3}rG~=d0o)Z@JQ5KTWyl z#6RNx*FpIY4>$vH%6C47&qq^0qom>EUWa7Md8r=1nt*r7TFVkP zD|YcbXV`|O;7J(Z*V=TqU(~d)*Rmmv42-=?I zW-h1V77ViB_d+3-0&Kvs@oeXjJ^{Lp`h(FYGk_A@3eyo#G}adzFnsBNbpTHQ;-a(( z4l5l{)`?8P3qDvxEQk4t54Z};)mAo|Jg*F?g73&OLK7GJ<9f8GYO!`YA-{y}CAOA7 ze=2p|A2=su8>){c=BB0n(c~(~f9|kGskuJuQqMbqCtr~Woxu;~&*OC+Qj9N=oToFS z=u<2Ys{yOLD;r22o;CGb_d`4zo}`Vq_@kN=`amybZ^d%qDb3Va3GYl1yq=K}x_z9+ei$;nj}M2$F2bI}Maxe8TR z?bBu&%DLf54*Plg^s`xefg}O_1uA&Oz^--0n!-W0CRM(3dH!nt%U}J7LNBM-Ndw`c z1$eL7UpY|g8Nhp*fi-K@ILLmFJi`G(!$3g>@+b*=*YM*9llbnhkLi%bU|9)&V%qI> zYM`n|MeEt?{x+MktFh49GApi^z{-!!jB7`RPayR*)fCM=%FQ#boug$u_iC|M#Rb@P z0TTQq*({2>2F#soC}>q|IDo_sq@9wb(kTMD$da^OTO0G)Y8#nbz3+LkCGXA^Y;GpO z=;#lA6W8YT9X}&+37aC_6Zw^uF*zJF%QsnE*p&-l$IQ7dnXO@3CI36waPi`j)U6!^uhg&p>n*h4y4CvUwEoGmN4hC_{N zxN@EY8@>S>vI0;<8nhIdqZgokR;&)@6$Zmzu*rp_q+n-U zH1y8Js<(mzmB^oonznTEStbx?8&7~uYeZ9Lq!S%XXTrpCW_Zb>a@*3GaQMRkePn~n z*kVbBw$q_69dc;DT4^h~@`dKR6GmWIo+fq-h39M;i2KTs*N;Du=@fF!BB(IC)O}7o zT;(oPFSEIvA$LRyziqi%mf_l&A@IQNgzxPv{s7$S%jK(T8W9EG;_fYk-G`m>Lv&Rm zot|G4Jm6{lc6w1sG63S-4j2h6Bge^QDtTv=GWxNwV7h8o@U4h#9rTR9+=ts~3^@O-J9@a_p23x*E@EZLh_% z*Vew-f6NyEg>x*qv9&_VJeu@v$~;+GC%GP-X#W)@^70K7>&@5xEA;N7E4!a}P()%a zHkSeHb}G8-Mr)3otlrT`X%n6c59ZJxg)M~}PsyKsv9Ll?lj%9u^<|2FDj%LGo?OUK zIHmHv)1D+P?8>$&;NGk)a82ZB@1LmCMTSnKSbY=O;2-OU%P3GJBsKWyFd%A{y zh8jaBB=6?ljZv=HZ?#puUKnx5;j>t0L*dSwwk`B^Wv(w<i+Lk5vE8&3lql|Ho2VZ-l3`s5Ng~?-S*#CI%vQbj@;>+k<`a$s>?+ z`>*daKfKjkaM{h9e^>2vJHf1?JL7V6BcECzK&J%czGu^;>OU(V9`wE0x4TcX#Vc0M zF!W{Nx7IUDxPoS#DKw#62lK9)2H|HKF$Iex{&v1#dq@i!ub1TOoMv+>`{>< zHb49ukGl@PPq*QP!k$y_KtJQ_FB!Eq6$VOT@U41Z7;@-bL`9wKs*Ryqfqc0)jroW> z5N!kMbE@8bNT6-q=>Mp;u-|p#8^_Q6)b)rK*^$C6*Wt;p7MC*Ol~%r*p1pcW9dQ)n;E*05iIBPX1^eS=Htyml8##b+{QLLZ!c)?I zKVCcBs1w7KW!^31&7GuK0`n?P_yYSq|M6(~xIz8NTaTx(kBkoD+Lf^H>Y~lk`zG%_ zO<)Mg5BT>Z-e{K4cugN8CmIJichZi4_j=5T9>v!B@*yAbUR$UWZMHnPdg^;?Jl((sJu=t@sg)wmD|XRb7~sA7?!Smi|WglDlAX`}}I~!wZthS!{JFGl# z%f8iKxQQzaq#aJ-+JKe&Ar7DjZGG7(ZC&8;YX8ql&GPk>`v0CDvn-twu(L%ALT%9s zMmRwnLti(1S4bLc%WY+jg`MP?M#`xTiuu|Y@KdHQ^*aB)EuQ>hEXC0eZ$*7BAp3E<6}O>$<6gWB?ut9xMV OCUj~chimUR=>GxJczsv^ literal 0 HcmV?d00001 From 8cee5c5f6b1db4cd54dcbd24be11346b7f457c9d Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 30 Sep 2020 16:29:38 +0100 Subject: [PATCH 104/693] Create a robolectricutils module This holds shared test infrastructure that needs to depend on Robolectric. PiperOrigin-RevId: 334604041 --- core_settings.gradle | 2 ++ library/core/build.gradle | 1 + .../exoplayer2/e2etest/Mp4PlaybackTest.java | 4 +-- .../exoplayer2/e2etest/TsPlaybackTest.java | 4 +-- robolectricutils/README.md | 10 ++++++ robolectricutils/build.gradle | 35 +++++++++++++++++++ robolectricutils/src/main/AndroidManifest.xml | 17 +++++++++ .../robolectric}/PlaybackOutput.java | 2 +- .../robolectric}/ShadowMediaCodecConfig.java | 2 +- .../exoplayer2/robolectric}/TeeCodec.java | 2 +- .../exoplayer2/robolectric/package-info.java | 19 ++++++++++ 11 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 robolectricutils/README.md create mode 100644 robolectricutils/build.gradle create mode 100644 robolectricutils/src/main/AndroidManifest.xml rename {library/core/src/test/java/com/google/android/exoplayer2/e2etest/util => robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric}/PlaybackOutput.java (98%) rename {library/core/src/test/java/com/google/android/exoplayer2/e2etest/util => robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric}/ShadowMediaCodecConfig.java (98%) rename {library/core/src/test/java/com/google/android/exoplayer2/e2etest/util => robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric}/TeeCodec.java (98%) create mode 100644 robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/package-info.java diff --git a/core_settings.gradle b/core_settings.gradle index b5082433712..bd217a37e56 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -29,6 +29,7 @@ include modulePrefix + 'library-extractor' include modulePrefix + 'library-hls' include modulePrefix + 'library-smoothstreaming' include modulePrefix + 'library-ui' +include modulePrefix + 'robolectricutils' include modulePrefix + 'testutils' include modulePrefix + 'testdata' include modulePrefix + 'extension-av1' @@ -56,6 +57,7 @@ project(modulePrefix + 'library-extractor').projectDir = new File(rootDir, 'libr project(modulePrefix + 'library-hls').projectDir = new File(rootDir, 'library/hls') project(modulePrefix + 'library-smoothstreaming').projectDir = new File(rootDir, 'library/smoothstreaming') project(modulePrefix + 'library-ui').projectDir = new File(rootDir, 'library/ui') +project(modulePrefix + 'robolectricutils').projectDir = new File(rootDir, 'robolectricutils') project(modulePrefix + 'testutils').projectDir = new File(rootDir, 'testutils') project(modulePrefix + 'testdata').projectDir = new File(rootDir, 'testdata') project(modulePrefix + 'extension-av1').projectDir = new File(rootDir, 'extensions/av1') diff --git a/library/core/build.gradle b/library/core/build.gradle index ddeb734947c..45c8e785c62 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -71,6 +71,7 @@ dependencies { testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion testImplementation project(modulePrefix + 'testutils') + testImplementation project(modulePrefix + 'robolectricutils') } ext { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java index f37610d982c..5fd7453beb8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java @@ -23,8 +23,8 @@ import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.e2etest.util.PlaybackOutput; -import com.google.android.exoplayer2.e2etest.util.ShadowMediaCodecConfig; +import com.google.android.exoplayer2.robolectric.PlaybackOutput; +import com.google.android.exoplayer2.robolectric.ShadowMediaCodecConfig; import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; import com.google.android.exoplayer2.testutil.DumpFileAsserts; import com.google.android.exoplayer2.testutil.TestExoPlayer; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java index edc546897af..5b201b9a9c3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java @@ -21,8 +21,8 @@ import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.e2etest.util.PlaybackOutput; -import com.google.android.exoplayer2.e2etest.util.ShadowMediaCodecConfig; +import com.google.android.exoplayer2.robolectric.PlaybackOutput; +import com.google.android.exoplayer2.robolectric.ShadowMediaCodecConfig; import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; import com.google.android.exoplayer2.testutil.DumpFileAsserts; import com.google.android.exoplayer2.testutil.TestExoPlayer; diff --git a/robolectricutils/README.md b/robolectricutils/README.md new file mode 100644 index 00000000000..430a907c2d6 --- /dev/null +++ b/robolectricutils/README.md @@ -0,0 +1,10 @@ +# ExoPlayer Robolectric utils + +Provides test infrastructure for ExoPlayer Robolectric-based tests. + +## Links + +* [Javadoc][]: Classes matching `com.google.android.exoplayer2.robolectric` + belong to this module. + +[Javadoc]: https://exoplayer.dev/doc/reference/index.html diff --git a/robolectricutils/build.gradle b/robolectricutils/build.gradle new file mode 100644 index 00000000000..f5a86822b72 --- /dev/null +++ b/robolectricutils/build.gradle @@ -0,0 +1,35 @@ +// 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. +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" + +dependencies { + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion + compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion + implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'org.robolectric:robolectric:' + robolectricVersion + implementation project(modulePrefix + 'library-core') + implementation project(modulePrefix + 'testutils') +} + +ext { + javadocTitle = 'Robolectric utils' +} +apply from: '../javadoc_library.gradle' + +ext { + releaseArtifact = 'exoplayer-robolectricutils' + releaseDescription = 'Robolectric utils for ExoPlayer.' +} +apply from: '../publish.gradle' diff --git a/robolectricutils/src/main/AndroidManifest.xml b/robolectricutils/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..0548a1b32b1 --- /dev/null +++ b/robolectricutils/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/PlaybackOutput.java similarity index 98% rename from library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java rename to robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/PlaybackOutput.java index f9c32d34b56..264b4bcc2f3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java +++ b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/PlaybackOutput.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.e2etest.util; +package com.google.android.exoplayer2.robolectric; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.metadata.Metadata; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/ShadowMediaCodecConfig.java similarity index 98% rename from library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java rename to robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/ShadowMediaCodecConfig.java index 89e120e2e80..81014caea1d 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java +++ b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/ShadowMediaCodecConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.e2etest.util; +package com.google.android.exoplayer2.robolectric; import android.media.MediaCodecInfo; import android.media.MediaFormat; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/TeeCodec.java similarity index 98% rename from library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java rename to robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/TeeCodec.java index a14787e959a..172350414ef 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java +++ b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/TeeCodec.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.e2etest.util; +package com.google.android.exoplayer2.robolectric; import com.google.android.exoplayer2.testutil.Dumper; import com.google.android.exoplayer2.util.MimeTypes; diff --git a/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/package-info.java b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/package-info.java new file mode 100644 index 00000000000..0dd7ab81ae6 --- /dev/null +++ b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/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.robolectric; + +import com.google.android.exoplayer2.util.NonNullApi; From 4d3a781ca4d905f84fb9d6c60a27315ba0561209 Mon Sep 17 00:00:00 2001 From: claincly Date: Wed, 30 Sep 2020 19:59:39 +0100 Subject: [PATCH 105/693] End to end playback test for gapless playback In the test, a real instance of SimpleExoplayer plays two identical Mp3 files. The GaplessMp3Decoder will write randomized data to decoder output on receiving input. The test compares the bytes written by the decoder with the bytes received by the AudioTrack, to verify that the trimming of encoder delay/ padding is correctly carried out. Test mp3 has delay 576 frames and padding 1404 frames. File generated from: ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.mp3 This change needs robolectric version 4.5, which is not currently released (2020 Sep 30). PiperOrigin-RevId: 334648486 --- constants.gradle | 2 +- .../e2etest/EndToEndGaplessTest.java | 150 ++++++++++++++++++ .../robolectric/RandomizedMp3Decoder.java | 94 +++++++++++ testdata/src/test/assets/media/mp3/test.mp3 | Bin 0 -> 8586 bytes 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java create mode 100644 robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java create mode 100644 testdata/src/test/assets/media/mp3/test.mp3 diff --git a/constants.gradle b/constants.gradle index c2b00003680..82a6a554791 100644 --- a/constants.gradle +++ b/constants.gradle @@ -24,7 +24,7 @@ project.ext { guavaVersion = '27.1-android' mockitoVersion = '2.28.2' mockWebServerVersion = '3.12.0' - robolectricVersion = '4.4' + robolectricVersion = '4.5-SNAPSHOT' checkerframeworkVersion = '3.3.0' checkerframeworkCompatVersion = '2.5.0' jsr305Version = '3.0.2' diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java new file mode 100644 index 00000000000..7d953fc8e6b --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/EndToEndGaplessTest.java @@ -0,0 +1,150 @@ +/* + * 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.e2etest; + +import static com.google.common.truth.Truth.assertThat; +import static java.lang.Integer.max; + +import android.media.AudioFormat; +import android.media.MediaFormat; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.robolectric.RandomizedMp3Decoder; +import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; +import com.google.android.exoplayer2.testutil.TestExoPlayer; +import com.google.android.exoplayer2.util.Assertions; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Bytes; +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.MediaCodecInfoBuilder; +import org.robolectric.shadows.ShadowAudioTrack; +import org.robolectric.shadows.ShadowMediaCodec; +import org.robolectric.shadows.ShadowMediaCodecList; + +/** End to end playback test for gapless audio playbacks. */ +@RunWith(AndroidJUnit4.class) +@Config(sdk = 29) +public class EndToEndGaplessTest { + private static final int CODEC_INPUT_BUFFER_SIZE = 5120; + private static final int CODEC_OUTPUT_BUFFER_SIZE = 5120; + private static final String DECODER_NAME = "RandomizedMp3Decoder"; + + private RandomizedMp3Decoder mp3Decoder; + private AudioTrackListener audioTrackListener; + + @Before + public void setUp() throws Exception { + audioTrackListener = new AudioTrackListener(); + ShadowAudioTrack.addAudioDataListener(audioTrackListener); + + mp3Decoder = new RandomizedMp3Decoder(); + ShadowMediaCodec.addDecoder( + DECODER_NAME, + new ShadowMediaCodec.CodecConfig( + CODEC_INPUT_BUFFER_SIZE, CODEC_OUTPUT_BUFFER_SIZE, mp3Decoder)); + + MediaFormat mp3Format = new MediaFormat(); + mp3Format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_MPEG); + ShadowMediaCodecList.addCodec( + MediaCodecInfoBuilder.newBuilder() + .setName(DECODER_NAME) + .setCapabilities( + MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder() + .setMediaFormat(mp3Format) + .build()) + .build()); + } + + @Test + public void testPlayback_twoIdenticalMp3Files() throws Exception { + SimpleExoPlayer player = + new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) + .setClock(new AutoAdvancingFakeClock()) + .build(); + + player.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset:///media/mp3/test.mp3"), + MediaItem.fromUri("asset:///media/mp3/test.mp3"))); + player.prepare(); + player.play(); + TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + + Format playerAudioFormat = player.getAudioFormat(); + assertThat(playerAudioFormat).isNotNull(); + + int bytesPerFrame = audioTrackListener.getAudioTrackOutputFormat().getFrameSizeInBytes(); + int paddingBytes = max(0, playerAudioFormat.encoderPadding) * bytesPerFrame; + int delayBytes = max(0, playerAudioFormat.encoderDelay) * bytesPerFrame; + assertThat(paddingBytes).isEqualTo(2808); + assertThat(delayBytes).isEqualTo(1152); + + byte[] decoderOutputBytes = Bytes.concat(mp3Decoder.getAllOutputBytes().toArray(new byte[0][])); + int bytesPerAudioFile = decoderOutputBytes.length / 2; + assertThat(bytesPerAudioFile).isEqualTo(92160); + + byte[] expectedTrimmedByteContent = + Bytes.concat( + // Track one is trimmed at its beginning and its end. + Arrays.copyOfRange(decoderOutputBytes, delayBytes, bytesPerAudioFile - paddingBytes), + // Track two is only trimmed at its beginning, but not its end. + Arrays.copyOfRange( + decoderOutputBytes, bytesPerAudioFile + delayBytes, decoderOutputBytes.length)); + + byte[] audioTrackReceivedBytes = audioTrackListener.getAllReceivedBytes(); + assertThat(audioTrackReceivedBytes).isEqualTo(expectedTrimmedByteContent); + } + + private static class AudioTrackListener implements ShadowAudioTrack.OnAudioDataWrittenListener { + private final ByteArrayOutputStream audioTrackReceivedBytesStream = new ByteArrayOutputStream(); + // Output format from the audioTrack. + private AudioFormat format; + private ShadowAudioTrack audioTrack; + + @Override + public synchronized void onAudioDataWritten( + ShadowAudioTrack audioTrack, byte[] audioData, AudioFormat format) { + if (this.audioTrack == null) { + this.audioTrack = audioTrack; + } else { + Assertions.checkArgument( + audioTrack == this.audioTrack, "Data written from a different AudioTrack"); + } + + if (!format.equals(this.format)) { + this.format = format; + } + audioTrackReceivedBytesStream.write(audioData, 0, audioData.length); + } + + public byte[] getAllReceivedBytes() { + return audioTrackReceivedBytesStream.toByteArray(); + } + + public AudioFormat getAudioTrackOutputFormat() { + return format; + } + } +} diff --git a/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java new file mode 100644 index 00000000000..1b033e1955f --- /dev/null +++ b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/RandomizedMp3Decoder.java @@ -0,0 +1,94 @@ +/* + * 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.robolectric; + +import android.media.AudioFormat; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.view.Surface; +import com.google.android.exoplayer2.audio.MpegAudioUtil; +import com.google.android.exoplayer2.testutil.TestUtil; +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.ImmutableList; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import org.robolectric.shadows.ShadowMediaCodec; + +/** + * Generates randomized, but correct amount of data on MP3 audio input. + * + *

    The decoder reads the MP3 header for each input MP3 frame, determines the number of bytes the + * input frame should inflate to, and writes randomized data of that amount to the output buffer. + * Decoder randomness can help us identify possible errors in downstream renderers and audio + * processors. The random bahavior is deterministic, it outputs the same bytes across multiple runs. + * + *

    All the data written to the output by the decoder can be obtained by getAllOutputBytes(). + */ +public final class RandomizedMp3Decoder implements ShadowMediaCodec.CodecConfig.Codec { + private final List decoderOutput = new ArrayList<>(); + private int frameSizeInBytes; + + @Override + public void process(ByteBuffer in, ByteBuffer out) { + if (in.remaining() == 0) { + // An empty frame will be queued by the MediaCodecRenderer on END_OF_STREAM. + return; + } + + Assertions.checkState( + in.remaining() >= 4, "Frame size too small, should be at least 4 to hold an MP3 header"); + + // Get the desired output size for every input. + int headerDataBigEndian = Util.getBigEndianInt(in, in.position()); + int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(headerDataBigEndian); + + int expectedNumBytes = frameCount * frameSizeInBytes; + byte[] bytesToWrite = TestUtil.buildTestData(expectedNumBytes); + + out.put(bytesToWrite); + decoderOutput.add(bytesToWrite); + + in.position(in.limit()); + } + + @Override + public void onConfigured(MediaFormat format, Surface surface, MediaCrypto crypto, int flags) { + // Both getInteger and getString require API29. This class is only used in EndToEndGaplessTest + // that only runs on + // API29. + int pcmEncoding = + format.getInteger( + MediaFormat.KEY_PCM_ENCODING, /* defaultValue= */ AudioFormat.ENCODING_PCM_16BIT); + int channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + Assertions.checkArgument( + format.getString(MediaFormat.KEY_MIME, MimeTypes.AUDIO_MPEG).equals(MimeTypes.AUDIO_MPEG)); + frameSizeInBytes = Util.getPcmFrameSize(pcmEncoding, channelCount); + } + + /** + * Returns all arrays of bytes output from the decoder. + * + * @return a list of byte arrays (for each MP3 frame input) that were previously output from the + * decoder. + */ + public ImmutableList getAllOutputBytes() { + return ImmutableList.copyOf(decoderOutput); + } +} diff --git a/testdata/src/test/assets/media/mp3/test.mp3 b/testdata/src/test/assets/media/mp3/test.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..77ee5e9c5a362cf4440a21621dc1e3aab425ade2 GIT binary patch literal 8586 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 uql9(Ryhb#yLR$kMI1iKK?&XmJxse literal 0 HcmV?d00001 From 7b8895d655c9b48e042f66045bc9c7ab27e93346 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 1 Oct 2020 10:22:06 +0100 Subject: [PATCH 106/693] Use Mp4WebvttDecoder for WebVTT content in DASH MP4 containers This was broken by https://github.com/google/ExoPlayer/commit/74a9d8f680995f2096c59fde6cd1ef6e85bb4d55 because DashManifestParser switched to setting Format.sampleMimeType to text/vtt while SubtitleDecoderFactory was still expecting application/x-mp4-vtt. This change teaches SubtitleDecoderFactory to check both Format.containerMimeType and Format.sampleMimeType. I'll investigate a follow-up change to remove MimeTypes.APPLICATION_MP4VTT completely (it's currently still used in AtomParsers). Issue: #7985 PiperOrigin-RevId: 334771672 --- RELEASENOTES.md | 2 + .../text/SubtitleDecoderFactory.java | 16 ++-- library/dash/build.gradle | 1 + .../exoplayer2/e2etest/DashPlaybackTest.java | 73 ++++++++++++++++++ .../robolectric/PlaybackOutput.java | 63 ++++++++++++++- .../media/dash/webvtt-in-mp4/sample.mpd | 23 ++++++ .../media/dash/webvtt-in-mp4/sample.text.mp4 | Bin 0 -> 1006 bytes .../media/dash/webvtt-in-mp4/sample.video.mp4 | Bin 0 -> 91242 bytes .../playbackdumps/dash/webvtt-in-mp4.dump | 58 ++++++++++++++ 9 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 library/dash/src/test/java/com/google/android/exoplayer2/e2etest/DashPlaybackTest.java create mode 100644 testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.mpd create mode 100644 testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.text.mp4 create mode 100644 testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.video.mp4 create mode 100644 testdata/src/test/assets/playbackdumps/dash/webvtt-in-mp4.dump diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c04ea1114d0..b16cdcea0c2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -17,6 +17,8 @@ ([#7866](https://github.com/google/ExoPlayer/issues/7866)). * Text: * Add support for `\h` SSA/ASS style override code (non-breaking space). + * Fix WebVTT subtitles in MP4 containers in DASH streams + ([#7985](https://github.com/google/ExoPlayer/issues/7985)). * UI: * Do not require subtitleButton in custom layouts of StyledPlayerView ([#7962](https://github.com/google/ExoPlayer/issues/7962)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java index bd652c65863..e59a7489bb9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java @@ -91,11 +91,15 @@ public boolean supportsFormat(Format format) { @Override public SubtitleDecoder createDecoder(Format format) { - @Nullable String mimeType = format.sampleMimeType; - if (mimeType != null) { - switch (mimeType) { + @Nullable String sampleMimeType = format.sampleMimeType; + if (sampleMimeType != null) { + switch (sampleMimeType) { case MimeTypes.TEXT_VTT: - return new WebvttDecoder(); + if (MimeTypes.APPLICATION_MP4.equals(format.containerMimeType)) { + return new Mp4WebvttDecoder(); + } else { + return new WebvttDecoder(); + } case MimeTypes.TEXT_SSA: return new SsaDecoder(format.initializationData); case MimeTypes.APPLICATION_MP4VTT: @@ -109,7 +113,7 @@ public SubtitleDecoder createDecoder(Format format) { case MimeTypes.APPLICATION_CEA608: case MimeTypes.APPLICATION_MP4CEA608: return new Cea608Decoder( - mimeType, + sampleMimeType, format.accessibilityChannel, Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS); case MimeTypes.APPLICATION_CEA708: @@ -123,7 +127,7 @@ public SubtitleDecoder createDecoder(Format format) { } } throw new IllegalArgumentException( - "Attempted to create decoder for unsupported MIME type: " + mimeType); + "Attempted to create decoder for unsupported MIME type: " + sampleMimeType); } }; } diff --git a/library/dash/build.gradle b/library/dash/build.gradle index e6cb20d9334..e34ab3f9db2 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -36,6 +36,7 @@ dependencies { compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + testImplementation project(modulePrefix + 'robolectricutils') testImplementation project(modulePrefix + 'testutils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/e2etest/DashPlaybackTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/e2etest/DashPlaybackTest.java new file mode 100644 index 00000000000..e0ea43b1147 --- /dev/null +++ b/library/dash/src/test/java/com/google/android/exoplayer2/e2etest/DashPlaybackTest.java @@ -0,0 +1,73 @@ +/* + * 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.e2etest; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.graphics.SurfaceTexture; +import android.view.Surface; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.robolectric.PlaybackOutput; +import com.google.android.exoplayer2.robolectric.ShadowMediaCodecConfig; +import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; +import com.google.android.exoplayer2.testutil.DumpFileAsserts; +import com.google.android.exoplayer2.testutil.TestExoPlayer; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** End-to-end tests using DASH samples. */ +// TODO(b/143232359): Remove once https://issuetracker.google.com/143232359 is resolved. +@Config(sdk = 29) +@RunWith(AndroidJUnit4.class) +public final class DashPlaybackTest { + + @Rule + public ShadowMediaCodecConfig mediaCodecConfig = + ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + + // https://github.com/google/ExoPlayer/issues/7985 + @Test + public void webvttInMp4() throws Exception { + SimpleExoPlayer player = + new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) + .setClock(new AutoAdvancingFakeClock()) + .build(); + player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, mediaCodecConfig); + + // Ensure the subtitle track is selected. + DefaultTrackSelector trackSelector = + checkNotNull((DefaultTrackSelector) player.getTrackSelector()); + trackSelector.setParameters(trackSelector.buildUponParameters().setPreferredTextLanguage("en")); + player.setMediaItem(MediaItem.fromUri("asset:///media/dash/webvtt-in-mp4/sample.mpd")); + player.prepare(); + player.play(); + TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + ApplicationProvider.getApplicationContext(), + playbackOutput, + "playbackdumps/dash/webvtt-in-mp4.dump"); + } +} diff --git a/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/PlaybackOutput.java b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/PlaybackOutput.java index 264b4bcc2f3..64ff61cb222 100644 --- a/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/PlaybackOutput.java +++ b/robolectricutils/src/main/java/com/google/android/exoplayer2/robolectric/PlaybackOutput.java @@ -15,12 +15,17 @@ */ package com.google.android.exoplayer2.robolectric; +import android.graphics.Bitmap; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.testutil.Dumper; +import com.google.android.exoplayer2.text.Cue; 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 java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -35,17 +40,19 @@ public final class PlaybackOutput implements Dumper.Dumpable { private final ShadowMediaCodecConfig codecConfig; - // TODO: Add support for subtitles too private final List metadatas; + private final List> subtitles; private PlaybackOutput(SimpleExoPlayer player, ShadowMediaCodecConfig codecConfig) { this.codecConfig = codecConfig; metadatas = Collections.synchronizedList(new ArrayList<>()); + subtitles = Collections.synchronizedList(new ArrayList<>()); // TODO: Consider passing playback position into MetadataOutput and TextOutput. Calling // player.getCurrentPosition() inside onMetadata/Cues will likely be non-deterministic // because renderer-thread != playback-thread. player.addMetadataOutput(metadatas::add); + player.addTextOutput(subtitles::add); } /** @@ -74,6 +81,7 @@ public void dump(Dumper dumper) { } dumpMetadata(dumper); + dumpSubtitles(dumper); } private void dumpMetadata(Dumper dumper) { @@ -91,4 +99,57 @@ private void dumpMetadata(Dumper dumper) { } dumper.endBlock(); } + + private void dumpSubtitles(Dumper dumper) { + if (subtitles.isEmpty()) { + return; + } + dumper.startBlock("TextOutput"); + for (int i = 0; i < subtitles.size(); i++) { + dumper.startBlock("Subtitle[" + i + "]"); + List subtitle = subtitles.get(i); + if (subtitle.isEmpty()) { + dumper.add("Cues", ImmutableList.of()); + } + for (int j = 0; j < subtitle.size(); j++) { + dumper.startBlock("Cue[" + j + "]"); + Cue cue = subtitle.get(j); + dumpIfNotEqual(dumper, "text", cue.text, null); + dumpIfNotEqual(dumper, "textAlignment", cue.textAlignment, null); + dumpBitmap(dumper, cue.bitmap); + dumpIfNotEqual(dumper, "line", cue.line, Cue.DIMEN_UNSET); + dumpIfNotEqual(dumper, "lineType", cue.lineType, Cue.TYPE_UNSET); + dumpIfNotEqual(dumper, "lineAnchor", cue.lineAnchor, Cue.TYPE_UNSET); + dumpIfNotEqual(dumper, "position", cue.position, Cue.DIMEN_UNSET); + dumpIfNotEqual(dumper, "positionAnchor", cue.positionAnchor, Cue.TYPE_UNSET); + dumpIfNotEqual(dumper, "size", cue.size, Cue.DIMEN_UNSET); + dumpIfNotEqual(dumper, "bitmapHeight", cue.bitmapHeight, Cue.DIMEN_UNSET); + if (cue.windowColorSet) { + dumper.add("cue.windowColor", cue.windowColor); + } + dumpIfNotEqual(dumper, "textSizeType", cue.textSizeType, Cue.TYPE_UNSET); + dumpIfNotEqual(dumper, "textSize", cue.textSize, Cue.DIMEN_UNSET); + dumpIfNotEqual(dumper, "verticalType", cue.verticalType, Cue.TYPE_UNSET); + dumper.endBlock(); + } + dumper.endBlock(); + } + dumper.endBlock(); + } + + private static void dumpIfNotEqual( + Dumper dumper, String field, @Nullable Object actual, @Nullable Object comparison) { + if (!Util.areEqual(actual, comparison)) { + dumper.add(field, actual); + } + } + + private static void dumpBitmap(Dumper dumper, @Nullable Bitmap bitmap) { + if (bitmap == null) { + return; + } + byte[] bytes = new byte[bitmap.getByteCount()]; + bitmap.copyPixelsToBuffer(ByteBuffer.wrap(bytes)); + dumper.add("bitmap", bytes); + } } diff --git a/testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.mpd b/testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.mpd new file mode 100644 index 00000000000..fae0dc98eca --- /dev/null +++ b/testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.mpd @@ -0,0 +1,23 @@ + + + + + + + + sample.text.mp4 + + + + + + + + sample.video.mp4 + + + + + + + diff --git a/testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.text.mp4 b/testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.text.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..44844168f87ef92b18d9857b103ac5f29818712d GIT binary patch literal 1006 zcmZuv&2G~`5S|7kI8-QyRz-+|1-&&tN*bwEFH|i^g8#`dNrVdT|(-?uZfyW;_XAi|3*aj5|^@`olH4Ww{!Xrd6H z_1UCpL0j4svE-N^AHVEl=AxU4a4u%bLNV-( zdV_9wus;|M4!TxpC7hzCy+JGHRO|)L%Ld%IY5+fd-AZwFXN4dCleTfk$V8A782SGu zKl@T9behdTb&kz46XK7>LTY*q*;kV+BJ#Vk@^__G%-Q%ho4M6Njo&+*YQiTDUHOx1 zefX+Ce+K8=B6|A#(aG6lLTszhV(QY|hUz6D#TD8P(UJE?+|d{j2TZzJn1xJTHZ#hGo4r0)*%5!!q~XK@S<;#*pLr#)KyzMHl00BnEq+(^MS x_NcSmr~8?>(33cE0eu*&AWE#mz~xg+P^%uNQ78>2bn2Ad)fA_~bRpMs{sO!7u+9Jg literal 0 HcmV?d00001 diff --git a/testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.video.mp4 b/testdata/src/test/assets/media/dash/webvtt-in-mp4/sample.video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..7f456f85f90287364aa84b1ff6d39e31137ee8aa GIT binary patch literal 91242 zcmZ^K19)Ux({60rwr$(CZQIFA>`XKh+jcUsZDZnzZB24J=X~FP&hy`U*VB9Nx2oQ% zRkhSfrvn58L}uaU?PTrhXb%KvfG3x|6C1Ocv8$D_hbgnEy@e?d5GaqmqoW7FY-jIb zW%kKi|1t9Lc>w{rGkwZHz<<5|g8!BO5&pLSTNe7a@PA_vfGXDB+|BquCL${{JD2~` zq{UcS{)_+fH2$v+eHehi{?bY-sYq)8kRT9{m7ANBD-R>1rL~)ty9tA-qdlXgqobvr zIU}G-TVr}BV^dpWOLG?>4;BVC24;FwPF7|XW=?t+b31cmS92gB5MVbKV_QHlJvZCG z1_1J(0r)Ka|I_}r<$uXS05>o$K=heUe_(HWGwaWSiR{h(--E0^@W}(B|My^cSepUz z`=@CDWJf7u2QxcBIe?&K_SOy-06=E;_Y=>|#r#wLY@g}Cxx3gA{Ri*6x|!Gk@DEov z*S}`@Kl+Hzl>oq>?SGZpAV2SCLFh$;$f z21NL=1B7h}cvh|oiGg<+0wVzd2_yg^6a!rcV;cbjL0Fycjg#~MJdj-7++6<^3?RD# zR_FgTG=S{$Khoa-z%h0Fmk!Ot{;$FPcO7KrW`D)SGq-bf`w#a&cF4K^RLo`XVeSR6 zA=#V%Jvo4tFAtE3++6uE|SbU#)EVoZR2oMYa|GO_FHw&}>=KX2%b8~Tb00Khzv3llRtK>2yB0ZuhfPfv!= zW5ChQ*nz>(#gg%}7X~XgdpiKf(aFu)(ZQ96$kf=x*p#1{2(bP6S&7WdP3#;^ZTXpb zn0T0oj2(>ayj{)tnY>ten7o*oS&8h;`K`>oh+N%G0Em;w$<-U+3b-4(nDH|+FaewZ zHzIp$FLN`)&xp(b4?`DY2TOB)W)31#D;Gz5V?%%|^XLAsv$J*uATBR1GgCJJFm<-) zX9A>PZ06(WV9w9NOvlVjWMS;;X6WQ^Z3;-{zX~QI2baG(F}1cgcKb}k+QH4-#m*Q&0_aWb++B>l z4FT^2PR4Ek*%UBFfL&(o00;pfx)^_QEL@E3&0PVbWn$>$4ZzlB{44-$Y-a5AS%!(B ziM6roXD8M!=6@CDX>M(44&5D-vI zmU%=7kmR~$tX|q_ovt6s{1&0)L2px3=hnwZn0MZHIN)@ld)vnxDt+Gotm`dhXBBQ%L;NSV6?l>!8p z@V7N;KwZwd6X{in<+aj_f<>A!CfNpJgHx+Ad~Q9HMuE{39weOZl{ING3iGaeo~deq z&)tfLre#%)`Q&k`IBr$tkEKA*oK1g%PP$-gvR&y+XUjYMxJnN3U!u9&sgsKq>sMAw zclrf-FG30BF7(PaA+ajKzn~tJ(_z(l%TjCQxp2g)tp@FlW35@o6+)mFqIfEckX_Fe z6@nSKc>N?9UAlOsGyxWebKU!uC*Wso%i2m{qgro-`~6(&NI1@JzyV>(J(`Bq%vHn* zNN~+cgCD1!F;?a~8nR-G^GykY00J=^LPuMMq{4YL%RcZ7-rUNE@50v~;0Y^s9$MQf z(F6+n$c69NViY{#;heBwJZ-2NJIITyCGanpVPi9ro=~HkE5D$#xO^6dSXp>aPFC-- zlBpsO8rG3#2|`8VJK2Lu#ofF2Htu#`48ndRT}7c`h8ro@AopAPaBqCeJjgM^mud^a z^Z?x@fs*Q!z0XGhS=-%}A4kRa=(%HZSy6Q$ytV)vr8<#(Gln9^x?kUU=r;JtkEvA+ z8b4OL%0}+fMZmIr>}YGkXryCfm@=R$?do(mKOdb?JAmHJC_h9u%F|BKP~v?wJRCJA zBKa<2jG$|ARDR@_`Jw0*|FTUfwzXVJuRykw@Aa~~$8Rka9LaJ5Y~t_;on>XVu*Ik@ zcG26C7EzQ&>;^ZtuSZvbBy{dkaMkWvCPsuYH)JfOQlj*9S@Ekhj}I(d#Ho;7;A>?w z+st$SQq9rcgnb}ibL&j0YYXPj+5$24v<77PUa3)DH3^w z@Nu>8$_Rww68kTmKfz4#5`?0yeTqb!I6y&7IJC*qH^!;%*G_`!>gyg_ujO(NzNf}9 zi`wb^(S`)a-Sc~Sl@IS1Z+QAc^o6T80^Lx@LrtjC&{L5Sjx_MzA8o*u0|Tx2)Y%}f zsn9w1CuzCt!=K*r71XO&^(IOM(g$bzBf9sPy9IdOETm$?HE1Zg{lC~3`A&^L+aM%{jC`~PM9Q8V zTl@5t?dPYUD~G3MF``v%Wy^-LIOKF>=AdFd&FDD-Z_yPVoi8y48s;BI z1@XkgGg!x*l&U;Ih*R74Um!x|Y1$Xzc~c>oqd1(J3EFZk3t>mejiyN)k(IIr=_GDQ zi%+YqSX05-=}F>c(&*W436nes;BGa}v{ZI2GS^wpy`o6mMgK3ZvQKu*$c_Kwy@mxbPH7`IWH@+ zj5iVF9=-}PG~)^_aDHEqC^0EVb&Y_bo~lmSkEa~S51q~OiU^n-UB`IY%|lHKVxom0 zh9NMmzp7<>5N*5iY^Go&DVuyXOVs^ot&u|_pd`j?J?&4uK0_NO8+7<=K=-#8|C)k)O0{KNiVVxD3g;F7GAUYVOGI ziU7_hFHXJi&?n`3B_5Jf935=}6Y+=G4QL>agx6q&h;z5nyF{lnT-=7GhO1GkqfI(9 zs#nBUyj90Bf-kA;q*0Bm{FTF%<6}GI?^37dUM#j0>Eb%Y4uwA+6SO1>ktyNjWQ^@d z&k_)I<;;=uc$^@^5Ycu_Xf*{~H}YQEY8nRma&?qSbJ>F;XzHvE5O2aPg74`X`i4UXjk*z;<7t4bh z#23d!eA9+Lj5Nyx#?x?Fx?{tZC4rd${fMXyyHy3jyl#`A%8ocR8=2 zIb$m)V@7 z;G?XP0cQEoO<|>CDe}4^0pdQ|F(FQz?TzvC#K*iF@sY+0nJzBU-?6|kXrw#5>{^@y zD)5J@1cJm8ZX$glJ`#R5qAF6%p$Irl3ZY#SC!qBJ*Tg$R6WHMy_$aRMV?oRbyNGEi zyqS@#+2v24eZ6Sh_8o+hfs}H+%QQM_DZNJHx2u*QFfh=GAYOQp>e#uX75Vk9Us!=qf! z+1wB>IYcC zFzMf#6y71Gvb7j6n|7!Ft$&keAD;5!2Z(0b!95oUdqRIhqrHxr+ z===?p7~Rl`mSoq8hD*I4uS64VJ~Mk`42W)9A{4F7(KYWxI=C#7b&66Heu=#td2wL9 zD14m^X?a}cHPb*6%#hsCuQA_tls&#wkzp4RN&Giz?|)OfZcsrtJJnWD;ejyc?R{Y`}u)Z#ek8rK{P zo5FM=DHU;FF751~C}nc|7fL_Q90-c(uq-(}O0sB_E(? zR&d|MZ->)Bk@SDthM9XB;n8BznpGi|-|k+8 z9_y`X{3ayNjs4wFmVU*Jt#f_K@7(i;!ZNF*{9O!0e@2qGM-8TLudm~q_}eZN=db8( zi~DhNRLH-$shOyYZDa5L93u=4gY4qxX?y#2S&4#G0*Q&HOOjmE+6~<@ zViK(t&GyZM>ajQd*6kY&>Lq63vqG;?^u3TWAHPjdV$Qw_5#V!*&zCCb*$g+H3|5If^^}7D(Ek;1~RRc-*phV98?QXd%N3FNbn+pl{(%J4W_|d zPDv%-+;+s$;f|h8s73Pwe%WO5j!U>0Z?oOL&rZ>2JwU@=)?&)jw9H?{dn{LIdlsQp z26G=Es>ViCt3LoED67fq2Eoypw#i_zpl4s9iu*v1i6-Zuz8mDOW~7rW+;9|j<{>XZ zat;^^dse=tgFY%?Zo-C3<)DqbmTd3JraiwNDD)|OmOg61nTjfBVN&5=L>d06UH$6%$aY-O-@cMt5}a1o#>iuNR;S`F)KCM zm9yg`UfB2928t-&vs|uiLMFhR_R?bP6GUvEzde9(;Jja^vaciQcBr%r^?S> zy$go;MmiZl>TWrzl;*u}Z557i;IT+0;&MTiy**HcX42)9!}=yoQI1Xlm*0y65&O$q zX~SJ-6(6i`!~_eM{o=Aisg=lrg06+ffcHVTB9En4hEL{0U zy$fX1jSG=BmmbM~1LM0x!${GjkKg*kMia%?O4KuxfPx7Yua?ot>REpUKi4r^y<|$n z>caz{;@O|>0p@@v2^Jy@!T4={;Yx|H=rP8c$d>Rx9*H>Js44tZriGi_c}=hZ0rljN zF&z4*HNmB8%6@tQv!kDGZ>PE^;Sq*xuM$dF=?fYb3u-^#oN2I8Fl2u+i_8Vh?h3K5 z(o#gVHN`#qW?ogsbkN{v0OQhCOt-uhp~(Aekj~53{INhmh^itz?X0n-Va+`4c*cX! zkCD*$_dsfVrmR7$)h}ce!@qdEDsbKP72Y4Q!swgv516;hjf_cTkCqe;zs=AvS`o)& zNAMrel%l2uA46)br2*A9aqXFIHWpq{Uda30yZ++6$`mV)vZ2P#f_c@#SC~4rpL|1* znSrXR(HxTb$!mcrlu=TvJF9P`HZ#*oz!u z!v}YDh3$&~)7rAOMD8G$Bf~fBdL?Yb-9WyJi66pV^SWLYkP!x}FTV)My$nh#PAI9( zZaGdu^IA!j=mZvAzdNN|S!(9Djqt;Uy=sntm#QCVI6Z`aBX)a5iO&rM4#(`Qz)DGF zMRnN0HHM$x`#mB3%Th!eT0`E%C|gY8KAg6Y6`@y=*PN>VK*vESUPb^>P7!HRie8uw zUdO$61dqINO%0^~klwC9jje2WysMSV$q6UZeY8^?lEQDeA1XxS7!^%fG27ecl^gH( zVtK%;et~FN^Njt7iN5H`@@O&hwIj5}YlcRr-)q)(zSZ;=SwXx-No?6TkejK_9q0XU z;O=8WsUI6S_->=bZC`0(jK?OT(?SzPl<1}bt1N8W(8uzir6;C-vm49rONM$NAVj-U zIf+-J-;aCse^%KcGdVM}+gRAZA4VyX2NB~HuU~h0fagP`E5Bx_KwJtTCNTbrVvmy_ z6BcDMV;{=Ul-Y(%!+y_+lN52v+p-=371VDA{GI^yGd)GcO)3=udEAE}8@n0p1)7Q@ zUP(Ee%;qp_K;KmzPG40e7&8WMSroU-;$#6V0Xu`V2sgQDPoKU_=r~k`WvX$Fmq1u% zQpz%+U4puwEDT%lrrh5`cYMW8;0tOI67U!pq`%uRxGD;cp!b|H z9kte4NpaSwBAmB|X|Q^AHe&pEmO2c{^n^=DH~ZqMw*FLnTChFcV7p$)l)k$(BumEJ z^AA+LOZO@2BlaLx%-SemQ9m*6iuFHzZ;6iqh~rL3-Z?m%O}~g#4G&t%b2#R3{J9Vw zai!Q}umTX;KUOM=w1P4hJg#*s5u$m0#?Ygxe|Py>*CIue*GMKMKC%TqaPPHz^m)N* zih)1e=?KO^=Qs{T1r~6fKpT1tx3+%2_TV)yPB}x!R7%-r#`VO!C|#*_M0ke0;a;JD z-^uF8H3r9v!t-PEamvlf=z`AkwV0SO*;lrQ_Lr`DGUMIo9a7G5`V|k~WvqxM$4hL{ zXOplA#Pnr8ZS9AZXKy3eI&i@qo~~ie5Y!7nEbB*VFlACp(4*VdVM95O9Ew>(!$l$% zYM_k|!bcLUzO`(DEu!w-Ql^ukZ(&41?2aqg*Uio8^8*f;_?bCh+W1_R`Jr!fnCf)I zf|KmS2jcrY;>0;Vt!&vzdj78?zUA;H$fLB+PGm+1WE1!yZtAVYtUo8TOcfJD;p6Lv zSzt~;rWU{hN~Y6!u-FKJI#255z-Z#zp2T+&8SW7u1p&b|&zyb4rJ4V+HRr%Zu7qtv9RKlXJjY4M#~`19E2%_ej~g8W183a7 zQCt7s|7i3V#bpp z?XAF==EDxYy1KP4h)w3_a>||yr)qj~ZHF~n@MP<%7L+*I_=e00xyvik*|5?}QfM@` zTX@`Be`$z0+JSFXolJb_WmCNfo^?K4J|vEc&05=KC9doFH?``E$fPbX1eYCQBooVH+wXQH!Uw_1!<~y-kJ9Q{TJ80x32s&H-s1=s}SS!JT zf@K&x9rIMU?Cb?mi8uEyg{cSrSRE*L>@n7?fH@D~Corowh7HRbf|Tt)IzG*W=}-9} zoKG4~_~*p#`i(2l4}B3FL^90mZ`HxBDFZTO0qRCm8Z!rl$l*9);%yJt-s#>HhEms8 z3*-M$Nd}jcZuuanu#&IWuE7O+* zSBo5l-Aeh5WakmG)>@$EDqoSRuf(j@2G#yW&}@MuvK{wew&}jV=h%d1G^q89a;1`` zYD#x=df8AHPaAttj6WZGIb@D3AOiQk&_?nsy4#g>{ z;#g;i@BZ)%RwFe4?`Lx%9uzoRDmDkD!`&(PQ{X-FW7TyBlU~lU{XFS*MD7QK!pV{{ zj3q?M+QVfg&(Q_atB5ddB{6|wL&6fAXCSjWnAk{{XGv0x~(RT753U+vp7BHzToJ0huxACrHH|Y{p@SKv1%Vi^vEk^dG6d0 zK7;`8s`M>G%HgUc};HAMpgf@)(+Gid~J=yW{!Jtw>Mbr4+X>P@mXJg_0wFESdk zG#IxE9M4(bQ^;sy^7jb)W6QGaM}^>V76|RDSYg1U=2mIU3B!jfaQY9^#@W!Jq?sQE z#{v<55PNCzW?#vkNQE4rQQ60xDmXZTM)?Q(J{q7D%-bW^Z?P>NtKA7rvNeYde3+G5 ztaLleXVyLn;xU8+-PhSG0@(p)+)c5jFKK#AnW2BF^u z`a5u(wXu1R(8-w}N-ds?O(3KXMiS|J-?iVP493?9lec7~&Q^E11Y)2o<1_Gpe-gg2 zb@UDfm_y{n%!)XPkyyYvi<(6!mrj)0CkC`Y5eu;#__`m`YTB6__^u-LL(w?&yPD=_$ zPUR}mOwa-X0yoJ2?)p_BuObdR9%+rU;(~oCi=BOcs>~N6{%5^?y`Ag;&-%R4sRR|= z99yzl?LP8|V2n8e#Fy+s86ooYNk91$ruvBg@lBD$7OyUT=h{bcv@{y9Ozk+KK zTxmTUS`b>OQ)YRNNjrJoz9NqIe8eBGotitn~1U;R01*Fx)I)+OoO!jXj^WP zAjLA1-2Pbs0@;SH80S=Kn@?7Y=r$?(tv4k*7Wzzk!GKTCy=P~$UiMYL^vp&;vRX=* zlPK?z&X8EczFv?>1HzDIdI_fUOgF2!De}W4d33HuusfSC;Ycru5IfBKtoobv@mxd(?p;NdIP&=q*lE~WA|q2!9Gl0Uf~$anm3{2)pQvB&y|fZfsMKlpc>03tOt1)W(}oQ}6u`nrDWS9Rm5;+YkM_ zbvNf*19;7lr=Hrpue1oHbUzIe;!AZXBf@ktFT?okV3=4qX`|dx-YRKzX=8II1EF2M zcE+iyW#XnbQJrF;`6Aq2Ln(rF#jmhwK@zX8NI6b5sqt|(e}jEP z_vwhHySzgd@)mr+$y)eTjK4Qzbmrqm!A2Tn`l5r3GK4GjjVdV_e|OJ>ek${sYPp78 zElM6iV_?Ih+@Alj*aXz*xhFL?x7=_mE~RcGqT56T8=Yv4U(rVYlFU&VjA<89S@Apx zj@i03MC3etVNEn|0tDd>M5{-30YzpRHUY$vcnO|UEN*SxfAx)9F}DJEaMVQzY41$= zT0`U^C5edKR)2devuQBNzhcx7lFxO`5ey{+zTsC2Ggfd_JhboDZ7GRt3;+BgFE(YR z8MQ>y6%o?wea!o6pg5qQJzWm1n>29fmJWxbfmo2vJp5tz(K zAUniV!AZ5Y)~--E;!L_SBEM8Q99%Kv0e?vc1xAS9dJ4P5;}sBZmM-gBbk8?jzS_Jz zjQ^5r)mYTA=og`zd5w|R;TnihsCHTrW2ltR4yxThhZbjIx^(ZL^h(*29^F`hiMSE= z4o*R>_1->kQ<@Xvg<#X^zwJtec8P%XTrgnY+{vjH{bkXXZAx!?#B85M@JPnjgm!N& z=vE}MmRn*AtTru#>f({~;$o~GwQaz`*NS<`bM@GTKzg}$Gh}FhcsCW3P6_78V1MCA zMzZj$IviOQc%a_?focf-F%!ZCiOkY6+BoV$u;U4=BH=PUh)k4SL~j-d78D;*B;J+a=@CY$sa8YF7!0bM1Z*cTr8meMB@u`}7xaBVnpFm)EG z(0Fo8gu#TU(bdbqX*1<=NetmcNL2T`8BsxM>$<1!m>77iS4pm$6UU&=61uWYg&$Mf z`t2JJdE&F{QM%EHhb#U`q}a!jwJ$JEBSe#iry?JACdR&5-Y>+(Khgp-Hx%!zP&^S0 zyX6Gu>i2zh6Wa{QeIob~(db7QP#fh&OFhz|b#0Wu!hS3CZ53Ae67nBq_rpol&~!*& zvA~wk-b0YjbnGLjI9b)~$&B^~zI@>sObvkubBgRpW-`FR<2?~Fdte9*(3|C&I50Cl zP8?Hm=S=T0G$kxbvBo_<7ryr{9(w%xMQR`6+G}{I@LPgDk`i^YXas4TblyXB?l~iR zqV_d^&jUg#4}~k0Coe+}5D;GEY*3|Sso%FC>qki5P+LQZ3<~6B5>@}O(3v=$@v|sR z6$9N$+KP-qTkgAunzUVwu$%lXW=8oxmcK4i_{DHI$mjAR_t4&V-DLu&eS1|AJoU~> zzy9G0{FyATBtJ`9{p+`lMmnEFeP29O{L&wA+7>P!+3)z%KeRp@-3~pBo*cHfTdaUL$$|n zAy7d+vU+h7BC;VY)n;$h&TcH2718N-7^Nl6QAlCd%snd!4>_sGd4J6-9Hdx!%X_TiGf+33C85x? z{g{Cd>hs$Mi+@l%q!XxLa&11IMXbBG8nKFw57i@sL8BVL}W#^mX6-!kS-FFN`mf_NO8G`twXono@A6 zytyfH3{avP6Pje3Sr~#9d{=*RNt7WGAYY!Ia7Nh$&!3Ju9i(c@2|as|lv2WkOwR?j zdL&`*i$wsZ5=g}rOAg#@OymmhxZ0d5)uvOYe9LgkQ~p?Qg8E*}rAIVQUjO~MumptJ zo|^1+zkLo*fd5A=q-+IP2{A1Flp+ln1visUEMdnlx4W8UH^Ee-6_pdw$l}#N>*PX^ z#9#wjL|44@#h9;;wN5$22QZxMih}_!B*#=2nzmi?^W?DU=^Q07no3VQH>zXfe&6^b zKt$z{!l%`uDH2aQD`JP07@#pZ+dxV=eJDo2%sWQh^xp^ne8D*1Q;OP5a>e? zlY=6cYtfZZq-(XuT)9;$mIulerC$(;lAXoa%nzSPHpRdb+h~UujeZ#3fiv_H z7YkGDf>PRymSUT6!?ZT~=@CKZ#M%Ak3UyGa;tE=lZ&dj+7(eqe>FoO(yrM=9t=8V+ znYrG$>WlisH)o0dOEgt$=k2=zr(8!#$8U!2ux?!D8P%;BsW!RV_PDG3L(qOE6))VZ zk<5GtRId&DLo*eOCAgT1hStgr=)d;HXAU3Zrs$v!%vH8T9zvk2OXlcQ1cwaVXKS+Q zH=6Uy(yKKE;01KXCJ+Fe&5B*{GrQJF*b3P_M&lu34QXLr5{PAP+}o4$30wJ_?uRs znJ^nK)>{R;_PzHCN(LwKx(GVk=7m-(UO^97YSx!6^yxG#6#H{FdL_2+cWsF6tR;Q+ zlsvm8uuemzL9c{t=XN>B!Dco;Av|5G`fh4R^>`c$9^@R&56Y4;eoFLj9aQp+YO~>) zuY?ZP1%v}%%E8{u9?p2Fio4d#vh*d`?CJScf9s&s-a>DxBVZp?2;KKhLW5`HJ@cMy z!-)u-)SM3f8EAFWw^$`27s-0?PMlX=M=NIZcpK*1oa_YOjB96qF|tUF>4a!QL$ zARkRfZ^FRKpj#Q#pCRW80jYT8L-I4LP*5P1nS!X4QrxjL((EkMtC;er*21Hw$Z%h0 zLZ!bPS)TsUZ%^j}a{^Ls*3%VzjFkQRxIDkzGWQlKo*~R;DqHwK%H<+4to5a8ZM34I zONa9307gp+Zvhlqfl-?B%@4oU>) zP9)Uv#|5)(ls75zRoU_!7=d=q9((@Wg7n{DU=`~^L;Gmv z*YJTRDWBDnTy>@9@8UH(tY7of-J&JBrP7zz_ZN+mYTxSbIw6u?5SR(E8|V2ZKit|z zAJGegXpMCMEfPeF8!e5)SDfn1=tb|1_T8YU6PsEPmJpgOXUT3ODe`*n&p0$Mzj!BQ zfuOl9>S%o{h~0wKJ=D|>qYQ5#8a(>3^w zt@5R|tyRD8a^~7#<>~dC`@~>+WDdsHMo~5)f{uAQQ(wX;huynChyw=n%_9ggl6wA| zaLvlv5B+#`6olh;4*|DFM9kJn(Us?gGoq$kyga8B&0qMkY5YvBE=MVXYj7#m3ox3g z)p@7Sm)Tzn=OmI2G%VoPP)xHI*4OdSf&`O93tdX!*3Z`V7kZ)rDckc)fu`a4TNjSG zj~SWUA?2CPnh$6^iT-3mwLCaJ2&j-}nm?;fco{%zH_KBF@;xyw#DqEyi)a3^Ex6zi z?1#=&P!S>>9*923jF{cvf<8n>B>BFZqz=<~E#tXgyEe;(NHNe1sZ#CC$6k6RB~9bE zC7GdMm%3(O%o^dR{phx`u44-xE@SMYx`rpHHra)&mofL_b?$uCD-bM}UmF`bFF~}OL3qL+2I$Y?M$=S?EmpPN zG*w-}SS9?=vnhyzdw!Q!_N{9ZXR`3B9Yn)E!XIRh^g`xb$SbeKDtKY z9_WJIyBTYKKb!}@bDszPXufbiuRpA3YA%CT6%X%fmcuiMlLxOrL;z}{gy6zfH}9RB zMWPh%)$mXRJ|A_8J9O3AU%AC@vlZ_bQt@5y)-69-Q2qi9&txftSk405&TKX_s7Y;@ zgY!gS%b%a!?)(iQ8Fp;XhyFa4D(^D&a7w3U(7KLI{!s^uE|@tl#_OK^vWFoQ5f0yZ z<5m~N(x$gQ^jMJ}{{)JzwQyAhR70RP&lc-jBL%qsLvJnB`^@P(RWV(>)p?X=I?~~e z1ONBFP}%BMV%Cb1#Y$&KqP-`3x()duWBH#&w2IFcdVlU{o-3Qbydm3fEgP+*%V)7^ z{!-1!W7Wg)Lxr!YFJrHmK3OBG_qpQlTIi5p`^{Z=?|^DXP;US`o$~A&BMS9(nQ_{r zjC#MDLT){4Z@oKd@}VHd^-a(b%98P75?nrE*23?NAmE};Ny8Z$$i)$C{!^ZkL+3LvtLQL zqx#ouj|-#qT5Dwjajh(zS6^)Tm$pwJYNynF+gL+KO}viYxXI}lC{Lh(#~KlW*t%v_ zeyUjnmqm5qdR@Yhf2jonZG;nEQj0xZkQtV>Q!^J4_BecVnRcdDvqTHVDDxEK^58fZ z$zqSeP%d0=F2#aDw1<|g5glg+s)z11zjwvqQ%DGqZW}!3@j)lcwlrFX5;wzh3X(5# zh?&KwxZNOq(G(soU}9>VN(WohHAOZflE4~wrf?Qu9wSvz&f+20TeCV(_g;pcrYH>6 zKn|mxRVhzswu!9IV~ehW-b~DfzoPmX#kX~)Y79A0CXPPIO@Glq1A*5pCsd(cu#4qi z0>j0)iRx-!Q1OdBDdnz({gio~X18tc8rS`%?J?$PqSr^BBfj52fF^!AN&i;}@_pL6 z!ct=PF|bG3IvV=>Z_>UjzI_$X4ArE1UE9H##Xsqi{?4IcV1aCQyuMo}Ljj1CH-rRV zgfnUh2;tj_be<&4I^P^ST})|3D?JfHc-M&7qg_^HqKrFjKpPB3IWvJc!UJQ}B#ulv z-`uL|#H+}uiNsy_H@Va(zqBR4Qn5Nv3aYri*s3AG&Q4~)nNdbSW)WOX0VOk}xuo4B z`oW7CO2hznFYL;8AUmfa;fw^*vF<&glt%6}Oy{9c7WjyHi-GP<_9NG;{m|BTn0xti zD|U&`g}f1u-{yapkYgXO9Q(kc1zRyS&{^$+kDZLZk0~BwXYDXgl4hG2`s8q_nqcHp_7h>~q`L+!VC zclZ`2qV`N8gjZo$%BQ+W6?z!_1n^5czDmQz0+~=VSX-Z z-Me6}g$jw%v@II#-W$5u2Y19E$FpURqk=~YeN3g4H0H$P+PA7Ta((=!6Biy)g+>ix z?y@A?H)=DwKeM9`X>xipniaD< z@&|Tth;rty<>8vP?%*bu`CM|;^zd)nj_!1X=YhU@xWs`FnBnLMacI#th;ujmN(}1wBaUdB$A-=wbqV-m}(q$bRc-vJX<5sQ} zJ4SXLzne2>WGe&bG5m($0gYhY5nB&NB@HHrNHCHDKD4_5MJwYm zQb6FniCN5Uha6MS5onc5|0^wDqYgrSFeoo#+#sl6?R3Ot>ATEJbY|}2lCPZ`%uQy+ zv~tcV<4R&sI8TSipt<8fwDD_Zu{JFM?rp9WlGT||Y`t>vv__7f0gtYpE+u9_XNbi> zuZP$AinmPrpG$>lS zQ307-tnu(sjzVB6zq09#*I-GaqRROFBX3>^rx25NBkh~2(j&n(wrvd%a7LJp`I_f7 z(}!fE*fcU+G7_p@?a&Pmt5>rk&2dpK`Aa1Z22uuTf@Lnu(bQ3}jz~}0E0kb--|zAt z$rhK&nOITtt9eXW^aF(>Wf--h-H~bmK{uGZ9^=WjNAln2F?ckI4z$QuKju*SEuGJ@U_hRRm&gLR!idp0*wFI z=Vd$o!o^J-j&p9QO#Js7d#%{4;a*2X`IYP!a4kZd%f}vhvMMxwdS$9GHNl8Xq*ua; zoBp2@L*Gk;rMe0X^15mec`|qu9>Z?K8Kod&z}YW{*NJ(IWuMi-dwWw9%14iGdg zKk_U()4V`=&pNsdG^M;O^=b5uIMC#CrD`Z6J}~XabGaQ?-ncJL zith3Cc78-h9*g!dU<3Bvp4!;aqGRDU=qfG+nc5_emGo8UP`$+) zbypU|ydOc6wU$RdjI30MXt>NiSVt4CXCn$!ui)fW<94E8-(kde&T=^$Y51oNw`8b} zJKl&chq|Dao-CakL3-cfhUhJ^6SYRALS;LHrZCV_C+^1Q{T@PM@_ZTDme66@mZN3vPH06j}8=pkKd(1Xz@$o^Mp?tBb zlmC+U%h4gC07;#PME)=a^IIF=vXzEsRI*M=bUqIAJ65X*1Q1+wxX*VJTeu&_S=*Q< zCpO(LkMd)<*)}BPj=4-9jB&cYDy84Io?Z}>e%nEi5iNy+m-O%y7a2t}ut?9+KQjsM za%Bp}CORP3AYOp5%b5K*rGU7UaA=hW3!45aMdi8d@v(KJdEBg_Z^&!ZK|!&aNb9{5 zLB?;YdXt3;COzkPsH`r~y}OekR1rU9E>b=y1A2BjZHlcCj6fXowtkH;h}&|kfq(2; zf^mo)!KK&W8z9okjo|}hkjphZspu&_dOT-<4hlq~+q%_obB%u>AXb2+*7U(%XI=e$ zs{Q^{S<+f5lcn;_(1HvUlS}_ZcQmi*QhJqbXglFDy9D(!*4S8+>A$6wf884@fVrj zey2BEGgcAWI>?*f>?c{rv*!o(GO?^%efeWkx3MW`NWI0&u`hVxFdRK2_{{8cbT!v4 z)9G0lFI^rzo?M6g>=m6cM~tY%4A+RX0qa`ZN?(qU>U7dM3?oEE+Pl zMg(-16h_Q_tQl%$XGWnKOJ(&ZT154+ns&6VP*jm=iNorv7iDPMv7Zzp%EwF(7dUxKg{#FV3!>;h3gcd=B#KOnFt#6(aP z*nlTrE9R2dN@J1S`b+2ZeO*S!KOu2?b*Cy|(OrT>X046faR9R7mg2%`VSL(Ye9(l+dOg%N$P%uc$5d`H7ZM@G+PHV$&g)aX^p= z2ATe|^W^vLsNv3KM#;M#_Pp6iMv%d5M%&@+n_5To^qW(wvJ$arc^&3&Nv10Y&twDi z#v-|uz#vY<>$Bn%;86;UW!05#=;2$)!DYxvIx#;idC#<*%d5VRfSPTaoozqPok^Yp zl1CjFLGCV`sSDHzP`Rr7@5}{=i8=g*L;UW*qQ~ddv7ktSmY|NdnZkkaUFtU+ETSm| zR1;Uh>m)#W9({)=vk%n-HhteW=itef1pO`&{Tz>QYAAjL^XjLC2Y3X;9bg_BQY}A| zT`sOba%fW!CJBb%(^t0Wg*b>TW8PK`0KF8@6(}{6KN+t--!!pz{rtJw2bs!Ks;KLL z#1I9glMxnz%@2Q^e6pW~5i6X1ZdM>S5R9`7_;#Zlaa#2&|BzTp);uti!m^^Z6rWH8h9FhD;BR{5Ff4B0;9ywcaiAY z6svTMbIS#bTQvp1zL0X$BD`*gfiwhKn2mI0nAsHr4!Ag`56RmXBQZuioqIhHXGpGW zQ-5vpWU83l<{f?2U<^nN3SE+J`r z%nQACyX$VI`}M`MXJ;?tF#s>rakQw6%HXS2@4b>$FqPM>pV^kuH~(W^Eevbg;ZYZ; zz6JM@h6=-jN)2`40hT6zh@{nutUZ<|u50{Ww-H4Aa ziGk9Z^^Jjxor)K^w|M=$jlljPxbf>Z-d!1@IaoOlI2JIaAfvMdZYt?faZv@`cL(KJ zF^DP*f0=lbV!Yo$Rwij>fKQ2f=Pd3k{|^8+K*+!S#5YDr8bC7uLN2wU3*Yy+O|l7`Yg@XY7ZM;B>r`zCZWz!xvfKwaqjXHvPn< z92^;VlJm4Is^$sm)Q8T-dLEUMRZP#fu6MP95{Gdid;|T(@Pibk2e^toP3mzwOVz#O z6M(m?_+Fp|a;q{?HBU-jB=7{crA=!U8 zoxEhGz*1!V_NR6irLTsC+he20q0y|5)11}`T3J2&mq7+MffwbV!7<@bo=%er>Vihw zfbp#2d4-6j>@r0N9O?o8To@{|+2(}VUy~58bUyC%bncQT`iMINuL5oi{0vvG-9|>T zdoGO4Jw^ZZD260iL=1I^M!-!X8tE^2E1E{L3VuAI{ci%_>+$v;H!{P22cucNohk0c zFK!csmKdN04GBk;s#{bB(A!k3u_gGW=Ptk+<^{0aeIbtH=7!=+8tJ5MtCjGS+-viG zrs_+90)8P(=oh-3ptB1o5JLVfQhG6fIgyTXoa)D9!;a;>d`4zD>p7dAeP0dn?lmEt z6SC+~tvh>}i!VzIT0CXHM~*H*=T8*NIS8-@x3(#Z2V2tw+8Zoe=NdYp#gLqcC2jWs z*>#ldRnV%$1f>NT;FYmKzn@>kmzs<^`Y^gf67^R8h5SI4n5|*V)8Vn*Cr226h|pJz zo7~;Im7h`BArvt;P1|+*B)KV9i8DW~V}2Q;>%O4I3n0jnt-Lhm2?WlIb&C`V=AF6X z{b>=Zgg{+2)}94g+WxQFffjoW9)_@jN*eB`w8bw|y_49MdunS03O6BQ)fZ>AUFL|xrw6iK>boU0`)X9wCKzT|??09R zG)+Ab!DI!1G)zE7!6Foe#P^9%E<}*soxRGp5d&CkLe~tj7+Rp_meHFn) zww}4SoPL+L#Or2dMhE-N>r<^EK1RQ62UcW#if@^ClTqDAEP2F&;w7-4X2)@upzNoh zq~e%Wg`(NHG$Rx4XSK}WF0_r?>CEh>9h2ngCK*UEMmQFEKb~%MVyWdOFPJOCjNf)$ zmI560>^jLN8N9KL;;liTj|@;@nujA=NTabt>Ji;Pg$@#bO$%hNKoO1d8b%)9-yDf3{0D; z`ggQ!K46xM<}b!x6et3S;Dx`i3$ue}+;8g0$6K&iM%6{hT1WMk=!6d?<@lohkK8IRE*O{6J@`Y#P43M zGRbuuf*ST2s*-Lmlz&JK2;}?&05`&TR+*LXRIhTG)^lPoX8Iz$i|J{6IFTe_9!*vw zV;5MSFV&_Z1z$LVYjU{{6o1W934DY#=Vk(y|3ZRQE;ZxVf9+HbHpC~wSI!U!90Y9e zg!ZKS3{Um82L~fRkqFSV89KoQBS5ubM-xS8`kfyp;2gg=MKxDy0n;aaE=KdNTa1DP zRqlwMs|EVj8~9iBeooIYr$p?hNF2P!pKqe*_S3*69PZ-BnKMbQCG-N>E*o)LsZ5}O zqb9;y(v-fISKJqQPnrVIq&R{%0(#MLo>o@+sQ6NOSmfh-|K-p;;AD$B2)#<99$TS+$db9mNqGOXn(>AxoKN>aq< zN6#p~9Vz4fG_e@k9KPUdg?I%8v#UNwKX@g3|CltwrjAnOBsW`dD^t95_QOBLvh$Th z^yyf0Kj6#>bL%T*w)B@0MKUKQx%#8i>mwTuL z*is@I9rx>XzcFayGn}NdqkT^9_+tFSHoY=6E_uW;%w_|gJ_H>1Yd{IMO2cD`08A;* zWXB3HC{Ww zks&>}ywzB5*|;DUH4hd~w+mVn9?YWeBy>B=3jAnM9KGSQ2|@iqFBs+_JVeTmm-o+I z3KgLF0}7HhiaxT6W@U{W`O*Yzp7_iIQ_r=4A)Tm+9Cr9{B*pFncP z`rLH2xBX!K-1yd6rRZ?StA%~}KD|H`#T?$hXz_oY3eZG!ag*Zh3j{%Ba0eP=jMmmG ze=~=wXYto(Kff3T*j6)!xY@U>m6vqLa!Lx$NX<@TGMpx8e@XIHeQPozA2VLmIa|E3 z_pf9X@Ga9oZ?xMNf+2 z51?KB!>3%1li;B`vdcfR3{#h4+B%7&U&o#KmV7&o|DMZ-n(1P}kN7~jto@CfrSjTU zLj7?m3mm67Eg9V}`of@*?}0BeImo3>ba#Jhf8q8L4={p^)mk#kG`m|hOnRlIn!T>Z zeP{mqo}4DXL8nt=bjin!T(l5PQD5C)c0wQ0^}v1ion=(Svz$vyY5$tkE6w#G(Au{s zq+sf4=$O%^;{;1a%ol5|T(K3IP4UHhqaGBW*^t{NEllP6L5e&v(wV@THup@tuDD$b zJB`MAQKJbW>ZyXnI|nzRF4bx1C`+T^ZsCs;bRfz7qCWd^;c$xkvwUeOY`O~P;ahZ; z&Yge|$JjzYNjC#+hF~IT*>D?-mC^WdK#bT@&|;_5e|x&pFzEztv+S@yZf+*e61kHK zHN;lx?25Xd)LdlusGQOiZ>Z<0A)s2{fcE#st~1O5kKWM?^^p`HdPP>V41Ek9OIBjR z50nuz)eD}LIemSf^2NGkEr6c$J5@mH1s{y*;q$YGb+@*R5|eX{ln@H9f@UVYzg++| zC~b{0LZ6|)7J;T9fcgX-rj7g{2D5Z`4@{Ji%=|d8g>nrrG&GydRFwwcudj-NquFEh zNsN^(L0Yh!})j!8f6K^%f4^)T?I2ZPu|Cc!$4;wfjIla(5;r_r3jrOp3?f<*3o<$ zk=cS&yb_N=TDM~U-YBW5N6pxzP{^}U6j!T~Z`VEp{VP%CK6-dJT z!_e=S3uj+`*6}3;ENC&ALqo*VHX5p~pq*z2QFnlW&>x`vWg07j7?A6*!QZy`rYJ^3 zy`{EdJvc(iYv{}d6Uqx^2y@_dCf;WiPOl|_`fgTQ^o#mvZWI1si~JV@K(o;j1)gPq%U6OhOR4|j_|KYsh6NzdwyX# zc=Q)0rTb}o>fj|GePY8SzE3U_S-uI9J2l17SRPn=NlZNsZF%R)Lc}rzd{6d(lSuet z2cIzRL^5`V9mXl~uam!+zUBH z7;Cfu3Rmy5zeG*dcm^toWADtVICDXza+&OnIbWx7R88!~ZRn;-mj=1=V_?%xIza8) zOtbPQfo3Rx)pIU9ncZTI)nEY{9UTyDT4Eu}{wMO>$K~0ND}FqyPlMI6+sIO4d-gXvW`8f(I=Y^Tye*dt4L9SLqJiSK(k% znO1Spa%Y<@?KvXw?=5rT>mn)@M~Z02WpGyFrH^@gH50P zbdRgyGfZ#ay+W`#lK~2{Om&bWrm`%iar-r!q3fcVD17A-VMbsLk$+&uHlrBY#Umv< zcUQRgkIAe-dtDJA&XrMFw1MaI_hL1J=KPY|AxJYkZD(-H>mq97*Z{x=56Hc&)x(rJa-^QedUb<1HN`^$aHMmK1ld|X?Pz0Z zWx=jlt%>qD_=oD5NPo27<|HD2fAaTunmE|210FV4D~#VO{G+k}UTJUZ$suAe0_LDH zYHC-ms(Z97W|Y6)C+UTF8;oQqbarabCju#bJoC|+(IJ67P%a7oWpxwhgS+nZ!qv~^ zw-^TF%uV_m+B0$^Fpf~>S5*oZ4h5|TKm}YpSCUK#B&h~Yva1!9A8-;2rWIQs#t}a(%+PlPm%Z_A0oe|HaXEh~hNhrT&bQ>1q*N~PpXt097696?YuXNMxZTkrFf_~Iy=tUc^v)07T^7&} z@8B_s9oB$)4lKxIgTEO(vTqbrM z^~Wq7{9gtzIsX*J(BwqcmaA5HV7;%b+u|je%3mY4MBpg9fox=xlf6R(KuXavBButrv(k`te9v3cRudi2*U+To)VEAc((AKl zX9;Sc*9o}C&Cs~b+Gsj?LQlC>_`vJk;K7RbBBHmQWr(Js*N zY_H*fJZ2n0c7$1pS{}Bpaks$QZv4*-%kOCXtoc3(5fFetxcbJ(%zPf~lsP9@JES1M zFYMEWnrM9_ZsI%{!Wdi0NKJ31)b%qr3^S&pVE=sdFLlZZj}VH9ws5^@9v^k4=m-ot za@YdR{OE=CCc{zQ8NrHH7KT3|F!3+R04(-1T+RsJQLTh6WjhJkgZziw>pbg};AT+S zCT5R#wnGLL`O-y8-HJfcd*f8EVNpE?Wp0uv1smj~ugpvSItJ-LRDm7?*St3V4E%nLW^*u^seZ&2@rASbpo1H<6gm z8^hyuNNQ>&yzCds{f0%EP)N1|B!}cx>O#3UfdtoaoJK1mFMuad#?U<>y0f-7B&SCp z68cp6OxOjE;6R_{^e9{8!!eZwHhm)+b;pY#!F-BNFF<-UnMrz0#a$EWv*T-+T`p<> z`lZ(B^Cq0QGb6U#h*&_usb>XAEbt<5C|q(`Ap}*JP!9WAxz;vE?)n>%`Lu177J^$ z+3CrE6`zo<NdkPqmjzWK^0okDvjsr|3KNZC zfI#jUOMB`)FVdJn9uENq4OT`%vK1lcg$=0_ftY3~FXm!{dYBk5X22`e1Vo?A{A-ir z(rE4ODC@?lKed+GKm-4!YE%r$PvKtl{B@Eyf(HPGtwU!}p#<12hb=$F7=6cdI8^Pc z8v8AGJ}}u7go^;)CDOeXL(9cU_?NZ{*xaZjep9UdBlL$@*07IIf< z$w0aB9s0i2wHoBp7B#&dBx1VNip-^2cQvDkc@4W9=&>y9e@}jAC#)i_9+b z7X&BURElK95c9#5g~uO}&$)2mro%2-UQIYw$WRSr2d|4krP8gII)}xiweewB zVr{5F;Y8I)#w|Nc?3KADl@jwD^Qd9W-$=)bv`C2TGn+PES&jqlZq6p1P;MDDj z`ytsM9MV6s^|_thZe1J!>m8*bSr2^xuR-51nR-Vz}Pg}l8UD4_h@E96!4rO^ePTWQ4kDwFXKlM7lnhkrhA@OT*XY7O~3K~w} z*TD7)MxMsneC}pQuec;%_-ABmw%`we2CXRER9wrx75j_h|P>VMWr-CRw^mt z(NdN=KYMe^+f#|e7f=BNvr+oLX}8wohZsys1#u$m?T*&-)JrM4Zh=aE)E`&k<)Ap_F*a1*h9S7!DEtTgjSHjfpgV& zcVWegFo1O1>vke756di*uF#JJl^o%d5i*t%$yYx~HQrv-@5SaZ+$km^m^|Ff-4N%a zj6X`67PbuTeHyP8oYG2u{Oxv5F`Z{P{d!G3Q&m>u8W2k<)zy8tLY zL$J1VOZ5;TBvxV3#ld;6SId=L<2s!7IvI4-6jv7ec=Rl)ed&CJO^+W0}zVMKjBtUo^bVnT|8^UZLtRp88yhorSvp#`iVx zzo^RfW~NuYD5Ty@eu;h)x7t>F2Dl^>^lwZA}O(k5F+qOjJX z1}8A#VdCI#DK=_%9E)``Me1Bu3bPz^^@)RF##^5?kQPKRb zB1R}aoF7WJ0s(Qf&`6O!1_AcofK5UCJ17P?)#Blu=uXT~b8-iAZ^Liei_2gn-PH^? z`ja#yZ!Ri>yATdkq1s%fTCndpN^n5DpvTQOMlWeuzF7Wx*R(|H;TEy0b*21x{k2Ys z&&4d{vg_%x|0Y3kBr{uox$^Lf!P4 zBjQ%3n>I-asw+{Tf5JQt<&wt#ZZ=VJED97H8tK;ibXkjs123? zE6VI^EYou!(GBMmf9OU2<}{xtFgw|Kd5zf3Mdp`v>}THhL(zPMkUss}1Of<5-B*fo z;CV>2=-(wy6sYlvys6j+JWgP#`=vBSinZ^@hDeV&ueFg)(j za}IN-mU0YSeHrGgV(C(L)Z?r;o+Hs~%dR$!CIBmLkKM|`qeJInfs8X)1qL#j$M?m>S5pq)4#_E~=C~cM|Gx(uI z4o2K9qkG0yv}*^G^EVHn)ITKyrZDB3qP@3kZBw5^&^VsTY51zWIuNv@CZ?sKo{+D* zQ>7+nLxOd_P`0^4!Sl22T4g`HIgK=T_wD;ojuWV54AJPPj$FnTfz|K>Zowa^Mew7Z z&otwhDKvy$J6N!BFX$dW0o9q1kwH+O%iaZfs2(WnhewT#X}&u0Bus05BExxo4eudCV0eV!tgKGaT0`XdwH# z+C-jGixheO+9%H-(Z*vZTQo~Qj1v4{;{!xWdd{n;26N`ARTnK&pD%$UFjU=>{@P6*xzxe+7`h!9H(^A82(JP=wN*~ioZYO!lvY2P zN3pq~{%rt%HBu1eRqsl{ZOal|%y`UPsTkj{OayUaY)T3f`?lFX*2($_QM|0=W9)KO zoyrGVb{8gbX6OAKGZ!V>OVC^tEQHTAn<%N0AWWLtq098dv@HK_MqO`Wd!2IHl`VIC zp$u*3B2{OAZvM3A2q)E~hTZ6+ZWoJ58=%E9WPu=)X9b(&)+{J=iq+i;h?*eMZlJ#x zwmR3NRqjNT4rMYL3ynL!iVeEtiGV>PL&L0hrD0A16%Rb_+Z0!6kDQp`#sofne?H9r{!|oq`WGEOo{JA&dQSUkJIttHkR{FnggRY5ceUirAl&4OHntI10Ojp#6he$bRt~4*1|+GQoNjS&Kxf} z5i61rhDC72i~DF;FKi=zT;Q46ty<;Pg&+J>^mdJ06A3_sD4FhPq7xvv#N^!W-!mPr zSSa?zsShE#2u|@jaSBjr%7%lL_v+9f)v~$ct?==OHVLVY3de8{-Mc+Nbn0hu3I0S? zCEMysvIa*2hC5ar`Wud;Sc$i`h_cP2E^LqKe$%AcrNxN*m(97(kV75~P)U8OLl(1j zEzKW|j2c@J7&pj)wRCZfBF{&St=ks*rZiCZ$$iE<3YRvSP)Ov9*uF9mK1JN5c5ExF z1>zU~edxr}d?W2B0|y1yonTt144;aqj~Z=xwUUpXjKsY6zbA;u3d)euYnBRG5_Mq- z|EI`r3x%IBqE(cHbaq@=b2wF1i51Y^q3Xd1^NdvtLBu()h!1EeZ@%V8Q_|FWRD!$I zE$mq)57Xyj=f>o3W)C4RoI0GCuwv(se2h{K@+N)f4nuyLcgf)kH6H9RL#e8cJ;;cy z1!%O2Vp{E7?qs=MgIY!|*f-Ioq{bTxd^mC`LD9^A_9uO?CDH5mfdgdU`l;&Iy`Al+ z4M~yo#wfT;CwyAWDR(E0zU~v1gdL}CI0Kv08&5Gr5i3ypeFl`I#oJ?smLj*GyL(%- z__@Y1_dvMuXiey#vEjb-Z+&}NCM1fAfk-|N>L}83VkT-$;RMd8&c7igsoS78 z08s@URwC_r(01jVna7^0sGn{5z+>AQ(>sOlH7%aoq%{VQ1y3RgXHctJE;$afIW(AA z6j=%<4#QsehY#6fKP3WWY~)N4cnP9C#if_Ap(sL7nm53X+Qo6_-XxtX(*F$tp0(tL zs<*+8>a)r|HhS9V8Qu1hBq{K0(*w(7h^6Y5-rk)w+a6&-(b2oTQulk1ax8m#Gci;@ z74D|07zz8luZc=bY90cea|D_~I?%NCuXcDgHVt0neLd|Z3EWu%uaVD_{t@he+TNqW zKsnt8n@}$t15cGz6K}Fq|3`XdOf{iQJ#FViSFyQch_o4SQ*lja^{1=*k5|Q}l+r!S z-p}%2h0N!%kME0|xgY+SfXpPym9Dq8Sm3%oZMdiMBVOROhpa|{#Ri$)cuIB})}dXn zT7_YYMK-eikziK@t-irxTTbEZ$uXRIr=kBUA18RqA3BK5+soNjF;Jn1n2al8UfOvL zkI9G0T|SJr5Mzm7D_aQ`1WBT}S&O;tLQmE!Fut(=w5*m<_f3tn!YK)!ob|pBcPBHB zdbk18VVACYw)LJ^EskIkxg9Ku2N4E=)<<36mR}csgobA4px`OO0EQsPlsJvjQn`{Z zs^AFj$3P3^lZ}L4L$g^Ql&o|e$sdCrG+9N5Li#&!g50K^B!cLDrlTP-za)41{av#X z@vGTvYE1|wGwt=g1_#e745QbMpKKYSSht}Z_hXw@(Jt#DCRyL;8=9u9L5h)>b8>=3 zPCZeyr8COn>5I$vN43A)4i$-By6OcpPzVJPKQ~MMF5!#5_WedSLn*@_K4WAB_v7bv z6%kI53lLzhbMi5d*cL=?Y^{g1|3?7Ssa^P=k{<(lzk(I<2pgxoET_{k*@~ZgLxlUagQF*L?P& zJe45to2QuL0uJtjU{IQd{BX7Ot#n_t;}HHm+Gd^|c%38SpmQ)4X}B|(CYP6xcybxT zY6Z&Hyr8}{AoimTu02gsVOHS42{c1`aDc1|R3X;dAdIt@t{a`A+PoXi`NaAHye#=C zD!=}T_Sva?GC4%PSZe?3qo3X=vw7sfqNq@8 zE`3;w5@>1tn!UJU#bw7agp}O5m(tN-kK5^UXyy65LZtb(fZ->Z!rc;gdwo;x!K8AT zA)xiC5zZm#_=4?aq|)AmC<6+ZR60o)#c{J%$X;2%_OEc*Y(D0T!7}(M8w;)=6 zEi5c1p0Tr^w8YsG;T|>2d1>-;``)3w=u+qkKFwOg!E{vEjyZpuzKl0IFf028EC=~W z#1ysLRFcgGduJAab1i=$*=K{u(>LE?;7X~a2aQv4>DE|s9lojm$qAJ4JAeAR7wxU$ zeti%zH<(iNK&ws%j?{gWvi24Rzr3s~AYe()+6gaFrI~%2-bVQN0ji;x|8a8I#*zkw z@d-H^yO<0J+yCKj z=*6f~GZij#%D!&z-|XZ2v43p|m`mRNUIj4ETxEnl%V{~_+{rNvXQ$JL)>@|lopsq@ zS3;(2O)@O@x#FXX$1XKZfPwN!xqhWN2&Pob*C)X+xF+XQT)3r2rgobSA(XPSEBVzX zF^_JNG>vqdh_9?I!5ew$Ka;j$FgO@1n!s0n;bPsFUZ8=>!tn+K0WD0;g;KttX}3J4 zUNeUtIhE?Y`wJ#8_t_#IHhdB7AWFdz%6QVWym7JCKG%4>e;9zu!#kUrcfA0(3!LmV zTA8xMV&RaFm&f; zlGW*3S~WTwMrH=kti)$WYz)!K@>y<)5BT?>%Vl#k91D>fb_oKQNG9JJAre~`s81$1 zqt3&J;s1}kyz3z@-h~@M0>6WdEVrTfUs;)V*y8bdvqhf-%yibu_b{>!*=9`Mg&Cny zTd<{MwdJ8_RO$OIfRvAm=a`ENKpQY5mgK^qWJ`}QB}12 z5{3jzj1-n=;p6K~Vj&IQDG8pk6H}t!)VKi&>l_8 z+@3teUX%xK!c@A(nvuI_6UUvhu52vVjUiaw4B(U=%rH{4pYRuBa(re-z&|6uBW?x? zNTDDUCQ%#U@)Sz7Tq=%5U8A_1d<;XBhWU3AA25}ExE^>v8TTB3{Gd8zys|)QPt{;R z)2U;_Qe(ZY70d^%hPp>Hn9T{-TV&NH*)JsDZVkhNdJNAgq6~3~G^#qCBULk}s=R^& z6V+n--wJl4&ekYdE?Q&<*rZ(%ahpIjez>j^U}5vus1(dJuGxr{C`*#)jt}3201zSCw2Ieo0IP?R2hAzkOk=ZKB}dZFLMW)rz*lt3w20`b=C0lUh58uK!vFqvd&XtBGfVDo?B~PITP`Q6)kHrv(y zhKtjRK)gQYdcir3ShMyo%y$_1Zh<|_e7=hyEN2gI``;b!xvHnuk;PSLte}l*pvlSF zHswuOndGr|KFur*X@s}O(Tq?YG5Z&*)8RisQC1td-L1|d;EX!aCk9%B{T~Ey$BkQa z$wm7?CW%q&X1E7r<}d2+U)q$0I#OJpBz6>@vPb{SX|Z{~GPIU89zjU_83JI9b*O7` zkUhDbhBShFy*1FS(its#U3`>VAc(zmx+lDtro_sZEy?5-jt*{=<(m(Ib_K^8!_)U| zKhDtwchj)XR)9Ed>AzsPotdn4DUV@vb{@~k$`%tIF}->dve6DEeJS34a5s?TKlMyi z_l*snJT7n-Fa_`pe){#m`Ud&#iK*s<*`2o^tuZqDIB)VWL`8%0fw-)oK7D$k?Blavy&YCQUOLan>&QTurt-4G zBn#JoA|9Ibf_DdRKDy3fJjK|wPs=q-b6!b~XO=tQ<)IhSQwz)}Fp#rVJhd08Dfb&_ z>t)Bve3UNrG&M*71)rsi=r{8B?3us`WI9hciP?KXswmQ#FIHTqmp4SN{ zw6v^`4p2b($xuAM#af?T&^0gla_#qy%C|5bqu{%`$KOXZn0S;ee~OFhVWF|8I%+)j zH32}L07nQ)CFYYLP22V44rqGCBSMjq+^<$B_Y$I^OmUp0*7tjkXvhS=g-A6Z3UV`ff-!nvt_UA*o^op-7CYNi%4TFX+)ujv=_Xxm|Xas@3aw=YPw(#0GN`GrCt15m%@ z?4_xJnDwd1kJ|%$VZF;&4NbZAzgP`Kb{eLe%)4(w@X#}0P2>*%*qiJI*vC0PlvN^s&zJ* zG$bomG*kvLB;1NtssmWJ1I zRSyT6ess%C{!z#!y(Ig+849i9wX@^q7Ho_>NKklW>g{Vt<;&4|q~#?N$e$enzie-2 zM1RmJF+_jwKU8!~%~U?Mg!P+%y477f?9xjY^$h zAI*jjHkzk{O;b{Q)4F>*^Q8Ki&&m1D#K%c2F0z2c4P{0ybS>uP++q1b`Qb=EdskS8s&MzR`TSHdm-5o*O7EfnXaQEakPOy4j4^e*fPGI&vfo^# z^ZF~XWHQ@08~05|e%x^d$R{~NbkkclYqt9Y$g%1ENx|?L0&1Djy)*3qO-OW+qWi~y zw|#fbrkn%x#Md92hzEZKD7Q*6WL&icY!9n(f&l&Bv@57jsvj;&=q*l&2UMt-%(XeH{sYj96y;Cm za^NIRGXp-285OMo%Cu4S2Mf^JI;^*Dd7zZ*KEwtJHhuJgi;BeeLYf|Vzl<3|WQhNB zEqOtLJNMbNm|omB4+ zp=>%H&Cv4vYsV#puhq4#RiLmTYslJ+wT8ofi`MGlnWL%FDP=v?9kWF#+m2H2M;C2h z_1Lzvm^dt=-peZ2g&{SkX#6Q>kzdf6U6OG5An~EPfj4Uxq(A72cxKJ!MPtQkO^Uh% zmg5+iq!{y}Q*g2^px5`6PV*d2SR_}4_Rs`shmgDfa+B*tfOYV-++)c)^m_4?el$G2 z1H{o#uD>?iaQLUW#MsmOD~XSL5+P}7RWhKCC6F)^%+05m6pIgn-Se|2AHxyUk*to- z<+Z!`=Mnjelb&jxVCOC+LW@$0w|;?`tDPd&t!k?W6%J}QzK^W~Tw*y{U~0z(#V==| z`2RuWTYm|9`wL)P{H+Tb(xtA)n!atD?l5xVb~l0Fp!OT%FJ6kr(0x8ox9MPZt;8ut zr7PB$gx>=@aio~!67B#uDbeiz_u3QVU5dJ3-D2L#MUhFIEYy!(=gX+`$7chY-TFXx_I#8`A`G1YW9~$6JmDsB3W^Gha z^laK4Va2Bf_cK9%hSI|%Jyv9Wy4hvBNPmO;vi2aSiyZ0Y8#}2zdf*G+b1C`RRxy^K zhGp}17LXKNAoUZRA(CiIwvD_(S#zsBFYdgIA|#J_@H7T;)UXbk)s1ovmd8@Db_!)3 zQ}`A;y6iMl>qfV*V-J|GU3k=*S??da$t@g&b%eP=2?AjT&dQmQ*`Oj_Mn$F;12$$$ zN>K**!W?p4z*fy)ro@(hTdsNzUtS|=_GKm?@FOXmWKQSI^2D3dT{#!)w zxjMbgSeEbLVVJVG6Yf;0Sm^q2y*HO_B)9fSZW2pMr>?Z(TUF`X-y^5~)L?&hbyG6i zJ_9l7jGBK0AY1o3hd)~iy0c%2w3kO7@%j1%rsoRG%}oa3@$e-6k}SEiMCr`P*@B2x zPiAFN5Ov*MPe59hOMPr#bjmE`czilX*0pnM$T6Yfl!qejQrjmMk-sIv24Fo*d13g7 z>_P+=vjS8R_95nZMd$69@p#0eKZ_b8H=IN9E1>;71T2hV1OUiTm!5a6cI&4X&kCz{ z%cWBjT|RvH9H3w(r?ths z3}BDp99O_UT`_CjmATi?gLZ!0#<*88<0Me2IqNgC=jHTPU2VY%`l8HyPU$IMQI?a}zvM zSG)`;gc#1~+qchx`A7bf9n&K7c??Fy$Mmv_8x*2#P9!%ChhY@|*V9_>>6tP&NShOJ zS$IyyKl_>w-y(O)ryBoOaeBt~VzNs){V-b~<%SF7U{0GxH`QiG9I?q&p; zu3OQD2y=d^Sw16&HVVHoNonhg3AYX;p*o`^6c@$C-lwOIQZ6WdNd!s)qX}nZnC~y; z`-4llZ1;>L<+PCk+JEKzw0Xs)o#iDeg}z29!X{#LU=x}~!PXAKyyOQP#b$fv%i)Lh zSdZ+RAmi}s%o2`g6){BQ;<>~hJZ)xJs72;iLDTQhT{qI-pQ(QX2&+w-M25~- zGQK5CBTeP`_&RdeA{X72YSGM31FGa}o+FeC0))D2Sm#Yr33K)~PsWBxY15?4UE3Z} zar(tUfbTXP0V>U|le5|1bK?1FS{Rp(Z)4IOBD$KaSEoA_EnC{hLW5b~jS62LuQ}l2 z?_9f*fz30R&murdap>w8ICyD^U$MoUNui@wABO5d!%`oZJtszKw0VqC@V#k3oa^;7 z(JlqfAYaWlNLNnvI4MWN+tgoAIvXifvE3}0I`{T1PL|QG5myB~2c=$!5{*)5| z71iB-FdIXVe~4W4lIJ~6Jb`j2{ndC%A@-X%qS7PoH}oW*ru{JwP_|h+uDNV@0K*5z zG9CEyIv9dx0NSfgg{l18&`}!awjs*ORPam92Kz$s$~b7F{*ymYR53Jh#Z=T+Znnp8 zJY(CWAm0r~;@=R>{Dt1zOYUL00N2yn<}=g4jt z{a8O8N8cBYk|%`AJ*tNG4~{_qw|5%2UtYcJb*c+Pn>T^5Y~X1!>;e;OqgaK=TTR3i zycF6xBrW*;u(j5Jf1Wnqfv9VDgC9aXbVFvY`Ym;zy{w7QW%NC=Ea3GF_3J+dbl+Ve zmsv|fYzfs0gu8S0IV=xbS-34b$b8*x&RSPcxAM}nj&#K94Jf47Sp%j5N0d$_eDUF~ zjiT~C1(M4TzL<^SO2hFcF%o74DK*)BcknLGa@sEXse(NtYkS4T<2Jg_K3nYu%1bBB zbxLOZ>-pO=QoU&~k+n9K$Rk?6{cbam9&4fig6$Ty#;anKt7|qt2vr}o-frx|GOo5J&+f*! zZSg%)ZgC`hjQeCyPo!{L!6^=jb+R(R1k6rvLO(nzwi>$L=1-~c~yn$-Up@$ws0 zZ$7UazWbtc#>(g~I{>#i!_#emHBb_*J?O1=4i9FP(}iR#W^9#a+~#CkLU2Gqa%FMC zpqZf1(2xlxXr1S?^Oga(YzyNZkq;9Qv0zu#*bwl5ST3mN)Iz^-)xVwwy&OUaLt2qB z_pPZmu0B(WBgu4OO+km(iRSm{8pzq)q?>>!qZ$r4#>}}6W0OJyVh}~amJhXYI>=#v zS0iQV_^z5M7__^zq2Gavy3Mm7p$|X^_|ruy{lV3w=cW%!vc)y8j>I6(?g}0rA{W8Q zDnp|EirO1Y1JD3yM4n2Hw2J{f&lb|a^$|+p2f`(D^B1OYFwwhuvq^PnV|4dXGB-^a zdB6rKn9MeE;knPL{wNf>goJpAmR2vTzxFy6j=~JVxd0bc+iFf8vY%VvJ&d=jQiEiJ z?SvVqn-1!a(rjWUJTAUyVieA~08y)`9M9Jczgh2$o!Pbhlq>*6K)S!eXuReyvp>Z4 zhb)TWEn9Gw9sP}<^kDIs$YEBV-Q)ATDXE%*TU00OA!YG*t(jI=*uPVM zIMvWZ0CN{|@#Rlq5dhz9?fv`r1UH2SP$4Hwtk$w;{GSpQQ0uuueKh|5bw;;2tY%}w zkDg`}wsi(MZ9{{A#!gwh@sNxj=$1Sh0|=B<7ZrmCx^tD3h{ROb0#hI5*A^)={iR%9Gn!QzEi}Sq z9gfs}VZM-&Q2HCBC5)Z~kw0SOHCnWSg5*eg)!%a4i3=Qjuir(th1 zKuYbg-QI2q!WsWjAs~+TT9Yf~qW=`%!%MRBnfJfS&5uiNET}ukru|^+qHy0p^?pex z;^fxzBLw&VDn<%CbY(iUkpqX$Pgh=yzwK{g)|LGHd0kAE!bern;~x)7XEzBd8wr#V znUZ(sxKQ6jHCqKK_z)urgG}Lp3j!cQ`bv zqodX&o`RZFSQ;Nr2!1cj%j97$y>f<9{{xtg4Cu(dF19OL$GS)b0-WNSFDU?GLgrgR zFhgy$B(r$PTHnye?ETcty?y6Lp|K_%+k;^%44dlYgh%BSN?0iAS1XvV^!j?s(OvGo z_F-jQE*n8|J7YAUJ7cGO_(R4Z*&k`a2C-XKOlr;o4j{+hvQ{mXA|Io_v+~MJkjHa(0WkeFy50u7u1F|Cs8Y z3_}#o$FKJR3rFvrP)a1)!m3LGBEgVC9VZVbk>7UT1t18mbpKMjB(ud1x#@o~bXpxF zasUvUhrD31sG$KeJvW4$#?2eWm3Iag%sW+H7#Jo5*MMv*CckpvEnnds!(g`S*`xLE zzM_YwR8T{c+%c0dmjS_yyvqkGwxQ+TuI=224~cwVKV3=+^~HeSW6BAG{P1#7HN$_n zE6w5MJtIv!7T&6vzl3LlOrhTq%upqq11d_`m}xibcs@lV5y3O_ZYkf|u6*pH!Wo9G z=5T^4!-c*bOnm(a% zEzmBq&ocU;u}QaLOU_Z;yyWo8(kcXZUD^ts%^)ewqh#c@9?Po&F!laexGm4ZN3$<1 zVA2eZh&+RMadj|>0&QH0@p0`G1{LmVNx20?H(L%)O2n)UMIO%=`e;Ryd)D9`Jkpzf zkT1EF(vUcz%$o|bLSio5kAlG)il$ABv(7bxLtA`P#T(CO8goH{pxefEL1V3?&zSV`8UCW_CY=ValidAPT&-% za^gS@tCM2SkubccIy%OLsj+v>>oRy^+AiD8{^5O2Xo5O0RRrFHpYlWcj{s4j*e-ru z6_XbOy9*MryOJ#6PckeQ3v-wb+hkOLV;LZMn`@O@Ms&D}#`4iwRVq5x3xWIipq07|-r?`5;2f9_-@*;4oMZn0#3 z^o1)T_GDS|@ZUJ7FfWb!*WA5lRXh>037n7O64KKk`PkD=*5W&XhY)Qu>6*aBQc5gv z>EEYvZ6u`#4_LH6_i2*yd38p(dpHNU;taJR|iLt zpLvF)&SqJG0TaXTUGoHA%sgHQ5T>N*1@Lf>9nxm>J2e^<_>kRwXUY5~5Oooa`w#Ec zPiq`>&DjR)F~KekYfbtO)@PoD^e!n%gE?o!MC1K%N zgeuR`wda^xBy62XPTxXHf5&JV;sZhyq1N}OCbG4vX7eg}yyxANl|F77NaKPMFB_T5 z*udSABDl~zfUXmfZ}NvgLsg=(Bx|`+NU102SM~Xjq{8YP`_<2y2rWyUT9u z^P>k_veTpRhzsSP1@RDLCtVy^nWasf7kbNolBhChY| zO*eS}52l1DJ(^Y98=D`_pkmq}I? z*7gb8I}Ah*9H|6OvKDxW2#F>519a4*I)_72hrF!)1-y#=eSepjHRKaKfq?q-c0X7< zJzTNdo-}*Ggk;rCxlAj$>p5&V`<56eAMq`-vRHfiiP;hTaBK)RIqur?=KRvsXE>D* z_umGimpF-6-WCjT#dc$S`mid!oq^tt`BRxhZ7I@w%4C%KHTJ^7meZ}-3GkmuX%Ed# znp^4u9asD*e)LfPzzBExu3x}3Esml^e>;sI;)r)^F4)5-dWBluU!`iSz<;LyK(odTNrx>WkO1JK(dEH;m?a;%_XMYdoE2yw~s@3?zInDYp_JS1cl_ z+Dh|qBf+Y=0w65zlcsou7^`d}R%~R&>FNY+%?a3%|83Fvju-w=%3X-YG(DK)!iIsh zh5id`9r@SkI{cRbFlq_Pr?2T78dc64()L)8NVN}SEd1`Jr1e8NQuAqM6YsR$(9LGW zV34-IU1eL%mtvE_i9nd}_ms1l7Lzm$fws2#>ee+$n}fPSP5rc=eUa$9^^jQekv)NU zmbg#-i!2?Ha;Dkj=#LaVO9-na$=?J3ml!a-W%JnV7!9m7M}|66 z6Hi@&D!1=fxRz0kv|bqd&H;te<%bQy5&L(~()PYgy{VK3Ghvrp_=wv*aolF-VvINS z(%=fAq86sHz^aTTTN&J4#EJycUoNH*@miNaL#3VQadS2C4(Zo1M~&Dm+6tm{`ZHvh z0z*XrG7LN=*L9!^k!yAaVt!N1>{PS^v2>t=m%<>X-L6&wtwVhAQek^RB$_E@=)j7h zO2Hle%lcp*p9c|Fb@w$r#6p&y@S1_eXgpB{V10umtJ3tP;6YR4$J>r)vLt7$+W_N1 zNn~Ou81I{P181QLGp9s=rL{Ut+Ec+*0$b-e$P;a`f~Yz3aPI+)^96^ojegJALVMVF zJ3PADPBp@7enayqj8gfiAx|@GqmyH3Ot&9}`%FKpnG`W)*|{m0OqZhCn=UAhXHGy4 z=Jr;Kn6*XgvKTE=3yQ2F8`cuRNlsWRApAABjR%UEJaLcF(y(yjU^>~}rd+Je2KBoy z9*@6Y8rz`vV<`ZA>R-o~RoiXH_embVXfHE}!wm5FM|e~U|Ge_wEVU^DXVP4zqFpQS zi#BOX)Bv^JmxY=Ecxo%qAIkAN+@ ztmdiXbwe8laef^al22WTT=R4)*6XR;9C)I%tdhW#ogm0)jZ%JdQS>y#owmMFIKc}S zsg$VX3J6XLq_J>~Twd9y0H2b{C+nj(&?YVdXJC`c~xasE~q+d~o3TvM! z9+4M1m_@>4I!;fJA_5I z)V3lLvYSqo9=G3OO(A!aI9fWBAA2X*)FJPi;6(a$gh65a62#Nxcpd>jaNp(k_LB}| z39VQ1I+l{PaBPl0`tu$C`w-Qf)SpSM<`6hT8>20Z=T0uQFw@QzN{LWb+S{F}ICO38 zq>__;Uk?e@v9hO;ucfXK*?pq*4XNJsi?&(TFI4zhbz*PT-NR|0PTIM_)KKZ+(}@&B z#n?J2IOIJ|kx5Gt6(M`p)Y;loT=P$d^|w_sgtY08(_oOX$j|25B+^Vv)qVFuEXapZ zFZ9Xy7q4^((qBF!&SF_fS zk~#Gk+y7QCk?qW}U@y*C$d=*UQ8j2*`;n6?V*;7IBO?2bhR$6P_N4*51oOm0m7+Kp z>t?u?ePs&Ey4l9?|BXk;yFdlT{qDV`(-j>2-^L9~BJYYyTM@|LP+?Fd?t821hf3OL z;zhC5n)pt=hf5EowH}T2vX1I95Tj#xkre=sQez1&)t8o)&$j`Ov(#9Q@FtFF=nt?n z20@tOaLWerKqBsgZrQ~aJfK!??x6qx6ud#2B5Xu|ba={h9UQR6CnHm1*aE!>VT8&N zFllHS6(m6$DFX{XM^jEy-(w^5iv_uIYD@a;w*{8B#fAfL002pP*0L0A#tC@f1g}eF zek5U83F9uYenW%3+n$1owy>Vzl0ri`cvMF!rZ?Uci(PH?V+?E>Lr5 z5FfUWYJWCUL`L|F>l3I7RSWPQivUUKHXtQ3WOwZSjJeo9oMNHB_)j&po4L!vZfz=3 zLl6I9`PzzvtcUnr88#OwO_VTgj!(mBQq6lo{v+R;afwb%h%pNFLA&9Lo7#} zJCv+hN>8f9qTTnxPhS~t2Ua`|11AbgI}~~sYPCNuX#zR35}?m-+_x`3pwS(dPVk2LW% zuGKk`KuDY7j?$blEbluKwmcQ`NQp`yvC(*D(^{zc+;nhvB(7ltzlF1@jTc(3_&yWv zkJwiRy6IMB?I$*6G`K}mA!UwW;W5J56XpENqh8AU9|usF`bBe^Vl};&jFM3BLk2^T z;tne%4>JZ(q-B!zsN+Tt93OppmP?#h~JxcU`F2 zw>5$|gWnB`0<}y#OChC|pRd9!$27Sl(E^`LS(|*Q$a0{p4hX6mB;D-opOnwB`@K-X z`NSFTRm2R1ygebKw;d7$VLe*Hxeb1XF6XrN#ZwlrBIjqKZVcn*$@;vMquHfGEL*Qq zXuZJH%Q;C)%eO1m!_?;`~Q$TV;nt8jQ)zuH@C}y6a*z_@!OC@Ije%fj1jk0)U@Jl8B%VD zxXS`T?>;}0`hqpiXA|>61l>N*b3Gr`0&kf91xv$)B=pZ3<2Q0nf6eo>i5%XL|0JNc z(4G5Yos~9Jr|Wo)bm>q$`ME&)X_aN>m$3-~ZK+PdMgOIz+PC?ffL{oPoM3s(_HgcH zW-+N{D${Y=NC2V(2Uf!WcRbE^#mJJdP(IX4H*ZrnD9<^V0p=|!`7vVkz35CoN)%I+ z!Ni-K(bL`jc>M9*hqrTXC_V=L;gN>t?6|Y1r7&+yzz~WyKB*dGH#T3(6umcw@+K!_ zGq}v`sp76!<%SkkC9%GYjyq8~l^`WW2+fOoW454lj%Wbw!Gb)oN2SKUJ{Koy`>Wsc z%HYed-z$d9$5GCR!b%u$k1($*MHqM`sqE5Q&ahx$RkM z&HX4*Y!ytDwJrdZ-IQ9v-8eL#EI6{jXqj51kVt^lQbP4op2~Q1(t7|C*udHTDU$T- z?fiMU{<9|aNYfrC;$yjS#nYCD5gr7&ACaU;N7)=`!pII$5dlU|-;c!rI{(sG7R8JF4M?gA6$qEj6eSuJelpP>{LhH7#blVcfVv=hM!>$O7 z#~fm9A{y0R{H~0TeP{8`wdiDUU9w&yih$u--+*V+O-l8;Fe=x3^#0qGmhW!ZR0{3w zDuK99MT`INo6r_T8r|1_;V2=V+~T)8G$0J?wT-15N(`7R?EHXhQ&h&OXW>FQtOO`= zO(Gyf9TL?Np}KEuxvYRjgTYUE=U2&W@Rxn1V>{`GtA6v^33OMk+Q!pYhJE=j^_k4K zmY*6}uod4Sc&3l7Nm+aYsNhEiH50C?6n!k-d7OucR#>Nci*TCFt%>vI==OeDRpcRM zhFfTu<7(x*`2i6Z!ac|;AbY@3)<`mOW!N!M^MEFBYu^&*om?Nr@IbAgmnJgcPN#fp zwv|AGqF3MgY-37^*=<!o7^wvH@w!gZAIS(o7wYbmyy=)Ko)iqe9TFh^re=axdUW(Cs_L_`&?w=?Pog;a zIO3r;On;!y8s|!yQ{ejWW-ciXm468O8Fz6rT|08Qz02;E5`uv6ILw-_fPKke(xSA@ zJ_mC*k5V$cppy=Id((?)p9_pMQZZqRP4@3Uu(qBl=Le3j-qf|Svub3zr3$Bbb~ZA6MHK!K(tmawyB&=t76PD0+0vr}#O+R9x=r z_B_|m=$Nf80JvACZbg&ew|h`m+E@t5GjmQ8t!l)XjMM|AH8W|T$>ctp<1ZftdJVYp zgqO&cET)Ux!HCxs)WWc2aUVky5DTp3nu*EKMfr9^^!kU8TcPeZAv|7yx&EiZZ?)iS z;xl+mM){E0i+IFv;p(9kO<1f_94|>S!4(;y5h-8Cq)EmQAO1fc(J9x5$P4s|b9)>^ z3wMS^Qvx_w9qF6Xf`{PUt;l*Ra4oGY#Y8uAQVkxU1L8dbm83WL0fSl&1II6xfhra; zYg+c$kk^tXO24cH;_uW7xflA~g6x+T035`DK4=xp^Y3OG3;&TTHc5#8@eiCI9K)aT z?I5F8)xYG6wvS2a_q}w1%2Jw%i63Y!T!EEbKXkN?+{F_|ts;Oj9V>PqV zO$w1BBiP-6>B5FlT#G+r!^SB5FSxfL{t#j-#e9ML3V;yn@E6Icd^63D8<>;UwlpLQ zA|cBE*Nr%lmC9qYS<1AzvGK>sG6%Kx@s$;NP!Cq`Gg=7LTy$=zy-WE-=&#iBY3+v# z?>NkcQuKySZX(~v#z@nM-Jl!j0^}nXMQo$!k}JYoIz-QW^WI}GBPRFX+g*FmYRdR* zISp{VykkTzrqD5d%KUkqo{;_w6*`$}Jb&X(L^1ed0js_s%~X-ve_>CLz;aMa`A$9d zzO`gYN&2+~{BbXUT#9aw>dJkai~Rv(?XJujfbep`>eX%@8bww1w-;+!a%Z;BxujEk z(+aQ{#OyFBGJzF^bHEnmt=TaDrN1>$#R1Gk%AKVBf^G$Me+{K!?d6}@is39>W`n2u zC8HLgkk?_-u(S8U9A6%Cyrwm&p_mmy0~uF*TTJ>gF#Q{| z?)#9n!eX`-qXcU!IH)^^7VzWM6ict^ih(&O=wY`Bcq{?j8%T1;n%r&BP`!vZjIU%H z{3e-!oXdeo4A=wNl8442^C?XuE`>ol{BUyF5ojn}QXhXZnhU8t#lGj%`oJ*}<#yPm z_1F1s6Iq+4T)ptHb8+pa_5D#q+n;PK3QS8ZgC7o-#l!G%x zkpaJ|a%M{eMGFq9C=IGP!TnFeTi@sNX)>A!ZAi&He2e*KkS8PyByM1QB>OFJp^(Fd z$ladZd{3nT=iLqVIi->uOU*ro{bP0~+xIt_U5w@lOI&)Z0J}LoV>bhwZI_20tVnhx&!0vVv)Uq6TFCDQbYR#%Gq`3nL$i@Jl zWZhlGlk1Rj_SPf(8^X#=azb%5YQJk5Fda!)D`wU7-{geRVZB~pe!ObKaJ=VmC%e?&1NM&abbroSjTRHx_e0_+)xtl<#n zxnXfyZ+Af$AU*rBx~(rNmNrLR>ZnLBP8cICF{Z^@YbY0j#uy!29`Oc?Um$KTlP$1Z%Y#mT<7*#o#EH2-vQtmXR)SuE7Q5mG7czDDgzBoCu(g|sOhR2f zx#^T|;4)3yOnTVVM(jxE&7U%f(UH>?*#|Q8HxZGnh?n|oQ)!-{ljlIF8+oqZD!TBP znQ%DZHW+0ZQyy24=~TJzylg7L0(b0uoNk((%9ES`d51s>-+Zr1${;h(lwNAF%-}d2 zA-I%%Q210t(#~Iov(sr!E~SS_sppBL%d5>h!`%T7ddI!ciL5mGf)DVY4aarOfsxIk zP)CQCNk~YViF0v7a7r1v$y%*-DVkz5>G|d-;Kjq|#Fi$D@uh)S&UBZ`D7T}R6=81z zQ7+mV3`{I%_-3!c(U;mh)wGB*ePYUf$vPTuV3TOAYMbHv68aCsvoJ2KINg96X{WmM zzdT+XkGy(QAA4%|=5d*yq$g^R#A+3bkS}qEs)_2S*C#copY0^vV_2NiX?Ar2YqFuA zhB+zM#Rh8YD3G=cTlwJ-n%hs?3Xb~TJKm0=MkSKQI)gW1$DZO?PGxu_n>8ag#t;`O$! zVP_8YkyNTW_MSP}V*a9DS(=dR+?s$9u>+0y>X)Lv`%m_Y|9p4Quk&4ClbglvXpJS= z?;8V@c*!lLV{tthyp-oglPCB2Qz%IHb!o$?Q!J(Crg{qjc4hc!8pT)&O}_M0Heuo8 z?0o&<10~2Af~%RxKy3^M=X8;~dptQ7U95xR=k3S`2y%7Wc{Wtl02?d>%v8(Kq%pXD zb{ezd5i@#M{C5lmm!kCqQKnTPzaz|+pKO}z*p`1)@|j>Mew`m|d=$7BXp9Q3c<+LX z`cp9Qm22FeV*ZT6e@XKCWo(Fv{(PVxvSz|r62mJ%pRXH1o6vcKgv_;$*qoYxGo8ux z2u+SZt`Xv-iJ!%$Dg9z}{NzRJim8&dAC;Dr`e%4K)Fjq~_Wmw@=sYOTy%T@VftF3M zGeRsI6g+^843ib5jq~~Hy)ll3M}b{!A2tAN^4)YIsKhuw3T~dQ%z}v?D^I&e&oHlc z8nZVKvIb8zRhuG-yO)@0SLLcU7Xm-vKBCl$5Py$fK=_w(IGYtiF9p~h&5ca?u{4RN z^4#u=vuwV(f8ujYll)OC-f&6X@7GEuRro(0Dmg`dxkFnFl*fqcS9(lMQA~hq7gT?y znZ6PgZh1n58(3_8)2~hM;{r%0xsVi1H06wY`?K8ccwqNWtl{c*NqWRs?Eoq(X~s}5 zyp@c{9COfd<`c19`i1*qk}qsw%YPLa#Gqa&>$3}f<%Pjz=H*C8=Y9meDx=ydX_7W+ zU>B~JwQw>zHUoSnpFaJNDGWgWwJ=(Y&T9W?AP_oiZ`PK_H1({J3+V+AQVYJdcAi)# z63bPpI(|S3cW0)Bzu`FNU_NdV2S=EV&3u{TPTehvDj?D;xp$TNw0T74(mlVw7CXjF z00h`P4!h3D<0$9l%>@xuS3-4>#y=tv2~-BgPSfs_a1};omBr7Nm+AyZZAm-wp#EKP zL-A{`K;t%e*09umfpvUq=7fr+-1e34raVkN&A>521z{e5)?=U#cb?t|%k)KXJe>NKk26CwC*JP% z^^WoXYUE5OX$ud=A-^Cj^1eBeU-!TjF^vsn8B-tR zQ;TVb%Tg)eZe)m<#muv_vhq=6==`I6ZbDdh60-PaV(zq=b_Qox?otB8gT#XLi}`{@ z&744n63|y)2S%|yh#Z1*dx7TuUOulIz(OvCD@H9a*z6#WZHZ_dq}scPAXt@g7KGcl zz|gF~2{y$%_+_-ZT$T{$jk9kkgu#JuWu_ZbtQ=OY8Vrp(jESkg@gRb;_Q1iYPU>lJ z7hC-(-P%*AneISzNHqq_j-<*ThDb)U|3a$+voHVv0#gB=L3s;*kq!Sw&J_IG-x0`i>JL1Z}WQZ}9fP zCdqev&bR|U+z#*Ss2&6CX{uTN#&?1H;+%|r>%#!963_;s;+x0nB2M>+5-8W*TY(P` z8$v0g=YIU*L=>-}v+3{nzKs{fu^c|%iXsNb;Ak?6uS3(n)_O%1OfLp=KO+oQl9I*C z1ZZYco_bMz(p8{{QL>>An~+0Pb@?dub5WcbIm-kwgOt(oQ#3v5s+iO&n0a~B*330L z<-s38bFrfOP#Rbi>tqXSvL~q_POaKF4HsC${N;JEudhHi0k!cyKmCev<3L~QTUmhs zm6A#&iXvXt*hnZSkWxJ%x%Pnyw|uG~q6qHlRw51w$%x{%=-KDwT)dF!6j$HGf;+=Q z%eo(}#PId1beW9x!))&}lNdy)FaB?B>wmvImc2Xq))yw#2VizQ1gkl^AX5R{++Y(8 zyTA_b(D7{!sd(x{ImKKGkYUm_tLi}=Y9kZ9Jk600yC$wKJeajmR>d(sy>|18sDH@8 zXSaNasP|WdlRiKjILkd+j)_RiXrkvUf!szc*IaVx8A;?ac&mmP!n^;U7U51#JBWWm zaJM^DXGxBVr}nfpQ-l$8p$D~4Ah6RWz|C4DF8t@$?b{p1nyOmDpPLYdPReNi=UuM_>g z{3e&TfdBv=GeMe0JRy@Qgh$Cn(we1|nYup(Y_0n>AklnT!gcuI<96?_Re2a-uwb*d z8Yl!QTK*w>Sz0iy)!zV-Vj7$Y&dzzDyCEmfwe^miaDjdT+02Q?V?C0l%~n7)%ZIBk z^S}u>{Ai3(v8{aq634aL1N(qM?(X2B@O8?z2m2l>Xm>(P++!gKW&G}_KRm2WAASUh z;`2w&D5?+E0YuDztj0GJ=|zBT3YS^@ReU`tk#joxG;XX&`btgGtW0d;c7Ue9S{_2p z(hP7m_?DE`3x9$49|t}M#Qu_gTUsNTb7$*{ZL}Mn*Q9IM=x0iunjn0v1Sy?j-ikRM z*b(hpJh^E)sRfw@=7z`w82(!z)ZarH>98N`;$ERPrl^>kVKSb0C5UozauQu~DxDHl4@j}{SI%@0~}Rep9X-6h$t?~(3ObibMb=E8iR!iRiP z)`6J4XIB_(gkPbB&6Ak{;^}ki))n$>G#(SBJK~1q6eIn~u#MLKoF-Zpl5Motf``qp z6>>1u9vmHYp#4;qja&*LztkTrUjPBX(8+iDu#R?k#|0j6hE8B^A$!U~!)E|e*3Ab= zi%-;FpJ2UnH~Qi`oTT8h$wZGD^Be~maitqOXl2veVn;DOW=}-$KW+)U9^FYmuTypH z&}=#TT^;*27V+$vf{TBJf8+OE9r0^Vjh zgy;&K2b%n@yTo}gPgWhi=)4{$dag5IX>4$d)i0h=FY$s6Rcc4|Y*-5O( zKp2Sw{%C#blVZb75tqSj&5uMjcq~P-G_}>`jZ`F{(KSL29u1GK<7<*=_5RQu`4L87g>yoa;O20S(IzfEYyngj&e!4v!N zdDGRN&emG^wCREtQ_bLw{F)vnf`rfWR(XH4$p)!|afvg)&!H&S{h1?BIr5 zd6x8f5usW*7FcQWI0G31e5sr;zKN;f96e<-o$bOhMjh3!}+X3DMOp z8`6RZih)>vaIa0D1j?w!!;LeCiF2m9Jf%z}fBLtz!Q2t6H6*P-y$vWh1wI?r+mptO zAlF!DCf)}iZBh6pqlDv|?kk9ZGwz)_ti=j3)4sf+jwOD&lQ4szo*NYjf^6u&*oxa| zq(+#@E>utHLR5H+G>5$TxIlE$OpU^(wu~uOci!{(%zVr>fdeulX@SuRBTVv6TDVyS zGajRP^j6q%8AZg2{Vaquo9a|As=!&$=Wh-GWh#Vkydp!BwU!=VBMLwo_ywZ%SOVV4 z)l?CFo;3+>P{XB>_$IDg)fhhxQ|-&;uh{8ROV*jr|L!rLlV(v}bj_sBsN_!>r~?UU zrY*W>IY&9EADL$Z{>JDqUo_lE$-5l*i)20k1VHLKTpx;3>OdW{W)wDLqtx&UcWb;N zNyD14-JUuon?b*)VA#MHZv7w)+fMqg=p(WLvcsHxo1-wbJ~M*Dlpzj)49!S?4LB-k z#(tWhqx!v8m;`79fQ00JXWrC<+hjO?mAky#ZXu+RsuR729Q)AqR0e|rr9U^q>@@H8 zX;SOU2#~S}fFS8T4|hCH5TCdUaVsJ4w-1(qE=N{;M9) z4fNYBBYPdn9Gio++1+CeINc>L@#Z+@cdb*4z(S+r)`Bj+x#GA)HG+aq&wWcimiPE; z3K6`@NpiVlFl-BzX%T4`<6`zZrfl{(NEcQ&$nw8CcYyN=Np_G-&Caw@Zkc;2A8Iu3 z{x7;^m2aC~K8Gf^+MJ&4#v)=;G-l>amCVpP@{cf_F>AJmLl$|#Ozso`+mk@Eth-qCZRiGt$2FA1ig`ozV{clM5Q z06ByR!{SgqH;749rzjo&PFq7hx!gqE4JBj11r(NcjKZo9bbW{VMs-vWuh^kGTMOMR z)v-b#>@CTCePB$t&&WTCd_WDyf@{P8{23*F4`?|5+D+J@-Dl;WaVr9%0)VIx80M)7 zo!?-tcC<~t@zIS7SDrQcE$fx7NHa%BaF(GlY4xl))KR1Mk0{Rb{Qn&z79-Ufh zLb?d@P@yUAv?gFfuz8zaQ);k`=WMBsjFQ>qT)(sAOy=>y%6*KF8eVb!6Xsw!DJ$!D zT~V(rUd9H3wDxLyNMn`e&`-&oC2zRygN#oU2&?6aL+KF2K|Q6#j3fzswwYo?biUJV z;p0p4BjD1|uz&da&LEo--UmeJz3hDLzjA!|Dfv4eFr3@e*}nLIwzqYmAyOMRJJG); zI&VTPMj5oi@n;vdz4pOS5uD-o*R9!ZFX`9M7?N!v^xV{aideCsRbu7M1~iPND_HCe zl=t;Ww?|0>y;4(QstpegUed?wrEAjw_P1Yn8fV#7PQC5OaHegd zK5J=8iP=x*Yy-ENi@ar@-LQy3JC9L>RXV zk3MjRt0FXR3Rv-UCPm|q{e-8$0GvVYvIlanCvsu*?ieXxaMjzGrhmkCR{o4m;k+}$ zSB-H!T(?uDOmSEsf7Bq%QSj)Yv?P&OBv^nX+Lyb!E(83#HBzyE4-rljFjC6}V}I2T zhs*Y+N$MnL%-!kx5l=t4fuJIQZpM_=``cYphiFdS>jeB2dc{uI#?8+#Q|{GNp&*rZ zce%ITBKKOn{nv?YSC7TsT7o>yg*dxSgi=Jzxy1LZ9w2v%`N$=#9%zMb(_IhZympwS z3xP+#2Jaf0GVyM|@aqz*dRSUP&Gh%J8?Jby-5(Sb(E5GCabakf=R!En&|X6R@l3yh z(hV(!zGJu+b}UHd*_C*%a{oc?Cl4@ZvJ5Q$w~HYz_7(Aqr^ciC@8Y;8ob^7+Fl$@U z*y3ZBj&B{WYSI9n{y4DhUY(plP zWtJHM`7wQu@z6-H4p3J9BK;4Bb~81kdRCFc{1}O@(>1_Tw6I9*G zA*D3;+Y+Nc5*}8btYrd?i$MZFO^SYBd?x)b41sbCng|{A&Ew(yfw?3?I(8`a0 z8cL)VHghp&@?!xe>*RBE9XGx#+)!?7VIZ1!98VKf*jC zDor-H^_%W@RKnB^Ny*FDbA|erp_t;{G4X_is@m)UO2Xgl4>OO&`zf!2`PP4XD$Yp@ z+j(?7lTvr_Z9Np(f#Vb_2cjHz zBx?HHDQdPUcHK8w>gkt79%4ba#?T%)ec$;o=U-tj7z2}aQfBA^cRW{p+C0>XO{aAI z9)=k;!q#dqILcU^Z09)%9gSaghcdmPU^fb(XhWI#N7~oqpb$xOd&h~El;1nR5QXAo zg12{cGF9>F$B5eZLhq@T1pvNAc>rUWP&exW;UcSSyHDtmX^<#a&{3bzt!8@B-$L+sx zW0L~~M+1z5<-oGf5#~dq1fg}fBCT93p|${MY^L$IP>)f_!MBq+^g5(a!e0QfN#V*}CCWMYPOL;s2ikgxJ1V3_0QfXiQG3vt-7 zA(8YJk(;XA9g+Lxxc>>_8xVR@2JK^##NITF%bl={!M%s?sT}5a07oryo*G4u3`?7J zlY&EX+`SH6)|}ZNr)1hZeO}+-sg>x6e5|yw@*|p_TDSz4q~buJ(J_U+PqHXl0$S9P z&Un7a(x)0E*6U+smp~-!;ru(b_e=ag8rY<;x-5ifeI5_D6J@@hEk*+Q4;xW03Hu0} zinstJP^+NQry(JityYm^@-_>m;X_WjaYsesgv4|_|B>`Wc^Mqn3Tj)mfND-gQzG5p zeX+119W)PTe2%5PjmiEbi(nJw$oV_`N8|iE>!FFmtj1$AMd9QG)NWT zF7R?!Po=7q-GYsfm8)3FM?0{ebMY#e z`aQ3x#i4ZuRPm%(J#3eK)eO)Pkr&0ldN5GIf2BO`bu^E8tN>oF?ef*8h-#5-mB`71o*y1M!1X zouu;)ri!Rl4mUM#42pR{`X7v?=3*4u0_zPx#p7%^kmv)P{cMOZc2$LX?g=*0jM7aP z<1lyJrI#ArY<$gU9qfVef9AHlx5r;}jtpfXGs!bDX;)H{&JY?o{u{}dsS{dnz(t`2 zPtzHE`z6;J84Ys;{5R;XVdS;*>(7?Z9S475**Y*zAub?Gu{5$Kk`+r}TJ}dLFr$TJ zGrvGXJ5KSe?|}^A)__0+R~vJ{70AgkBvgx@jp6Jf4<^P z1_7IdRuuG%@F8CT@3c<=Yh0;wAv`dX@Rbl2^y#0fxVIW7Uf#!>=;rWF3;Ct!~n$hn?uwhUUn|om6!@@$_LaK}4@jUDH8? z2c6Ah!}+nSDM*wbI+>=LodRGI4G2RnPI9Bz$jyhgjnFc0+O96*wg34)C7WqN4`^9PC(EIgv+tL&F}HjPJ@Z3kOAh zm{UV07t_;)D1`ulVUlwW*aw2hWEkg>EcKp$X^Hi&nWqV=iO(FBat!S;utHXL+Oqen zaRc=k7kJ2sGI{Gxg2*GMK0P53HS+EHS7Y*W&#fk%<^4&8qV#6|!)`e@d6F1%^ccj4 zR(j3vZM6r{C&Ly)Ql8%CED|t!HeD4X@sPDUD#$KQgvOxi+cG}`$vEBO-2pk6&vc%={bi^}SpYLz_JvoTd+ZmZW5IxVY512z zeVT_V)5b{ut>|$TI=dU)0M+<64*Z?{#EvBh%?!zAYIREUhj*6Nw;Q}Bpy1JapvkJ~ z_`j%ItdP9YR4OGZD{Hmjg*XQbAlF}g`P;AoK^`9d*Z9t!ON07P9&M$O=u6R&`C6dE zL-FSi&BJj>-`VyTCXZz{Y=9peg5y_Lwvc(}W-#Sky)3cY(#3Q|qu&6F_zvz%P{;lN zwRbo8uMT>&17_g({6b2HQ;#bg%Fsb!u@Z1rEr8F68?*k$bC6o1#N>qf4Qlxc7|vn~ zD!rf!LWK)bwDXrxD)R?^krS?PE3q=SX$@W1;X1G?aE7R;o!X98ufS)H*Vc)%@_7V! zK~t(IYOxKit7!e9xs!L6+0H=4iZ7u`_$jb;?-&*pCNNKgL^m5zwbm%By}1A${CzYKAV2uoYGO?Tj=JHog{x#pz;kxF9(1^2o-w05!Z5 zqu#OJTBg*138B7_`^*4RX9W$p)XK>;YqnoG0HBsaRxDz>w~a?wcdm(8JMpDO*-tj5 z4G$z5KYWhAjO-IEYA<~oh;;A)HZ2i^Uskx=8;N#7s1!_ZPVm_)6^JZu;KFGoz>L{0 zPQd7sEP!D4>eL>gZZE8yR|Webpv$ou`-eK#faSGjgUsNyZ;=7<`x>)nV_r7NZBZ!iAiv@@1d<_ty-6DWGDJr9BWns(Xb)Pt(ei2Rn6aDE zRjL>j$S`@ib}vVdpE-UN#P_-{fdinSpS#uWwe)fuR0agM?;{(wVg7WE{6pv5^Sz2z zcJ)R+MsoN|-JFSR?UKNBsat5hUNzXhQs&Svhi#3LDJ-!9B0I5YYAw~@Mb)@a6}3{e zAFtuUvUY(cGEd5bR6)dW;@>WY>ea5mIZ5A8f+o5kvndTlHl~%Mr5uC}gn^#)du1i# zyUd1-icvTK8Ix9O|KclW2Gjs7?^dC*6TB!^TRY75kB6N_O;E^LWE$jp+E{x}c&kc_ zTC2M|E*r>u)~2tX08K!$zm>!Vpq}lLlAq48&DZoAQgADw8VHlQ2kfi5w!^bE{wq30NZ5^~niGRZej9(Fa zkyqYaHwV5R+fY|W@%7M7Yn?waAWhB>l_fsmM2qH-l66o)v9Bx#T>T{WUwU6+AITvP zgag_X%xAL~AtXD^ze`maOMiqxpa2yf3EQN*ZD1R=Ge77&;7Xx@7WJulG;kT@sS(XY z#`T8 zu3D9+tP=RjtHEhMn<{6;odx&Xpw|wKr|Pp8yLH&_cI@k;WtC{WUCw%SS^&kZ-O`0a zI=oi=Qawd_KNWStvF-F%(;CF=qiZM!#C?X+o+vmw8G{%sGla`R6UF*`e@!Et-vp<+ zweo~KcF!4w6nn~o?&nzT`8>iijT{5tm-+N*YeV#>*26p{L!eYIUZ}iRr1sbjE=l1@0O=JWb=4S zjXSUkh?%$W$wyR`L#r=T;had#;Q#wzCES5r(9rjr^Z)@v8@pHFA$6azFMI+`4tsu* zm@`e?qoUoQdq1clFL!(ua=8r#ohi!oesL#G;r3Dh;V{47px2}bKgupFcc8^)RqKv7 zhjgF+bxXod7(%5IAu6~6ow>1d_ZsRs1DX;TB%OiXOt~_a4xav~~ zVmDA9YCZg~qbmi@wPE5IW>X^30fJkX}t z53Ex!NUwyPS>k)F&%~F9)W%#VUBeh-Hro>(>vh`&nk<4yMZuIeDjxD{=CeDW`(|+; z&^Ghg^_=0rNTJU>(&Qc@(5pw6+`AcFm0Xd0ey;9{uNuWv4F^4Ex&&Guju83FY;*lts>D#J0 zqlB!cHR_6ob{G7Z>Jq@X^m94z^cHuVAuV|%e4qlMRVpl#I4>lvN3^9?CLlNyqFD0s zXRc)zA=ui3zUkDDiuLV}NEaqjslD*MMQF#^*KgSnq-4qfeRL|46Fk-YBpnIoU43x; zTD1k7Yh;1*{Mr}IyL+T>LCXAM3yEGc@)#`s*vF1M@O-bBNIZUsI~}giD3taN3yB$! z!^(VYr^-%sDuFLSgNweN;(-eAYim6D8uQu)@36s%*EI_e7pGaYVAV`b-pcSp_>JodRiyZK^il@|iuQAQcbn?M59vWzS z0hoc-|Ax2!sBP}+J%q?CUc8iN7gmf|`Y2gI+CP!-<6__Uj zT&1I~y2ZbGSQ@W|cY}3pDLGX$40Z`nQ;y|2i_W)4Dq#Vp+u>iCOu7Wm*HlmbDOvre zfzE$z*(j~H^Dk08A!C=>>g@N74|DN}r33>H;i08HqD4o2{{2z;it?H)bkZDcW(MQR z2nIMN*5i2wh|M7fK-+R;)#E5lJ!AC4dW=BaBZv?z>m2yeVH0fJ6VuGd)S_T!9(_y4 zvJL>p0_R4de6JO4?BD7mIm*p)-fM>w{X#`q<1?n#E=>v_`XT1@s)k%Y^kns)`ca)E zg4rvT}P4kepUZ}DXs`XYZ$<5>0Kx*X@b8w`Wyzb$F^xhQmzbGg1Wv7 zuu&GFs~TQlO)i%T@n14aDIXmwP`DQjsn6|6P84GAS3(pNmUsZ+9}w%_4nXzQj?n&c zK?6c#m={&1)}cae7Y-1mG<@vpTN5}cegceP&fL655}dKXfHHB)~H&v=dcENY3bg_HfHuz%A!Q&_)>7^01o z8zati)X`4E*Bjb@kCdqWqh0fY@+vH1Pthr$FZq*ynI2yd`E5M?+tFN+0PA0qVONDs z2QTNK?$6Ujl|vp!Wk5Sl1wd7^;ZUGoN7eG_;+pYG?2PZFRpGK#xId*`jcPQeqJ#>f z3Z0#2$jr8$UrvCy{qU7cY-ujflkOq&$>u?v!lC*lD(D82yCGD;=?a*Rt6P^v*fwFFo)_S|qu z<7ElJq^1T7p5?`>Ld_dIo{HT~T=cX-R#~;|kbP@$%*@WsCe| zd+djxzI^%^g{s-8282(~=0Kz|7MMz8E^t35cG?~9p30G-9Qtx>lfTpjt2J5n1zW@E z>f*>Tn?{{ks%d344;DX2ZDL*dT>M1&Qm+7J^Uy-r&!dPPg81(GNsO+2vgi>ePYVQ9 z7A)LZtnnhKYZBR}bc+kO3c1B!>D<5iSCR>bb>tW=>&iga*#aD+T!Q1~%VdR5Al`H7 z<)FcM?W4<>Po@0Ub-8S)UK*;W*`nj*LgPXUuzzO#bCW)QQmbpw>+C{;^wle7&6l_( zBWRu&U>!|oMW1SWplg2=hW?H|o0u41X!ks^)A80JKYR71x^2!d*&-66>6A+x!6VJbr;Y z^Haq0u0upECPU(yRew-YA&5>%p>X>|}QS$vd6`Z3NU|`q=|{qQZ?#^tXNI zK@d3HZW7rH{6M<*&waOQY2Qwpw4lf%>A-Gouw0?Qq;<2F!#KcX{@qRvgF}I1tC|CASDTvz z{rO2Y-l;uD-udq?AovUpXAMCp{G53ca~=Y$u#lzS?4Inr*X0h0_4HIx>durj>jCF z1WDa#os?{895+aq10w78h$|F!Q8h`*fWZXg?;%@VcSCxG&aqWdKChn!8=;*2co*d-cr( zRkyvP3Wf(Jl@sJ)R}+wE(3t*13Zc+Lr|hlKci9X3bc1%wL}fT~1N`@&>Mc?n_#HYZ z%1+sz$tb4_?4&UeCk<|H*S4ED`FY}abX;4@Rwv?s~@@Fm1o$_dv8ZXqbnG51@IMA=C`Ox4@~mj;4_8)7C=G}feo~9F z#6vz>?om76-BFB(vn@I>#`Y`D!$6a{8H-pr^!B7AL#l~mJXsR{L(0Yp z?Bzs3ra}Sw^4~;^qsxDi0~kRwvNiEaZ}zC?XNPpZnGvj@KeRPvvgwb`GJp`McE_WMtRk4&aiiA)tLAY zW+$T(C~AEwBJ0`dhhKJarLcqrwoHo4Sj3Wd*y@II{|1!T;?lLTG$4vnX{V$6D zoX#ARJ=CFO3axn{Y7zU+%v4Uqv;__p&XQC2OgTJ^ZPuxGEbs5o%A?n5_#rAOm9HuG% z13)R~i_1FP}s*Z9_B#6l8pVe-0UJJqFn0*iHp(<&A!hJ3^uP(pD0yRG!kLE7Xppu!$-0Q2&_1 zvBRC24htx{;G5;FU`tQajtZfSh&lfUv=?KJu+AKt#tM99V1xIXpv2CF)K26@*pSEH zYxFgff-uzD0lWZ`p=Rs_2EvdT`62~+tK6|4+8On{&?cMD>Dh1rF$ zg^GNAdf#EN`IMmk{znGf^Jb$ha}@1|n18qiYd%_#R`5tQo*Mu+`rfTaK};2DO95 zBpJ-QMiA^i%v?+jLl-iKkC33ebJE*_$>|*GkP=#JJ2-^aXX^3}z-w_mKxV831B>41 z=bA-`FSfj&&s%*8`iXRaKxg?d|3KfdE`P_51-ySwmUEH)ij6g`gY5-?l8Z?NRB~qW zAXOHl$0$5-`7{a}5_Z!AL#2w)rk=K-=QqcVkdU44FSqwXIoUF@mJN|lKUwFilVy^*qId2yEvd@(atvtHznD!&^1j&G%qe=5i?rD1FkwT z*BXYFiOa@J5liqC%7GMse+N_0w+6p}t2N&*yGXr)tw){_z@`gHTNfYpD`X!+s% zO%`c$m)70D_a8)UK~ANR+0p|Oq|e3wWHuQ;i;w|AuqIi zad=j=BAF;>zBqu_du_g*LRh0F#RW5B$l;4Nt8qP7$@=^$qI59wNq*ADWG2?H3roPT z%}BlPVNeRs=|d;OS)H!$E=rgQ177?Kk?e%mz4p|6?D@mwc4y1PF0-l+kXn z(>pIR!{>BFAhT4`^k-y4IS3!FqQLXP^VrYFH4W@;qHOp*KT~3!R2Y~UH^UHLEnOzk zDgCBJWAkXse!k2$P+Laao!~w5#_;t#VPh_d%tNpfDISHpC;`*Zn^lpguA>|dAp!rK zI3B^kQ-r!LYKH7vTNk-*D z;e8}9{4B$ioYNDg{pUe>Mzv;5hdR(}>*L)a+_3>523AHit;I3r!@9_OH_45@IlYL2 zm8k%|jB}ClI8lU*E#$Y}R-^=!N)iwpqZE@SP4)Y<#+VvX$GqHl15ci8H+NBoT+^a__Rcxpk*9Z135K_Irh#~Y=`Qgx3qnD znGcolkMHEy`?g6LF*}t7e1G8tJL>H(H&=&q9nw{bIH#-&qeG@+*yuLo<1al%TCOhn7uqip1 zi^}YtKa9R;zgMdrQdXY`ifuVl4YpzCq7eZ(j!FVV33@!}q>^JZn#9|UO^#AE{7Oh0>N{_cnligp2?Hx)p`_6@7Cwe_fQ zqoU#_61Yt28j{ciEeFf1o&a~{{}Mh4a>WY?{!GRuEz;c!Z4r(cD-*suCvgUKie-@? z+Wh}gN6ub)f=W#O`HIXnr_I|xmqMRTkZU}lA?W)X_-B0f!EFp1U0;1+JbtQz@webt zD^e+S^y+Vzw-GFAVfD8$KoDhmK2elgjc8U`u;pI>(LW&W&kBkRVXyihsP`yyY{>J* z%uI~Pe1;>wFfoCCIcPmzyRzr>4(ER&KuOg)X-L2nXKIMN{> zPHw?5@1M>J$*pM>kYV%@tMXajHna#AJ6j4)*vYjMlQB#wv7;%BCQ2v>!62CKX!Tmn5@5 z3VUcdB`LKDkm2BC#Qh8S>>q$DvFlfMuMUOF*Uo#u!=aswO6r$Z2N7t>cHK)-Pcn$C zyeuhFkB-|Q9^yL5XX_aOSM3=rWLsM_ZY+4FuWJ-Q>RP1XSLtf3t7NcpY4O!c zM!8nz?-0WDHj-Sh(F+cDdo7o9_zOFX#u{jJ@!Ru;$QzMc1D5sXn@e&(P)QrgZ>S4n zdukJjm|yd&R5hziq+CF_Vq#sVWP~N~_O)@ttvmWn}?9#Eku%^cC(|ebi z>K?~%s1BCk8soljp)yVJ9kWcI8;26V3S`U&*1QI*Dux9b-y8tJa~WC=p_6igZtI(J z+DE+_O|l)`6^a3IFsDG^e`g)m4@}xm-bqf|{PaqJyop1_8NDI+Am1bNV&ADFas1xG zE0HE@g31__qAk12!^CKxlr*%#@6`1PK@F?p{F5%J43Ep3)g`d&m={Ts&=XTB#okuJ ze7I}rwvAWJJ?g=GrPz?}v}E>EAn@lO1oBab(q}7l=$QAPaGFMULTHaF433cF{0q{$ zy#t5i$OOWvJwKTbGL~bQ48KW_mB|`3n|Jg@gS(cj#p9Vwj<=KptXy@ti$@=1o zusK9M?NpKRccsirGJ!NeC<+Ibf-3h28T_!>4&z}}KUSS?yE&2r+dM@#McKko>` zRQkFaDTD-5Dw#Pw4(=H{%!;X__kMz=DHyDa{?Dlgg0mILZ-W#iq35G3L20BYWT}P7 zH6LWs0M2yAr(?oSu*5wR56qmyT>F-?O!$lVbK+ZJ+SJ%xnK}$91wH6+9k^@I@oM zgFGk`H-#bkWiZ^o_`M^MsAkZY_s2N7D@DjFzI3*7{iRIa5B6lL>P{peuRY0U?KzvC|?V+EBFY9gX>M_jvA zv0QyELX|4PP+|Hd&DZH72b*J2C`LZ6aC?T4r7Cj?$f^)iYz^1b;4b$ELFtV`knT79 z?*8Dw77ZmbAdt91T{ZuB7mOuKiL&E#*~a=U1Z##fHL$k{cOQu$-XNxoj`RN5gXVCJ zBeqU}2>TEH!+eVGPIEK9JTVonCi7U4Li)xf&fhZtcE?C_ajJzgyVa`HUIW7fpFo!E zws-m_qHk>5hcM?LD8#-F2B)x5%fcTXk}yi5+gw~xU-lop!j^8~Z8&GaTxJD-yl&P1 z1sqn}A^JMtB)!CZpyPLKRR~h!uV>bF!Ai{vK1Hr&7jP=pq5MyRlj|Uws*Ys!^l)*& z@Lcda@{=TVQkVjvUrcl5@4Hk7#AfH1adOcfi|MPxmK%@J*K&NK12b3ri8n0cD&Z+3 z8mc8QXv?~~XDGsCs5`(q_RVRN zwV2D@iEG&;6H8mU>M1#R^DhY)*2yhTG%9sC!JSQdyOt_fu1(0f`a#oSf;dV8ZP?JF zhG;!FU}d*`-1qw(1JLNN;!CQd-_bLaniP^@OZi8w{;M~6z|3z{DT+ML=DQJ`bZR}j+y*3W&~D0i&GlNf{OZFN>i}YJKkWRQga$N z+mqoIKl8K{o)pfI-udd0GdsecBr++K%Nc2oxZ3IYVhSgTg8TQN$ zD!Tl{L9{oPwkhJ;lX}^WPF)yiU-H4!-EW7%>#?;(*I6aYyefS6S60AYTs}guO zRwY(F<4Y7WLmDlJs}0*uW;=oX6jEjy&CO}+Xz{^ji0w~yjI~>^y47& z+G|r6y6@Y|E-F*3w13t{?b{hcTpsuG@lqLCU!TKEf|zX;b}`skb|m&D{c<4Cuiu* zL%8HR{~|g1Uh+_!KfwArjOm$FxQlPH;Lf5RKWQ9tp$?2#m>R_BKir8+F!6!w)N(uQ8w_FZ2x4ZfUE-URZEV4DXuEW%v3O$ z>T3@sF|w_9O*XLT0zQyM3hlY{>On-9)I{x&Fe1HUG9T4^ z8M~8fBg%tJ3_`V6S5DC+0@nz-q^|uCW14iY?IC?Mh}Yk}`j7R5hD(C?90FN3ok66MkC;zV{pfZ3rsw&=n4Y|kXJowtgp5SJ4T_p;_ z>mkHC(GThZ5YEXDUQbs2jnG|&oJ13&7`w%=Nr6)K<#bH^R8NG4*p6$JB48UzZjOUp zdj+lBfNP)l&80=WY1z7jA)C5R8lxj7BLLfw6Ku|m<=fs z4GO7g#j+nC=TTQSt*k!Gd4-vKzZEfWOB7T>tTQZav85Q~r&LX0km~0X9za`eH zqe(3qfFUXm-aG4MHbm+pi7wxYh1XZBtJ+J)Xf{Ac%%H%yHd5vbDAu_b%~_uRTCS!o zl%)Q^Kqk#zoDI^Z(QlYVUEGDMqIZ_FWFf(bKhY6HHiOAHfa5E-;E)Cl6In2?ZUB^# zCdT@iZm?cCvV-b;h(O>6k zw?{$$fRI6m^q;4WODZi^DTBhQSgNBmjkbWq{UwHqAovk{}1sivi+-6?^ZziYq_hFxrpwPtEq*jsUH!U5{=S6}!py1pnHFUqX9F8+dCfVjC``HB%j(8qKd=m5Qy*xuSn1_=! zfQ{DYxNzu)R;r-3FipGnF$@Hgz}u!|w~-E{4=X&SZQsH55hP}Zt#f~)u*(UDeQIua zT&PI|(ZCIcZYNE6S(HeSY6_tJ0HE738vu&G0=8Yz%l{`=MffiuZreRdBXheLX9v*Bdb-jX3ZpJmdr+ zcrPf2)KKu89@7}^kd#GBZ#-}#6VpUGnLlg`=9XNW>Bvjl%v;BBp}MR8`hcP!*1$Sg z_RbE2jZ`)xjK9J>Qj$i;IxDr4t()3^v}=?PUO!}J!6!Rd`291-X%@-a1P(iEi<0_( zA+KA1HV?yVyYuUWO3Z5X39PzmmaU^&goCA4wa&+;c<$9Q-%bnuDY3-lz*ken~Bd?L`K(b8#^=tpJn$sAQC+D6vT+45>R&0~)uAJtU5`LvX`d zNKl>CU%YvsK3wRlI{cjDVSd#4!8*$rbRtC6ArBzEY=XmuL0Scc1Is|6r_KdO+l$Cn)Ma8T!hy)=V>+w1cvMDEy6P@q7gd7{+dG(U z!f3j@dT83|+X(;Z@!j?aV3hP70$F>FR8#ExJOBU!YXP3ebVA=|r(QV2v5on4As0bv zg=Ez75P365dI3`D5P9*xu^vas_?oueptE?3FiXXhFi{(KpHu)BSk@KG0Y9=q%_d#$)IJKPC@{)gE!(4wMNivuU42wl~zE9Jcoc5mk~q7amX^K3|NAQ1TJ zje+^506vpF#%<0dgK+#8nx8PfSfFbpbEY0-+535n2D!rF%MhS{Rf|h)+|k0xQ-M2T zG+Trgd>Ij^i!T3VgZ}iDkyU-%xB1YB`?iKS9>AL5#(Ec zgaAofez1oX7CxRLd}Oq;Kr%XI^4Ah9Q;TN;hYSTii^Qqa&KgqiH7UPrVj0lWw?{U5 z2d~;{y`f&67Bd>b>5EzLXsDY4F4rLpKO-3ckGnz#d;VLpj~u`$@{N3|bh zWkW#5^YiYfl}lh}@JG=|qMf^p#Q@xZ-iXXl~sz$fY3OSmH7U^0n;luiq` zz{%qQ##|se;t~s-0Cg^-ZEzqrd-BBv+#AZK_;97U&`DsNajZjL#jWe0004@0iMZfLf^L_rV8?a%y$a0Y>$9D znk0!S?2ZTQjus6=s2cBlEfO{kMU*Iv>asb-j%Z^9+clE;d* zDre{GaFn6#Z|#x9ThZ*Rp_ze}9iH%(nBo+aZ^R*B+#Ubz^6W*iu(IANBLyw-5BoH~ z9OwZXYMM3k3qIZA$Mw7BYYw1fWoJ!97bC7-QJ;sq8mbaaoyn_|uqu z#hVb~^EPRT0`o|pi5X7Z81%$SY`cglW5qY-Jfqckr6Lki2|lhy8~~-uu#O-}K4duF z=t?%~l0L^}fo>XBcl#8C@%m0P@I(+E@O`b%HPd|?@n=WP zMmM`Kj_9zW{siO!Mb!xIG*?6@M(&V{j2qbDn-e>_;gOEvDsBug!~+}>B1+a|TUaZi zgW1p`cj=d7|LCJ(sS4AlHF_l(LeZcn`kI84X>d$f_DTsNF`ge2y7@EyIPdYOKz`jh z#lF1)8*4q$GcFT{QGE#34HEQ|ibPV81q%}c?uRfVUNvJ_=nM??;msoZ@ z;HSkKNEC}0x>e=RFz7jVjdsz!$zW3pSb?z{_bg6)xxgfK%p3?Oey=m;2l1R;NPzPd zf^OvuS`V2TA8*8}^ovJkK@Gkoor!W{*vAIlROX!!u6LMb#UNV z*0u!#4xjXGfd8iz;&;tQ4|!oNII&;n!G!`eAOV5^ZgRBVloY`F8H~kty`k$1@({E2+>qB6rghdYU-!yICSPg!X*;Ae22Xh(g9A3?8J^>=9!o=P3~`vr0#@m&KAGf$mRP1>=YNtt3G$ zRhsbjU!vrabPR9yRFixMtIs*+JAB>OV(2=LMWv^hCNUCP?z*GdEecNVu$P8kJ-u$2 z3a0f!ZyHV=J(;05#of*6rI$DF3El^BMP=*Po8(>8!6YrFofyek(=k!K=xypJ4`2{W=`+yBV@Q8p zomGG%ZsFtfhJfZk^7faZ3wOr(0!cw*dcqiDfJ-@E&kSX@hxLs4peCNq+QADtDhkq8+#{Ol83u?(lrw#08rkiD zAuU{yn4b(TUn?JJWQZhC6_s0R&6>;fBio}73>h(Zbg`S!Oy43#hhRf57VN{VVOV|b zq3T5LPUfj+iv+v9mg9F-47IB6U?uTDv9uwF6*;We(VwPGl%b zR+#*7WEwk;VmzG$awHFpeVYQMF<>Yn)9J)IJXLl>!RaOJ;20GsOScVvzp(_E}0=5n!ruFp6Mc)kWkZ``&t{F*T_=fU&t-$ocglHGcdxF z;g9Q7R1GGGm|a;~+1Z^LTCAzT*G|HDtH8$cr96NphfPlD=8}AJWxIre=m9sw!q${z z`}~ij*C?Ja)cCdqNvqlSnu{g>NjvH_9Oibq)6c*FJ~F_SH2VRz)uC{eIaNwz);Tjd zfYwTx1+F!fdo?0e%#igP_8Ma%V9EFcpXm@6_It@G1(XSvo;AhXcSAk(RjRxGqYHYR zGse>@%F1}Fo*)yR>o~ca5exNY(_PnmFJfeOxAO@vl#>y&{p~CNkz_?Rf#`gELKN{; z2!A2^EOucXGiYfuYt^#gEqR(` zgA!*@e^U3&eK%}@6+GnKHr8<`NULQp|Nh)_G0ijI943wlzj+|*#O9y~@(1}GZBf!; z*wlv$hK$SONhDk7ilfyYOPIc`TqVK)a0!#y@c+*P`a5><@z4Eo>?vLfAM~38*n)Wh z3r8sL2QDCq!#S)>f9Bmz%o(zU+w1t5XuISj%9aj!=^DpwSc=Glpsjo`f?_qtN+Abr zH7_h8+8f@9dN{Dg>g7Wx=5QP?Bpppelx6ds7*e`ZR3lA(#I9S z!q070%+w}vu_ax%4o}{I$iJ-xw1M!GID?;;iI@?LOsUYIbPEcF1&5~CpDAA{rPXcw zr_D-3)~4vOxFt_{5l7^t#V0{4Hk}ZI>J-RmWMF%)W^b$zQMF8ian4@Fx3N=ewR65I z`F{8H>2wxb`RSb8oikZ}($Je8YBMV9gL0MKQN=1Y zYDFm{!Nw{F5Y2K@$Wx>B6)`=%Wo+K~ZhGMt5gloHkqU{^iB^mz1eTGi%~Be7w7I&h zc{SDTG|#0tK>#k@9?4W!kccE3gfgrfS5aK-?K>V_v;F87$@625B5|RY1RhEk1U`UN=-p_l%p&B41{S3d(SCCik{4MUAa9s>^!y%K75zlk70?>>lOR0) zcgM4dk2w1q)>b(W8=~pbvyxnkRN&3f?$B7wxOC1O=Mq|h7_p}<_HPX#-<{__FkoRA z&?l^mkpL4nz7)@Gnp4VOX{FS<;iDMWlL=wW_+);Gqd9Zzm;WOgr2yo{MFZEHZ_QwM z1NRfEh=HSXR5Jqjsf3fn=%lDA#=9Sk;EQIpEl)>xt7wQ+`}V5#0Q3ms`m@qKjHBx}-`kRUcf0Ile-|+L zf7Mk{b))UBBVHASJ&E!&Z%tGI2b{tBl#(cZ1_k^{OTb(46arZ3$x^{Zi_^Z$R1cWZlrwnu zV+e2{XHD+M!+RY>T;__0*)qXutHG2|Dzt&$|B#zr9Q&k~D6W@~ikc_T!Mwb-c2*#X zOF&iE>koaNCo*H8q2V>7+7%4+lIiV6O#hgyBCZaL5XfjC>3)h&m_Rb)0Yqrw9MEg` zrnZB`v$ac3Zw$_2^GIUHrtmFdhEHH9{xX<2;>0j5YeIvntP z<$kHIU^v3Ve55YO{NV;gd+5dpGVlehWL5wfhc#bN<9DM@@GMd#XJ0w(;E?iq;39j9 z=2;<3m#yT>ZI4iherPsABEcZE^6IrY^lx68Uwo{9iy@}Mrakmv@}=i{5O=rdA0XEU zHNRDRj3{eK@KxlU3CjO^NjOMK7|wa+v?FZFe^+A}d43iAAQ)Fgn>oARZnlKSoy}x1>5v(*nqi*xoIOU z=2Sr;SmsFJl`Q#p_LS0Ap933m%p(Izt<1}lBWv!HV55+F9nj7)uceH}t0(K3L1#;# zplSA{Cb3Kh%cp|qtuD>+TGiWn&$tkExCoxk6IkuQ+O4_^%DYA;jV+^GNk&HvP zrg+HF)@cik!JvcP(%wmgmF?G}%V}-n3a|GlQeoDb8Q&{AGolk-CJ*88^;rDD$876P zm57mH4Lfgch}_Ct<|H8CejoxBb5c$gthYJrjodiK*VUCaz3y$a{Zw8K-L2%|pM()r z){q6u{IjN0@HPY$ce>FxZU%hGg5Nbk#Sjcha?K7@Wof7AfK_Yf_F_M zc%8V5dgI47oCiHf-ms8&2XR3u#Y6BzSvS#-#Rmi_#sLLAm#}VyzmfW{>&FxdTMt}? zXA(k}6%1x2NW^iuEYs43%NKBPbcyQQy|$llt1MA*yn@((RZJ4aG7|74+0BUFg=|p5 z8GV@7F}7QYhxq6`+wV{VJR>s4XTkgTBBTa(Zm8fXmNxmy6&sZ=iQ5+U#pLwWbwf#0 z!s92DZz9Z(({q(f4PZKuxdUBDZO=k7VhuBLEiOPE{9?UTz4(QFF)4D{s&&^U4r6_1j7eaoK7VtMfE=#C!dfW_@a##-#E()(Bh0oKIa=Q+2OJrRp z)c$%82>BkQ0r3+L*g1l_6b(`8@p+XV+OVKfQ+RXSl>-F3UyG>V`3i6d7rFXtVY;a} zC5~n^MTo-BBo24!1Sr$fUAHgsFp%A$z;ggG;IJlS`p};xnNfack0ew4_FjP7Xjf~| zr8wCQ&rONM;_R9H6eWEOo!f?$u>yFVrKSDHBq%?yklZfn^_ybp)dfYOhl0S6@t|S9 zUYM8NaP6t(Ak_vdCNjkGlAzVm|1&E3x=Kd^`3W%U#N^i!INp8&YJk7t(JwyL8&^T9 z_UyBSP&C;Uo>Td9Z;Wg-@ZLaHV1k_ar$zNw-1=IFf?nav!Ig4fS*mPU-M$1oG4}~RgZ4@MG>qkh2 zPPsQSoL}sLIe=JtDDL4a2HfE~UGO^Dd)H%hs!HVYOUzOKk!v$fSoB}`uF6C+W*-|T z+MnT^C4{+s{z!mZON4miQ|eUBr_D}3i$z0_OTj*|{IKtaMwkhPJ^g_6NUNdpHVy!g z3|jAJ-X;?(7@v$YBkIPA#EtZyQ;kt~^@O$wo7 zCI-yrJdP1+kgfJ+8ciumy^=+d3BWUZql%8236kn9a9Kg={IhHBQkz-;aJ9Q4=n^hM zzK*F-B(63J9D-r#MQMA8iM|AKyE$R@bHZiIY?zq??vlJ9Ykqs zGh*3?l%EKz6?? zqe8|6es(Nq1X8rC^rMaJbGMT!c5M-40Z}~=>s*K0LcDUe4UZ75;W2^owo2F4u4bJ5 zFMWdIEEs+>kqWKBtRLg>_B|yu?y~U+d38N6`_hG0#m+CZw;w6pVF7!rPG7=EfB*mj zG(n#1LKgpM=eqnh54QmX6~ig&;GWJeO6_M*@w=m|&oS_**4r2(MXnf+^>^j2twHdB ziAvUO+Q8Pe+%fCtqa}wZPU!$qy%B)261~fMyu+LG4PZv64WF0VWbub**&Hjuuxa*F z(_%Qv_e3Ccs99SNv%E?4fA*DlLW-I!WZjz4-KgMsYoda&_Z~0}1WWs_)j^?(l22se z(evT_pd&O%pZzjNUntkMs#mhoc;he^izgPBz<3ad^escr@(m47=oj=2zpXQ?6C?Mb&jlO z&%7B_=Zi=w6*v^Ye(qC@c*CEFzbaWp7^Nv)5}lBh1x7DME8D;N9XPo%9MmzDq#*m) zJQ$2JbSfWI?I1#;X|U;T&$K{zMhE-e?X-*LTXF1?)isqe@q~q=2`i3`46u-7O4nVi z+8h?_7s3*3y+XpcylYdYMTV796ZQS7B&-0&kP{2Pv0jk|7*zDA4iXu4b~U`uXo()3LRZVdd(y`8zru zcX1Zgm@BWJuo1Qn`aCW=)q^CnBt1&&w82wLk>iFgE&X7#-anPp%=@IZfB*mjXaS!K zX%M$^g=aO`z0?#3puML7n122VEKS#fggB-Xf~8q@ z=t}cv6^BlKOpN>fp@)Qc?#82dxzl_bzk%W6GM*Y)3ss2(NETcuDm} z_`MKSWM4pOoM|PRT(>l-aqCpcb}J2>gP;4LQFmbnQ$W} zZlF-U2fhhA4Nbkf^-niPm&|@UL|H4`Jtl@p?R>(rKXt#)lg@41U)ny+Uc9pArMDO0 z%8Gy7|5$84ZKxf97|RF<7@ktDs1z-3d7G7)QFtyP_-!>u^!n6WzS#lJ#l9M7SJ4dC zb*SYdrQ|lm$nM=BSs!>WEQRw+wSD?2@)DqRPn6Te8->Z5*zonhVE_OXMnRhqNvJ_= znM??;y;rl+*C}*yPIh>)#=0X%n-#wS`3WblL2@(5fQcb#Gk*AyUqyRr%v@NntAgTQ zX9x|>fRDY=Qm&}L=u8`aIB6}p^~r^d5gVKy zzm&u%GtwX@grJz;{vU^sIGHGo8E%&^hd3n{PqkOA_EgEovRPOST58PIMQ5{x?~2K) zY>M|yY$JgOgL?rvQdErI6wP1DKA?b`bXzack9Ixv1YilcjVN$+Bb|_EM%8Y1MMnwJ z*QLnuUqJ0y4xr0CizM9IC*1cYew6Va$-XvYSvZ8o7v(QHJU!;JR+Vmkoe<}ZLzmh$ zrfgW&2EZZ?&K)zShp`^xXa7xu7c8I|vvFKJyPvPg{mC{jp84J5m zqcWI!B_iFModeYXMtUZwqb%1zZIM!z<=0Hi+@7hPsqw5OfM}e#&oay=QkahU4FH}vNcgKT0=aKx!`n+LgBRt*Q)Pr4eC-~zwJWrs= zE{MBxgx4+b@XGR|I0gvj_$`1aGzT-4nkleEgVGyXG|rK9vMaMQZovU5Y7o)9mBmKV zA_Ut^$e+Ek#IZQLw1WF5Mi2kE|B?R+aMLxZ=BsugVH8mvR}ChPPN~yuC<5P8MK^dYVb4Qxmtv- z-kD48fA>SRRH-{Geg#MdFdN#>vIlq75(}zhYBC{d(tG^RPzJ6j@ucJZ7l2T0U$9=! z6zA(29lK215XOzforN3ii@H5i4b2k0|K$K0v%g^5xT{LsSw3@p>pOs{oE^~;G@10$ z6{DATu+FYrZ-bTc_sMpGeq5*|^AvbOwD{acM?BjA{r4{?WpRI6{s~AmdIkz!KpZ{3 z2W<;}529tUk$+2-s=!7Eh!7E^n*xHwtI(LmrnXQ+(A%v&@vONU=x5{eFIqe$V@2fB zCHD^-pILO;g51X~osISxgl-l9WD*8AVHw7J26g==>Ji)brd}BC^Mk`*qh4Wm>#NRu zi<5z-#Wna-iRMjSJpG8F^>;Q**s`Y&<53I=fJ?Fz(n`0b z{+Smlo?q~)Jf_?>*#+pDA>W}C#Z}0TaC}>BHne2~=0JYY*H=MGXqr|p9EHypx<;`< z>f7=0UtGDpioVh|FK44J0++BJwfO%A6W7x97QL=n{?Y@fMvPVBf|h__>^#6ll2C5| zW>*VJ+s`<&3_4wG)#Z7GY2JsEI~sG$`e@l~d>&+M78vJ3!Sz2u?flK0{BbR2DT*dt z=LZZ57e!28r|kVm3_Rt_p*bq^nD`IIg0`%IGGt{o;Xho^Z&-NtcarmRmc*MqaPd1U zroT=Yi>J2{M5<#><53cLT0BPH-goz5TqyU&^TJbyvBcJEhaN^-l}v5? z^`?ru(W}%myOR;1#qONSOxvV=R6HH+`x{Ym{`QvS zv-p1HM6(K7qj0zve%ysJH9z27V^ws&X!$SK?XYQM4jM*NVws3@VGBDFsElJFb3+0= zq_YfIlwXrea1q{TZAv>+@#B`DzfB$9oH=yAJ^OP7y6eQ>&rA;wlO!tcSHHsTayAZ7 zV(@U^7{z5%9hoC|5l_PKP7FIxY$j3pn|->$&?0&=!_AX3g4jc`)1yA?6efzZmvB)s zDg#FJ3{F1Mu12lu#=0*VzyX;YJDql?Xr#D(`ztREW}V@?A2Feg*&kB;t!TeC1DF@p zkZo|4eTKo?p(W1Y)E!P*g3pvQ6d$s@Aq6k~e>eZ|apl6OQpX}zr^!|;$ zyMM-m6cE(8$8NLfG>C?s$7!x7CNKOGOHtP48n!2>Y2@y0)lOb0c5E*BlqvnmmK_S{ z`El&+LW^osCDYC5yQj%;K!qi=|5k$Ie?X`_%zh3VOh~gO17R|sDNJ!!UM^uITJlp1 z2ID*AJ;}GfwoBq`Br_5eY01BYNyJ2g#1`v2>q#4c1>5uy`31RI%A`K?S;hsNaSd8} z))^d=DejviLRGlO1O5Xo?RZIt+3st@K3PH42{m$f$ob6#&<0RDM>w&wJ=ey(f*_w0 zGI7EpZ^WnP9YZe??H3;Pml3*Wew(Xi8=gt|E#}gvQClXx#W{z;vuaw;AepolvzSsU zwv`wbP>DR6Xz2_!7Z{Mc%nfK*-mBa+1!HCkbO0SZIUgPOXv?VLTvS2ybCL+T9OS>{ zwDxrWgaSikh*4e`YuIZQ2q7R=*31(%-ZHoN9YA6sN51g$9`=Y^;@^hVPB3o->g_Y$ z$DmO$%5t>^GluX6E}BGnR&0Kxpw_s3L;>G+BqIBd%l#Avjt9@~Wnf zf?6QvTC;P#&@iW>EO^^y?kxhdVWA8E4jR&-Q2^4?&G?mOaHLqAzWGdxf7S7h4SG>; zPFWJL%t04{=sI+BKr%@?t}k+~kdwBgoFZZ&_k?puO7iOiu;2)&Mei_Ia~;%x#V=^hJzY>eei$BN4ADOjsY>z( zJ$6(2TZ~OJFQarvSaKH$SL@C>fY96TGVo_WnASCRM(CJ z<4`iypN7}t$7r@XoY^%+q>JpKuj**Gto%Ag`gIa{9E|UNV$<|f^iCKd6&2SY=^P#L z;IRx+Jn6#f0K#%(z~G-chpna~&3$??p0zo~E;?E&HXrZ>DMADCRLu-WeLw+(JiC}F zzL_etf^X9(sGfb+6wdQ0;%xBG_{SuK`t3x7!{eCbUU?j}!F7}y&4|J3(#_xzgH|j4 z(;Z?V!#*9<&ryKy)%Bu<#AmUjE+#3jityBr4_a9K@m2)=3uKWHmRrj|1#=wDH%ax; zOt}L;+Hu$-GE~#Z6g(r6h^4n7*n7pwP=uDS6LymDNvx-3WJG)rD3NxYR~8;Fj|wqj zE%U^cj49-E6ihJe0KTJNzQC=V#Z2KD(^qZ!9>&qY(Cmzo5d)Kb1-C)qaidr zv4|`TIn`|_QQjuc*WuN!@0pLCf?i}(5x7cyG;_r+RDN($RPLtKA@9W5Kab|AeYALz z6o{5vqr-PoA(Yv7d;o<7s{#a@%^B-JwI zJsx>yo=#d6g_Cd)uxT9e@=E!I_`Pp)_LGxQJd+8tYZe-7A9}4%IQ?QC4Knu)yPR5B zqb;Y|c=Q^|`+@PQTo6oAqRIK|#5oeBs!|qVQK_H{s^vn;6|0Y|d)7dR2WX!M15_AHOcM79wJg`y%b@tnHh9GcK>!kE6~U9clTz&zm1+ zgW(c`8HTW5t}`axkxOc`KfL@VM%JI_EumDRB1Wu6f54|}Mk+5xB(0*<`Zx4+yV+Xu zH^hqmIDjSA7Vx*;oI?OrRy)#R+|=ik3_smF(nx=2_q}0vBU7HlYYI8rmMr^V0y8c* zP8x@`9BYN(NXW;@9Tb*h`hA3UlVd0%$1J)x?l2dGk*Oakg~Y}h2nal(nA+ymh;DRj z(x2XV&5YjGOU#||JwMM=zwN}G8h-xjig#-h%N()J>ZP>Ey94YokvOOqDD#RMzJ@5k z-ZeaQ(fQahg4^4{LBFz`m?8l`HctK)0s?T!figOtzjRi*`qqH;djf@8^yUS@EqswtTY=?K! z@fezMNYrpdbM`R#a3JwQEsqTarE6V~39g=1tdXH=R1=W5X_%5jhb(2_4Z;+gYSvr^ zut}D@8lKlg@@@~+y}w=dwm}Equ;Bkn#~IC&W-f6hP_GE}s~*j4KXF08B|<@E3&d+M zQ+C!gfe1y%_FwWLQHOz#^FpdLU%Dc$RADKcXoyF`gU0=Vj(+W6cR7xZ<0n@@q#0H* z*(YE0niT}@)({EL@`0?XBAUHD($Tt-04zfnWe8aqOZlFSp=iC4|M<@W@h(g>%{_*8 zl$772nifvwZ2;zBoWZzW6eaAzCPzO7!!Zpm7@9tu5?4@W-3+%Xl$XzX2+ zvR>sC88n~aMTEE4_+xs{K3nfnIP1R;jH7=iKPPWCjCw<}%z!@;U zPf)AXuIt1MAOP;GF$Yu`gPVB5itE^TYaFJkh#Qrp#oO+m1B_3*JyYA@ z2|YNDYS`!$S-MYJz*RF^wx}`06|G}aU-eRoNJ6=D-n zATH5RE(j2Rt)K{egI)%Md(jV`*{yFJ1&^5h$1~(s_6%~Lo7M*Wt+6Djyk7`C(5eY( zSUMN zz&fE#z!3D~Vo*T?K{a|Mdnr+0$~Qql(A^-jH>->9W!^(3n~UNum+PUd==tr?iU||P!M@w zdM1zu97YC)Qb{5AO3)Hy@w$4#;ThQBkElfWYYWA1;Us%fwU_&QB6xCkBr2Sj`a7HAVuo%^#n zy1^v@Ge6+mQ~bjih=Tk%3%HEj*YGzeLX0K;0c1O-$2GxJdVRuG0iIPaNVlslx<7RM zP5K%&cyT*pHVTDRSaR(Ofo@!?Bq=BMFI;)hMK@PTE2xmv3JCx13U**QX)bacHAeNDp1a`qko7P$*S%%2Z+f za;xNIcbV&ts@2W~JsqOc^zi;SX2SLUD9+i*B}_fw6n{9r4`0;1atu-ODk4~W!J{jA zygy(@&v~03O0BgG*q9y%{h5k!d(?X|*fNQLxp`bqz(jPS@#xBp6i2!_bc5qKzS^MD zW6C$808~gl&F^*N%oDqoXD5KJhT(CD*Zb8PxTgl|+$fhoxIhweKKT!F^11b1^M|jL zUIANh`EvN2`Q9X5$Hvr`Ii`u@sqlX3au?aM@;jZ|zVeq@xc7=;+c;l1_TTYxcWQ$? zzorrY*0m|ne`sCIMraJjo`vXZUJ{_#+M3J;zh=YN0V%n)91mU5A3H)i1ZWgtj!db? zPN3`2BP=rA=A3z=`|7QQ;G2EAf24|immxjeTJeJ|fu;D`Auv=x)R%6{z1bm-S>XAz zAJJLYGcbJ+jly%1y5Q#WVFgicA1I?J?nYo}NS9G9>*z=8F4F5AcsJ2MVjLzl$Acx} zm+A9+TApT+1C)Ye8@x#vC;ds|IdsP(B{J$!-hT0?#g zV{?B=Nk1STA_zCnwPcI}IU%ruRD&9CC9~f;hTS}G&3J4L+)y?C`e5V&I76dV$98<} z5ndm9hYhV~sMmPT5%{vBrNQ(yIMyGcVOX}1xzq%T&P2bfLZ(qx|8Ow|j8uEP!BI!} z)+Z`Z??_r`1LcPUB%+GkT0}nhs)$e+@5^2UQhoR9VPX)#f|@weq=M!|K|ApRP^?Zt zSK6B%MvNl@29_v1xU;XB00(t#%pqYAwdu7u1`%SW#pX9PIHNxp?gpmkXmHINX8g|f zod~dM{pWtoqUAx~NEmd*G}!8MSanQLRi?ey1e>*e(h{L9OG=C)tClvf>;RteaGaKl z>KF8P3T*HeOSRbYp}Tq2&$4~0TvXi*L^4Kp1lvxsGXE_TfqAbrOo0FZ17-oAPIN-w z(8h#UV9Y_Tfw4EQ6sydBBsz1!YueSTs3ST!o>5(DO4JAkfr07{g)~7sk_y&Iq^jHS z940JjWYiU<5H8v>WGyv@(}LVItQ(q|GG@~O^e$p_+8U+6(+1Z3)*S*T350nP9b37e zpX>8va#4hsH$~h?iTW;35pJU7DU=KKDk?X~$@e^+_vlhUsRWkBQNT4+Z?>C0kMTY4 zNZzXW)eAwjgD@;Du@naUQpFN-N8eCL|ISKTgFFm*(N^~RmrxVB_Nw5{DI>jJV~L2l ziQd@V1OJAn#DORdmd&kD=F(-XDxm=99#|xQ4j$OvI^r=76aaukX6JYGf`JHJ0^5m+ z6W@Dvd@iQX2&WnxaSV^AQ>&1pl{}c}2G86z@`gaz6lDebKOOBmou62PQ!1$=2KjW} zUg{WBZ$+E(v!cTeKXkv3x3BlX$en;Ormf7%Rk%5kHg`05=j;*{6zNP$d{XR8vhq`f zT(S5Ee52q3pK8kh>Pv~;H54@>_dwL{eP7(g#l=n*88teOPoZb(2#Nyzo-s`5vPpS8&ivMHt?y->=I--$))~GiZQ|w$T6gnBYY$q6|HW@3)ES&M=x!Z6_Q806E zOHQY@HRa+&pjZ*xU2nEsEtrM6)}i`Di-_@|%IP;?H(I2=lwV+Bmx$$syTHef+F$Y8 z3&(g3n-;vXLzY?F5qeb5+E3e4P^b(N8+<;$2ZooMN6??MQweiC9r2xSX0i@Fd*@)J zpUXsrk0BDAy$c&m_5#ThDJ@E z&m5s&N)7ng__OB1zNvx2bK-Z9VQXN7H{R}@eLTb!h0%4{Y|}J zJqbh~3=H(Zh||Ir9xSTUv0Rs6-Yv2Pg*WI-tntqv0009C0iRH6Lf|GdX54@RXMmdghVTKP37g+|nXMg%NXal(i(} z-0OY?8Jy&rU>ZR7NYI9QdPR1ogoYI;omYG4ZM6mlRrAD-(OcbxK?B7Kec6;%VpY9_ zZt{85nUQ(6olM6Zv*}r0X`nDUq>jlHB*H@DwoS+(W*AmPVX46& zznTR_Muyn2c7Vnm)fHgTFvRob75$K{-x~OoxWi-WKOdQGmPA7D>^+J(k?qC7NNXUm zWxK5LSdHJV=*F!7csm?FgF80a@_g(fox=y94oN1A*DKgtJc#d8KVJ(R6M{|7T-Fo{$j}@_g)cO3p;^AX7k(_-WOWoxoS6l03 ztwZsG`gyIYA_5qQka&M4)HYhVKx+%$I?}DU60I*jW?&H<>|@}AZLQJB`Y`)(O7sm8 z2YeL$x-_Yf@n|EeA+wPVmjs8w6leGVfuTLoof9o(^R6gjgVshPy)81;k1c%0Vp#PRv9^O5NZhr2K$4hXIE)dTF**vf^0 zHm{8lMyH0nGakKCJobkiVBkmv`d}*>Ijm}+_&q2O7ISjU~2x*rQ4-fyd+IM1? z0Vz7jDYN=N#pi}#d*|+`UxLpS47-ntI-}+>@_G;T1*q7!i*vzxM6;dSw;pNc2YgY^ zm{C?`Lcym-B|B`p@PvG880PyBM%@VMXA)>ZiNdtmaJgLgPe{@npEjafNE!xYCA+y> zv7{@Fle?clveG-p#ofkx={<&h0!f=PaYB9(Hg%;T&-VF=*k-iTDEfTV9QJ!kD;3jf ze_lp)C^MiT7E13b*DCDe-^Fh?nxd*Y$*r~S^T$k*(U=XCE>s!8acbV>dMHv4WZiFF z;Yg13{$Vw4WV6*zRt)Ob9#-FD#;_wSBtV(3tC2hmDER|_i*WVnNL7r7E8uG&Wl;oX zkFbGU4hN~?B$SganhXG;4EP{s4H5wH!=V~;+}ZogCO^x#Vga|{iS#DF8ft^8jZXfV%EVH5gzT9M6wIT}80a;! zqv4N8z$t?dMMd3+-zZ6kK^Il%WxOd}8_1L(P3xb8RWFhwB`iDDnN>tt`YX{Ipj2M9~m6akr)kI->x?s zc~6K!i1L6Mdd)Q@#B zv7g{V2hPs-Gn}UmRxuEJOPzZs3Js6c6EE4(0m5FU=V`QlfbsfG$z4&;hMy(oki7b= zni@7v>4iG~ni>NWM$@tZiB(CEWG`X5yTY-rR?u2iWg4^K3M$U+dUAGT_>5~UzvA?x*kjyzmebN)S|%6jQG-{v%$`k z@QtF#DJ;%;U|nR!rT;etEQCx*eE6w+pvyh|e%i`X7=+bT^EOfuNhWM}C8f-dC8wYr zaeeT3CRg6p&IDcNFk~V@iwwnNh$0$PBFTAIf-F$#HGHg@xJuU2NRkhhvK#3{2v^AP zhKx%bkakyI>DC$95@oiVY^F^A(jjhv_ZFzEFepXPON3vD;HeeuL7cH74QvD+KG!hn zmGcRqZ#f|c@~|w(`NeP`bnP(4AL>zzXVg@yTNFbdr^7iDIX$a$UzQKAc*=aoc>;CC zU1VR9oA<>6<7auuYAXmaSzIQCLA|QZ1=e*qxoiD^Mx9u*Myj!!SwP>vk}Wq=0cj)# zF)sqJ^k$zEqx2tHI+O+m2;(t~77anaTEc5%vf=n?D9rmW~cH+N6 z$MJ~;Nz{>WqUVXn)^NfLpxu(Bec$K!*EiShElo5d(yDKx%X`!yVHGI@lPOc^B{Q{{ z4I-S7U;UW4VB!9f9cjxH|LwnUnh1Y7>T-BBfo8?_YO%`P?_|44vU&6arzx~GMRXc5W%zf}!)vG8f=`Pf zWOTPXy68xS&!&2PhYRx@YMrGaS@3-##gjk~{ryXI$~{iWPR!8(p?G8=k?nISO)nMh z(X_p^RNZI^gNS-0)}hsk_f(@sc|Qeq8ZwsLR2=RZUxlD;7!DL&5(-JxmG|d4*lBOM ztD^zJ35Fqz*sN`FtEwW$L;bNQ!a6$!&1j{P}V~4z`9rjXa9t_i#+Ap>oOox@wLE+*>=&A$8>6w z?54U6olcl(B}Yy%It!*w01HN`8wL8;qdN#;#-2c!bwmr6b3sqt9!O8=NC+G~LmJh* z&*o=DF)v^s2h9D;&pm!aF|5>D26jlk^3%hv&I-t6f&d*C` z@EPAzN(11U%n`7zK;=MB#vO0k?%AM7@C8>*YYVNF!2@C$8bd=7!Kc_I(d+9c4W#yU zsc<-=gFY`AM-(N|+2}*R%Q#ex2W^|Xu|jw_eVuqh53){~zkBZrJxwn$`#dTJ)Y)*BA~x#;T#9hWY1d;0uo}ZMpNH4XgY;x`74*8i z_uS3)lPEKz;3(NNX@_|%*`}NAh&L%+C3di&dM&>+)#*nN`!4T}1Sqx77ZzCI3>g0{mcNk>B#OP+yi7r(lZW{tQ#~ z57pC-BI+#vTmUef_j1lY?j3X(lgL*x65YRrv6&y`Yx5T}POsN#0(^a4Tj`r(7o@RRMTp>6!49*t^KM1yW>Quh4k<5p0pBBy&o~xgh$gs| zQYOXDGgBmKoor0ku{8rh4~-DzR}R7iY_4}a>+Yop&XOOI1wAY6Fg= zD88NxpPXq)_XtK5*yCp;Ad8J^yN1#E*zWiNov0}|WE(r{;XF`Cr`&Zcj@BZhKc=+; zlSNYyAY3y08EiSNlKPIu@fOj+cu{z zYr-pcW4@v#mP7&gk`CmvUsYOsYKM6m(wuZcGN@0I6zZBL5X^{T06)F=Ex=~Jil0Qy zW6P2&S^_m6RO$|+m_HXGn1{ja<2WwUlbjupFW^lCDghnYMZ4TRhuK`{KHZxNCs;7ZJ1^xhxzLuh#V=0G%Ca?|2{ z8_~`AHlBN#!R4&&Xr%6nln>^^ucUG$BVT1FN`RNb!2*X1r#{N);_u)&{*`X}Wwn;& zh4MS3keUf2?e&JgGn~vnl zF36u_+%X+*R+Pf*RvvHz`KvYI*Qw%a89iExC10&(Psbdv=4nGd`48kT#IS+EE_#=! zOk8qsD5Tb`w?N)S1DF66s`?PoR+|Ng!(yMxDik6Ok(Jl<$~IK<J*%4vx+w0*zG(NRQjI5@Y%AkRJ|K^EYG=~>=*zSd#edB>r5V498vM1>l@TxX|k@8LN~cP0C^gJ5a&1dH!Gs18;b~FISu@ zy(*{kIrL$^6CN1SV***Mvhw#n-=uJ-$S7+=Zyjw*-uM^aRj(yEO5A8ldy)z0+_Y}* z#)%Q+!6}>CZhMa(=*N{s7wYiuC1$3zy`BFazzE<82R^CB4+5 zxYMQsKkwbI@sOa$aoM*bS?B2wLBPHDHGKYpWO8Vxx*nIn#2qj_uu@wtAZw!5MozVi6ESH$w?^BfdCILn-zg-URPlLRP zstsBn2;vabUNZ)PgK!VZMPu5<}@kKw@A}-GWT?t;rm|#L(i9_}sSs)nd+(wB>e(j)%Idclx$_RQ?JC zBzSm$C40gd#1(I5%o@yaYT+QKrP2%dvv_=6U_hE)Yt$?i$l)g|UFV zC#{z@Re6FJlX)mhA{>V2KW#85VR6JIJ zA`36}!VK8yv2v8p5f#CS$a{cvA(SJ5r$&A}YJKZRx?91<$l(LUgDHU=hMfyZB!>WH zJP{*-dva%xoJ1s&Gp2kX3-J9Qxlts~kbqfk;#^NG*!BamBD}aUQYiiQW)fKYOZ~8O zj1Q#tV}#VA_;^U@!OwHuZnh^P3IY9Dvn<^4kkAR3LNna>=Ebl&y$=hebJupu>Hi4e z2v`q5y>%fAXz&pGF^Hr(?E)`bt34`!#g)6d7Hlvckd7?zl?T5iA7ofmtIxnn-kBfm zDSI`vq)LfEuArJT%(REyBK+D#ZbRCMaWch^?b?HC_rd33X0b{})cv6^`}jVtb$S;# zYMfq4AlOCym_6gSQFu7OkK}-{nUOyFtgGIX2`4+4e5Or}O_mb3SN@~jp}L{>z)Hiv za@5H>KRSB_T4uOH@`E|Vq_8h%%+4M_VNCj@j!&^PK5Co7v>x&2WZtZ-ub(id^2HT z5G%9aFcMty^$hvUcF0vRo4SHWNrJTw2{KnLAwN{?d?z&_-jJ+6;wMdU$U$gJU#?jS z^a=mPLkgq(^x=^U^Fo>YM9+E1){oyWLhN#;0n+QpIb1+qI_un1|I4-L8-vT7W|vi? zLrgxHXi$H3_|`FqCs8=d@UtUf&%Nj~mS%hXCa7$<`8sK71bIo6t3)|ODZsubR?H(; zaVMi*1}ATrkQdFxJzdt2lg|702D%@t<`*=n0~K*GYE|<#+POYVMg2Wk&Wz!Cpfy!F ztkntR^)lGE0)LoC!aFDMD((7YqpnlVds6(~&CltR{=Mg_`b5^(;dS2_D_=OVJ;c;! z&EMp{*&YGlC6@ZZ#9TNscPj2t2lR`=v@+Qm3t|ZDof&1aqo)7zyhUW!ABkD+r}~GS z|IBI$KioedU0kR97r}0jtI+sNyF*xNlhudx5iq~0gvzOSkAp0b$`B>z$Cbg8Y9+4p zzdGU^oYcgfGI{=1E`O#iK>^U7( zWmpZa!aU;B{7AA=iqBtCv(ahjPR^FAu!4N_>rIY`~iFgS0P|wSYC*P4NXg#!=#T?WcQEjRfgVj2D00&Qu=+%gVlQjG} z-k++GSN4ZC!tBy$ia9IFip>+r>}#TU%=_j|_a{Wn@RExCt8w`Z`li&KJemMz+KYA zRI_>#D~*-#bB!KUf)1uIQo>p0Wtq{c6F&;Kw@ZD>@v}ZR;=vGCuGZ_#-?D)61SvjZ z=q#3GEnU(ESr$wlCHqCYik31?i7&~2MF$>#5kcgeQJkYW`g$@9J39=W7Sic#&wZPQ zr?h@-!;-9PP1lRwQOzwVi98A^*EjX2_meQe*_P$-;-1n>gm~obwJh9Npl-!&KW<>z5I{KO=)GrRflz|_ z(!(t1aC9{MWoqHZB~Zju?tX(5FacWp6|w=2@n|tc=C`TAlc_|ZzLDOzJ-#A&;4NP#xd3{!{r-1`h(qEZ~#mR7ccTW}pj^2u@;M~sB1x5N;zRYc#it?Mu zxG<#knNL)V+)8W>5N!@&;ni_cBv##yzqiWMS8LI!r%Z`G1UjkHt|K%_*58!GWSVUI z9t;y2Et+rM2PG2?6bnG`BgVyTQjT5-c3nL5$2yz#C00S)HC1@Sw-VJMEyXqo(bk<1 zyQLc(N_?$$)pFzXLE=JSiBprE5K*;zE2V8t@NTVeXrsxhWlGPn(NlxdaU`NZ#U}8? z7p$lN)qus=c}*hiAhnYH#>7N)t+H2u?bGAH9m z(`DJobm|T^S=5}Mf`5{S)obD?TN^#YN1C%Av6kO=5sB2%OSRbgScYu*Eno%FnE-(S zGNHEwQrH$u_Yqrc_vfYm-Tu{=Q#Tl+soo5%ri`U$m|Us*X;jT)#+5Pdrbc4fEb4V` z;uXYbGlpTbO&oRwKAS@)Qsq%nEL~Akl@su5+TTG_fyy!{IOQcFToj2# zV8NI$maG{@%Y0B{qa=cg3*fLCSV89*XnM?U7v(D^rFdkiWoe@ zUBJ;>x9Fmo1MTbg(=pyYVa?Um!dt*_ll&EMk;Iq*KB;4mvM*!n)L7@b7d|X*qw{m9B04hXMR_J z=12B@t%q(078*r;pnChf(#jQ6={NxwU90_xL*|`Vd#NcAxiR$%@zbb-M%xqB$nnTD z9Zlaz*YMExxs0}7!8Y8X6o3RP|8Xry0KAmo=^D6B^u7e^%m=lfGtZx1(lefAi|V?? z;)=l`bc@4avMJDo0hs8bxhE1N4o$(Brl}T(IQz1CM&n|44HRe+ZoL&}^ykW?q&{tV z-Gx30a!AW<**}{B`HY-eg?~GUSA>nqvEzG6pu9e_mI@Stmm?rCSEG(TXB#Bs>F5_N ztOlT7u?2R&tsmzOO|8szw2IP!=;f$hzzbWO&K?IBeN8d$0R$iW;8mY~X&4+;-+Gkn z#sNxc^(l%Xbu*M9L8q@i-!bTy>X#F+`zdB_Ld~-3VG2-=x&3HozuTe#Nz>>yFr_oNvJ_=nM?@p$dBlea51p27?UGgX?@*?nR)Hrwj*~cilC#e zeJxpB>SKjUThsX)w(AZ&^_cYw)Z0wRX_$%cMSOa`x98T7qKJN!nD#LBSYC?;#hBU>>MrpMgx& z9!a}ybE8-=TPAlu63WrHj z1+XQQL8O6qJf2v9Wl}*8Pbb1}!JsidWR@Bm2vx)k#Vi2mAbO%goiQ~VU(*e9pFh^r zq>`OF;jjYyfgbZnR=GiD+i)MgLG50SuP;$FM1X~tW-SUWUE-h*KImE1ccU?I0q0RU z!ueqV!SiAph2C!dkKx&jsGP{UJBI{0dM>`DOmN>s2El8()9z%=L5l`0eyWbQ_3@@y z-DWR0y6j-voL4k1^X!)Y5jf29tptw*#BNWep=->&2k_sLQzR~J{tl5r8d44Vo`<;f zSwn}R-NVE;1$-Bn<;3ZZzGrzYrrk(B`L}DQ-NaUmgL#fL(;1e7NGQtyBidK-ZMXd4 zGHE^+G2JN{kWmhOzDsay4Uk^NKF2xAaP1`6WvyN-huq!we84)-X;nxF>4OQ;2YLZC zCNkwiIZRjSme*Jsj1|c)mhcpOVRS4* z&jrGao}_9O0~1-aH58@c9;p<0Pd$l$vsmz)5HjB+km4;i+Vtb_H$Bn}8E`A;%fke3 zyFK)>|2Iyj4TY4}D;#wEq{nzZXzE~51;g`U?89uy3au+&+5tCDY4)rV%3%1vagS%r zgCdF>q`#Y*5a{ne_~^$~EJ%_i&(UsURs8*KT2|wbCmUOR2ZOZiwvXHknZ%`c?Gweq zXihLjV&HSi61M=YK>Uci6l&i@v=3{@&iU8fT1iJUx>hfn`rj>>*w0x*>)XXPcR%Ub z5Dplh^}Hidx?Ty5+mj4xY zqL@Br4QZ*v4N-)Y?seriu`0oKz;WS8p$?;cnvF8pIUW~rawa8mH-7NFjj1V;<5n-4 zyp{Jd!ikl{Xj_``iJ%wgU=w)BZLkmc6MVvLtLx)#Rt70f*@3ji?>%2i0jMB#JvXOTUxK)0*%!zb$SOi+~ zt@2ni(I9ZdqhquKw?vfA^{TV;LQi3;O>?@%8G!tHYyE9;1ohwpnKw2L2C6Vbg)rQX%G&z65_`8kfm`u+I?#tq;Y9?A+~oX;TXyIjORxI zugh^IXVh#R1{zW=L_8^gf1?YSD-c`5u-541ha$*H?IkJE&=E152TjietBf%ITI{@C5tN69KYvVxcF$PqXC2Lo9`) z8|p5F0g8syUW)W@rJSFy_2OLoN-VR01Ez9*Vd$P2F zxF*p*v{rrP_R64T$OO2G^=P#)LbHB_cr9#QG-*r=Rn>#xHM@xo&bW#;VCf#tIn<}x zD2-7mj{)RJB5|ucJhyJCl9{^9^ij1t+Km$PzGM*u0Q52JJ=vPRCq zkrW;PY}FVv!wk>iM~4HLIp{#6XDQt52;jS4s7z~F-6W(+W<8D~QSvoGm^6e{5SE)W z0O5Nm{xsPK(Fi_$cH)$ccRfa!Ez|&L=%|`1$N`y{_mn6`4(@fJo!KpQ2iVqn=9*R% zmk{3OxQj!~6 z8`3dqgYwBdiVddZ_QpFMB!RZEp{%iFz0F@v+=Ayaf5c=tC0fau0wOjd) z81DhuUw0hC%pa>*q4Ek(f}+T%=ILK_Z@=_Y6B~p?W4xX`F8pW*&R^{`RKKVCTwGI5^$c4%5CX56I zooABFeA6L%FOzohR2uMdxq#pM;P4EqZaR?i9g%Ho=t7?n2e9)-4+X^E&K@ z_B)XTMR{ax-Trkf;}@Nq;pbLwiZrg#1T zC{^@eHxhggW&m8AqgRst*vsUtFrJ z#hTpk)J=neDeOg9i>p!7_wE?1hGvVvqy`;tk@wsDffNAecb(x5Z&2^?26|}ld}>>O z_hd3#q775a6}c*H`$tEI3sT0&w0EK5@5FvSdAV*iBs>j=IE4{Yx(@60h48F_ zeSo7**Ps56ti2|kqc!}(< z*`}<{Gy-v+AL>2~bS(+fO$1g)My^-|{Nze3BsOrlZayqBCao>)44Tu=o>J9o}BMW;^h4FsUaqaHN1BEB~D`3@y=(oqqg z$#lZ6yy-PK1irH*iAGs#NOofejMRz$WJ4n+g#w!f_$nZ{v^^dR--lbe;7t{UFARZ; zH~X7%f1uGF(JON2d3XU2XHV_PT?)#6}XDt z4N;?fS~;vmrBhy$O_HoFbLd4Qd7ycQc!vh6{hqFJj5PXi{4!(OBXe_=re{z&VGWdi zV0s}W*!(YGPTgG9i+>gCEFbVB2SLpkC>ue6*zeV(_0|DCLENpse1qorGZYyH(bP+| zx)CAc5k}QWebQOrf5b#Z1Er$RD`@eCn#TfQVW?PO(!)FiukPc-*#Cu^-AbARG{&IWl3prHV8)TJ81 zoq&1?jAmBlLsxmb8CK?$`sQSc^8f$>X#t)O?dWSDt|!OhO+bqKIFj zpWnIHND^7!Q@~G_lFy*{7siA&S>#8mL5Fk^n7{PIA*nMy|D_uc5d`}Zah|ag@n(J7 zYddu2MB04C)5{J{&q$;Y&FlNLzKe4rC_9jgqv-A(4CZjrzbX87f zbmJEs3o0ng%E&4ZAoWkXSHln4%xVQ$sl@erGqO+9Q~dSD!U?F^St0!fjZe57h1i}a zG)6=M%GOWc|16z72mzU~KVm5e84j|%@6%E)S*lys$s_oYn8;;P`V^XsFFiM#fsd?B znGrbJs)%Ms(N7t=WwmECB;mnO0=R7xLEwOw!SfQJ#I;V>QK?*{YUONlCI&5OWtzrQ z*V^RoNIBw*hI_R*7F=%^Ll9S&zEcGS8|M`-B7LWnhugrsKV5c1r=3lw)h@Nr#JUI@ zk3AL2vltw6E{fGLLRRjJpn8CEd1eB?lf;WhMBnV81gFChO+^Q4r(r)C!}|O8$WLbNlr`H@gD%pM|z-6vCD~%^5xG_3(4Y^LsI`A|gg9)~1suW5^Lj4At zNXTS}9VSM$QQGAvhI?GJve`l2#!r+4XvF^&P<^>AY>53Ge#k>Bxa;OLE!gr%hS!(N z2yWoR6O(RVD>vP8zG#y{XRe|@DB(*_;q(@h>4)SsR#yZt{8)-MJb0{0rdf&*lI%G*SBUKLK3+mTy8&WqtqD??vf-FNK~K` zi0GmR332kXJqi^Z>T2Icp=q(KljDd;kMd^nn(2zJ&H+^ULgHA_=6}hDzzl{^-13hP zIHMj5mtZW-uZ4kf)HQr$oQ>c4*z`vTWRZ;n+t~%BHz3LB^t%|Qks!VUHBCB-u(mi) ze)mXpU%*7}2}sL#0fdGJnNO3dh;qva^|T!GJpJ;kEt7KezDX~F$n zKh=0Khi=~WbR3Maz=M2skDK$5h`(R8i4bkKcw2q@naR|Zcl8hA@` zBpF};00i(so83vML2Q{!2wVS27SJFL{Y$ci_ph!k)zZ`kqADW%0NXXnRkcNWX%^J# z&5RSQRdZ$dsup94@VL1zqi)NqpK6x=1yi$V>~l|ue4!}|$@>cWDG7H) zdKcs}NgYY(L_QofY3%kh!eT=Y{j}DrQXb6o0I4E` zUhL8=w*T=efybSb@=kBu(Tmk{r7pb*=i5K^Sr~M+(Pcg6->obRL2f4G+FH^+W^IHb zcQnZBu}%I0C&McJt>g2($SfA!uC@4`jSJy57}SY!*9Lh#mGd4w4nO9@aC?if(=#33 zDmJf*z9aZ8l4o}_k)uwsMPqVJr;vfZQ#@CSJ?n z!nt$$PgiPGmegOL*kdR$6+ceee4DD4SBTKKzkDm%+v_TVF}9g8@wVV0s-=4S0G^jD z%0bO{UE(d;Ha&3bK#}8iVYMOa{JAoCyj)yR>A#=vvJ)C<@;Q2vssxvV<3soPmiB*4 zgCev8gdA{vvr@4R#&~T(V8}vFV(Xnz$KK_$myuEV&tilJu@(d~<2okP0VmA^Aq{=$ z91nX&`$_k?0xMF%>0T4MU3NR|8sIwQ1W;au^B8A5qw5iWo3y zDIdEY;JoQkONXCu6hE0X6cpq9#`ZNYLK(}Lpj^E=xEe!(t&qPri-#e5tGa-d_Gg?{ zB#mm*aD$=%Bb{dgl|v=C=k2|rEwJkS+X~{J2G_DjidqXr1xZbQ@IgL#v*@TeAXnSF zaqOC`Pb+Rj4yBmQ{UZRGt!4%pq`V*22sW@Wz$r)dx%8vvZpvKqczVBq+t}5S8)*<( z)K!sq2Kf#;BXn$jN?yq7KB*Y8Qp%9pS=ym0f{bB)Mlt#fieeMOWA*Ck&T!^X5K6q| zj{vhh1O|c19yX;cyBgrPSnw>_>}i@u8~;}$U^orh(F8)$o7s{ze0{a)KKXKW%9re2 zE+NUzn}DS9J9E2X?9;}jqtOrr#W%Z4gY9R@&zp&WVR>5}f~by|{hWaBC9ZoX%TQ^t zgAE z>6rQXQXz_P`|;P!=Lpy!(>Uu7?p~{wP49&f)!GeVmrT>8tm;JEeX;;>ALs22_MPvr z;uGGZsOj$NwaD$@S#1ktKJWykjU&Z$4~btDu$K?3buWUGH*$#=K$K}pmtK`tgxt;z zwsfXFzaJZV9>fjODpIZKLcA-u%B{8MTe%0YK~d6!J9IEBDz|ZZ2ARr*at+sc1I58t zwB7)3jq~ndCfK79eS(nNZn@T_X_Gks00V46pZi4>EGhrrp2udK2nF&i70$MFc=*8M znvnu!=OsSk=HfLUR?`o{?=AzpsElQdb&Hp-{^u~^iM)5H>UtATgvAkw_e9BbN$C$d z#q6I6HjT=nl8s~>TTmDJjn6~g&~*ycs1|gUpMEtaOY#aLTbN7pF+QYEup6uU@ zZQM@gXgd>a8JK2O!A(g9x4680@7&DEH~fCoccg2@zAZnI50nnKimuM9WS#U8FnM?d zhQuJ`N8n(L2-A~hR$ltQL_W&Kzv7`V|R)wlj2TWd3RPb5Bf(gZu*V)O3Gu{TDDCvTsoR!BC7nbQM6#`h^csB0 zltxErdX_OaQk4u$#MVN(CIDLsFGgC(wrx>dP=|@Y*d8$>P+n^6vLMA@+{o~xyQ(qSRGPfoCm|sY z|Jio-vnPhue2VG&8#ow0v{>XdS8cGpmmaj3m0nGO{(QDgQU#GKE?i1FKrjNL98QFm6u-AnXiR#nbhQ1jxw}Oh z;BG61Wly>wS?Uqa5rvypC=UPWoHZn__)y;J`>~pzwt&knG#eQo6Cb{DZDv|(+tytg zFRU{Dhl83g*VjvMU}77~9|vk)BYi#um6p0B;LBzH@IXG}^mSsoSW&aqA<6zlR^J+v z&~VCXpKdjZ++kf82uUR?rHnBz?6*b&M`eNGreLxpQ_F+SCWYGtivK%Pzyy^4%rxqN zjx2LaGsPsO@N5$|U#PJXh4F1IEsRT#rvv$r+<5MR7UauitG7nVygviYf&~5;4FZoz zOkQGk&Kd7CAvzmLMyspk*^f__p|yIDO8iV9b(kDuvzPllE2~TQ>$dokxRb2g#!Bv1 c(`Td?*a5>5U6EvEyotRkZNUHDC)JeoL7WEbaR2}S literal 0 HcmV?d00001 diff --git a/testdata/src/test/assets/playbackdumps/dash/webvtt-in-mp4.dump b/testdata/src/test/assets/playbackdumps/dash/webvtt-in-mp4.dump new file mode 100644 index 00000000000..3fbd68f8dbe --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/dash/webvtt-in-mp4.dump @@ -0,0 +1,58 @@ +MediaCodec (video/avc): + buffers.length = 31 + buffers[0] = length 36692, hash D216076E + buffers[1] = length 5312, hash D45D3CA0 + buffers[2] = length 599, hash 1BE7812D + buffers[3] = length 7735, hash 4490F110 + buffers[4] = length 987, hash 560B5036 + buffers[5] = length 673, hash ED7CD8C7 + buffers[6] = length 523, hash 3020DF50 + buffers[7] = length 6061, hash 736C72B2 + buffers[8] = length 992, hash FE132F23 + buffers[9] = length 623, hash 5B2C1816 + buffers[10] = length 421, hash 742E69C1 + buffers[11] = length 4899, hash F72F86A1 + buffers[12] = length 568, hash 519A8E50 + buffers[13] = length 620, hash 3990AA39 + buffers[14] = length 5450, hash F06EC4AA + buffers[15] = length 1051, hash 92DFA63A + buffers[16] = length 874, hash 69587FB4 + buffers[17] = length 781, hash 36BE495B + buffers[18] = length 4725, hash AC0C8CD3 + buffers[19] = length 1022, hash 5D8BFF34 + buffers[20] = length 790, hash 99413A99 + buffers[21] = length 610, hash 5E129290 + buffers[22] = length 2751, hash 769974CB + buffers[23] = length 745, hash B78A477A + buffers[24] = length 621, hash CF741E7A + buffers[25] = length 505, hash 1DB4894E + buffers[26] = length 1268, hash C15348DC + buffers[27] = length 880, hash C2DE85D0 + buffers[28] = length 530, hash C98BC6A8 + buffers[29] = length 568, hash 4FE5C8EA + buffers[30] = length 0, hash 1 +TextOutput: + Subtitle[0]: + Cues = [] + Subtitle[1]: + Cue[0]: + text = This is the first subtitle. + textAlignment = ALIGN_CENTER + lineType = 1 + lineAnchor = 0 + position = 0.5 + positionAnchor = 1 + size = 1.0 + Subtitle[2]: + Cues = [] + Subtitle[3]: + Cue[0]: + text = This is the second subtitle. + textAlignment = ALIGN_CENTER + lineType = 1 + lineAnchor = 0 + position = 0.5 + positionAnchor = 1 + size = 1.0 + Subtitle[4]: + Cues = [] From 990051f5c507272c5914fcf85ef59542ba36173f Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 1 Oct 2020 10:24:16 +0100 Subject: [PATCH 107/693] Merge 2 UI sections in dev-v2 part of release notes PiperOrigin-RevId: 334771927 --- RELEASENOTES.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b16cdcea0c2..12e62ae4c39 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -22,6 +22,9 @@ * UI: * Do not require subtitleButton in custom layouts of StyledPlayerView ([#7962](https://github.com/google/ExoPlayer/issues/7962)). + * Add the option to sort tracks by `Format` in `TrackSelectionView` and + `TrackSelectionDialogBuilder` + ([#7709](https://github.com/google/ExoPlayer/issues/7709)). * Audio: * Retry playback after some types of `AudioTrack` error. * Fix the default audio sink position not advancing correctly when using @@ -34,10 +37,6 @@ ([#7949](https://github.com/google/ExoPlayer/issues/7949)). * Fix regression for Ogg files with packets that span multiple pages ([#7992](https://github.com/google/ExoPlayer/issues/7992)). -* UI - * Add the option to sort tracks by `Format` in `TrackSelectionView` and - `TrackSelectionDialogBuilder` - ([#7709](https://github.com/google/ExoPlayer/issues/7709)). * IMA extension: * Fix position reporting after fetch errors ([#7956](https://github.com/google/ExoPlayer/issues/7956)). From 907e2e051542e0a394d6a146594e158a5470e58e Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 1 Oct 2020 11:08:36 +0100 Subject: [PATCH 108/693] Tweak null-checking in TextRenderer#getNextEventTime() `subtitle` is only guaranteed to be non-null if `nextSubtitleEventIndex != C.INDEX_UNSET`. The null check added in https://github.com/google/ExoPlayer/commit/0efec5f6c12a5d583f24c122fbcbc1b1eebbabc3 was too early. Issue: #8017 PiperOrigin-RevId: 334777742 --- RELEASENOTES.md | 2 ++ .../com/google/android/exoplayer2/text/TextRenderer.java | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 12e62ae4c39..d43ecfcc8a1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -19,6 +19,8 @@ * Add support for `\h` SSA/ASS style override code (non-breaking space). * Fix WebVTT subtitles in MP4 containers in DASH streams ([#7985](https://github.com/google/ExoPlayer/issues/7985)). + * Fix NPE in `TextRenderer` when playing content with a single subtitle + buffer ([#8017](https://github.com/google/ExoPlayer/issues/8017)). * UI: * Do not require subtitleButton in custom layouts of StyledPlayerView ([#7962](https://github.com/google/ExoPlayer/issues/7962)). 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 6c140c74d17..76c13600457 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 @@ -325,10 +325,13 @@ private void replaceDecoder() { } private long getNextEventTime() { + if (nextSubtitleEventIndex == C.INDEX_UNSET) { + return Long.MAX_VALUE; + } checkNotNull(subtitle); - return nextSubtitleEventIndex == C.INDEX_UNSET - || nextSubtitleEventIndex >= subtitle.getEventTimeCount() - ? Long.MAX_VALUE : subtitle.getEventTime(nextSubtitleEventIndex); + return nextSubtitleEventIndex >= subtitle.getEventTimeCount() + ? Long.MAX_VALUE + : subtitle.getEventTime(nextSubtitleEventIndex); } private void updateOutput(List cues) { From 3e8dacc2844f9d8dd86eefb2604a25166568d58a Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Thu, 1 Oct 2020 11:18:18 +0100 Subject: [PATCH 109/693] Move DataSource reading methods into Util This will be used to read ads responses out of data: URLs in a subsequent change. PiperOrigin-RevId: 334778780 --- .../google/android/exoplayer2/util/Util.java | 48 ++++++++++++++++++ .../upstream/DataSchemeDataSourceTest.java | 3 +- .../upstream/cache/CacheDataSourceTest.java | 16 +++--- .../exoplayer2/testutil/CacheAsserts.java | 2 +- .../android/exoplayer2/testutil/TestUtil.java | 50 +------------------ 5 files changed, 59 insertions(+), 60 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 0c13900330c..34092633674 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 @@ -534,6 +534,54 @@ public static ExecutorService newSingleThreadExecutor(final String threadName) { return Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, threadName)); } + /** + * Reads data from the specified opened {@link DataSource} until it ends, and returns a byte array + * containing the read data. + * + * @param dataSource The source from which to read. + * @return The concatenation of all read data. + * @throws IOException If an error occurs reading from the source. + */ + public static byte[] readToEnd(DataSource dataSource) throws IOException { + byte[] data = new byte[1024]; + int position = 0; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT) { + if (position == data.length) { + data = Arrays.copyOf(data, data.length * 2); + } + bytesRead = dataSource.read(data, position, data.length - position); + if (bytesRead != C.RESULT_END_OF_INPUT) { + position += bytesRead; + } + } + return Arrays.copyOf(data, position); + } + + /** + * Reads {@code length} bytes from the specified opened {@link DataSource}, and returns a byte + * array containing the read data. + * + * @param dataSource The source from which to read. + * @return The read data. + * @throws IOException If an error occurs reading from the source. + * @throws IllegalStateException If the end of the source was reached before {@code length} bytes + * could be read. + */ + public static byte[] readExactly(DataSource dataSource, int length) throws IOException { + byte[] data = new byte[length]; + int position = 0; + while (position < length) { + int bytesRead = dataSource.read(data, position, data.length - position); + if (bytesRead == C.RESULT_END_OF_INPUT) { + throw new IllegalStateException( + "Not enough data could be read: " + position + " < " + length); + } + position += bytesRead; + } + return data; + } + /** * Closes a {@link DataSource}, suppressing any {@link IOException} that may occur. * diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java index 0acbd74891e..ab0bf954048 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java @@ -22,7 +22,6 @@ import android.net.Uri; 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.util.Util; import java.io.IOException; import org.junit.Before; @@ -167,7 +166,7 @@ private static void assertDataSourceContent( try { long length = dataSource.open(dataSpec); assertThat(length).isEqualTo(expectedData.length); - byte[] readData = TestUtil.readToEnd(dataSource); + byte[] readData = Util.readToEnd(dataSource); assertThat(readData).isEqualTo(expectedData); } finally { dataSource.close(); 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 cadd9e43aba..5ee8e423b7d 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 @@ -302,7 +302,7 @@ public void unknownLengthContentReadInOneConnectionAndLengthIsResolved() throws CacheDataSource cacheDataSource = new CacheDataSource(cache, upstream, 0); cacheDataSource.open(unboundedDataSpec); - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); assertThat(upstream.getAndClearOpenedDataSpecs()).hasLength(1); @@ -319,7 +319,7 @@ public void ignoreCacheForUnsetLengthRequests() throws Exception { cache, upstream, CacheDataSource.FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS); cacheDataSource.open(unboundedDataSpec); - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); assertThat(cache.getKeys()).isEmpty(); @@ -369,7 +369,7 @@ public void switchToCacheSourceWithReadOnlyCacheDataSource() throws Exception { cacheWriter.cache(); // Read the rest of the data. - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); } @@ -419,7 +419,7 @@ public void switchToCacheSourceWithNonBlockingCacheDataSource() throws Exception cacheWriter.cache(); // Read the rest of the data. - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); } @@ -449,14 +449,14 @@ public void deleteCachedWhileReadingFromUpstreamWithReadOnlyCacheDataSourceDoesN // Open source and read some data from upstream as the data hasn't cached yet. cacheDataSource.open(unboundedDataSpec); - TestUtil.readExactly(cacheDataSource, 100); + Util.readExactly(cacheDataSource, 100); // Delete cached data. cache.removeResource(cacheDataSource.getCacheKeyFactory().buildCacheKey(unboundedDataSpec)); assertCacheEmpty(cache); // Read the rest of the data. - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); } @@ -487,7 +487,7 @@ public void deleteCachedWhileReadingFromUpstreamWithBlockingCacheDataSourceDoesN cacheDataSource.open(unboundedDataSpec); // Read the first half from upstream as it hasn't cached yet. - TestUtil.readExactly(cacheDataSource, halfDataLength); + Util.readExactly(cacheDataSource, halfDataLength); // Delete the cached latter half. NavigableSet cachedSpans = cache.getCachedSpans(defaultCacheKey); @@ -498,7 +498,7 @@ public void deleteCachedWhileReadingFromUpstreamWithBlockingCacheDataSourceDoesN } // Read the rest of the data. - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java index 118f571e01c..65cc7dc8e78 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/CacheAsserts.java @@ -130,7 +130,7 @@ public static void assertDataCached(Cache cache, DataSpec dataSpec, byte[] expec byte[] bytes; try { dataSource.open(dataSpec); - bytes = TestUtil.readToEnd(dataSource); + bytes = Util.readToEnd(dataSource); } catch (IOException e) { throw new IOException("Opening/reading cache failed: " + dataSpec, e); } finally { 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 ea423660f4e..f2ead0485f9 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 @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.testutil; import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; import android.content.Context; import android.database.sqlite.SQLiteDatabase; @@ -53,7 +52,6 @@ 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; @@ -77,52 +75,6 @@ public class TestUtil { private TestUtil() {} - /** - * Given an open {@link DataSource}, repeatedly calls {@link DataSource#read(byte[], int, int)} - * until {@link C#RESULT_END_OF_INPUT} is returned. - * - * @param dataSource The source from which to read. - * @return The concatenation of all read data. - * @throws IOException If an error occurs reading from the source. - */ - public static byte[] readToEnd(DataSource dataSource) throws IOException { - byte[] data = new byte[1024]; - int position = 0; - int bytesRead = 0; - while (bytesRead != C.RESULT_END_OF_INPUT) { - if (position == data.length) { - data = Arrays.copyOf(data, data.length * 2); - } - bytesRead = dataSource.read(data, position, data.length - position); - if (bytesRead != C.RESULT_END_OF_INPUT) { - position += bytesRead; - } - } - return Arrays.copyOf(data, position); - } - - /** - * Given an open {@link DataSource}, repeatedly calls {@link DataSource#read(byte[], int, int)} - * until exactly {@code length} bytes have been read. - * - * @param dataSource The source from which to read. - * @return The read data. - * @throws IOException If an error occurs reading from the source. - */ - public static byte[] readExactly(DataSource dataSource, int length) throws IOException { - byte[] data = new byte[length]; - int position = 0; - while (position < length) { - int bytesRead = dataSource.read(data, position, data.length - position); - if (bytesRead == C.RESULT_END_OF_INPUT) { - fail("Not enough data could be read: " + position + " < " + length); - } else { - position += bytesRead; - } - } - return data; - } - /** * Equivalent to {@code buildTestData(length, length)}. * @@ -271,7 +223,7 @@ public static void assertDataSourceContent( try { long length = dataSource.open(dataSpec); assertThat(length).isEqualTo(expectKnownLength ? expectedData.length : C.LENGTH_UNSET); - byte[] readData = readToEnd(dataSource); + byte[] readData = Util.readToEnd(dataSource); assertThat(readData).isEqualTo(expectedData); } finally { dataSource.close(); From 43fe38672cd7c39450088f8f1f010b69ee67809c Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 1 Oct 2020 13:41:44 +0100 Subject: [PATCH 110/693] Bump compileSdkVersion to 30 Without this, depending on Robolectric 4.5 causes compilation failures. PiperOrigin-RevId: 334795155 --- constants.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constants.gradle b/constants.gradle index 82a6a554791..0625fbd3b72 100644 --- a/constants.gradle +++ b/constants.gradle @@ -18,7 +18,7 @@ project.ext { minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved. Also fix TODOs in UtilTest. - compileSdkVersion = 29 + compileSdkVersion = 30 dexmakerVersion = '2.21.0' junitVersion = '4.13-rc-2' guavaVersion = '27.1-android' From 067712f5994d900796da0758e4ed830e9c3a4591 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 1 Oct 2020 14:34:50 +0100 Subject: [PATCH 111/693] Fix flaky test PiperOrigin-RevId: 334801561 --- .../test/java/com/google/android/exoplayer2/ExoPlayerTest.java | 1 + 1 file changed, 1 insertion(+) 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 a1b3b9014d9..4c64ffd781f 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 @@ -6902,6 +6902,7 @@ public void removeMediaItems_currentItemRemovedThatIsTheLast_correctMasking() th ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .waitForPlaybackState(Player.STATE_READY) + .waitForPendingPlayerCommands() .executeRunnable( new PlayerRunnable() { @Override From 41192ee046043a663c5c185bbb5d090817425f0c Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 2 Oct 2020 14:03:37 +0100 Subject: [PATCH 112/693] Allow apps to add a VideoAdPlayerCallback Issue: #7944 PiperOrigin-RevId: 335012643 --- RELEASENOTES.md | 2 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d43ecfcc8a1..37551c9575d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -42,6 +42,8 @@ * IMA extension: * Fix position reporting after fetch errors ([#7956](https://github.com/google/ExoPlayer/issues/7956)). + * Allow apps to specify a `VideoAdPlayerCallback` + ([#7944](https://github.com/google/ExoPlayer/issues/7944)). ### 2.12.0 (2020-09-11) ### 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 592920bfc48..ffece0f110e 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 @@ -125,6 +125,7 @@ public static final class Builder { @Nullable private ImaSdkSettings imaSdkSettings; @Nullable private AdErrorListener adErrorListener; @Nullable private AdEventListener adEventListener; + @Nullable private VideoAdPlayer.VideoAdPlayerCallback videoAdPlayerCallback; @Nullable private Set adUiElements; @Nullable private Collection companionAdSlots; private long adPreloadTimeoutMs; @@ -190,6 +191,22 @@ public Builder setAdEventListener(AdEventListener adEventListener) { return this; } + /** + * Sets a callback to receive video ad player events. Note that these events are handled + * internally by the IMA SDK and this ads loader. For analytics and diagnostics, new + * implementations should generally use events from the top-level {@link Player} listeners + * instead of setting a callback via this method. + * + * @param videoAdPlayerCallback The callback to receive video ad player events. + * @return This builder, for convenience. + * @see com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer.VideoAdPlayerCallback + */ + public Builder setVideoAdPlayerCallback( + VideoAdPlayer.VideoAdPlayerCallback videoAdPlayerCallback) { + this.videoAdPlayerCallback = checkNotNull(videoAdPlayerCallback); + return this; + } + /** * Sets the ad UI elements to be rendered by the IMA SDK. * @@ -524,6 +541,9 @@ private ImaAdsLoader(Builder builder, @Nullable Uri adTagUri, @Nullable String a handler = Util.createHandler(getImaLooper(), /* callback= */ null); componentListener = new ComponentListener(); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); + if (builder.videoAdPlayerCallback != null) { + adCallbacks.add(builder.videoAdPlayerCallback); + } updateAdProgressRunnable = this::updateAdProgress; adInfoByAdMediaInfo = HashBiMap.create(); supportedMimeTypes = Collections.emptyList(); From a552e35f6a3fdd5b948e33dbf03a7c89fd6b8613 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 5 Oct 2020 15:54:44 +0100 Subject: [PATCH 113/693] Add getter and callbacks for static metadata retrieval. Issue:#7266 PiperOrigin-RevId: 335416280 --- RELEASENOTES.md | 2 + .../exoplayer2/ext/cast/CastPlayer.java | 8 +++ .../android/exoplayer2/ExoPlayerImpl.java | 27 ++++++++-- .../exoplayer2/ExoPlayerImplInternal.java | 24 ++++++++- .../android/exoplayer2/PlaybackInfo.java | 24 ++++++++- .../com/google/android/exoplayer2/Player.java | 37 ++++++++++++-- .../android/exoplayer2/SimpleExoPlayer.java | 6 +++ .../analytics/AnalyticsCollector.java | 8 +++ .../analytics/AnalyticsListener.java | 18 +++++++ .../android/exoplayer2/util/EventLogger.java | 11 ++++ .../android/exoplayer2/ExoPlayerTest.java | 51 +++++++++++++++++++ .../exoplayer2/MediaPeriodQueueTest.java | 1 + .../exoplayer2/ui/StyledPlayerView.java | 13 ++--- .../exoplayer2/testutil/StubExoPlayer.java | 6 +++ 14 files changed, 215 insertions(+), 21 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 37551c9575d..d2ca5e7f155 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -10,6 +10,8 @@ still possible until the next major release using `setThrowsWhenUsingWrongThread(false)` ([#4463](https://github.com/google/ExoPlayer/issues/4463)). + * Add a getter and callback for static metadata to the player + ([#7266](https://github.com/google/ExoPlayer/issues/7266)). * Track selection: * Add option to specify multiple preferred audio or text languages. * Data sources: 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 80d9817a463..da42778c348 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 @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.FixedTrackSelection; @@ -51,6 +52,7 @@ import com.google.android.gms.common.api.ResultCallback; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -561,6 +563,12 @@ public TrackGroupArray getCurrentTrackGroups() { return currentTrackGroups; } + @Override + public List getCurrentStaticMetadata() { + // CastPlayer does not currently support metadata. + return Collections.emptyList(); + } + @Override public Timeline getCurrentTimeline() { return currentTimeline; 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 1b0b34bd7bd..304635bb2c6 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 @@ -28,6 +28,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceFactory; @@ -43,6 +44,7 @@ import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; @@ -861,6 +863,11 @@ public TrackSelectionArray getCurrentTrackSelections() { return playbackInfo.trackSelectorResult.selections; } + @Override + public List getCurrentStaticMetadata() { + return playbackInfo.staticMetadata; + } + @Override public Timeline getCurrentTimeline() { return playbackInfo.timeline; @@ -1168,7 +1175,8 @@ private PlaybackInfo maskTimelineAndPosition( /* requestedContentPositionUs= */ C.msToUs(maskingWindowPositionMs), /* totalBufferedDurationUs= */ 0, TrackGroupArray.EMPTY, - emptyTrackSelectorResult); + emptyTrackSelectorResult, + ImmutableList.of()); playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(dummyMediaPeriodId); playbackInfo.bufferedPositionUs = playbackInfo.positionUs; return playbackInfo; @@ -1195,7 +1203,8 @@ private PlaybackInfo maskTimelineAndPosition( /* requestedContentPositionUs= */ newContentPositionUs, /* totalBufferedDurationUs= */ 0, playingPeriodChanged ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, - playingPeriodChanged ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult); + playingPeriodChanged ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, + playingPeriodChanged ? ImmutableList.of() : playbackInfo.staticMetadata); playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(newPeriodId); playbackInfo.bufferedPositionUs = newContentPositionUs; } else if (newContentPositionUs == oldContentPositionUs) { @@ -1219,7 +1228,8 @@ private PlaybackInfo maskTimelineAndPosition( /* requestedContentPositionUs= */ playbackInfo.positionUs, /* totalBufferedDurationUs= */ maskedBufferedPositionUs - playbackInfo.positionUs, playbackInfo.trackGroups, - playbackInfo.trackSelectorResult); + playbackInfo.trackSelectorResult, + playbackInfo.staticMetadata); playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(newPeriodId); playbackInfo.bufferedPositionUs = maskedBufferedPositionUs; } @@ -1241,7 +1251,8 @@ private PlaybackInfo maskTimelineAndPosition( /* requestedContentPositionUs= */ newContentPositionUs, maskedTotalBufferedDurationUs, playbackInfo.trackGroups, - playbackInfo.trackSelectorResult); + playbackInfo.trackSelectorResult, + playbackInfo.staticMetadata); playbackInfo.bufferedPositionUs = maskedBufferedPositionUs; } return playbackInfo; @@ -1348,6 +1359,7 @@ private static final class PlaybackInfoUpdate implements Runnable { private final boolean isLoadingChanged; private final boolean timelineChanged; private final boolean trackSelectorResultChanged; + private final boolean staticMetadataChanged; private final boolean playWhenReadyChanged; private final boolean playbackSuppressionReasonChanged; private final boolean isPlayingChanged; @@ -1387,6 +1399,8 @@ public PlaybackInfoUpdate( timelineChanged = !previousPlaybackInfo.timeline.equals(playbackInfo.timeline); trackSelectorResultChanged = previousPlaybackInfo.trackSelectorResult != playbackInfo.trackSelectorResult; + staticMetadataChanged = + !previousPlaybackInfo.staticMetadata.equals(playbackInfo.staticMetadata); playWhenReadyChanged = previousPlaybackInfo.playWhenReady != playbackInfo.playWhenReady; playbackSuppressionReasonChanged = previousPlaybackInfo.playbackSuppressionReason != playbackInfo.playbackSuppressionReason; @@ -1428,6 +1442,11 @@ public void run() { listener.onTracksChanged( playbackInfo.trackGroups, playbackInfo.trackSelectorResult.selections)); } + if (staticMetadataChanged) { + invokeAll( + listenerSnapshot, + listener -> listener.onStaticMetadataChanged(playbackInfo.staticMetadata)); + } if (isLoadingChanged) { invokeAll( listenerSnapshot, listener -> listener.onIsLoadingChanged(playbackInfo.isLoading)); 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 45af6d601f3..94ffe2db6e1 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 @@ -33,12 +33,14 @@ import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.BandwidthMeter; @@ -49,6 +51,7 @@ import com.google.android.exoplayer2.util.TraceUtil; import com.google.android.exoplayer2.util.Util; import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -1338,6 +1341,7 @@ private void resetInternal( /* isLoading= */ false, resetTrackInfo ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, resetTrackInfo ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, + resetTrackInfo ? ImmutableList.of() : playbackInfo.staticMetadata, mediaPeriodId, playbackInfo.playWhenReady, playbackInfo.playbackSuppressionReason, @@ -2096,6 +2100,7 @@ private PlaybackInfo handlePositionDiscontinuity( resetPendingPauseAtEndOfPeriod(); TrackGroupArray trackGroupArray = playbackInfo.trackGroups; TrackSelectorResult trackSelectorResult = playbackInfo.trackSelectorResult; + List staticMetadata = playbackInfo.staticMetadata; if (mediaSourceList.isPrepared()) { @Nullable MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); trackGroupArray = @@ -2106,18 +2111,35 @@ private PlaybackInfo handlePositionDiscontinuity( playingPeriodHolder == null ? emptyTrackSelectorResult : playingPeriodHolder.getTrackSelectorResult(); + staticMetadata = extractMetadataFromTrackSelectionArray(trackSelectorResult.selections); } else if (!mediaPeriodId.equals(playbackInfo.periodId)) { // Reset previously kept track info if unprepared and the period changes. trackGroupArray = TrackGroupArray.EMPTY; trackSelectorResult = emptyTrackSelectorResult; + staticMetadata = ImmutableList.of(); } + return playbackInfo.copyWithNewPosition( mediaPeriodId, positionUs, contentPositionUs, getTotalBufferedDurationUs(), trackGroupArray, - trackSelectorResult); + trackSelectorResult, + staticMetadata); + } + + private ImmutableList extractMetadataFromTrackSelectionArray( + TrackSelectionArray trackSelectionArray) { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (int i = 0; i < trackSelectionArray.length; i++) { + @Nullable TrackSelection trackSelection = trackSelectionArray.get(i); + if (trackSelection != null) { + Format format = trackSelection.getFormat(0); + builder.add(format.metadata == null ? new Metadata() : format.metadata); + } + } + return builder.build(); } private void enableRenderers() throws ExoPlaybackException { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java index e7f200d8b7d..96d14d0239e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java @@ -18,9 +18,12 @@ import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import com.google.common.collect.ImmutableList; +import java.util.List; /** * Information about an ongoing playback. @@ -57,6 +60,8 @@ public final TrackGroupArray trackGroups; /** The result of the current track selection. */ public final TrackSelectorResult trackSelectorResult; + /** The current static metadata of the track selections. */ + public final List staticMetadata; /** The {@link MediaPeriodId} of the currently loading media period in the {@link #timeline}. */ public final MediaPeriodId loadingMediaPeriodId; /** Whether playback should proceed when {@link #playbackState} == {@link Player#STATE_READY}. */ @@ -104,6 +109,7 @@ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorRes /* isLoading= */ false, TrackGroupArray.EMPTY, emptyTrackSelectorResult, + /* staticMetadata= */ ImmutableList.of(), PLACEHOLDER_MEDIA_PERIOD_ID, /* playWhenReady= */ false, Player.PLAYBACK_SUPPRESSION_REASON_NONE, @@ -126,6 +132,7 @@ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorRes * @param isLoading See {@link #isLoading}. * @param trackGroups See {@link #trackGroups}. * @param trackSelectorResult See {@link #trackSelectorResult}. + * @param staticMetadata See {@link #staticMetadata}. * @param loadingMediaPeriodId See {@link #loadingMediaPeriodId}. * @param playWhenReady See {@link #playWhenReady}. * @param playbackSuppressionReason See {@link #playbackSuppressionReason}. @@ -145,6 +152,7 @@ public PlaybackInfo( boolean isLoading, TrackGroupArray trackGroups, TrackSelectorResult trackSelectorResult, + List staticMetadata, MediaPeriodId loadingMediaPeriodId, boolean playWhenReady, @PlaybackSuppressionReason int playbackSuppressionReason, @@ -162,6 +170,7 @@ public PlaybackInfo( this.isLoading = isLoading; this.trackGroups = trackGroups; this.trackSelectorResult = trackSelectorResult; + this.staticMetadata = staticMetadata; this.loadingMediaPeriodId = loadingMediaPeriodId; this.playWhenReady = playWhenReady; this.playbackSuppressionReason = playbackSuppressionReason; @@ -189,6 +198,8 @@ public static MediaPeriodId getDummyPeriodForEmptyTimeline() { * @param trackGroups The track groups for the new position. See {@link #trackGroups}. * @param trackSelectorResult The track selector result for the new position. See {@link * #trackSelectorResult}. + * @param staticMetadata The static metadata for the track selections. See {@link + * #staticMetadata}. * @return Copied playback info with new playing position. */ @CheckResult @@ -198,7 +209,8 @@ public PlaybackInfo copyWithNewPosition( long requestedContentPositionUs, long totalBufferedDurationUs, TrackGroupArray trackGroups, - TrackSelectorResult trackSelectorResult) { + TrackSelectorResult trackSelectorResult, + List staticMetadata) { return new PlaybackInfo( timeline, periodId, @@ -208,6 +220,7 @@ public PlaybackInfo copyWithNewPosition( isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -236,6 +249,7 @@ public PlaybackInfo copyWithTimeline(Timeline timeline) { isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -264,6 +278,7 @@ public PlaybackInfo copyWithPlaybackState(int playbackState) { isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -292,6 +307,7 @@ public PlaybackInfo copyWithPlaybackError(@Nullable ExoPlaybackException playbac isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -320,6 +336,7 @@ public PlaybackInfo copyWithIsLoading(boolean isLoading) { isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -348,6 +365,7 @@ public PlaybackInfo copyWithLoadingMediaPeriodId(MediaPeriodId loadingMediaPerio isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -380,6 +398,7 @@ public PlaybackInfo copyWithPlayWhenReady( isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -408,6 +427,7 @@ public PlaybackInfo copyWithPlaybackParameters(PlaybackParameters playbackParame isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -437,6 +457,7 @@ public PlaybackInfo copyWithOffloadSchedulingEnabled(boolean offloadSchedulingEn isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, @@ -465,6 +486,7 @@ public PlaybackInfo copyWithSleepingForOffload(boolean sleepingForOffload) { isLoading, trackGroups, trackSelectorResult, + staticMetadata, loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, 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 89a00eb4758..16656029dbb 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 @@ -28,12 +28,14 @@ import com.google.android.exoplayer2.audio.AuxEffectInfo; import com.google.android.exoplayer2.device.DeviceInfo; import com.google.android.exoplayer2.device.DeviceListener; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextOutput; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; +import com.google.android.exoplayer2.util.StableApiCandidate; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer; import com.google.android.exoplayer2.video.VideoFrameMetadataListener; @@ -491,6 +493,22 @@ default void onMediaItemTransition( default void onTracksChanged( TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {} + /** + * Called when the static metadata changes. + * + *

    The provided {@code metadataList} is an immutable list of {@link Metadata} instances, + * where the elements correspond to the {@link #getCurrentTrackSelections() current track + * selections}, or an empty list if there are no track selections or the implementation does not + * support metadata. + * + *

    The metadata is considered static in the sense that it comes from the tracks' declared + * Formats, rather than being timed (or dynamic) metadata, which is represented within a + * metadata track. + * + * @param metadataList The static metadata. + */ + default void onStaticMetadataChanged(List metadataList) {} + /** * Called when the player starts or stops loading the source. * @@ -1226,15 +1244,24 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { @Nullable TrackSelector getTrackSelector(); - /** - * Returns the available track groups. - */ + /** Returns the available track groups. */ TrackGroupArray getCurrentTrackGroups(); + /** Returns the current track selections for each renderer. */ + TrackSelectionArray getCurrentTrackSelections(); + /** - * Returns the current track selections for each renderer. + * Returns the current static metadata for the track selections. + * + *

    The returned {@code metadataList} is an immutable list of {@link Metadata} instances, where + * the elements correspond to the {@link #getCurrentTrackSelections() current track selections}, + * or an empty list if there are no track selections or the implementation does not support + * metadata. + * + *

    This metadata is considered static in that it comes from the tracks' declared Formats, + * rather than being timed (or dynamic) metadata, which is represented within a metadata track. */ - TrackSelectionArray getCurrentTrackSelections(); + List getCurrentStaticMetadata(); /** * Returns the current manifest. The type depends on the type of media being played. May be null. 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 baa1400143d..088bfad0d2e 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 @@ -1761,6 +1761,12 @@ public TrackSelectionArray getCurrentTrackSelections() { return player.getCurrentTrackSelections(); } + @Override + public List getCurrentStaticMetadata() { + verifyApplicationThread(); + return player.getCurrentStaticMetadata(); + } + @Override public Timeline getCurrentTimeline() { verifyApplicationThread(); 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 30321c59728..a5535d7456c 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 @@ -470,6 +470,14 @@ public final void onTracksChanged( } } + @Override + public final void onStaticMetadataChanged(List metadataList) { + EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onStaticMetadataChanged(eventTime, metadataList); + } + } + @Override public final void onIsLoadingChanged(boolean isLoading) { EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); 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 7e5abbd803e..65e2abb5828 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 @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.common.base.Objects; import java.io.IOException; +import java.util.List; /** * A listener for analytics events. @@ -337,6 +338,23 @@ default void onPlayerError(EventTime eventTime, ExoPlaybackException error) {} default void onTracksChanged( EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {} + /** + * Called when the static metadata changes. + * + *

    The provided {@code metadataList} is an immutable list of {@link Metadata} instances, where + * the elements correspond to the current track selections (as returned by {@link + * #onTracksChanged(EventTime, TrackGroupArray, TrackSelectionArray)}, or an empty list if there + * are no track selections or the implementation does not support metadata. + * + *

    The metadata is considered static in the sense that it comes from the tracks' declared + * Formats, rather than being timed (or dynamic) metadata, which is represented within a metadata + * track. + * + * @param eventTime The event time. + * @param metadataList The static metadata. + */ + default void onStaticMetadataChanged(EventTime eventTime, List metadataList) {} + /** * Called when a media source started loading data. * 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 a441e81bc4a..45b7050c6a4 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 @@ -45,6 +45,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import java.io.IOException; import java.text.NumberFormat; +import java.util.List; import java.util.Locale; /** Logs events from {@link Player} and other core components using {@link Log}. */ @@ -295,6 +296,16 @@ public void onTracksChanged( logd("]"); } + @Override + public void onStaticMetadataChanged(EventTime eventTime, List metadataList) { + logd("staticMetadata [" + getEventTimeString(eventTime)); + for (int i = 0; i < metadataList.size(); i++) { + logd(" " + i); + printMetadata(metadataList.get(i), " "); + } + logd("]"); + } + @Override public void onMetadata(EventTime eventTime, Metadata metadata) { logd("metadata [" + getEventTimeString(eventTime)); 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 4c64ffd781f..1e21f25d3dd 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 @@ -56,6 +56,8 @@ import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.source.ClippingMediaSource; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; @@ -104,6 +106,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.MimeTypes; import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; @@ -8309,6 +8312,54 @@ public void wakeupListenerWhileSleepingForOffload_isWokenUp_renderingResumes() t runUntilPlaybackState(player, Player.STATE_ENDED); } + @Test + public void staticMetadata_callbackIsCalledCorrectlyAndMatchesGetter() throws Exception { + Format videoFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setWidth(1920) + .setHeight(720) + .setMetadata( + new Metadata( + new TextInformationFrame( + /* id= */ "TT2", + /* description= */ "Video", + /* value= */ "Video track name"))) + .build(); + + Format audioFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_AAC) + .setSampleRate(44_000) + .setMetadata( + new Metadata( + new TextInformationFrame( + /* id= */ "TT2", + /* description= */ "Audio", + /* value= */ "Audio track name"))) + .build(); + + EventListener eventListener = mock(EventListener.class); + + Timeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 100000)); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).build(); + + player.setMediaSource(new FakeMediaSource(fakeTimeline, videoFormat, audioFormat)); + player.addListener(eventListener); + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + assertThat(player.getCurrentStaticMetadata()) + .containsExactly(videoFormat.metadata, audioFormat.metadata) + .inOrder(); + verify(eventListener) + .onStaticMetadataChanged(ImmutableList.of(videoFormat.metadata, audioFormat.metadata)); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { 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 ff4cdb7340f..6b90dfab15b 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 @@ -432,6 +432,7 @@ private void setupTimeline(Timeline timeline) { /* isLoading= */ false, /* trackGroups= */ null, /* trackSelectorResult= */ null, + /* staticMetadata= */ ImmutableList.of(), /* loadingMediaPeriodId= */ null, /* playWhenReady= */ false, Player.PLAYBACK_SUPPRESSION_REASON_NONE, 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 index 8b6c5983c6d..230bb9fbf52 100644 --- 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 @@ -58,7 +58,6 @@ 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; @@ -1369,15 +1368,9 @@ private void updateForCurrentTrackSelections(boolean isNewPlayer) { 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; - } - } + for (Metadata metadata : player.getCurrentStaticMetadata()) { + if (setArtworkFromMetadata(metadata)) { + return; } } if (setDrawableArtwork(defaultArtwork)) { 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 7a96e1c797f..858f7319b82 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 @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.PlayerMessage; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -375,6 +376,11 @@ public TrackSelectionArray getCurrentTrackSelections() { throw new UnsupportedOperationException(); } + @Override + public List getCurrentStaticMetadata() { + throw new UnsupportedOperationException(); + } + @Override public Timeline getCurrentTimeline() { throw new UnsupportedOperationException(); From 6ed371aaf339d1ab441a3cbe1929e89326d098f1 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 6 Oct 2020 11:53:02 +0100 Subject: [PATCH 114/693] Add search bytes parameter to TsExtractor Context: Issue: #7988 PiperOrigin-RevId: 335608610 --- RELEASENOTES.md | 3 ++ .../extractor/DefaultExtractorsFactory.java | 20 +++++++- .../extractor/ts/TsBinarySearchSeeker.java | 18 ++++--- .../extractor/ts/TsDurationReader.java | 10 ++-- .../exoplayer2/extractor/ts/TsExtractor.java | 51 ++++++++++++++++--- .../extractor/ts/TsDurationReaderTest.java | 2 +- 6 files changed, 83 insertions(+), 21 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index d2ca5e7f155..55f32af3e0a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -41,6 +41,9 @@ ([#7949](https://github.com/google/ExoPlayer/issues/7949)). * Fix regression for Ogg files with packets that span multiple pages ([#7992](https://github.com/google/ExoPlayer/issues/7992)). + * Add TS extractor parameter to configure the number of bytes in which + to search for a timestamp to determine the duration and to seek. + ([#7988](https://github.com/google/ExoPlayer/issues/7988)). * IMA extension: * Fix position reporting after fetch errors ([#7956](https://github.com/google/ExoPlayer/issues/7956)). 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 2eba1b1cca0..2068853d9ea 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 @@ -131,9 +131,11 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { @Mp3Extractor.Flags private int mp3Flags; @TsExtractor.Mode private int tsMode; @DefaultTsPayloadReaderFactory.Flags private int tsFlags; + private int tsTimestampSearchBytes; public DefaultExtractorsFactory() { tsMode = TsExtractor.MODE_SINGLE_PMT; + tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES; } /** @@ -246,7 +248,7 @@ public synchronized DefaultExtractorsFactory setMp3ExtractorFlags(@Mp3Extractor. /** * Sets the mode for {@link TsExtractor} instances created by the factory. * - * @see TsExtractor#TsExtractor(int, TimestampAdjuster, TsPayloadReader.Factory) + * @see TsExtractor#TsExtractor(int, TimestampAdjuster, TsPayloadReader.Factory, int) * @param mode The mode to use. * @return The factory, for convenience. */ @@ -269,6 +271,20 @@ public synchronized DefaultExtractorsFactory setTsExtractorFlags( return this; } + /** + * Sets the number of bytes searched to find a timestamp for {@link TsExtractor} instances created + * by the factory. + * + * @see TsExtractor#TsExtractor(int, TimestampAdjuster, TsPayloadReader.Factory, int) + * @param timestampSearchBytes The number of search bytes to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setTsExtractorTimestampSearchBytes( + int timestampSearchBytes) { + tsTimestampSearchBytes = timestampSearchBytes; + return this; + } + @Override public synchronized Extractor[] createExtractors() { return createExtractors(Uri.EMPTY, new HashMap<>()); @@ -361,7 +377,7 @@ private void addExtractorsForFileType(@FileTypes.Type int fileType, ListGiven a PCR timestamp, and a position within a TS stream, this seeker will peek up to {@link - * #TIMESTAMP_SEARCH_BYTES} from that stream position, look for all packets with PID equal to + * #timestampSearchBytes} from that stream position, look for all packets with PID equal to * PCR_PID, and then compare the PCR timestamps (if available) of these packets to the target * timestamp. */ @@ -67,10 +70,13 @@ private static final class TsPcrSeeker implements TimestampSeeker { private final TimestampAdjuster pcrTimestampAdjuster; private final ParsableByteArray packetBuffer; private final int pcrPid; + private final int timestampSearchBytes; - public TsPcrSeeker(int pcrPid, TimestampAdjuster pcrTimestampAdjuster) { + public TsPcrSeeker( + int pcrPid, TimestampAdjuster pcrTimestampAdjuster, int timestampSearchBytes) { this.pcrPid = pcrPid; this.pcrTimestampAdjuster = pcrTimestampAdjuster; + this.timestampSearchBytes = timestampSearchBytes; packetBuffer = new ParsableByteArray(); } @@ -78,7 +84,7 @@ public TsPcrSeeker(int pcrPid, TimestampAdjuster pcrTimestampAdjuster) { public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) throws IOException { long inputPosition = input.getPosition(); - int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); + int bytesToSearch = (int) min(timestampSearchBytes, input.getLength() - inputPosition); packetBuffer.reset(bytesToSearch); input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java index 5020f4c76da..504b84d575c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java @@ -38,8 +38,7 @@ */ /* package */ final class TsDurationReader { - private static final int TIMESTAMP_SEARCH_BYTES = 600 * TsExtractor.TS_PACKET_SIZE; - + private final int timestampSearchBytes; private final TimestampAdjuster pcrTimestampAdjuster; private final ParsableByteArray packetBuffer; @@ -51,7 +50,8 @@ private long lastPcrValue; private long durationUs; - /* package */ TsDurationReader() { + /* package */ TsDurationReader(int timestampSearchBytes) { + this.timestampSearchBytes = timestampSearchBytes; pcrTimestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); firstPcrValue = C.TIME_UNSET; lastPcrValue = C.TIME_UNSET; @@ -125,7 +125,7 @@ private int finishReadDuration(ExtractorInput input) { private int readFirstPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) throws IOException { - int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength()); + int bytesToSearch = (int) min(timestampSearchBytes, input.getLength()); int searchStartPosition = 0; if (input.getPosition() != searchStartPosition) { seekPositionHolder.position = searchStartPosition; @@ -161,7 +161,7 @@ private long readFirstPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcr private int readLastPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) throws IOException { long inputLength = input.getLength(); - int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, inputLength); + int bytesToSearch = (int) min(timestampSearchBytes, inputLength); long searchStartPosition = inputLength - bytesToSearch; if (input.getPosition() != searchStartPosition) { seekPositionHolder.position = searchStartPosition; 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 2fcfd422a01..2a9613f7f40 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 @@ -80,6 +80,9 @@ public final class TsExtractor implements Extractor { */ public static final int MODE_HLS = 2; + public static final int TS_PACKET_SIZE = 188; + public static final int DEFAULT_TIMESTAMP_SEARCH_BYTES = 600 * TS_PACKET_SIZE; + public static final int TS_STREAM_TYPE_MPA = 0x03; public static final int TS_STREAM_TYPE_MPA_LSF = 0x04; public static final int TS_STREAM_TYPE_AAC_ADTS = 0x0F; @@ -100,7 +103,6 @@ public final class TsExtractor implements Extractor { // Stream types that aren't defined by the MPEG-2 TS specification. public static final int TS_STREAM_TYPE_AIT = 0x101; - public static final int TS_PACKET_SIZE = 188; public static final int TS_SYNC_BYTE = 0x47; // First byte of each TS packet. private static final int TS_PAT_PID = 0; @@ -115,6 +117,7 @@ public final class TsExtractor implements Extractor { private static final int SNIFF_TS_PACKET_COUNT = 5; private final @Mode int mode; + private final int timestampSearchBytes; private final List timestampAdjusters; private final ParsableByteArray tsPacketBuffer; private final SparseIntArray continuityCounters; @@ -136,7 +139,7 @@ public final class TsExtractor implements Extractor { private int pcrPid; public TsExtractor() { - this(0); + this(/* defaultTsPayloadReaderFlags= */ 0); } /** @@ -144,7 +147,7 @@ public TsExtractor() { * {@code FLAG_*} values that control the behavior of the payload readers. */ public TsExtractor(@Flags int defaultTsPayloadReaderFlags) { - this(MODE_SINGLE_PMT, defaultTsPayloadReaderFlags); + this(MODE_SINGLE_PMT, defaultTsPayloadReaderFlags, DEFAULT_TIMESTAMP_SEARCH_BYTES); } /** @@ -152,12 +155,22 @@ public TsExtractor(@Flags int defaultTsPayloadReaderFlags) { * and {@link #MODE_HLS}. * @param defaultTsPayloadReaderFlags A combination of {@link DefaultTsPayloadReaderFactory} * {@code FLAG_*} values that control the behavior of the payload readers. + * @param timestampSearchBytes The number of bytes searched from a given position in the stream to + * find a PCR timestamp. If this value is too small, the duration might be unknown and seeking + * might not be supported for high bitrate progressive streams. Setting a large value for this + * field might be inefficient though because the extractor stores a buffer of {@code + * timestampSearchBytes} bytes when determining the duration or when performing a seek + * operation. The default value is {@link #DEFAULT_TIMESTAMP_SEARCH_BYTES}. If the number of + * bytes left in the stream from the current position is less than {@code + * timestampSearchBytes}, the search is performed on the bytes left. */ - public TsExtractor(@Mode int mode, @Flags int defaultTsPayloadReaderFlags) { + public TsExtractor( + @Mode int mode, @Flags int defaultTsPayloadReaderFlags, int timestampSearchBytes) { this( mode, new TimestampAdjuster(0), - new DefaultTsPayloadReaderFactory(defaultTsPayloadReaderFlags)); + new DefaultTsPayloadReaderFactory(defaultTsPayloadReaderFlags), + timestampSearchBytes); } /** @@ -170,7 +183,30 @@ public TsExtractor( @Mode int mode, TimestampAdjuster timestampAdjuster, TsPayloadReader.Factory payloadReaderFactory) { + this(mode, timestampAdjuster, payloadReaderFactory, DEFAULT_TIMESTAMP_SEARCH_BYTES); + } + + /** + * @param mode Mode for the extractor. One of {@link #MODE_MULTI_PMT}, {@link #MODE_SINGLE_PMT} + * and {@link #MODE_HLS}. + * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. + * @param payloadReaderFactory Factory for injecting a custom set of payload readers. + * @param timestampSearchBytes The number of bytes searched from a given position in the stream to + * find a PCR timestamp. If this value is too small, the duration might be unknown and seeking + * might not be supported for high bitrate progressive streams. Setting a large value for this + * field might be inefficient though because the extractor stores a buffer of {@code + * timestampSearchBytes} bytes when determining the duration or when performing a seek + * operation. The default value is {@link #DEFAULT_TIMESTAMP_SEARCH_BYTES}. If the number of + * bytes left in the stream from the current position is less than {@code + * timestampSearchBytes}, the search is performed on the bytes left. + */ + public TsExtractor( + @Mode int mode, + TimestampAdjuster timestampAdjuster, + TsPayloadReader.Factory payloadReaderFactory, + int timestampSearchBytes) { this.payloadReaderFactory = Assertions.checkNotNull(payloadReaderFactory); + this.timestampSearchBytes = timestampSearchBytes; this.mode = mode; if (mode == MODE_SINGLE_PMT || mode == MODE_HLS) { timestampAdjusters = Collections.singletonList(timestampAdjuster); @@ -183,7 +219,7 @@ public TsExtractor( trackPids = new SparseBooleanArray(); tsPayloadReaders = new SparseArray<>(); continuityCounters = new SparseIntArray(); - durationReader = new TsDurationReader(); + durationReader = new TsDurationReader(timestampSearchBytes); pcrPid = -1; resetPayloadReaders(); } @@ -365,7 +401,8 @@ private void maybeOutputSeekMap(long inputLength) { durationReader.getPcrTimestampAdjuster(), durationReader.getDurationUs(), inputLength, - pcrPid); + pcrPid, + timestampSearchBytes); output.seekMap(tsBinarySearchSeeker.getSeekMap()); } else { output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java index 8f744e855d7..0e55d292b8c 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java @@ -37,7 +37,7 @@ public final class TsDurationReaderTest { @Before public void setUp() { - tsDurationReader = new TsDurationReader(); + tsDurationReader = new TsDurationReader(TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES); seekPositionHolder = new PositionHolder(); } From 39277ebe95fc0be39272bdaedf9e04aa2f113e9d Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 6 Oct 2020 13:17:38 +0100 Subject: [PATCH 115/693] Pass ad tags via AdsMediaSource This is in preparation for supporting playlists of ads media sources using ImaAdsLoader. Existing ways of passing ad tags should still function but are deprecated (and won't be supported with playlists). Issue: #3750 PiperOrigin-RevId: 335618364 --- RELEASENOTES.md | 5 + .../exoplayer2/demo/PlayerActivity.java | 2 +- .../exoplayer2/ext/ima/ImaAdsLoader.java | 115 ++++++++++++++---- .../android/exoplayer2/ext/ima/ImaUtil.java | 22 ++++ .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 74 +++++++++-- .../google/android/exoplayer2/util/Util.java | 9 ++ .../android/exoplayer2/util/UtilTest.java | 8 ++ .../source/DefaultMediaSourceFactory.java | 13 +- .../exoplayer2/source/ads/AdsLoader.java | 8 ++ .../exoplayer2/source/ads/AdsMediaSource.java | 52 +++++++- .../upstream/DataSchemeDataSource.java | 3 +- .../android/exoplayer2/ExoPlayerTest.java | 15 ++- .../upstream/DataSchemeDataSourceTest.java | 8 ++ 13 files changed, 289 insertions(+), 45 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 55f32af3e0a..ef65c9aab1b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -49,6 +49,11 @@ ([#7956](https://github.com/google/ExoPlayer/issues/7956)). * Allow apps to specify a `VideoAdPlayerCallback` ([#7944](https://github.com/google/ExoPlayer/issues/7944)). + * Accept ad tags via the `AdsMediaSource` constructor and deprecate + passing them via the `ImaAdsLoader` constructor/builders. Passing the + ad tag via media item playback properties continues to be supported. + This is in preparation for supporting ads in playlists + ([#3750](https://github.com/google/ExoPlayer/issues/3750)). ### 2.12.0 (2020-09-11) ### 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 eae302887e0..739e89ab867 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 @@ -375,7 +375,7 @@ private AdsLoader getAdsLoader(Uri adTagUri) { } // The ads loader is reused for multiple playbacks, so that ad playback can resume. if (adsLoader == null) { - adsLoader = new ImaAdsLoader(/* context= */ PlayerActivity.this, adTagUri); + adsLoader = new ImaAdsLoader.Builder(/* context= */ this).build(); } adsLoader.setPlayer(player); return adsLoader; 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 ffece0f110e..64ad8970630 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 @@ -18,7 +18,6 @@ import static com.google.android.exoplayer2.util.Assertions.checkArgument; 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 static java.lang.Math.max; import android.content.Context; @@ -92,6 +91,15 @@ * #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling * {@link #release()}. * + *

    See https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for + * information on compatible ad tag formats. Pass the ad tag URI when setting media item playback + * properties (if using the media item API) or as a {@link DataSpec} when constructing the {@link + * com.google.android.exoplayer2.source.ads.AdsMediaSource} (if using media sources directly). For + * the latter case, please note that this implementation delegates loading of the data spec to the + * IMA SDK, so range and headers specifications will be ignored in ad tag URIs. Literal ads + * responses can be encoded as data scheme data specs, for example, by constructing the data spec + * using a URI generated via {@link Util#getDataUriForString(String, String)}. + * *

    The IMA SDK can report obstructions to the ad view for accurate viewability measurement. This * means that any overlay views that obstruct the ad overlay but are essential for playback need to * be registered via the {@link AdViewProvider} passed to the {@link @@ -331,7 +339,12 @@ public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) { * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for * information on compatible ad tags. * @return The new {@link ImaAdsLoader}. + * @deprecated Pass the ad tag URI when setting media item playback properties (if using the + * media item API) or as a {@link DataSpec} when constructing the {@link + * com.google.android.exoplayer2.source.ads.AdsMediaSource} (if using media sources + * directly). */ + @Deprecated public ImaAdsLoader buildForAdTag(Uri adTagUri) { return new ImaAdsLoader( /* builder= */ this, /* adTagUri= */ adTagUri, /* adsResponse= */ null); @@ -343,10 +356,21 @@ public ImaAdsLoader buildForAdTag(Uri adTagUri) { * @param adsResponse The sideloaded VAST, VMAP, or ad rules response to be used instead of * making a request via an ad tag URL. * @return The new {@link ImaAdsLoader}. + * @deprecated Pass the ads response as a data URI when setting media item playback properties + * (if using the media item API) or as a {@link DataSpec} when constructing the {@link + * com.google.android.exoplayer2.source.ads.AdsMediaSource} (if using media sources + * directly). {@link Util#getDataUriForString(String, String)} can be used to construct a + * data URI from literal string ads response (with MIME type text/xml). */ + @Deprecated public ImaAdsLoader buildForAdsResponse(String adsResponse) { return new ImaAdsLoader(/* builder= */ this, /* adTagUri= */ null, adsResponse); } + + /** Returns a new {@link ImaAdsLoader}. */ + public ImaAdsLoader build() { + return new ImaAdsLoader(/* builder= */ this, /* adTagUri= */ null, /* adsResponse= */ null); + } } private static final boolean DEBUG = false; @@ -400,6 +424,8 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { */ private static final int IMA_AD_STATE_PAUSED = 2; + private static final DataSpec EMPTY_AD_TAG_DATA_SPEC = new DataSpec(Uri.EMPTY); + private final Context context; @Nullable private final Uri adTagUri; @Nullable private final String adsResponse; @@ -430,6 +456,7 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { private List supportedMimeTypes; @Nullable private EventListener eventListener; @Nullable private Player player; + private DataSpec adTagDataSpec; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; private int lastVolumePercent; @@ -505,14 +532,18 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { * @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. + * @deprecated Use {@link Builder} to create an instance. Pass the ad tag URI when setting media + * item playback properties (if using the media item API) or as a {@link DataSpec} when + * constructing the {@link com.google.android.exoplayer2.source.ads.AdsMediaSource} (if using + * media sources directly). */ + @Deprecated public ImaAdsLoader(Context context, Uri adTagUri) { this(new Builder(context), adTagUri, /* adsResponse= */ null); } @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader(Builder builder, @Nullable Uri adTagUri, @Nullable String adsResponse) { - checkArgument(adTagUri != null || adsResponse != null); this.context = builder.context.getApplicationContext(); this.adTagUri = adTagUri; this.adsResponse = adsResponse; @@ -547,6 +578,7 @@ private ImaAdsLoader(Builder builder, @Nullable Uri adTagUri, @Nullable String a updateAdProgressRunnable = this::updateAdProgress; adInfoByAdMediaInfo = HashBiMap.create(); supportedMimeTypes = Collections.emptyList(); + adTagDataSpec = EMPTY_AD_TAG_DATA_SPEC; lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; @@ -592,12 +624,62 @@ public AdDisplayContainer getAdDisplayContainer() { * * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code * null} if playing audio-only ads. + * @deprecated Use {@link #requestAds(DataSpec, ViewGroup)}, specifying the ad tag data spec to + * request, and migrate off deprecated builder methods/constructor that require an ad tag or + * ads response. */ + @Deprecated public void requestAds(@Nullable ViewGroup adViewGroup) { + requestAds(adTagDataSpec, adViewGroup); + } + + /** + * Requests ads, if they have not already been requested. Must be called on the main thread. + * + *

    Ads will be requested automatically when the player is prepared if this method has not been + * called, so it is only necessary to call this method if you want to request ads before preparing + * the player. + * + * @param adTagDataSpec The data specification of the ad tag to load. See class javadoc for + * information about compatible ad tag formats. + * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code + * null} if playing audio-only ads. + */ + public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) { if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) { // Ads have already been requested. return; } + + if (EMPTY_AD_TAG_DATA_SPEC.equals(adTagDataSpec)) { + // Handle deprecated ways of specifying the ad tag. + if (adTagUri != null) { + adTagDataSpec = new DataSpec(adTagUri); + } else if (adsResponse != null) { + adTagDataSpec = new DataSpec(Util.getDataUriForString(adsResponse, "text/xml")); + } else { + throw new IllegalStateException(); + } + } + + AdsRequest request; + try { + request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec); + } catch (IOException e) { + hasAdPlaybackState = true; + updateAdPlaybackState(); + pendingAdLoadError = AdLoadException.createForAllAds(e); + maybeNotifyPendingAdLoadError(); + return; + } + this.adTagDataSpec = adTagDataSpec; + pendingAdRequestContext = new Object(); + request.setUserRequestContext(pendingAdRequestContext); + if (vastLoadTimeoutMs != TIMEOUT_UNSET) { + request.setVastLoadTimeout(vastLoadTimeoutMs); + } + request.setContentProgressProvider(componentListener); + if (adViewGroup != null) { adDisplayContainer = imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener); @@ -608,24 +690,13 @@ public void requestAds(@Nullable ViewGroup adViewGroup) { if (companionAdSlots != null) { adDisplayContainer.setCompanionSlots(companionAdSlots); } + adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(componentListener); if (adErrorListener != null) { adsLoader.addAdErrorListener(adErrorListener); } adsLoader.addAdsLoadedListener(componentListener); - AdsRequest request = imaFactory.createAdsRequest(); - if (adTagUri != null) { - request.setAdTagUrl(adTagUri.toString()); - } else { - request.setAdsResponse(castNonNull(adsResponse)); - } - if (vastLoadTimeoutMs != TIMEOUT_UNSET) { - request.setVastLoadTimeout(vastLoadTimeoutMs); - } - request.setContentProgressProvider(componentListener); - pendingAdRequestContext = new Object(); - request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); } @@ -674,6 +745,11 @@ public void setSupportedContentTypes(@C.ContentType int... contentTypes) { this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes); } + @Override + public void setAdTagDataSpec(DataSpec adTagDataSpec) { + this.adTagDataSpec = adTagDataSpec; + } + @Override public void start(EventListener eventListener, AdViewProvider adViewProvider) { checkState( @@ -700,7 +776,7 @@ public void start(EventListener eventListener, AdViewProvider adViewProvider) { updateAdPlaybackState(); } else { // Ads haven't loaded yet, so request them. - requestAds(adViewProvider.getAdViewGroup()); + requestAds(adTagDataSpec, adViewProvider.getAdViewGroup()); } if (adDisplayContainer != null) { for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { @@ -1431,7 +1507,7 @@ private void updateAdPlaybackState() { private void maybeNotifyPendingAdLoadError() { if (pendingAdLoadError != null && eventListener != null) { - eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri)); + eventListener.onAdLoadError(pendingAdLoadError, adTagDataSpec); pendingAdLoadError = null; } } @@ -1446,8 +1522,7 @@ private void maybeNotifyInternalError(String name, Exception cause) { updateAdPlaybackState(); if (eventListener != null) { eventListener.onAdLoadError( - AdLoadException.createForUnexpected(new RuntimeException(message, cause)), - getAdsDataSpec(adTagUri)); + AdLoadException.createForUnexpected(new RuntimeException(message, cause)), adTagDataSpec); } } @@ -1500,10 +1575,6 @@ private String getAdMediaInfoString(AdMediaInfo 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(); diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java index c4b2c3dca37..e896e1c1151 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java @@ -32,6 +32,10 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader.OverlayInfo; +import com.google.android.exoplayer2.upstream.DataSchemeDataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; import java.util.Arrays; import java.util.List; @@ -116,6 +120,24 @@ public static AdPlaybackState getInitialAdPlaybackStateForCuePoints(List return new AdPlaybackState(adGroupTimesUs); } + /** Returns an {@link AdsRequest} based on the specified ad tag {@link DataSpec}. */ + public static AdsRequest getAdsRequestForAdTagDataSpec( + ImaFactory imaFactory, DataSpec adTagDataSpec) throws IOException { + AdsRequest request = imaFactory.createAdsRequest(); + if (DataSchemeDataSource.SCHEME_DATA.equals(adTagDataSpec.uri.getScheme())) { + DataSchemeDataSource dataSchemeDataSource = new DataSchemeDataSource(); + try { + dataSchemeDataSource.open(adTagDataSpec); + request.setAdsResponse(Util.fromUtf8Bytes(Util.readToEnd(dataSchemeDataSource))); + } finally { + dataSchemeDataSource.close(); + } + } else { + request.setAdTagUrl(adTagDataSpec.uri.toString()); + } + return request; + } + /** Returns whether the ad error indicates that an entire ad group failed to load. */ public static boolean isAdGroupLoadError(AdError adError) { // TODO: Find out what other errors need to be handled (if any), and whether each one relates to 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 98610654540..dc6f5b517c6 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 @@ -64,6 +64,7 @@ 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.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.io.IOException; @@ -97,8 +98,9 @@ public final class ImaAdsLoaderTest { /* 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 Uri TEST_URI = Uri.parse("https://www.google.com"); + private static final DataSpec TEST_DATA_SPEC = new DataSpec(TEST_URI); + private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo("https://www.google.com"); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; private static final ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f); @@ -285,7 +287,7 @@ public void playback_withPrerollAd_marksAdAsPlayed() { new AdPlaybackState(/* adGroupTimesUs...= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) - .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI) .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); @@ -550,7 +552,8 @@ public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPr .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -582,7 +585,8 @@ public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPrerol .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -614,7 +618,8 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -650,7 +655,8 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -689,7 +695,8 @@ public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skips .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -707,11 +714,51 @@ public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skips .withSkippedAdGroup(/* adGroupIndex= */ 0)); } + @Test + public void requestAdTagWithDataScheme_requestsWithAdsResponse() throws Exception { + String adsResponse = + "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + ""; + DataSpec adDataSpec = new DataSpec(Util.getDataUriForString("text/xml", adsResponse)); + + setupPlayback( + CONTENT_TIMELINE, + ImmutableList.of(0f), + new ImaAdsLoader.Builder(getApplicationContext()) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .build(), + adDataSpec); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRequest).setAdsResponse(adsResponse); + } + + @Test + public void requestAdTagWithUri_requestsWithAdTagUrl() throws Exception { + setupPlayback( + CONTENT_TIMELINE, + ImmutableList.of(0f), + new ImaAdsLoader.Builder(getApplicationContext()) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .build(), + TEST_DATA_SPEC); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRequest).setAdTagUrl(TEST_DATA_SPEC.uri.toString()); + } + @Test public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.requestAds(adViewGroup); + imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup); imaAdsLoader.stop(); InOrder inOrder = inOrder(mockAdDisplayContainer); @@ -775,16 +822,21 @@ private void setupPlayback(Timeline contentTimeline, List cuePoints) { new ImaAdsLoader.Builder(getApplicationContext()) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); } private void setupPlayback( - Timeline contentTimeline, List cuePoints, ImaAdsLoader imaAdsLoader) { + Timeline contentTimeline, + List cuePoints, + ImaAdsLoader imaAdsLoader, + DataSpec adTagDataSpec) { fakeExoPlayer = new FakePlayer(); adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); when(mockAdsManager.getAdCuePoints()).thenReturn(cuePoints); this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); + imaAdsLoader.setAdTagDataSpec(adTagDataSpec); } private void setupMocks() { 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 34092633674..81801f33e21 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 @@ -48,6 +48,7 @@ import android.security.NetworkSecurityPolicy; import android.telephony.TelephonyManager; import android.text.TextUtils; +import android.util.Base64; import android.view.Display; import android.view.SurfaceView; import android.view.WindowManager; @@ -2007,6 +2008,14 @@ private static boolean shouldEscapeCharacter(char c) { return builder.toString(); } + /** Returns a data URI with the specified MIME type and data. */ + public static Uri getDataUriForString(String mimeType, String data) { + // TODO(internal: b/169937045): For now we don't pass the URL_SAFE flag as DataSchemeDataSource + // doesn't decode using it. + return Uri.parse( + "data:" + mimeType + ";base64," + Base64.encodeToString(data.getBytes(), Base64.NO_WRAP)); + } + /** * A hacky method that always throws {@code t} even if {@code t} is a checked exception, * and is not declared to be thrown. 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 cda9e054f16..dd2ee7af890 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 @@ -876,6 +876,14 @@ public void escapeUnescapeFileName_returnsEscapedString() { } } + @Test + public void getDataUriForString_returnsCorrectDataUri() { + assertThat( + Util.getDataUriForString(/* mimeType= */ "text/plain", "Some Data!<>:\"/\\|?*%") + .toString()) + .isEqualTo("data:text/plain;base64,U29tZSBEYXRhITw+OiIvXHw/KiU="); + } + @Test public void crc32_returnsUpdatedCrc32() { byte[] bytes = {0x5F, 0x78, 0x04, 0x7B, 0x5F}; 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 3f1c03d3b18..8f85a0ac171 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 @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider; import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; @@ -280,7 +281,8 @@ private static MediaSource maybeClipMediaSource(MediaItem mediaItem, MediaSource private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource mediaSource) { Assertions.checkNotNull(mediaItem.playbackProperties); - if (mediaItem.playbackProperties.adTagUri == null) { + @Nullable Uri adTagUri = mediaItem.playbackProperties.adTagUri; + if (adTagUri == null) { return mediaSource; } AdsLoaderProvider adsLoaderProvider = this.adsLoaderProvider; @@ -292,14 +294,17 @@ private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource + " setAdViewProvider."); return mediaSource; } - @Nullable - AdsLoader adsLoader = adsLoaderProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri); + @Nullable AdsLoader adsLoader = adsLoaderProvider.getAdsLoader(adTagUri); if (adsLoader == null) { Log.w(TAG, "Playing media without ads. No AdsLoader for provided adTagUri"); return mediaSource; } return new AdsMediaSource( - mediaSource, /* adMediaSourceFactory= */ this, adsLoader, adViewProvider); + mediaSource, + new DataSpec(adTagUri), + /* adMediaSourceFactory= */ this, + adsLoader, + adViewProvider); } private static SparseArray loadDelegates( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java index f1c17c10935..fda5e15215d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java @@ -198,6 +198,14 @@ public OverlayInfo(View view, @Purpose int purpose, @Nullable String detailedRea */ void setSupportedContentTypes(@C.ContentType int... contentTypes); + /** + * Sets the data spec of the ad tag to load. + * + * @param adTagDataSpec The data spec of the ad tag to load. See the implementation's + * documentation for information about compatible ad tag formats. + */ + void setAdTagDataSpec(DataSpec adTagDataSpec); + /** * Starts using the ads loader for playback. Called on the main thread by {@link AdsMediaSource}. * 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 62c3e2ed173..7320f6f6c57 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 @@ -128,6 +128,7 @@ public RuntimeException getRuntimeExceptionForUnexpected() { private final MediaSourceFactory adMediaSourceFactory; private final AdsLoader adsLoader; private final AdsLoader.AdViewProvider adViewProvider; + @Nullable private final DataSpec adTagDataSpec; private final Handler mainHandler; private final Timeline.Period period; @@ -145,7 +146,10 @@ public RuntimeException getRuntimeExceptionForUnexpected() { * @param dataSourceFactory Factory for data sources used to load ad media. * @param adsLoader The loader for ads. * @param adViewProvider Provider of views for the ad UI. + * @deprecated Use {@link AdsMediaSource#AdsMediaSource(MediaSource, DataSpec, MediaSourceFactory, + * AdsLoader, AdsLoader.AdViewProvider)} instead. */ + @Deprecated public AdsMediaSource( MediaSource contentMediaSource, DataSource.Factory dataSourceFactory, @@ -155,7 +159,33 @@ public AdsMediaSource( contentMediaSource, new ProgressiveMediaSource.Factory(dataSourceFactory), adsLoader, - adViewProvider); + adViewProvider, + /* adTagDataSpec= */ null); + } + + /** + * Constructs a new source that inserts ads linearly with the content specified by {@code + * contentMediaSource}. + * + * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param adMediaSourceFactory Factory for media sources used to load ad media. + * @param adsLoader The loader for ads. + * @param adViewProvider Provider of views for the ad UI. + * @deprecated Use {@link AdsMediaSource#AdsMediaSource(MediaSource, DataSpec, MediaSourceFactory, + * AdsLoader, AdsLoader.AdViewProvider)} instead. + */ + @Deprecated + public AdsMediaSource( + MediaSource contentMediaSource, + MediaSourceFactory adMediaSourceFactory, + AdsLoader adsLoader, + AdsLoader.AdViewProvider adViewProvider) { + this( + contentMediaSource, + adMediaSourceFactory, + adsLoader, + adViewProvider, + /* adTagDataSpec= */ null); } /** @@ -163,19 +193,31 @@ public AdsMediaSource( * contentMediaSource}. * * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param adTagDataSpec The data specification of the ad tag to load. * @param adMediaSourceFactory Factory for media sources used to load ad media. * @param adsLoader The loader for ads. * @param adViewProvider Provider of views for the ad UI. */ public AdsMediaSource( MediaSource contentMediaSource, + DataSpec adTagDataSpec, MediaSourceFactory adMediaSourceFactory, AdsLoader adsLoader, AdsLoader.AdViewProvider adViewProvider) { + this(contentMediaSource, adMediaSourceFactory, adsLoader, adViewProvider, adTagDataSpec); + } + + private AdsMediaSource( + MediaSource contentMediaSource, + MediaSourceFactory adMediaSourceFactory, + AdsLoader adsLoader, + AdsLoader.AdViewProvider adViewProvider, + @Nullable DataSpec adTagDataSpec) { this.contentMediaSource = contentMediaSource; this.adMediaSourceFactory = adMediaSourceFactory; this.adsLoader = adsLoader; this.adViewProvider = adViewProvider; + this.adTagDataSpec = adTagDataSpec; mainHandler = new Handler(Looper.getMainLooper()); period = new Timeline.Period(); adMediaSourceHolders = new AdMediaSourceHolder[0][]; @@ -204,7 +246,13 @@ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferLis ComponentListener componentListener = new ComponentListener(); this.componentListener = componentListener; prepareChildSource(CHILD_SOURCE_MEDIA_PERIOD_ID, contentMediaSource); - mainHandler.post(() -> adsLoader.start(componentListener, adViewProvider)); + mainHandler.post( + () -> { + if (adTagDataSpec != null) { + adsLoader.setAdTagDataSpec(adTagDataSpec); + } + adsLoader.start(componentListener, adViewProvider); + }); } @Override 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 680ebbb2b1a..2c3670f52a6 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 @@ -59,7 +59,8 @@ public long open(DataSpec dataSpec) throws IOException { String dataString = uriParts[1]; if (uriParts[0].contains(";base64")) { try { - data = Base64.decode(dataString, 0); + // TODO(internal: b/169937045): Consider passing Base64.URL_SAFE flag. + data = Base64.decode(dataString, /* flags= */ Base64.DEFAULT); } catch (IllegalArgumentException e) { throw new ParserException("Error while parsing Base64 encoded string: " + dataString, e); } 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 1e21f25d3dd..b176d41b459 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 @@ -61,6 +61,7 @@ import com.google.android.exoplayer2.source.ClippingMediaSource; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.LoopingMediaSource; import com.google.android.exoplayer2.source.MaskingMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; @@ -101,7 +102,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocation; import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; @@ -5556,7 +5557,8 @@ public void setMediaSources_secondAdMediaSource_throws() throws Exception { AdsMediaSource adsMediaSource = new AdsMediaSource( new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), - new DefaultDataSourceFactory(context), + /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); Exception[] exception = {null}; @@ -5593,7 +5595,8 @@ public void setMediaSources_multipleMediaSourcesWithAd_throws() throws Exception AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory(context), + /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); final Exception[] exception = {null}; @@ -5632,7 +5635,8 @@ public void setMediaSources_addingMediaSourcesWithAdToNonEmptyPlaylist_throws() AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory(context), + /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); final Exception[] exception = {null}; @@ -8539,6 +8543,9 @@ public void release() {} @Override public void setSupportedContentTypes(int... contentTypes) {} + @Override + public void setAdTagDataSpec(DataSpec adTagDataSpec) {} + @Override public void start(AdsLoader.EventListener eventListener, AdViewProvider adViewProvider) {} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java index ab0bf954048..7a99b97bd5a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java @@ -145,6 +145,14 @@ public void malformedData() { } } + @Test + public void readSourceToEnd_readsEncodedString() throws Exception { + String data = "Some Data!<>:\"/\\|?*%"; + schemeDataDataSource.open(new DataSpec(Util.getDataUriForString("text/plain", data))); + + assertThat(Util.fromUtf8Bytes(Util.readToEnd(schemeDataDataSource))).isEqualTo(data); + } + private static DataSpec buildDataSpec(String uriString) { return buildDataSpec(uriString, /* position= */ 0, /* length= */ C.LENGTH_UNSET); } From 53f50f7c0e0751776be84521f5ded537f7f1f1dc Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 6 Oct 2020 14:24:37 +0100 Subject: [PATCH 116/693] Ignore negative payload size in TS PesReader Issue: #8005 PiperOrigin-RevId: 335625992 --- RELEASENOTES.md | 2 ++ .../exoplayer2/extractor/ts/PesReader.java | 22 +++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ef65c9aab1b..175bd4b671e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -44,6 +44,8 @@ * Add TS extractor parameter to configure the number of bytes in which to search for a timestamp to determine the duration and to seek. ([#7988](https://github.com/google/ExoPlayer/issues/7988)). + * Ignore negative payload size in PES packets + ([#8005](https://github.com/google/ExoPlayer/issues/8005)). * IMA extension: * Fix position reporting after fetch errors ([#7956](https://github.com/google/ExoPlayer/issues/7956)). diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java index 0764087b592..97fe7a73368 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java @@ -97,11 +97,11 @@ public final void consume(ParsableByteArray data, @Flags int flags) throws Parse Log.w(TAG, "Unexpected start indicator reading extended header"); break; case STATE_READING_BODY: - // If payloadSize == -1 then the length of the previous packet was unspecified, and so - // we only know that it's finished now that we've seen the start of the next one. This - // is expected. If payloadSize != -1, then the length of the previous packet was known, - // but we didn't receive that amount of data. This is not expected. - if (payloadSize != -1) { + // If payloadSize is unset then the length of the previous packet was unspecified, and so + // we only know that it's finished now that we've seen the start of the next one. This is + // expected. If payloadSize is set, then the length of the previous packet was known, but + // we didn't receive that amount of data. This is not expected. + if (payloadSize != C.LENGTH_UNSET) { Log.w(TAG, "Unexpected start indicator: expected " + payloadSize + " more bytes"); } // Either way, notify the reader that it has now finished. @@ -136,13 +136,13 @@ && continueRead(data, /* target= */ null, extendedHeaderLength)) { break; case STATE_READING_BODY: readLength = data.bytesLeft(); - int padding = payloadSize == -1 ? 0 : readLength - payloadSize; + int padding = payloadSize == C.LENGTH_UNSET ? 0 : readLength - payloadSize; if (padding > 0) { readLength -= padding; data.setLimit(data.getPosition() + readLength); } reader.consume(data); - if (payloadSize != -1) { + if (payloadSize != C.LENGTH_UNSET) { payloadSize -= readLength; if (payloadSize == 0) { reader.packetFinished(); @@ -191,7 +191,7 @@ private boolean parseHeader() { int startCodePrefix = pesScratch.readBits(24); if (startCodePrefix != 0x000001) { Log.w(TAG, "Unexpected start code prefix: " + startCodePrefix); - payloadSize = -1; + payloadSize = C.LENGTH_UNSET; return false; } @@ -208,10 +208,14 @@ private boolean parseHeader() { extendedHeaderLength = pesScratch.readBits(8); if (packetLength == 0) { - payloadSize = -1; + payloadSize = C.LENGTH_UNSET; } else { payloadSize = packetLength + 6 /* packetLength does not include the first 6 bytes */ - HEADER_SIZE - extendedHeaderLength; + if (payloadSize < 0) { + Log.w(TAG, "Found negative packet payload size: " + payloadSize); + payloadSize = C.LENGTH_UNSET; + } } return true; } From 008c80812b06384b416649196c7601543832cc13 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 6 Oct 2020 15:18:12 +0000 Subject: [PATCH 117/693] Enable release timeout by default and make config non-experimental. Using a timeout prevents ANRs in cases where the underlying platform gets blocked forever, so we enable this feature by default. Issue: #4352 PiperOrigin-RevId: 335642485 --- RELEASENOTES.md | 2 + .../google/android/exoplayer2/ExoPlayer.java | 45 +++++++++------ .../android/exoplayer2/ExoPlayerFactory.java | 1 + .../android/exoplayer2/ExoPlayerImpl.java | 17 +----- .../exoplayer2/ExoPlayerImplInternal.java | 55 ++++++------------- .../android/exoplayer2/SimpleExoPlayer.java | 21 +++++++ 6 files changed, 70 insertions(+), 71 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 175bd4b671e..b211d559618 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,6 +12,8 @@ ([#4463](https://github.com/google/ExoPlayer/issues/4463)). * Add a getter and callback for static metadata to the player ([#7266](https://github.com/google/ExoPlayer/issues/7266)). + * Timeout on release to prevent ANRs if the underlying platform call + is stuck ([#4352](https://github.com/google/ExoPlayer/issues/4352)). * Track selection: * Add option to specify multiple preferred audio or text languages. * Data sources: 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 ccb67866a41..f8fcec9571e 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 @@ -133,6 +133,12 @@ */ public interface ExoPlayer extends Player { + /** + * The default timeout for calls to {@link #release} and {@link #setForegroundMode}, in + * milliseconds. + */ + long DEFAULT_RELEASE_TIMEOUT_MS = 500; + /** * A builder for {@link ExoPlayer} instances. * @@ -152,9 +158,9 @@ final class Builder { private boolean useLazyPreparation; private SeekParameters seekParameters; private boolean pauseAtEndOfMediaItems; + private long releaseTimeoutMs; private boolean buildCalled; - private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; /** @@ -173,6 +179,7 @@ final class Builder { *

  • {@link AnalyticsCollector}: {@link AnalyticsCollector} with {@link Clock#DEFAULT} *
  • {@code useLazyPreparation}: {@code true} *
  • {@link SeekParameters}: {@link SeekParameters#DEFAULT} + *
  • {@code releaseTimeoutMs}: {@link ExoPlayer#DEFAULT_RELEASE_TIMEOUT_MS} *
  • {@code pauseAtEndOfMediaItems}: {@code false} *
  • {@link Clock}: {@link Clock#DEFAULT} * @@ -218,20 +225,7 @@ public Builder( seekParameters = SeekParameters.DEFAULT; clock = Clock.DEFAULT; throwWhenStuckBuffering = true; - } - - /** - * 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 experimentalSetReleaseTimeoutMs(long timeoutMs) { - releaseTimeoutMs = timeoutMs; - return this; + releaseTimeoutMs = DEFAULT_RELEASE_TIMEOUT_MS; } /** @@ -356,6 +350,23 @@ public Builder setSeekParameters(SeekParameters seekParameters) { return this; } + /** + * Sets a timeout for calls to {@link #release} and {@link #setForegroundMode}. + * + *

    If a call to {@link #release} or {@link #setForegroundMode} takes more than {@code + * timeoutMs} to complete, the player will report an error via {@link + * Player.EventListener#onPlayerError}. + * + * @param releaseTimeoutMs The release timeout, in milliseconds. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setReleaseTimeoutMs(long releaseTimeoutMs) { + Assertions.checkState(!buildCalled); + this.releaseTimeoutMs = releaseTimeoutMs; + return this; + } + /** * Sets whether to pause playback at the end of each media item. * @@ -407,13 +418,11 @@ public ExoPlayer build() { analyticsCollector, useLazyPreparation, seekParameters, + releaseTimeoutMs, pauseAtEndOfMediaItems, clock, looper); - if (releaseTimeoutMs > 0) { - player.experimentalSetReleaseTimeoutMs(releaseTimeoutMs); - } if (!throwWhenStuckBuffering) { player.experimentalDisableThrowWhenStuckBuffering(); } 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 dfe96ffa322..7ce6073ef30 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 @@ -256,6 +256,7 @@ public static ExoPlayer newInstance( /* analyticsCollector= */ null, /* useLazyPreparation= */ true, SeekParameters.DEFAULT, + ExoPlayer.DEFAULT_RELEASE_TIMEOUT_MS, /* 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 304635bb2c6..738748ff136 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 @@ -117,6 +117,7 @@ * 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 releaseTimeoutMs The timeout for calls to {@link #release()} in milliseconds. * @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 @@ -132,6 +133,7 @@ public ExoPlayerImpl( @Nullable AnalyticsCollector analyticsCollector, boolean useLazyPreparation, SeekParameters seekParameters, + long releaseTimeoutMs, boolean pauseAtEndOfMediaItems, Clock clock, Looper applicationLooper) { @@ -180,6 +182,7 @@ public ExoPlayerImpl( shuffleModeEnabled, analyticsCollector, seekParameters, + releaseTimeoutMs, pauseAtEndOfMediaItems, applicationLooper, clock, @@ -187,20 +190,6 @@ public ExoPlayerImpl( 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 experimentalSetReleaseTimeoutMs(long timeoutMs) { - internalPlayer.experimentalSetReleaseTimeoutMs(timeoutMs); - } - /** * Configures the player to not throw when it detects it's stuck buffering. * 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 94ffe2db6e1..875bf2dc443 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 @@ -178,6 +178,7 @@ public interface PlaybackInfoUpdateListener { private final PlaybackInfoUpdateListener playbackInfoUpdateListener; private final MediaPeriodQueue queue; private final MediaSourceList mediaSourceList; + private final long releaseTimeoutMs; @SuppressWarnings("unused") private SeekParameters seekParameters; @@ -202,7 +203,6 @@ public interface PlaybackInfoUpdateListener { private boolean deliverPendingMessageAtStartPositionRequired; @Nullable private ExoPlaybackException pendingRecoverableError; - private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; public ExoPlayerImplInternal( @@ -215,6 +215,7 @@ public ExoPlayerImplInternal( boolean shuffleModeEnabled, @Nullable AnalyticsCollector analyticsCollector, SeekParameters seekParameters, + long releaseTimeoutMs, boolean pauseAtEndOfWindow, Looper applicationLooper, Clock clock, @@ -228,6 +229,7 @@ public ExoPlayerImplInternal( this.repeatMode = repeatMode; this.shuffleModeEnabled = shuffleModeEnabled; this.seekParameters = seekParameters; + this.releaseTimeoutMs = releaseTimeoutMs; this.pauseAtEndOfWindow = pauseAtEndOfWindow; this.clock = clock; @@ -262,10 +264,6 @@ public ExoPlayerImplInternal( handler = clock.createHandler(playbackLooper, this); } - public void experimentalSetReleaseTimeoutMs(long releaseTimeoutMs) { - this.releaseTimeoutMs = releaseTimeoutMs; - } - public void experimentalDisableThrowWhenStuckBuffering() { throwWhenStuckBuffering = false; } @@ -374,6 +372,12 @@ public synchronized void sendMessage(PlayerMessage message) { handler.obtainMessage(MSG_SEND_MESSAGE, message).sendToTarget(); } + /** + * Sets the foreground mode. + * + * @param foregroundMode Whether foreground mode should be enabled. + * @return Whether the operations succeeded. If false, the operation timed out. + */ public synchronized boolean setForegroundMode(boolean foregroundMode) { if (released || !internalPlaybackThread.isAlive()) { return true; @@ -386,26 +390,22 @@ public synchronized boolean setForegroundMode(boolean foregroundMode) { handler .obtainMessage(MSG_SET_FOREGROUND_MODE, /* foregroundMode */ 0, 0, processedFlag) .sendToTarget(); - if (releaseTimeoutMs > 0) { - waitUninterruptibly(/* condition= */ processedFlag::get, releaseTimeoutMs); - } else { - waitUninterruptibly(/* condition= */ processedFlag::get); - } + waitUninterruptibly(/* condition= */ processedFlag::get, releaseTimeoutMs); return processedFlag.get(); } } + /** + * Releases the player. + * + * @return Whether the release succeeded. If false, the release timed out. + */ public synchronized boolean release() { if (released || !internalPlaybackThread.isAlive()) { return true; } - handler.sendEmptyMessage(MSG_RELEASE); - if (releaseTimeoutMs > 0) { - waitUninterruptibly(/* condition= */ () -> released, releaseTimeoutMs); - } else { - waitUninterruptibly(/* condition= */ () -> released); - } + waitUninterruptibly(/* condition= */ () -> released, releaseTimeoutMs); return released; } @@ -606,29 +606,6 @@ private void attemptErrorRecovery(ExoPlaybackException exceptionToRecoverFrom) } } - /** - * Blocks the current thread until a condition becomes true. - * - *

    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. - * - * @param condition The condition. - */ - private synchronized void waitUninterruptibly(Supplier condition) { - boolean wasInterrupted = false; - while (!condition.get()) { - try { - wait(); - } catch (InterruptedException e) { - wasInterrupted = true; - } - } - if (wasInterrupted) { - // Restore the interrupted status. - Thread.currentThread().interrupt(); - } - } - /** * Blocks the current thread until a condition becomes true or the specified amount of time has * elapsed. 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 088bfad0d2e..4d7fa8c67f9 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 @@ -110,6 +110,7 @@ public static final class Builder { @Renderer.VideoScalingMode private int videoScalingMode; private boolean useLazyPreparation; private SeekParameters seekParameters; + private long releaseTimeoutMs; private boolean pauseAtEndOfMediaItems; private boolean throwWhenStuckBuffering; private boolean buildCalled; @@ -143,6 +144,7 @@ public static final class Builder { *

  • {@link Renderer.VideoScalingMode}: {@link Renderer#VIDEO_SCALING_MODE_DEFAULT} *
  • {@code useLazyPreparation}: {@code true} *
  • {@link SeekParameters}: {@link SeekParameters#DEFAULT} + *
  • {@code releaseTimeoutMs}: {@link ExoPlayer#DEFAULT_RELEASE_TIMEOUT_MS} *
  • {@code pauseAtEndOfMediaItems}: {@code false} *
  • {@link Clock}: {@link Clock#DEFAULT} * @@ -240,6 +242,7 @@ public Builder( seekParameters = SeekParameters.DEFAULT; clock = Clock.DEFAULT; throwWhenStuckBuffering = true; + releaseTimeoutMs = ExoPlayer.DEFAULT_RELEASE_TIMEOUT_MS; } /** @@ -456,6 +459,23 @@ public Builder setSeekParameters(SeekParameters seekParameters) { return this; } + /** + * Sets a timeout for calls to {@link #release} and {@link #setForegroundMode}. + * + *

    If a call to {@link #release} or {@link #setForegroundMode} takes more than {@code + * timeoutMs} to complete, the player will report an error via {@link + * Player.EventListener#onPlayerError}. + * + * @param releaseTimeoutMs The release timeout, in milliseconds. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setReleaseTimeoutMs(long releaseTimeoutMs) { + Assertions.checkState(!buildCalled); + this.releaseTimeoutMs = releaseTimeoutMs; + return this; + } + /** * Sets whether to pause playback at the end of each media item. * @@ -631,6 +651,7 @@ protected SimpleExoPlayer(Builder builder) { analyticsCollector, builder.useLazyPreparation, builder.seekParameters, + builder.releaseTimeoutMs, builder.pauseAtEndOfMediaItems, builder.clock, builder.looper); From ac782235caca22139cd5f2ef7130622f1d62c20d Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 6 Oct 2020 15:20:54 +0000 Subject: [PATCH 118/693] Fix miscellaneous nits/typos PiperOrigin-RevId: 335642909 --- .../google/android/exoplayer2/demo/PlayerActivity.java | 2 +- .../exoplayer2/ext/media2/DefaultMediaItemConverter.java | 2 +- .../android/exoplayer2/ext/media2/MediaItemConverter.java | 4 ++-- .../java/com/google/android/exoplayer2/ExoPlayerImpl.java | 2 +- .../com/google/android/exoplayer2/MediaPeriodQueue.java | 8 ++++---- .../android/exoplayer2/source/ProgressiveMediaSource.java | 2 +- .../exoplayer2/trackselection/MappingTrackSelector.java | 5 +++-- .../android/exoplayer2/trackselection/TrackSelector.java | 2 +- .../exoplayer2/trackselection/TrackSelectorResult.java | 7 ++++--- .../exoplayer2/trackselection/TrackSelectorTest.java | 3 ++- .../exoplayer2/upstream/DefaultBandwidthMeterTest.java | 2 +- .../exoplayer2/video/MediaCodecVideoRendererTest.java | 4 +--- .../com/google/android/exoplayer2/ui/SubtitleView.java | 1 - 13 files changed, 22 insertions(+), 22 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 739e89ab867..c35080c47fa 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 @@ -102,7 +102,7 @@ public class PlayerActivity extends AppCompatActivity private int startWindow; private long startPosition; - // Fields used only for ad playback. The ads loader is loaded via reflection. + // Fields used only for ad playback. private AdsLoader adsLoader; private Uri loadedAdTagUri; diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java index c23bdd56692..e6d4550d884 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java @@ -45,7 +45,7 @@ public MediaItem convertToExoPlayerMediaItem(androidx.media2.common.MediaItem me if (media2MediaItem instanceof CallbackMediaItem) { throw new IllegalStateException("CallbackMediaItem isn't supported"); } - + @Nullable Uri uri = null; @Nullable String mediaId = null; @Nullable String title = null; diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java index 218c2a737e5..99b284af3c7 100644 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java @@ -23,13 +23,13 @@ */ public interface MediaItemConverter { /** - * Converts an {@link androidx.media2.common.MediaItem Media2 MediaItem} to an {@link MediaItem + * Converts a {@link androidx.media2.common.MediaItem Media2 MediaItem} to an {@link MediaItem * ExoPlayer MediaItem}. */ MediaItem convertToExoPlayerMediaItem(androidx.media2.common.MediaItem media2MediaItem); /** - * Converts an {@link MediaItem ExoPlayer MediaItem} to an {@link androidx.media2.common.MediaItem + * Converts an {@link MediaItem ExoPlayer MediaItem} to a {@link androidx.media2.common.MediaItem * Media2 MediaItem}. */ androidx.media2.common.MediaItem convertToMedia2MediaItem(MediaItem exoPlayerMediaItem); 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 738748ff136..f2e1aff1af5 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 @@ -157,7 +157,7 @@ public ExoPlayerImpl( new TrackSelectorResult( new RendererConfiguration[renderers.length], new TrackSelection[renderers.length], - null); + /* info= */ null); period = new Timeline.Period(); maskingWindowIndex = C.INDEX_UNSET; playbackInfoUpdateHandler = new Handler(applicationLooper); 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 b64a9c8087a..fa6201bf37f 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 @@ -315,8 +315,8 @@ public void clear() { public boolean updateQueuedPeriods( Timeline timeline, long rendererPositionUs, long maxRendererReadPositionUs) { // TODO: Merge this into setTimeline so that the queue gets updated as soon as the new timeline - // is set, once all cases handled by ExoPlayerImplInternal.handleSourceInfoRefreshed can be - // handled here. + // is set, once all cases handled by ExoPlayerImplInternal.handleMediaSourceListInfoRefreshed + // can be handled here. MediaPeriodHolder previousPeriodHolder = null; MediaPeriodHolder periodHolder = playing; while (periodHolder != null) { @@ -326,8 +326,8 @@ public boolean updateQueuedPeriods( MediaPeriodInfo newPeriodInfo; if (previousPeriodHolder == null) { // The id and start position of the first period have already been verified by - // ExoPlayerImplInternal.handleSourceInfoRefreshed. Just update duration, isLastInTimeline - // and isLastInPeriod flags. + // ExoPlayerImplInternal.handleMediaSourceListInfoRefreshed. Just update duration, + // isLastInTimeline and isLastInPeriod flags. newPeriodInfo = getUpdatedMediaPeriodInfo(timeline, oldPeriodInfo); } else { newPeriodInfo = 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 4d7230cc3ae..19f09fde22b 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 @@ -340,7 +340,7 @@ private void notifySourceInfoRefreshed() { /* manifest= */ null, mediaItem); if (timelineIsPlaceholder) { - // TODO: Actually prepare the extractors during prepatation so that we don't need a + // TODO: Actually prepare the extractors during preparation so that we don't need a // placeholder. See https://github.com/google/ExoPlayer/issues/4727. timeline = new ForwardingTimeline(timeline) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java index 9949a370ede..16c63353ee4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java @@ -339,14 +339,15 @@ public TrackGroupArray getUnmappedTrackGroups() { * Returns the mapping information for the currently active track selection, or null if no * selection is currently active. */ - public final @Nullable MappedTrackInfo getCurrentMappedTrackInfo() { + @Nullable + public final MappedTrackInfo getCurrentMappedTrackInfo() { return currentMappedTrackInfo; } // TrackSelector implementation. @Override - public final void onSelectionActivated(Object info) { + public final void onSelectionActivated(@Nullable Object info) { currentMappedTrackInfo = (MappedTrackInfo) info; } 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 8ee9d29d3d2..59c5d5447bf 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 @@ -137,7 +137,7 @@ public abstract TrackSelectorResult selectTracks( * * @param info The value of {@link TrackSelectorResult#info} in the activated selection. */ - public abstract void onSelectionActivated(Object info); + public abstract void onSelectionActivated(@Nullable Object info); /** * Calls {@link InvalidationListener#onTrackSelectionsInvalidated()} to invalidate all previously diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java index 9228f3af628..67623c2cf6b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java @@ -40,19 +40,20 @@ public final class TrackSelectorResult { * An opaque object that will be returned to {@link TrackSelector#onSelectionActivated(Object)} * should the selections be activated. */ - public final Object info; + @Nullable public final Object info; /** * @param rendererConfigurations A {@link RendererConfiguration} for each renderer. A null entry * indicates the corresponding renderer should be disabled. * @param selections A {@link TrackSelectionArray} containing the selection for each renderer. * @param info An opaque object that will be returned to {@link - * TrackSelector#onSelectionActivated(Object)} should the selection be activated. + * TrackSelector#onSelectionActivated(Object)} should the selection be activated. May be + * {@code null}. */ public TrackSelectorResult( @NullableType RendererConfiguration[] rendererConfigurations, @NullableType TrackSelection[] selections, - Object info) { + @Nullable Object info) { this.rendererConfigurations = rendererConfigurations; this.selections = new TrackSelectionArray(selections); this.info = info; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/TrackSelectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/TrackSelectorTest.java index 477f7226a46..f4073317112 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/TrackSelectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/TrackSelectorTest.java @@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.RendererCapabilities; @@ -52,7 +53,7 @@ public TrackSelectorResult selectTracks( } @Override - public void onSelectionActivated(Object info) {} + public void onSelectionActivated(@Nullable Object info) {} }; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java index 23f5a17e93f..0b807c487a4 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java @@ -107,7 +107,7 @@ public void setUp() { /* isAvailable= */ true, CONNECTED); } - + @Test public void defaultInitialBitrateEstimate_forWifi_isGreaterThanEstimateFor2G() { setActiveNetworkInfo(networkInfoWifi); 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 4ba5eb34b1f..74d110516b1 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 @@ -46,7 +46,6 @@ 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; @@ -107,8 +106,7 @@ public void setUp() throws Exception { /* maxDroppedFramesToNotify= */ 1) { @Override @Capabilities - protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) - throws DecoderQueryException { + protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) { return RendererCapabilities.create(FORMAT_HANDLED); } 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 452be5a3b77..bfd18aead76 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 @@ -414,5 +414,4 @@ private Cue removeEmbeddedStyling(Cue cue) { return cue; } - } From ed163db1c16ef980aff7853ec36b049031a22133 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 6 Oct 2020 16:10:17 +0000 Subject: [PATCH 119/693] Enable detach surface timeout by default. Experiments showed the timeout is beneficial to avoid ANRs and we can thus enable the feature by default. Also add configuration to set the timeout if required. Issue: #5887 PiperOrigin-RevId: 335652506 --- RELEASENOTES.md | 5 ++- .../exoplayer2/ExoPlaybackException.java | 11 +++-- .../android/exoplayer2/ExoPlayerImpl.java | 25 ++++++++--- .../android/exoplayer2/PlayerMessage.java | 42 +++++++++---------- .../android/exoplayer2/SimpleExoPlayer.java | 34 ++++++++++++++- .../android/exoplayer2/PlayerMessageTest.java | 24 +++++------ 6 files changed, 94 insertions(+), 47 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b211d559618..7b7b842dfd4 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,8 +12,11 @@ ([#4463](https://github.com/google/ExoPlayer/issues/4463)). * Add a getter and callback for static metadata to the player ([#7266](https://github.com/google/ExoPlayer/issues/7266)). - * Timeout on release to prevent ANRs if the underlying platform call + * Time out on release to prevent ANRs if the underlying platform call is stuck ([#4352](https://github.com/google/ExoPlayer/issues/4352)). + * Time out when detaching a surface to prevent ANRs if the underlying + platform call is stuck + ([#5887](https://github.com/google/ExoPlayer/issues/5887)). * Track selection: * Add option to specify multiple preferred audio or text languages. * Data sources: 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 d69b747f8dc..369235cbdac 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 @@ -83,16 +83,17 @@ public final class ExoPlaybackException extends Exception { /** * The operation which produced the timeout error. One of {@link #TIMEOUT_OPERATION_RELEASE}, - * {@link #TIMEOUT_OPERATION_SET_FOREGROUND_MODE} or {@link #TIMEOUT_OPERATION_UNDEFINED}. Note - * that new operations may be added in the future and error handling should handle unknown - * operation values. + * {@link #TIMEOUT_OPERATION_SET_FOREGROUND_MODE}, {@link #TIMEOUT_OPERATION_DETACH_SURFACE} or + * {@link #TIMEOUT_OPERATION_UNDEFINED}. Note that new operations may be added in the future and + * error handling should handle unknown operation values. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef({ TIMEOUT_OPERATION_UNDEFINED, TIMEOUT_OPERATION_RELEASE, - TIMEOUT_OPERATION_SET_FOREGROUND_MODE + TIMEOUT_OPERATION_SET_FOREGROUND_MODE, + TIMEOUT_OPERATION_DETACH_SURFACE }) public @interface TimeoutOperation {} @@ -102,6 +103,8 @@ public final class ExoPlaybackException extends Exception { public static final int TIMEOUT_OPERATION_RELEASE = 1; /** The error occurred in {@link ExoPlayer#setForegroundMode}. */ public static final int TIMEOUT_OPERATION_SET_FOREGROUND_MODE = 2; + /** The error occurred while detaching a surface from the player. */ + public static final int TIMEOUT_OPERATION_DETACH_SURFACE = 3; /** If {@link #type} is {@link #TYPE_RENDERER}, this is the name of the renderer. */ @Nullable public final String rendererName; 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 f2e1aff1af5..a435684d695 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 @@ -656,18 +656,28 @@ public void setForegroundMode(boolean foregroundMode) { if (this.foregroundMode != foregroundMode) { this.foregroundMode = foregroundMode; if (!internalPlayer.setForegroundMode(foregroundMode)) { - notifyListeners( - listener -> - listener.onPlayerError( - ExoPlaybackException.createForTimeout( - new TimeoutException("Setting foreground mode timed out."), - ExoPlaybackException.TIMEOUT_OPERATION_SET_FOREGROUND_MODE))); + stop( + /* reset= */ false, + ExoPlaybackException.createForTimeout( + new TimeoutException("Setting foreground mode timed out."), + ExoPlaybackException.TIMEOUT_OPERATION_SET_FOREGROUND_MODE)); } } } @Override public void stop(boolean reset) { + stop(reset, /* error= */ null); + } + + /** + * Stops the player. + * + * @param reset Whether the playlist should be cleared and whether the playback position and + * playback error should be reset. + * @param error An optional {@link ExoPlaybackException} to set. + */ + public void stop(boolean reset, @Nullable ExoPlaybackException error) { PlaybackInfo playbackInfo; if (reset) { playbackInfo = @@ -680,6 +690,9 @@ public void stop(boolean reset) { playbackInfo.totalBufferedDurationUs = 0; } playbackInfo = playbackInfo.copyWithPlaybackState(Player.STATE_IDLE); + if (error != null) { + playbackInfo = playbackInfo.copyWithPlaybackError(error); + } pendingOperationAcks++; internalPlayer.stop(); updatePlaybackInfo( 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 7e2cb69bc69..6f81a35dd82 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 @@ -269,6 +269,20 @@ public synchronized boolean isCanceled() { return isCanceled; } + /** + * Marks the message as processed. Should only be called by a {@link Sender} and may be called + * multiple times. + * + * @param isDelivered Whether the message has been delivered to its target. The message is + * considered as being delivered when this method has been called with {@code isDelivered} set + * to true at least once. + */ + public synchronized void markAsProcessed(boolean isDelivered) { + this.isDelivered |= isDelivered; + isProcessed = true; + notifyAll(); + } + /** * Blocks until after the message has been delivered or the player is no longer able to deliver * the message. @@ -292,44 +306,30 @@ public synchronized boolean blockUntilDelivered() throws InterruptedException { return isDelivered; } - /** - * Marks the message as processed. Should only be called by a {@link Sender} and may be called - * multiple times. - * - * @param isDelivered Whether the message has been delivered to its target. The message is - * considered as being delivered when this method has been called with {@code isDelivered} set - * to true at least once. - */ - public synchronized void markAsProcessed(boolean isDelivered) { - this.isDelivered |= isDelivered; - isProcessed = true; - notifyAll(); - } - /** * 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. + * the message or the specified timeout elapsed. * *

    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. + * @param timeoutMs The timeout 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 TimeoutException If the {@code timeoutMs} 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 experimentalBlockUntilDelivered(long timeoutMs) + public synchronized boolean blockUntilDelivered(long timeoutMs) throws InterruptedException, TimeoutException { - return experimentalBlockUntilDelivered(timeoutMs, Clock.DEFAULT); + return blockUntilDelivered(timeoutMs, Clock.DEFAULT); } @VisibleForTesting() - /* package */ synchronized boolean experimentalBlockUntilDelivered(long timeoutMs, Clock clock) + /* package */ synchronized boolean blockUntilDelivered(long timeoutMs, Clock clock) throws InterruptedException, TimeoutException { Assertions.checkState(isSent); Assertions.checkState(handler.getLooper().getThread() != Thread.currentThread()); 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 4d7fa8c67f9..7f5384442e4 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 @@ -67,6 +67,7 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.TimeoutException; /** * An {@link ExoPlayer} implementation that uses default {@link Renderer} components. Instances can @@ -80,6 +81,9 @@ public class SimpleExoPlayer extends BasePlayer Player.MetadataComponent, Player.DeviceComponent { + /** The default timeout for detaching a surface from the player, in milliseconds. */ + public static final long DEFAULT_DETACH_SURFACE_TIMEOUT_MS = 2_000; + /** @deprecated Use {@link com.google.android.exoplayer2.video.VideoListener}. */ @Deprecated public interface VideoListener extends com.google.android.exoplayer2.video.VideoListener {} @@ -111,6 +115,7 @@ public static final class Builder { private boolean useLazyPreparation; private SeekParameters seekParameters; private long releaseTimeoutMs; + private long detachSurfaceTimeoutMs; private boolean pauseAtEndOfMediaItems; private boolean throwWhenStuckBuffering; private boolean buildCalled; @@ -145,6 +150,7 @@ public static final class Builder { *

  • {@code useLazyPreparation}: {@code true} *
  • {@link SeekParameters}: {@link SeekParameters#DEFAULT} *
  • {@code releaseTimeoutMs}: {@link ExoPlayer#DEFAULT_RELEASE_TIMEOUT_MS} + *
  • {@code detachSurfaceTimeoutMs}: {@link #DEFAULT_DETACH_SURFACE_TIMEOUT_MS} *
  • {@code pauseAtEndOfMediaItems}: {@code false} *
  • {@link Clock}: {@link Clock#DEFAULT} * @@ -243,6 +249,7 @@ public Builder( clock = Clock.DEFAULT; throwWhenStuckBuffering = true; releaseTimeoutMs = ExoPlayer.DEFAULT_RELEASE_TIMEOUT_MS; + detachSurfaceTimeoutMs = DEFAULT_DETACH_SURFACE_TIMEOUT_MS; } /** @@ -476,6 +483,23 @@ public Builder setReleaseTimeoutMs(long releaseTimeoutMs) { return this; } + /** + * Sets a timeout for detaching a surface from the player. + * + *

    If detaching a surface or replacing a surface takes more than {@code + * detachSurfaceTimeoutMs} to complete, the player will report an error via {@link + * Player.EventListener#onPlayerError}. + * + * @param detachSurfaceTimeoutMs The timeout for detaching a surface, in milliseconds. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setDetachSurfaceTimeoutMs(long detachSurfaceTimeoutMs) { + Assertions.checkState(!buildCalled); + this.detachSurfaceTimeoutMs = detachSurfaceTimeoutMs; + return this; + } + /** * Sets whether to pause playback at the end of each media item. * @@ -557,6 +581,7 @@ public SimpleExoPlayer build() { private final StreamVolumeManager streamVolumeManager; private final WakeLockManager wakeLockManager; private final WifiLockManager wifiLockManager; + private final long detachSurfaceTimeoutMs; @Nullable private Format videoFormat; @Nullable private Format audioFormat; @@ -617,6 +642,7 @@ protected SimpleExoPlayer(Builder builder) { audioAttributes = builder.audioAttributes; videoScalingMode = builder.videoScalingMode; skipSilenceEnabled = builder.skipSilenceEnabled; + detachSurfaceTimeoutMs = builder.detachSurfaceTimeoutMs; componentListener = new ComponentListener(); videoListeners = new CopyOnWriteArraySet<>(); audioListeners = new CopyOnWriteArraySet<>(); @@ -2019,10 +2045,16 @@ private void setVideoSurfaceInternal(@Nullable Surface surface, boolean ownsSurf // We're replacing a surface. Block to ensure that it's not accessed after the method returns. try { for (PlayerMessage message : messages) { - message.blockUntilDelivered(); + message.blockUntilDelivered(detachSurfaceTimeoutMs); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); + } catch (TimeoutException e) { + player.stop( + /* reset= */ false, + ExoPlaybackException.createForTimeout( + new TimeoutException("Detaching surface timed out."), + ExoPlaybackException.TIMEOUT_OPERATION_DETACH_SURFACE)); } // If we created the previous surface, we are responsible for releasing it. if (this.ownsSurface) { 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 index 490cc520fe7..70fd5445e18 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java @@ -17,7 +17,7 @@ import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.SECONDS; -import static org.junit.Assert.fail; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -66,31 +66,27 @@ public void tearDown() { } @Test - public void experimentalBlockUntilDelivered_timesOut() throws Exception { + public void blockUntilDelivered_timesOut() throws Exception { when(clock.elapsedRealtime()).thenReturn(0L).thenReturn(TIMEOUT_MS * 2); - try { - message.send().experimentalBlockUntilDelivered(TIMEOUT_MS, clock); - fail(); - } catch (TimeoutException expected) { - } + assertThrows( + TimeoutException.class, () -> message.send().blockUntilDelivered(TIMEOUT_MS, clock)); - // Ensure experimentalBlockUntilDelivered() entered the blocking loop + // Ensure blockUntilDelivered() entered the blocking loop. verify(clock, Mockito.times(2)).elapsedRealtime(); } @Test - public void experimentalBlockUntilDelivered_onAlreadyProcessed_succeeds() throws Exception { + public void blockUntilDelivered_onAlreadyProcessed_succeeds() throws Exception { when(clock.elapsedRealtime()).thenReturn(0L); message.send().markAsProcessed(/* isDelivered= */ true); - assertThat(message.experimentalBlockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + assertThat(message.blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); } @Test - public void experimentalBlockUntilDelivered_markAsProcessedWhileBlocked_succeeds() - throws Exception { + public void blockUntilDelivered_markAsProcessedWhileBlocked_succeeds() throws Exception { message.send(); // Use a separate Thread to mark the message as processed. @@ -114,8 +110,8 @@ public void experimentalBlockUntilDelivered_markAsProcessedWhileBlocked_succeeds }); try { - assertThat(message.experimentalBlockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); - // Ensure experimentalBlockUntilDelivered() entered the blocking loop. + assertThat(message.blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + // Ensure blockUntilDelivered() entered the blocking loop. verify(clock, Mockito.atLeast(2)).elapsedRealtime(); future.get(1, SECONDS); } finally { From 9753c3fcfb2e2e7fd878666c3f8aa11cbee9728f Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 6 Oct 2020 19:31:55 +0100 Subject: [PATCH 120/693] Add back copybara stripping PiperOrigin-RevId: 335683390 --- .../core/src/main/java/com/google/android/exoplayer2/Player.java | 1 - 1 file changed, 1 deletion(-) 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 16656029dbb..6ee35b25e18 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 @@ -35,7 +35,6 @@ import com.google.android.exoplayer2.text.TextOutput; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; -import com.google.android.exoplayer2.util.StableApiCandidate; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer; import com.google.android.exoplayer2.video.VideoFrameMetadataListener; From 64d5be8719ae9a1fdc0c958f6ebfaa914fb20833 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 7 Oct 2020 10:02:42 +0100 Subject: [PATCH 121/693] Fix HLS chunkful preparation bug affecting certain master playlists The bug affects playlists that start with an I-FRAME only variant. Issue: #8025 PiperOrigin-RevId: 335819497 --- RELEASENOTES.md | 4 ++++ .../google/android/exoplayer2/source/hls/HlsChunkSource.java | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7b7b842dfd4..2b460aa4a9c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -51,6 +51,10 @@ ([#7988](https://github.com/google/ExoPlayer/issues/7988)). * Ignore negative payload size in PES packets ([#8005](https://github.com/google/ExoPlayer/issues/8005)). +* HLS: + * Fix crash affecting chunkful preparation of master playlists that start + with an I-FRAME only variant + ([#8025](https://github.com/google/ExoPlayer/issues/8025)). * IMA extension: * Fix position reporting after fetch errors ([#7956](https://github.com/google/ExoPlayer/issues/7956)). 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 530d56fa9c8..2ab4852339b 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 @@ -592,7 +592,9 @@ private static final class InitializationTrackSelection extends BaseTrackSelecti public InitializationTrackSelection(TrackGroup group, int[] tracks) { super(group, tracks); - selectedIndex = indexOf(group.getFormat(0)); + // The initially selected index corresponds to the first EXT-X-STREAMINF tag in the master + // playlist. + selectedIndex = indexOf(group.getFormat(tracks[0])); } @Override From 0a9b11d3dd0ffeab5ae1e3bd1a29d94e22ae24fa Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 7 Oct 2020 13:30:05 +0100 Subject: [PATCH 122/693] Make resetPosition reset the position if true Issue: #8024 PiperOrigin-RevId: 335846035 --- .../exoplayer2/ext/cast/CastPlayer.java | 7 ++++ .../google/android/exoplayer2/BasePlayer.java | 6 --- .../android/exoplayer2/ExoPlayerImpl.java | 5 +++ .../android/exoplayer2/ExoPlayerTest.java | 37 +++++++++++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) 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 da42778c348..020b71c692f 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 @@ -309,6 +309,13 @@ public void removeListener(EventListener listener) { } } + @Override + public void setMediaItems(List mediaItems, boolean resetPosition) { + int windowIndex = resetPosition ? 0 : getCurrentWindowIndex(); + long startPositionMs = resetPosition ? C.TIME_UNSET : getContentPosition(); + setMediaItems(mediaItems, windowIndex, startPositionMs); + } + @Override public void setMediaItems( List mediaItems, int startWindowIndex, long startPositionMs) { 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 9d7af2dce61..4f89925121f 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 @@ -44,12 +44,6 @@ public void setMediaItem(MediaItem mediaItem, boolean resetPosition) { setMediaItems(Collections.singletonList(mediaItem), resetPosition); } - @Override - public void setMediaItems(List mediaItems, boolean resetPosition) { - setMediaItems( - mediaItems, /* startWindowIndex= */ C.INDEX_UNSET, /* startPositionMs= */ C.TIME_UNSET); - } - @Override public void setMediaItems(List mediaItems) { setMediaItems(mediaItems, /* resetPosition= */ true); 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 a435684d695..4167307da33 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 @@ -338,6 +338,11 @@ public void prepare(MediaSource mediaSource, boolean resetPosition, boolean rese prepare(); } + @Override + public void setMediaItems(List mediaItems, boolean resetPosition) { + setMediaSources(createMediaSources(mediaItems), resetPosition); + } + @Override public void setMediaItems( List mediaItems, int startWindowIndex, long startPositionMs) { 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 b176d41b459..fa31d1a4f47 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 @@ -109,6 +109,7 @@ import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -5706,6 +5707,42 @@ public void run(SimpleExoPlayer player) { assertArrayEquals(new int[] {0, 0, 0}, currentWindowIndices); } + @Test + public void setMediaItems_resetPosition_resetsPosition() throws Exception { + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] currentPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1000); + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + List listOfTwo = + Lists.newArrayList( + MediaItem.fromUri(Uri.EMPTY), MediaItem.fromUri(Uri.EMPTY)); + player.setMediaItems(listOfTwo, /* resetPosition= */ true); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPositions[1] = player.getCurrentPosition(); + } + }) + .prepare() + .waitForTimelineChanged() + .play() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + assertArrayEquals(new long[] {1000, 0}, currentPositions); + } + @Test public void setMediaSources_empty_whenEmpty_validInitialSeek_correctMaskingWindowIndex() throws Exception { From c898e71908d92a5e845342cd07a4401df6bbc0af Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 7 Oct 2020 20:59:12 +0100 Subject: [PATCH 123/693] Read until the track formats are available in TestUtil.extractSeekMap() Otherwise, some extractor tests are seeking without making sure that the extractor has retrieved the formats. This is needed for PR Issue: #7378. PiperOrigin-RevId: 335934326 --- .../google/android/exoplayer2/testutil/TestUtil.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 f2ead0485f9..7107c0b8a4a 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 @@ -297,7 +297,8 @@ public static Uri buildAssetUri(String assetPath) { /** * Reads from the given input using the given {@link Extractor}, until it can produce the {@link - * SeekMap} and all of the tracks have been identified, or until the extractor encounters EOF. + * SeekMap} and all of the track formats have been identified, or until the extractor encounters + * EOF. * * @param extractor The {@link Extractor} to extractor from input. * @param output The {@link FakeTrackOutput} to store the extracted {@link SeekMap} and track. @@ -316,11 +317,18 @@ public static SeekMap extractSeekMap( int readResult = Extractor.RESULT_CONTINUE; while (true) { try { - // Keep reading until we can get the seek map + // Keep reading until we get the seek map and the track information. while (readResult == Extractor.RESULT_CONTINUE && (output.seekMap == null || !output.tracksEnded)) { readResult = extractor.read(input, positionHolder); } + for (int i = 0; i < output.trackOutputs.size(); i++) { + int trackId = output.trackOutputs.keyAt(i); + while (readResult == Extractor.RESULT_CONTINUE + && output.trackOutputs.get(trackId).lastFormat == null) { + readResult = extractor.read(input, positionHolder); + } + } } finally { Util.closeQuietly(dataSource); } From 7228b2d718fbae4cc46a7a592918d60264d4acc3 Mon Sep 17 00:00:00 2001 From: insun Date: Thu, 8 Oct 2020 08:04:01 +0100 Subject: [PATCH 124/693] Show overflow button only when there is no enough space Previously, the overflow button was always shown at the bottom in StyledPlayerControlView and hided the settings cog even when there is enough space. With this change, the settings cog moves out from overflow and the overflow button is shown only when the buttom space is not enough. PiperOrigin-RevId: 336029179 --- RELEASENOTES.md | 6 +++ .../StyledPlayerControlViewLayoutManager.java | 38 +++++++++++-------- .../layout/exo_styled_player_control_view.xml | 7 ++-- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2b460aa4a9c..99277376129 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -56,6 +56,7 @@ with an I-FRAME only variant ([#8025](https://github.com/google/ExoPlayer/issues/8025)). * IMA extension: + * Fix position reporting after fetch errors ([#7956](https://github.com/google/ExoPlayer/issues/7956)). * Allow apps to specify a `VideoAdPlayerCallback` @@ -66,6 +67,11 @@ This is in preparation for supporting ads in playlists ([#3750](https://github.com/google/ExoPlayer/issues/3750)). +* UI: + + * Show overflow button in `StyledPlayerControlView` only when there is no + enough space. + ### 2.12.0 (2020-09-11) ### To learn more about what's new in 2.12, read the corresponding 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 9435d2b5ba2..f91271b439d 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 @@ -677,26 +677,26 @@ private void onLayoutWidthChanged() { - styledPlayerControlView.getPaddingLeft() - styledPlayerControlView.getPaddingRight() : 0); - int basicBottomBarWidth = getWidth(timeView); + int bottomBarWidth = getWidth(timeView); for (int i = 0; i < basicControls.getChildCount(); ++i) { - basicBottomBarWidth += basicControls.getChildAt(i).getWidth(); + bottomBarWidth += 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 + if (bottomBarWidth > width) { + if (overflowShowButton != null && overflowShowButton.getVisibility() != View.VISIBLE) { + overflowShowButton.setVisibility(View.VISIBLE); + bottomBarWidth += overflowShowButton.getWidth(); + } + // Move control views from basicControls to extraControls ArrayList movingChildren = new ArrayList<>(); int movingWidth = 0; - int endIndex = basicControls.getChildCount() - minBasicControlsChildCount; + // The last child is overflow show button which shouldn't move. + int endIndex = basicControls.getChildCount() - 1; for (int index = 0; index < endIndex; index++) { View child = basicControls.getChildAt(index); movingWidth += child.getWidth(); movingChildren.add(child); - if (basicBottomBarWidth - movingWidth <= width) { + if (bottomBarWidth - movingWidth <= width) { break; } } @@ -705,7 +705,9 @@ private void onLayoutWidthChanged() { basicControls.removeViews(0, movingChildren.size()); for (View child : movingChildren) { - int index = extraControls.getChildCount() - minExtraControlsChildCount; + // The last child of extra controls should be overflow hide button. + // Adding other buttons before it. + int index = extraControls.getChildCount() - 1; extraControls.addView(child, index); } } @@ -714,23 +716,27 @@ private void onLayoutWidthChanged() { // 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--) { + // The last child of extra controls is overflow button and it should not move. + int endIndex = extraControls.getChildCount() - 2; + for (int index = endIndex; index >= 0; index--) { View child = extraControls.getChildAt(index); movingWidth += child.getWidth(); - if (basicBottomBarWidth + movingWidth > width) { + if (bottomBarWidth + movingWidth > width) { break; } movingChildren.add(child); } if (!movingChildren.isEmpty()) { - extraControls.removeViews(startIndex - movingChildren.size() + 1, movingChildren.size()); + extraControls.removeViews(endIndex - movingChildren.size() + 1, movingChildren.size()); for (View child : movingChildren) { basicControls.addView(child, 0); } } + if (extraControls.getChildCount() == 1 && overflowShowButton != null) { + overflowShowButton.setVisibility(View.GONE); + } } } diff --git a/library/ui/src/main/res/layout/exo_styled_player_control_view.xml b/library/ui/src/main/res/layout/exo_styled_player_control_view.xml index 3136f9d8110..9655576b042 100644 --- a/library/ui/src/main/res/layout/exo_styled_player_control_view.xml +++ b/library/ui/src/main/res/layout/exo_styled_player_control_view.xml @@ -87,7 +87,11 @@ + + @@ -105,9 +109,6 @@ android:orientation="horizontal" android:layoutDirection="ltr"> - - From 850510ac688f4af6072b22268f34893bff46f887 Mon Sep 17 00:00:00 2001 From: insun Date: Thu, 8 Oct 2020 09:58:11 +0100 Subject: [PATCH 125/693] Expand bottom button's height and extend greyed background area to seekbar Adjusted the bottom layout of StyledPlayerControlView : - Enlarged bottom button's height to make tapping easier. - Extended greyed background area to upper edge of seekbar. - Gave padding between bottom edge of the overall layout and bottom buttons. - Reduced horizontal margins between bottom buttons. PiperOrigin-RevId: 336041160 --- RELEASENOTES.md | 2 ++ .../res/layout/exo_styled_player_control_view.xml | 3 ++- library/ui/src/main/res/values/dimens.xml | 15 +++++++++------ library/ui/src/main/res/values/styles.xml | 10 ++++++---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 99277376129..d565091bf5a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -34,6 +34,8 @@ * Add the option to sort tracks by `Format` in `TrackSelectionView` and `TrackSelectionDialogBuilder` ([#7709](https://github.com/google/ExoPlayer/issues/7709)). + * Adjusted bottom buttons' heights and paddings in StyledPlayerView for + easy tapping. * Audio: * Retry playback after some types of `AudioTrack` error. * Fix the default audio sink position not advancing correctly when using diff --git a/library/ui/src/main/res/layout/exo_styled_player_control_view.xml b/library/ui/src/main/res/layout/exo_styled_player_control_view.xml index 9655576b042..7d9cbb551b2 100644 --- a/library/ui/src/main/res/layout/exo_styled_player_control_view.xml +++ b/library/ui/src/main/res/layout/exo_styled_player_control_view.xml @@ -40,11 +40,12 @@ android:layout_height="@dimen/exo_bottom_bar_height" android:layout_gravity="bottom" android:background="@color/exo_bottom_bar_background" + android:paddingBottom="@dimen/exo_bottom_bar_padding_bottom" android:layoutDirection="ltr"> 8dp 52dp - 5dp + 5dp 2dp 9dp 18dp - 48dp - 32dp + 48dp + 48dp + 2dp 12dp - 4dp + 12dp 2dp 24dp - 40dp + 56dp - 32dp + 70dp + 4dp 10dp 170sp + 48dp 32dp 64dp diff --git a/library/ui/src/main/res/values/styles.xml b/library/ui/src/main/res/values/styles.xml index d86c3e5a39d..03afddfdc53 100644 --- a/library/ui/src/main/res/values/styles.xml +++ b/library/ui/src/main/res/values/styles.xml @@ -61,8 +61,8 @@ + From 54506b506bb6506c6389dd6700cd179e42fff576 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 21 Oct 2020 09:02:47 +0100 Subject: [PATCH 172/693] Explicitly prevent callbacks after player is released We currently implicitly rely on the internal playback thread to not send new updates after the player got released. This may not always be ensured since we let the release call timeout. For the timeout case, there may still be a pending operation returning much later when it unstuck itself. Fix this and potential other edge cases by explicitly removing all listeners and preventing new listeners from being added after the release. PiperOrigin-RevId: 338217220 --- .../android/exoplayer2/ExoPlayerImpl.java | 1 + .../android/exoplayer2/util/ListenerSet.java | 18 +++++++++ .../exoplayer2/util/ListenerSetTest.java | 38 +++++++++++++++++++ 3 files changed, 57 insertions(+) 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 dcc28f974e8..1636275109d 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 @@ -721,6 +721,7 @@ public void release() { new TimeoutException("Player release timed out."), ExoPlaybackException.TIMEOUT_OPERATION_RELEASE))); } + listeners.release(); playbackInfoUpdateHandler.removeCallbacksAndMessages(null); if (analyticsCollector != null) { bandwidthMeter.removeEventListener(analyticsCollector); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java index 12b73ec94fd..499e05acf0d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java @@ -48,6 +48,8 @@ public interface Event { private final ArrayDeque flushingEvents; private final ArrayDeque queuedEvents; + private boolean released; + /** Creates the listener set. */ public ListenerSet() { listeners = new CopyOnWriteArraySet<>(); @@ -63,6 +65,9 @@ public ListenerSet() { * @param listener The listener to be added. */ public void add(T listener) { + if (released) { + return; + } Assertions.checkNotNull(listener); listeners.add(new ListenerHolder(listener)); } @@ -124,6 +129,19 @@ public void sendEvent(Event event) { flushEvents(); } + /** + * Releases the set of listeners. + * + *

    This will ensure no events are sent to any listener after this method has been called. + */ + public void release() { + for (ListenerHolder listenerHolder : listeners) { + listenerHolder.release(); + } + listeners.clear(); + released = true; + } + private static final class ListenerHolder { @Nonnull public final T listener; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/ListenerSetTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/ListenerSetTest.java index 36f20c065e2..aaa299ecd74 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/ListenerSetTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ListenerSetTest.java @@ -184,6 +184,44 @@ public void remove_withQueueing_stopsReceivingEventsImmediately() { verify(listener2, times(2)).callback1(); } + @Test + public void release_stopsForwardingEventsImmediately() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener2 = mock(TestListener.class); + // Listener1 releases the set from within the callback. + TestListener listener1 = + spy( + new TestListener() { + @Override + public void callback1() { + listenerSet.release(); + } + }); + listenerSet.add(listener1); + listenerSet.add(listener2); + + // Listener2 shouldn't even get this event as it's released before the event can be invoked. + listenerSet.sendEvent(TestListener::callback1); + listenerSet.sendEvent(TestListener::callback2); + + verify(listener1).callback1(); + verify(listener2, never()).callback1(); + verify(listener1, never()).callback2(); + verify(listener2, never()).callback2(); + } + + @Test + public void release_preventsRegisteringNewListeners() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener = mock(TestListener.class); + + listenerSet.release(); + listenerSet.add(listener); + listenerSet.sendEvent(TestListener::callback1); + + verify(listener, never()).callback1(); + } + private interface TestListener { default void callback1() {} From 1051580a63b190451159f86d22477cebe4bcb3a1 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 21 Oct 2020 11:25:21 +0100 Subject: [PATCH 173/693] Improve naming of the methods of LivePlaybackSpeedControl PiperOrigin-RevId: 338232327 --- .../DefaultLivePlaybackSpeedControl.java | 6 +- .../exoplayer2/ExoPlayerImplInternal.java | 9 ++- .../exoplayer2/LivePlaybackSpeedControl.java | 17 ++-- .../DefaultLivePlaybackSpeedControlTest.java | 78 ++++++++++--------- 4 files changed, 58 insertions(+), 52 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java index 2ba7887bd14..1107d90aa4e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java @@ -181,19 +181,19 @@ private DefaultLivePlaybackSpeedControl( } @Override - public void updateLiveConfiguration(LiveConfiguration liveConfiguration) { + public void setLiveConfiguration(LiveConfiguration liveConfiguration) { this.mediaConfiguration = liveConfiguration; lastPlaybackSpeedUpdateMs = C.TIME_UNSET; } @Override - public void overrideTargetLiveOffsetUs(long liveOffsetUs) { + public void setTargetLiveOffsetOverrideUs(long liveOffsetUs) { this.targetLiveOffsetOverrideUs = liveOffsetUs; lastPlaybackSpeedUpdateMs = C.TIME_UNSET; } @Override - public float adjustPlaybackSpeed(long liveOffsetUs) { + public float getAdjustedPlaybackSpeed(long liveOffsetUs) { long targetLiveOffsetUs = getTargetLiveOffsetUs(); if (targetLiveOffsetUs == C.TIME_UNSET) { return 1f; 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 57cebc0baca..2ecbf3731bb 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 @@ -870,7 +870,8 @@ private void updatePlaybackPositions() throws ExoPlaybackException { if (playbackInfo.playWhenReady && isCurrentPeriodInMovingLiveWindow() && playbackInfo.playbackParameters.speed == 1f) { - float adjustedSpeed = livePlaybackSpeedControl.adjustPlaybackSpeed(getCurrentLiveOffsetUs()); + float adjustedSpeed = + livePlaybackSpeedControl.getAdjustedPlaybackSpeed(getCurrentLiveOffsetUs()); if (mediaClock.getPlaybackParameters().speed != adjustedSpeed) { mediaClock.setPlaybackParameters(playbackInfo.playbackParameters.withSpeed(adjustedSpeed)); } @@ -1798,9 +1799,9 @@ private void updateLivePlaybackSpeedControl( } int windowIndex = newTimeline.getPeriodByUid(newPeriodId.periodUid, period).windowIndex; newTimeline.getWindow(windowIndex, window); - livePlaybackSpeedControl.updateLiveConfiguration(window.mediaItem.liveConfiguration); + livePlaybackSpeedControl.setLiveConfiguration(window.mediaItem.liveConfiguration); if (positionForTargetOffsetOverrideUs != C.TIME_UNSET) { - livePlaybackSpeedControl.overrideTargetLiveOffsetUs( + livePlaybackSpeedControl.setTargetLiveOffsetOverrideUs( getLiveOffsetUs(newTimeline, newPeriodId.periodUid, positionForTargetOffsetOverrideUs)); } else { Object windowUid = window.uid; @@ -1811,7 +1812,7 @@ private void updateLivePlaybackSpeedControl( } if (!Util.areEqual(oldWindowUid, windowUid)) { // Reset overridden target live offset to media values if window changes. - livePlaybackSpeedControl.overrideTargetLiveOffsetUs(C.TIME_UNSET); + livePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java index e2f63df4b30..03aa3253079 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java @@ -24,20 +24,21 @@ public interface LivePlaybackSpeedControl { /** - * Updates the live configuration defined by the media. + * Sets the live configuration defined by the media. * * @param liveConfiguration The {@link LiveConfiguration} as defined by the media. */ - void updateLiveConfiguration(LiveConfiguration liveConfiguration); + void setLiveConfiguration(LiveConfiguration liveConfiguration); /** - * Overrides the {@link #updateLiveConfiguration configured} target live offset in microseconds, - * or {@code C.TIME_UNSET} to delete a previous override. + * Sets the target live offset in microseconds that overrides the live offset {@link + * #setLiveConfiguration configured} by the media. Passing {@code C.TIME_UNSET} deletes a previous + * override. * - *

    If no target live offset is configured by {@link #updateLiveConfiguration}, this override - * has no effect. + *

    If no target live offset is configured by {@link #setLiveConfiguration}, this override has + * no effect. */ - void overrideTargetLiveOffsetUs(long liveOffsetUs); + void setTargetLiveOffsetOverrideUs(long liveOffsetUs); /** * Returns the adjusted playback speed in order get closer towards the {@link @@ -46,7 +47,7 @@ public interface LivePlaybackSpeedControl { * @param liveOffsetUs The current live offset, in microseconds. * @return The adjusted playback speed. */ - float adjustPlaybackSpeed(long liveOffsetUs); + float getAdjustedPlaybackSpeed(long liveOffsetUs); /** * Returns the current target live offset, in microseconds, or {@link C#TIME_UNSET} if no target diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java index 57155b9a18c..61c0f88b486 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java @@ -40,7 +40,7 @@ public void getTargetLiveOffsetUs_returnsUnset() { public void getTargetLiveOffsetUs_afterUpdateLiveConfiguration_usesMediaLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 42, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); @@ -52,8 +52,8 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); - defaultLivePlaybackSpeedControl.overrideTargetLiveOffsetUs(123_456_789); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 42, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); @@ -67,7 +67,7 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() getTargetLiveOffsetUs_afterOverrideTargetLiveOffset_withoutMediaConfiguration_returnsUnset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); - defaultLivePlaybackSpeedControl.overrideTargetLiveOffsetUs(123_456_789); + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); @@ -79,11 +79,11 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() getTargetLiveOffsetUs_afterOverrideTargetLiveOffsetUsWithTimeUnset_usesMediaLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); - defaultLivePlaybackSpeedControl.overrideTargetLiveOffsetUs(123_456_789); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 42, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); - defaultLivePlaybackSpeedControl.overrideTargetLiveOffsetUs(C.TIME_UNSET); + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET); long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); @@ -94,14 +94,14 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() public void adjustPlaybackSpeed_liveOffsetMatchesTargetOffset_returnsUnitSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 2_000_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_000_000); assertThat(adjustedSpeed).isEqualTo(1f); } @@ -110,19 +110,19 @@ public void adjustPlaybackSpeed_liveOffsetMatchesTargetOffset_returnsUnitSpeed() public void adjustPlaybackSpeed_liveOffsetWithinAcceptableErrorMargin_returnsUnitSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeedJustAboveLowerErrorMargin = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed( + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( /* liveOffsetUs= */ 2_000_000 - DefaultLivePlaybackSpeedControl.MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED + 1); float adjustedSpeedJustBelowUpperErrorMargin = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed( + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( /* liveOffsetUs= */ 2_000_000 + DefaultLivePlaybackSpeedControl.MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED - 1); @@ -135,14 +135,14 @@ public void adjustPlaybackSpeed_liveOffsetWithinAcceptableErrorMargin_returnsUni public void adjustPlaybackSpeed_withLiveOffsetGreaterThanTargetOffset_returnsAdjustedSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setProportionalControlFactor(0.01f).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); float expectedSpeedAccordingToDocumentation = 1f + 0.01f * (2.5f - 2f); assertThat(adjustedSpeed).isEqualTo(expectedSpeedAccordingToDocumentation); @@ -153,15 +153,15 @@ public void adjustPlaybackSpeed_withLiveOffsetGreaterThanTargetOffset_returnsAdj public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjustedSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setProportionalControlFactor(0.01f).build(); - defaultLivePlaybackSpeedControl.overrideTargetLiveOffsetUs(2_000_000); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(2_000_000); + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); float expectedSpeedAccordingToDocumentation = 1f + 0.01f * (1.5f - 2f); assertThat(adjustedSpeed).isEqualTo(expectedSpeedAccordingToDocumentation); @@ -173,14 +173,15 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus adjustPlaybackSpeed_withLiveOffsetGreaterThanTargetOffset_clampedToFallbackMaximumSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setFallbackMaxPlaybackSpeed(1.5f).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 999_999_999_999L); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 999_999_999_999L); assertThat(adjustedSpeed).isEqualTo(1.5f); } @@ -190,14 +191,15 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_clampedToFallbackMinimumSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setFallbackMinPlaybackSpeed(0.5f).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ -999_999_999_999L); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ -999_999_999_999L); assertThat(adjustedSpeed).isEqualTo(0.5f); } @@ -207,14 +209,15 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus adjustPlaybackSpeed_andMediaProvidedMaxSpeedWithLiveOffsetGreaterThanTargetOffset_clampedToMediaMaxSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setFallbackMaxPlaybackSpeed(1.5f).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ 2f)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 999_999_999_999L); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 999_999_999_999L); assertThat(adjustedSpeed).isEqualTo(2f); } @@ -224,14 +227,15 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus adjustPlaybackSpeed_andMediaProvidedMinSpeedWithLiveOffsetLowerThanTargetOffset_clampedToMediaMinSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setFallbackMinPlaybackSpeed(0.5f).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ 0.2f, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ -999_999_999_999L); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ -999_999_999_999L); assertThat(adjustedSpeed).isEqualTo(0.2f); } @@ -240,20 +244,20 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameAdjustedSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); ShadowSystemClock.advanceBy(Duration.ofMillis(122)); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); ShadowSystemClock.advanceBy(Duration.ofMillis(2)); float adjustedSpeed3 = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); assertThat(adjustedSpeed1).isEqualTo(adjustedSpeed2); assertThat(adjustedSpeed3).isNotEqualTo(adjustedSpeed2); @@ -263,21 +267,21 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA public void adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfiguration_updatesSpeedAgain() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); } @@ -286,17 +290,17 @@ public void adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfiguration_updates public void adjustPlaybackSpeed_repeatedCallAfterNewTargetLiveOffset_updatesSpeedAgain() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); - defaultLivePlaybackSpeedControl.updateLiveConfiguration( + defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); - defaultLivePlaybackSpeedControl.overrideTargetLiveOffsetUs(2_000_001); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(2_000_001); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.adjustPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); } From e52a044ec5704fe58059049abe35cfff8dad9fbe Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 21 Oct 2020 11:31:39 +0100 Subject: [PATCH 174/693] Parse #EXT-X-SERVER-CONTROL and #EXT-X-PART-INF in HLS media playlists. PiperOrigin-RevId: 338232910 --- .../source/hls/playlist/HlsMediaPlaylist.java | 70 +++++++++++++++- .../hls/playlist/HlsPlaylistParser.java | 79 +++++++++++++++++-- .../playlist/HlsMediaPlaylistParserTest.java | 75 +++++++++++++++++- 3 files changed, 211 insertions(+), 13 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java index be771b92fc2..022e68bc7d6 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -29,6 +29,54 @@ /** Represents an HLS media playlist. */ public final class HlsMediaPlaylist extends HlsPlaylist { + /** Server control attributes. */ + public static final class ServerControl { + + /** + * The skip boundary for delta updates in microseconds, or {@link C#TIME_UNSET} if delta updates + * are not supported. + */ + public final long skipUntilUs; + /** + * Whether the playlist can produce delta updates that skip older #EXT-X-DATERANGE tags in + * addition to media segments. + */ + public final boolean canSkipDateRanges; + /** + * The server-recommended live offset in microseconds, or {@link C#TIME_UNSET} if none defined. + */ + public final long holdBackUs; + /** + * The server-recommended live offset in microseconds in low-latency mode, or {@link + * C#TIME_UNSET} if none defined. + */ + public final long partHoldBackUs; + /** Whether the server supports blocking playlist reload. */ + public final boolean canBlockReload; + + /** + * Creates a new instance. + * + * @param skipUntilUs See {@link #skipUntilUs}. + * @param canSkipDateRanges See {@link #canSkipDateRanges}. + * @param holdBackUs See {@link #holdBackUs}. + * @param partHoldBackUs See {@link #partHoldBackUs}. + * @param canBlockReload See {@link #canBlockReload}. + */ + public ServerControl( + long skipUntilUs, + boolean canSkipDateRanges, + long holdBackUs, + long partHoldBackUs, + boolean canBlockReload) { + this.skipUntilUs = skipUntilUs; + this.canSkipDateRanges = canSkipDateRanges; + this.holdBackUs = holdBackUs; + this.partHoldBackUs = partHoldBackUs; + this.canBlockReload = canBlockReload; + } + } + /** Media segment reference. */ @SuppressWarnings("ComparableType") public static final class Segment implements Comparable { @@ -208,8 +256,11 @@ public int compareTo(Long relativeStartTimeUs) { */ public final long targetDurationUs; /** - * Whether the playlist contains the #EXT-X-ENDLIST tag. + * The target duration for segment parts, as defined by #EXT-X-PART-INF, or {@link C#TIME_UNSET} + * if undefined. */ + public final long partTargetDurationUs; + /** Whether the playlist contains the #EXT-X-ENDLIST tag. */ public final boolean hasEndTag; /** * Whether the playlist contains a #EXT-X-PROGRAM-DATE-TIME tag. @@ -228,6 +279,8 @@ public int compareTo(Long relativeStartTimeUs) { * The total duration of the playlist in microseconds. */ public final long durationUs; + /** The attributes of the #EXT-X-SERVER-CONTROL header. */ + public final ServerControl serverControl; /** * @param playlistType See {@link #playlistType}. @@ -245,6 +298,7 @@ public int compareTo(Long relativeStartTimeUs) { * @param protectionSchemes See {@link #protectionSchemes}. * @param hasProgramDateTime See {@link #hasProgramDateTime}. * @param segments See {@link #segments}. + * @param serverControl See {@link #serverControl} */ public HlsMediaPlaylist( @PlaylistType int playlistType, @@ -257,11 +311,13 @@ public HlsMediaPlaylist( long mediaSequence, int version, long targetDurationUs, + long partTargetDurationUs, boolean hasIndependentSegments, boolean hasEndTag, boolean hasProgramDateTime, @Nullable DrmInitData protectionSchemes, - List segments) { + List segments, + ServerControl serverControl) { super(baseUri, tags, hasIndependentSegments); this.playlistType = playlistType; this.startTimeUs = startTimeUs; @@ -270,6 +326,7 @@ public HlsMediaPlaylist( this.mediaSequence = mediaSequence; this.version = version; this.targetDurationUs = targetDurationUs; + this.partTargetDurationUs = partTargetDurationUs; this.hasEndTag = hasEndTag; this.hasProgramDateTime = hasProgramDateTime; this.protectionSchemes = protectionSchemes; @@ -282,6 +339,7 @@ public HlsMediaPlaylist( } this.startOffsetUs = startOffsetUs == C.TIME_UNSET ? C.TIME_UNSET : startOffsetUs >= 0 ? startOffsetUs : durationUs + startOffsetUs; + this.serverControl = serverControl; } @Override @@ -337,11 +395,13 @@ public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { mediaSequence, version, targetDurationUs, + partTargetDurationUs, hasIndependentSegments, hasEndTag, hasProgramDateTime, protectionSchemes, - segments); + segments, + serverControl); } /** @@ -363,11 +423,13 @@ public HlsMediaPlaylist copyWithEndTag() { mediaSequence, version, targetDurationUs, + partTargetDurationUs, hasIndependentSegments, /* hasEndTag= */ true, hasProgramDateTime, protectionSchemes, - segments); + segments, + serverControl); } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index fd6efbf4455..7e44bcfa51f 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls.playlist; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; import android.text.TextUtils; import android.util.Base64; @@ -68,7 +70,9 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions) { Matcher matcher = pattern.matcher(line); - @PolyNull - String value = matcher.find() ? Assertions.checkNotNull(matcher.group(1)) : defaultValue; + @PolyNull String value = matcher.find() ? checkNotNull(matcher.group(1)) : defaultValue; return variableDefinitions.isEmpty() || value == null ? value : replaceVariableReferences(value, variableDefinitions); } + private static double parseOptionalDoubleAttr(String line, Pattern pattern, double defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return Double.parseDouble(checkNotNull(matcher.group(1))); + } + return defaultValue; + } + private static String replaceVariableReferences( String string, Map variableDefinitions) { Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string); @@ -970,7 +1035,7 @@ public boolean hasNext() throws IOException { return true; } if (!extraLines.isEmpty()) { - next = Assertions.checkNotNull(extraLines.poll()); + next = checkNotNull(extraLines.poll()); return true; } while ((next = reader.readLine()) != null) { @@ -992,7 +1057,5 @@ public String next() throws IOException { throw new NoSuchElementException(); } } - } - } 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 42b51056cf7..563d8ab3efd 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 @@ -45,7 +45,7 @@ public void parseMediaPlaylist() throws Exception { "#EXTM3U\n" + "#EXT-X-VERSION:3\n" + "#EXT-X-PLAYLIST-TYPE:VOD\n" - + "#EXT-X-START:TIME-OFFSET=-25" + + "#EXT-X-START:TIME-OFFSET=-25\n" + "#EXT-X-TARGETDURATION:8\n" + "#EXT-X-MEDIA-SEQUENCE:2679\n" + "#EXT-X-DISCONTINUITY-SEQUENCE:4\n" @@ -86,6 +86,8 @@ public void parseMediaPlaylist() throws Exception { assertThat(mediaPlaylist.version).isEqualTo(3); assertThat(mediaPlaylist.hasEndTag).isTrue(); assertThat(mediaPlaylist.protectionSchemes).isNull(); + assertThat(mediaPlaylist.targetDurationUs).isEqualTo(8000000); + assertThat(mediaPlaylist.partTargetDurationUs).isEqualTo(C.TIME_UNSET); List segments = mediaPlaylist.segments; assertThat(segments).isNotNull(); assertThat(segments).hasSize(5); @@ -219,6 +221,7 @@ public void parseSampleAesCtrMethod() throws Exception { + "https://priv.example.com/2.ts\n" + "#EXT-X-ENDLIST\n"; InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + HlsMediaPlaylist playlist = (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); assertThat(playlist.protectionSchemes.schemeType).isEqualTo(C.CENC_TYPE_cenc); @@ -226,6 +229,76 @@ public void parseSampleAesCtrMethod() throws Exception { assertThat(playlist.protectionSchemes.get(0).hasData()).isFalse(); } + @Test + public void parseMediaPlaylist_withPartMediaInformation_succeeds() throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-SERVER-CONTROL:PART-HOLD-BACK=1.234\n" + + "#EXT-X-PART-INF:PART-TARGET=0.5\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:13:36.106Z\n" + + "#EXT-X-MAP:URI=\"init.mp4\"\n" + + "#EXTINF:4.00008,\n" + + "fileSequence266.mp4"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.partTargetDurationUs).isEqualTo(500000); + } + + @Test + public void parseMediaPlaylist_withoutServerControl_serverControlDefaultValues() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:8,\n" + + "https://priv.example.com/1.ts\n" + + "#EXT-X-ENDLIST\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + assertThat(playlist.serverControl.canBlockReload).isFalse(); + assertThat(playlist.serverControl.partHoldBackUs).isEqualTo(C.TIME_UNSET); + assertThat(playlist.serverControl.holdBackUs).isEqualTo(C.TIME_UNSET); + assertThat(playlist.serverControl.skipUntilUs).isEqualTo(C.TIME_UNSET); + assertThat(playlist.serverControl.canSkipDateRanges).isFalse(); + } + + @Test + public void parseMediaPlaylist_withServerControl_succeeds() throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,HOLD-BACK=18.5,PART-HOLD-BACK=1.234," + + "CAN-SKIP-UNTIL=24.0,CAN-SKIP-DATERANGES=YES\n" + + "#EXT-X-PART-INF:PART-TARGET=0.5\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:13:36.106Z\n" + + "#EXT-X-MAP:URI=\"init.mp4\"\n" + + "#EXTINF:4.00008,\n" + + "fileSequence266.mp4"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.serverControl.canBlockReload).isTrue(); + assertThat(playlist.serverControl.partHoldBackUs).isEqualTo(1234000); + assertThat(playlist.serverControl.holdBackUs).isEqualTo(18500000); + assertThat(playlist.serverControl.skipUntilUs).isEqualTo(24000000); + assertThat(playlist.serverControl.canSkipDateRanges).isTrue(); + } + @Test public void multipleExtXKeysForSingleSegment() throws Exception { Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); From 057771aeec61058cd6c95553cbe45766113e00ed Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 21 Oct 2020 12:01:45 +0100 Subject: [PATCH 175/693] Fix calculation for isSegmentAvailableAtFullNetworkSpeed. The current caluclation was comparing Unix time with period time. Fix it by always comparing against period time. Issue: #4904 PiperOrigin-RevId: 338235754 --- .../source/dash/DefaultDashChunkSource.java | 37 ++++---- .../dash/DefaultDashChunkSourceTest.java | 92 +++++++++++++++++++ .../sample_mpd_live_with_offset_inside_window | 3 +- 3 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java 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 ebadd96d2da..12a5bf85121 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 @@ -277,6 +277,7 @@ public void getNextChunk( } long nowUnixTimeUs = C.msToUs(Util.getNowUnixTimeMs(elapsedRealtimeOffsetMs)); + long nowPeriodTimeUs = getNowPeriodTimeUs(nowUnixTimeUs); MediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1); MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()]; for (int i = 0; i < chunkIterators.length; i++) { @@ -300,7 +301,7 @@ public void getNextChunk( } else { chunkIterators[i] = new RepresentationSegmentIterator( - representationHolder, segmentNum, lastAvailableSegmentNum, nowUnixTimeUs); + representationHolder, segmentNum, lastAvailableSegmentNum, nowPeriodTimeUs); } } } @@ -391,7 +392,7 @@ public void getNextChunk( segmentNum, maxSegmentCount, seekTimeUs, - nowUnixTimeUs); + nowPeriodTimeUs); } @Override @@ -488,13 +489,16 @@ private long getAvailableLiveDurationUs(long nowUnixTimeUs, long playbackPositio } long lastSegmentNum = representationHolders[0].getLastAvailableSegmentNum(nowUnixTimeUs); long lastSegmentEndTimeUs = representationHolders[0].getSegmentEndTimeUs(lastSegmentNum); - long nowPeriodTimeUs = - nowUnixTimeUs - - C.msToUs(manifest.availabilityStartTimeMs + manifest.getPeriod(periodIndex).startMs); + long nowPeriodTimeUs = getNowPeriodTimeUs(nowUnixTimeUs); long availabilityEndTimeUs = min(nowPeriodTimeUs, lastSegmentEndTimeUs); return max(0, availabilityEndTimeUs - playbackPositionUs); } + private long getNowPeriodTimeUs(long nowUnixTimeUs) { + return nowUnixTimeUs + - C.msToUs(manifest.availabilityStartTimeMs + manifest.getPeriod(periodIndex).startMs); + } + protected Chunk newInitializationChunk( RepresentationHolder representationHolder, DataSource dataSource, @@ -535,7 +539,7 @@ protected Chunk newMediaChunk( long firstSegmentNum, int maxSegmentCount, long seekTimeUs, - long nowUnixTimeUs) { + long nowPeriodTimeUs) { Representation representation = representationHolder.representation; long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); @@ -543,7 +547,8 @@ protected Chunk newMediaChunk( if (representationHolder.chunkExtractor == null) { long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum); int flags = - representationHolder.isSegmentAvailableAtFullNetworkSpeed(firstSegmentNum, nowUnixTimeUs) + representationHolder.isSegmentAvailableAtFullNetworkSpeed( + firstSegmentNum, nowPeriodTimeUs) ? 0 : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri, flags); @@ -569,7 +574,7 @@ protected Chunk newMediaChunk( ? periodDurationUs : C.TIME_UNSET; int flags = - representationHolder.isSegmentAvailableAtFullNetworkSpeed(segmentNum, nowUnixTimeUs) + representationHolder.isSegmentAvailableAtFullNetworkSpeed(segmentNum, nowPeriodTimeUs) ? 0 : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri, flags); @@ -597,7 +602,7 @@ protected Chunk newMediaChunk( protected static final class RepresentationSegmentIterator extends BaseMediaChunkIterator { private final RepresentationHolder representationHolder; - private final long currentUnixTimeUs; + private final long nowPeriodTimeUs; /** * Creates iterator. @@ -605,17 +610,17 @@ protected static final class RepresentationSegmentIterator extends BaseMediaChun * @param representation The {@link RepresentationHolder} to wrap. * @param firstAvailableSegmentNum The number of the first available segment. * @param lastAvailableSegmentNum The number of the last available segment. - * @param currentUnixTimeUs The current time in microseconds since the epoch used for - * calculating if segments are available at full network speed. + * @param nowPeriodTimeUs The current time in microseconds since the start of the period used + * for calculating if segments are available at full network speed. */ public RepresentationSegmentIterator( RepresentationHolder representation, long firstAvailableSegmentNum, long lastAvailableSegmentNum, - long currentUnixTimeUs) { + long nowPeriodTimeUs) { super(/* fromIndex= */ firstAvailableSegmentNum, /* toIndex= */ lastAvailableSegmentNum); this.representationHolder = representation; - this.currentUnixTimeUs = currentUnixTimeUs; + this.nowPeriodTimeUs = nowPeriodTimeUs; } @Override @@ -624,7 +629,7 @@ public DataSpec getDataSpec() { long currentIndex = getCurrentIndex(); RangedUri segmentUri = representationHolder.getSegmentUrl(currentIndex); int flags = - representationHolder.isSegmentAvailableAtFullNetworkSpeed(currentIndex, currentUnixTimeUs) + representationHolder.isSegmentAvailableAtFullNetworkSpeed(currentIndex, nowPeriodTimeUs) ? 0 : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; return DashUtil.buildDataSpec(representationHolder.representation, segmentUri, flags); @@ -787,8 +792,8 @@ public long getLastAvailableSegmentNum(long nowUnixTimeUs) { - 1; } - public boolean isSegmentAvailableAtFullNetworkSpeed(long segmentNum, long nowUnixTimeUs) { - return getSegmentEndTimeUs(segmentNum) <= nowUnixTimeUs; + public boolean isSegmentAvailableAtFullNetworkSpeed(long segmentNum, long nowPeriodTimeUs) { + return getSegmentEndTimeUs(segmentNum) <= nowPeriodTimeUs; } @Nullable diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java new file mode 100644 index 00000000000..71aed770b03 --- /dev/null +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java @@ -0,0 +1,92 @@ +/* + * 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.dash; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +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.source.TrackGroup; +import com.google.android.exoplayer2.source.chunk.ChunkHolder; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; +import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.trackselection.FixedTrackSelection; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.LoaderErrorThrower; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link DefaultDashChunkSource}. */ +@RunWith(AndroidJUnit4.class) +public class DefaultDashChunkSourceTest { + + private static final String SAMPLE_MPD_LIVE_WITH_OFFSET_INSIDE_WINDOW = + "media/mpd/sample_mpd_live_with_offset_inside_window"; + + @Test + public void getNextChunk_forLowLatencyManifest_setsCorrectMayNotLoadAtFullNetworkSpeedFlag() + throws Exception { + long nowMs = 2_000_000_000_000L; + SystemClock.setCurrentTimeMillis(nowMs); + DashManifest manifest = + new DashManifestParser() + .parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_LIVE_WITH_OFFSET_INSIDE_WINDOW)); + DefaultDashChunkSource chunkSource = + new DefaultDashChunkSource( + new LoaderErrorThrower.Dummy(), + manifest, + /* periodIndex= */ 0, + /* adaptationSetIndices= */ new int[] {0}, + new FixedTrackSelection(new TrackGroup(new Format.Builder().build()), /* track= */ 0), + C.TRACK_TYPE_VIDEO, + new FakeDataSource(), + /* elapsedRealtimeOffsetMs= */ 0, + /* maxSegmentsPerLoad= */ 1, + /* enableEventMessageTrack= */ false, + /* closedCaptionFormats */ ImmutableList.of(), + /* playerTrackEmsgHandler= */ null); + + long nowInPeriodUs = C.msToUs(nowMs - manifest.availabilityStartTimeMs); + ChunkHolder output = new ChunkHolder(); + + chunkSource.getNextChunk( + /* playbackPositionUs= */ nowInPeriodUs - 5 * C.MICROS_PER_SECOND, + /* loadPositionUs= */ nowInPeriodUs - 5 * C.MICROS_PER_SECOND, + /* queue= */ ImmutableList.of(), + output); + assertThat(output.chunk.dataSpec.flags & DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED) + .isEqualTo(0); + + chunkSource.getNextChunk( + /* playbackPositionUs= */ nowInPeriodUs, + /* loadPositionUs= */ nowInPeriodUs, + /* queue= */ ImmutableList.of(), + output); + assertThat(output.chunk.dataSpec.flags & DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED) + .isNotEqualTo(0); + } +} diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_live_with_offset_inside_window b/testdata/src/test/assets/media/mpd/sample_mpd_live_with_offset_inside_window index e0d4cfdddc4..bf0704b440d 100644 --- a/testdata/src/test/assets/media/mpd/sample_mpd_live_with_offset_inside_window +++ b/testdata/src/test/assets/media/mpd/sample_mpd_live_with_offset_inside_window @@ -17,7 +17,8 @@ timescale="1000000" duration="2000000" availabilityTimeOffset="2" - startNumber="1"/> + startNumber="1" + media="chunk-$Number%05d$.mp4"/> From a21eb772b171e66cb2a10dae83482c3f90664246 Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 21 Oct 2020 14:12:35 +0100 Subject: [PATCH 176/693] HlsMediaSourceTest: rename dashMediaItem to hlsMediaItem Issue: #4904 PiperOrigin-RevId: 338249499 --- .../source/hls/HlsMediaSourceTest.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) 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 index 7001417186d..fd8d90c8b4b 100644 --- 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 @@ -40,11 +40,11 @@ public void factorySetTag_nullMediaItemTag_setsMediaItemTag() { HlsMediaSource.Factory factory = new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); - MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + MediaItem hlsMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); - assertThat(dashMediaItem.playbackProperties).isNotNull(); - assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); - assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(tag); + assertThat(hlsMediaItem.playbackProperties).isNotNull(); + assertThat(hlsMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(hlsMediaItem.playbackProperties.tag).isEqualTo(tag); } // Tests backwards compatibility @@ -58,11 +58,11 @@ public void factorySetTag_nonNullMediaItemTag_doesNotOverrideMediaItemTag() { HlsMediaSource.Factory factory = new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(factoryTag); - MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + MediaItem hlsMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); - assertThat(dashMediaItem.playbackProperties).isNotNull(); - assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); - assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); + assertThat(hlsMediaItem.playbackProperties).isNotNull(); + assertThat(hlsMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(hlsMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); } // Tests backwards compatibility @@ -104,11 +104,11 @@ public void factorySetStreamKeys_emptyMediaItemStreamKeys_setsMediaItemStreamKey new HlsMediaSource.Factory(mock(DataSource.Factory.class)) .setStreamKeys(Collections.singletonList(streamKey)); - MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + MediaItem hlsMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); - assertThat(dashMediaItem.playbackProperties).isNotNull(); - assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); - assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); + assertThat(hlsMediaItem.playbackProperties).isNotNull(); + assertThat(hlsMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(hlsMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); } // Tests backwards compatibility @@ -126,10 +126,10 @@ public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotOverrideMediaIte .setStreamKeys( Collections.singletonList(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); - MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + MediaItem hlsMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); - assertThat(dashMediaItem.playbackProperties).isNotNull(); - assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); - assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); + assertThat(hlsMediaItem.playbackProperties).isNotNull(); + assertThat(hlsMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(hlsMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); } } From 563767d5e9e8b67b0e8c7a3f4b3f86e953d43c06 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 21 Oct 2020 15:50:35 +0100 Subject: [PATCH 177/693] Bump version to 2.12.1 PiperOrigin-RevId: 338261975 --- RELEASENOTES.md | 86 ++++++++++--------- constants.gradle | 4 +- .../exoplayer2/ExoPlayerLibraryInfo.java | 6 +- 3 files changed, 51 insertions(+), 45 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bc54c949011..a0905944b38 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,9 +5,6 @@ * Core library: * `LoadControl`: * Add a `targetLiveOffsetUs` parameter to `shouldStartPlayback`. - * Fix bug where streams with highly uneven durations may get stuck in a - buffering state - ([#7943](https://github.com/google/ExoPlayer/issues/7943)). * Verify correct thread usage in `SimpleExoPlayer` by default. Opt-out is still possible until the next major release using `setThrowsWhenUsingWrongThread(false)` @@ -19,63 +16,72 @@ * Time out when detaching a surface to prevent ANRs if the underlying platform call is stuck ([#5887](https://github.com/google/ExoPlayer/issues/5887)). - * Switch Guava dependency from `implementation` to `api` - ([#7905](https://github.com/google/ExoPlayer/issues/7905), - ([#7993](https://github.com/google/ExoPlayer/issues/7993)). * Fix bug where `AnalyticsListener` callbacks can arrive in the wrong order ([#8048](https://github.com/google/ExoPlayer/issues/8048)). * Track selection: * Add option to specify multiple preferred audio or text languages. +* UI: + * Show overflow button in `StyledPlayerControlView` only when there is not + enough space. +* Audio: + * Retry playback after some types of `AudioTrack` error. + +### 2.12.1 (2020-10-23) ### + +* Core library: + * Fix bug where streams with highly uneven track durations may get stuck + in a buffering state + ([#7943](https://github.com/google/ExoPlayer/issues/7943)). + * Switch Guava dependency from `implementation` to `api` + ([#7905](https://github.com/google/ExoPlayer/issues/7905), + ([#7993](https://github.com/google/ExoPlayer/issues/7993)). + * Add 403, 500 and 503 to the list of HTTP status codes that can trigger + failover to another quality variant during adaptive playbacks. * Data sources: * Add support for `android.resource` URI scheme in `RawResourceDataSource` ([#7866](https://github.com/google/ExoPlayer/issues/7866)). * Text: * Add support for `\h` SSA/ASS style override code (non-breaking space). - * Fix WebVTT subtitles in MP4 containers in DASH streams + * Fix playback of WebVTT subtitles in MP4 containers in DASH streams ([#7985](https://github.com/google/ExoPlayer/issues/7985)). - * Fix NPE in `TextRenderer` when playing content with a single subtitle - buffer ([#8017](https://github.com/google/ExoPlayer/issues/8017)). + * Fix `NullPointerException` in `TextRenderer` when playing content with a + single subtitle buffer + ([#8017](https://github.com/google/ExoPlayer/issues/8017)). * UI: - * Show overflow button in `StyledPlayerControlView` only when there is not - enough space. * Fix animation when `StyledPlayerView` first shows its playback controls. - * Allow subtitleButton to be omitted in custom `StyledPlayerView` layouts - ([#7962](https://github.com/google/ExoPlayer/issues/7962)). + * Improve touch targets in `StyledPlayerView` to make tapping easier. + * Allow `subtitleButton` to be omitted in custom `StyledPlayerView` + layouts ([#7962](https://github.com/google/ExoPlayer/issues/7962)). * Add an option to sort tracks by `Format` in `TrackSelectionView` and `TrackSelectionDialogBuilder` ([#7709](https://github.com/google/ExoPlayer/issues/7709)). - * Improve touch targets in `StyledPlayerView` to make tapping easier. * Audio: - * Retry playback after some types of `AudioTrack` error. * Fix the default audio sink position not advancing correctly when using - `AudioTrack`-based speed adjustment + `AudioTrack` based speed adjustment ([#7982](https://github.com/google/ExoPlayer/issues/7982)). * Fix `NoClassDefFoundError` warning for `AudioTrack$StreamEventCallback` - even though the class was not used ([#8058](https://github.com/google/ExoPlayer/issues/8058)). * Extractors: - * Add support for `_mp2` boxes in `Mp4Extractor` - ([#7967](https://github.com/google/ExoPlayer/issues/7967)). - * Fix playback of MP4 and MOV files containing `pcm_alaw` or `pcm_mulaw` - audio tracks, by enabling sample rechunking of such tracks - * Use TLEN ID3 tag to compute the duration in `Mp3Extractor` + * MP4: + * Add support for `_mp2` boxes + ([#7967](https://github.com/google/ExoPlayer/issues/7967)). + * Fix playback of files containing `pcm_alaw` or `pcm_mulaw` audio + tracks, by enabling sample rechunking for such tracks. + * MPEG-TS: + * Add `TsExtractor` parameter to configure the number of bytes in + which to search for timestamps when seeking and determining stream + duration ([#7988](https://github.com/google/ExoPlayer/issues/7988)). + * Ignore negative payload size in PES packets + ([#8005](https://github.com/google/ExoPlayer/issues/8005)). + * MP3: Use TLEN ID3 tag to compute the stream duration ([#7949](https://github.com/google/ExoPlayer/issues/7949)). - * Fix regression for Ogg files with packets that span multiple pages + * Ogg: Fix regression playing files with packets that span multiple pages ([#7992](https://github.com/google/ExoPlayer/issues/7992)). - * Add TS extractor parameter to configure the number of bytes in which to - search for a timestamp to determine the duration and to seek. - ([#7988](https://github.com/google/ExoPlayer/issues/7988)). - * Ignore negative payload size in PES packets - ([#8005](https://github.com/google/ExoPlayer/issues/8005)). - * Make FLV files seekable by using the key frame index + * FLV: Make files seekable by using the key frame index ([#7378](https://github.com/google/ExoPlayer/issues/7378)). -* Adaptive playback (DASH / HLS / SmoothStreaming): - * Add 403, 500 and 503 to the list of HTTP status codes that can trigger - failover to another quality variant. -* HLS: - * Fix crash affecting chunkful preparation of master playlists that start - with an I-FRAME only variant - ([#8025](https://github.com/google/ExoPlayer/issues/8025)). +* HLS: Fix crash affecting chunkful preparation of master playlists that start + with an I-FRAME only variant + ([#8025](https://github.com/google/ExoPlayer/issues/8025)). * IMA extension: * Fix position reporting after fetch errors ([#7956](https://github.com/google/ExoPlayer/issues/7956)). @@ -88,7 +94,7 @@ ([#3750](https://github.com/google/ExoPlayer/issues/3750)). * Add a way to override ad media MIME types ([#7961)(https://github.com/google/ExoPlayer/issues/7961)). - * Fix truncating large cue points in microseconds + * Fix incorrect truncation of large cue point positions ([#8067](https://github.com/google/ExoPlayer/issues/8067)). * Upgrade IMA SDK dependency to 3.20.1. This brings in a fix for companion ads rendering when targeting API 29 @@ -257,7 +263,7 @@ To learn more about what's new in 2.12, read the corresponding * Redefine `Cue.lineType=LINE_TYPE_NUMBER` in terms of aligning the cue text lines to grid of viewport lines. Only consider `Cue.lineAnchor` when `Cue.lineType=LINE_TYPE_FRACTION`. - * WebVTT + * WebVTT: * Add support for default [text](https://www.w3.org/TR/webvtt1/#default-text-color) and [background](https://www.w3.org/TR/webvtt1/#default-text-background) @@ -272,10 +278,10 @@ To learn more about what's new in 2.12, read the corresponding * Parse the `ruby-position` CSS property. * Parse the `text-combine-upright` CSS property (i.e., tate-chu-yoko). * Parse the `` and `` tags. - * TTML + * TTML: * Parse the `tts:combineText` property (i.e., tate-chu-yoko). * Parse t`tts:ruby` and `tts:rubyPosition` properties. - * CEA-608 + * CEA-608: * Implement timing-out of stuck captions, as permitted by ANSI/CTA-608-E R-2014 Annex C.9. The default timeout is set to 16 seconds ([#7181](https://github.com/google/ExoPlayer/issues/7181)). diff --git a/constants.gradle b/constants.gradle index 3a58ceee76f..1e7bf51c7bf 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.12.0' - releaseVersionCode = 2012000 + releaseVersion = '2.12.1' + releaseVersionCode = 2012001 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 15c4bf1c1d2..b751fff7bd8 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 @@ -30,11 +30,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.12.0"; + public static final String VERSION = "2.12.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.12.0"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.12.1"; /** * The version of the library expressed as an integer, for example 1002003. @@ -44,7 +44,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 = 2012000; + public static final int VERSION_INT = 2012001; /** The default user agent for requests made by the library. */ public static final String DEFAULT_USER_AGENT = From 9bde5d0351d3a1a62527bae2270a8f6f9c5b866e Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 21 Oct 2020 16:27:28 +0100 Subject: [PATCH 178/693] [nullness] The nullness checking fixes of the media related files We rolled back the previous because some of the nullness checking fixes broke the exoplayer2 tests. We submit this CL into the TAP Global Presubmit train (https://test.corp.google.com/OCL:337620582:BASE:338201100:1603283151742:36afd5a5) and make sure that this CL wouldn't break any other tests on google3. PiperOrigin-RevId: 338267548 --- .../exoplayer2/ext/cast/DefaultMediaItemConverter.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java index 705f2c25085..c72a1fb316f 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java @@ -45,7 +45,9 @@ public final class DefaultMediaItemConverter implements MediaItemConverter { @Override public MediaItem toMediaItem(MediaQueueItem item) { // `item` came from `toMediaQueueItem()` so the custom JSON data must be set. - return getMediaItem(Assertions.checkNotNull(item.getMedia().getCustomData())); + MediaInfo mediaInfo = item.getMedia(); + Assertions.checkNotNull(mediaInfo); + return getMediaItem(Assertions.checkNotNull(mediaInfo.getCustomData())); } @Override From 175b8eb69ed71e14053b67ec5625b2872e5834cf Mon Sep 17 00:00:00 2001 From: kimvde Date: Thu, 22 Oct 2020 11:08:37 +0100 Subject: [PATCH 179/693] Read Google Photos motion photo metadata PiperOrigin-RevId: 338436906 --- RELEASENOTES.md | 2 + .../java/com/google/android/exoplayer2/C.java | 8 +-- .../exoplayer2/metadata/mp4/MotionPhoto.java | 36 +++++++------- .../android/exoplayer2/MetadataRetriever.java | 15 ++++-- .../exoplayer2/MetadataRetrieverTest.java | 28 +++++++++-- .../exoplayer2/extractor/mp4/Atom.java | 3 ++ .../extractor/mp4/Mp4Extractor.java | 46 ++++++++++++++++-- .../exoplayer2/extractor/mp4/Sniffer.java | 29 ++++++++--- .../src/test/assets/media/mp4/sample_MP.heic | Bin 0 -> 57672 bytes 9 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 testdata/src/test/assets/media/mp4/sample_MP.heic diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a0905944b38..9ce93ef81d9 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -99,6 +99,8 @@ * Upgrade IMA SDK dependency to 3.20.1. This brings in a fix for companion ads rendering when targeting API 29 ([#6432](https://github.com/google/ExoPlayer/issues/6432)). +* Metadata retriever: + * Parse Google Photos HEIC motion photos metadata. ### 2.12.0 (2020-09-11) ### 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 1a1543cc7e4..c0baa4cbdc0 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,12 +690,14 @@ private C() {} public static final int TRACK_TYPE_VIDEO = 2; /** A type constant for text tracks. */ public static final int TRACK_TYPE_TEXT = 3; + /** A type constant for image tracks. */ + public static final int TRACK_TYPE_IMAGE = 4; /** A type constant for metadata tracks. */ - public static final int TRACK_TYPE_METADATA = 4; + public static final int TRACK_TYPE_METADATA = 5; /** A type constant for camera motion tracks. */ - public static final int TRACK_TYPE_CAMERA_MOTION = 5; + public static final int TRACK_TYPE_CAMERA_MOTION = 6; /** A type constant for a fake or empty track. */ - public static final int TRACK_TYPE_NONE = 6; + public static final int TRACK_TYPE_NONE = 7; /** * Applications or extensions may define custom {@code TRACK_TYPE_*} constants greater than or * equal to this value. diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhoto.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhoto.java index ca1a110c613..9dfd423a7dc 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhoto.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhoto.java @@ -20,21 +20,23 @@ import android.os.Parcelable; import androidx.annotation.Nullable; import com.google.android.exoplayer2.metadata.Metadata; +import com.google.common.primitives.Longs; /** Metadata of a motion photo file. */ public final class MotionPhoto implements Metadata.Entry { /** The start offset of the photo data, in bytes. */ - public final int photoStartPosition; + public final long photoStartPosition; /** The size of the photo data, in bytes. */ - public final int photoSize; + public final long photoSize; /** The start offset of the video data, in bytes. */ - public final int videoStartPosition; + public final long videoStartPosition; /** The size of the video data, in bytes. */ - public final int videoSize; + public final long videoSize; /** Creates an instance. */ - public MotionPhoto(int photoStartPosition, int photoSize, int videoStartPosition, int videoSize) { + public MotionPhoto( + long photoStartPosition, long photoSize, long videoStartPosition, long videoSize) { this.photoStartPosition = photoStartPosition; this.photoSize = photoSize; this.videoStartPosition = videoStartPosition; @@ -42,10 +44,10 @@ public MotionPhoto(int photoStartPosition, int photoSize, int videoStartPosition } private MotionPhoto(Parcel in) { - photoStartPosition = in.readInt(); - photoSize = in.readInt(); - videoStartPosition = in.readInt(); - videoSize = in.readInt(); + photoStartPosition = in.readLong(); + photoSize = in.readLong(); + videoStartPosition = in.readLong(); + videoSize = in.readLong(); } @Override @@ -66,10 +68,10 @@ public boolean equals(@Nullable Object obj) { @Override public int hashCode() { int result = 17; - result = 31 * result + photoStartPosition; - result = 31 * result + photoSize; - result = 31 * result + videoStartPosition; - result = 31 * result + videoSize; + result = 31 * result + Longs.hashCode(photoStartPosition); + result = 31 * result + Longs.hashCode(photoSize); + result = 31 * result + Longs.hashCode(videoStartPosition); + result = 31 * result + Longs.hashCode(videoSize); return result; } @@ -89,10 +91,10 @@ public String toString() { @Override public void writeToParcel(Parcel dest, int flags) { - dest.writeInt(photoStartPosition); - dest.writeInt(photoSize); - dest.writeInt(videoStartPosition); - dest.writeInt(videoSize); + dest.writeLong(photoStartPosition); + dest.writeLong(photoSize); + dest.writeLong(videoStartPosition); + dest.writeLong(videoSize); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java index 72f6957865e..5a00cd66f81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java @@ -22,6 +22,9 @@ import android.os.Handler; import android.os.HandlerThread; import android.os.Message; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; @@ -43,8 +46,9 @@ private MetadataRetriever() {} /** * Retrieves the {@link TrackGroupArray} corresponding to a {@link MediaItem}. * - *

    This is equivalent to using {@code retrieveMetadata(new DefaultMediaSourceFactory(context), - * mediaItem)}. + *

    This is equivalent to using {@link #retrieveMetadata(MediaSourceFactory, MediaItem)} with a + * {@link DefaultMediaSourceFactory} and a {@link DefaultExtractorsFactory} with {@link + * Mp4Extractor#FLAG_READ_MOTION_PHOTO_METADATA} set. * * @param context The {@link Context}. * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. @@ -52,7 +56,12 @@ private MetadataRetriever() {} */ public static ListenableFuture retrieveMetadata( Context context, MediaItem mediaItem) { - return retrieveMetadata(new DefaultMediaSourceFactory(context), mediaItem); + ExtractorsFactory extractorsFactory = + new DefaultExtractorsFactory() + .setMp4ExtractorFlags(Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA); + MediaSourceFactory mediaSourceFactory = + new DefaultMediaSourceFactory(context, extractorsFactory); + return retrieveMetadata(mediaSourceFactory, mediaItem); } /** diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java index e666ec979d1..235639f6787 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java @@ -25,6 +25,7 @@ import android.os.SystemClock; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.metadata.mp4.MotionPhoto; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.util.concurrent.ListenableFuture; @@ -37,7 +38,7 @@ public class MetadataRetrieverTest { @Test - public void retrieveMetadata_singleMediaItem() throws Exception { + public void retrieveMetadata_singleMediaItem_outputsExpectedMetadata() throws Exception { Context context = ApplicationProvider.getApplicationContext(); MediaItem mediaItem = MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); @@ -55,7 +56,7 @@ public void retrieveMetadata_singleMediaItem() throws Exception { } @Test - public void retrieveMetadata_multipleMediaItems() throws Exception { + public void retrieveMetadata_multipleMediaItems_outputsExpectedMetadata() throws Exception { Context context = ApplicationProvider.getApplicationContext(); MediaItem mediaItem1 = MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); @@ -84,7 +85,28 @@ public void retrieveMetadata_multipleMediaItems() throws Exception { } @Test - public void retrieveMetadata_throwsErrorIfCannotLoad() { + public void retrieveMetadata_motionPhoto_outputsExpectedMetadata() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample_MP.heic")); + MotionPhoto expectedMotionPhoto = + new MotionPhoto( + /* photoStartPosition= */ 0, + /* photoSize= */ 28_853, + /* videoStartPosition= */ 28_869, + /* videoSize= */ 28_803); + + ListenableFuture trackGroupsFuture = retrieveMetadata(context, mediaItem); + TrackGroupArray trackGroups = waitAndGetTrackGroups(trackGroupsFuture); + + assertThat(trackGroups.length).isEqualTo(1); + assertThat(trackGroups.get(0).length).isEqualTo(1); + assertThat(trackGroups.get(0).getFormat(0).metadata.length()).isEqualTo(1); + assertThat(trackGroups.get(0).getFormat(0).metadata.get(0)).isEqualTo(expectedMotionPhoto); + } + + @Test + public void retrieveMetadata_invalidMediaItem_throwsError() { Context context = ApplicationProvider.getApplicationContext(); MediaItem mediaItem = MediaItem.fromUri(Uri.parse("asset://android_asset/media/does_not_exist")); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index 325dc24aeca..71e6c69887b 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -181,6 +181,9 @@ @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_moov = 0x6d6f6f76; + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mpvd = 0x6d707664; + @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_mvhd = 0x6d766864; 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 f9e70915bca..d478eb2b4bb 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 @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.mp4.MotionPhoto; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; @@ -61,19 +62,27 @@ public final class Mp4Extractor implements Extractor, SeekMap { public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new Mp4Extractor()}; /** - * Flags controlling the behavior of the extractor. Possible flag value is {@link - * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS}. + * Flags controlling the behavior of the extractor. Possible flag values are {@link + * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS} and {@link #FLAG_READ_MOTION_PHOTO_METADATA}. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {FLAG_WORKAROUND_IGNORE_EDIT_LISTS}) + value = {FLAG_WORKAROUND_IGNORE_EDIT_LISTS, FLAG_READ_MOTION_PHOTO_METADATA}) public @interface Flags {} /** * Flag to ignore any edit lists in the stream. */ public static final int FLAG_WORKAROUND_IGNORE_EDIT_LISTS = 1; + /** + * Flag to extract {@link MotionPhoto} metadata from HEIC motion photos following the Google + * Photos Motion Photo File Format V1.1. + * + *

    As playback is not supported for motion photos, this flag should only be used for metadata + * retrieval use cases. + */ + public static final int FLAG_READ_MOTION_PHOTO_METADATA = 1 << 1; /** Parser states. */ @Documented @@ -154,7 +163,8 @@ public Mp4Extractor(@Flags int flags) { @Override public boolean sniff(ExtractorInput input) throws IOException { - return Sniffer.sniffUnfragmented(input); + return Sniffer.sniffUnfragmented( + input, /* acceptHeic= */ (flags & FLAG_READ_MOTION_PHOTO_METADATA) != 0); } @Override @@ -335,6 +345,14 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { this.atomData = atomData; parserState = STATE_READING_ATOM_PAYLOAD; } else { + if (atomType == Atom.TYPE_mpvd && (flags & FLAG_READ_MOTION_PHOTO_METADATA) != 0) { + // There is no need to parse the mpvd atom payload. All the necessary information is in the + // header. + processMpvdBox( + /* atomStartPosition= */ input.getPosition() - atomHeaderBytesRead, + /* atomHeaderSize= */ atomHeaderBytesRead, + atomSize); + } atomData = null; parserState = STATE_READING_ATOM_PAYLOAD; } @@ -662,6 +680,26 @@ private void maybeSkipRemainingMetaAtomHeaderBytes(ExtractorInput input) throws } } + /** + * Processes the Motion Photo Video Data of an HEIC motion photo following the Google Photos + * Motion Photo File Format V1.1. This consists in adding a track with the motion photo metadata + * and ending playback preparation. + */ + private void processMpvdBox(long atomStartPosition, int atomHeaderSize, long atomSize) { + ExtractorOutput extractorOutput = checkNotNull(this.extractorOutput); + extractorOutput.seekMap(new SeekMap.Unseekable(/* durationUs= */ C.TIME_UNSET)); + + TrackOutput trackOutput = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_IMAGE); + MotionPhoto motionPhoto = + new MotionPhoto( + /* photoStartPosition= */ 0, + /* photoSize= */ atomStartPosition, + /* videoStartPosition= */ atomStartPosition + atomHeaderSize, + /* videoSize= */ atomSize - atomHeaderSize); + trackOutput.format(new Format.Builder().setMetadata(new Metadata(motionPhoto)).build()); + extractorOutput.endTracks(); + } + /** * For each sample of each track, calculates accumulated size of all samples which need to be read * before this sample can be used. diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java index 00acb299066..f830c86edb3 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java @@ -70,7 +70,7 @@ * @throws IOException If an error occurs reading from the input. */ public static boolean sniffFragmented(ExtractorInput input) throws IOException { - return sniffInternal(input, true); + return sniffInternal(input, /* fragmented= */ true, /* acceptHeic= */ false); } /** @@ -82,10 +82,24 @@ public static boolean sniffFragmented(ExtractorInput input) throws IOException { * @throws IOException If an error occurs reading from the input. */ public static boolean sniffUnfragmented(ExtractorInput input) throws IOException { - return sniffInternal(input, false); + return sniffInternal(input, /* fragmented= */ false, /* acceptHeic= */ false); } - private static boolean sniffInternal(ExtractorInput input, boolean fragmented) + /** + * Returns whether data peeked from the current position in {@code input} is consistent with the + * input being an unfragmented MP4 file. + * + * @param input The extractor input from which to peek data. The peek position will be modified. + * @param acceptHeic Whether {@code true} should be returned for HEIC photos. + * @return Whether the input appears to be in the unfragmented MP4 format. + * @throws IOException If an error occurs reading from the input. + */ + public static boolean sniffUnfragmented(ExtractorInput input, boolean acceptHeic) + throws IOException { + return sniffInternal(input, /* fragmented= */ false, acceptHeic); + } + + private static boolean sniffInternal(ExtractorInput input, boolean fragmented, boolean acceptHeic) throws IOException { long inputLength = input.getLength(); int bytesToSearch = (int) (inputLength == C.LENGTH_UNSET || inputLength > SEARCH_LENGTH @@ -165,7 +179,7 @@ private static boolean sniffInternal(ExtractorInput input, boolean fragmented) if (i == 1) { // This index refers to the minorVersion, not a brand, so skip it. buffer.skipBytes(4); - } else if (isCompatibleBrand(buffer.readInt())) { + } else if (isCompatibleBrand(buffer.readInt(), acceptHeic)) { foundGoodFileType = true; break; } @@ -185,9 +199,12 @@ private static boolean sniffInternal(ExtractorInput input, boolean fragmented) /** * Returns whether {@code brand} is an ftyp atom brand that is compatible with the MP4 extractors. */ - private static boolean isCompatibleBrand(int brand) { - // Accept all brands starting '3gp'. + private static boolean isCompatibleBrand(int brand, boolean acceptHeic) { if (brand >>> 8 == 0x00336770) { + // Brand starts with '3gp'. + return true; + } else if (brand == 0x68656963 && acceptHeic) { + // Brand is `heic` and HEIC is supported by the extractor. return true; } for (int compatibleBrand : COMPATIBLE_BRANDS) { diff --git a/testdata/src/test/assets/media/mp4/sample_MP.heic b/testdata/src/test/assets/media/mp4/sample_MP.heic new file mode 100644 index 0000000000000000000000000000000000000000..68dd4c4d6de107de7490574c421a4458fb17ff60 GIT binary patch literal 57672 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 z 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 Date: Thu, 22 Oct 2020 12:32:37 +0100 Subject: [PATCH 180/693] Add missing release note PiperOrigin-RevId: 338446775 --- RELEASENOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9ce93ef81d9..f3d7e1ee3b7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -29,6 +29,8 @@ ### 2.12.1 (2020-10-23) ### * Core library: + * Fix issue where `Player.setMediaItems` would ignore its `resetPosition` + argument ([#8024](https://github.com/google/ExoPlayer/issues/8024)). * Fix bug where streams with highly uneven track durations may get stuck in a buffering state ([#7943](https://github.com/google/ExoPlayer/issues/7943)). From 18c6b16f9116d5eec15a89a82540b9ff97578637 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 22 Oct 2020 15:38:06 +0100 Subject: [PATCH 181/693] Switch playback tests to single-parameter parameterized syntax This is simpler than instantiating a single-element string array for every parameter. PiperOrigin-RevId: 338469324 --- .../exoplayer2/e2etest/FlacPlaybackTest.java | 20 +++++----- .../exoplayer2/e2etest/Mp3PlaybackTest.java | 19 +++++----- .../exoplayer2/e2etest/Mp4PlaybackTest.java | 38 ++++++++++--------- .../exoplayer2/e2etest/TsPlaybackTest.java | 38 +++++++++---------- 4 files changed, 59 insertions(+), 56 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlacPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlacPlaybackTest.java index 31a7834d087..0affce8eab1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlacPlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlacPlaybackTest.java @@ -42,17 +42,17 @@ public class FlacPlaybackTest { @Parameters(name = "{0}") - public static ImmutableList params() { + public static ImmutableList mediaSamples() { return ImmutableList.of( - new String[] {"bear.flac"}, - new String[] {"bear_no_min_max_frame_size.flac"}, - new String[] {"bear_no_num_samples.flac"}, - new String[] {"bear_no_seek_table_no_num_samples.flac"}, - new String[] {"bear_one_metadata_block.flac"}, - new String[] {"bear_uncommon_sample_rate.flac"}, - new String[] {"bear_with_id3.flac"}, - new String[] {"bear_with_picture.flac"}, - new String[] {"bear_with_vorbis_comments.flac"}); + "bear.flac", + "bear_no_min_max_frame_size.flac", + "bear_no_num_samples.flac", + "bear_no_seek_table_no_num_samples.flac", + "bear_one_metadata_block.flac", + "bear_uncommon_sample_rate.flac", + "bear_with_id3.flac", + "bear_with_picture.flac", + "bear_with_vorbis_comments.flac"); } @Parameter public String inputFile; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp3PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp3PlaybackTest.java index f27e25dec09..77f45291c52 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp3PlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp3PlaybackTest.java @@ -32,6 +32,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; import org.robolectric.annotation.Config; /** End-to-end tests using MP3 samples. */ @@ -39,16 +40,16 @@ @Config(sdk = 29) @RunWith(ParameterizedRobolectricTestRunner.class) public final class Mp3PlaybackTest { - @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") - public static ImmutableList params() { + @Parameters(name = "{0}") + public static ImmutableList mediaSamples() { return ImmutableList.of( - new String[] {"bear-cbr-constant-frame-size-no-seek-table.mp3"}, - new String[] {"bear-cbr-variable-frame-size-no-seek-table.mp3"}, - new String[] {"bear-id3.mp3"}, - new String[] {"bear-vbr-no-seek-table.mp3"}, - new String[] {"bear-vbr-xing-header.mp3"}, - new String[] {"play-trimmed.mp3"}, - new String[] {"test.mp3"}); + "bear-cbr-constant-frame-size-no-seek-table.mp3", + "bear-cbr-variable-frame-size-no-seek-table.mp3", + "bear-id3.mp3", + "bear-vbr-no-seek-table.mp3", + "bear-vbr-xing-header.mp3", + "play-trimmed.mp3", + "test.mp3"); } @ParameterizedRobolectricTestRunner.Parameter public String inputFile; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java index fc6c890bc5f..499aa3105de 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java @@ -32,6 +32,8 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; import org.robolectric.annotation.Config; /** End-to-end tests using MP4 samples. */ @@ -43,27 +45,27 @@ public class Mp4PlaybackTest { // TODO: Add samples with >2 audio channels when supported (sample_ac3_fragmented.mp4, // sample_ac3.mp4sample_eac3.mp4, sample_eac3_fragmented.mp4, sample_eac3joc.mp4, // sample_eac3joc_fragmented.mp4). - @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") - public static ImmutableList params() { + @Parameters(name = "{0}") + public static ImmutableList mediaSamples() { return ImmutableList.of( - new String[] {"midroll-5s.mp4"}, - new String[] {"postroll-5s.mp4"}, - new String[] {"preroll-5s.mp4"}, - new String[] {"sample_ac4_fragmented.mp4"}, - new String[] {"sample_ac4.mp4"}, - new String[] {"sample_android_slow_motion.mp4"}, - new String[] {"sample_fragmented.mp4"}, - new String[] {"sample_fragmented_seekable.mp4"}, - new String[] {"sample_fragmented_sei.mp4"}, - new String[] {"sample_mdat_too_long.mp4"}, - new String[] {"sample.mp4"}, - new String[] {"sample_opus_fragmented.mp4"}, - new String[] {"sample_opus.mp4"}, - new String[] {"sample_partially_fragmented.mp4"}, - new String[] {"testvid_1022ms.mp4"}); + "midroll-5s.mp4", + "postroll-5s.mp4", + "preroll-5s.mp4", + "sample_ac4_fragmented.mp4", + "sample_ac4.mp4", + "sample_android_slow_motion.mp4", + "sample_fragmented.mp4", + "sample_fragmented_seekable.mp4", + "sample_fragmented_sei.mp4", + "sample_mdat_too_long.mp4", + "sample.mp4", + "sample_opus_fragmented.mp4", + "sample_opus.mp4", + "sample_partially_fragmented.mp4", + "testvid_1022ms.mp4"); } - @ParameterizedRobolectricTestRunner.Parameter public String inputFile; + @Parameter public String inputFile; @Rule public ShadowMediaCodecConfig mediaCodecConfig = diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java index 3a48463c731..2407631b90e 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java @@ -44,26 +44,26 @@ public class TsPlaybackTest { // TODO: Add samples with >2 audio channels when supported (sample.ac3, sample_ac3.ts, // sample.eac3, sample_eac3joc.ec3, sample_eac3joc.ts, sample_eac3.ts). @Parameters(name = "{0}") - public static ImmutableList params() { + public static ImmutableList mediaSamples() { return ImmutableList.of( - new String[] {"bbb_2500ms.ts"}, - new String[] {"elephants_dream.mpg"}, - new String[] {"sample.ac4"}, - new String[] {"sample_ac4.ts"}, - new String[] {"sample.adts"}, - new String[] {"sample_ait.ts"}, - new String[] {"sample_cbs_truncated.adts"}, - new String[] {"sample_h262_mpeg_audio.ps"}, - new String[] {"sample_h262_mpeg_audio.ts"}, - new String[] {"sample_h263.ts"}, - new String[] {"sample_h264_dts_audio.ts"}, - new String[] {"sample_h264_mpeg_audio.ts"}, - new String[] {"sample_h264_no_access_unit_delimiters.ts"}, - new String[] {"sample_h265.ts"}, - new String[] {"sample_latm.ts"}, - new String[] {"sample_scte35.ts"}, - new String[] {"sample_with_id3.adts"}, - new String[] {"sample_with_junk"}); + "bbb_2500ms.ts", + "elephants_dream.mpg", + "sample.ac4", + "sample_ac4.ts", + "sample.adts", + "sample_ait.ts", + "sample_cbs_truncated.adts", + "sample_h262_mpeg_audio.ps", + "sample_h262_mpeg_audio.ts", + "sample_h263.ts", + "sample_h264_dts_audio.ts", + "sample_h264_mpeg_audio.ts", + "sample_h264_no_access_unit_delimiters.ts", + "sample_h265.ts", + "sample_latm.ts", + "sample_scte35.ts", + "sample_with_id3.adts", + "sample_with_junk"); } @Parameter public String inputFile; From 1191820429de7860e0295068107ef12107e3e9e8 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Thu, 22 Oct 2020 16:23:54 +0100 Subject: [PATCH 182/693] Allow additional entries in MetadataUtil.setFormatMetadata. The primary use of this currently will be for appending SEF metadata. PiperOrigin-RevId: 338475948 --- .../exoplayer2/extractor/mp4/MetadataUtil.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java index b61b5134b2e..4a048f8b646 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -293,7 +293,10 @@ public static void setFormatMetadata( @Nullable Metadata udtaMetadata, @Nullable Metadata mdtaMetadata, GaplessInfoHolder gaplessInfoHolder, - Format.Builder formatBuilder) { + Format.Builder formatBuilder, + Metadata.Entry... additionalEntries) { + Metadata formatMetadata = new Metadata(); + if (trackType == C.TRACK_TYPE_AUDIO) { if (gaplessInfoHolder.hasGaplessInfo()) { formatBuilder @@ -302,7 +305,7 @@ public static void setFormatMetadata( } // We assume all udta metadata is associated with the audio track. if (udtaMetadata != null) { - formatBuilder.setMetadata(udtaMetadata); + formatMetadata = udtaMetadata; } } else if (trackType == C.TRACK_TYPE_VIDEO && mdtaMetadata != null) { // Populate only metadata keys that are known to be specific to video. @@ -318,9 +321,15 @@ public static void setFormatMetadata( } } if (!mdtaMetadataEntries.isEmpty()) { - formatBuilder.setMetadata(new Metadata(mdtaMetadataEntries)); + formatMetadata = new Metadata(mdtaMetadataEntries); } } + + formatMetadata = formatMetadata.copyWithAppendedEntries(additionalEntries); + + if (formatMetadata.length() > 0) { + formatBuilder.setMetadata(formatMetadata); + } } /** From 521a220728d62a9af21376ff400480bb93aa4741 Mon Sep 17 00:00:00 2001 From: kimvde Date: Thu, 22 Oct 2020 18:14:08 +0100 Subject: [PATCH 183/693] Avoid throwing for still photo metadata retrieval PiperOrigin-RevId: 338497163 --- .../exoplayer2/MetadataRetrieverTest.java | 27 ++++- .../extractor/mp4/Mp4Extractor.java | 104 +++++++++++------- .../exoplayer2/extractor/mp4/Sniffer.java | 10 +- .../assets/media/mp4/sample_still_photo.heic | Bin 0 -> 42283 bytes 4 files changed, 95 insertions(+), 46 deletions(-) create mode 100644 testdata/src/test/assets/media/mp4/sample_still_photo.heic diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java index 235639f6787..c8fd43677d7 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java @@ -30,6 +30,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.util.concurrent.ListenableFuture; import java.util.concurrent.ExecutionException; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -37,9 +38,15 @@ @RunWith(AndroidJUnit4.class) public class MetadataRetrieverTest { + private Context context; + + @Before + public void setUp() throws Exception { + context = ApplicationProvider.getApplicationContext(); + } + @Test public void retrieveMetadata_singleMediaItem_outputsExpectedMetadata() throws Exception { - Context context = ApplicationProvider.getApplicationContext(); MediaItem mediaItem = MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); @@ -57,7 +64,6 @@ public void retrieveMetadata_singleMediaItem_outputsExpectedMetadata() throws Ex @Test public void retrieveMetadata_multipleMediaItems_outputsExpectedMetadata() throws Exception { - Context context = ApplicationProvider.getApplicationContext(); MediaItem mediaItem1 = MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); MediaItem mediaItem2 = @@ -85,8 +91,7 @@ public void retrieveMetadata_multipleMediaItems_outputsExpectedMetadata() throws } @Test - public void retrieveMetadata_motionPhoto_outputsExpectedMetadata() throws Exception { - Context context = ApplicationProvider.getApplicationContext(); + public void retrieveMetadata_heicMotionPhoto_outputsExpectedMetadata() throws Exception { MediaItem mediaItem = MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample_MP.heic")); MotionPhoto expectedMotionPhoto = @@ -105,9 +110,21 @@ public void retrieveMetadata_motionPhoto_outputsExpectedMetadata() throws Except assertThat(trackGroups.get(0).getFormat(0).metadata.get(0)).isEqualTo(expectedMotionPhoto); } + @Test + public void retrieveMetadata_heicStillPhoto_outputsEmptyMetadata() throws Exception { + MediaItem mediaItem = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample_still_photo.heic")); + + ListenableFuture trackGroupsFuture = retrieveMetadata(context, mediaItem); + TrackGroupArray trackGroups = waitAndGetTrackGroups(trackGroupsFuture); + + assertThat(trackGroups.length).isEqualTo(1); + assertThat(trackGroups.get(0).length).isEqualTo(1); + assertThat(trackGroups.get(0).getFormat(0).metadata).isNull(); + } + @Test public void retrieveMetadata_invalidMediaItem_throwsError() { - Context context = ApplicationProvider.getApplicationContext(); MediaItem mediaItem = MediaItem.fromUri(Uri.parse("asset://android_asset/media/does_not_exist")); 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 d478eb2b4bb..313f1cebe79 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.extractor.mp4.Sniffer.BRAND_HEIC; +import static com.google.android.exoplayer2.extractor.mp4.Sniffer.BRAND_QUICKTIME; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; import static java.lang.Math.max; @@ -94,8 +96,15 @@ public final class Mp4Extractor implements Extractor, SeekMap { private static final int STATE_READING_ATOM_PAYLOAD = 1; private static final int STATE_READING_SAMPLE = 2; - /** Brand stored in the ftyp atom for QuickTime media. */ - private static final int BRAND_QUICKTIME = 0x71742020; + /** Supported file types. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({FILE_TYPE_MP4, FILE_TYPE_QUICKTIME, FILE_TYPE_HEIC}) + private @interface FileType {} + + private static final int FILE_TYPE_MP4 = 0; + private static final int FILE_TYPE_QUICKTIME = 1; + private static final int FILE_TYPE_HEIC = 2; /** * When seeking within the source, if the offset is greater than or equal to this value (or the @@ -133,10 +142,12 @@ public final class Mp4Extractor implements Extractor, SeekMap { // Extractor outputs. private @MonotonicNonNull ExtractorOutput extractorOutput; private Mp4Track @MonotonicNonNull [] tracks; + private long @MonotonicNonNull [][] accumulatedSampleSizes; private int firstVideoTrackIndex; private long durationUs; - private boolean isQuickTime; + @FileType private int fileType; + @Nullable private MotionPhoto motionPhoto; /** * Creates a new extractor for unfragmented MP4 streams. @@ -290,6 +301,7 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { if (atomHeaderBytesRead == 0) { // Read the standard length atom header. if (!input.readFully(atomHeader.getData(), 0, Atom.HEADER_SIZE, true)) { + processEndOfStreamReadingAtomHeader(); return false; } atomHeaderBytesRead = Atom.HEADER_SIZE; @@ -345,14 +357,7 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { this.atomData = atomData; parserState = STATE_READING_ATOM_PAYLOAD; } else { - if (atomType == Atom.TYPE_mpvd && (flags & FLAG_READ_MOTION_PHOTO_METADATA) != 0) { - // There is no need to parse the mpvd atom payload. All the necessary information is in the - // header. - processMpvdBox( - /* atomStartPosition= */ input.getPosition() - atomHeaderBytesRead, - /* atomHeaderSize= */ atomHeaderBytesRead, - atomSize); - } + processUnparsedAtom(input.getPosition() - atomHeaderBytesRead); atomData = null; parserState = STATE_READING_ATOM_PAYLOAD; } @@ -374,7 +379,7 @@ private boolean readAtomPayload(ExtractorInput input, PositionHolder positionHol if (atomData != null) { input.readFully(atomData.getData(), atomHeaderBytesRead, (int) atomPayloadSize); if (atomType == Atom.TYPE_ftyp) { - isQuickTime = processFtypAtom(atomData); + fileType = processFtypAtom(atomData); } else if (!containerAtoms.isEmpty()) { containerAtoms.peek().add(new Atom.LeafAtom(atomType, atomData)); } @@ -418,6 +423,7 @@ private void processMoovAtom(ContainerAtom moov) throws ParserException { // Process metadata. @Nullable Metadata udtaMetadata = null; + boolean isQuickTime = fileType == FILE_TYPE_QUICKTIME; GaplessInfoHolder gaplessInfoHolder = new GaplessInfoHolder(); @Nullable Atom.LeafAtom udta = moov.getLeafAtomOfType(Atom.TYPE_udta); if (udta != null) { @@ -655,6 +661,19 @@ private void updateSampleIndices(long timeUs) { } } + /** Processes the end of stream in case there is not atom left to read. */ + private void processEndOfStreamReadingAtomHeader() { + if (fileType == FILE_TYPE_HEIC && (flags & FLAG_READ_MOTION_PHOTO_METADATA) != 0) { + // Add image track and prepare media. + ExtractorOutput extractorOutput = checkNotNull(this.extractorOutput); + TrackOutput trackOutput = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_IMAGE); + @Nullable Metadata metadata = motionPhoto == null ? null : new Metadata(motionPhoto); + trackOutput.format(new Format.Builder().setMetadata(metadata).build()); + extractorOutput.endTracks(); + extractorOutput.seekMap(new SeekMap.Unseekable(/* durationUs= */ C.TIME_UNSET)); + } + } + /** * Possibly skips the version and flags fields (1+3 byte) of a full meta atom of the {@code * input}. @@ -680,24 +699,18 @@ private void maybeSkipRemainingMetaAtomHeaderBytes(ExtractorInput input) throws } } - /** - * Processes the Motion Photo Video Data of an HEIC motion photo following the Google Photos - * Motion Photo File Format V1.1. This consists in adding a track with the motion photo metadata - * and ending playback preparation. - */ - private void processMpvdBox(long atomStartPosition, int atomHeaderSize, long atomSize) { - ExtractorOutput extractorOutput = checkNotNull(this.extractorOutput); - extractorOutput.seekMap(new SeekMap.Unseekable(/* durationUs= */ C.TIME_UNSET)); - - TrackOutput trackOutput = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_IMAGE); - MotionPhoto motionPhoto = - new MotionPhoto( - /* photoStartPosition= */ 0, - /* photoSize= */ atomStartPosition, - /* videoStartPosition= */ atomStartPosition + atomHeaderSize, - /* videoSize= */ atomSize - atomHeaderSize); - trackOutput.format(new Format.Builder().setMetadata(new Metadata(motionPhoto)).build()); - extractorOutput.endTracks(); + /** Processes an atom whose payload does not need to be parsed. */ + private void processUnparsedAtom(long atomStartPosition) { + if (atomType == Atom.TYPE_mpvd) { + // The input is an HEIC motion photo following the Google Photos Motion Photo File Format + // V1.1. + motionPhoto = + new MotionPhoto( + /* photoStartPosition= */ 0, + /* photoSize= */ atomStartPosition, + /* videoStartPosition= */ atomStartPosition + atomHeaderBytesRead, + /* videoSize= */ atomSize - atomHeaderBytesRead); + } } /** @@ -779,24 +792,39 @@ private static int getSynchronizationSampleIndex(TrackSampleTable sampleTable, l } /** - * Process an ftyp atom to determine whether the media is QuickTime. + * Process an ftyp atom to determine the corresponding {@link FileType}. * * @param atomData The ftyp atom data. - * @return Whether the media is QuickTime. + * @return The {@link FileType}. */ - private static boolean processFtypAtom(ParsableByteArray atomData) { + @FileType + private static int processFtypAtom(ParsableByteArray atomData) { atomData.setPosition(Atom.HEADER_SIZE); int majorBrand = atomData.readInt(); - if (majorBrand == BRAND_QUICKTIME) { - return true; + @FileType int fileType = brandToFileType(majorBrand); + if (fileType != FILE_TYPE_MP4) { + return fileType; } atomData.skipBytes(4); // minor_version while (atomData.bytesLeft() > 0) { - if (atomData.readInt() == BRAND_QUICKTIME) { - return true; + fileType = brandToFileType(atomData.readInt()); + if (fileType != FILE_TYPE_MP4) { + return fileType; } } - return false; + return FILE_TYPE_MP4; + } + + @FileType + private static int brandToFileType(int brand) { + switch (brand) { + case BRAND_QUICKTIME: + return FILE_TYPE_QUICKTIME; + case BRAND_HEIC: + return FILE_TYPE_HEIC; + default: + return FILE_TYPE_MP4; + } } /** Returns whether the extractor should decode a leaf atom with type {@code atom}. */ diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java index f830c86edb3..409931b1de1 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java @@ -26,6 +26,11 @@ */ /* package */ final class Sniffer { + /** Brand stored in the ftyp atom for QuickTime media. */ + public static final int BRAND_QUICKTIME = 0x71742020; + /** Brand stored in the ftyp atom for HEIC media. */ + public static final int BRAND_HEIC = 0x68656963; + /** The maximum number of bytes to peek when sniffing. */ private static final int SEARCH_LENGTH = 4 * 1024; @@ -54,7 +59,7 @@ 0x66347620, // f4v[space] 0x6b646469, // kddi 0x4d345650, // M4VP - 0x71742020, // qt[space][space], Apple QuickTime + BRAND_QUICKTIME, // qt[space][space] 0x4d534e56, // MSNV, Sony PSP 0x64627931, // dby1, Dolby Vision 0x69736d6c, // isml @@ -203,8 +208,7 @@ private static boolean isCompatibleBrand(int brand, boolean acceptHeic) { if (brand >>> 8 == 0x00336770) { // Brand starts with '3gp'. return true; - } else if (brand == 0x68656963 && acceptHeic) { - // Brand is `heic` and HEIC is supported by the extractor. + } else if (brand == BRAND_HEIC && acceptHeic) { return true; } for (int compatibleBrand : COMPATIBLE_BRANDS) { diff --git a/testdata/src/test/assets/media/mp4/sample_still_photo.heic b/testdata/src/test/assets/media/mp4/sample_still_photo.heic new file mode 100644 index 0000000000000000000000000000000000000000..0a673803b7b8e6d9e698cef697da5e44928e1d1b GIT binary patch literal 42283 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

    7x({L zFgtCgvx+Yy{uWegE*P!zUzZY38{tqXFrGQ1(k#EaAd0Oua2?M!8Oz;OcHmS&rjTBk zdvO2eg1}bJRCL<3vp^0bP<}(VHY?u>J8!hJT^c`#v84~6sO~McR(7_;Y9nScK3&dS z1#I;g{OcktOumM4N#M&4KRl~xi{Tjl5z6XqhA`v&k?Xu+q5W^GA0(cD-9;sd#!2pX zZ?N$;F90BuF+dm{1~6_Q=pzLwk`<@0L@^A6U3B$Dnq68XmE*#YCds{dA! z1<5G|&a{jAMBt7FsBs=u@n6u{l*>QM-aNT@fi6dc(0>ssZyqf_4eY;zF^LuF*0>q? z^tYg-3f-S5V*v`&2%j6b)bPN*J@j%?UZmq(_W{wI*JAlkHcX|i4v|RYRBP|FaR~J2 zXU1YSorQTE;vM4epWD-ElPKM!sg02ve|U4_WB$^p1J5$TIHLryZut%noO}4!@^oF{ zGSH$ekAmxhXlR1{PGsbYS3a-R*EPGG$H)(AbEUbxv2#khOZAM@leO+=E{*8j#AdO z2Y_7)GGIb?e&IU+KSC7FQj^t{D=MV~Pk)stkdx#x= z1;+%H)Yh`eDSfBo7F5=xi93|)!35#Ux}GN!*(UQVJj{e;Tp_O*Z}EkwJC_rj*%eT& zO6^^au&>7==h<^mA=ajbIOepS>s)`-#?X(H)p(r&ZV#Jt?UBss7Gby6r>u8`5Vc~b zB%=CadXUM=Hklvn!u5JOB{vaElWNXpKt?@c>r-x1c~FqC6}tkdCr^GlZnCphlHVis z$@<74uQYL40~V{@skgp{Io`a&f}wiSPXT7G6ja57^%DWg5|ym*5+I&;cF{a`&uREo zj1nVLn5BD1g_2faP-uuFu)ZU^kKsU6((crMwU9-7ra7^pV<|sA{lR*Op!Go{H0@IN z^vdf}A(;Y`H^-8CKQ86XE~ORNg8CA=pk1nq$+f6cvUlcx>?u)qP%Yr* zviZlPT{blQ6aI(^4k%UM<0RW35k2=$C7#nd_wKpP$TySmmqKnda5=T@R<+JKPIM~q zKa-xNnOT>PwixNRj)FuJj><#Dw3&lVG8+r0dbYl8vKB(bhdQvn(S?XmPvg!>AcGRt zSc?S;m#EJXYBIB8aEHVSgq0*G&t1ChlXz-o-wmaNQ2hDO(r#zDA9g%HgZR6@slZRD z;A#mKq!o#LmKsm6!=%smL`ms79HOb#dm=Hv$EnqBBIcaG=t%sf1uGtzRx5u`qn7e# zXYckda!hTjn7{v!aAH8+;tHIs<3Qivz_4N7r;0%6O&x4`c8YSG%E4~lr*P&zI>Xbo z?G{H<_(685SXmvi1)mspb2EpH({Y~oP{vpH9F36yM}eMTR`{gk%wne2Eqw}aML^Ye zv2AcrOfJez-`?xIz+zp&#u!qlL}{h1OKYsp26!^#-XyhPFc+gFQX9OhaM{#RD0wD6 zC~{3?(!^%ypgiPg+!k4MM=WNyiYJY{u3~8#iG6}Ilbo={lvxlNF5b%cS;T$MA>eZF z>jhwh?zn8G@LxT2A;taSL+{1;`Q#Qz90PSS;ehw4)W8<-sa|g!x$kZrVWuDoxi%q& ztB7bzUJw@D`K#IzTN!_`^q|eFloFG>i{z+cRK;OIPSSL!W9)rM=Lb1rB0LK8->jfE zoroVa4uf2;eEg=^AMS4FsxY%c?5rEIsB@OU1F}0Y4}IBpWC|o+c&~t0uLt|XF6!R| zJP7lZ<6XirDkLX|> z*OU`epbu|8-9fZ^9??K0bjT_O6?einF>|4!Kz2sv-818rgQ{_v0?33+p%;5cMW4O3 z!oytPa?NApotJ{DL8>NCQ@H7jf5&x~!HAk3s^O(6CZCU*_N*XU>2#5!=`s zG;(>dsbQxMj8C|;-r+m@=$Oz32OE|HoZ2qzpQxmN6$(eUcgqp1qqZ_920d%%xx2cB zM_v?BGa&|t*=JdS=JB<;fD$*!AI7AEwX?m8mOj-n?-DjPUnxTalg3awRKoMaNeJmP zXqF(VS}(`)kPb9)7dK`zWsWroSgg>}{|_t9!0IGe{noxtYs7pKU!AhqxMCUq)gklG&0!T2{Auzq%i99rnvpVkzT>19%vuX=RhxT1zJE+5 zjVc+3;_qF7k$x8uotK0Bn0lYCDR-#s!;ZEZ9KyaAw{+4kb?o6|w`AgfC&KetyA>=S3uf-F_(p4*TTE z|Ci7Wf9F0`(>nmsVe+)rv7Ki`4S#-81N7Gm&7)exWj2*G)2d_$TnOc^{2-9qL5h(0 zpexmji1fd@L9TW^u&fm;(Yc>f1-@C=@GDQ#6oKXwD=YEla?%51_w&8z3uA76s{f6j zW7N(Y3eCKdY5OZLWVQ>{yEj?iEiInsNAUb`=QIbmcYQ$K()%=oXw=l=P)6C%8k1eE!7BGb{#xK(& zZvND_Bu2Yfh{0!lLyt3aHvws$Jw3;QrxuYA5^=4~h!VR^FL+#ph%T!r=k%xs&M|UD2r^ z75GbD;j+civ(c9*M!Z8B0*5g?9Th2xd&+0P?(L<@4{h4y@8%gJB@eAB-=RgP<87rA z2=U<=cNra=Sn#q3rZ|Q?Y*7@FGJ{wWY&lg7WldYK@Sk;Q{(2siS?TF8$6^(cj3kWX zAsax%)i?oKc?S)-)qw)nB0Z`Ndvi-K^vX%#yAg$JKK_~)sZVr=cSc3`VI&#{`q=VekkuK4P#(eZ)2U0 z&C%<6|H6XJ9W9#4DX5x9^{=YdoX^T2x2u=6kYS>0=PM{t$4`kz2FHX>9#>RgIeNTN zt=aD})PyWC4-2Kse^3P~1O05GzkMpJ_ByzolU6L?DJl!{Mc13(-IX5B3KM@9VA4wi z#@oe_?#lKo&TiF0=inS`i^%YSHFu{d3Iy6m**rG z#!N@GU*fx5ev$|#sS$VtMDL#_4AE}ct@EWKl*&M}T%10$n&aS=Q zMa>`O-Q=_kqXL1|4FdcfNd%EU`A2y*${6@Cs!kTTdb`+v1gyP{^Fp_3l^&?Sg!xab z25=U~Zh=}*JNJdf=tjsEp9!}IXlIjWnXG=@P)KN6d9OuwfTh?(cz<;lpRCybJ%i7r zSWC(laZ#O&9p3#**c*rF5VIYYi26|qUq{lU2oG~5yVyAe-OiaJGNGdHGE6z(1IvQx z{^Jx1F%L;JiDo=gASMr{^_8#bDSgpW8*|24i>$K(dx#0Ap}*4(L$ zFx*IPVz*)$;c03>bM3;t*O9RI;{|VdT5(Ox0ko4s9fJ)VuEZ!5=PQs-6uxjC0K6Hi z*|&J%hyWQZ6P-Uj#Sw=wox&R7ULBIzo}4_ZUU0S36~^o}Sjo#?Xsy`{O!k+IJR0_v z+pUzh7`ABCgQOz$W-lEIM`}}gl9j-GiLJs3x>f*HH#%BiWC;x8(??I61Pog|MB)+m zJ-4Lsi92iT6e#7dk(dvV4qH5LP81<3kBzex8*)qnC;;)T0Ebw?=g^1fevVg35fK<8 zxT1Nzd0@#4S`SxT_zpc&r2g4hv9V-adT_{lnj^Bmc`NfHjoFoH%YOh^84Nxh#?fG0 z$u&6;hd-1xBBK&&%Vup0x)2xBbyeVqX5~CdQ+bJI2ql=~&?ASOydPhHw_mjZg2|nf z(n+Ca%DE)0!7SnOSN!>SIxj6HJC!E`k^2nP9dqG5W2?;zBFPC92MZE0W`Z~p=|B9At8&%?0PbNw^m{q&VfndW0mg4CFqtH?G6^LmdYk zo7aS^l7}ZC*pRYso0u!Kdd{Nf01;Fpz8AV6I(O;05Nh?*EDsI zP^AU(ky=I*533ka-EI!rM?JV6&BC35tG=|)k;d_L!6t*jxzzZMRoc;a4WaWR?7bMjvdT8{b z1wt0B`+qP9R<{ksnDDuEDrFCBskj=yhCDW8^LY-$*$qqCf~Ef;`(z9gj_ZKT9Ybx za5|v@sViQtWq%ng*w`&@rhu3^5vqzHL@7!0r?Q0FljME>v3Vv1!xP|t_X5qv=+P6$ zY!@d9Y8-)3)Gfo=*nflv$YOuvO3f3yOoGO1L!ZF( z7md*5y#(AhUWU2|h>j-YU^yH+{xT3s9j3G4UHoY((AXcOOF-DUdawMy6;7B!dAcb~ zRl@+L)ww-VDCV%pyZ4lj zeFc)k0!j-|24={v4v9HdyU3+`SgF^i*$o9~w+stpD35uz8FDJV^zB5AErMO4RX_%m z)twm=&7sjX?FF!y^rg?AJ@JcUznBKC8iOZ`p~Kp3HpCH@bHOVUxvrB;r}}joU0A%s z*ql)mTu(c?ZKUHr)qk(tnh%`r@4;0C=(c|=jpY&KGA@;1Fr~$!6ZCVH7<5@}F4HWKm0g>qP)m zG)WG-t$i+uttT}g1dxEu5c+Yih3eNjyMiDD6A`5EuT6VR44dI_@aY}^avlNIhpWK< ztIe`AU<8l)jz6y8!?W>|2~W>@r{M?s zO1c5J^?rAh+e<171w*4~L@OtlQa|cbU5?PPqrs1ei5|Sc6m$5lUZi0%omJA$U{Yfh z;IUQ`3%9+tG=bi8|LLG#XtGv$6%Pt=zX!KIf6eZjQ_bJp&d0jB$r@qcgyMzBdM8<% z4CYAi6@dfmxb~63)m*K53Ca)ME>m>T zJTGB3Q;l%7#nf9sGH7mFR^xXERSjRfOkJOS}Dor3N4#>?Cjj1}( zZZ7p>HaEf#l>nhhqnSAIl{dP(>fwOHajaTandA)CCN`IhYZPX&o{sMD7!NvmMQYht zD0RL#@bDD|GW#79k0G9n^bPZYZy-9GU{XCPk>w` zj=z~Wy*pEnZvQDaR|fK!?aB<@UPn;QPx{oFI!N2P zo_3Ui-iqlUdl=7nkO^%l2(o4L5;0C9$KiR`g;GB6il+FE$(4?soUlNA{v_s>&Q;_J zNK2C(1M##_%#QDo>H0>@%N@?nVNf1^yn4S75Nq-&LXPtOt8QzDL$|2`H-*)XWB1u_ zY7fKL+F__@7wSwOs%J*~$v8A2a1_sm-+Q4#Ag&TNN)?quHl6X5yeIR`^X7FiOF2oDY<$`0W}eD zrzg`mQH_m~-&is}{f!#&7Sv~U73+UAThd9-rTpi00OzqCySr2l0b5NPV^~0#I{CwH zdwC?O{Xj$L#rIZ`Ks@jiema&@_v*L5?q9+0E8nz%WwcW-obT0tc$;naVORbt%>VjD zJIQaCTmjei?sDib2;=W}wFt5JdeRGUpk(tbh*qih8NFl#+#jK_20p&eb{MTOM8cW_~$pg+bUx}+@r)}2!VvI^I@eNBD!2d5v z%}Db9Ce0*RbBI8YJi;=F0yJ(kb0O`9Oq_2R!B}_IHprv>ze_*>000I>cxh$;08LE* zDgXcg2mk~D000C40003100cMz0RRL50003100)2pG5`Vq000yK002IRX#fNO00031 z002?|0000oFfuSYF)%tZF(5HAIx;poGdBPL0gwX#0ssI26aWAKWB>pF05UK#Fgh_X QIx;aJF)}(bHaaml07ZMcT>t<8 literal 0 HcmV?d00001 From ccf78f99c45e7a303e2bc649ef101b2c823778af Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 22 Oct 2020 20:47:19 +0100 Subject: [PATCH 184/693] Allow multiple codecs with same type in DefaultHlsExtractorFactory When disabling a TsExtractor track type because of a missing codec in the master playlist, look through the entire codecs string instead of checking the first codec with matching type. Issue: #7877 PiperOrigin-RevId: 338530046 --- .../android/exoplayer2/util/MimeTypes.java | 23 +++++++++++++ .../exoplayer2/util/MimeTypesTest.java | 32 +++++++++++++++++++ .../hls/DefaultHlsExtractorFactory.java | 4 +-- 3 files changed, 57 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 6d5f167047d..64afcd393a2 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 @@ -239,6 +239,29 @@ public static String getVideoMediaMimeType(@Nullable String codecs) { return null; } + /** + * Returns whether the given {@code codecs} string contains a codec which corresponds to the given + * {@code mimeType}. + * + * @param codecs An RFC 6381 codecs string. + * @param mimeType A MIME type to look for. + * @return Whether the given {@code codecs} string contains a codec which corresponds to the given + * {@code mimeType}. + */ + public static boolean containsCodecsCorrespondingToMimeType( + @Nullable String codecs, String mimeType) { + if (codecs == null) { + return false; + } + String[] codecList = Util.splitCodecs(codecs); + for (String codec : codecList) { + if (mimeType.equals(getMediaMimeType(codec))) { + return true; + } + } + return false; + } + /** * Returns the first audio MIME type derived from an RFC 6381 codecs string. * diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java index 46202a59912..6f68328dc73 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java @@ -28,6 +28,38 @@ @RunWith(AndroidJUnit4.class) public final class MimeTypesTest { + @Test + public void containsCodecsCorrespondingToMimeType_returnsCorrectResult() { + assertThat( + MimeTypes.containsCodecsCorrespondingToMimeType( + /* codecs= */ "ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.AUDIO_AAC)) + .isTrue(); + assertThat( + MimeTypes.containsCodecsCorrespondingToMimeType( + /* codecs= */ "ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.AUDIO_AC3)) + .isTrue(); + assertThat( + MimeTypes.containsCodecsCorrespondingToMimeType( + /* codecs= */ "ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.VIDEO_H264)) + .isTrue(); + assertThat( + MimeTypes.containsCodecsCorrespondingToMimeType( + /* codecs= */ "unknown-codec,mp4a.40.2,avc1.4D4015", MimeTypes.AUDIO_AAC)) + .isTrue(); + + assertThat( + MimeTypes.containsCodecsCorrespondingToMimeType( + /* codecs= */ "unknown-codec,mp4a.40.2,avc1.4D4015", MimeTypes.AUDIO_AC3)) + .isFalse(); + assertThat( + MimeTypes.containsCodecsCorrespondingToMimeType( + /* codecs= */ null, MimeTypes.AUDIO_AC3)) + .isFalse(); + assertThat( + MimeTypes.containsCodecsCorrespondingToMimeType(/* codecs= */ "", MimeTypes.AUDIO_AC3)) + .isFalse(); + } + @Test public void isText_returnsCorrectResult() { assertThat(MimeTypes.isText(MimeTypes.TEXT_VTT)).isTrue(); 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 0a9ead7c480..c8ef90742b3 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 @@ -199,10 +199,10 @@ private static TsExtractor createTsExtractor( // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really // exist. If we know from the codec attribute that they don't exist, then we can // explicitly ignore them even if they're declared. - if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) { + if (!MimeTypes.containsCodecsCorrespondingToMimeType(codecs, MimeTypes.AUDIO_AAC)) { payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM; } - if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) { + if (!MimeTypes.containsCodecsCorrespondingToMimeType(codecs, MimeTypes.VIDEO_H264)) { payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM; } } From 4783c329cc545da4269a9401a4975a41e75f2f43 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 22 Oct 2020 21:33:03 +0100 Subject: [PATCH 185/693] Map HLS sample formats to the correct codec string This change fixes format creation for traditional preparation of streams where the master playlist contains more than one codec string per track type. Issue: #7877 PiperOrigin-RevId: 338538693 --- .../android/exoplayer2/util/MimeTypes.java | 29 ++++++++++++++--- .../exoplayer2/util/MimeTypesTest.java | 31 +++++++++++++++++++ .../source/hls/HlsSampleStreamWrapper.java | 6 ++-- 3 files changed, 60 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 64afcd393a2..d6dd67ee7d8 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 @@ -250,16 +250,37 @@ public static String getVideoMediaMimeType(@Nullable String codecs) { */ public static boolean containsCodecsCorrespondingToMimeType( @Nullable String codecs, String mimeType) { - if (codecs == null) { - return false; + return getCodecsCorrespondingToMimeType(codecs, mimeType) != null; + } + + /** + * Returns a subsequence of {@code codecs} containing the codec strings that correspond to the + * given {@code mimeType}. Returns null if {@code mimeType} is null, {@code codecs} is null, or + * {@code codecs} does not contain a codec that corresponds to {@code mimeType}. + * + * @param codecs An RFC 6381 codecs string. + * @param mimeType A MIME type to look for. + * @return A subsequence of {@code codecs} containing the codec strings that correspond to the + * given {@code mimeType}. Returns null if {@code mimeType} is null, {@code codecs} is null, + * or {@code codecs} does not contain a codec that corresponds to {@code mimeType}. + */ + @Nullable + public static String getCodecsCorrespondingToMimeType( + @Nullable String codecs, @Nullable String mimeType) { + if (codecs == null || mimeType == null) { + return null; } String[] codecList = Util.splitCodecs(codecs); + StringBuilder builder = new StringBuilder(); for (String codec : codecList) { if (mimeType.equals(getMediaMimeType(codec))) { - return true; + if (builder.length() > 0) { + builder.append(","); + } + builder.append(codec); } } - return false; + return builder.length() > 0 ? builder.toString() : null; } /** diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java index 6f68328dc73..2baac87e858 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java @@ -60,6 +60,37 @@ public void containsCodecsCorrespondingToMimeType_returnsCorrectResult() { .isFalse(); } + @Test + public void getCodecsCorrespondingToMimeType_returnsCorrectResult() { + assertThat( + MimeTypes.getCodecsCorrespondingToMimeType( + /* codecs= */ "avc1.4D5015,ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.AUDIO_AAC)) + .isEqualTo("mp4a.40.2"); + assertThat( + MimeTypes.getCodecsCorrespondingToMimeType( + /* codecs= */ "avc1.4D5015,ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.VIDEO_H264)) + .isEqualTo("avc1.4D5015,avc1.4D4015"); + assertThat( + MimeTypes.getCodecsCorrespondingToMimeType( + /* codecs= */ "avc1.4D5015,ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.AUDIO_AC3)) + .isEqualTo("ac-3"); + assertThat( + MimeTypes.getCodecsCorrespondingToMimeType( + /* codecs= */ "unknown-codec,ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.AUDIO_AC3)) + .isEqualTo("ac-3"); + + assertThat( + MimeTypes.getCodecsCorrespondingToMimeType( + /* codecs= */ "avc1.4D5015,ac-3,mp4a.40.2,avc1.4D4015", MimeTypes.VIDEO_H265)) + .isNull(); + assertThat( + MimeTypes.getCodecsCorrespondingToMimeType( + /* codecs= */ "avc1.4D5015,ac-3,mp4a.40.2,avc1.4D4015", null)) + .isNull(); + assertThat(MimeTypes.getCodecsCorrespondingToMimeType(/* codecs= */ null, MimeTypes.AUDIO_AAC)) + .isNull(); + } + @Test public void isText_returnsCorrectResult() { assertThat(MimeTypes.isText(MimeTypes.TEXT_VTT)).isTrue(); 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 89e7687a21a..7f1af4496f9 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 @@ -1404,8 +1404,10 @@ private static Format deriveFormat( return sampleFormat; } - int sampleTrackType = MimeTypes.getTrackType(sampleFormat.sampleMimeType); - @Nullable String codecs = Util.getCodecsOfType(playlistFormat.codecs, sampleTrackType); + @Nullable + String codecs = + MimeTypes.getCodecsCorrespondingToMimeType( + playlistFormat.codecs, sampleFormat.sampleMimeType); @Nullable String sampleMimeType = MimeTypes.getMediaMimeType(codecs); Format.Builder formatBuilder = From 485949b56ce1ed85608f69c71f10617dab1b3cfa Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 23 Oct 2020 10:32:08 +0100 Subject: [PATCH 186/693] Refactor AsynchronousMediaCoderAdapter Refactor the AsynchronousMediaCoderAdapter and move the callback thread out of the adapter so that implementation of async callback and and async queueing are consistent design-wise. PiperOrigin-RevId: 338637837 --- .../AsynchronousMediaCodecAdapter.java | 175 ++------ .../AsynchronousMediaCodecCallback.java | 325 ++++++++++++++ .../mediacodec/MediaCodecAsyncCallback.java | 157 ------- .../mediacodec/MediaCodecRenderer.java | 2 +- .../AsynchronousMediaCodecAdapterTest.java | 88 +--- ...nchronousMediaCodecBufferEnqueuerTest.java | 27 +- .../AsynchronousMediaCodecCallbackTest.java | 418 ++++++++++++++++++ .../MediaCodecAsyncCallbackTest.java | 249 ----------- 8 files changed, 794 insertions(+), 647 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallback.java delete mode 100644 library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallbackTest.java delete mode 100644 library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.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 index cb3acc0362f..3a0de6fab86 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 @@ -19,32 +19,25 @@ import android.media.MediaCodec; import android.media.MediaCrypto; import android.media.MediaFormat; -import android.os.Handler; import android.os.HandlerThread; import android.view.Surface; -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.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -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. - * - *

    This adapter supports queueing input buffers asynchronously. + * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in asynchronous mode, + * routes {@link MediaCodec.Callback} callbacks on a dedicated thread that is managed internally, + * and queues input buffers asynchronously. */ @RequiresApi(23) -/* package */ final class AsynchronousMediaCodecAdapter extends MediaCodec.Callback - implements MediaCodecAdapter { +/* package */ final class AsynchronousMediaCodecAdapter implements MediaCodecAdapter { @Documented @Retention(RetentionPolicy.SOURCE) @@ -56,24 +49,10 @@ 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 final AsynchronousMediaCodecCallback asynchronousMediaCodecCallback; private final AsynchronousMediaCodecBufferEnqueuer bufferEnqueuer; - - @GuardedBy("lock") - @Nullable - private IllegalStateException internalException; + @State private int state; /** * Creates an instance that wraps the specified {@link MediaCodec}. @@ -85,21 +64,15 @@ /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, int trackType) { this( codec, - trackType, new HandlerThread(createCallbackThreadLabel(trackType)), new HandlerThread(createQueueingThreadLabel(trackType))); } @VisibleForTesting /* package */ AsynchronousMediaCodecAdapter( - MediaCodec codec, - int trackType, - HandlerThread callbackThread, - HandlerThread enqueueingThread) { - this.lock = new Object(); - this.mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); + MediaCodec codec, HandlerThread callbackThread, HandlerThread enqueueingThread) { this.codec = codec; - this.handlerThread = callbackThread; + this.asynchronousMediaCodecCallback = new AsynchronousMediaCodecCallback(callbackThread); this.bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, enqueueingThread); this.state = STATE_CREATED; } @@ -110,9 +83,7 @@ public void configure( @Nullable Surface surface, @Nullable MediaCrypto crypto, int flags) { - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); - codec.setCallback(this, handler); + asynchronousMediaCodecCallback.initialize(codec); codec.configure(mediaFormat, surface, crypto, flags); state = STATE_CONFIGURED; } @@ -138,60 +109,40 @@ public void queueSecureInputBuffer( @Override public int dequeueInputBufferIndex() { - synchronized (lock) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueInputBufferIndex(); - } - } + return asynchronousMediaCodecCallback.dequeueInputBufferIndex(); } @Override public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - synchronized (lock) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); - } - } + return asynchronousMediaCodecCallback.dequeueOutputBufferIndex(bufferInfo); } @Override public MediaFormat getOutputFormat() { - synchronized (lock) { - return mediaCodecAsyncCallback.getOutputFormat(); - } + return asynchronousMediaCodecCallback.getOutputFormat(); } @Override public void flush() { - synchronized (lock) { - bufferEnqueuer.flush(); - codec.flush(); - ++pendingFlushCount; - Util.castNonNull(handler).post(this::onFlushCompleted); - } + // The order of calls is important: + // First, flush the bufferEnqueuer to stop queueing input buffers. + // Second, flush the codec to stop producing available input/output buffers. + // Third, flush the callback after flushing the codec so that in-flight callbacks are discarded. + bufferEnqueuer.flush(); + codec.flush(); + // When flushAsync() is completed, start the codec again. + asynchronousMediaCodecCallback.flushAsync(/* onFlushCompleted= */ codec::start); } @Override 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; + asynchronousMediaCodecCallback.shutdown(); } state = STATE_SHUT_DOWN; - } } @Override @@ -199,86 +150,14 @@ 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 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); - } - } - - private void onFlushCompleted() { - synchronized (lock) { - onFlushCompletedSynchronized(); - } - } - - @GuardedBy("lock") - private void onFlushCompletedSynchronized() { - if (state == STATE_SHUT_DOWN) { - return; - } - - --pendingFlushCount; - if (pendingFlushCount > 0) { - // Another flush() has been called. - return; - } else if (pendingFlushCount < 0) { - // This should never happen. - internalException = new IllegalStateException(); - return; - } - - mediaCodecAsyncCallback.flush(); - try { - codec.start(); - } catch (IllegalStateException e) { - internalException = e; - } catch (Exception e) { - internalException = new IllegalStateException(e); - } - } - - @GuardedBy("lock") - private boolean isFlushing() { - return pendingFlushCount > 0; - } - - @GuardedBy("lock") - private void maybeThrowException() { - maybeThrowInternalException(); - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); + @VisibleForTesting + /* package */ void onError(MediaCodec.CodecException error) { + asynchronousMediaCodecCallback.onError(codec, error); } - @GuardedBy("lock") - private void maybeThrowInternalException() { - if (internalException != null) { - IllegalStateException e = internalException; - internalException = null; - throw e; - } + @VisibleForTesting + /* package */ void onOutputFormatChanged(MediaFormat format) { + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, format); } private static String createCallbackThreadLabel(int trackType) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallback.java new file mode 100644 index 00000000000..f05d7520615 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallback.java @@ -0,0 +1,325 @@ +/* + * 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.android.exoplayer2.util.Assertions.checkState; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Handler; +import android.os.HandlerThread; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.util.IntArrayQueue; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayDeque; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** A {@link MediaCodec.Callback} that routes callbacks on a separate thread. */ +@RequiresApi(23) +/* package */ final class AsynchronousMediaCodecCallback extends MediaCodec.Callback { + private final Object lock; + + private final HandlerThread callbackThread; + private @MonotonicNonNull Handler handler; + + @GuardedBy("lock") + private final IntArrayQueue availableInputBuffers; + + @GuardedBy("lock") + private final IntArrayQueue availableOutputBuffers; + + @GuardedBy("lock") + private final ArrayDeque bufferInfos; + + @GuardedBy("lock") + private final ArrayDeque formats; + + @GuardedBy("lock") + @Nullable + private MediaFormat currentFormat; + + @GuardedBy("lock") + @Nullable + private MediaFormat pendingOutputFormat; + + @GuardedBy("lock") + @Nullable + private MediaCodec.CodecException mediaCodecException; + + @GuardedBy("lock") + private long pendingFlushCount; + + @GuardedBy("lock") + private boolean shutDown; + + @GuardedBy("lock") + @Nullable + private IllegalStateException internalException; + + /** + * Creates a new instance. + * + * @param callbackThread The thread that will be used for routing the {@link MediaCodec} + * callbacks. The thread must not be started. + */ + /* package */ AsynchronousMediaCodecCallback(HandlerThread callbackThread) { + this.lock = new Object(); + this.callbackThread = callbackThread; + this.availableInputBuffers = new IntArrayQueue(); + this.availableOutputBuffers = new IntArrayQueue(); + this.bufferInfos = new ArrayDeque<>(); + this.formats = new ArrayDeque<>(); + } + + /** + * Sets the callback on {@code codec} and starts the background callback thread. + * + *

    Make sure to call {@link #shutdown()} to stop the background thread and release its + * resources. + * + * @see MediaCodec#setCallback(MediaCodec.Callback, Handler) + */ + public void initialize(MediaCodec codec) { + checkState(handler == null); + + callbackThread.start(); + Handler handler = new Handler(callbackThread.getLooper()); + codec.setCallback(this, handler); + // Initialize this.handler at the very end ensuring the callback in not considered configured + // if MediaCodec raises an exception. + this.handler = handler; + } + + /** + * Shuts down this instance. + * + *

    This method will stop the callback thread. After calling it, callbacks will no longer be + * handled and dequeue methods will return {@link MediaCodec#INFO_TRY_AGAIN_LATER}. + */ + public void shutdown() { + synchronized (lock) { + shutDown = true; + callbackThread.quit(); + flushInternal(); + } + } + + /** + * Returns the next available input buffer index or {@link MediaCodec#INFO_TRY_AGAIN_LATER} if no + * such buffer exists. + */ + public int dequeueInputBufferIndex() { + synchronized (lock) { + if (isFlushingOrShutdown()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + return availableInputBuffers.isEmpty() + ? MediaCodec.INFO_TRY_AGAIN_LATER + : availableInputBuffers.remove(); + } + } + } + + /** + * Returns the next available output buffer index. If the next available output is a MediaFormat + * change, it will return {@link MediaCodec#INFO_OUTPUT_FORMAT_CHANGED} and you should call {@link + * #getOutputFormat()} to get the format. If there is no available output, this method will return + * {@link MediaCodec#INFO_TRY_AGAIN_LATER}. + */ + public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { + synchronized (lock) { + if (isFlushingOrShutdown()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + if (availableOutputBuffers.isEmpty()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + int bufferIndex = availableOutputBuffers.remove(); + if (bufferIndex >= 0) { + MediaCodec.BufferInfo nextBufferInfo = bufferInfos.remove(); + bufferInfo.set( + nextBufferInfo.offset, + nextBufferInfo.size, + nextBufferInfo.presentationTimeUs, + nextBufferInfo.flags); + } else if (bufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + currentFormat = formats.remove(); + } + return bufferIndex; + } + } + } + } + + /** + * Returns the {@link MediaFormat} signalled by the underlying {@link MediaCodec}. + * + *

    Call this after {@link #dequeueOutputBufferIndex} returned {@link + * MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}. + * + * @throws IllegalStateException If called before {@link #dequeueOutputBufferIndex} has returned + * {@link MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}. + */ + public MediaFormat getOutputFormat() { + synchronized (lock) { + if (currentFormat == null) { + throw new IllegalStateException(); + } + return currentFormat; + } + } + + /** + * Initiates a flush asynchronously, which will be completed on the callback thread. When the + * flush is complete, it will trigger {@code onFlushCompleted} from the callback thread. + * + * @param onFlushCompleted A {@link Runnable} that will be called when flush is completed. {@code + * onFlushCompleted} will be called from the scallback thread, therefore it should execute + * synchronized and thread-safe code. + */ + public void flushAsync(Runnable onFlushCompleted) { + synchronized (lock) { + ++pendingFlushCount; + Util.castNonNull(handler).post(() -> this.onFlushCompleted(onFlushCompleted)); + } + } + + // Called from the callback thread. + + @Override + public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) { + synchronized (lock) { + availableInputBuffers.add(index); + } + } + + @Override + public void onOutputBufferAvailable( + @NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) { + synchronized (lock) { + if (pendingOutputFormat != null) { + addOutputFormat(pendingOutputFormat); + pendingOutputFormat = null; + } + availableOutputBuffers.add(index); + bufferInfos.add(info); + } + } + + @Override + public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) { + synchronized (lock) { + mediaCodecException = e; + } + } + + @Override + public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) { + synchronized (lock) { + addOutputFormat(format); + pendingOutputFormat = null; + } + } + + private void onFlushCompleted(Runnable onFlushCompleted) { + synchronized (lock) { + onFlushCompletedSynchronized(onFlushCompleted); + } + } + + @GuardedBy("lock") + private void onFlushCompletedSynchronized(Runnable onFlushCompleted) { + if (shutDown) { + return; + } + + --pendingFlushCount; + if (pendingFlushCount > 0) { + // Another flush() has been called. + return; + } else if (pendingFlushCount < 0) { + // This should never happen. + setInternalException(new IllegalStateException()); + return; + } + flushInternal(); + try { + onFlushCompleted.run(); + } catch (IllegalStateException e) { + setInternalException(e); + } catch (Exception e) { + setInternalException(new IllegalStateException(e)); + } + } + + /** Flushes all available input and output buffers and any error that was previously set. */ + @GuardedBy("lock") + private void flushInternal() { + pendingOutputFormat = formats.isEmpty() ? null : formats.getLast(); + availableInputBuffers.clear(); + availableOutputBuffers.clear(); + bufferInfos.clear(); + formats.clear(); + mediaCodecException = null; + } + + @GuardedBy("lock") + private boolean isFlushingOrShutdown() { + return pendingFlushCount > 0 || shutDown; + } + + @GuardedBy("lock") + private void addOutputFormat(MediaFormat mediaFormat) { + availableOutputBuffers.add(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + formats.add(mediaFormat); + } + + @GuardedBy("lock") + private void maybeThrowException() { + maybeThrowInternalException(); + maybeThrowMediaCodecException(); + } + + @GuardedBy("lock") + private void maybeThrowInternalException() { + if (internalException != null) { + IllegalStateException e = internalException; + internalException = null; + throw e; + } + } + + @GuardedBy("lock") + private void maybeThrowMediaCodecException() { + if (mediaCodecException != null) { + MediaCodec.CodecException codecException = mediaCodecException; + mediaCodecException = null; + throw codecException; + } + } + + private void setInternalException(IllegalStateException e) { + synchronized (lock) { + internalException = e; + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java deleted file mode 100644 index 65f0c266a9b..00000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java +++ /dev/null @@ -1,157 +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 androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; -import com.google.android.exoplayer2.util.IntArrayQueue; -import java.util.ArrayDeque; - -/** Handles the asynchronous callbacks from {@link android.media.MediaCodec.Callback}. */ -@RequiresApi(21) -/* package */ final class MediaCodecAsyncCallback extends MediaCodec.Callback { - private final IntArrayQueue availableInputBuffers; - private final IntArrayQueue availableOutputBuffers; - private final ArrayDeque bufferInfos; - private final ArrayDeque formats; - @Nullable private MediaFormat currentFormat; - @Nullable private MediaFormat pendingOutputFormat; - @Nullable private IllegalStateException mediaCodecException; - - /** Creates a new MediaCodecAsyncCallback. */ - public MediaCodecAsyncCallback() { - availableInputBuffers = new IntArrayQueue(); - availableOutputBuffers = new IntArrayQueue(); - bufferInfos = new ArrayDeque<>(); - formats = new ArrayDeque<>(); - } - - /** - * Returns the next available input buffer index or {@link MediaCodec#INFO_TRY_AGAIN_LATER} if no - * such buffer exists. - */ - public int dequeueInputBufferIndex() { - return availableInputBuffers.isEmpty() - ? MediaCodec.INFO_TRY_AGAIN_LATER - : availableInputBuffers.remove(); - } - - /** - * Returns the next available output buffer index. If the next available output is a MediaFormat - * change, it will return {@link MediaCodec#INFO_OUTPUT_FORMAT_CHANGED} and you should call {@link - * #getOutputFormat()} to get the format. If there is no available output, this method will return - * {@link MediaCodec#INFO_TRY_AGAIN_LATER}. - */ - public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - if (availableOutputBuffers.isEmpty()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - int bufferIndex = availableOutputBuffers.remove(); - if (bufferIndex >= 0) { - MediaCodec.BufferInfo nextBufferInfo = bufferInfos.remove(); - bufferInfo.set( - nextBufferInfo.offset, - nextBufferInfo.size, - nextBufferInfo.presentationTimeUs, - nextBufferInfo.flags); - } else if (bufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - currentFormat = formats.remove(); - } - return bufferIndex; - } - } - - /** - * Returns the {@link MediaFormat} signalled by the underlying {@link MediaCodec}. - * - *

    Call this after {@link #dequeueOutputBufferIndex} returned {@link - * MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}. - * - * @throws IllegalStateException If called before {@link #dequeueOutputBufferIndex} has returned - * {@link MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}. - */ - public MediaFormat getOutputFormat() throws IllegalStateException { - if (currentFormat == null) { - throw new IllegalStateException(); - } - return currentFormat; - } - - /** - * Checks and throws an {@link IllegalStateException} if an error was previously set on this - * instance via {@link #onError}. - */ - public void maybeThrowMediaCodecException() throws IllegalStateException { - IllegalStateException exception = mediaCodecException; - mediaCodecException = null; - if (exception != null) { - throw exception; - } - } - - /** - * Flushes the MediaCodecAsyncCallback. This method removes all available input and output buffers - * and any error that was previously set. - */ - public void flush() { - pendingOutputFormat = formats.isEmpty() ? null : formats.getLast(); - availableInputBuffers.clear(); - availableOutputBuffers.clear(); - bufferInfos.clear(); - formats.clear(); - mediaCodecException = null; - } - - @Override - public void onInputBufferAvailable(MediaCodec mediaCodec, int index) { - availableInputBuffers.add(index); - } - - @Override - public void onOutputBufferAvailable( - MediaCodec mediaCodec, int index, MediaCodec.BufferInfo bufferInfo) { - if (pendingOutputFormat != null) { - addOutputFormat(pendingOutputFormat); - pendingOutputFormat = null; - } - availableOutputBuffers.add(index); - bufferInfos.add(bufferInfo); - } - - @Override - public void onError(MediaCodec mediaCodec, MediaCodec.CodecException e) { - onMediaCodecError(e); - } - - @Override - public void onOutputFormatChanged(MediaCodec mediaCodec, MediaFormat mediaFormat) { - addOutputFormat(mediaFormat); - pendingOutputFormat = null; - } - - @VisibleForTesting() - void onMediaCodecError(IllegalStateException e) { - mediaCodecException = e; - } - - private void addOutputFormat(MediaFormat mediaFormat) { - availableOutputBuffers.add(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - formats.add(mediaFormat); - } -} 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 ecaa4e64005..d5092a8e51f 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 @@ -1067,7 +1067,7 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce codecOperatingRate = CODEC_OPERATING_RATE_UNSET; } - MediaCodecAdapter codecAdapter = null; + @Nullable MediaCodecAdapter codecAdapter = null; try { codecInitializingTimestamp = SystemClock.elapsedRealtime(); TraceUtil.beginSection("createCodec:" + codecName); 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 0128b77adda..6c3294c2aa9 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 @@ -24,7 +24,6 @@ import android.media.MediaFormat; 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; @@ -38,18 +37,16 @@ public class AsynchronousMediaCodecAdapterTest { private AsynchronousMediaCodecAdapter adapter; private MediaCodec codec; - private TestHandlerThread callbackThread; + private HandlerThread callbackThread; private HandlerThread queueingThread; private MediaCodec.BufferInfo bufferInfo; @Before public void setUp() throws IOException { codec = MediaCodec.createByCodecName("h264"); - callbackThread = new TestHandlerThread("TestCallbackThread"); + callbackThread = new HandlerThread("TestCallbackThread"); queueingThread = new HandlerThread("TestQueueingThread"); - adapter = - new AsynchronousMediaCodecAdapter( - codec, /* trackType= */ C.TRACK_TYPE_VIDEO, callbackThread, queueingThread); + adapter = new AsynchronousMediaCodecAdapter(codec, callbackThread, queueingThread); bufferInfo = new MediaCodec.BufferInfo(); } @@ -57,8 +54,6 @@ public void setUp() throws IOException { public void tearDown() { adapter.shutdown(); codec.release(); - - assertThat(callbackThread.hasQuit()).isTrue(); } @Test @@ -85,39 +80,7 @@ public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } - @Test - public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { - adapter.configure( - createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); - adapter.start(); - - // 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(callbackThread.getLooper()); - shadowLooper.idle(); - shadowLooper.pause(); - adapter.flush(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInputBuffer() { - adapter.configure( - createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); - adapter.start(); - // 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(callbackThread.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. - shadowLooper.idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); - } @Test public void dequeueInputBufferIndex_withMediaCodecError_throwsException() throws Exception { @@ -128,7 +91,7 @@ public void dequeueInputBufferIndex_withMediaCodecError_throwsException() throws adapter.start(); // Set an error directly on the adapter (not through the looper). - adapter.onError(codec, createCodecException()); + adapter.onError(createCodecException()); assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); } @@ -192,25 +155,6 @@ public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(index); } - @Test - public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { - 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. - ShadowLooper shadowLooper = shadowOf(callbackThread.getLooper()); - shadowLooper.idle(); - - // 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)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - @Test public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() throws Exception { // Pause the looper so that we interact with the adapter from this thread only. @@ -220,7 +164,7 @@ public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() throw adapter.start(); // Set an error directly on the adapter. - adapter.onError(codec, createCodecException()); + adapter.onError(createCodecException()); assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); } @@ -266,8 +210,8 @@ public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { // progress the adapter's looper. shadowOf(callbackThread.getLooper()).idle(); - // Add another format directly on the adapter. - adapter.onOutputFormatChanged(codec, createMediaFormat("format2")); + // Add another format on the adapter. + adapter.onOutputFormatChanged(createMediaFormat("format2")); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); @@ -314,22 +258,4 @@ private static MediaCodec.CodecException createCodecException() throws Exception return constructor.newInstance( /* errorCode= */ 0, /* actionCode= */ 0, /* detailMessage= */ "error from codec"); } - - private static class TestHandlerThread extends HandlerThread { - private boolean quit; - - TestHandlerThread(String label) { - super(label); - } - - public boolean hasQuit() { - return quit; - } - - @Override - public boolean quit() { - quit = true; - return super.quit(); - } - } } 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 e27c428a941..9e2c715b314 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 @@ -30,7 +30,6 @@ import com.google.android.exoplayer2.util.ConditionVariable; import java.io.IOException; import java.nio.ByteBuffer; -import java.util.concurrent.atomic.AtomicLong; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -65,7 +64,7 @@ public void tearDown() { enqueuer.shutdown(); codec.stop(); codec.release(); - assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); + assertThat(!handlerThread.hasStarted() || handlerThread.hasQuit()).isTrue(); } @Test @@ -221,25 +220,31 @@ public void shutdown_onInterruptedException_throwsIllegalStateException() } private static class TestHandlerThread extends HandlerThread { - private static final AtomicLong INSTANCES_STARTED = new AtomicLong(0); + private boolean started; + private boolean quit; - TestHandlerThread(String name) { - super(name); + TestHandlerThread(String label) { + super(label); + } + + public boolean hasStarted() { + return started; + } + + public boolean hasQuit() { + return quit; } @Override public synchronized void start() { super.start(); - INSTANCES_STARTED.incrementAndGet(); + started = true; } @Override public boolean quit() { - boolean quit = super.quit(); - if (quit) { - INSTANCES_STARTED.decrementAndGet(); - } - return quit; + quit = true; + return super.quit(); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallbackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallbackTest.java new file mode 100644 index 00000000000..6ca468d7396 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecCallbackTest.java @@ -0,0 +1,418 @@ +/* + * 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.android.exoplayer2.testutil.TestUtil.assertBufferInfosEqual; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; +import static org.robolectric.Shadows.shadowOf; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.HandlerThread; +import android.os.Looper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.util.concurrent.atomic.AtomicBoolean; +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.shadows.ShadowLooper; + +/** Unit tests for {@link AsynchronousMediaCodecCallback}. */ +@RunWith(AndroidJUnit4.class) +public class AsynchronousMediaCodecCallbackTest { + + private AsynchronousMediaCodecCallback asynchronousMediaCodecCallback; + private TestHandlerThread callbackThread; + private MediaCodec codec; + + @Before + public void setUp() throws IOException { + callbackThread = new TestHandlerThread("TestCallbackThread"); + codec = MediaCodec.createByCodecName("h264"); + asynchronousMediaCodecCallback = new AsynchronousMediaCodecCallback(callbackThread); + asynchronousMediaCodecCallback.initialize(codec); + } + + @After + public void tearDown() { + codec.release(); + asynchronousMediaCodecCallback.shutdown(); + + assertThat(callbackThread.hasQuit()).isTrue(); + } + + @Test + public void dequeInputBufferIndex_afterCreation_returnsTryAgain() { + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeInputBufferIndex_returnsEnqueuedBuffers() { + // Send two input buffers to the callback. + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 0); + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 1); + + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()).isEqualTo(0); + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()).isEqualTo(1); + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeInputBufferIndex_withPendingFlush_returnsTryAgain() { + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + // Pause the callback thread so that flush() never completes. + shadowOf(callbackThreadLooper).pause(); + + // Send two input buffers to the callback and then flush(). + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 0); + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 1); + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + assertThat(flushCompleted.get()).isFalse(); + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeInputBufferIndex_afterFlush_returnsTryAgain() { + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + + // Send two input buffers to the callback and then flush(). + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 0); + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 1); + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + // Progress the callback thread so that flush() completes. + shadowOf(callbackThreadLooper).idle(); + + assertThat(flushCompleted.get()).isTrue(); + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeInputBufferIndex_afterFlushAndNewInputBuffer_returnsEnqueuedBuffer() { + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + + // Send two input buffers to the callback, then flush(), then send + // another input buffer. + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 0); + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 1); + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + // Progress the callback thread so that flush() completes. + shadowOf(callbackThreadLooper).idle(); + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, 2); + + assertThat(flushCompleted.get()).isTrue(); + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()).isEqualTo(2); + } + + @Test + public void dequeueInputBufferIndex_afterShutdown_returnsTryAgainLater() { + asynchronousMediaCodecCallback.onInputBufferAvailable(codec, /* index= */ 1); + + asynchronousMediaCodecCallback.shutdown(); + + assertThat(asynchronousMediaCodecCallback.dequeueInputBufferIndex()) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeueInputBufferIndex_afterOnErrorCallback_throwsError() throws Exception { + asynchronousMediaCodecCallback.onError(codec, createCodecException()); + + assertThrows( + MediaCodec.CodecException.class, + () -> asynchronousMediaCodecCallback.dequeueInputBufferIndex()); + } + + @Test + public void dequeueInputBufferIndex_afterFlushCompletedWithError_throwsError() throws Exception { + MediaCodec.CodecException codecException = createCodecException(); + asynchronousMediaCodecCallback.flushAsync( + () -> { + throw codecException; + }); + shadowOf(callbackThread.getLooper()).idle(); + + assertThrows( + MediaCodec.CodecException.class, + () -> asynchronousMediaCodecCallback.dequeueInputBufferIndex()); + } + + @Test + public void dequeOutputBufferIndex_afterCreation_returnsTryAgain() { + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeOutputBufferIndex_returnsEnqueuedBuffers() { + // Send two output buffers to the callback. + MediaCodec.BufferInfo bufferInfo1 = new MediaCodec.BufferInfo(); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 0, bufferInfo1); + MediaCodec.BufferInfo bufferInfo2 = new MediaCodec.BufferInfo(); + bufferInfo2.set(1, 1, 1, 1); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 1, bufferInfo2); + + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(0); + assertBufferInfosEqual(bufferInfo1, outBufferInfo); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); + assertBufferInfosEqual(bufferInfo2, outBufferInfo); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeOutputBufferIndex_withPendingFlush_returnsTryAgain() { + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + // Pause the callback thread so that flush() never completes. + shadowOf(callbackThreadLooper).pause(); + + // Send two output buffers to the callback and then flush(). + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 0, bufferInfo); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 1, bufferInfo); + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + + assertThat(flushCompleted.get()).isFalse(); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(new MediaCodec.BufferInfo())) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeOutputBufferIndex_afterFlush_returnsTryAgain() { + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + + // Send two output buffers to the callback and then flush(). + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 0, bufferInfo); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 1, bufferInfo); + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + // Progress the callback looper so that flush() completes. + shadowOf(callbackThreadLooper).idle(); + + assertThat(flushCompleted.get()).isTrue(); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(new MediaCodec.BufferInfo())) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeOutputBufferIndex_afterFlushAndNewOutputBuffers_returnsEnqueueBuffer() { + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + + // Send two output buffers to the callback, then flush(), then send + // another output buffer. + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 0, bufferInfo); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 1, bufferInfo); + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + // Progress the callback looper so that flush() completes. + shadowOf(callbackThreadLooper).idle(); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, 2, bufferInfo); + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + + assertThat(flushCompleted.get()).isTrue(); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(2); + } + + @Test + public void dequeOutputBufferIndex_withPendingOutputFormat_returnsPendingOutputFormat() { + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, new MediaFormat()); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, /* index= */ 0, outBufferInfo); + MediaFormat pendingMediaFormat = new MediaFormat(); + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, pendingMediaFormat); + // flush() should not discard the last format. + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + // Progress the callback looper so that flush() completes. + shadowOf(callbackThreadLooper).idle(); + // Right after flush(), we send an output buffer: the pending output format should be + // dequeued first. + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, /* index= */ 1, outBufferInfo); + + assertThat(flushCompleted.get()).isTrue(); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(asynchronousMediaCodecCallback.getOutputFormat()).isEqualTo(pendingMediaFormat); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); + } + + @Test + public void dequeOutputBufferIndex_withPendingOutputFormatAndNewFormat_returnsNewFormat() { + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, new MediaFormat()); + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, /* index= */ 0, bufferInfo); + MediaFormat pendingMediaFormat = new MediaFormat(); + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, pendingMediaFormat); + // flush() should not discard the last format. + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + // Progress the callback looper so that flush() completes. + shadowOf(callbackThreadLooper).idle(); + // The first callback after flush() is a new MediaFormat, it should overwrite the pending + // format. + MediaFormat newFormat = new MediaFormat(); + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, newFormat); + asynchronousMediaCodecCallback.onOutputBufferAvailable(codec, /* index= */ 1, bufferInfo); + + assertThat(flushCompleted.get()).isTrue(); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(asynchronousMediaCodecCallback.getOutputFormat()).isEqualTo(newFormat); + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); + } + + @Test + public void dequeueOutputBufferIndex_afterShutdown_returnsTryAgainLater() { + asynchronousMediaCodecCallback.onOutputBufferAvailable( + codec, /* index= */ 1, new MediaCodec.BufferInfo()); + + asynchronousMediaCodecCallback.shutdown(); + + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(new MediaCodec.BufferInfo())) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } + + @Test + public void dequeueOutputBufferIndex_afterOnErrorCallback_throwsError() throws Exception { + asynchronousMediaCodecCallback.onError(codec, createCodecException()); + + assertThrows( + MediaCodec.CodecException.class, + () -> asynchronousMediaCodecCallback.dequeueOutputBufferIndex(new MediaCodec.BufferInfo())); + } + + @Test + public void dequeueOutputBufferIndex_afterFlushCompletedWithError_throwsError() throws Exception { + MediaCodec.CodecException codecException = createCodecException(); + asynchronousMediaCodecCallback.flushAsync( + () -> { + throw codecException; + }); + shadowOf(callbackThread.getLooper()).idle(); + + assertThrows( + MediaCodec.CodecException.class, + () -> asynchronousMediaCodecCallback.dequeueOutputBufferIndex(new MediaCodec.BufferInfo())); + } + + @Test + public void getOutputFormat_onNewInstance_raisesException() { + try { + asynchronousMediaCodecCallback.getOutputFormat(); + fail(); + } catch (IllegalStateException expected) { + } + } + + @Test + public void getOutputFormat_afterOnOutputFormatCalled_returnsFormat() { + MediaFormat format = new MediaFormat(); + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, format); + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + assertThat(asynchronousMediaCodecCallback.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(asynchronousMediaCodecCallback.getOutputFormat()).isEqualTo(format); + } + + @Test + public void getOutputFormat_afterFlush_returnsCurrentFormat() { + MediaFormat format = new MediaFormat(); + Looper callbackThreadLooper = callbackThread.getLooper(); + AtomicBoolean flushCompleted = new AtomicBoolean(); + + asynchronousMediaCodecCallback.onOutputFormatChanged(codec, format); + asynchronousMediaCodecCallback.dequeueOutputBufferIndex(new MediaCodec.BufferInfo()); + asynchronousMediaCodecCallback.flushAsync( + /* onFlushCompleted= */ () -> flushCompleted.set(true)); + // Progress the callback looper so that flush() completes. + shadowOf(callbackThreadLooper).idle(); + + assertThat(flushCompleted.get()).isTrue(); + assertThat(asynchronousMediaCodecCallback.getOutputFormat()).isEqualTo(format); + } + + @Test + public void flush_withPendingFlush_onlyLastFlushCompletes() { + ShadowLooper callbackLooperShadow = shadowOf(callbackThread.getLooper()); + callbackLooperShadow.pause(); + AtomicInteger flushCompleted = new AtomicInteger(); + + asynchronousMediaCodecCallback.flushAsync(/* onFlushCompleted= */ () -> flushCompleted.set(1)); + asynchronousMediaCodecCallback.flushAsync(/* onFlushCompleted= */ () -> flushCompleted.set(2)); + callbackLooperShadow.idle(); + + assertThat(flushCompleted.get()).isEqualTo(2); + } + + /** 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; + + TestHandlerThread(String label) { + super(label); + } + + public boolean hasQuit() { + return quit; + } + + @Override + public boolean quit() { + quit = true; + return super.quit(); + } + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.java deleted file mode 100644 index 7cf3f323916..00000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.java +++ /dev/null @@ -1,249 +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.fail; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import java.io.IOException; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** Unit tests for {@link MediaCodecAsyncCallback}. */ -@RunWith(AndroidJUnit4.class) -public class MediaCodecAsyncCallbackTest { - - private MediaCodecAsyncCallback mediaCodecAsyncCallback; - private MediaCodec codec; - - @Before - public void setUp() throws IOException { - mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); - codec = MediaCodec.createByCodecName("h264"); - } - - @Test - public void dequeInputBufferIndex_afterCreation_returnsTryAgain() { - assertThat(mediaCodecAsyncCallback.dequeueInputBufferIndex()) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeInputBufferIndex_returnsEnqueuedBuffers() { - // Send two input buffers to the mediaCodecAsyncCallback. - mediaCodecAsyncCallback.onInputBufferAvailable(codec, 0); - mediaCodecAsyncCallback.onInputBufferAvailable(codec, 1); - - assertThat(mediaCodecAsyncCallback.dequeueInputBufferIndex()).isEqualTo(0); - assertThat(mediaCodecAsyncCallback.dequeueInputBufferIndex()).isEqualTo(1); - assertThat(mediaCodecAsyncCallback.dequeueInputBufferIndex()) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeInputBufferIndex_afterFlush_returnsTryAgain() { - // Send two input buffers to the mediaCodecAsyncCallback and then flush(). - mediaCodecAsyncCallback.onInputBufferAvailable(codec, 0); - mediaCodecAsyncCallback.onInputBufferAvailable(codec, 1); - mediaCodecAsyncCallback.flush(); - - assertThat(mediaCodecAsyncCallback.dequeueInputBufferIndex()) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeInputBufferIndex_afterFlushAndNewInputBuffer_returnsEnqueuedBuffer() { - // Send two input buffers to the mediaCodecAsyncCallback, then flush(), then send - // another input buffer. - mediaCodecAsyncCallback.onInputBufferAvailable(codec, 0); - mediaCodecAsyncCallback.onInputBufferAvailable(codec, 1); - mediaCodecAsyncCallback.flush(); - mediaCodecAsyncCallback.onInputBufferAvailable(codec, 2); - - assertThat(mediaCodecAsyncCallback.dequeueInputBufferIndex()).isEqualTo(2); - } - - @Test - public void dequeOutputBufferIndex_afterCreation_returnsTryAgain() { - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeOutputBufferIndex_returnsEnqueuedBuffers() { - // Send two output buffers to the mediaCodecAsyncCallback. - MediaCodec.BufferInfo bufferInfo1 = new MediaCodec.BufferInfo(); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, 0, bufferInfo1); - - MediaCodec.BufferInfo bufferInfo2 = new MediaCodec.BufferInfo(); - bufferInfo2.set(1, 1, 1, 1); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, 1, bufferInfo2); - - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(0); - assertBufferInfosEqual(bufferInfo1, outBufferInfo); - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); - assertBufferInfosEqual(bufferInfo2, outBufferInfo); - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeOutputBufferIndex_afterFlush_returnsTryAgain() { - // Send two output buffers to the mediaCodecAsyncCallback and then flush(). - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, 0, bufferInfo); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, 1, bufferInfo); - mediaCodecAsyncCallback.flush(); - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeOutputBufferIndex_afterFlushAndNewOutputBuffers_returnsEnqueueBuffer() { - // Send two output buffers to the mediaCodecAsyncCallback, then flush(), then send - // another output buffer. - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, 0, bufferInfo); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, 1, bufferInfo); - mediaCodecAsyncCallback.flush(); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, 2, bufferInfo); - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(2); - } - - @Test - public void dequeOutputBufferIndex_withPendingOutputFormat_returnsPendingOutputFormat() { - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - - mediaCodecAsyncCallback.onOutputFormatChanged(codec, new MediaFormat()); - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 0, bufferInfo); - MediaFormat pendingMediaFormat = new MediaFormat(); - mediaCodecAsyncCallback.onOutputFormatChanged(codec, pendingMediaFormat); - // Flush should not discard the last format. - mediaCodecAsyncCallback.flush(); - // First callback after flush is an output buffer, pending output format should be pushed first. - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 1, bufferInfo); - - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(mediaCodecAsyncCallback.getOutputFormat()).isEqualTo(pendingMediaFormat); - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); - } - - @Test - public void dequeOutputBufferIndex_withPendingOutputFormatAndNewFormat_returnsNewFormat() { - mediaCodecAsyncCallback.onOutputFormatChanged(codec, new MediaFormat()); - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 0, bufferInfo); - MediaFormat pendingMediaFormat = new MediaFormat(); - mediaCodecAsyncCallback.onOutputFormatChanged(codec, pendingMediaFormat); - // Flush should not discard the last format - mediaCodecAsyncCallback.flush(); - // The first callback after flush is a new MediaFormat, it should overwrite the pending format. - MediaFormat newFormat = new MediaFormat(); - mediaCodecAsyncCallback.onOutputFormatChanged(codec, newFormat); - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 1, bufferInfo); - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(mediaCodecAsyncCallback.getOutputFormat()).isEqualTo(newFormat); - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); - } - - @Test - public void getOutputFormat_onNewInstance_raisesException() { - try { - mediaCodecAsyncCallback.getOutputFormat(); - fail(); - } catch (IllegalStateException expected) { - } - } - - @Test - public void getOutputFormat_afterOnOutputFormatCalled_returnsFormat() { - MediaFormat format = new MediaFormat(); - mediaCodecAsyncCallback.onOutputFormatChanged(codec, format); - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - - assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(mediaCodecAsyncCallback.getOutputFormat()).isEqualTo(format); - } - - @Test - public void getOutputFormat_afterFlush_raisesCurrentFormat() { - MediaFormat format = new MediaFormat(); - mediaCodecAsyncCallback.onOutputFormatChanged(codec, format); - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); - mediaCodecAsyncCallback.flush(); - - assertThat(mediaCodecAsyncCallback.getOutputFormat()).isEqualTo(format); - } - - @Test - public void maybeThrowExoPlaybackException_withoutErrorFromCodec_doesNotThrow() { - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); - } - - @Test - public void maybeThrowExoPlaybackException_withErrorFromCodec_Throws() { - IllegalStateException exception = new IllegalStateException(); - mediaCodecAsyncCallback.onMediaCodecError(exception); - - try { - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); - fail(); - } catch (IllegalStateException expected) { - } - } - - @Test - public void maybeThrowExoPlaybackException_doesNotThrowTwice() { - IllegalStateException exception = new IllegalStateException(); - mediaCodecAsyncCallback.onMediaCodecError(exception); - - try { - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); - fail(); - } catch (IllegalStateException expected) { - } - - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); - } - - @Test - public void maybeThrowExoPlaybackException_afterFlush_doesNotThrow() { - IllegalStateException exception = new IllegalStateException(); - mediaCodecAsyncCallback.onMediaCodecError(exception); - mediaCodecAsyncCallback.flush(); - - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); - } -} From e5434ff4d39ffe7aeaa698b39e5ae2deafb84d8d Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 23 Oct 2020 13:26:42 +0100 Subject: [PATCH 187/693] Add plumbing for decoderReleased analytics events A subsequent CL will plumb a release reason through these event paths. PiperOrigin-RevId: 338655603 --- .../google/android/exoplayer2/Renderer.java | 6 +++--- .../android/exoplayer2/SimpleExoPlayer.java | 14 +++++++++++++ .../analytics/AnalyticsCollector.java | 12 +++++++++++ .../analytics/AnalyticsListener.java | 16 ++++++++++++++ .../audio/AudioRendererEventListener.java | 14 +++++++++++++ .../audio/DecoderAudioRenderer.java | 3 ++- .../audio/MediaCodecAudioRenderer.java | 5 +++++ .../mediacodec/MediaCodecRenderer.java | 12 +++++++++++ .../android/exoplayer2/util/EventLogger.java | 10 +++++++++ .../video/DecoderVideoRenderer.java | 21 +++---------------- .../video/MediaCodecVideoRenderer.java | 5 +++++ .../video/VideoRendererEventListener.java | 14 +++++++++++++ 12 files changed, 110 insertions(+), 22 deletions(-) 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 10ffcc9f9f3..c7b527481a9 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 @@ -215,9 +215,9 @@ interface WakeupListener { @IntDef({STATE_DISABLED, STATE_ENABLED, STATE_STARTED}) @interface State {} /** - * The renderer is disabled. A renderer in this state may hold resources that it requires for - * rendering (e.g. media decoders), for use if it's subsequently enabled. {@link #reset()} can be - * called to force the renderer to release these resources. + * The renderer is disabled. A renderer in this state will not proactively acquire resources that + * it requires for rendering (e.g., media decoders), but may continue to hold any that it already + * has. {@link #reset()} can be called to force the renderer to release such resources. */ int STATE_DISABLED = 0; /** 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 147f277b8a7..c09a43feb6b 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 @@ -2285,6 +2285,13 @@ public void onRenderedFirstFrame(Surface surface) { } } + @Override + public void onVideoDecoderReleased(String decoderName) { + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onVideoDecoderReleased(decoderName); + } + } + @Override public void onVideoDisabled(DecoderCounters counters) { for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { @@ -2351,6 +2358,13 @@ public void onAudioUnderrun(int bufferSize, long bufferSizeMs, long elapsedSince } } + @Override + public void onAudioDecoderReleased(String decoderName) { + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioDecoderReleased(decoderName); + } + } + @Override public void onAudioDisabled(DecoderCounters counters) { for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { 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 4e46914c4be..b19ed00276c 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 @@ -220,6 +220,12 @@ public final void onAudioUnderrun( listener.onAudioUnderrun(eventTime, bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)); } + @Override + public final void onAudioDecoderReleased(String decoderName) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + listeners.sendEvent(listener -> listener.onAudioDecoderReleased(eventTime, decoderName)); + } + @SuppressWarnings("deprecation") @Override public final void onAudioDisabled(DecoderCounters counters) { @@ -307,6 +313,12 @@ public final void onDroppedFrames(int count, long elapsedMs) { listeners.sendEvent(listener -> listener.onDroppedVideoFrames(eventTime, count, elapsedMs)); } + @Override + public final void onVideoDecoderReleased(String decoderName) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + listeners.sendEvent(listener -> listener.onVideoDecoderReleased(eventTime, decoderName)); + } + @SuppressWarnings("deprecation") @Override public final void onVideoDisabled(DecoderCounters 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 65e2abb5828..26e93cc9826 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 @@ -512,6 +512,14 @@ default void onAudioPositionAdvancing(EventTime eventTime, long playoutStartSyst default void onAudioUnderrun( EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + /** + * Called when an audio renderer releases a decoder. + * + * @param eventTime The event time. + * @param decoderName The decoder that was released. + */ + default void onAudioDecoderReleased(EventTime eventTime, String decoderName) {} + /** * Called when an audio renderer is disabled. * @@ -600,6 +608,14 @@ default void onVideoInputFormatChanged(EventTime eventTime, Format format) {} */ default void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {} + /** + * Called when a video renderer releases a decoder. + * + * @param eventTime The event time. + * @param decoderName The decoder that was released. + */ + default void onVideoDecoderReleased(EventTime eventTime, String decoderName) {} + /** * Called when a video renderer is disabled. * 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 e51948725b0..31c3b1f0b32 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 @@ -87,6 +87,13 @@ default void onAudioPositionAdvancing(long playoutStartSystemTimeMs) {} */ default void onAudioUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + /** + * Called when a decoder is released. + * + * @param decoderName The decoder that was released. + */ + default void onAudioDecoderReleased(String decoderName) {} + /** * Called when the renderer is disabled. * @@ -184,6 +191,13 @@ public void underrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFee } } + /** Invokes {@link AudioRendererEventListener#onAudioDecoderReleased(String)}. */ + public void decoderReleased(String decoderName) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onAudioDecoderReleased(decoderName)); + } + } + /** Invokes {@link AudioRendererEventListener#onAudioDisabled(DecoderCounters)}. */ public void disabled(DecoderCounters counters) { counters.ensureUpdated(); 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 c8f3d958d6d..cff2cd5a570 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 @@ -632,9 +632,10 @@ private void releaseDecoder() { decoderReinitializationState = REINITIALIZATION_STATE_NONE; decoderReceivedBuffers = false; if (decoder != null) { + decoderCounters.decoderReleaseCount++; decoder.release(); + eventDispatcher.decoderReleased(decoder.getName()); decoder = null; - decoderCounters.decoderReleaseCount++; } setDecoderDrmSession(null); } 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 e051aa1a3fb..55deaadd658 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 @@ -391,6 +391,11 @@ protected void onCodecInitialized( eventDispatcher.decoderInitialized(name, initializedTimestampMs, initializationDurationMs); } + @Override + protected void onCodecReleased(String name) { + eventDispatcher.decoderReleased(name); + } + @Override protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { super.onInputFormatChanged(formatHolder); 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 d5092a8e51f..89bdefba584 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 @@ -745,6 +745,7 @@ protected void releaseCodec() { if (codec != null) { decoderCounters.decoderReleaseCount++; codec.release(); + onCodecReleased(codecInfo.name); } } finally { codec = null; @@ -1384,6 +1385,17 @@ protected void onCodecInitialized(String name, long initializedTimestampMs, // Do nothing. } + /** + * Called when a {@link MediaCodec} has been released. + * + *

    The default implementation is a no-op. + * + * @param name The name of the codec that was released. + */ + protected void onCodecReleased(String name) { + // Do nothing. + } + /** * Called when a new {@link Format} is read from the upstream {@link MediaPeriod}. * 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 d3e61d02902..32fc0be8edc 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 @@ -342,6 +342,11 @@ public void onAudioUnderrun( /* throwable= */ null); } + @Override + public void onAudioDecoderReleased(EventTime eventTime, String decoderName) { + logd(eventTime, "audioDecoderReleased", decoderName); + } + @Override public void onAudioDisabled(EventTime eventTime, DecoderCounters counters) { logd(eventTime, "audioDisabled"); @@ -397,6 +402,11 @@ public void onDroppedVideoFrames(EventTime eventTime, int count, long elapsedMs) logd(eventTime, "droppedFrames", Integer.toString(count)); } + @Override + public void onVideoDecoderReleased(EventTime eventTime, String decoderName) { + logd(eventTime, "videoDecoderReleased", decoderName); + } + @Override public void onVideoDisabled(EventTime eventTime, DecoderCounters counters) { logd(eventTime, "videoDisabled"); 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 6fda6d2e9c9..1e4aafa71c8 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 @@ -311,22 +311,6 @@ protected void onStreamChanged(Format[] formats, long startPositionUs, long offs super.onStreamChanged(formats, startPositionUs, offsetUs); } - /** - * Called when a decoder has been created and configured. - * - *

    The default implementation is a no-op. - * - * @param name The name of the decoder that was initialized. - * @param initializedTimestampMs {@link SystemClock#elapsedRealtime()} when initialization - * finished. - * @param initializationDurationMs The time taken to initialize the decoder, in milliseconds. - */ - @CallSuper - protected void onDecoderInitialized( - String name, long initializedTimestampMs, long initializationDurationMs) { - eventDispatcher.decoderInitialized(name, initializedTimestampMs, initializationDurationMs); - } - /** * Flushes the decoder. * @@ -358,9 +342,10 @@ protected void releaseDecoder() { decoderReceivedBuffers = false; buffersInCodecCount = 0; if (decoder != null) { + decoderCounters.decoderReleaseCount++; decoder.release(); + eventDispatcher.decoderReleased(decoder.getName()); decoder = null; - decoderCounters.decoderReleaseCount++; } setDecoderDrmSession(null); } @@ -690,7 +675,7 @@ private void maybeInitDecoder() throws ExoPlaybackException { decoder = createDecoder(inputFormat, mediaCrypto); setDecoderOutputMode(outputMode); long decoderInitializedTimestamp = SystemClock.elapsedRealtime(); - onDecoderInitialized( + eventDispatcher.decoderInitialized( decoder.getName(), decoderInitializedTimestamp, decoderInitializedTimestamp - decoderInitializingTimestamp); 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 8eef603ca60..55415b5d33f 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 @@ -616,6 +616,11 @@ protected void onCodecInitialized(String name, long initializedTimestampMs, Assertions.checkNotNull(getCodecInfo()).isHdr10PlusOutOfBandMetadataSupported(); } + @Override + protected void onCodecReleased(String name) { + eventDispatcher.decoderReleased(name); + } + @Override protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { super.onInputFormatChanged(formatHolder); 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 992a262dabd..c714f3ca217 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 @@ -120,6 +120,13 @@ default void onVideoSizeChanged( */ default void onRenderedFirstFrame(@Nullable Surface surface) {} + /** + * Called when a decoder is released. + * + * @param decoderName The decoder that was released. + */ + default void onVideoDecoderReleased(String decoderName) {} + /** * Called when the renderer is disabled. * @@ -211,6 +218,13 @@ public void renderedFirstFrame(@Nullable Surface surface) { } } + /** Invokes {@link VideoRendererEventListener#onVideoDecoderReleased(String)}. */ + public void decoderReleased(String decoderName) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onVideoDecoderReleased(decoderName)); + } + } + /** Invokes {@link VideoRendererEventListener#onVideoDisabled(DecoderCounters)}. */ public void disabled(DecoderCounters counters) { counters.ensureUpdated(); From 5b1514e933f1bd7415737af3c2323f47f08f0b64 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 23 Oct 2020 13:49:56 +0100 Subject: [PATCH 188/693] Fix release note PiperOrigin-RevId: 338657613 --- RELEASENOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index f3d7e1ee3b7..c0bb13644e6 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -36,7 +36,7 @@ ([#7943](https://github.com/google/ExoPlayer/issues/7943)). * Switch Guava dependency from `implementation` to `api` ([#7905](https://github.com/google/ExoPlayer/issues/7905), - ([#7993](https://github.com/google/ExoPlayer/issues/7993)). + [#7993](https://github.com/google/ExoPlayer/issues/7993)). * Add 403, 500 and 503 to the list of HTTP status codes that can trigger failover to another quality variant during adaptive playbacks. * Data sources: From 4bc392a8efbd688c5a8d98769aa4116cfc398c12 Mon Sep 17 00:00:00 2001 From: xufuji456 Date: Thu, 29 Oct 2020 15:12:49 +0800 Subject: [PATCH 189/693] fix the end point of span exceeds the length of SpannableStringBuilder --- .../com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java | 3 +++ 1 file changed, 3 insertions(+) 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 4ce0ea8df52..9c03127803d 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 @@ -185,6 +185,9 @@ private void applyStyleRecord(ParsableByteArray parsableByteArray, int fontFace = parsableByteArray.readUnsignedByte(); parsableByteArray.skipBytes(1); // font size int colorRgba = parsableByteArray.readInt(); + if (end > cueText.length()) { + end = cueText.length(); + } attachFontFace(cueText, fontFace, defaultFontFace, start, end, SPAN_PRIORITY_HIGH); attachColor(cueText, colorRgba, defaultColorRgba, start, end, SPAN_PRIORITY_HIGH); } From a184924322348eec91279e1670b9aeea0f11655c Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 23 Oct 2020 14:11:49 +0100 Subject: [PATCH 190/693] Avoid chunkless preparation if the codec mapping is ambiguous Issue: #7877 PiperOrigin-RevId: 338659937 --- .../google/android/exoplayer2/util/Util.java | 12 +++++++++ .../exoplayer2/source/hls/HlsMediaPeriod.java | 26 ++++++++++--------- 2 files changed, 26 insertions(+), 12 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 81801f33e21..7c865edf9ab 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 @@ -1488,6 +1488,18 @@ public static String getUserAgent(Context context, String applicationName) { + ") " + ExoPlayerLibraryInfo.VERSION_SLASHY; } + /** Returns the number of codec strings in {@code codecs} whose type matches {@code trackType}. */ + public static int getCodecCountOfType(@Nullable String codecs, int trackType) { + String[] codecArray = splitCodecs(codecs); + int count = 0; + for (String codec : codecArray) { + if (trackType == MimeTypes.getTrackTypeOfCodec(codec)) { + count++; + } + } + return count; + } + /** * Returns a copy of {@code codecs} without the codecs whose track type doesn't match {@code * trackType}. 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 5e0709228db..0089f68bf45 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 @@ -603,6 +603,12 @@ private void buildAndPrepareMainSampleStreamWrapper( } } String codecs = selectedPlaylistFormats[0].codecs; + int numberOfVideoCodecs = Util.getCodecCountOfType(codecs, C.TRACK_TYPE_VIDEO); + int numberOfAudioCodecs = Util.getCodecCountOfType(codecs, C.TRACK_TYPE_AUDIO); + boolean codecsStringAllowsChunklessPreparation = + numberOfAudioCodecs <= 1 + && numberOfVideoCodecs <= 1 + && numberOfAudioCodecs + numberOfVideoCodecs > 0; HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper( C.TRACK_TYPE_DEFAULT, @@ -614,18 +620,16 @@ private void buildAndPrepareMainSampleStreamWrapper( positionUs); sampleStreamWrappers.add(sampleStreamWrapper); manifestUrlIndicesPerWrapper.add(selectedVariantIndices); - if (allowChunklessPreparation && codecs != null) { - boolean variantsContainVideoCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_VIDEO) != null; - boolean variantsContainAudioCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_AUDIO) != null; + if (allowChunklessPreparation && codecsStringAllowsChunklessPreparation) { List muxedTrackGroups = new ArrayList<>(); - if (variantsContainVideoCodecs) { + if (numberOfVideoCodecs > 0) { Format[] videoFormats = new Format[selectedVariantsCount]; for (int i = 0; i < videoFormats.length; i++) { videoFormats[i] = deriveVideoFormat(selectedPlaylistFormats[i]); } muxedTrackGroups.add(new TrackGroup(videoFormats)); - if (variantsContainAudioCodecs + if (numberOfAudioCodecs > 0 && (masterPlaylist.muxedAudioFormat != null || masterPlaylist.audios.isEmpty())) { muxedTrackGroups.add( new TrackGroup( @@ -640,7 +644,7 @@ private void buildAndPrepareMainSampleStreamWrapper( muxedTrackGroups.add(new TrackGroup(ccFormats.get(i))); } } - } else if (variantsContainAudioCodecs) { + } else /* numberOfAudioCodecs > 0 */ { // Variants only contain audio. Format[] audioFormats = new Format[selectedVariantsCount]; for (int i = 0; i < audioFormats.length; i++) { @@ -651,9 +655,6 @@ private void buildAndPrepareMainSampleStreamWrapper( /* isPrimaryTrackInVariant= */ true); } muxedTrackGroups.add(new TrackGroup(audioFormats)); - } else { - // Variants contain codecs but no video or audio entries could be identified. - throw new IllegalArgumentException("Unexpected codecs attribute: " + codecs); } TrackGroup id3TrackGroup = @@ -693,7 +694,7 @@ private void buildAndPrepareAudioSampleStreamWrappers( continue; } - boolean renditionsHaveCodecs = true; + boolean codecStringsAllowChunklessPreparation = true; scratchPlaylistUrls.clear(); scratchPlaylistFormats.clear(); scratchIndicesList.clear(); @@ -704,7 +705,8 @@ private void buildAndPrepareAudioSampleStreamWrappers( scratchIndicesList.add(renditionIndex); scratchPlaylistUrls.add(rendition.url); scratchPlaylistFormats.add(rendition.format); - renditionsHaveCodecs &= rendition.format.codecs != null; + codecStringsAllowChunklessPreparation &= + Util.getCodecCountOfType(rendition.format.codecs, C.TRACK_TYPE_AUDIO) == 1; } } @@ -720,7 +722,7 @@ private void buildAndPrepareAudioSampleStreamWrappers( manifestUrlsIndicesPerWrapper.add(Ints.toArray(scratchIndicesList)); sampleStreamWrappers.add(sampleStreamWrapper); - if (allowChunklessPreparation && renditionsHaveCodecs) { + if (allowChunklessPreparation && codecStringsAllowChunklessPreparation) { Format[] renditionFormats = scratchPlaylistFormats.toArray(new Format[0]); sampleStreamWrapper.prepareWithMasterPlaylistInfo( new TrackGroup[] {new TrackGroup(renditionFormats)}, /* primaryTrackGroupIndex= */ 0); From 160ee9d890df463fe907c09736f932b255e1c53d Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 23 Oct 2020 14:50:03 +0100 Subject: [PATCH 191/693] Handle stream volume register/unregister errors Issue: #8106 Issue: #8087 PiperOrigin-RevId: 338664455 --- RELEASENOTES.md | 3 +++ .../exoplayer2/StreamVolumeManager.java | 27 +++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c0bb13644e6..59de500ccad 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -18,6 +18,9 @@ ([#5887](https://github.com/google/ExoPlayer/issues/5887)). * Fix bug where `AnalyticsListener` callbacks can arrive in the wrong order ([#8048](https://github.com/google/ExoPlayer/issues/8048)). + * Suppress exceptions from registering/unregistering the stream volume + receiver ([#8087](https://github.com/google/ExoPlayer/issues/8087)), + ([#8106](https://github.com/google/ExoPlayer/issues/8106)). * Track selection: * Add option to specify multiple preferred audio or text languages. * UI: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java b/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java index 66216de8617..fa5d316b60b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java @@ -21,7 +21,9 @@ import android.content.IntentFilter; import android.media.AudioManager; import android.os.Handler; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; /** A manager that wraps {@link AudioManager} to control/listen audio stream volume. */ @@ -37,6 +39,8 @@ public interface Listener { void onStreamVolumeChanged(int streamVolume, boolean streamMuted); } + private static final String TAG = "StreamVolumeManager"; + // TODO(b/151280453): Replace the hidden intent action with an official one. // Copied from AudioManager#VOLUME_CHANGED_ACTION private static final String VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION"; @@ -48,12 +52,11 @@ public interface Listener { private final Handler eventHandler; private final Listener listener; private final AudioManager audioManager; - private final VolumeChangeReceiver receiver; + @Nullable private VolumeChangeReceiver receiver; @C.StreamType private int streamType; private int volume; private boolean muted; - private boolean released; /** Creates a manager. */ public StreamVolumeManager(Context context, Handler eventHandler, Listener listener) { @@ -68,9 +71,14 @@ public StreamVolumeManager(Context context, Handler eventHandler, Listener liste volume = getVolumeFromManager(audioManager, streamType); muted = getMutedFromManager(audioManager, streamType); - receiver = new VolumeChangeReceiver(); + VolumeChangeReceiver receiver = new VolumeChangeReceiver(); IntentFilter filter = new IntentFilter(VOLUME_CHANGED_ACTION); - applicationContext.registerReceiver(receiver, filter); + try { + applicationContext.registerReceiver(receiver, filter); + this.receiver = receiver; + } catch (RuntimeException e) { + Log.w(TAG, "Error registering stream volume receiver", e); + } } /** Sets the audio stream type. */ @@ -159,11 +167,14 @@ public void setMuted(boolean muted) { /** Releases the manager. It must be called when the manager is no longer required. */ public void release() { - if (released) { - return; + if (receiver != null) { + try { + applicationContext.unregisterReceiver(receiver); + } catch (RuntimeException e) { + Log.w(TAG, "Error unregistering stream volume receiver", e); + } + receiver = null; } - applicationContext.unregisterReceiver(receiver); - released = true; } private void updateVolumeAndNotifyIfChanged() { From 1887983a2283e72975ffc60047fca89ab32ee9a2 Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 23 Oct 2020 15:25:08 +0100 Subject: [PATCH 192/693] Minor Javadoc fix. Issue: #4904 PiperOrigin-RevId: 338669087 --- .../google/android/exoplayer2/source/dash/DashMediaSource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b40972c830e..a9ef1609713 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 @@ -222,7 +222,7 @@ public Factory setLivePresentationDelayMs( } /** - * Sets the {@link Player#getCurrentLiveOffset() target offset for live streams} that is used if + * Sets the target {@link Player#getCurrentLiveOffset() offset for live streams} that is used if * no value is defined in the {@link MediaItem} or the manifest. * *

    The default value is {@link #DEFAULT_FALLBACK_TARGET_LIVE_OFFSET_MS}. From 7ccbf572c7dcc1c1e46855b040fb0580932ffddc Mon Sep 17 00:00:00 2001 From: samrobinson Date: Fri, 23 Oct 2020 18:08:40 +0100 Subject: [PATCH 193/693] Add a Metadata Entry class for SEF Slow motion data. PiperOrigin-RevId: 338695793 --- .../metadata/mp4/SefSlowMotion.java | 167 ++++++++++++++++++ .../extractor/mp4/SefSlowMotionTest.java | 72 ++++++++ 2 files changed, 239 insertions(+) create mode 100644 library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SefSlowMotion.java create mode 100644 library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SefSlowMotionTest.java diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SefSlowMotion.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SefSlowMotion.java new file mode 100644 index 00000000000..d3e9f29d5cc --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SefSlowMotion.java @@ -0,0 +1,167 @@ +/* + * 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.metadata.mp4; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Objects; +import java.util.ArrayList; +import java.util.List; + +/** Holds information about the segments of slow motion playback within a track. */ +public final class SefSlowMotion implements Metadata.Entry { + + /** Holds information about a single segment of slow motion playback within a track. */ + public static final class Segment implements Parcelable { + + /** The start time, in milliseconds, of the track segment that is intended to be slow motion. */ + public final int startTimeMs; + /** The end time, in milliseconds, of the track segment that is intended to be slow motion. */ + public final int endTimeMs; + /** + * The speed reduction factor. + * + *

    For example, 4 would mean the segment should be played at a quarter (1/4) of the normal + * speed. + */ + public final int speedDivisor; + + /** + * Creates an instance. + * + * @param startTimeMs See {@link #startTimeMs}. + * @param endTimeMs See {@link #endTimeMs}. + * @param speedDivisor See {@link #speedDivisor}. + */ + public Segment(int startTimeMs, int endTimeMs, int speedDivisor) { + this.startTimeMs = startTimeMs; + this.endTimeMs = endTimeMs; + this.speedDivisor = speedDivisor; + } + + @Override + public String toString() { + return Util.formatInvariant( + "Segment: startTimeMs=%d, endTimeMs=%d, speedDivisor=%d", + startTimeMs, endTimeMs, speedDivisor); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Segment segment = (Segment) o; + return startTimeMs == segment.startTimeMs + && endTimeMs == segment.endTimeMs + && speedDivisor == segment.speedDivisor; + } + + @Override + public int hashCode() { + return Objects.hashCode(startTimeMs, endTimeMs, speedDivisor); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(startTimeMs); + dest.writeInt(endTimeMs); + dest.writeInt(speedDivisor); + } + + public static final Creator CREATOR = + new Creator() { + + @Override + public Segment createFromParcel(Parcel in) { + int startTimeMs = in.readInt(); + int endTimeMs = in.readInt(); + int speedDivisor = in.readInt(); + return new Segment(startTimeMs, endTimeMs, speedDivisor); + } + + @Override + public Segment[] newArray(int size) { + return new Segment[size]; + } + }; + } + + public final List segments; + + /** Creates an instance with a list of {@link Segment}s. */ + public SefSlowMotion(List segments) { + this.segments = segments; + } + + @Override + public String toString() { + return "SefSlowMotion: segments=" + segments; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SefSlowMotion that = (SefSlowMotion) o; + return segments.equals(that.segments); + } + + @Override + public int hashCode() { + return segments.hashCode(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeList(segments); + } + + public static final Creator CREATOR = + new Creator() { + @Override + public SefSlowMotion createFromParcel(Parcel in) { + List slowMotionSegments = new ArrayList<>(); + in.readList(slowMotionSegments, Segment.class.getClassLoader()); + return new SefSlowMotion(slowMotionSegments); + } + + @Override + public SefSlowMotion[] newArray(int size) { + return new SefSlowMotion[size]; + } + }; +} diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SefSlowMotionTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SefSlowMotionTest.java new file mode 100644 index 00000000000..fe602a1790e --- /dev/null +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SefSlowMotionTest.java @@ -0,0 +1,72 @@ +/* + * 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.extractor.mp4; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Parcel; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.metadata.mp4.SefSlowMotion; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link SefSlowMotion} */ +@RunWith(AndroidJUnit4.class) +public class SefSlowMotionTest { + + @Test + public void parcelable() { + List segments = new ArrayList<>(); + segments.add( + new SefSlowMotion.Segment( + /* startTimeMs= */ 1000, /* endTimeMs= */ 2000, /* speedDivisor= */ 4)); + segments.add( + new SefSlowMotion.Segment( + /* startTimeMs= */ 2600, /* endTimeMs= */ 4000, /* speedDivisor= */ 8)); + segments.add( + new SefSlowMotion.Segment( + /* startTimeMs= */ 8765, /* endTimeMs= */ 12485, /* speedDivisor= */ 16)); + + SefSlowMotion sefSlowMotionToParcel = new SefSlowMotion(segments); + Parcel parcel = Parcel.obtain(); + sefSlowMotionToParcel.writeToParcel(parcel, /* flags= */ 0); + parcel.setDataPosition(0); + + SefSlowMotion sefSlowMotionFromParcel = SefSlowMotion.CREATOR.createFromParcel(parcel); + assertThat(sefSlowMotionFromParcel).isEqualTo(sefSlowMotionToParcel); + + parcel.recycle(); + } + + @Test + public void segment_parcelable() { + SefSlowMotion.Segment segmentToParcel = + new SefSlowMotion.Segment( + /* startTimeMs= */ 1000, /* endTimeMs= */ 2000, /* speedDivisor= */ 4); + + Parcel parcel = Parcel.obtain(); + segmentToParcel.writeToParcel(parcel, /* flags= */ 0); + parcel.setDataPosition(0); + + SefSlowMotion.Segment segmentFromParcel = + SefSlowMotion.Segment.CREATOR.createFromParcel(parcel); + assertThat(segmentFromParcel).isEqualTo(segmentToParcel); + + parcel.recycle(); + } +} From 1fcfef5cdefd020420eaca0d890061b3acbf79c1 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Fri, 23 Oct 2020 20:03:53 +0100 Subject: [PATCH 194/693] Add readDelimiterTerminatedString to ParsableByteArray. PiperOrigin-RevId: 338718776 --- .../exoplayer2/util/ParsableByteArray.java | 13 +++++++- .../util/ParsableByteArrayTest.java | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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 a4e3c1dfbe9..04cc74a05d2 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 @@ -496,11 +496,22 @@ public String readNullTerminatedString(int length) { */ @Nullable public String readNullTerminatedString() { + return readDelimiterTerminatedString('\0'); + } + + /** + * Reads up to the next delimiter byte (or the limit) as UTF-8 characters. + * + * @return The string not including any terminating delimiter byte, or null if the end of the data + * has already been reached. + */ + @Nullable + public String readDelimiterTerminatedString(char delimiter) { if (bytesLeft() == 0) { return null; } int stringLimit = position; - while (stringLimit < limit && data[stringLimit] != 0) { + while (stringLimit < limit && data[stringLimit] != delimiter) { stringLimit++; } String string = Util.fromUtf8Bytes(data, position, stringLimit - position); diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java index 919f50fdc55..8c9cd62cc69 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java @@ -483,6 +483,38 @@ public void readNullTerminatedStringWithoutEndingNull() { assertThat(parser.readNullTerminatedString()).isNull(); } + @Test + public void readDelimiterTerminatedString() { + byte[] bytes = new byte[] {'f', 'o', 'o', '*', 'b', 'a', 'r', '*'}; + // Test normal case. + ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(4); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("bar"); + assertThat(parser.getPosition()).isEqualTo(8); + assertThat(parser.readDelimiterTerminatedString('*')).isNull(); + + // Test with limit at delimiter. + parser = new ParsableByteArray(bytes, 4); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(4); + assertThat(parser.readDelimiterTerminatedString('*')).isNull(); + // Test with limit before delimiter. + parser = new ParsableByteArray(bytes, 3); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); + assertThat(parser.getPosition()).isEqualTo(3); + assertThat(parser.readDelimiterTerminatedString('*')).isNull(); + } + + @Test + public void readDelimiterTerminatedStringWithoutEndingDelimiter() { + byte[] bytes = new byte[] {'f', 'o', 'o', '*', 'b', 'a', 'r'}; + ParsableByteArray parser = new ParsableByteArray(bytes); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("foo"); + assertThat(parser.readDelimiterTerminatedString('*')).isEqualTo("bar"); + assertThat(parser.readDelimiterTerminatedString('*')).isNull(); + } + @Test public void readSingleLineWithoutEndingTrail() { byte[] bytes = new byte[] { From 270e274ef3f6cb17e1f11893cbc9faeffab96d6f Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Mon, 26 Oct 2020 12:05:09 +0000 Subject: [PATCH 195/693] Treat -1000 duration as unknown duration for live streams in Cast Issue: #7983 #minor-release PiperOrigin-RevId: 339016928 --- .../com/google/android/exoplayer2/ext/cast/CastUtils.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java index 182afb04685..4f3f52a5f9d 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java @@ -27,6 +27,10 @@ */ /* package */ final class CastUtils { + /** The duration returned by {@link MediaInfo#getStreamDuration()} for live streams. */ + // TODO: Remove once [Internal ref: b/171657375] is fixed. + private static final long LIVE_STREAM_DURATION = -1000; + /** * Returns the duration in microseconds advertised by a media info, or {@link C#TIME_UNSET} if * unknown or not applicable. @@ -39,7 +43,9 @@ public static long getStreamDurationUs(@Nullable MediaInfo mediaInfo) { return C.TIME_UNSET; } long durationMs = mediaInfo.getStreamDuration(); - return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET; + return durationMs != MediaInfo.UNKNOWN_DURATION && durationMs != LIVE_STREAM_DURATION + ? C.msToUs(durationMs) + : C.TIME_UNSET; } /** From 16c60ecf4b1ec013aa52e91127713d6df6ad6ee0 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 26 Oct 2020 12:59:26 +0000 Subject: [PATCH 196/693] Upgrade IMA SDK dependency to 3.21.0 Call the new method AdsLoader.release() to allow the IMA SDK to dispose of its WebView. Issue: #7344 PiperOrigin-RevId: 339022162 --- RELEASENOTES.md | 3 +++ extensions/ima/build.gradle | 2 +- .../com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 59de500ccad..e567e38f6c3 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -28,6 +28,9 @@ enough space. * Audio: * Retry playback after some types of `AudioTrack` error. +* IMA extension: + * Upgrade IMA SDK dependency to 3.21.0, and release the `AdsLoader` + ([#7344](https://github.com/google/ExoPlayer/issues/7344)). ### 2.12.1 (2020-10-23) ### diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index ed20dedb108..8cdfb0dffc7 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -25,7 +25,7 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.20.1' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.21.0' 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 4185a158f75..c72650cf5ce 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 @@ -871,6 +871,7 @@ public void release() { if (configuration.applicationAdErrorListener != null) { adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener); } + adsLoader.release(); } imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; From 80a37c7ed1013d387b7398614e76c1ef1aab1557 Mon Sep 17 00:00:00 2001 From: kimvde Date: Mon, 26 Oct 2020 15:41:41 +0000 Subject: [PATCH 197/693] Rename MotionPhoto to MotionPhotoMetadata This make it clear that this class does not contain any photo/video data. PiperOrigin-RevId: 339045203 --- ...ionPhoto.java => MotionPhotoMetadata.java} | 22 +++++++++---------- ...Test.java => MotionPhotoMetadataTest.java} | 15 +++++++------ .../exoplayer2/MetadataRetrieverTest.java | 9 ++++---- .../extractor/mp4/Mp4Extractor.java | 15 +++++++------ 4 files changed, 32 insertions(+), 29 deletions(-) rename library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/{MotionPhoto.java => MotionPhotoMetadata.java} (83%) rename library/common/src/test/java/com/google/android/exoplayer2/metadata/mp4/{MotionPhotoTest.java => MotionPhotoMetadataTest.java} (72%) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhoto.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoMetadata.java similarity index 83% rename from library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhoto.java rename to library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoMetadata.java index 9dfd423a7dc..54f678bc1ca 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhoto.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoMetadata.java @@ -23,7 +23,7 @@ import com.google.common.primitives.Longs; /** Metadata of a motion photo file. */ -public final class MotionPhoto implements Metadata.Entry { +public final class MotionPhotoMetadata implements Metadata.Entry { /** The start offset of the photo data, in bytes. */ public final long photoStartPosition; @@ -35,7 +35,7 @@ public final class MotionPhoto implements Metadata.Entry { public final long videoSize; /** Creates an instance. */ - public MotionPhoto( + public MotionPhotoMetadata( long photoStartPosition, long photoSize, long videoStartPosition, long videoSize) { this.photoStartPosition = photoStartPosition; this.photoSize = photoSize; @@ -43,7 +43,7 @@ public MotionPhoto( this.videoSize = videoSize; } - private MotionPhoto(Parcel in) { + private MotionPhotoMetadata(Parcel in) { photoStartPosition = in.readLong(); photoSize = in.readLong(); videoStartPosition = in.readLong(); @@ -58,7 +58,7 @@ public boolean equals(@Nullable Object obj) { if (obj == null || getClass() != obj.getClass()) { return false; } - MotionPhoto other = (MotionPhoto) obj; + MotionPhotoMetadata other = (MotionPhotoMetadata) obj; return photoStartPosition == other.photoStartPosition && photoSize == other.photoSize && videoStartPosition == other.videoStartPosition @@ -77,7 +77,7 @@ public int hashCode() { @Override public String toString() { - return "Motion photo: photoStartPosition=" + return "Motion photo metadata: photoStartPosition=" + photoStartPosition + ", photoSize=" + photoSize @@ -102,17 +102,17 @@ public int describeContents() { return 0; } - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { @Override - public MotionPhoto createFromParcel(Parcel in) { - return new MotionPhoto(in); + public MotionPhotoMetadata createFromParcel(Parcel in) { + return new MotionPhotoMetadata(in); } @Override - public MotionPhoto[] newArray(int size) { - return new MotionPhoto[size]; + public MotionPhotoMetadata[] newArray(int size) { + return new MotionPhotoMetadata[size]; } }; } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoMetadataTest.java similarity index 72% rename from library/common/src/test/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoTest.java rename to library/common/src/test/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoMetadataTest.java index 596ae210f56..600aa1d9cba 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/mp4/MotionPhotoMetadataTest.java @@ -23,25 +23,26 @@ import org.junit.Test; import org.junit.runner.RunWith; -/** Test for {@link MotionPhoto}. */ +/** Test for {@link MotionPhotoMetadata}. */ @RunWith(AndroidJUnit4.class) -public class MotionPhotoTest { +public class MotionPhotoMetadataTest { @Test public void parcelable() { - MotionPhoto motionPhotoToParcel = - new MotionPhoto( + MotionPhotoMetadata motionPhotoMetadataToParcel = + new MotionPhotoMetadata( /* photoStartPosition= */ 0, /* photoSize= */ 10, /* videoStartPosition= */ 15, /* videoSize= */ 35); Parcel parcel = Parcel.obtain(); - motionPhotoToParcel.writeToParcel(parcel, /* flags= */ 0); + motionPhotoMetadataToParcel.writeToParcel(parcel, /* flags= */ 0); parcel.setDataPosition(0); - MotionPhoto motionPhotoFromParcel = MotionPhoto.CREATOR.createFromParcel(parcel); - assertThat(motionPhotoFromParcel).isEqualTo(motionPhotoToParcel); + MotionPhotoMetadata motionPhotoMetadataFromParcel = + MotionPhotoMetadata.CREATOR.createFromParcel(parcel); + assertThat(motionPhotoMetadataFromParcel).isEqualTo(motionPhotoMetadataToParcel); parcel.recycle(); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java index c8fd43677d7..32230a5eaed 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java @@ -25,7 +25,7 @@ import android.os.SystemClock; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.metadata.mp4.MotionPhoto; +import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.util.concurrent.ListenableFuture; @@ -94,8 +94,8 @@ public void retrieveMetadata_multipleMediaItems_outputsExpectedMetadata() throws public void retrieveMetadata_heicMotionPhoto_outputsExpectedMetadata() throws Exception { MediaItem mediaItem = MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample_MP.heic")); - MotionPhoto expectedMotionPhoto = - new MotionPhoto( + MotionPhotoMetadata expectedMotionPhotoMetadata = + new MotionPhotoMetadata( /* photoStartPosition= */ 0, /* photoSize= */ 28_853, /* videoStartPosition= */ 28_869, @@ -107,7 +107,8 @@ public void retrieveMetadata_heicMotionPhoto_outputsExpectedMetadata() throws Ex assertThat(trackGroups.length).isEqualTo(1); assertThat(trackGroups.get(0).length).isEqualTo(1); assertThat(trackGroups.get(0).getFormat(0).metadata.length()).isEqualTo(1); - assertThat(trackGroups.get(0).getFormat(0).metadata.get(0)).isEqualTo(expectedMotionPhoto); + assertThat(trackGroups.get(0).getFormat(0).metadata.get(0)) + .isEqualTo(expectedMotionPhotoMetadata); } @Test 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 313f1cebe79..ed386a1541d 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 @@ -40,7 +40,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.mp4.MotionPhoto; +import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; @@ -78,8 +78,8 @@ public final class Mp4Extractor implements Extractor, SeekMap { */ public static final int FLAG_WORKAROUND_IGNORE_EDIT_LISTS = 1; /** - * Flag to extract {@link MotionPhoto} metadata from HEIC motion photos following the Google - * Photos Motion Photo File Format V1.1. + * Flag to extract {@link MotionPhotoMetadata} from HEIC motion photos following the Google Photos + * Motion Photo File Format V1.1. * *

    As playback is not supported for motion photos, this flag should only be used for metadata * retrieval use cases. @@ -147,7 +147,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { private int firstVideoTrackIndex; private long durationUs; @FileType private int fileType; - @Nullable private MotionPhoto motionPhoto; + @Nullable private MotionPhotoMetadata motionPhotoMetadata; /** * Creates a new extractor for unfragmented MP4 streams. @@ -667,7 +667,8 @@ private void processEndOfStreamReadingAtomHeader() { // Add image track and prepare media. ExtractorOutput extractorOutput = checkNotNull(this.extractorOutput); TrackOutput trackOutput = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_IMAGE); - @Nullable Metadata metadata = motionPhoto == null ? null : new Metadata(motionPhoto); + @Nullable + Metadata metadata = motionPhotoMetadata == null ? null : new Metadata(motionPhotoMetadata); trackOutput.format(new Format.Builder().setMetadata(metadata).build()); extractorOutput.endTracks(); extractorOutput.seekMap(new SeekMap.Unseekable(/* durationUs= */ C.TIME_UNSET)); @@ -704,8 +705,8 @@ private void processUnparsedAtom(long atomStartPosition) { if (atomType == Atom.TYPE_mpvd) { // The input is an HEIC motion photo following the Google Photos Motion Photo File Format // V1.1. - motionPhoto = - new MotionPhoto( + motionPhotoMetadata = + new MotionPhotoMetadata( /* photoStartPosition= */ 0, /* photoSize= */ atomStartPosition, /* videoStartPosition= */ atomStartPosition + atomHeaderBytesRead, From 50566fb8acb2919ead9c4fea7c6a228b1d020388 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 26 Oct 2020 16:10:20 +0000 Subject: [PATCH 198/693] Suppress ProGuard warnings related to Guava's compile-only deps Without these lines, ProGuard fails on the demo app (R8 works). Also include some more `-dontwarn` lines from https://github.com/google/guava/wiki/UsingProGuardWithGuava #minor-release Issue: #8103 PiperOrigin-RevId: 339050634 --- RELEASENOTES.md | 2 ++ library/common/proguard-rules.txt | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e567e38f6c3..65800a2de32 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -21,6 +21,8 @@ * Suppress exceptions from registering/unregistering the stream volume receiver ([#8087](https://github.com/google/ExoPlayer/issues/8087)), ([#8106](https://github.com/google/ExoPlayer/issues/8106)). + * Suppress ProGuard warnings caused by Guava's compile-only dependencies + ([#8103](https://github.com/google/ExoPlayer/issues/8103)). * Track selection: * Add option to specify multiple preferred audio or text languages. * UI: diff --git a/library/common/proguard-rules.txt b/library/common/proguard-rules.txt index 18e5264c203..8de310a867f 100644 --- a/library/common/proguard-rules.txt +++ b/library/common/proguard-rules.txt @@ -7,3 +7,12 @@ # From https://github.com/google/guava/wiki/UsingProGuardWithGuava -dontwarn java.lang.ClassValue +-dontwarn java.lang.SafeVarargs +-dontwarn javax.lang.model.element.Modifier +-dontwarn sun.misc.Unsafe + +# Don't warn about Guava's compile-only dependencies. +# These lines are needed for ProGuard but not R8. +-dontwarn com.google.errorprone.annotations.** +-dontwarn com.google.j2objc.annotations.** +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement From e16ab27b6372293799237dd49f2ba22c8c7f4b33 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 26 Oct 2020 18:45:46 +0000 Subject: [PATCH 199/693] No-op refactor of MediaCodecInfo.isSeamlessAdaptationSupported PiperOrigin-RevId: 339084261 --- .../exoplayer2/mediacodec/MediaCodecInfo.java | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) 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 404066e96d8..95cd0f40eb3 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 @@ -327,34 +327,42 @@ public boolean isSeamlessAdaptationSupported(Format format) { */ public boolean isSeamlessAdaptationSupported( Format oldFormat, Format newFormat, boolean isNewFormatComplete) { + if (!Util.areEqual(oldFormat.sampleMimeType, newFormat.sampleMimeType)) { + return false; + } + if (isVideo) { - return Assertions.checkNotNull(oldFormat.sampleMimeType).equals(newFormat.sampleMimeType) - && oldFormat.rotationDegrees == newFormat.rotationDegrees + return oldFormat.rotationDegrees == newFormat.rotationDegrees && (adaptive || (oldFormat.width == newFormat.width && oldFormat.height == newFormat.height)) && ((!isNewFormatComplete && newFormat.colorInfo == null) || Util.areEqual(oldFormat.colorInfo, newFormat.colorInfo)); } else { - if (!MimeTypes.AUDIO_AAC.equals(mimeType) - || !Assertions.checkNotNull(oldFormat.sampleMimeType).equals(newFormat.sampleMimeType) - || oldFormat.channelCount != newFormat.channelCount + if (oldFormat.channelCount != newFormat.channelCount || oldFormat.sampleRate != newFormat.sampleRate) { return false; } - // Check the codec profile levels support adaptation. - @Nullable - Pair oldCodecProfileLevel = - MediaCodecUtil.getCodecProfileAndLevel(oldFormat); - @Nullable - Pair newCodecProfileLevel = - MediaCodecUtil.getCodecProfileAndLevel(newFormat); - if (oldCodecProfileLevel == null || newCodecProfileLevel == null) { - return false; + + // Check whether we're adapting between two xHE-AAC formats, for which adaptation is possible + // without reconfiguration. + if (MimeTypes.AUDIO_AAC.equals(mimeType)) { + @Nullable + Pair oldCodecProfileLevel = + MediaCodecUtil.getCodecProfileAndLevel(oldFormat); + @Nullable + Pair newCodecProfileLevel = + MediaCodecUtil.getCodecProfileAndLevel(newFormat); + if (oldCodecProfileLevel != null && newCodecProfileLevel != null) { + int oldProfile = oldCodecProfileLevel.first; + int newProfile = newCodecProfileLevel.first; + if (oldProfile == CodecProfileLevel.AACObjectXHE + && newProfile == CodecProfileLevel.AACObjectXHE) { + return true; + } + } } - int oldProfile = oldCodecProfileLevel.first; - int newProfile = newCodecProfileLevel.first; - return oldProfile == CodecProfileLevel.AACObjectXHE - && newProfile == CodecProfileLevel.AACObjectXHE; + + return false; } } From 78940445fe89c7a4f585dd2f27068ab160988948 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 26 Oct 2020 19:01:21 +0000 Subject: [PATCH 200/693] Improve handling of VPAID ads Issue: #7832 PiperOrigin-RevId: 339087555 --- RELEASENOTES.md | 2 ++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 65800a2de32..63a1c8b8d89 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -33,6 +33,8 @@ * IMA extension: * Upgrade IMA SDK dependency to 3.21.0, and release the `AdsLoader` ([#7344](https://github.com/google/ExoPlayer/issues/7344)). + * Improve handling of ad tags with unsupported VPAID ads + ([#7832](https://github.com/google/ExoPlayer/issues/7832)). ### 2.12.1 (2020-10-23) ### 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 c72650cf5ce..e7a82534516 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 @@ -1212,11 +1212,24 @@ private void resumeContentInternal() { if (imaAdInfo != null) { adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); updateAdPlaybackState(); - } else if (adPlaybackState.adGroupCount == 1 && adPlaybackState.adGroupTimesUs[0] == 0) { - // For incompatible VPAID ads with one preroll, content is resumed immediately. In this case - // we haven't received ad info (the ad never loaded), but there is only one ad group to skip. - adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ 0); - updateAdPlaybackState(); + } else { + // Mark any ads for the current/reported player position that haven't loaded as being in the + // error state, to force resuming content. This includes VPAID ads that never load. + long playerPositionUs; + if (player != null) { + playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period)); + } else if (!VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(lastContentProgress)) { + // Playback is backgrounded so use the last reported content position. + playerPositionUs = C.msToUs(lastContentProgress.getCurrentTimeMs()); + } else { + return; + } + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + playerPositionUs, C.msToUs(contentDurationMs)); + if (adGroupIndex != C.INDEX_UNSET) { + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); + } } } From 949e26d1baf62ead53cc29797fa4d313dfe10c23 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 26 Oct 2020 19:28:03 +0000 Subject: [PATCH 201/693] Support delta updates for media playlists Issue: #5011 PiperOrigin-RevId: 339093145 --- library/hls/build.gradle | 1 + .../playlist/DefaultHlsPlaylistTracker.java | 44 ++- .../source/hls/playlist/HlsMediaPlaylist.java | 63 +++- .../hls/playlist/HlsPlaylistParser.java | 7 + .../DefaultHlsPlaylistTrackerTest.java | 296 ++++++++++++++++++ .../playlist/HlsMediaPlaylistParserTest.java | 21 ++ .../assets/media/m3u8/live_low_latency_master | 5 + ...ve_low_latency_master_media_uri_with_param | 5 + .../m3u8/live_low_latency_media_can_not_skip | 16 + .../live_low_latency_media_can_not_skip_next | 16 + ...live_low_latency_media_can_skip_dateranges | 17 + .../live_low_latency_media_can_skip_skipped | 14 + ...skip_skipped_media_sequence_no_overlapping | 14 + .../live_low_latency_media_can_skip_until | 17 + 14 files changed, 517 insertions(+), 19 deletions(-) create mode 100644 library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_master create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_master_media_uri_with_param create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip_next create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until diff --git a/library/hls/build.gradle b/library/hls/build.gradle index 2cc91a5105b..cefa8418164 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -29,6 +29,7 @@ dependencies { compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation project(modulePrefix + 'library-core') + testImplementation project(modulePrefix + 'robolectricutils') testImplementation project(modulePrefix + 'testutils') testImplementation project(modulePrefix + 'testdata') testImplementation 'org.robolectric:robolectric:' + robolectricVersion 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 ccbcb986c1d..c97cdd376a4 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.hls.playlist; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static java.lang.Math.max; import android.net.Uri; @@ -163,7 +164,7 @@ public void stop() { @Override public void addListener(PlaylistEventListener listener) { - Assertions.checkNotNull(listener); + checkNotNull(listener); listeners.add(listener); } @@ -390,7 +391,7 @@ private boolean notifyPlaylistError(Uri playlistUrl, long exclusionDurationMs) { } private HlsMediaPlaylist getLatestPlaylistSnapshot( - HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + @Nullable HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { if (!loadedPlaylist.isNewerThan(oldPlaylist)) { if (loadedPlaylist.hasEndTag) { // If the loaded playlist has an end tag but is not newer than the old playlist then we have @@ -408,7 +409,7 @@ private HlsMediaPlaylist getLatestPlaylistSnapshot( } private long getLoadedPlaylistStartTimeUs( - HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + @Nullable HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { if (loadedPlaylist.hasProgramDateTime) { return loadedPlaylist.startTimeUs; } @@ -430,7 +431,7 @@ private long getLoadedPlaylistStartTimeUs( } private int getLoadedPlaylistDiscontinuitySequence( - HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + @Nullable HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { if (loadedPlaylist.hasDiscontinuitySequence) { return loadedPlaylist.discontinuitySequence; } @@ -464,7 +465,7 @@ private final class MediaPlaylistBundle private final Uri playlistUrl; private final Loader mediaPlaylistLoader; - private final ParsingLoadable mediaPlaylistLoadable; + private final DataSource mediaPlaylistDataSource; @Nullable private HlsMediaPlaylist playlistSnapshot; private long lastSnapshotLoadMs; @@ -477,12 +478,7 @@ private final class MediaPlaylistBundle public MediaPlaylistBundle(Uri playlistUrl) { this.playlistUrl = playlistUrl; mediaPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MediaPlaylist"); - mediaPlaylistLoadable = - new ParsingLoadable<>( - dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), - playlistUrl, - C.DATA_TYPE_MANIFEST, - mediaPlaylistParser); + mediaPlaylistDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST); } @Nullable @@ -533,7 +529,7 @@ public void maybeThrowPlaylistRefreshError() throws IOException { @Override public void onLoadCompleted( ParsingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { - HlsPlaylist result = loadable.getResult(); + @Nullable HlsPlaylist result = loadable.getResult(); LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -631,6 +627,12 @@ public void run() { // Internal methods. private void loadPlaylistImmediately() { + ParsingLoadable mediaPlaylistLoadable = + new ParsingLoadable<>( + mediaPlaylistDataSource, + getMediaPlaylistUriForRequest(playlistUrl, playlistSnapshot), + C.DATA_TYPE_MANIFEST, + mediaPlaylistParser); long elapsedRealtime = mediaPlaylistLoader.startLoading( mediaPlaylistLoadable, @@ -644,7 +646,11 @@ private void loadPlaylistImmediately() { private void processLoadedPlaylist( HlsMediaPlaylist loadedPlaylist, LoadEventInfo loadEventInfo) { - HlsMediaPlaylist oldPlaylist = playlistSnapshot; + @Nullable HlsMediaPlaylist oldPlaylist = playlistSnapshot; + loadedPlaylist = + loadedPlaylist.skippedSegmentCount > 0 + ? loadedPlaylist.expandSkippedSegments(checkNotNull(playlistSnapshot)) + : loadedPlaylist; long currentTimeMs = SystemClock.elapsedRealtime(); lastSnapshotLoadMs = currentTimeMs; playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist); @@ -695,6 +701,18 @@ private void processLoadedPlaylist( } } + private Uri getMediaPlaylistUriForRequest( + Uri playlistUri, @Nullable HlsMediaPlaylist currentMediaPlaylist) { + if (currentMediaPlaylist == null + || currentMediaPlaylist.serverControl.skipUntilUs == C.TIME_UNSET) { + return playlistUri; + } + Uri.Builder uriBuilder = playlistUri.buildUpon(); + uriBuilder.appendQueryParameter( + "_HLS_skip", currentMediaPlaylist.serverControl.canSkipDateRanges ? "v2" : "YES"); + return uriBuilder.build(); + } + /** * Excludes the playlist. * diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java index 022e68bc7d6..1acc864fd3d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls.playlist; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; + import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -23,6 +25,7 @@ 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.List; @@ -275,9 +278,9 @@ public int compareTo(Long relativeStartTimeUs) { * The list of segments in the playlist. */ public final List segments; - /** - * The total duration of the playlist in microseconds. - */ + /** The number of skipped segments. */ + public int skippedSegmentCount; + /** The total duration of the playlist in microseconds. */ public final long durationUs; /** The attributes of the #EXT-X-SERVER-CONTROL header. */ public final ServerControl serverControl; @@ -317,6 +320,7 @@ public HlsMediaPlaylist( boolean hasProgramDateTime, @Nullable DrmInitData protectionSchemes, List segments, + int skippedSegmentCount, ServerControl serverControl) { super(baseUri, tags, hasIndependentSegments); this.playlistType = playlistType; @@ -331,6 +335,7 @@ public HlsMediaPlaylist( this.hasProgramDateTime = hasProgramDateTime; this.protectionSchemes = protectionSchemes; this.segments = Collections.unmodifiableList(segments); + this.skippedSegmentCount = skippedSegmentCount; if (!segments.isEmpty()) { Segment last = segments.get(segments.size() - 1); durationUs = last.relativeStartTimeUs + last.durationUs; @@ -353,7 +358,7 @@ public HlsMediaPlaylist copy(List streamKeys) { * @param other The playlist to compare. * @return Whether this playlist is newer than {@code other}. */ - public boolean isNewerThan(HlsMediaPlaylist other) { + public boolean isNewerThan(@Nullable HlsMediaPlaylist other) { if (other == null || mediaSequence > other.mediaSequence) { return true; } @@ -361,8 +366,8 @@ public boolean isNewerThan(HlsMediaPlaylist other) { return false; } // The media sequences are equal. - int segmentCount = segments.size(); - int otherSegmentCount = other.segments.size(); + int segmentCount = segments.size() + skippedSegmentCount; + int otherSegmentCount = other.segments.size() + other.skippedSegmentCount; return segmentCount > otherSegmentCount || (segmentCount == otherSegmentCount && hasEndTag && !other.hasEndTag); } @@ -374,6 +379,50 @@ public long getEndTimeUs() { return startTimeUs + durationUs; } + /** + * Merges the skipped segments of the previous playlist and returns a copy with a {@link + * #skippedSegmentCount} of 0. + * + * @param previousPlaylist The previous playlist with a {@link #skippedSegmentCount} of zero. + * @return A new playlist with a complete list of segments. + */ + public HlsMediaPlaylist expandSkippedSegments(HlsMediaPlaylist previousPlaylist) { + if (skippedSegmentCount == 0) { + return this; + } + checkArgument(previousPlaylist.skippedSegmentCount == 0); + List mergedSegments = new ArrayList<>(); + long mediaSequence = this.mediaSequence; + int startIndex = (int) (mediaSequence - previousPlaylist.mediaSequence); + int endIndex = startIndex + skippedSegmentCount; + if (startIndex >= 0 && endIndex <= previousPlaylist.segments.size()) { + mergedSegments.addAll(previousPlaylist.segments.subList(startIndex, endIndex)); + } else { + // Adjust the media sequence if the old playlist doesn't contain all of the skipped segments. + mediaSequence += skippedSegmentCount; + } + mergedSegments.addAll(segments); + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + startTimeUs, + hasDiscontinuitySequence, + discontinuitySequence, + mediaSequence, + version, + targetDurationUs, + partTargetDurationUs, + hasIndependentSegments, + hasEndTag, + hasProgramDateTime, + protectionSchemes, + mergedSegments, + /* skippedSegmentCount= */ 0, + serverControl); + } + /** * Returns a playlist identical to this one except for the start time, the discontinuity sequence * and {@code hasDiscontinuitySequence} values. The first two are set to the specified values, @@ -401,6 +450,7 @@ public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { hasProgramDateTime, protectionSchemes, segments, + skippedSegmentCount, serverControl); } @@ -429,6 +479,7 @@ public HlsMediaPlaylist copyWithEndTag() { hasProgramDateTime, protectionSchemes, segments, + skippedSegmentCount, serverControl); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 7e44bcfa51f..587d8c6a26d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -90,6 +90,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser dataSourceQueue = new ArrayDeque<>(); + dataSourceQueue.add(new ByteArrayDataSource(getBytes(SAMPLE_M3U8_LIVE_MASTER))); + dataSourceQueue.add( + new DataSourceList( + new ByteArrayDataSource(getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_NOT_SKIP)), + new ByteArrayDataSource(getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_NOT_SKIP_NEXT)))); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + /* dataSourceFactory= */ dataSourceQueue::remove, + masterPlaylistUri, + /* awaitedMediaPlaylistCount= */ 2); + + HlsMediaPlaylist firstFullPlaylist = mediaPlaylists.get(0); + assertThat(firstFullPlaylist.mediaSequence).isEqualTo(10); + assertThat(firstFullPlaylist.segments.get(0).url).isEqualTo("fileSequence10.ts"); + assertThat(firstFullPlaylist.segments.get(5).url).isEqualTo("fileSequence15.ts"); + assertThat(firstFullPlaylist.segments).hasSize(6); + HlsMediaPlaylist secondFullPlaylist = mediaPlaylists.get(1); + assertThat(secondFullPlaylist.mediaSequence).isEqualTo(11); + assertThat(secondFullPlaylist.skippedSegmentCount).isEqualTo(0); + assertThat(secondFullPlaylist.segments.get(0).url).isEqualTo("fileSequence11.ts"); + assertThat(secondFullPlaylist.segments.get(5).url).isEqualTo("fileSequence16.ts"); + assertThat(secondFullPlaylist.segments).hasSize(6); + assertThat(secondFullPlaylist.segments).containsNoneIn(firstFullPlaylist.segments); + } + + @Test + public void start_playlistCanSkip_requestsDeltaUpdateAndExpandsSkippedSegments() + throws IOException, TimeoutException { + Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); + Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8"); + Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "?_HLS_skip=YES"); + FakeDataSet fakeDataSet = + new FakeDataSet() + .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER)) + .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL)) + .setData(mediaPlaylistSkippedUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), + masterPlaylistUri, + /* awaitedMediaPlaylistCount= */ 2); + + HlsMediaPlaylist initialPlaylistWithAllSegments = mediaPlaylists.get(0); + assertThat(initialPlaylistWithAllSegments.mediaSequence).isEqualTo(10); + assertThat(initialPlaylistWithAllSegments.segments).hasSize(6); + HlsMediaPlaylist mergedPlaylist = mediaPlaylists.get(1); + assertThat(mergedPlaylist.mediaSequence).isEqualTo(11); + assertThat(mergedPlaylist.skippedSegmentCount).isEqualTo(0); + assertThat(mergedPlaylist.segments).hasSize(6); + // First 2 segments of the merged playlist need to be copied from the previous playlist. + assertThat(mergedPlaylist.segments.subList(0, 2)) + .containsExactlyElementsIn(initialPlaylistWithAllSegments.segments.subList(1, 3)) + .inOrder(); + assertThat(mergedPlaylist.segments.get(2).url) + .isEqualTo(initialPlaylistWithAllSegments.segments.get(3).url); + } + + @Test + public void start_playlistCanSkip_missingSegments_correctedMediaSequence() + throws IOException, TimeoutException { + Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); + Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8"); + Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "?_HLS_skip=YES"); + FakeDataSet fakeDataSet = + new FakeDataSet() + .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER)) + .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL)) + .setData( + mediaPlaylistSkippedUri, + getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED_MEDIA_SEQUENCE_NO_OVERLAPPING)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), + masterPlaylistUri, + /* awaitedMediaPlaylistCount= */ 2); + + HlsMediaPlaylist initialPlaylistWithAllSegments = mediaPlaylists.get(0); + assertThat(initialPlaylistWithAllSegments.mediaSequence).isEqualTo(10); + assertThat(initialPlaylistWithAllSegments.segments).hasSize(6); + HlsMediaPlaylist mergedPlaylist = mediaPlaylists.get(1); + assertThat(mergedPlaylist.mediaSequence).isEqualTo(22); + assertThat(mergedPlaylist.skippedSegmentCount).isEqualTo(0); + assertThat(mergedPlaylist.segments).hasSize(4); + } + + @Test + public void start_playlistCanSkipDataRanges_requestsDeltaUpdateV2() + throws IOException, TimeoutException { + Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); + Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8"); + // Expect _HLS_skip parameter with value v2. + Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "?_HLS_skip=v2"); + FakeDataSet fakeDataSet = + new FakeDataSet() + .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER)) + .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_DATERANGES)) + .setData(mediaPlaylistSkippedUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), + masterPlaylistUri, + /* awaitedMediaPlaylistCount= */ 2); + + // Finding the media sequence of the second playlist request asserts that the second request has + // been made with the correct uri parameter appended. + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(11); + } + + @Test + public void start_playlistCanSkipAndUriWithParams_preservesOriginalParams() + throws IOException, TimeoutException { + Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); + Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8?param1=1¶m2=2"); + // Expect _HLS_skip parameter appended with an ampersand. + Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "&_HLS_skip=YES"); + FakeDataSet fakeDataSet = + new FakeDataSet() + .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER_MEDIA_URI_WITH_PARAM)) + .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL)) + .setData(mediaPlaylistSkippedUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), + masterPlaylistUri, + /* awaitedMediaPlaylistCount= */ 2); + + // Finding the media sequence of the second playlist request asserts that the second request has + // been made with the original uri parameters preserved and the additional param concatenated + // correctly. + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(11); + } + + private static List runPlaylistTrackerAndCollectMediaPlaylists( + DataSource.Factory dataSourceFactory, Uri masterPlaylistUri, int awaitedMediaPlaylistCount) + throws TimeoutException { + + DefaultHlsPlaylistTracker defaultHlsPlaylistTracker = + new DefaultHlsPlaylistTracker( + dataType -> dataSourceFactory.createDataSource(), + new DefaultLoadErrorHandlingPolicy(), + new DefaultHlsPlaylistParserFactory()); + + List mediaPlaylists = new ArrayList<>(); + AtomicInteger playlistCounter = new AtomicInteger(); + defaultHlsPlaylistTracker.start( + masterPlaylistUri, + new MediaSourceEventListener.EventDispatcher(), + mediaPlaylist -> { + mediaPlaylists.add(mediaPlaylist); + playlistCounter.addAndGet(1); + }); + + RobolectricUtil.runMainLooperUntil(() -> playlistCounter.get() == awaitedMediaPlaylistCount); + + defaultHlsPlaylistTracker.stop(); + return mediaPlaylists; + } + + private static byte[] getBytes(String filename) throws IOException { + return TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), filename); + } + + private static final class DataSourceList implements DataSource { + + private final DataSource[] dataSources; + + private DataSource delegate; + private int index; + + /** + * Creates an instance. + * + * @param dataSources The data sources to delegate to. + */ + public DataSourceList(DataSource... dataSources) { + checkArgument(dataSources.length > 0); + this.dataSources = dataSources; + delegate = dataSources[index++]; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + for (DataSource dataSource : dataSources) { + dataSource.addTransferListener(transferListener); + } + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + checkState(index <= dataSources.length); + return delegate.open(dataSpec); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + return delegate.read(buffer, offset, readLength); + } + + @Override + @Nullable + public Uri getUri() { + return delegate.getUri(); + } + + @Override + public Map> getResponseHeaders() { + return delegate.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + delegate.close(); + if (index < dataSources.length) { + delegate = dataSources[index]; + } + index++; + } + } +} 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 563d8ab3efd..e92f9a80272 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 @@ -299,6 +299,27 @@ public void parseMediaPlaylist_withServerControl_succeeds() throws IOException { assertThat(playlist.serverControl.canSkipDateRanges).isTrue(); } + @Test + public void parseMediaPlaylist_withSkippedSegments_parsesNumberOfSkippedSegments() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24.0\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-SKIP:SKIPPED-SEGMENTS=1234\n" + + "#EXTINF:4.00008,\n" + + "fileSequence266.mp4"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.skippedSegmentCount).isEqualTo(1234); + } + @Test public void multipleExtXKeysForSingleSegment() throws Exception { Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_master b/testdata/src/test/assets/media/m3u8/live_low_latency_master new file mode 100644 index 00000000000..e595fcaceba --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_master @@ -0,0 +1,5 @@ +#EXTM3U +#EXT-X-INDEPENDENT-SEGMENTS + +#EXT-X-STREAM-INF:BANDWIDTH=2000000,CODECS="avc1.640028,mp4a.40.2" +media0/playlist.m3u8 diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_master_media_uri_with_param b/testdata/src/test/assets/media/m3u8/live_low_latency_master_media_uri_with_param new file mode 100644 index 00000000000..096472d491b --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_master_media_uri_with_param @@ -0,0 +1,5 @@ +#EXTM3U +#EXT-X-INDEPENDENT-SEGMENTS + +#EXT-X-STREAM-INF:BANDWIDTH=2000000,CODECS="avc1.640028,mp4a.40.2" +media0/playlist.m3u8?param1=1¶m2=2 diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip new file mode 100644 index 00000000000..410366456cc --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip @@ -0,0 +1,16 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip_next b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip_next new file mode 100644 index 00000000000..7683359742e --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_not_skip_next @@ -0,0 +1,16 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:11 +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts +#EXTINF:4.00000, +fileSequence16.ts diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges new file mode 100644 index 00000000000..b3ccbaad3c9 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24,CAN-SKIP-DATERANGES=YES diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped new file mode 100644 index 00000000000..05a9fdefb14 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:9 +#EXT-X-MEDIA-SEQUENCE:11 +#EXT-X-SKIP:SKIPPED-SEGMENTS=2 +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts +#EXTINF:4.00000, +fileSequence16.ts +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping new file mode 100644 index 00000000000..639b7f5af42 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:9 +#EXT-X-MEDIA-SEQUENCE:20 +#EXT-X-SKIP:SKIPPED-SEGMENTS=2 +#EXTINF:4.00000, +fileSequence22.ts +#EXTINF:4.00000, +fileSequence23.ts +#EXTINF:4.00000, +fileSequence24.ts +#EXTINF:4.00000, +fileSequence25.ts +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until new file mode 100644 index 00000000000..140fe5556a0 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 From 959851b19081d3bb853d5afb4893443de067fb8f Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 26 Oct 2020 20:38:54 +0000 Subject: [PATCH 202/693] Add MediaCodecInfoTest PiperOrigin-RevId: 339107784 --- .../exoplayer2/mediacodec/MediaCodecInfo.java | 20 +- .../mediacodec/MediaCodecInfoTest.java | 242 ++++++++++++++++++ 2 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java 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 95cd0f40eb3..9a1daf96003 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 @@ -24,6 +24,7 @@ import android.util.Pair; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; @@ -152,11 +153,13 @@ public static MediaCodecInfo newInstance( hardwareAccelerated, softwareOnly, vendor, - forceDisableAdaptive, - forceSecure); + /* adaptive= */ !forceDisableAdaptive && capabilities != null && isAdaptive(capabilities), + /* tunneling= */ capabilities != null && isTunneling(capabilities), + /* secure= */ forceSecure || (capabilities != null && isSecure(capabilities))); } - private MediaCodecInfo( + @VisibleForTesting + /* package */ MediaCodecInfo( String name, String mimeType, String codecMimeType, @@ -164,8 +167,9 @@ private MediaCodecInfo( boolean hardwareAccelerated, boolean softwareOnly, boolean vendor, - boolean forceDisableAdaptive, - boolean forceSecure) { + boolean adaptive, + boolean tunneling, + boolean secure) { this.name = Assertions.checkNotNull(name); this.mimeType = mimeType; this.codecMimeType = codecMimeType; @@ -173,9 +177,9 @@ private MediaCodecInfo( this.hardwareAccelerated = hardwareAccelerated; this.softwareOnly = softwareOnly; this.vendor = vendor; - adaptive = !forceDisableAdaptive && capabilities != null && isAdaptive(capabilities); - tunneling = capabilities != null && isTunneling(capabilities); - secure = forceSecure || (capabilities != null && isSecure(capabilities)); + this.adaptive = adaptive; + this.tunneling = tunneling; + this.secure = secure; isVideo = MimeTypes.isVideo(mimeType); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java new file mode 100644 index 00000000000..488f58b573a --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java @@ -0,0 +1,242 @@ +/* + * 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.android.exoplayer2.util.MimeTypes.AUDIO_AAC; +import static com.google.android.exoplayer2.util.MimeTypes.VIDEO_AV1; +import static com.google.android.exoplayer2.util.MimeTypes.VIDEO_H264; +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.video.ColorInfo; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link MediaCodecInfo}. */ +@RunWith(AndroidJUnit4.class) +public final class MediaCodecInfoTest { + + private static final Format FORMAT_H264_HD = + new Format.Builder() + .setSampleMimeType(VIDEO_H264) + .setWidth(1024) + .setHeight(768) + .setInitializationData(ImmutableList.of(new byte[] {1, 0, 2, 4})) + .build(); + private static final Format FORMAT_H264_4K = + new Format.Builder() + .setSampleMimeType(VIDEO_H264) + .setWidth(3840) + .setHeight(2160) + .setInitializationData(ImmutableList.of(new byte[] {3, 8, 4, 0})) + .build(); + private static final Format FORMAT_AAC_STEREO = + new Format.Builder() + .setSampleMimeType(AUDIO_AAC) + .setChannelCount(2) + .setSampleRate(44100) + .setAverageBitrate(2000) + .setInitializationData(ImmutableList.of(new byte[] {4, 4, 1, 0, 0})) + .build(); + private static final Format FORMAT_AAC_SURROUND = + new Format.Builder() + .setSampleMimeType(AUDIO_AAC) + .setChannelCount(5) + .setSampleRate(44100) + .setAverageBitrate(5000) + .setInitializationData(ImmutableList.of(new byte[] {4, 4, 1, 0, 0})) + .build(); + + @Test + public void isSeamlessAdaptationSupported_withDifferentMimeType_returnsFalse() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); + + Format hdAv1Format = FORMAT_H264_HD.buildUpon().setSampleMimeType(VIDEO_AV1).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_H264_HD, hdAv1Format, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_withRotation_returnsFalse() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); + + Format hdRotatedFormat = FORMAT_H264_HD.buildUpon().setRotationDegrees(90).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_H264_HD, hdRotatedFormat, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_withResolutionChange_adaptiveCodec_returnsTrue() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); + + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_H264_HD, FORMAT_H264_4K, /* isNewFormatComplete= */ true)) + .isTrue(); + } + + @Test + public void isSeamlessAdaptationSupported_withResolutionChange_nonAdaptiveCodec_returnsFalse() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_H264_HD, FORMAT_H264_4K, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_noResolutionChange_nonAdaptiveCodec_returnsTrue() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdVariantFormat = + FORMAT_H264_HD.buildUpon().setInitializationData(ImmutableList.of(new byte[] {0})).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_H264_HD, hdVariantFormat, /* isNewFormatComplete= */ true)) + .isTrue(); + } + + @Test + public void isSeamlessAdaptationSupported_colorInfoOmittedFromCompleteNewFormat_returnsFalse() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdrVariantFormat = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT601)).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + hdrVariantFormat, FORMAT_H264_4K, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_colorInfoOmittedFromIncompleteNewFormat_returnsTrue() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdrVariantFormat = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT601)).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + hdrVariantFormat, FORMAT_H264_4K, /* isNewFormatComplete= */ false)) + .isTrue(); + } + + @Test + public void isSeamlessAdaptationSupported_colorInfoOmittedFromOldFormat_returnsFalse() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdrVariantFormat = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT601)).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_H264_4K, hdrVariantFormat, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_colorInfoChange_returnsFalse() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdrVariantFormat1 = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT601)).build(); + Format hdrVariantFormat2 = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT709)).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + hdrVariantFormat1, hdrVariantFormat2, /* isNewFormatComplete= */ true)) + .isFalse(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + hdrVariantFormat1, hdrVariantFormat2, /* isNewFormatComplete= */ false)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_audioWithDifferentChannelCounts_returnsFalse() { + MediaCodecInfo codecInfo = buildAacCodecInfo(); + + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_AAC_STEREO, FORMAT_AAC_SURROUND, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_audioWithSameChannelCounts_returnsFalse() { + MediaCodecInfo codecInfo = buildAacCodecInfo(); + + Format stereoVariantFormat = FORMAT_AAC_STEREO.buildUpon().setAverageBitrate(100).build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_AAC_STEREO, stereoVariantFormat, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + @Test + public void isSeamlessAdaptationSupported_audioWithDifferentInitializationData_returnsFalse() { + MediaCodecInfo codecInfo = buildAacCodecInfo(); + + Format stereoVariantFormat = + FORMAT_AAC_STEREO + .buildUpon() + .setInitializationData(ImmutableList.of(new byte[] {0})) + .build(); + assertThat( + codecInfo.isSeamlessAdaptationSupported( + FORMAT_AAC_STEREO, stereoVariantFormat, /* isNewFormatComplete= */ true)) + .isFalse(); + } + + private static MediaCodecInfo buildH264CodecInfo(boolean adaptive) { + return new MediaCodecInfo( + "h264", + VIDEO_H264, + VIDEO_H264, + /* capabilities= */ null, + /* hardwareAccelerated= */ true, + /* softwareOnly= */ false, + /* vendor= */ true, + adaptive, + /* tunneling= */ false, + /* secure= */ false); + } + + private static MediaCodecInfo buildAacCodecInfo() { + return new MediaCodecInfo( + "aac", + AUDIO_AAC, + AUDIO_AAC, + /* capabilities= */ null, + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* adaptive= */ false, + /* tunneling= */ false, + /* secure= */ false); + } + + private static ColorInfo buildColorInfo(@C.ColorSpace int colorSpace) { + return new ColorInfo( + colorSpace, C.COLOR_RANGE_FULL, C.COLOR_TRANSFER_HLG, /* hdrStaticInfo= */ null); + } +} From d436a69d8f50df98677a7ff7f619623394e9bada Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 26 Oct 2020 22:39:26 +0000 Subject: [PATCH 203/693] Centralize canKeepCodec logic Logic for determining if (and how) decoders can be adapted is currently split between renderers and MediaCodecInfo. This change centralizes the majority of the logic in MediaCodecInfo. This change also fixes a bug in MediaCodecAudioRenderer when computing max values for the codec. Previously, max values would not be increased to account for potential adaptation to another stream in the case that the codec needs to be flushed for the adaptation to occur. PiperOrigin-RevId: 339133416 --- .../audio/MediaCodecAudioRenderer.java | 38 +----- .../exoplayer2/mediacodec/MediaCodecInfo.java | 103 ++++++++++++--- .../mediacodec/MediaCodecRenderer.java | 35 ++--- .../video/MediaCodecVideoRenderer.java | 27 ++-- .../mediacodec/MediaCodecInfoTest.java | 122 ++++++++++++++++++ 5 files changed, 235 insertions(+), 90 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 55deaadd658..50e7723cd8a 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.audio; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_NO; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static java.lang.Math.max; @@ -42,6 +43,7 @@ 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.MediaCodecInfo.KeepCodecResult; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; @@ -328,40 +330,13 @@ protected void configureCodec( } @Override - protected @KeepCodecResult int canKeepCodec( + @KeepCodecResult + protected int canKeepCodec( MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { if (getCodecMaxInputSize(codecInfo, newFormat) > codecMaxInputSize) { return KEEP_CODEC_RESULT_NO; - } else if (codecInfo.isSeamlessAdaptationSupported( - oldFormat, newFormat, /* isNewFormatComplete= */ true)) { - return KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION; - } else if (canKeepCodecWithFlush(oldFormat, newFormat)) { - return KEEP_CODEC_RESULT_YES_WITH_FLUSH; - } else { - return KEEP_CODEC_RESULT_NO; } - } - - /** - * Returns whether the codec can be flushed and reused when switching to a new format. Reuse is - * generally possible when the codec would be configured in an identical way after the format - * change (excluding {@link MediaFormat#KEY_MAX_INPUT_SIZE} and configuration that does not come - * from the {@link Format}). - * - * @param oldFormat The first format. - * @param newFormat The second format. - * @return Whether the codec can be flushed and reused when switching to a new format. - */ - protected boolean canKeepCodecWithFlush(Format oldFormat, Format newFormat) { - // Flush and reuse the codec if the audio format and initialization data matches. For Opus, we - // don't flush and reuse the codec because the decoder may discard samples after flushing, which - // would result in audio being dropped just after a stream change (see [Internal: b/143450854]). - return Util.areEqual(oldFormat.sampleMimeType, newFormat.sampleMimeType) - && oldFormat.channelCount == newFormat.channelCount - && oldFormat.sampleRate == newFormat.sampleRate - && oldFormat.pcmEncoding == newFormat.pcmEncoding - && oldFormat.initializationDataEquals(newFormat) - && !MimeTypes.AUDIO_OPUS.equals(oldFormat.sampleMimeType); + return codecInfo.canKeepCodec(oldFormat, newFormat); } @Override @@ -698,8 +673,7 @@ protected int getCodecMaxInputSize( return maxInputSize; } for (Format streamFormat : streamFormats) { - if (codecInfo.isSeamlessAdaptationSupported( - format, streamFormat, /* isNewFormatComplete= */ false)) { + if (codecInfo.canKeepCodec(format, streamFormat) != KEEP_CODEC_RESULT_NO) { maxInputSize = max(maxInputSize, getCodecMaxInputSize(codecInfo, streamFormat)); } } 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 9a1daf96003..ced56b53f31 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 @@ -22,6 +22,7 @@ import android.media.MediaCodecInfo.CodecProfileLevel; import android.media.MediaCodecInfo.VideoCapabilities; import android.util.Pair; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; @@ -30,6 +31,9 @@ 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; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; /** Information about a {@link MediaCodec} for a given mime type. */ @SuppressWarnings("InlinedApi") @@ -43,10 +47,32 @@ public final class MediaCodecInfo { */ public static final int MAX_SUPPORTED_INSTANCES_UNKNOWN = -1; + /** The possible return values for {@link #canKeepCodec(Format, Format)}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + KEEP_CODEC_RESULT_NO, + KEEP_CODEC_RESULT_YES_WITH_FLUSH, + KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION, + KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION + }) + public @interface KeepCodecResult {} + /** The codec cannot be kept. */ + public static final int KEEP_CODEC_RESULT_NO = 0; + /** The codec can be kept, but must be flushed. */ + public static final int KEEP_CODEC_RESULT_YES_WITH_FLUSH = 1; + /** + * The codec can be kept. It does not need to be flushed, but must be reconfigured by prefixing + * the next input buffer with the new format's configuration data. + */ + public static final int KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION = 2; + /** The codec can be kept. It does not need to be flushed and no reconfiguration is required. */ + public static final int KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION = 3; + /** * The name of the decoder. - *

    - * May be passed to {@link MediaCodec#createByCodecName(String)} to create an instance of the + * + *

    May be passed to {@link MediaCodec#createByCodecName(String)} to create an instance of the * decoder. */ public final String name; @@ -300,11 +326,12 @@ public boolean isHdr10PlusOutOfBandMetadataSupported() { } /** - * Returns whether it may be possible to adapt to playing a different format when the codec is - * configured to play media in the specified {@code format}. For adaptation to succeed, the codec - * must also be configured with appropriate maximum values and {@link - * #isSeamlessAdaptationSupported(Format, Format, boolean)} must return {@code true} for the - * old/new formats. + * Returns whether it may be possible to adapt an instance of this decoder to playing a different + * format when the codec is configured to play media in the specified {@code format}. + * + *

    For adaptation to succeed, the codec must also be configured with appropriate maximum values + * and {@link #isSeamlessAdaptationSupported(Format, Format, boolean)} must return {@code true} + * for the old/new formats. * * @param format The format of media for which the decoder will be configured. * @return Whether adaptation may be possible @@ -319,36 +346,66 @@ public boolean isSeamlessAdaptationSupported(Format format) { } /** - * Returns whether it is possible to adapt the decoder seamlessly from {@code oldFormat} to {@code - * newFormat}. If {@code newFormat} may not be completely populated, pass {@code false} for {@code - * isNewFormatComplete}. + * Returns whether it is possible to adapt an instance of this decoder seamlessly from {@code + * oldFormat} to {@code newFormat}. If {@code newFormat} may not be completely populated, pass + * {@code false} for {@code isNewFormatComplete}. + * + *

    For adaptation to succeed, the codec must also be configured with maximum values that are + * compatible with the new format. * * @param oldFormat The format being decoded. * @param newFormat The new format. * @param isNewFormatComplete Whether {@code newFormat} is populated with format-specific * metadata. * @return Whether it is possible to adapt the decoder seamlessly. + * @deprecated Use {@link #canKeepCodec}. */ + @Deprecated public boolean isSeamlessAdaptationSupported( Format oldFormat, Format newFormat, boolean isNewFormatComplete) { + if (!isNewFormatComplete && oldFormat.colorInfo != null && newFormat.colorInfo == null) { + newFormat = newFormat.buildUpon().setColorInfo(oldFormat.colorInfo).build(); + } + @KeepCodecResult int keepCodecResult = canKeepCodec(oldFormat, newFormat); + return keepCodecResult == KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION + || keepCodecResult == KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION; + } + + /** + * Returns the extent to which it's possible to adapt an instance of this decoder that's currently + * decoding {@code oldFormat} to decode {@code newFormat} instead. + * + *

    For adaptation to succeed, the codec must also be configured with maximum values that are + * compatible with the new format. + * + * @param oldFormat The format being decoded. + * @param newFormat The new format. + * @return The extent to which it's possible to adapt an instance of the decoder. + */ + @KeepCodecResult + public int canKeepCodec(Format oldFormat, Format newFormat) { if (!Util.areEqual(oldFormat.sampleMimeType, newFormat.sampleMimeType)) { - return false; + return KEEP_CODEC_RESULT_NO; } if (isVideo) { - return oldFormat.rotationDegrees == newFormat.rotationDegrees + if (oldFormat.rotationDegrees == newFormat.rotationDegrees && (adaptive || (oldFormat.width == newFormat.width && oldFormat.height == newFormat.height)) - && ((!isNewFormatComplete && newFormat.colorInfo == null) - || Util.areEqual(oldFormat.colorInfo, newFormat.colorInfo)); + && Util.areEqual(oldFormat.colorInfo, newFormat.colorInfo)) { + return oldFormat.initializationDataEquals(newFormat) + ? KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION + : KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; + } } else { if (oldFormat.channelCount != newFormat.channelCount - || oldFormat.sampleRate != newFormat.sampleRate) { - return false; + || oldFormat.sampleRate != newFormat.sampleRate + || oldFormat.pcmEncoding != newFormat.pcmEncoding) { + return KEEP_CODEC_RESULT_NO; } // Check whether we're adapting between two xHE-AAC formats, for which adaptation is possible - // without reconfiguration. + // without reconfiguration or flushing. if (MimeTypes.AUDIO_AAC.equals(mimeType)) { @Nullable Pair oldCodecProfileLevel = @@ -361,13 +418,21 @@ public boolean isSeamlessAdaptationSupported( int newProfile = newCodecProfileLevel.first; if (oldProfile == CodecProfileLevel.AACObjectXHE && newProfile == CodecProfileLevel.AACObjectXHE) { - return true; + return KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION; } } } - return false; + // For Opus, we don't flush and reuse the codec because the decoder may discard samples after + // flushing, which would result in audio being dropped just after a stream change (see + // [Internal: b/143450854]). For other formats, we allow reuse after flushing if the codec + // initialization data is unchanged. + if (!MimeTypes.AUDIO_OPUS.equals(mimeType) && oldFormat.initializationDataEquals(newFormat)) { + return KEEP_CODEC_RESULT_YES_WITH_FLUSH; + } } + + return KEEP_CODEC_RESULT_NO; } /** 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 89bdefba584..b21d2152778 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,10 @@ */ package com.google.android.exoplayer2.mediacodec; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_NO; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITH_FLUSH; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; import static com.google.android.exoplayer2.util.Assertions.checkState; import static java.lang.Math.max; @@ -43,6 +47,7 @@ import com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KeepCodecResult; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.SampleStream; @@ -192,31 +197,6 @@ private static String buildCustomDiagnosticInfo(int errorCode) { // pending output streams that have fewer frames than the codec latency. private static final int MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT = 10; - /** - * The possible return values for {@link #canKeepCodec(MediaCodec, MediaCodecInfo, Format, - * Format)}. - */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({ - KEEP_CODEC_RESULT_NO, - KEEP_CODEC_RESULT_YES_WITH_FLUSH, - KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION, - KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION - }) - protected @interface KeepCodecResult {} - /** The codec cannot be kept. */ - protected static final int KEEP_CODEC_RESULT_NO = 0; - /** The codec can be kept, but must be flushed. */ - protected static final int KEEP_CODEC_RESULT_YES_WITH_FLUSH = 1; - /** - * The codec can be kept. It does not need to be flushed, but must be reconfigured by prefixing - * the next input buffer with the new format's configuration data. - */ - protected static final int KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION = 2; - /** The codec can be kept. It does not need to be flushed and no reconfiguration is required. */ - protected static final int KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION = 3; - @Documented @Retention(RetentionPolicy.SOURCE) @IntDef({ @@ -1574,7 +1554,7 @@ protected void onProcessedStreamChange() { * Determines whether the existing {@link MediaCodec} can be kept for a new {@link Format}, and if * it can whether it requires reconfiguration. * - *

    The default implementation returns {@link #KEEP_CODEC_RESULT_NO}. + *

    The default implementation returns {@link MediaCodecInfo#KEEP_CODEC_RESULT_NO}. * * @param codec The existing {@link MediaCodec} instance. * @param codecInfo A {@link MediaCodecInfo} describing the decoder. @@ -1582,7 +1562,8 @@ protected void onProcessedStreamChange() { * @param newFormat The new {@link Format}. * @return Whether the instance can be kept, and if it can whether it requires reconfiguration. */ - protected @KeepCodecResult int canKeepCodec( + @KeepCodecResult + protected int canKeepCodec( MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { return KEEP_CODEC_RESULT_NO; } 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 55415b5d33f..eb0cd994eba 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.video; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_NO; import static java.lang.Math.max; import static java.lang.Math.min; @@ -49,6 +50,7 @@ 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.MediaCodecInfo.KeepCodecResult; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; @@ -565,18 +567,15 @@ protected void configureCodec( } @Override - protected @KeepCodecResult int canKeepCodec( + @KeepCodecResult + protected int canKeepCodec( MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { - if (codecInfo.isSeamlessAdaptationSupported( - oldFormat, newFormat, /* isNewFormatComplete= */ true) - && newFormat.width <= codecMaxValues.width - && newFormat.height <= codecMaxValues.height - && getMaxInputSize(codecInfo, newFormat) <= codecMaxValues.inputSize) { - return oldFormat.initializationDataEquals(newFormat) - ? KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION - : KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; + if (newFormat.width > codecMaxValues.width + || newFormat.height > codecMaxValues.height + || getMaxInputSize(codecInfo, newFormat) > codecMaxValues.inputSize) { + return KEEP_CODEC_RESULT_NO; } - return KEEP_CODEC_RESULT_NO; + return codecInfo.canKeepCodec(oldFormat, newFormat); } @CallSuper @@ -1325,8 +1324,12 @@ protected CodecMaxValues getCodecMaxValues( } boolean haveUnknownDimensions = false; for (Format streamFormat : streamFormats) { - if (codecInfo.isSeamlessAdaptationSupported( - format, streamFormat, /* isNewFormatComplete= */ false)) { + if (format.colorInfo != null && streamFormat.colorInfo == null) { + // streamFormat likely has incomplete color information. Copy the complete color information + // from format to avoid codec re-use being ruled out for only this reason. + streamFormat = streamFormat.buildUpon().setColorInfo(format.colorInfo).build(); + } + if (codecInfo.canKeepCodec(format, streamFormat) != KEEP_CODEC_RESULT_NO) { haveUnknownDimensions |= (streamFormat.width == Format.NO_VALUE || streamFormat.height == Format.NO_VALUE); maxWidth = max(maxWidth, streamFormat.width); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java index 488f58b573a..efef2b47b32 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfoTest.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.mediacodec; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_NO; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITH_FLUSH; +import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; import static com.google.android.exoplayer2.util.MimeTypes.AUDIO_AAC; import static com.google.android.exoplayer2.util.MimeTypes.VIDEO_AV1; import static com.google.android.exoplayer2.util.MimeTypes.VIDEO_H264; @@ -64,6 +67,114 @@ public final class MediaCodecInfoTest { .build(); @Test + public void canKeepCodec_withDifferentMimeType_returnsNo() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); + + Format hdAv1Format = FORMAT_H264_HD.buildUpon().setSampleMimeType(VIDEO_AV1).build(); + assertThat(codecInfo.canKeepCodec(FORMAT_H264_HD, hdAv1Format)).isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + public void canKeepCodec_withRotation_returnsNo() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); + + Format hdRotatedFormat = FORMAT_H264_HD.buildUpon().setRotationDegrees(90).build(); + assertThat(codecInfo.canKeepCodec(FORMAT_H264_HD, hdRotatedFormat)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + public void canKeepCodec_withResolutionChange_adaptiveCodec_returnsYesWithReconfiguration() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); + + assertThat(codecInfo.canKeepCodec(FORMAT_H264_HD, FORMAT_H264_4K)) + .isEqualTo(KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION); + } + + @Test + public void canKeepCodec_withResolutionChange_nonAdaptiveCodec_returnsNo() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + assertThat(codecInfo.canKeepCodec(FORMAT_H264_HD, FORMAT_H264_4K)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + public void canKeepCodec_noResolutionChange_nonAdaptiveCodec_returnsYesWithReconfiguration() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdVariantFormat = + FORMAT_H264_HD.buildUpon().setInitializationData(ImmutableList.of(new byte[] {0})).build(); + assertThat(codecInfo.canKeepCodec(FORMAT_H264_HD, hdVariantFormat)) + .isEqualTo(KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION); + } + + @Test + public void canKeepCodec_colorInfoOmittedFromNewFormat_returnsNo() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdrVariantFormat = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT601)).build(); + assertThat(codecInfo.canKeepCodec(hdrVariantFormat, FORMAT_H264_4K)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + public void canKeepCodec_colorInfoOmittedFromOldFormat_returnsNo() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdrVariantFormat = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT601)).build(); + assertThat(codecInfo.canKeepCodec(FORMAT_H264_4K, hdrVariantFormat)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + public void canKeepCodec_colorInfoChange_returnsNo() { + MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); + + Format hdrVariantFormat1 = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT601)).build(); + Format hdrVariantFormat2 = + FORMAT_H264_4K.buildUpon().setColorInfo(buildColorInfo(C.COLOR_SPACE_BT709)).build(); + assertThat(codecInfo.canKeepCodec(hdrVariantFormat1, hdrVariantFormat2)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + assertThat(codecInfo.canKeepCodec(hdrVariantFormat1, hdrVariantFormat2)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + public void canKeepCodec_audioWithDifferentChannelCounts_returnsNo() { + MediaCodecInfo codecInfo = buildAacCodecInfo(); + + assertThat(codecInfo.canKeepCodec(FORMAT_AAC_STEREO, FORMAT_AAC_SURROUND)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + public void canKeepCodec_audioWithSameChannelCounts_returnsYesWithFlush() { + MediaCodecInfo codecInfo = buildAacCodecInfo(); + + Format stereoVariantFormat = FORMAT_AAC_STEREO.buildUpon().setAverageBitrate(100).build(); + assertThat(codecInfo.canKeepCodec(FORMAT_AAC_STEREO, stereoVariantFormat)) + .isEqualTo(KEEP_CODEC_RESULT_YES_WITH_FLUSH); + } + + @Test + public void canKeepCodec_audioWithDifferentInitializationData_returnsNo() { + MediaCodecInfo codecInfo = buildAacCodecInfo(); + + Format stereoVariantFormat = + FORMAT_AAC_STEREO + .buildUpon() + .setInitializationData(ImmutableList.of(new byte[] {0})) + .build(); + assertThat(codecInfo.canKeepCodec(FORMAT_AAC_STEREO, stereoVariantFormat)) + .isEqualTo(KEEP_CODEC_RESULT_NO); + } + + @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_withDifferentMimeType_returnsFalse() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); @@ -75,6 +186,7 @@ public void isSeamlessAdaptationSupported_withDifferentMimeType_returnsFalse() { } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_withRotation_returnsFalse() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); @@ -86,6 +198,7 @@ public void isSeamlessAdaptationSupported_withRotation_returnsFalse() { } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_withResolutionChange_adaptiveCodec_returnsTrue() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ true); @@ -96,6 +209,7 @@ public void isSeamlessAdaptationSupported_withResolutionChange_adaptiveCodec_ret } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_withResolutionChange_nonAdaptiveCodec_returnsFalse() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); @@ -106,6 +220,7 @@ public void isSeamlessAdaptationSupported_withResolutionChange_nonAdaptiveCodec_ } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_noResolutionChange_nonAdaptiveCodec_returnsTrue() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); @@ -118,6 +233,7 @@ public void isSeamlessAdaptationSupported_noResolutionChange_nonAdaptiveCodec_re } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_colorInfoOmittedFromCompleteNewFormat_returnsFalse() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); @@ -130,6 +246,7 @@ public void isSeamlessAdaptationSupported_colorInfoOmittedFromCompleteNewFormat_ } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_colorInfoOmittedFromIncompleteNewFormat_returnsTrue() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); @@ -142,6 +259,7 @@ public void isSeamlessAdaptationSupported_colorInfoOmittedFromIncompleteNewForma } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_colorInfoOmittedFromOldFormat_returnsFalse() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); @@ -154,6 +272,7 @@ public void isSeamlessAdaptationSupported_colorInfoOmittedFromOldFormat_returnsF } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_colorInfoChange_returnsFalse() { MediaCodecInfo codecInfo = buildH264CodecInfo(/* adaptive= */ false); @@ -172,6 +291,7 @@ public void isSeamlessAdaptationSupported_colorInfoChange_returnsFalse() { } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_audioWithDifferentChannelCounts_returnsFalse() { MediaCodecInfo codecInfo = buildAacCodecInfo(); @@ -182,6 +302,7 @@ public void isSeamlessAdaptationSupported_audioWithDifferentChannelCounts_return } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_audioWithSameChannelCounts_returnsFalse() { MediaCodecInfo codecInfo = buildAacCodecInfo(); @@ -193,6 +314,7 @@ public void isSeamlessAdaptationSupported_audioWithSameChannelCounts_returnsFals } @Test + @SuppressWarnings("deprecation") public void isSeamlessAdaptationSupported_audioWithDifferentInitializationData_returnsFalse() { MediaCodecInfo codecInfo = buildAacCodecInfo(); From e57193676aafe3bca2534e8efaffb258dc20a3e6 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 27 Oct 2020 10:12:48 +0000 Subject: [PATCH 204/693] Add min/max live offset to MediaItem.LiveConfiguration. This allows the user and the media to define bounds in which the live offset can vary. Issue: #4904 PiperOrigin-RevId: 339214605 --- .../google/android/exoplayer2/MediaItem.java | 69 ++++++++++++++++++- .../android/exoplayer2/MediaItemTest.java | 20 +++++- .../source/DefaultMediaSourceFactory.java | 42 ++++++++++- .../DefaultLivePlaybackSpeedControlTest.java | 42 ++++++++++- .../source/DefaultMediaSourceFactoryTest.java | 10 +++ 5 files changed, 175 insertions(+), 8 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java index 53d81e91582..b1a09fb72f2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -77,6 +77,8 @@ public static final class Builder { @Nullable private Object tag; @Nullable private MediaMetadata mediaMetadata; private long liveTargetOffsetMs; + private long liveMinOffsetMs; + private long liveMaxOffsetMs; private float liveMinPlaybackSpeed; private float liveMaxPlaybackSpeed; @@ -88,6 +90,8 @@ public Builder() { streamKeys = Collections.emptyList(); subtitles = Collections.emptyList(); liveTargetOffsetMs = C.TIME_UNSET; + liveMinOffsetMs = C.TIME_UNSET; + liveMaxOffsetMs = C.TIME_UNSET; liveMinPlaybackSpeed = C.RATE_UNSET; liveMaxPlaybackSpeed = C.RATE_UNSET; } @@ -102,6 +106,8 @@ private Builder(MediaItem mediaItem) { mediaId = mediaItem.mediaId; mediaMetadata = mediaItem.mediaMetadata; liveTargetOffsetMs = mediaItem.liveConfiguration.targetLiveOffsetMs; + liveMinOffsetMs = mediaItem.liveConfiguration.minLiveOffsetMs; + liveMaxOffsetMs = mediaItem.liveConfiguration.maxLiveOffsetMs; liveMinPlaybackSpeed = mediaItem.liveConfiguration.minPlaybackSpeed; liveMaxPlaybackSpeed = mediaItem.liveConfiguration.maxPlaybackSpeed; @Nullable PlaybackProperties playbackProperties = mediaItem.playbackProperties; @@ -436,6 +442,32 @@ public Builder setLiveTargetOffsetMs(long liveTargetOffsetMs) { return this; } + /** + * Sets the optional minimum offset from the live edge for live streams, in milliseconds. + * + *

    See {@code Player#getCurrentLiveOffset()}. + * + * @param liveMinOffsetMs The minimum allowed live offset, in milliseconds, or {@link + * C#TIME_UNSET} to use the media-defined default. + */ + public Builder setLiveMinOffsetMs(long liveMinOffsetMs) { + this.liveMinOffsetMs = liveMinOffsetMs; + return this; + } + + /** + * Sets the optional maximum offset from the live edge for live streams, in milliseconds. + * + *

    See {@code Player#getCurrentLiveOffset()}. + * + * @param liveMaxOffsetMs The maximum allowed live offset, in milliseconds, or {@link + * C#TIME_UNSET} to use the media-defined default. + */ + public Builder setLiveMaxOffsetMs(long liveMaxOffsetMs) { + this.liveMaxOffsetMs = liveMaxOffsetMs; + return this; + } + /** * Sets the optional minimum playback speed for live stream speed adjustment. * @@ -519,7 +551,12 @@ public MediaItem build() { clipRelativeToDefaultPosition, clipStartsAtKeyFrame), playbackProperties, - new LiveConfiguration(liveTargetOffsetMs, liveMinPlaybackSpeed, liveMaxPlaybackSpeed), + new LiveConfiguration( + liveTargetOffsetMs, + liveMinOffsetMs, + liveMaxOffsetMs, + liveMinPlaybackSpeed, + liveMaxPlaybackSpeed), mediaMetadata != null ? mediaMetadata : new MediaMetadata.Builder().build()); } } @@ -712,7 +749,7 @@ public static final class LiveConfiguration { /** A live playback configuration with unset values. */ public static final LiveConfiguration UNSET = - new LiveConfiguration(C.TIME_UNSET, C.RATE_UNSET, C.RATE_UNSET); + new LiveConfiguration(C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET, C.RATE_UNSET, C.RATE_UNSET); /** * Target live offset, in milliseconds, or {@link C#TIME_UNSET} to use the media-defined @@ -720,6 +757,18 @@ public static final class LiveConfiguration { */ public final long targetLiveOffsetMs; + /** + * The minimum allowed live offset, in milliseconds, or {@link C#TIME_UNSET} to use the + * media-defined default. + */ + public final long minLiveOffsetMs; + + /** + * The maximum allowed live offset, in milliseconds, or {@link C#TIME_UNSET} to use the + * media-defined default. + */ + public final long maxLiveOffsetMs; + /** Minimum playback speed, or {@link C#RATE_UNSET} to use the media-defined default. */ public final float minPlaybackSpeed; @@ -731,14 +780,24 @@ public static final class LiveConfiguration { * * @param targetLiveOffsetMs Target live offset, in milliseconds, or {@link C#TIME_UNSET} to use * the media-defined default. + * @param minLiveOffsetMs The minimum allowed live offset, in milliseconds, or {@link + * C#TIME_UNSET} to use the media-defined default. + * @param maxLiveOffsetMs The maximum allowed live offset, in milliseconds, or {@link + * C#TIME_UNSET} to use the media-defined default. * @param minPlaybackSpeed Minimum playback speed, or {@link C#RATE_UNSET} to use the * media-defined default. * @param maxPlaybackSpeed Maximum playback speed, or {@link C#RATE_UNSET} to use the * media-defined default. */ public LiveConfiguration( - long targetLiveOffsetMs, float minPlaybackSpeed, float maxPlaybackSpeed) { + long targetLiveOffsetMs, + long minLiveOffsetMs, + long maxLiveOffsetMs, + float minPlaybackSpeed, + float maxPlaybackSpeed) { this.targetLiveOffsetMs = targetLiveOffsetMs; + this.minLiveOffsetMs = minLiveOffsetMs; + this.maxLiveOffsetMs = maxLiveOffsetMs; this.minPlaybackSpeed = minPlaybackSpeed; this.maxPlaybackSpeed = maxPlaybackSpeed; } @@ -754,6 +813,8 @@ public boolean equals(@Nullable Object obj) { LiveConfiguration other = (LiveConfiguration) obj; return targetLiveOffsetMs == other.targetLiveOffsetMs + && minLiveOffsetMs == other.minLiveOffsetMs + && maxLiveOffsetMs == other.maxLiveOffsetMs && minPlaybackSpeed == other.minPlaybackSpeed && maxPlaybackSpeed == other.maxPlaybackSpeed; } @@ -761,6 +822,8 @@ public boolean equals(@Nullable Object obj) { @Override public int hashCode() { int result = (int) (targetLiveOffsetMs ^ (targetLiveOffsetMs >>> 32)); + result = 31 * result + (int) (minLiveOffsetMs ^ (minLiveOffsetMs >>> 32)); + result = 31 * result + (int) (maxLiveOffsetMs ^ (maxLiveOffsetMs >>> 32)); result = 31 * result + (minPlaybackSpeed != 0 ? Float.floatToIntBits(minPlaybackSpeed) : 0); result = 31 * result + (maxPlaybackSpeed != 0 ? Float.floatToIntBits(maxPlaybackSpeed) : 0); return result; diff --git a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java index 31aa8bbff23..683e3cbf7f9 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java @@ -295,7 +295,7 @@ public void builderSetMediaMetadata_setsMetadata() { } @Test - public void builderSetLiveTargetLatencyMs_setsLiveTargetLatencyMs() { + public void builderSetLiveTargetOffsetMs_setsLiveTargetOffsetMs() { MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).setLiveTargetOffsetMs(10_000).build(); @@ -318,6 +318,22 @@ public void builderSetMaxLivePlaybackSpeed_setsMaxLivePlaybackSpeed() { assertThat(mediaItem.liveConfiguration.maxPlaybackSpeed).isEqualTo(1.1f); } + @Test + public void builderSetMinLiveOffset_setsMinLiveOffset() { + MediaItem mediaItem = + new MediaItem.Builder().setUri(URI_STRING).setLiveMinOffsetMs(1234).build(); + + assertThat(mediaItem.liveConfiguration.minLiveOffsetMs).isEqualTo(1234); + } + + @Test + public void builderSetMaxLiveOffset_setsMaxLiveOffset() { + MediaItem mediaItem = + new MediaItem.Builder().setUri(URI_STRING).setLiveMaxOffsetMs(1234).build(); + + assertThat(mediaItem.liveConfiguration.maxLiveOffsetMs).isEqualTo(1234); + } + @Test public void buildUpon_equalsToOriginal() { MediaItem mediaItem = @@ -346,6 +362,8 @@ public void buildUpon_equalsToOriginal() { .setLiveTargetOffsetMs(20_000) .setLiveMinPlaybackSpeed(.9f) .setLiveMaxPlaybackSpeed(1.1f) + .setLiveMinOffsetMs(2222) + .setLiveMaxOffsetMs(4444) .setSubtitles( Collections.singletonList( new MediaItem.Subtitle( 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 62a40b01106..a4b97219d25 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 @@ -110,6 +110,8 @@ public interface AdsLoaderProvider { @Nullable private List streamKeys; @Nullable private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private long liveTargetOffsetMs; + private long liveMinOffsetMs; + private long liveMaxOffsetMs; private float liveMinSpeed; private float liveMaxSpeed; @@ -161,6 +163,8 @@ public DefaultMediaSourceFactory( supportedTypes[i] = mediaSourceFactories.keyAt(i); } liveTargetOffsetMs = C.TIME_UNSET; + liveMinOffsetMs = C.TIME_UNSET; + liveMaxOffsetMs = C.TIME_UNSET; liveMinSpeed = C.RATE_UNSET; liveMaxSpeed = C.RATE_UNSET; } @@ -201,6 +205,30 @@ public DefaultMediaSourceFactory setLiveTargetOffsetMs(long liveTargetOffsetMs) return this; } + /** + * Sets the minimum offset from the live edge for live streams, in milliseconds. + * + * @param liveMinOffsetMs The minimum allowed live offset, in milliseconds, or {@link + * C#TIME_UNSET} to use the media-defined default. + * @return This factory, for convenience. + */ + public DefaultMediaSourceFactory setLiveMinOffsetMs(long liveMinOffsetMs) { + this.liveMinOffsetMs = liveMinOffsetMs; + return this; + } + + /** + * Sets the maximum offset from the live edge for live streams, in milliseconds. + * + * @param liveMaxOffsetMs The maximum allowed live offset, in milliseconds, or {@link + * C#TIME_UNSET} to use the media-defined default. + * @return This factory, for convenience. + */ + public DefaultMediaSourceFactory setLiveMaxOffsetMs(long liveMaxOffsetMs) { + this.liveMaxOffsetMs = liveMaxOffsetMs; + return this; + } + /** * Sets the minimum playback speed for live streams. * @@ -294,7 +322,11 @@ public MediaSource createMediaSource(MediaItem mediaItem) { || (mediaItem.liveConfiguration.minPlaybackSpeed == C.RATE_UNSET && liveMinSpeed != C.RATE_UNSET) || (mediaItem.liveConfiguration.maxPlaybackSpeed == C.RATE_UNSET - && liveMaxSpeed != C.RATE_UNSET)) { + && liveMaxSpeed != C.RATE_UNSET) + || (mediaItem.liveConfiguration.minLiveOffsetMs == C.TIME_UNSET + && liveMinOffsetMs != C.TIME_UNSET) + || (mediaItem.liveConfiguration.maxLiveOffsetMs == C.TIME_UNSET + && liveMaxOffsetMs != C.TIME_UNSET)) { mediaItem = mediaItem .buildUpon() @@ -310,6 +342,14 @@ public MediaSource createMediaSource(MediaItem mediaItem) { mediaItem.liveConfiguration.maxPlaybackSpeed == C.RATE_UNSET ? liveMaxSpeed : mediaItem.liveConfiguration.maxPlaybackSpeed) + .setLiveMinOffsetMs( + mediaItem.liveConfiguration.minLiveOffsetMs == C.TIME_UNSET + ? liveMinOffsetMs + : mediaItem.liveConfiguration.minLiveOffsetMs) + .setLiveMaxOffsetMs( + mediaItem.liveConfiguration.maxLiveOffsetMs == C.TIME_UNSET + ? liveMaxOffsetMs + : mediaItem.liveConfiguration.maxLiveOffsetMs) .build(); } MediaSource mediaSource = mediaSourceFactory.createMediaSource(mediaItem); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java index 61c0f88b486..50fcf962838 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java @@ -42,7 +42,11 @@ public void getTargetLiveOffsetUs_afterUpdateLiveConfiguration_usesMediaLiveOffs new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( - /* targetLiveOffsetMs= */ 42, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 200, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); assertThat(defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs()).isEqualTo(42_000); } @@ -55,7 +59,11 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( - /* targetLiveOffsetMs= */ 42, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 200, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); @@ -82,7 +90,11 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( - /* targetLiveOffsetMs= */ 42, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 200, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET); long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); @@ -97,6 +109,8 @@ public void adjustPlaybackSpeed_liveOffsetMatchesTargetOffset_returnsUnitSpeed() defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -113,6 +127,8 @@ public void adjustPlaybackSpeed_liveOffsetWithinAcceptableErrorMargin_returnsUni defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -138,6 +154,8 @@ public void adjustPlaybackSpeed_withLiveOffsetGreaterThanTargetOffset_returnsAdj defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -157,6 +175,8 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -176,6 +196,8 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -194,6 +216,8 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -212,6 +236,8 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ 2f)); @@ -230,6 +256,8 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ 0.2f, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -247,6 +275,8 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -270,6 +300,8 @@ public void adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfiguration_updates defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); @@ -278,6 +310,8 @@ public void adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfiguration_updates defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed2 = @@ -293,6 +327,8 @@ public void adjustPlaybackSpeed_repeatedCallAfterNewTargetLiveOffset_updatesSpee defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); 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 29fe5c690ec..08200f93f33 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 @@ -246,6 +246,8 @@ public void createMediaSource_undefinedLiveProperties_livePropertiesUnset() { MediaItem mediaItemFromSource = mediaSource.getMediaItem(); assertThat(mediaItemFromSource.liveConfiguration.targetLiveOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(mediaItemFromSource.liveConfiguration.minLiveOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(mediaItemFromSource.liveConfiguration.maxLiveOffsetMs).isEqualTo(C.TIME_UNSET); assertThat(mediaItemFromSource.liveConfiguration.minPlaybackSpeed).isEqualTo(C.RATE_UNSET); assertThat(mediaItemFromSource.liveConfiguration.maxPlaybackSpeed).isEqualTo(C.RATE_UNSET); } @@ -255,6 +257,8 @@ public void createMediaSource_withoutMediaItemProperties_usesFactoryLiveProperti DefaultMediaSourceFactory defaultMediaSourceFactory = new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .setLiveTargetOffsetMs(20) + .setLiveMinOffsetMs(2222) + .setLiveMaxOffsetMs(4444) .setLiveMinSpeed(.1f) .setLiveMaxSpeed(2.0f); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mp4").build(); @@ -263,6 +267,8 @@ public void createMediaSource_withoutMediaItemProperties_usesFactoryLiveProperti MediaItem mediaItemFromSource = mediaSource.getMediaItem(); assertThat(mediaItemFromSource.liveConfiguration.targetLiveOffsetMs).isEqualTo(20); + assertThat(mediaItemFromSource.liveConfiguration.minLiveOffsetMs).isEqualTo(2222); + assertThat(mediaItemFromSource.liveConfiguration.maxLiveOffsetMs).isEqualTo(4444); assertThat(mediaItemFromSource.liveConfiguration.minPlaybackSpeed).isEqualTo(.1f); assertThat(mediaItemFromSource.liveConfiguration.maxPlaybackSpeed).isEqualTo(2.0f); } @@ -272,12 +278,16 @@ public void createMediaSource_withMediaItemLiveProperties_overridesFactoryLivePr DefaultMediaSourceFactory defaultMediaSourceFactory = new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .setLiveTargetOffsetMs(20) + .setLiveMinOffsetMs(2222) + .setLiveMinOffsetMs(4444) .setLiveMinSpeed(.1f) .setLiveMaxSpeed(2.0f); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA + "/file.mp4") .setLiveTargetOffsetMs(10) + .setLiveMinOffsetMs(1111) + .setLiveMinOffsetMs(3333) .setLiveMinPlaybackSpeed(20.0f) .setLiveMaxPlaybackSpeed(20.0f) .build(); From aee7e6087c31c3650895495d77695b9d8220c1a1 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 27 Oct 2020 10:16:52 +0000 Subject: [PATCH 205/693] Parse min/max live offset from ServiceDescription. Issue: #4904 PiperOrigin-RevId: 339215091 --- .../dash/manifest/DashManifestParser.java | 7 +++++- .../manifest/ServiceDescriptionElement.java | 16 ++++++++++++- .../dash/manifest/DashManifestParserTest.java | 23 ++++++++++++------- .../dash/manifest/DashManifestTest.java | 6 ++++- ...sample_mpd_service_description_low_latency | 2 +- ...scription_low_latency_only_playback_rates} | 0 ...scription_low_latency_only_target_latency} | 0 7 files changed, 42 insertions(+), 12 deletions(-) rename testdata/src/test/assets/media/mpd/{sample_mpd_service_description_low_latency_no_target_latency => sample_mpd_service_description_low_latency_only_playback_rates} (100%) rename testdata/src/test/assets/media/mpd/{sample_mpd_service_description_low_latency_no_playback_rates => sample_mpd_service_description_low_latency_only_target_latency} (100%) 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 6f4fbf81232..9b5efd49535 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 @@ -250,18 +250,23 @@ protected UtcTimingElement buildUtcTimingElement(String schemeIdUri, String valu protected ServiceDescriptionElement parseServiceDescription(XmlPullParser xpp) throws XmlPullParserException, IOException { long targetOffsetMs = C.TIME_UNSET; + long minOffsetMs = C.TIME_UNSET; + long maxOffsetMs = C.TIME_UNSET; float minPlaybackSpeed = C.RATE_UNSET; float maxPlaybackSpeed = C.RATE_UNSET; do { xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "Latency")) { targetOffsetMs = parseLong(xpp, "target", C.TIME_UNSET); + minOffsetMs = parseLong(xpp, "min", C.TIME_UNSET); + maxOffsetMs = parseLong(xpp, "max", C.TIME_UNSET); } else if (XmlPullParserUtil.isStartTag(xpp, "PlaybackRate")) { minPlaybackSpeed = parseFloat(xpp, "min", C.RATE_UNSET); maxPlaybackSpeed = parseFloat(xpp, "max", C.RATE_UNSET); } } while (!XmlPullParserUtil.isEndTag(xpp, "ServiceDescription")); - return new ServiceDescriptionElement(targetOffsetMs, minPlaybackSpeed, maxPlaybackSpeed); + return new ServiceDescriptionElement( + targetOffsetMs, minOffsetMs, maxOffsetMs, minPlaybackSpeed, maxPlaybackSpeed); } protected Pair parsePeriod( diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/ServiceDescriptionElement.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/ServiceDescriptionElement.java index f54f3aa2ad3..51bd365e3c3 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/ServiceDescriptionElement.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/ServiceDescriptionElement.java @@ -22,6 +22,10 @@ public final class ServiceDescriptionElement { /** The target live offset in milliseconds, or {@link C#TIME_UNSET} if undefined. */ public final long targetOffsetMs; + /** The minimum live offset in milliseconds, or {@link C#TIME_UNSET} if undefined. */ + public final long minOffsetMs; + /** The maximum live offset in milliseconds, or {@link C#TIME_UNSET} if undefined. */ + public final long maxOffsetMs; /** The minimum playback speed for live speed adjustment, or {@link C#RATE_UNSET} if undefined. */ public final float minPlaybackSpeed; /** The maximum playback speed for live speed adjustment, or {@link C#RATE_UNSET} if undefined. */ @@ -32,14 +36,24 @@ public final class ServiceDescriptionElement { * * @param targetOffsetMs The target live offset in milliseconds, or {@link C#TIME_UNSET} if * undefined. + * @param minOffsetMs The minimum live offset in milliseconds, or {@link C#TIME_UNSET} if + * undefined. + * @param maxOffsetMs The maximum live offset in milliseconds, or {@link C#TIME_UNSET} if + * undefined. * @param minPlaybackSpeed The minimum playback speed for live speed adjustment, or {@link * C#RATE_UNSET} if undefined. * @param maxPlaybackSpeed The maximum playback speed for live speed adjustment, or {@link * C#RATE_UNSET} if undefined. */ public ServiceDescriptionElement( - long targetOffsetMs, float minPlaybackSpeed, float maxPlaybackSpeed) { + long targetOffsetMs, + long minOffsetMs, + long maxOffsetMs, + float minPlaybackSpeed, + float maxPlaybackSpeed) { this.targetOffsetMs = targetOffsetMs; + this.minOffsetMs = minOffsetMs; + this.maxOffsetMs = maxOffsetMs; this.minPlaybackSpeed = minPlaybackSpeed; this.maxPlaybackSpeed = maxPlaybackSpeed; } 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 4eb2d37bab6..1265e3d40a2 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 @@ -59,10 +59,10 @@ public class DashManifestParserTest { "media/mpd/sample_mpd_availabilityTimeOffset_segmentList"; private static final String SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY = "media/mpd/sample_mpd_service_description_low_latency"; - private static final String SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_NO_TARGET_LATENCY = - "media/mpd/sample_mpd_service_description_low_latency_no_target_latency"; - private static final String SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_NO_PLAYBACK_RATES = - "media/mpd/sample_mpd_service_description_low_latency_no_playback_rates"; + private static final String SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_ONLY_PLAYBACK_RATES = + "media/mpd/sample_mpd_service_description_low_latency_only_playback_rates"; + private static final String SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_ONLY_TARGET_LATENCY = + "media/mpd/sample_mpd_service_description_low_latency_only_target_latency"; private static final String NEXT_TAG_NAME = "Next"; private static final String NEXT_TAG = "<" + NEXT_TAG_NAME + "/>"; @@ -579,12 +579,14 @@ public void serviceDescriptionElement_allValuesSet() throws IOException { assertThat(manifest.serviceDescription).isNotNull(); assertThat(manifest.serviceDescription.targetOffsetMs).isEqualTo(20_000); + assertThat(manifest.serviceDescription.minOffsetMs).isEqualTo(1_000); + assertThat(manifest.serviceDescription.maxOffsetMs).isEqualTo(30_000); assertThat(manifest.serviceDescription.minPlaybackSpeed).isEqualTo(0.1f); assertThat(manifest.serviceDescription.maxPlaybackSpeed).isEqualTo(99f); } @Test - public void serviceDescriptionElement_noLatency_isUnset() throws IOException { + public void serviceDescriptionElement_onlyPlaybackRates_latencyValuesUnset() throws IOException { DashManifestParser parser = new DashManifestParser(); DashManifest manifest = @@ -592,16 +594,19 @@ public void serviceDescriptionElement_noLatency_isUnset() throws IOException { Uri.parse("https://example.com/test.mpd"), TestUtil.getInputStream( ApplicationProvider.getApplicationContext(), - SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_NO_TARGET_LATENCY)); + SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_ONLY_PLAYBACK_RATES)); assertThat(manifest.serviceDescription).isNotNull(); assertThat(manifest.serviceDescription.targetOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(manifest.serviceDescription.minOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(manifest.serviceDescription.maxOffsetMs).isEqualTo(C.TIME_UNSET); assertThat(manifest.serviceDescription.minPlaybackSpeed).isEqualTo(0.1f); assertThat(manifest.serviceDescription.maxPlaybackSpeed).isEqualTo(99f); } @Test - public void serviceDescriptionElement_noPlaybackRates_isUnset() throws IOException { + public void serviceDescriptionElement_onlyTargetLatency_playbackRatesAndMinMaxLatencyUnset() + throws IOException { DashManifestParser parser = new DashManifestParser(); DashManifest manifest = @@ -609,10 +614,12 @@ public void serviceDescriptionElement_noPlaybackRates_isUnset() throws IOExcepti Uri.parse("https://example.com/test.mpd"), TestUtil.getInputStream( ApplicationProvider.getApplicationContext(), - SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_NO_PLAYBACK_RATES)); + SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_ONLY_TARGET_LATENCY)); assertThat(manifest.serviceDescription).isNotNull(); assertThat(manifest.serviceDescription.targetOffsetMs).isEqualTo(20_000); + assertThat(manifest.serviceDescription.minOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(manifest.serviceDescription.maxOffsetMs).isEqualTo(C.TIME_UNSET); assertThat(manifest.serviceDescription.minPlaybackSpeed).isEqualTo(C.RATE_UNSET); assertThat(manifest.serviceDescription.maxPlaybackSpeed).isEqualTo(C.RATE_UNSET); } 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 c6c5b56e7dd..7b3b3cab512 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 @@ -43,7 +43,11 @@ public void copy() { Representation[][][] representations = newRepresentations(3, 2, 3); ServiceDescriptionElement serviceDescriptionElement = new ServiceDescriptionElement( - /* targetOffsetMs= */ 20, /* minPlaybackSpeed= */ 0.9f, /* maxPlaybackSpeed= */ 1.1f); + /* targetOffsetMs= */ 20, + /* minOffsetMs= */ 10, + /* maxOffsetMs= */ 40, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f); DashManifest sourceManifest = newDashManifest( 10, diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency b/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency index 57b8d59ff12..33d398bc7f3 100644 --- a/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency +++ b/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency @@ -1,7 +1,7 @@ - + diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_no_target_latency b/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_only_playback_rates similarity index 100% rename from testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_no_target_latency rename to testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_only_playback_rates diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_no_playback_rates b/testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_only_target_latency similarity index 100% rename from testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_no_playback_rates rename to testdata/src/test/assets/media/mpd/sample_mpd_service_description_low_latency_only_target_latency From 1c4653f7ee83424a70609bd92a0eacc3930fdc68 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 27 Oct 2020 12:01:10 +0000 Subject: [PATCH 206/693] Improve progress update logs Add logging for ad progress and switch from deprecated getters to new millisecond getters. PiperOrigin-RevId: 339226534 --- .../android/exoplayer2/ext/ima/ImaAdsLoader.java | 16 +++++++--------- .../android/exoplayer2/ext/ima/ImaUtil.java | 12 ++++++++++++ 2 files changed, 19 insertions(+), 9 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 e7a82534516..d619c1fe84f 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 @@ -1119,6 +1119,10 @@ private VideoProgressUpdate getAdVideoProgressUpdate() { private void updateAdProgress() { VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate(); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Ad progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate)); + } + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); for (int i = 0; i < adCallbacks.size(); i++) { adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate); @@ -1730,15 +1734,9 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { public VideoProgressUpdate getContentProgress() { VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); if (configuration.debugModeEnabled) { - if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { - Log.d(TAG, "Content progress: not ready"); - } else { - Log.d( - TAG, - Util.formatInvariant( - "Content progress: %.1f of %.1f s", - videoProgressUpdate.getCurrentTime(), videoProgressUpdate.getDuration())); - } + Log.d( + TAG, + "Content progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate)); } if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java index a4f1ec92cc4..6d69547278b 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java @@ -33,6 +33,7 @@ import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.UiElement; import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; +import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader.OverlayInfo; @@ -202,5 +203,16 @@ public static boolean isAdGroupLoadError(AdError adError) { || adError.getErrorCode() == AdError.AdErrorCode.UNKNOWN_ERROR; } + /** Returns a human-readable representation of a video progress update. */ + public static String getStringForVideoProgressUpdate(VideoProgressUpdate videoProgressUpdate) { + if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { + return "not ready"; + } else { + return Util.formatInvariant( + "%d ms of %d ms", + videoProgressUpdate.getCurrentTimeMs(), videoProgressUpdate.getDurationMs()); + } + } + private ImaUtil() {} } From c0a0708fc382172e85cd377d4de771e8bd99fc2e Mon Sep 17 00:00:00 2001 From: samrobinson Date: Tue, 27 Oct 2020 19:44:12 +0000 Subject: [PATCH 207/693] Extract SEF slow motion cues as Metadata PiperOrigin-RevId: 339307746 --- .../android/exoplayer2/MetadataRetriever.java | 5 +- .../extractor/mp4/Mp4Extractor.java | 63 ++++- .../exoplayer2/extractor/mp4/SefReader.java | 222 ++++++++++++++++++ 3 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java index 5a00cd66f81..6deb792c1bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java @@ -48,7 +48,7 @@ private MetadataRetriever() {} * *

    This is equivalent to using {@link #retrieveMetadata(MediaSourceFactory, MediaItem)} with a * {@link DefaultMediaSourceFactory} and a {@link DefaultExtractorsFactory} with {@link - * Mp4Extractor#FLAG_READ_MOTION_PHOTO_METADATA} set. + * Mp4Extractor#FLAG_READ_MOTION_PHOTO_METADATA} and {@link Mp4Extractor#FLAG_READ_SEF_DATA} set. * * @param context The {@link Context}. * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. @@ -58,7 +58,8 @@ public static ListenableFuture retrieveMetadata( Context context, MediaItem mediaItem) { ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory() - .setMp4ExtractorFlags(Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA); + .setMp4ExtractorFlags( + Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA | Mp4Extractor.FLAG_READ_SEF_DATA); MediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(context, extractorsFactory); return retrieveMetadata(mediaSourceFactory, mediaItem); 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 ed386a1541d..5e5848a0177 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 @@ -41,6 +41,7 @@ import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata; +import com.google.android.exoplayer2.metadata.mp4.SefSlowMotion; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; @@ -65,17 +66,20 @@ public final class Mp4Extractor implements Extractor, SeekMap { /** * Flags controlling the behavior of the extractor. Possible flag values are {@link - * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS} and {@link #FLAG_READ_MOTION_PHOTO_METADATA}. + * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS}, {@link #FLAG_READ_MOTION_PHOTO_METADATA} and {@link + * #FLAG_READ_SEF_DATA}. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {FLAG_WORKAROUND_IGNORE_EDIT_LISTS, FLAG_READ_MOTION_PHOTO_METADATA}) + value = { + FLAG_WORKAROUND_IGNORE_EDIT_LISTS, + FLAG_READ_MOTION_PHOTO_METADATA, + FLAG_READ_SEF_DATA + }) public @interface Flags {} - /** - * Flag to ignore any edit lists in the stream. - */ + /** Flag to ignore any edit lists in the stream. */ public static final int FLAG_WORKAROUND_IGNORE_EDIT_LISTS = 1; /** * Flag to extract {@link MotionPhotoMetadata} from HEIC motion photos following the Google Photos @@ -85,16 +89,27 @@ public final class Mp4Extractor implements Extractor, SeekMap { * retrieval use cases. */ public static final int FLAG_READ_MOTION_PHOTO_METADATA = 1 << 1; + /** + * Flag to extract {@link SefSlowMotion} metadata from Samsung Extension Format (SEF) slow motion + * videos. + */ + public static final int FLAG_READ_SEF_DATA = 1 << 2; /** Parser states. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_READING_ATOM_HEADER, STATE_READING_ATOM_PAYLOAD, STATE_READING_SAMPLE}) + @IntDef({ + STATE_READING_ATOM_HEADER, + STATE_READING_ATOM_PAYLOAD, + STATE_READING_SAMPLE, + STATE_READING_SEF, + }) private @interface State {} private static final int STATE_READING_ATOM_HEADER = 0; private static final int STATE_READING_ATOM_PAYLOAD = 1; private static final int STATE_READING_SAMPLE = 2; + private static final int STATE_READING_SEF = 3; /** Supported file types. */ @Documented @@ -127,6 +142,8 @@ public final class Mp4Extractor implements Extractor, SeekMap { private final ParsableByteArray atomHeader; private final ArrayDeque containerAtoms; + private final SefReader sefReader; + private final List slowMotionMetadataEntries; @State private int parserState; private int atomType; @@ -153,7 +170,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { * Creates a new extractor for unfragmented MP4 streams. */ public Mp4Extractor() { - this(0); + this(/* flags= */ 0); } /** @@ -164,6 +181,10 @@ public Mp4Extractor() { */ public Mp4Extractor(@Flags int flags) { this.flags = flags; + parserState = + ((flags & FLAG_READ_SEF_DATA) != 0) ? STATE_READING_SEF : STATE_READING_ATOM_HEADER; + sefReader = new SefReader(); + slowMotionMetadataEntries = new ArrayList<>(); atomHeader = new ParsableByteArray(Atom.LONG_HEADER_SIZE); containerAtoms = new ArrayDeque<>(); nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); @@ -192,7 +213,14 @@ public void seek(long position, long timeUs) { sampleBytesWritten = 0; sampleCurrentNalBytesRemaining = 0; if (position == 0) { - enterReadingAtomHeaderState(); + // Reading the SEF data occurs before normal MP4 parsing. Therefore we can not transition to + // reading the atom header until that has completed. + if (parserState != STATE_READING_SEF) { + enterReadingAtomHeaderState(); + } else { + sefReader.reset(); + slowMotionMetadataEntries.clear(); + } } else if (tracks != null) { updateSampleIndices(timeUs); } @@ -219,6 +247,8 @@ public int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce break; case STATE_READING_SAMPLE: return readSample(input, seekPosition); + case STATE_READING_SEF: + return readSefData(input, seekPosition); default: throw new IllegalStateException(); } @@ -396,6 +426,15 @@ private boolean readAtomPayload(ExtractorInput input, PositionHolder positionHol return seekRequired && parserState != STATE_READING_SAMPLE; } + @ReadResult + private int readSefData(ExtractorInput input, PositionHolder seekPosition) throws IOException { + @ReadResult int result = sefReader.read(input, seekPosition, slowMotionMetadataEntries); + if (result == RESULT_SEEK && seekPosition.position == 0) { + enterReadingAtomHeaderState(); + } + return result; + } + private void processAtomEnded(long atomEndPosition) throws ParserException { while (!containerAtoms.isEmpty() && containerAtoms.peek().endPosition == atomEndPosition) { Atom.ContainerAtom containerAtom = containerAtoms.pop(); @@ -474,8 +513,14 @@ private void processMoovAtom(ContainerAtom moov) throws ParserException { float frameRate = trackSampleTable.sampleCount / (trackDurationUs / 1000000f); formatBuilder.setFrameRate(frameRate); } + MetadataUtil.setFormatMetadata( - track.type, udtaMetadata, mdtaMetadata, gaplessInfoHolder, formatBuilder); + track.type, + udtaMetadata, + mdtaMetadata, + gaplessInfoHolder, + formatBuilder, + /* additionalEntries...= */ slowMotionMetadataEntries.toArray(new Metadata.Entry[0])); mp4Track.trackOutput.format(formatBuilder.build()); if (track.type == C.TRACK_TYPE_VIDEO && firstVideoTrackIndex == C.INDEX_UNSET) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java new file mode 100644 index 00000000000..2978e0f7145 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java @@ -0,0 +1,222 @@ +/* + * 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.extractor.mp4; + +import static com.google.android.exoplayer2.extractor.Extractor.RESULT_SEEK; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +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.mp4.SefSlowMotion; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.base.Splitter; +import java.io.IOException; +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.List; + +/** + * Reads Samsung Extension Format (SEF) metadata. + * + *

    To be used in conjunction with {@link Mp4Extractor}. + */ +/* package */ final class SefReader { + + /** Reader states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_SHOULD_CHECK_FOR_SEF, + STATE_CHECKING_FOR_SEF, + STATE_READING_SDRS, + STATE_READING_SEF_DATA + }) + private @interface State {} + + private static final int STATE_SHOULD_CHECK_FOR_SEF = 0; + private static final int STATE_CHECKING_FOR_SEF = 1; + private static final int STATE_READING_SDRS = 2; + private static final int STATE_READING_SEF_DATA = 3; + + /** Supported data types. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_SLOW_MOTION_DATA}) + private @interface DataType {} + + private static final int TYPE_SLOW_MOTION_DATA = 0x0890; + + private static final String TAG = "SefReader"; + + // Hex representation of `SEFT` (in ASCII). This is the last byte of a file that has Samsung + // Extension Format (SEF) data. + private static final int SAMSUNG_TAIL_SIGNATURE = 0x53454654; + + // Start signature (4 bytes), SEF version (4 bytes), SDR count (4 bytes). + private static final int TAIL_HEADER_LENGTH = 12; + // Tail offset (4 bytes), tail signature (4 bytes). + private static final int TAIL_FOOTER_LENGTH = 8; + private static final int LENGTH_OF_ONE_SDR = 12; + + private final List dataReferences; + @State private int readerState; + private int tailLength; + + public SefReader() { + dataReferences = new ArrayList<>(); + readerState = STATE_SHOULD_CHECK_FOR_SEF; + } + + public void reset() { + dataReferences.clear(); + readerState = STATE_SHOULD_CHECK_FOR_SEF; + } + + @Extractor.ReadResult + public int read( + ExtractorInput input, + PositionHolder seekPosition, + List slowMotionMetadataEntries) + throws IOException { + switch (readerState) { + case STATE_SHOULD_CHECK_FOR_SEF: + long inputLength = input.getLength(); + seekPosition.position = + inputLength == C.LENGTH_UNSET || inputLength < TAIL_FOOTER_LENGTH + ? 0 + : inputLength - TAIL_FOOTER_LENGTH; + readerState = STATE_CHECKING_FOR_SEF; + break; + case STATE_CHECKING_FOR_SEF: + checkForSefData(input, seekPosition); + break; + case STATE_READING_SDRS: + readSdrs(input, seekPosition); + break; + case STATE_READING_SEF_DATA: + readSefData(input, slowMotionMetadataEntries); + seekPosition.position = 0; + break; + default: + throw new IllegalStateException(); + } + return RESULT_SEEK; + } + + private void checkForSefData(ExtractorInput input, PositionHolder seekPosition) + throws IOException { + ParsableByteArray scratch = new ParsableByteArray(/* limit= */ TAIL_FOOTER_LENGTH); + input.readFully(scratch.getData(), /* offset= */ 0, /* length= */ TAIL_FOOTER_LENGTH); + tailLength = scratch.readLittleEndianInt() + TAIL_FOOTER_LENGTH; + if (scratch.readInt() != SAMSUNG_TAIL_SIGNATURE) { + seekPosition.position = 0; + return; + } + + // input.getPosition is at the very end of the tail, so jump forward by sefTailLength, but + // account for the tail header, which needs to be ignored. + seekPosition.position = input.getPosition() - (tailLength - TAIL_HEADER_LENGTH); + readerState = STATE_READING_SDRS; + } + + private void readSdrs(ExtractorInput input, PositionHolder seekPosition) throws IOException { + long streamLength = input.getLength(); + int sdrsLength = tailLength - TAIL_HEADER_LENGTH - TAIL_FOOTER_LENGTH; + ParsableByteArray scratch = new ParsableByteArray(/* limit= */ sdrsLength); + input.readFully(scratch.getData(), /* offset= */ 0, /* length= */ sdrsLength); + + for (int i = 0; i < sdrsLength / LENGTH_OF_ONE_SDR; i++) { + scratch.skipBytes(2); // SDR data sub info flag and reserved bits (2). + @DataType int dataType = scratch.readLittleEndianShort(); + if (dataType == TYPE_SLOW_MOTION_DATA) { + // The read int is the distance from the tail info to the start of the metadata. + // Calculated as an offset from the start by working backwards. + long startOffset = streamLength - tailLength - scratch.readLittleEndianInt(); + int size = scratch.readLittleEndianInt(); + dataReferences.add(new DataReference(dataType, startOffset, size)); + } else { + scratch.skipBytes(8); // startPosition (4), size (4). + } + } + + if (dataReferences.isEmpty()) { + seekPosition.position = 0; + return; + } + + Collections.sort(dataReferences, (o1, o2) -> Long.compare(o1.startOffset, o2.startOffset)); + readerState = STATE_READING_SEF_DATA; + seekPosition.position = dataReferences.get(0).startOffset; + } + + private void readSefData(ExtractorInput input, List slowMotionMetadataEntries) + throws IOException { + checkNotNull(dataReferences); + Splitter splitter = Splitter.on(':'); + int totalDataLength = (int) (input.getLength() - input.getPosition() - tailLength); + ParsableByteArray scratch = new ParsableByteArray(/* limit= */ totalDataLength); + input.readFully(scratch.getData(), 0, totalDataLength); + + int totalDataReferenceBytesConsumed = 0; + for (int i = 0; i < dataReferences.size(); i++) { + DataReference dataReference = dataReferences.get(i); + if (dataReference.dataType == TYPE_SLOW_MOTION_DATA) { + scratch.skipBytes(23); // data type (2), data sub info (2), name len (4), name (15). + List segments = new ArrayList<>(); + int dataReferenceEndPosition = totalDataReferenceBytesConsumed + dataReference.size; + while (scratch.getPosition() < dataReferenceEndPosition) { + @Nullable String data = scratch.readDelimiterTerminatedString('*'); + List values = splitter.splitToList(checkNotNull(data)); + if (values.size() != 3) { + throw new ParserException(); + } + try { + int startTimeMs = Integer.parseInt(values.get(0)); + int endTimeMs = Integer.parseInt(values.get(1)); + int speedMode = Integer.parseInt(values.get(2)); + int speedDivisor = 1 << (speedMode - 1); + segments.add(new SefSlowMotion.Segment(startTimeMs, endTimeMs, speedDivisor)); + } catch (NumberFormatException e) { + throw new ParserException(e); + } + } + totalDataReferenceBytesConsumed += dataReference.size; + slowMotionMetadataEntries.add(new SefSlowMotion(segments)); + } + } + } + + private static final class DataReference { + @DataType public final int dataType; + public final long startOffset; + public final int size; + + public DataReference(@DataType int dataType, long startOffset, int size) { + this.dataType = dataType; + this.startOffset = startOffset; + this.size = size; + } + } +} From 2c746c6b6bd31ee17c5b0369eb54809cdf995710 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Tue, 27 Oct 2020 23:15:11 +0000 Subject: [PATCH 208/693] Generalise the SlowMotion Metadata.Entry naming. PiperOrigin-RevId: 339352447 --- ...SefSlowMotion.java => SlowMotionData.java} | 22 ++++++------- .../extractor/mp4/Mp4Extractor.java | 4 +-- .../exoplayer2/extractor/mp4/SefReader.java | 10 +++--- ...otionTest.java => SlowMotionDataTest.java} | 32 +++++++++---------- 4 files changed, 34 insertions(+), 34 deletions(-) rename library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/{SefSlowMotion.java => SlowMotionData.java} (88%) rename library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/{SefSlowMotionTest.java => SlowMotionDataTest.java} (66%) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SefSlowMotion.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SlowMotionData.java similarity index 88% rename from library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SefSlowMotion.java rename to library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SlowMotionData.java index d3e9f29d5cc..01448508d78 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SefSlowMotion.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/SlowMotionData.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * 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. @@ -25,7 +25,7 @@ import java.util.List; /** Holds information about the segments of slow motion playback within a track. */ -public final class SefSlowMotion implements Metadata.Entry { +public final class SlowMotionData implements Metadata.Entry { /** Holds information about a single segment of slow motion playback within a track. */ public static final class Segment implements Parcelable { @@ -114,13 +114,13 @@ public Segment[] newArray(int size) { public final List segments; /** Creates an instance with a list of {@link Segment}s. */ - public SefSlowMotion(List segments) { + public SlowMotionData(List segments) { this.segments = segments; } @Override public String toString() { - return "SefSlowMotion: segments=" + segments; + return "SlowMotion: segments=" + segments; } @Override @@ -131,7 +131,7 @@ public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) { return false; } - SefSlowMotion that = (SefSlowMotion) o; + SlowMotionData that = (SlowMotionData) o; return segments.equals(that.segments); } @@ -150,18 +150,18 @@ public void writeToParcel(Parcel dest, int flags) { dest.writeList(segments); } - public static final Creator CREATOR = - new Creator() { + public static final Creator CREATOR = + new Creator() { @Override - public SefSlowMotion createFromParcel(Parcel in) { + public SlowMotionData createFromParcel(Parcel in) { List slowMotionSegments = new ArrayList<>(); in.readList(slowMotionSegments, Segment.class.getClassLoader()); - return new SefSlowMotion(slowMotionSegments); + return new SlowMotionData(slowMotionSegments); } @Override - public SefSlowMotion[] newArray(int size) { - return new SefSlowMotion[size]; + public SlowMotionData[] newArray(int size) { + return new SlowMotionData[size]; } }; } 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 5e5848a0177..2a6346fb87d 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 @@ -41,7 +41,7 @@ import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata; -import com.google.android.exoplayer2.metadata.mp4.SefSlowMotion; +import com.google.android.exoplayer2.metadata.mp4.SlowMotionData; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; @@ -90,7 +90,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { */ public static final int FLAG_READ_MOTION_PHOTO_METADATA = 1 << 1; /** - * Flag to extract {@link SefSlowMotion} metadata from Samsung Extension Format (SEF) slow motion + * Flag to extract {@link SlowMotionData} metadata from Samsung Extension Format (SEF) slow motion * videos. */ public static final int FLAG_READ_SEF_DATA = 1 << 2; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java index 2978e0f7145..c44671a2557 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * 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. @@ -26,7 +26,7 @@ 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.mp4.SefSlowMotion; +import com.google.android.exoplayer2.metadata.mp4.SlowMotionData; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.common.base.Splitter; import java.io.IOException; @@ -184,7 +184,7 @@ private void readSefData(ExtractorInput input, List slowMotionMe DataReference dataReference = dataReferences.get(i); if (dataReference.dataType == TYPE_SLOW_MOTION_DATA) { scratch.skipBytes(23); // data type (2), data sub info (2), name len (4), name (15). - List segments = new ArrayList<>(); + List segments = new ArrayList<>(); int dataReferenceEndPosition = totalDataReferenceBytesConsumed + dataReference.size; while (scratch.getPosition() < dataReferenceEndPosition) { @Nullable String data = scratch.readDelimiterTerminatedString('*'); @@ -197,13 +197,13 @@ private void readSefData(ExtractorInput input, List slowMotionMe int endTimeMs = Integer.parseInt(values.get(1)); int speedMode = Integer.parseInt(values.get(2)); int speedDivisor = 1 << (speedMode - 1); - segments.add(new SefSlowMotion.Segment(startTimeMs, endTimeMs, speedDivisor)); + segments.add(new SlowMotionData.Segment(startTimeMs, endTimeMs, speedDivisor)); } catch (NumberFormatException e) { throw new ParserException(e); } } totalDataReferenceBytesConsumed += dataReference.size; - slowMotionMetadataEntries.add(new SefSlowMotion(segments)); + slowMotionMetadataEntries.add(new SlowMotionData(segments)); } } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SefSlowMotionTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SlowMotionDataTest.java similarity index 66% rename from library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SefSlowMotionTest.java rename to library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SlowMotionDataTest.java index fe602a1790e..5b1c33303dd 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SefSlowMotionTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/SlowMotionDataTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * 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. @@ -19,52 +19,52 @@ import android.os.Parcel; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.metadata.mp4.SefSlowMotion; +import com.google.android.exoplayer2.metadata.mp4.SlowMotionData; import java.util.ArrayList; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link SefSlowMotion} */ +/** Unit test for {@link SlowMotionData} */ @RunWith(AndroidJUnit4.class) -public class SefSlowMotionTest { +public class SlowMotionDataTest { @Test public void parcelable() { - List segments = new ArrayList<>(); + List segments = new ArrayList<>(); segments.add( - new SefSlowMotion.Segment( + new SlowMotionData.Segment( /* startTimeMs= */ 1000, /* endTimeMs= */ 2000, /* speedDivisor= */ 4)); segments.add( - new SefSlowMotion.Segment( + new SlowMotionData.Segment( /* startTimeMs= */ 2600, /* endTimeMs= */ 4000, /* speedDivisor= */ 8)); segments.add( - new SefSlowMotion.Segment( + new SlowMotionData.Segment( /* startTimeMs= */ 8765, /* endTimeMs= */ 12485, /* speedDivisor= */ 16)); - SefSlowMotion sefSlowMotionToParcel = new SefSlowMotion(segments); + SlowMotionData slowMotionDataToParcel = new SlowMotionData(segments); Parcel parcel = Parcel.obtain(); - sefSlowMotionToParcel.writeToParcel(parcel, /* flags= */ 0); + slowMotionDataToParcel.writeToParcel(parcel, /* flags= */ 0); parcel.setDataPosition(0); - SefSlowMotion sefSlowMotionFromParcel = SefSlowMotion.CREATOR.createFromParcel(parcel); - assertThat(sefSlowMotionFromParcel).isEqualTo(sefSlowMotionToParcel); + SlowMotionData slowMotionDataFromParcel = SlowMotionData.CREATOR.createFromParcel(parcel); + assertThat(slowMotionDataFromParcel).isEqualTo(slowMotionDataToParcel); parcel.recycle(); } @Test public void segment_parcelable() { - SefSlowMotion.Segment segmentToParcel = - new SefSlowMotion.Segment( + SlowMotionData.Segment segmentToParcel = + new SlowMotionData.Segment( /* startTimeMs= */ 1000, /* endTimeMs= */ 2000, /* speedDivisor= */ 4); Parcel parcel = Parcel.obtain(); segmentToParcel.writeToParcel(parcel, /* flags= */ 0); parcel.setDataPosition(0); - SefSlowMotion.Segment segmentFromParcel = - SefSlowMotion.Segment.CREATOR.createFromParcel(parcel); + SlowMotionData.Segment segmentFromParcel = + SlowMotionData.Segment.CREATOR.createFromParcel(parcel); assertThat(segmentFromParcel).isEqualTo(segmentToParcel); parcel.recycle(); From de729ecf3a2a686a533de7999c26ca65a403e0e9 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 28 Oct 2020 10:04:20 +0000 Subject: [PATCH 209/693] Fix skipping behavior in ad pods ImaAdsLoader notified onEnded whenever an ad finished playing, but when an ad is skipped in an ad pod we'd receive a playAd call before the player discontinuity for skipping to the next ad. Fix this behavior by checking that IMA's playing ad matches the player's playing ad before notifying onEnded. #minor-release PiperOrigin-RevId: 339424910 --- RELEASENOTES.md | 2 ++ .../android/exoplayer2/ext/ima/ImaAdsLoader.java | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 63a1c8b8d89..ecee8de2f01 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -35,6 +35,8 @@ ([#7344](https://github.com/google/ExoPlayer/issues/7344)). * Improve handling of ad tags with unsupported VPAID ads ([#7832](https://github.com/google/ExoPlayer/issues/7832)). + * Fix a bug that caused multiple ads in an ad pod to be skipped when one + ad in the ad pod was skipped. ### 2.12.1 (2020-10-23) ### 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 d619c1fe84f..265ffe585b9 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 @@ -1300,13 +1300,18 @@ private void handleTimelineOrPositionChanged() { 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); + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + if (playingAdIndexInAdGroup == C.INDEX_UNSET + || (adInfo != null && adInfo.adIndexInAdGroup < playingAdIndexInAdGroup)) { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } + if (configuration.debugModeEnabled) { + Log.d( + TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); + } } } - if (configuration.debugModeEnabled) { - Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); - } } if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { int adGroupIndex = player.getCurrentAdGroupIndex(); From ae6907e1f59f616c9da003c0bf8375564e93eb72 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 28 Oct 2020 12:10:04 +0000 Subject: [PATCH 210/693] Don't rely on events after player release in ExoHostedTest ExoHostedTest was calculating the total play time by solely listening to onIsPlayingChanged. The last update to false (when playback ends) was relying on a callback that was always called after the player has already been released. This happened because of a now fixed bug where callbacks were still issued if player.release() is called from within another callback (as in ExoHostedTest). Fix the currently broken test by posting the release call so that all pending events are still delivered first. PiperOrigin-RevId: 339438863 --- .../google/android/exoplayer2/testutil/ExoHostedTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 fbdd9590aa8..179162d4140 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.testutil; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import static com.google.common.truth.Truth.assertWithMessage; import android.os.ConditionVariable; @@ -184,7 +185,9 @@ public final void onPlaybackStateChanged(EventTime eventTime, @Player.State int playerWasPrepared |= playbackState != Player.STATE_IDLE; if (playbackState == Player.STATE_ENDED || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { - stopTest(); + // Post stopTest to ensure all currently pending events (e.g. onIsPlayingChanged or + // onPlayerError) are still delivered before the player is released. + checkStateNotNull(actionHandler).post(this::stopTest); } } From 32a72fa237c181a7a0443d0da6116ca447a9ac7e Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Wed, 28 Oct 2020 13:32:28 +0000 Subject: [PATCH 211/693] Add sample for testing skippable ads in midrolls #minor-release PiperOrigin-RevId: 339447845 --- demos/main/src/main/assets/media.exolist.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index 24213918f50..4fdfaddea62 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -497,6 +497,11 @@ "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" + }, + { + "name": "VMAP midroll ad pod at 5 s with 10 skippable ads", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4", + "ad_tag_uri": "https://vastsynthesizer.appspot.com/midroll-10-skippable-ads" } ] }, From aab6aef443d49d60eaad0f749a174e6cf93533b5 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 28 Oct 2020 14:10:11 +0000 Subject: [PATCH 212/693] Set min/max supported live offset in DashMediaSource. In order to ensure we can update the values for new manifests but still use the user provided override, we need to save the original and the updated MediaItem seperately. And in order to incorporate the existing logic for the min/max supported live offset, which we already use to correct the target offset, also move both places together so that all the adjustment happens in one place. Logical adjustments to the previous min/max supported live offset: - Use the user-provided MediaItem values if set - Use the newly parsed ServiceDescription values if available. - Limit the minimum to 0 if the current time is in the window and we can assume to have low-latency stream. - Add minBufferTime from the manifest to ensure we don't reduce the live offset below this value. Issue: #4904 PiperOrigin-RevId: 339452816 --- .../source/dash/DashMediaSource.java | 145 ++++++++++-------- .../source/dash/DashMediaSourceTest.java | 38 ++++- ...mpd_live_with_complete_service_description | 2 +- ...esentation_delay_2s_min_buffer_time_500ms} | 3 +- 4 files changed, 116 insertions(+), 72 deletions(-) rename testdata/src/test/assets/media/mpd/{sample_mpd_live_with_suggested_presentation_delay_2s => sample_mpd_live_with_suggested_presentation_delay_2s_min_buffer_time_500ms} (91%) 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 a9ef1609713..ba34fd709dc 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 @@ -427,6 +427,7 @@ public int[] getSupportedTypes() { private static final String TAG = "DashMediaSource"; + private final MediaItem originalMediaItem; private final boolean sideloadedManifest; private final DataSource.Factory manifestDataSourceFactory; private final DashChunkSource.Factory chunkSourceFactory; @@ -451,8 +452,7 @@ public int[] getSupportedTypes() { private IOException manifestFatalError; private Handler handler; - private MediaItem mediaItem; - private MediaItem.PlaybackProperties playbackProperties; + private MediaItem updatedMediaItem; private Uri manifestUri; private Uri initialManifestUri; private DashManifest manifest; @@ -476,10 +476,10 @@ private DashMediaSource( DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, long fallbackTargetLiveOffsetMs) { - this.mediaItem = mediaItem; - this.playbackProperties = checkNotNull(mediaItem.playbackProperties); - this.manifestUri = playbackProperties.uri; - this.initialManifestUri = playbackProperties.uri; + this.originalMediaItem = mediaItem; + this.updatedMediaItem = mediaItem; + this.manifestUri = checkNotNull(mediaItem.playbackProperties).uri; + this.initialManifestUri = mediaItem.playbackProperties.uri; this.manifest = manifest; this.manifestDataSourceFactory = manifestDataSourceFactory; this.manifestParser = manifestParser; @@ -531,12 +531,12 @@ public void replaceManifestUri(Uri manifestUri) { @Override @Nullable public Object getTag() { - return playbackProperties.tag; + return castNonNull(updatedMediaItem.playbackProperties).tag; } @Override public MediaItem getMediaItem() { - return mediaItem; + return updatedMediaItem; } @Override @@ -692,9 +692,6 @@ protected void releaseSourceInternal() { staleManifestReloadAttempt = 0; } - mediaItem = mergeLiveConfiguration(mediaItem, fallbackTargetLiveOffsetMs, newManifest); - playbackProperties = castNonNull(mediaItem.playbackProperties); - manifest = newManifest; manifestLoadPending &= manifest.dynamic; manifestLoadStartTimestampMs = elapsedRealtimeMs - loadDurationMs; @@ -939,13 +936,13 @@ private void processManifest(boolean scheduleRefresh) { long windowDefaultStartPositionUs = 0; if (manifest.dynamic) { - ensureTargetLiveOffsetIsInLiveWindow( + updateMediaItemLiveConfiguration( /* nowPeriodTimeUs= */ currentStartTimeUs + nowUnixTimeUs - C.msToUs(windowStartTimeMs), /* windowStartPeriodTimeUs= */ currentStartTimeUs, /* windowEndPeriodTimeUs= */ currentEndTimeUs); windowDefaultStartPositionUs = nowUnixTimeUs - - C.msToUs(windowStartTimeMs + mediaItem.liveConfiguration.targetLiveOffsetMs); + - C.msToUs(windowStartTimeMs + updatedMediaItem.liveConfiguration.targetLiveOffsetMs); long minimumDefaultStartPositionUs = min(MIN_LIVE_DEFAULT_START_POSITION_US, windowDurationUs / 2); if (windowDefaultStartPositionUs < minimumDefaultStartPositionUs) { @@ -965,7 +962,7 @@ private void processManifest(boolean scheduleRefresh) { windowDurationUs, windowDefaultStartPositionUs, manifest, - mediaItem); + updatedMediaItem); refreshSourceInfo(timeline); if (!sideloadedManifest) { @@ -999,24 +996,81 @@ private void processManifest(boolean scheduleRefresh) { } } - private void ensureTargetLiveOffsetIsInLiveWindow( + private void updateMediaItemLiveConfiguration( long nowPeriodTimeUs, long windowStartPeriodTimeUs, long windowEndPeriodTimeUs) { - long targetLiveOffsetUs = C.msToUs(mediaItem.liveConfiguration.targetLiveOffsetMs); - long minOffsetUs = nowPeriodTimeUs - windowEndPeriodTimeUs; - if (targetLiveOffsetUs < minOffsetUs) { - targetLiveOffsetUs = minOffsetUs; + long maxLiveOffsetMs; + if (originalMediaItem.liveConfiguration.maxLiveOffsetMs != C.TIME_UNSET) { + maxLiveOffsetMs = originalMediaItem.liveConfiguration.maxLiveOffsetMs; + } else if (manifest.serviceDescription != null + && manifest.serviceDescription.maxOffsetMs != C.TIME_UNSET) { + maxLiveOffsetMs = manifest.serviceDescription.maxOffsetMs; + } else { + maxLiveOffsetMs = C.usToMs(nowPeriodTimeUs - windowStartPeriodTimeUs); } - long maxOffsetUs = nowPeriodTimeUs - windowStartPeriodTimeUs; - if (targetLiveOffsetUs > maxOffsetUs) { - long windowDurationUs = windowEndPeriodTimeUs - windowStartPeriodTimeUs; - targetLiveOffsetUs = - maxOffsetUs - min(MIN_LIVE_DEFAULT_START_POSITION_US, windowDurationUs / 2); + long minLiveOffsetMs; + if (originalMediaItem.liveConfiguration.minLiveOffsetMs != C.TIME_UNSET) { + minLiveOffsetMs = originalMediaItem.liveConfiguration.minLiveOffsetMs; + } else if (manifest.serviceDescription != null + && manifest.serviceDescription.minOffsetMs != C.TIME_UNSET) { + minLiveOffsetMs = manifest.serviceDescription.minOffsetMs; + } else { + minLiveOffsetMs = C.usToMs(nowPeriodTimeUs - windowEndPeriodTimeUs); + if (minLiveOffsetMs < 0 && maxLiveOffsetMs > 0) { + // The current time is in the window, so assume all clocks are synchronized and set the + // minimum to a live offset of zero. + minLiveOffsetMs = 0; + } + if (manifest.minBufferTimeMs != C.TIME_UNSET) { + minLiveOffsetMs = min(minLiveOffsetMs + manifest.minBufferTimeMs, maxLiveOffsetMs); + } + } + long targetOffsetMs; + if (updatedMediaItem.liveConfiguration.targetLiveOffsetMs != C.TIME_UNSET) { + // Keep existing target offset even if the media configuration changes. + targetOffsetMs = updatedMediaItem.liveConfiguration.targetLiveOffsetMs; + } else if (manifest.serviceDescription != null + && manifest.serviceDescription.targetOffsetMs != C.TIME_UNSET) { + targetOffsetMs = manifest.serviceDescription.targetOffsetMs; + } else if (manifest.suggestedPresentationDelayMs != C.TIME_UNSET) { + targetOffsetMs = manifest.suggestedPresentationDelayMs; + } else { + targetOffsetMs = fallbackTargetLiveOffsetMs; + } + if (targetOffsetMs < minLiveOffsetMs) { + targetOffsetMs = minLiveOffsetMs; } - long targetLiveOffsetMs = C.usToMs(targetLiveOffsetUs); - if (mediaItem.liveConfiguration.targetLiveOffsetMs != targetLiveOffsetMs) { - mediaItem = mediaItem.buildUpon().setLiveTargetOffsetMs(targetLiveOffsetMs).build(); - playbackProperties = castNonNull(mediaItem.playbackProperties); + if (targetOffsetMs > maxLiveOffsetMs) { + long windowDurationUs = windowEndPeriodTimeUs - windowStartPeriodTimeUs; + long liveOffsetAtWindowStartUs = nowPeriodTimeUs - windowStartPeriodTimeUs; + long safeDistanceFromWindowStartUs = + min(MIN_LIVE_DEFAULT_START_POSITION_US, windowDurationUs / 2); + long maxTargetOffsetForSafeDistanceToWindowStartMs = + C.usToMs(liveOffsetAtWindowStartUs - safeDistanceFromWindowStartUs); + targetOffsetMs = + Util.constrainValue( + maxTargetOffsetForSafeDistanceToWindowStartMs, minLiveOffsetMs, maxLiveOffsetMs); + } + float minPlaybackSpeed = C.RATE_UNSET; + if (originalMediaItem.liveConfiguration.minPlaybackSpeed != C.RATE_UNSET) { + minPlaybackSpeed = originalMediaItem.liveConfiguration.minPlaybackSpeed; + } else if (manifest.serviceDescription != null) { + minPlaybackSpeed = manifest.serviceDescription.minPlaybackSpeed; } + float maxPlaybackSpeed = C.RATE_UNSET; + if (originalMediaItem.liveConfiguration.maxPlaybackSpeed != C.RATE_UNSET) { + maxPlaybackSpeed = originalMediaItem.liveConfiguration.maxPlaybackSpeed; + } else if (manifest.serviceDescription != null) { + maxPlaybackSpeed = manifest.serviceDescription.maxPlaybackSpeed; + } + updatedMediaItem = + originalMediaItem + .buildUpon() + .setLiveTargetOffsetMs(targetOffsetMs) + .setLiveMinOffsetMs(minLiveOffsetMs) + .setLiveMaxOffsetMs(maxLiveOffsetMs) + .setLiveMinPlaybackSpeed(minPlaybackSpeed) + .setLiveMaxPlaybackSpeed(maxPlaybackSpeed) + .build(); } private void scheduleManifestRefresh(long delayUntilNextLoadMs) { @@ -1087,41 +1141,6 @@ private static long getIntervalUntilNextManifestRefreshMs( return LongMath.divide(intervalUs, 1000, RoundingMode.CEILING); } - private static MediaItem mergeLiveConfiguration( - MediaItem mediaItem, long fallbackTargetLiveOffsetMs, DashManifest manifest) { - // Evaluate live config properties from media item and manifest according to precedence. - long liveTargetOffsetMs; - if (mediaItem.liveConfiguration.targetLiveOffsetMs != C.TIME_UNSET) { - liveTargetOffsetMs = mediaItem.liveConfiguration.targetLiveOffsetMs; - } else if (manifest.serviceDescription != null - && manifest.serviceDescription.targetOffsetMs != C.TIME_UNSET) { - liveTargetOffsetMs = manifest.serviceDescription.targetOffsetMs; - } else if (manifest.suggestedPresentationDelayMs != C.TIME_UNSET) { - liveTargetOffsetMs = manifest.suggestedPresentationDelayMs; - } else { - liveTargetOffsetMs = fallbackTargetLiveOffsetMs; - } - float liveMinPlaybackSpeed = C.RATE_UNSET; - if (mediaItem.liveConfiguration.minPlaybackSpeed != C.RATE_UNSET) { - liveMinPlaybackSpeed = mediaItem.liveConfiguration.minPlaybackSpeed; - } else if (manifest.serviceDescription != null) { - liveMinPlaybackSpeed = manifest.serviceDescription.minPlaybackSpeed; - } - float liveMaxPlaybackSpeed = C.RATE_UNSET; - if (mediaItem.liveConfiguration.maxPlaybackSpeed != C.RATE_UNSET) { - liveMaxPlaybackSpeed = mediaItem.liveConfiguration.maxPlaybackSpeed; - } else if (manifest.serviceDescription != null) { - liveMaxPlaybackSpeed = manifest.serviceDescription.maxPlaybackSpeed; - } - // Update live configuration in the media item. - return mediaItem - .buildUpon() - .setLiveTargetOffsetMs(liveTargetOffsetMs) - .setLiveMinPlaybackSpeed(liveMinPlaybackSpeed) - .setLiveMaxPlaybackSpeed(liveMaxPlaybackSpeed) - .build(); - } - private static final class PeriodSeekInfo { public static PeriodSeekInfo createPeriodSeekInfo( 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 a4b3c2c5624..7e5beb7de4e 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 @@ -55,8 +55,9 @@ public final class DashMediaSourceTest { private static final String SAMPLE_MPD_LIVE_WITHOUT_LIVE_CONFIGURATION = "media/mpd/sample_mpd_live_without_live_configuration"; - private static final String SAMPLE_MPD_LIVE_WITH_SUGGESTED_PRESENTATION_DELAY_2S = - "media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s"; + private static final String + SAMPLE_MPD_LIVE_WITH_SUGGESTED_PRESENTATION_DELAY_2S_MIN_BUFFER_TIME_500MS = + "media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s_min_buffer_time_500ms"; private static final String SAMPLE_MPD_LIVE_WITH_COMPLETE_SERVICE_DESCRIPTION = "media/mpd/sample_mpd_live_with_complete_service_description"; private static final String SAMPLE_MPD_LIVE_WITH_OFFSET_INSIDE_WINDOW = @@ -290,6 +291,8 @@ public void prepare_withoutLiveConfiguration_withoutMediaItemLiveProperties_uses assertThat(mediaItemFromSource.liveConfiguration.targetLiveOffsetMs) .isEqualTo(DashMediaSource.DEFAULT_FALLBACK_TARGET_LIVE_OFFSET_MS); + assertThat(mediaItemFromSource.liveConfiguration.minLiveOffsetMs).isEqualTo(0L); + assertThat(mediaItemFromSource.liveConfiguration.maxLiveOffsetMs).isEqualTo(58_000L); assertThat(mediaItemFromSource.liveConfiguration.minPlaybackSpeed).isEqualTo(C.RATE_UNSET); assertThat(mediaItemFromSource.liveConfiguration.maxPlaybackSpeed).isEqualTo(C.RATE_UNSET); } @@ -306,6 +309,8 @@ public void prepare_withoutLiveConfiguration_withoutMediaItemLiveProperties_uses MediaItem mediaItemFromSource = prepareAndWaitForTimelineRefresh(mediaSource).mediaItem; assertThat(mediaItemFromSource.liveConfiguration.targetLiveOffsetMs).isEqualTo(1234L); + assertThat(mediaItemFromSource.liveConfiguration.minLiveOffsetMs).isEqualTo(0L); + assertThat(mediaItemFromSource.liveConfiguration.maxLiveOffsetMs).isEqualTo(58_000L); assertThat(mediaItemFromSource.liveConfiguration.minPlaybackSpeed).isEqualTo(C.RATE_UNSET); assertThat(mediaItemFromSource.liveConfiguration.maxPlaybackSpeed).isEqualTo(C.RATE_UNSET); } @@ -319,6 +324,8 @@ public void prepare_withoutLiveConfiguration_withMediaItemLiveProperties_usesMed .setLiveTargetOffsetMs(876L) .setLiveMinPlaybackSpeed(23f) .setLiveMaxPlaybackSpeed(42f) + .setLiveMinOffsetMs(500L) + .setLiveMaxOffsetMs(20_000L) .build(); DashMediaSource mediaSource = new DashMediaSource.Factory( @@ -329,47 +336,58 @@ public void prepare_withoutLiveConfiguration_withMediaItemLiveProperties_usesMed MediaItem mediaItemFromSource = prepareAndWaitForTimelineRefresh(mediaSource).mediaItem; assertThat(mediaItemFromSource.liveConfiguration.targetLiveOffsetMs).isEqualTo(876L); + assertThat(mediaItemFromSource.liveConfiguration.minLiveOffsetMs).isEqualTo(500L); + assertThat(mediaItemFromSource.liveConfiguration.maxLiveOffsetMs).isEqualTo(20_000L); assertThat(mediaItemFromSource.liveConfiguration.minPlaybackSpeed).isEqualTo(23f); assertThat(mediaItemFromSource.liveConfiguration.maxPlaybackSpeed).isEqualTo(42f); } @Test - public void prepare_withSuggestedPresentationDelay_usesManifestValue() + public void prepare_withSuggestedPresentationDelayAndMinBufferTime_usesManifestValue() throws InterruptedException { DashMediaSource mediaSource = new DashMediaSource.Factory( () -> - createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITH_SUGGESTED_PRESENTATION_DELAY_2S)) + createSampleMpdDataSource( + SAMPLE_MPD_LIVE_WITH_SUGGESTED_PRESENTATION_DELAY_2S_MIN_BUFFER_TIME_500MS)) .setFallbackTargetLiveOffsetMs(1234L) .createMediaSource(MediaItem.fromUri(Uri.EMPTY)); MediaItem mediaItem = prepareAndWaitForTimelineRefresh(mediaSource).mediaItem; assertThat(mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(2_000L); + assertThat(mediaItem.liveConfiguration.minLiveOffsetMs).isEqualTo(500L); + assertThat(mediaItem.liveConfiguration.maxLiveOffsetMs).isEqualTo(58_000L); assertThat(mediaItem.liveConfiguration.minPlaybackSpeed).isEqualTo(C.RATE_UNSET); assertThat(mediaItem.liveConfiguration.maxPlaybackSpeed).isEqualTo(C.RATE_UNSET); } @Test - public void prepare_withSuggestedPresentationDelay_withMediaItemLiveProperties_usesMediaItem() - throws InterruptedException { + public void + prepare_withSuggestedPresentationDelayAndMinBufferTime_withMediaItemLiveProperties_usesMediaItem() + throws InterruptedException { MediaItem mediaItem = new MediaItem.Builder() .setUri(Uri.EMPTY) .setLiveTargetOffsetMs(876L) .setLiveMinPlaybackSpeed(23f) .setLiveMaxPlaybackSpeed(42f) + .setLiveMinOffsetMs(200L) + .setLiveMaxOffsetMs(999L) .build(); DashMediaSource mediaSource = new DashMediaSource.Factory( () -> - createSampleMpdDataSource(SAMPLE_MPD_LIVE_WITH_SUGGESTED_PRESENTATION_DELAY_2S)) + createSampleMpdDataSource( + SAMPLE_MPD_LIVE_WITH_SUGGESTED_PRESENTATION_DELAY_2S_MIN_BUFFER_TIME_500MS)) .setFallbackTargetLiveOffsetMs(1234L) .createMediaSource(mediaItem); MediaItem mediaItemFromSource = prepareAndWaitForTimelineRefresh(mediaSource).mediaItem; assertThat(mediaItemFromSource.liveConfiguration.targetLiveOffsetMs).isEqualTo(876L); + assertThat(mediaItem.liveConfiguration.minLiveOffsetMs).isEqualTo(200L); + assertThat(mediaItem.liveConfiguration.maxLiveOffsetMs).isEqualTo(999L); assertThat(mediaItemFromSource.liveConfiguration.minPlaybackSpeed).isEqualTo(23f); assertThat(mediaItemFromSource.liveConfiguration.maxPlaybackSpeed).isEqualTo(42f); } @@ -386,6 +404,8 @@ public void prepare_withCompleteServiceDescription_usesManifestValue() MediaItem mediaItem = prepareAndWaitForTimelineRefresh(mediaSource).mediaItem; assertThat(mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(4_000L); + assertThat(mediaItem.liveConfiguration.minLiveOffsetMs).isEqualTo(2_000L); + assertThat(mediaItem.liveConfiguration.maxLiveOffsetMs).isEqualTo(6_000L); assertThat(mediaItem.liveConfiguration.minPlaybackSpeed).isEqualTo(0.96f); assertThat(mediaItem.liveConfiguration.maxPlaybackSpeed).isEqualTo(1.04f); } @@ -399,6 +419,8 @@ public void prepare_withCompleteServiceDescription_withMediaItemLiveProperties_u .setLiveTargetOffsetMs(876L) .setLiveMinPlaybackSpeed(23f) .setLiveMaxPlaybackSpeed(42f) + .setLiveMinOffsetMs(100L) + .setLiveMaxOffsetMs(999L) .build(); DashMediaSource mediaSource = new DashMediaSource.Factory( @@ -409,6 +431,8 @@ public void prepare_withCompleteServiceDescription_withMediaItemLiveProperties_u MediaItem mediaItemFromSource = prepareAndWaitForTimelineRefresh(mediaSource).mediaItem; assertThat(mediaItemFromSource.liveConfiguration.targetLiveOffsetMs).isEqualTo(876L); + assertThat(mediaItemFromSource.liveConfiguration.minLiveOffsetMs).isEqualTo(100L); + assertThat(mediaItemFromSource.liveConfiguration.maxLiveOffsetMs).isEqualTo(999L); assertThat(mediaItemFromSource.liveConfiguration.minPlaybackSpeed).isEqualTo(23f); assertThat(mediaItemFromSource.liveConfiguration.maxPlaybackSpeed).isEqualTo(42f); } diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_live_with_complete_service_description b/testdata/src/test/assets/media/mpd/sample_mpd_live_with_complete_service_description index f0e6e612557..f53268d38a1 100644 --- a/testdata/src/test/assets/media/mpd/sample_mpd_live_with_complete_service_description +++ b/testdata/src/test/assets/media/mpd/sample_mpd_live_with_complete_service_description @@ -9,7 +9,7 @@ schemeIdUri="urn:mpeg:dash:utc:direct:2014" value="2020-01-01T01:00:00Z" /> - + diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s b/testdata/src/test/assets/media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s_min_buffer_time_500ms similarity index 91% rename from testdata/src/test/assets/media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s rename to testdata/src/test/assets/media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s_min_buffer_time_500ms index e79977ba79b..efc7c5c21b6 100644 --- a/testdata/src/test/assets/media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s +++ b/testdata/src/test/assets/media/mpd/sample_mpd_live_with_suggested_presentation_delay_2s_min_buffer_time_500ms @@ -2,9 +2,10 @@ + timeShiftBufferDepth="PT1M"> From fe2acb59542496ed02e231c655443cb58609e5fe Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 28 Oct 2020 15:52:02 +0000 Subject: [PATCH 213/693] Parse HLS #EXT-X-PART tag Issue: #5011 PiperOrigin-RevId: 339467702 --- .../source/hls/playlist/HlsMediaPlaylist.java | 203 +++++++++++++----- .../hls/playlist/HlsPlaylistParser.java | 120 +++++++++-- .../playlist/HlsMediaPlaylistParserTest.java | 167 ++++++++++++++ 3 files changed, 415 insertions(+), 75 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java index 1acc864fd3d..7fc6b11af11 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -22,11 +22,11 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.common.collect.ImmutableList; 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.List; /** Represents an HLS media playlist. */ @@ -82,57 +82,16 @@ public ServerControl( /** Media segment reference. */ @SuppressWarnings("ComparableType") - public static final class Segment implements Comparable { + public static final class Segment extends SegmentBase { - /** - * The url of the segment. - */ - public final String url; - /** - * The media initialization section for this segment, as defined by #EXT-X-MAP. May be null if - * the media playlist does not define a media section for this segment. The same instance is - * used for all segments that share an EXT-X-MAP tag. - */ - @Nullable public final Segment initializationSegment; - /** The duration of the segment in microseconds, as defined by #EXTINF. */ - public final long durationUs; /** The human readable title of the segment. */ public final String title; - /** - * The number of #EXT-X-DISCONTINUITY tags in the playlist before the segment. - */ - public final int relativeDiscontinuitySequence; - /** - * The start time of the segment in microseconds, relative to the start of the playlist. - */ - public final long relativeStartTimeUs; - /** - * DRM initialization data for sample decryption, or null if the segment does not use CDM-DRM - * protection. - */ - @Nullable public final DrmInitData drmInitData; - /** - * The encryption identity key uri as defined by #EXT-X-KEY, or null if the segment does not use - * full segment encryption with identity key. - */ - @Nullable public final String fullSegmentEncryptionKeyUri; - /** - * The encryption initialization vector as defined by #EXT-X-KEY, or null if the segment is not - * encrypted. - */ - @Nullable public final String encryptionIV; - /** The segment's byte range offset, as defined by #EXT-X-BYTERANGE. */ - public final long byteRangeOffset; - /** - * The segment's byte range length, as defined by #EXT-X-BYTERANGE, or {@link C#LENGTH_UNSET} if - * no byte range is specified. - */ - public final long byteRangeLength; - - /** Whether the segment is tagged with #EXT-X-GAP. */ - public final boolean hasGapTag; + /** The parts belonging to this segment. */ + public final List parts; /** + * Creates an instance to be used as init segment. + * * @param uri See {@link #url}. * @param byteRangeOffset See {@link #byteRangeOffset}. * @param byteRangeLength See {@link #byteRangeLength}. @@ -157,10 +116,13 @@ public Segment( encryptionIV, byteRangeOffset, byteRangeLength, - /* hasGapTag= */ false); + /* hasGapTag= */ false, + /* parts= */ ImmutableList.of()); } /** + * Creates an instance. + * * @param url See {@link #url}. * @param initializationSegment See {@link #initializationSegment}. * @param title See {@link #title}. @@ -173,6 +135,7 @@ public Segment( * @param byteRangeOffset See {@link #byteRangeOffset}. * @param byteRangeLength See {@link #byteRangeLength}. * @param hasGapTag See {@link #hasGapTag}. + * @param parts See {@link #parts}. */ public Segment( String url, @@ -186,10 +149,136 @@ public Segment( @Nullable String encryptionIV, long byteRangeOffset, long byteRangeLength, + boolean hasGapTag, + List parts) { + super( + url, + initializationSegment, + durationUs, + relativeDiscontinuitySequence, + relativeStartTimeUs, + drmInitData, + fullSegmentEncryptionKeyUri, + encryptionIV, + byteRangeOffset, + byteRangeLength, + hasGapTag); + this.title = title; + this.parts = ImmutableList.copyOf(parts); + } + } + + /** A media part. */ + public static final class Part extends SegmentBase { + + /** Whether the part is independent. */ + public final boolean isIndependent; + + /** + * Creates an instance. + * + * @param url See {@link #url}. + * @param initializationSegment See {@link #initializationSegment}. + * @param durationUs See {@link #durationUs}. + * @param relativeDiscontinuitySequence See {@link #relativeDiscontinuitySequence}. + * @param relativeStartTimeUs See {@link #relativeStartTimeUs}. + * @param drmInitData See {@link #drmInitData}. + * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}. + * @param encryptionIV See {@link #encryptionIV}. + * @param byteRangeOffset See {@link #byteRangeOffset}. + * @param byteRangeLength See {@link #byteRangeLength}. + * @param hasGapTag See {@link #hasGapTag}. + * @param isIndependent See {@link #isIndependent}. + */ + public Part( + String url, + @Nullable Segment initializationSegment, + long durationUs, + int relativeDiscontinuitySequence, + long relativeStartTimeUs, + @Nullable DrmInitData drmInitData, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV, + long byteRangeOffset, + long byteRangeLength, + boolean hasGapTag, + boolean isIndependent) { + super( + url, + initializationSegment, + durationUs, + relativeDiscontinuitySequence, + relativeStartTimeUs, + drmInitData, + fullSegmentEncryptionKeyUri, + encryptionIV, + byteRangeOffset, + byteRangeLength, + hasGapTag); + this.isIndependent = isIndependent; + } + } + + /** The base for a {@link Segment} or a {@link Part} required for playback. */ + @SuppressWarnings("ComparableType") + public static class SegmentBase implements Comparable { + /** The url of the segment. */ + public final String url; + /** + * The media initialization section for this segment, as defined by #EXT-X-MAP. May be null if + * the media playlist does not define a media initialization section for this segment. The same + * instance is used for all segments that share an EXT-X-MAP tag. + */ + @Nullable public final Segment initializationSegment; + /** The duration of the segment in microseconds, as defined by #EXTINF or #EXT-X-PART. */ + public final long durationUs; + /** The number of #EXT-X-DISCONTINUITY tags in the playlist before the segment. */ + public final int relativeDiscontinuitySequence; + /** The start time of the segment in microseconds, relative to the start of the playlist. */ + public final long relativeStartTimeUs; + /** + * DRM initialization data for sample decryption, or null if the segment does not use CDM-DRM + * protection. + */ + @Nullable public final DrmInitData drmInitData; + /** + * The encryption identity key uri as defined by #EXT-X-KEY, or null if the segment does not use + * full segment encryption with identity key. + */ + @Nullable public final String fullSegmentEncryptionKeyUri; + /** + * The encryption initialization vector as defined by #EXT-X-KEY, or null if the segment is not + * encrypted. + */ + @Nullable public final String encryptionIV; + /** + * The segment's byte range offset, as defined by #EXT-X-BYTERANGE, #EXT-X-PART or + * #EXT-X-PRELOAD-HINT. + */ + public final long byteRangeOffset; + /** + * The segment's byte range length, as defined by #EXT-X-BYTERANGE, #EXT-X-PART or + * #EXT-X-PRELOAD-HINT, or {@link C#LENGTH_UNSET} if no byte range is specified or the byte + * range is open-ended. + */ + public final long byteRangeLength; + /** Whether the segment is marked as a gap. */ + public final boolean hasGapTag; + + private SegmentBase( + String url, + @Nullable Segment initializationSegment, + long durationUs, + int relativeDiscontinuitySequence, + long relativeStartTimeUs, + @Nullable DrmInitData drmInitData, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV, + long byteRangeOffset, + long byteRangeLength, boolean hasGapTag) { this.url = url; this.initializationSegment = initializationSegment; - this.title = title; this.durationUs = durationUs; this.relativeDiscontinuitySequence = relativeDiscontinuitySequence; this.relativeStartTimeUs = relativeStartTimeUs; @@ -206,7 +295,6 @@ public int compareTo(Long relativeStartTimeUs) { return this.relativeStartTimeUs > relativeStartTimeUs ? 1 : (this.relativeStartTimeUs < relativeStartTimeUs ? -1 : 0); } - } /** @@ -280,6 +368,10 @@ public int compareTo(Long relativeStartTimeUs) { public final List segments; /** The number of skipped segments. */ public int skippedSegmentCount; + /** + * The list of parts at the end of the playlist for which the segment is not in the playlist yet. + */ + public final List trailingParts; /** The total duration of the playlist in microseconds. */ public final long durationUs; /** The attributes of the #EXT-X-SERVER-CONTROL header. */ @@ -298,9 +390,11 @@ public int compareTo(Long relativeStartTimeUs) { * @param targetDurationUs See {@link #targetDurationUs}. * @param hasIndependentSegments See {@link #hasIndependentSegments}. * @param hasEndTag See {@link #hasEndTag}. - * @param protectionSchemes See {@link #protectionSchemes}. * @param hasProgramDateTime See {@link #hasProgramDateTime}. + * @param protectionSchemes See {@link #protectionSchemes}. * @param segments See {@link #segments}. + * @param skippedSegmentCount See {@link #skippedSegmentCount}. + * @param trailingParts See {@link #trailingParts}. * @param serverControl See {@link #serverControl} */ public HlsMediaPlaylist( @@ -321,6 +415,7 @@ public HlsMediaPlaylist( @Nullable DrmInitData protectionSchemes, List segments, int skippedSegmentCount, + List trailingParts, ServerControl serverControl) { super(baseUri, tags, hasIndependentSegments); this.playlistType = playlistType; @@ -334,8 +429,9 @@ public HlsMediaPlaylist( this.hasEndTag = hasEndTag; this.hasProgramDateTime = hasProgramDateTime; this.protectionSchemes = protectionSchemes; - this.segments = Collections.unmodifiableList(segments); + this.segments = ImmutableList.copyOf(segments); this.skippedSegmentCount = skippedSegmentCount; + this.trailingParts = ImmutableList.copyOf(trailingParts); if (!segments.isEmpty()) { Segment last = segments.get(segments.size() - 1); durationUs = last.relativeStartTimeUs + last.durationUs; @@ -420,6 +516,7 @@ public HlsMediaPlaylist expandSkippedSegments(HlsMediaPlaylist previousPlaylist) protectionSchemes, mergedSegments, /* skippedSegmentCount= */ 0, + trailingParts, serverControl); } @@ -451,6 +548,7 @@ public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { protectionSchemes, segments, skippedSegmentCount, + trailingParts, serverControl); } @@ -480,6 +578,7 @@ public HlsMediaPlaylist copyWithEndTag() { protectionSchemes, segments, skippedSegmentCount, + trailingParts, serverControl); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 587d8c6a26d..1f9ad1b7035 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -33,6 +33,7 @@ import com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry.VariantInfo; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Part; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Assertions; @@ -73,6 +74,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions = new HashMap<>(); HashMap urlToInferredInitSegment = new HashMap<>(); List segments = new ArrayList<>(); + List parts = new ArrayList<>(); List tags = new ArrayList<>(); long segmentDurationUs = 0; @@ -602,6 +608,8 @@ private static HlsMediaPlaylist parseMediaPlaylist( long segmentStartTimeUs = 0; long segmentByteRangeOffset = 0; long segmentByteRangeLength = C.LENGTH_UNSET; + long partStartTimeUs = 0; + long partByteRangeOffset = 0; boolean isIFrameOnly = false; long segmentMediaSequence = 0; boolean hasGapTag = false; @@ -614,12 +622,12 @@ private static HlsMediaPlaylist parseMediaPlaylist( /* canBlockReload= */ false); int skippedSegmentCount = 0; - DrmInitData playlistProtectionSchemes = null; - String fullSegmentEncryptionKeyUri = null; - String fullSegmentEncryptionIV = null; + @Nullable DrmInitData playlistProtectionSchemes = null; + @Nullable String fullSegmentEncryptionKeyUri = null; + @Nullable String fullSegmentEncryptionIV = null; TreeMap currentSchemeDatas = new TreeMap<>(); - String encryptionScheme = null; - DrmInitData cachedDrmInitData = null; + @Nullable String encryptionScheme = null; + @Nullable DrmInitData cachedDrmInitData = null; String line; while (iterator.hasNext()) { @@ -650,7 +658,7 @@ private static HlsMediaPlaylist parseMediaPlaylist( String uri = parseStringAttr(line, REGEX_URI, variableDefinitions); String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions); if (byteRange != null) { - String[] splitByteRange = byteRange.split("@"); + String[] splitByteRange = Util.split(byteRange, "@"); segmentByteRangeLength = Long.parseLong(splitByteRange[0]); if (splitByteRange.length > 1) { segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); @@ -730,7 +738,7 @@ private static HlsMediaPlaylist parseMediaPlaylist( } } else if (line.startsWith(TAG_BYTERANGE)) { String byteRange = parseStringAttr(line, REGEX_BYTERANGE, variableDefinitions); - String[] splitByteRange = byteRange.split("@"); + String[] splitByteRange = Util.split(byteRange, "@"); segmentByteRangeLength = Long.parseLong(splitByteRange[0]); if (splitByteRange.length > 1) { segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); @@ -752,16 +760,60 @@ private static HlsMediaPlaylist parseMediaPlaylist( hasIndependentSegmentsTag = true; } else if (line.equals(TAG_ENDLIST)) { hasEndTag = true; - } else if (!line.startsWith("#")) { - String segmentEncryptionIV; - if (fullSegmentEncryptionKeyUri == null) { - segmentEncryptionIV = null; - } else if (fullSegmentEncryptionIV != null) { - segmentEncryptionIV = fullSegmentEncryptionIV; - } else { - segmentEncryptionIV = Long.toHexString(segmentMediaSequence); + } else if (line.startsWith(TAG_PART)) { + @Nullable + String segmentEncryptionIV = + getSegmentEncryptionIV( + segmentMediaSequence, fullSegmentEncryptionKeyUri, fullSegmentEncryptionIV); + String url = parseStringAttr(line, REGEX_URI, variableDefinitions); + long partDurationUs = + (long) (parseDoubleAttr(line, REGEX_ATTR_DURATION) * C.MICROS_PER_SECOND); + boolean isIndependent = + parseOptionalBooleanAttribute(line, REGEX_INDEPENDENT, /* defaultValue= */ false); + boolean isGap = parseOptionalBooleanAttribute(line, REGEX_GAP, /* defaultValue= */ false); + @Nullable + String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions); + long partByteRangeLength = C.LENGTH_UNSET; + if (byteRange != null) { + String[] splitByteRange = Util.split(byteRange, "@"); + partByteRangeLength = Long.parseLong(splitByteRange[0]); + if (splitByteRange.length > 1) { + partByteRangeOffset = Long.parseLong(splitByteRange[1]); + } } - + if (partByteRangeLength == C.LENGTH_UNSET) { + partByteRangeOffset = 0; + } + if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) { + SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]); + cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas); + if (playlistProtectionSchemes == null) { + playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas); + } + } + parts.add( + new Part( + url, + initializationSegment, + partDurationUs, + relativeDiscontinuitySequence, + partStartTimeUs, + cachedDrmInitData, + fullSegmentEncryptionKeyUri, + segmentEncryptionIV, + partByteRangeOffset, + partByteRangeLength, + isGap, + isIndependent)); + partStartTimeUs += partDurationUs; + if (partByteRangeLength != C.LENGTH_UNSET) { + partByteRangeOffset += partByteRangeLength; + } + } else if (!line.startsWith("#")) { + @Nullable + String segmentEncryptionIV = + getSegmentEncryptionIV( + segmentMediaSequence, fullSegmentEncryptionKeyUri, fullSegmentEncryptionIV); segmentMediaSequence++; String segmentUri = replaceVariableReferences(line, variableDefinitions); @Nullable Segment inferredInitSegment = urlToInferredInitSegment.get(segmentUri); @@ -788,11 +840,7 @@ private static HlsMediaPlaylist parseMediaPlaylist( SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]); cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas); if (playlistProtectionSchemes == null) { - SchemeData[] playlistSchemeDatas = new SchemeData[schemeDatas.length]; - for (int i = 0; i < schemeDatas.length; i++) { - playlistSchemeDatas[i] = schemeDatas[i].copyWithData(null); - } - playlistProtectionSchemes = new DrmInitData(encryptionScheme, playlistSchemeDatas); + playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas); } } @@ -809,10 +857,13 @@ private static HlsMediaPlaylist parseMediaPlaylist( segmentEncryptionIV, segmentByteRangeOffset, segmentByteRangeLength, - hasGapTag)); + hasGapTag, + parts)); segmentStartTimeUs += segmentDurationUs; + partStartTimeUs = segmentStartTimeUs; segmentDurationUs = 0; segmentTitle = ""; + parts = new ArrayList<>(); if (segmentByteRangeLength != C.LENGTH_UNSET) { segmentByteRangeOffset += segmentByteRangeLength; } @@ -839,9 +890,32 @@ private static HlsMediaPlaylist parseMediaPlaylist( playlistProtectionSchemes, segments, skippedSegmentCount, + parts, serverControl); } + private static DrmInitData getPlaylistProtectionSchemes( + @Nullable String encryptionScheme, SchemeData[] schemeDatas) { + SchemeData[] playlistSchemeDatas = new SchemeData[schemeDatas.length]; + for (int i = 0; i < schemeDatas.length; i++) { + playlistSchemeDatas[i] = schemeDatas[i].copyWithData(null); + } + return new DrmInitData(encryptionScheme, playlistSchemeDatas); + } + + @Nullable + private static String getSegmentEncryptionIV( + long segmentMediaSequence, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String fullSegmentEncryptionIV) { + if (fullSegmentEncryptionKeyUri == null) { + return null; + } else if (fullSegmentEncryptionIV != null) { + return fullSegmentEncryptionIV; + } + return Long.toHexString(segmentMediaSequence); + } + @C.SelectionFlags private static int parseSelectionFlags(String line) { int flags = 0; 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 e92f9a80272..88a2a2f2fbb 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 @@ -19,10 +19,12 @@ import static org.junit.Assert.fail; import android.net.Uri; +import android.util.Base64; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import com.google.android.exoplayer2.util.Util; import java.io.ByteArrayInputStream; @@ -320,6 +322,171 @@ public void parseMediaPlaylist_withSkippedSegments_parsesNumberOfSkippedSegments assertThat(playlist.skippedSegmentCount).isEqualTo(1234); } + @Test + public void parseMediaPlaylist_withParts_parsesPartWithAllAttributes() throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-PART:DURATION=2.00000,GAP=YES," + + "INDEPENDENT=YES,URI=\"part267.1.ts\"\n" + + "#EXT-X-PART:DURATION=2.00000,BYTERANGE=\"1000@1234\",URI=\"part267.2.ts\"\n" + + "#EXTINF:4.00000,\n" + + "fileSequence267.ts\n" + + "#EXT-X-PART:DURATION=2.00000, BYTERANGE=\"1000@1234\",URI=\"part268.1.ts\"\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part268.2.ts\", BYTERANGE=\"1000\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments.get(0).parts).isEmpty(); + assertThat(playlist.segments.get(1).parts).hasSize(2); + assertThat(playlist.trailingParts).hasSize(2); + + HlsMediaPlaylist.Part firstPart = playlist.segments.get(1).parts.get(0); + assertThat(firstPart.byteRangeLength).isEqualTo(C.LENGTH_UNSET); + assertThat(firstPart.byteRangeOffset).isEqualTo(0); + assertThat(firstPart.durationUs).isEqualTo(2_000_000); + assertThat(firstPart.relativeStartTimeUs).isEqualTo(playlist.segments.get(0).durationUs); + assertThat(firstPart.isIndependent).isTrue(); + assertThat(firstPart.hasGapTag).isTrue(); + assertThat(firstPart.url).isEqualTo("part267.1.ts"); + HlsMediaPlaylist.Part secondPart = playlist.segments.get(1).parts.get(1); + assertThat(secondPart.byteRangeLength).isEqualTo(1000); + assertThat(secondPart.byteRangeOffset).isEqualTo(1234); + // Assert trailing parts. + HlsMediaPlaylist.Part thirdPart = playlist.trailingParts.get(0); + assertThat(thirdPart.byteRangeLength).isEqualTo(1000); + assertThat(thirdPart.byteRangeOffset).isEqualTo(1234); + assertThat(thirdPart.relativeStartTimeUs).isEqualTo(8_000_000); + HlsMediaPlaylist.Part lastPart = playlist.trailingParts.get(1); + assertThat(lastPart.relativeStartTimeUs).isEqualTo(10_000_000); + assertThat(lastPart.hasGapTag).isFalse(); + assertThat(lastPart.isIndependent).isFalse(); + assertThat(lastPart.byteRangeLength).isEqualTo(1000); + assertThat(lastPart.byteRangeOffset).isEqualTo(2234); + } + + @Test + public void parseMediaPlaylist_withPartAndAesPlayReadyKey_correctDrmInitData() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-KEY:METHOD=SAMPLE-AES," + + "KEYFORMAT=\"com.microsoft.playready\"," + + "URI=\"data:text/plain;base64,RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==\"\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.ts\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.1.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments.get(0).parts).isEmpty(); + assertThat(playlist.protectionSchemes.schemeType).isEqualTo("cbcs"); + HlsMediaPlaylist.Part part = playlist.trailingParts.get(0); + assertThat(part.drmInitData.schemeType).isEqualTo("cbcs"); + assertThat(part.drmInitData.schemeDataCount).isEqualTo(1); + assertThat(part.drmInitData.get(0).uuid).isEqualTo(C.PLAYREADY_UUID); + assertThat(part.drmInitData.get(0).data) + .isEqualTo( + PsshAtomUtil.buildPsshAtom( + C.PLAYREADY_UUID, + Base64.decode("RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==", Base64.DEFAULT))); + } + + @Test + public void parseMediaPlaylist_withPartAndAesPlayReadyWithOutPrecedingSegment_correctDrmInitData() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-KEY:METHOD=SAMPLE-AES," + + "KEYFORMAT=\"com.microsoft.playready\"," + + "URI=\"data:text/plain;base64,RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==\"\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.1.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments).isEmpty(); + assertThat(playlist.protectionSchemes.schemeType).isEqualTo("cbcs"); + HlsMediaPlaylist.Part part = playlist.trailingParts.get(0); + assertThat(part.drmInitData.schemeType).isEqualTo("cbcs"); + assertThat(part.drmInitData.schemeDataCount).isEqualTo(1); + assertThat(part.drmInitData.get(0).uuid).isEqualTo(C.PLAYREADY_UUID); + assertThat(part.drmInitData.get(0).data) + .isEqualTo( + PsshAtomUtil.buildPsshAtom( + C.PLAYREADY_UUID, + Base64.decode("RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==", Base64.DEFAULT))); + } + + @Test + public void parseMediaPlaylist_withPartAndAes128_partHasDrmKeyUriAndIV() throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-KEY:METHOD=AES-128,KEYFORMAT=\"identity\"" + + ", IV=0x410C8AC18AA42EFA18B5155484F5FC34,URI=\"fake://foo.bar/uri\"\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.ts\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.1.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments.get(0).parts).isEmpty(); + HlsMediaPlaylist.Part part = playlist.trailingParts.get(0); + assertThat(playlist.protectionSchemes).isNull(); + assertThat(part.drmInitData).isNull(); + assertThat(part.fullSegmentEncryptionKeyUri).isEqualTo("fake://foo.bar/uri"); + assertThat(part.encryptionIV).isEqualTo("0x410C8AC18AA42EFA18B5155484F5FC34"); + } + + @Test + public void parseMediaPlaylist_withPartAndAes128WithoutPrecedingSegment_partHasDrmKeyUriAndIV() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-KEY:METHOD=AES-128,KEYFORMAT=\"identity\"" + + ", IV=0x410C8AC18AA42EFA18B5155484F5FC34,URI=\"fake://foo.bar/uri\"\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.1.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments).isEmpty(); + HlsMediaPlaylist.Part part = playlist.trailingParts.get(0); + assertThat(playlist.protectionSchemes).isNull(); + assertThat(part.drmInitData).isNull(); + assertThat(part.fullSegmentEncryptionKeyUri).isEqualTo("fake://foo.bar/uri"); + assertThat(part.encryptionIV).isEqualTo("0x410C8AC18AA42EFA18B5155484F5FC34"); + } + @Test public void multipleExtXKeysForSingleSegment() throws Exception { Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); From d5170688b452111ab715f7ea36917529356e6280 Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 28 Oct 2020 16:15:50 +0000 Subject: [PATCH 214/693] MediaItem: document params in method call PiperOrigin-RevId: 339472160 --- .../main/java/com/google/android/exoplayer2/MediaItem.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java index b1a09fb72f2..678822b7d28 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -749,7 +749,12 @@ public static final class LiveConfiguration { /** A live playback configuration with unset values. */ public static final LiveConfiguration UNSET = - new LiveConfiguration(C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET, C.RATE_UNSET, C.RATE_UNSET); + new LiveConfiguration( + /* targetLiveOffsetMs= */ C.TIME_UNSET, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, + /* minPlaybackSpeed= */ C.RATE_UNSET, + /* maxPlaybackSpeed= */ C.RATE_UNSET); /** * Target live offset, in milliseconds, or {@link C#TIME_UNSET} to use the media-defined From 04c56c44cf01d6b393d1ecfbadc87f7c293d5e37 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Wed, 28 Oct 2020 23:02:54 +0000 Subject: [PATCH 215/693] Publish components that depend on MediaParser This change will be followed up by: - Changes adding APIs to enable the use of MediaParser in each of the supported media sources. - Changes removing TODOs related to the change of the stable SDK to API 30. PiperOrigin-RevId: 339556777 --- .../source/MediaParserExtractorAdapter.java | 121 +++ .../source/ProgressiveMediaPeriod.java | 4 +- .../chunk/MediaParserChunkExtractor.java | 169 +++++ .../mediaparser/InputReaderAdapterV30.java | 87 +++ .../source/mediaparser/MediaParserUtil.java | 60 ++ .../mediaparser/OutputConsumerAdapterV30.java | 691 ++++++++++++++++++ .../source/mediaparser/package-info.java | 19 + .../source/dash/DefaultDashChunkSource.java | 1 - .../exoplayer2/source/hls/HlsMediaSource.java | 2 - .../MediaParserHlsMediaChunkExtractor.java | 281 +++++++ 10 files changed, 1429 insertions(+), 6 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/MediaParserExtractorAdapter.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/chunk/MediaParserChunkExtractor.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/InputReaderAdapterV30.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/MediaParserUtil.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/OutputConsumerAdapterV30.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/package-info.java create mode 100644 library/hls/src/main/java/com/google/android/exoplayer2/source/hls/MediaParserHlsMediaChunkExtractor.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaParserExtractorAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaParserExtractorAdapter.java new file mode 100644 index 00000000000..6cb20c9fbe7 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaParserExtractorAdapter.java @@ -0,0 +1,121 @@ +/* + * 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; + +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_INCLUDE_SUPPLEMENTAL_DATA; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_IN_BAND_CRYPTO_INFO; + +import android.annotation.SuppressLint; +import android.media.MediaParser; +import android.media.MediaParser.SeekPoint; +import android.net.Uri; +import android.util.Pair; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.source.mediaparser.InputReaderAdapterV30; +import com.google.android.exoplayer2.source.mediaparser.OutputConsumerAdapterV30; +import com.google.android.exoplayer2.upstream.DataReader; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** {@link ProgressiveMediaExtractor} implemented on top of the platform's {@link MediaParser}. */ +@RequiresApi(30) +/* package */ final class MediaParserExtractorAdapter implements ProgressiveMediaExtractor { + + private final OutputConsumerAdapterV30 outputConsumerAdapter; + private final InputReaderAdapterV30 inputReaderAdapter; + private final MediaParser mediaParser; + private String parserName; + + @SuppressLint("WrongConstant") + public MediaParserExtractorAdapter() { + // TODO: Add support for injecting the desired extractor list. + outputConsumerAdapter = new OutputConsumerAdapterV30(); + inputReaderAdapter = new InputReaderAdapterV30(); + mediaParser = MediaParser.create(outputConsumerAdapter); + mediaParser.setParameter(PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE, true); + mediaParser.setParameter(PARAMETER_IN_BAND_CRYPTO_INFO, true); + mediaParser.setParameter(PARAMETER_INCLUDE_SUPPLEMENTAL_DATA, true); + parserName = MediaParser.PARSER_NAME_UNKNOWN; + } + + @Override + public void init( + DataReader dataReader, + Uri uri, + Map> responseHeaders, + long position, + long length, + ExtractorOutput output) + throws IOException { + outputConsumerAdapter.setExtractorOutput(output); + inputReaderAdapter.setDataReader(dataReader, length); + inputReaderAdapter.setCurrentPosition(position); + String currentParserName = mediaParser.getParserName(); + if (MediaParser.PARSER_NAME_UNKNOWN.equals(currentParserName)) { + // We need to sniff. + mediaParser.advance(inputReaderAdapter); + parserName = mediaParser.getParserName(); + outputConsumerAdapter.setSelectedParserName(parserName); + } else if (!currentParserName.equals(parserName)) { + // The parser was created by name. + parserName = mediaParser.getParserName(); + outputConsumerAdapter.setSelectedParserName(parserName); + } else { + // The parser implementation has already been selected. Do nothing. + } + } + + @Override + public void release() { + mediaParser.release(); + } + + @Override + public void disableSeekingOnMp3Streams() { + if (MediaParser.PARSER_NAME_MP3.equals(parserName)) { + outputConsumerAdapter.disableSeeking(); + } + } + + @Override + public long getCurrentInputPosition() { + return inputReaderAdapter.getPosition(); + } + + @Override + public void seek(long position, long seekTimeUs) { + inputReaderAdapter.setCurrentPosition(position); + Pair seekPoints = outputConsumerAdapter.getSeekPoints(seekTimeUs); + mediaParser.seek(seekPoints.second.position == position ? seekPoints.second : seekPoints.first); + } + + @Override + public int read(PositionHolder positionHolder) throws IOException { + boolean shouldContinue = mediaParser.advance(inputReaderAdapter); + positionHolder.position = inputReaderAdapter.getAndResetSeekPosition(); + return !shouldContinue + ? Extractor.RESULT_END_OF_INPUT + : positionHolder.position != C.POSITION_UNSET + ? Extractor.RESULT_SEEK + : Extractor.RESULT_CONTINUE; + } +} 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 121eeb940d3..f261b7ce05a 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 @@ -188,9 +188,7 @@ public ProgressiveMediaPeriod( this.customCacheKey = customCacheKey; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; loader = new Loader("Loader:ProgressiveMediaPeriod"); - ProgressiveMediaExtractor progressiveMediaExtractor = - new BundledExtractorsAdapter(extractorsFactory); - this.progressiveMediaExtractor = progressiveMediaExtractor; + this.progressiveMediaExtractor = new BundledExtractorsAdapter(extractorsFactory); loadCondition = new ConditionVariable(); maybeFinishPrepareRunnable = this::maybeFinishPrepare; onContinueLoadingRequestedRunnable = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/MediaParserChunkExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/MediaParserChunkExtractor.java new file mode 100644 index 00000000000..7c440b46d71 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/MediaParserChunkExtractor.java @@ -0,0 +1,169 @@ +/* + * 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 static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_EXPOSE_CAPTION_FORMATS; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_EXPOSE_CHUNK_INDEX_AS_MEDIA_FORMAT; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_EXPOSE_DUMMY_SEEK_MAP; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_INCLUDE_SUPPLEMENTAL_DATA; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_IN_BAND_CRYPTO_INFO; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS; + +import android.annotation.SuppressLint; +import android.media.MediaFormat; +import android.media.MediaParser; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +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.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.source.mediaparser.InputReaderAdapterV30; +import com.google.android.exoplayer2.source.mediaparser.MediaParserUtil; +import com.google.android.exoplayer2.source.mediaparser.OutputConsumerAdapterV30; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** {@link ChunkExtractor} implemented on top of the platform's {@link MediaParser}. */ +@RequiresApi(30) +public final class MediaParserChunkExtractor implements ChunkExtractor { + + private final OutputConsumerAdapterV30 outputConsumerAdapter; + private final InputReaderAdapterV30 inputReaderAdapter; + private final MediaParser mediaParser; + private final TrackOutputProviderAdapter trackOutputProviderAdapter; + private final DummyTrackOutput dummyTrackOutput; + private long pendingSeekUs; + @Nullable private TrackOutputProvider trackOutputProvider; + @Nullable private Format[] sampleFormats; + + /** + * Creates a new instance. + * + * @param primaryTrackType The type of the primary track, or {@link C#TRACK_TYPE_NONE} if there is + * no primary track. Must be one of the {@link C C.TRACK_TYPE_*} constants. + * @param manifestFormat The chunks {@link Format} as obtained from the manifest. + * @param closedCaptionFormats A list containing the {@link Format Formats} of the closed-caption + * tracks in the chunks. + */ + @SuppressLint("WrongConstant") + public MediaParserChunkExtractor( + int primaryTrackType, Format manifestFormat, List closedCaptionFormats) { + outputConsumerAdapter = + new OutputConsumerAdapterV30( + manifestFormat, primaryTrackType, /* expectDummySeekMap= */ true); + inputReaderAdapter = new InputReaderAdapterV30(); + String mimeType = Assertions.checkNotNull(manifestFormat.containerMimeType); + String parserName = + MimeTypes.isMatroska(mimeType) + ? MediaParser.PARSER_NAME_MATROSKA + : MediaParser.PARSER_NAME_FMP4; + outputConsumerAdapter.setSelectedParserName(parserName); + mediaParser = MediaParser.createByName(parserName, outputConsumerAdapter); + mediaParser.setParameter(MediaParser.PARAMETER_MATROSKA_DISABLE_CUES_SEEKING, true); + mediaParser.setParameter(PARAMETER_IN_BAND_CRYPTO_INFO, true); + mediaParser.setParameter(PARAMETER_INCLUDE_SUPPLEMENTAL_DATA, true); + mediaParser.setParameter(PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE, true); + mediaParser.setParameter(PARAMETER_EXPOSE_DUMMY_SEEK_MAP, true); + mediaParser.setParameter(PARAMETER_EXPOSE_CHUNK_INDEX_AS_MEDIA_FORMAT, true); + mediaParser.setParameter(PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS, true); + ArrayList closedCaptionMediaFormats = new ArrayList<>(); + for (int i = 0; i < closedCaptionFormats.size(); i++) { + closedCaptionMediaFormats.add( + MediaParserUtil.toCaptionsMediaFormat(closedCaptionFormats.get(i))); + } + mediaParser.setParameter(PARAMETER_EXPOSE_CAPTION_FORMATS, closedCaptionMediaFormats); + outputConsumerAdapter.setMuxedCaptionFormats(closedCaptionFormats); + trackOutputProviderAdapter = new TrackOutputProviderAdapter(); + dummyTrackOutput = new DummyTrackOutput(); + pendingSeekUs = C.TIME_UNSET; + } + + // ChunkExtractor implementation. + + @Override + public void init( + @Nullable TrackOutputProvider trackOutputProvider, long startTimeUs, long endTimeUs) { + this.trackOutputProvider = trackOutputProvider; + outputConsumerAdapter.setSampleTimestampUpperLimitFilterUs(endTimeUs); + outputConsumerAdapter.setExtractorOutput(trackOutputProviderAdapter); + pendingSeekUs = startTimeUs; + } + + @Override + public void release() { + mediaParser.release(); + } + + @Override + public boolean read(ExtractorInput extractorInput) throws IOException { + maybeExecutePendingSeek(); + inputReaderAdapter.setDataReader(extractorInput, extractorInput.getLength()); + return mediaParser.advance(inputReaderAdapter); + } + + @Nullable + @Override + public ChunkIndex getChunkIndex() { + return outputConsumerAdapter.getChunkIndex(); + } + + @Nullable + @Override + public Format[] getSampleFormats() { + return sampleFormats; + } + + // Internal methods. + + private void maybeExecutePendingSeek() { + @Nullable MediaParser.SeekMap dummySeekMap = outputConsumerAdapter.getDummySeekMap(); + if (pendingSeekUs != C.TIME_UNSET && dummySeekMap != null) { + mediaParser.seek(dummySeekMap.getSeekPoints(pendingSeekUs).first); + pendingSeekUs = C.TIME_UNSET; + } + } + + // Internal classes. + + private class TrackOutputProviderAdapter implements ExtractorOutput { + + @Override + public TrackOutput track(int id, int type) { + return trackOutputProvider != null ? trackOutputProvider.track(id, type) : dummyTrackOutput; + } + + @Override + public void endTracks() { + // Imitate BundledChunkExtractor behavior, which captures a sample format snapshot when + // endTracks is called. + sampleFormats = outputConsumerAdapter.getSampleFormats(); + } + + @Override + public void seekMap(SeekMap seekMap) { + // Do nothing. + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/InputReaderAdapterV30.java b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/InputReaderAdapterV30.java new file mode 100644 index 00000000000..3a55645e960 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/InputReaderAdapterV30.java @@ -0,0 +1,87 @@ +/* + * 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.mediaparser; + +import android.annotation.SuppressLint; +import android.media.MediaParser; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataReader; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** {@link MediaParser.SeekableInputReader} implementation wrapping a {@link DataReader}. */ +@RequiresApi(30) +@SuppressLint("Override") // TODO: Remove once the SDK becomes stable. +public final class InputReaderAdapterV30 implements MediaParser.SeekableInputReader { + + @Nullable private DataReader dataReader; + private long resourceLength; + private long currentPosition; + private long lastSeekPosition; + + /** + * Sets the wrapped {@link DataReader}. + * + * @param dataReader The {@link DataReader} to wrap. + * @param length The length of the resource from which {@code dataReader} reads. + */ + public void setDataReader(DataReader dataReader, long length) { + this.dataReader = dataReader; + resourceLength = length; + lastSeekPosition = C.POSITION_UNSET; + } + + /** Sets the absolute position in the resource from which the wrapped {@link DataReader} reads. */ + public void setCurrentPosition(long position) { + currentPosition = position; + } + + /** + * Returns the last value passed to {@link #seekToPosition(long)} and sets the stored value to + * {@link C#POSITION_UNSET}. + */ + public long getAndResetSeekPosition() { + long lastSeekPosition = this.lastSeekPosition; + this.lastSeekPosition = C.POSITION_UNSET; + return lastSeekPosition; + } + + // SeekableInputReader implementation. + + @Override + public void seekToPosition(long position) { + lastSeekPosition = position; + } + + @Override + public int read(byte[] bytes, int offset, int readLength) throws IOException { + int bytesRead = Util.castNonNull(dataReader).read(bytes, offset, readLength); + currentPosition += bytesRead; + return bytesRead; + } + + @Override + public long getPosition() { + return currentPosition; + } + + @Override + public long getLength() { + return resourceLength; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/MediaParserUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/MediaParserUtil.java new file mode 100644 index 00000000000..db37535a15e --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/MediaParserUtil.java @@ -0,0 +1,60 @@ +/* + * 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.mediaparser; + +import android.media.MediaFormat; +import android.media.MediaParser; +import com.google.android.exoplayer2.Format; + +/** + * Miscellaneous constants and utility methods related to the {@link MediaParser} integration. + * + *

    For documentation on constants, please see the {@link MediaParser} documentation. + */ +public final class MediaParserUtil { + + public static final String PARAMETER_IN_BAND_CRYPTO_INFO = + "android.media.mediaparser.inBandCryptoInfo"; + public static final String PARAMETER_INCLUDE_SUPPLEMENTAL_DATA = + "android.media.mediaparser.includeSupplementalData"; + public static final String PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE = + "android.media.mediaparser.eagerlyExposeTrackType"; + public static final String PARAMETER_EXPOSE_DUMMY_SEEK_MAP = + "android.media.mediaparser.exposeDummySeekMap"; + public static final String PARAMETER_EXPOSE_CHUNK_INDEX_AS_MEDIA_FORMAT = + "android.media.mediaParser.exposeChunkIndexAsMediaFormat"; + public static final String PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS = + "android.media.mediaParser.overrideInBandCaptionDeclarations"; + public static final String PARAMETER_EXPOSE_CAPTION_FORMATS = + "android.media.mediaParser.exposeCaptionFormats"; + public static final String PARAMETER_IGNORE_TIMESTAMP_OFFSET = + "android.media.mediaparser.ignoreTimestampOffset"; + + private MediaParserUtil() {} + + /** + * Returns a {@link MediaFormat} with equivalent {@link MediaFormat#KEY_MIME} and {@link + * MediaFormat#KEY_CAPTION_SERVICE_NUMBER} to the given {@link Format}. + */ + public static MediaFormat toCaptionsMediaFormat(Format format) { + MediaFormat mediaFormat = new MediaFormat(); + mediaFormat.setString(MediaFormat.KEY_MIME, format.sampleMimeType); + if (format.accessibilityChannel != Format.NO_VALUE) { + mediaFormat.setInteger(MediaFormat.KEY_CAPTION_SERVICE_NUMBER, format.accessibilityChannel); + } + return mediaFormat; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/OutputConsumerAdapterV30.java b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/OutputConsumerAdapterV30.java new file mode 100644 index 00000000000..f3bed012ec2 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/OutputConsumerAdapterV30.java @@ -0,0 +1,691 @@ +/* + * 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.mediaparser; + +import static android.media.MediaParser.PARSER_NAME_AC3; +import static android.media.MediaParser.PARSER_NAME_AC4; +import static android.media.MediaParser.PARSER_NAME_ADTS; +import static android.media.MediaParser.PARSER_NAME_AMR; +import static android.media.MediaParser.PARSER_NAME_FLAC; +import static android.media.MediaParser.PARSER_NAME_FLV; +import static android.media.MediaParser.PARSER_NAME_FMP4; +import static android.media.MediaParser.PARSER_NAME_MATROSKA; +import static android.media.MediaParser.PARSER_NAME_MP3; +import static android.media.MediaParser.PARSER_NAME_MP4; +import static android.media.MediaParser.PARSER_NAME_OGG; +import static android.media.MediaParser.PARSER_NAME_PS; +import static android.media.MediaParser.PARSER_NAME_TS; +import static android.media.MediaParser.PARSER_NAME_WAV; + +import android.annotation.SuppressLint; +import android.media.DrmInitData.SchemeInitData; +import android.media.MediaCodec; +import android.media.MediaCodec.CryptoInfo; +import android.media.MediaFormat; +import android.media.MediaParser; +import android.media.MediaParser.TrackData; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.SelectionFlags; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import com.google.android.exoplayer2.extractor.ChunkIndex; +import com.google.android.exoplayer2.extractor.DummyExtractorOutput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +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.extractor.TrackOutput.CryptoData; +import com.google.android.exoplayer2.upstream.DataReader; +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.TimestampAdjuster; +import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.video.ColorInfo; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.IntBuffer; +import java.nio.LongBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * {@link MediaParser.OutputConsumer} implementation that redirects output to an {@link + * ExtractorOutput}. + */ +@RequiresApi(30) +@SuppressLint("Override") // TODO: Remove once the SDK becomes stable. +public final class OutputConsumerAdapterV30 implements MediaParser.OutputConsumer { + + private static final String TAG = "OutputConsumerAdapterV30"; + + private static final Pair SEEK_POINT_PAIR_START = + Pair.create(MediaParser.SeekPoint.START, MediaParser.SeekPoint.START); + private static final String MEDIA_FORMAT_KEY_TRACK_TYPE = "track-type-string"; + private static final String MEDIA_FORMAT_KEY_CHUNK_INDEX_SIZES = "chunk-index-int-sizes"; + private static final String MEDIA_FORMAT_KEY_CHUNK_INDEX_OFFSETS = "chunk-index-long-offsets"; + private static final String MEDIA_FORMAT_KEY_CHUNK_INDEX_DURATIONS = + "chunk-index-long-us-durations"; + private static final String MEDIA_FORMAT_KEY_CHUNK_INDEX_TIMES = "chunk-index-long-us-times"; + private static final Pattern REGEX_CRYPTO_INFO_PATTERN = + Pattern.compile("pattern \\(encrypt: (\\d+), skip: (\\d+)\\)"); + + private final ArrayList<@NullableType TrackOutput> trackOutputs; + private final ArrayList<@NullableType Format> trackFormats; + private final ArrayList<@NullableType CryptoInfo> lastReceivedCryptoInfos; + private final ArrayList<@NullableType CryptoData> lastOutputCryptoDatas; + private final DataReaderAdapter scratchDataReaderAdapter; + private final boolean expectDummySeekMap; + private final int primaryTrackType; + @Nullable private final Format primaryTrackManifestFormat; + + private ExtractorOutput extractorOutput; + @Nullable private MediaParser.SeekMap dummySeekMap; + @Nullable private MediaParser.SeekMap lastSeekMap; + @Nullable private String containerMimeType; + @Nullable private ChunkIndex lastChunkIndex; + @Nullable private TimestampAdjuster timestampAdjuster; + private List muxedCaptionFormats; + private int primaryTrackIndex; + private long sampleTimestampUpperLimitFilterUs; + private boolean tracksFoundCalled; + private boolean tracksEnded; + private boolean seekingDisabled; + + /** + * Equivalent to {@link #OutputConsumerAdapterV30(Format, int, boolean) + * OutputConsumerAdapterV30(primaryTrackManifestFormat= null, primaryTrackType= C.TRACK_TYPE_NONE, + * expectDummySeekMap= false)} + */ + public OutputConsumerAdapterV30() { + this( + /* primaryTrackManifestFormat= */ null, + /* primaryTrackType= */ C.TRACK_TYPE_NONE, + /* expectDummySeekMap= */ false); + } + + /** + * Creates a new instance. + * + * @param primaryTrackManifestFormat The manifest-obtained format of the primary track, or null if + * not applicable. + * @param primaryTrackType The type of the primary track, or {@link C#TRACK_TYPE_NONE} if there is + * no primary track. Must be one of the {@link C C.TRACK_TYPE_*} constants. + * @param expectDummySeekMap Whether the output consumer should expect an initial dummy seek map + * which should be exposed through {@link #getDummySeekMap()}. + */ + public OutputConsumerAdapterV30( + @Nullable Format primaryTrackManifestFormat, + int primaryTrackType, + boolean expectDummySeekMap) { + this.expectDummySeekMap = expectDummySeekMap; + this.primaryTrackManifestFormat = primaryTrackManifestFormat; + this.primaryTrackType = primaryTrackType; + trackOutputs = new ArrayList<>(); + trackFormats = new ArrayList<>(); + lastReceivedCryptoInfos = new ArrayList<>(); + lastOutputCryptoDatas = new ArrayList<>(); + scratchDataReaderAdapter = new DataReaderAdapter(); + extractorOutput = new DummyExtractorOutput(); + sampleTimestampUpperLimitFilterUs = C.TIME_UNSET; + muxedCaptionFormats = ImmutableList.of(); + } + + /** + * Sets an upper limit for sample timestamp filtering. + * + *

    When set, samples with timestamps greater than {@code sampleTimestampUpperLimitFilterUs} + * will be discarded. + * + * @param sampleTimestampUpperLimitFilterUs The maximum allowed sample timestamp, or {@link + * C#TIME_UNSET} to remove filtering. + */ + public void setSampleTimestampUpperLimitFilterUs(long sampleTimestampUpperLimitFilterUs) { + this.sampleTimestampUpperLimitFilterUs = sampleTimestampUpperLimitFilterUs; + } + + /** Sets a {@link TimestampAdjuster} for adjusting the timestamps of the output samples. */ + public void setTimestampAdjuster(TimestampAdjuster timestampAdjuster) { + this.timestampAdjuster = timestampAdjuster; + } + + /** + * Sets the {@link ExtractorOutput} to which {@link MediaParser MediaParser's} output is directed. + */ + public void setExtractorOutput(ExtractorOutput extractorOutput) { + this.extractorOutput = extractorOutput; + } + + /** Sets {@link Format} information associated to the caption tracks multiplexed in the media. */ + public void setMuxedCaptionFormats(List muxedCaptionFormats) { + this.muxedCaptionFormats = muxedCaptionFormats; + } + + /** Overrides future received {@link SeekMap SeekMaps} with non-seekable instances. */ + public void disableSeeking() { + seekingDisabled = true; + } + + /** + * Returns a dummy {@link MediaParser.SeekMap}, or null if not available. + * + *

    the dummy {@link MediaParser.SeekMap} returns a single {@link MediaParser.SeekPoint} whose + * {@link MediaParser.SeekPoint#timeMicros} matches the requested timestamp, and {@link + * MediaParser.SeekPoint#position} is 0. + */ + @Nullable + public MediaParser.SeekMap getDummySeekMap() { + return dummySeekMap; + } + + /** Returns the most recently output {@link ChunkIndex}, or null if none has been output. */ + @Nullable + public ChunkIndex getChunkIndex() { + return lastChunkIndex; + } + + /** + * Returns the {@link MediaParser.SeekPoint} instances corresponding to the given timestamp. + * + * @param seekTimeUs The timestamp in microseconds to retrieve {@link MediaParser.SeekPoint} + * instances for. + * @return The {@link MediaParser.SeekPoint} instances corresponding to the given timestamp. + */ + public Pair getSeekPoints(long seekTimeUs) { + return lastSeekMap != null ? lastSeekMap.getSeekPoints(seekTimeUs) : SEEK_POINT_PAIR_START; + } + + /** + * Defines the container mime type to propagate through {@link TrackOutput#format}. + * + * @param parserName The name of the selected parser. + */ + public void setSelectedParserName(String parserName) { + containerMimeType = getMimeType(parserName); + } + + /** + * Returns the last output format for each track, or null if not all the tracks have been + * identified. + */ + @Nullable + public Format[] getSampleFormats() { + if (!tracksFoundCalled) { + return null; + } + Format[] sampleFormats = new Format[trackFormats.size()]; + for (int i = 0; i < trackFormats.size(); i++) { + sampleFormats[i] = Assertions.checkNotNull(trackFormats.get(i)); + } + return sampleFormats; + } + + // MediaParser.OutputConsumer implementation. + + @Override + public void onTrackCountFound(int numberOfTracks) { + tracksFoundCalled = true; + maybeEndTracks(); + } + + @Override + public void onSeekMapFound(MediaParser.SeekMap seekMap) { + if (expectDummySeekMap && dummySeekMap == null) { + // This is a dummy seek map. + dummySeekMap = seekMap; + } else { + lastSeekMap = seekMap; + long durationUs = seekMap.getDurationMicros(); + extractorOutput.seekMap( + seekingDisabled + ? new SeekMap.Unseekable( + durationUs != MediaParser.SeekMap.UNKNOWN_DURATION ? durationUs : C.TIME_UNSET) + : new SeekMapAdapter(seekMap)); + } + } + + @Override + public void onTrackDataFound(int trackIndex, TrackData trackData) { + if (maybeObtainChunkIndex(trackData.mediaFormat)) { + // The MediaFormat contains a chunk index. It does not contain anything else. + return; + } + + ensureSpaceForTrackIndex(trackIndex); + @Nullable TrackOutput trackOutput = trackOutputs.get(trackIndex); + if (trackOutput == null) { + @Nullable + String trackTypeString = trackData.mediaFormat.getString(MEDIA_FORMAT_KEY_TRACK_TYPE); + int trackType = + toTrackTypeConstant( + trackTypeString != null + ? trackTypeString + : trackData.mediaFormat.getString(MediaFormat.KEY_MIME)); + if (trackType == primaryTrackType) { + primaryTrackIndex = trackIndex; + } + trackOutput = extractorOutput.track(trackIndex, trackType); + trackOutputs.set(trackIndex, trackOutput); + if (trackTypeString != null) { + // The MediaFormat includes the track type string, so it cannot include any other keys, as + // per the android.media.mediaparser.eagerlyExposeTrackType parameter documentation. + return; + } + } + Format format = toExoPlayerFormat(trackData); + trackOutput.format( + primaryTrackManifestFormat != null && trackIndex == primaryTrackIndex + ? format.withManifestFormatInfo(primaryTrackManifestFormat) + : format); + trackFormats.set(trackIndex, format); + maybeEndTracks(); + } + + @Override + public void onSampleDataFound(int trackIndex, MediaParser.InputReader sampleData) + throws IOException { + ensureSpaceForTrackIndex(trackIndex); + scratchDataReaderAdapter.input = sampleData; + TrackOutput trackOutput = trackOutputs.get(trackIndex); + if (trackOutput == null) { + trackOutput = extractorOutput.track(trackIndex, C.TRACK_TYPE_UNKNOWN); + trackOutputs.set(trackIndex, trackOutput); + } + trackOutput.sampleData( + scratchDataReaderAdapter, (int) sampleData.getLength(), /* allowEndOfInput= */ true); + } + + @Override + public void onSampleCompleted( + int trackIndex, + long timeUs, + int flags, + int size, + int offset, + @Nullable MediaCodec.CryptoInfo cryptoInfo) { + if (sampleTimestampUpperLimitFilterUs != C.TIME_UNSET + && timeUs >= sampleTimestampUpperLimitFilterUs) { + // Ignore this sample. + return; + } else if (timestampAdjuster != null) { + timeUs = timestampAdjuster.adjustSampleTimestamp(timeUs); + } + Assertions.checkNotNull(trackOutputs.get(trackIndex)) + .sampleMetadata(timeUs, flags, size, offset, toExoPlayerCryptoData(trackIndex, cryptoInfo)); + } + + // Private methods. + + private boolean maybeObtainChunkIndex(MediaFormat mediaFormat) { + @Nullable + ByteBuffer chunkIndexSizesByteBuffer = + mediaFormat.getByteBuffer(MEDIA_FORMAT_KEY_CHUNK_INDEX_SIZES); + if (chunkIndexSizesByteBuffer == null) { + return false; + } + IntBuffer chunkIndexSizes = chunkIndexSizesByteBuffer.asIntBuffer(); + LongBuffer chunkIndexOffsets = + Assertions.checkNotNull(mediaFormat.getByteBuffer(MEDIA_FORMAT_KEY_CHUNK_INDEX_OFFSETS)) + .asLongBuffer(); + LongBuffer chunkIndexDurationsUs = + Assertions.checkNotNull(mediaFormat.getByteBuffer(MEDIA_FORMAT_KEY_CHUNK_INDEX_DURATIONS)) + .asLongBuffer(); + LongBuffer chunkIndexTimesUs = + Assertions.checkNotNull(mediaFormat.getByteBuffer(MEDIA_FORMAT_KEY_CHUNK_INDEX_TIMES)) + .asLongBuffer(); + int[] sizes = new int[chunkIndexSizes.remaining()]; + long[] offsets = new long[chunkIndexOffsets.remaining()]; + long[] durationsUs = new long[chunkIndexDurationsUs.remaining()]; + long[] timesUs = new long[chunkIndexTimesUs.remaining()]; + chunkIndexSizes.get(sizes); + chunkIndexOffsets.get(offsets); + chunkIndexDurationsUs.get(durationsUs); + chunkIndexTimesUs.get(timesUs); + lastChunkIndex = new ChunkIndex(sizes, offsets, durationsUs, timesUs); + extractorOutput.seekMap(lastChunkIndex); + return true; + } + + private void ensureSpaceForTrackIndex(int trackIndex) { + for (int i = trackOutputs.size(); i <= trackIndex; i++) { + trackOutputs.add(null); + trackFormats.add(null); + lastReceivedCryptoInfos.add(null); + lastOutputCryptoDatas.add(null); + } + } + + @Nullable + private CryptoData toExoPlayerCryptoData(int trackIndex, @Nullable CryptoInfo cryptoInfo) { + if (cryptoInfo == null) { + return null; + } + + @Nullable CryptoInfo lastReceivedCryptoInfo = lastReceivedCryptoInfos.get(trackIndex); + CryptoData cryptoDataToOutput; + // MediaParser keeps identity and value equality aligned for efficient comparison. + if (lastReceivedCryptoInfo == cryptoInfo) { + // They match, we can reuse the last one we created. + cryptoDataToOutput = Assertions.checkNotNull(lastOutputCryptoDatas.get(trackIndex)); + } else { + // They don't match, we create a new CryptoData. + + // TODO: Access pattern encryption info directly once the Android SDK makes it visible. + // See [Internal ref: b/154248283]. + int encryptedBlocks; + int clearBlocks; + try { + Matcher matcher = REGEX_CRYPTO_INFO_PATTERN.matcher(cryptoInfo.toString()); + matcher.find(); + encryptedBlocks = Integer.parseInt(Util.castNonNull(matcher.group(1))); + clearBlocks = Integer.parseInt(Util.castNonNull(matcher.group(2))); + } catch (RuntimeException e) { + // Should never happen. + Log.e(TAG, "Unexpected error while parsing CryptoInfo: " + cryptoInfo, e); + // Assume no-pattern encryption. + encryptedBlocks = 0; + clearBlocks = 0; + } + cryptoDataToOutput = + new CryptoData(cryptoInfo.mode, cryptoInfo.key, encryptedBlocks, clearBlocks); + lastReceivedCryptoInfos.set(trackIndex, cryptoInfo); + lastOutputCryptoDatas.set(trackIndex, cryptoDataToOutput); + } + return cryptoDataToOutput; + } + + private void maybeEndTracks() { + if (!tracksFoundCalled || tracksEnded) { + return; + } + int size = trackOutputs.size(); + for (int i = 0; i < size; i++) { + if (trackOutputs.get(i) == null) { + return; + } + } + extractorOutput.endTracks(); + tracksEnded = true; + } + + private static int toTrackTypeConstant(@Nullable String string) { + if (string == null) { + return C.TRACK_TYPE_UNKNOWN; + } + switch (string) { + case "audio": + return C.TRACK_TYPE_AUDIO; + case "video": + return C.TRACK_TYPE_VIDEO; + case "text": + return C.TRACK_TYPE_TEXT; + case "metadata": + return C.TRACK_TYPE_METADATA; + case "unknown": + return C.TRACK_TYPE_UNKNOWN; + default: + // Must be a MIME type. + return MimeTypes.getTrackType(string); + } + } + + private Format toExoPlayerFormat(TrackData trackData) { + // TODO: Consider adding support for the following: + // format.id + // format.stereoMode + // format.projectionData + MediaFormat mediaFormat = trackData.mediaFormat; + @Nullable String mediaFormatMimeType = mediaFormat.getString(MediaFormat.KEY_MIME); + int mediaFormatAccessibilityChannel = + mediaFormat.getInteger( + MediaFormat.KEY_CAPTION_SERVICE_NUMBER, /* defaultValue= */ Format.NO_VALUE); + Format.Builder formatBuilder = + new Format.Builder() + .setDrmInitData( + toExoPlayerDrmInitData( + mediaFormat.getString("crypto-mode-fourcc"), trackData.drmInitData)) + .setContainerMimeType(containerMimeType) + .setPeakBitrate( + mediaFormat.getInteger( + MediaFormat.KEY_BIT_RATE, /* defaultValue= */ Format.NO_VALUE)) + .setChannelCount( + mediaFormat.getInteger( + MediaFormat.KEY_CHANNEL_COUNT, /* defaultValue= */ Format.NO_VALUE)) + .setColorInfo(getColorInfo(mediaFormat)) + .setSampleMimeType(mediaFormatMimeType) + .setCodecs(mediaFormat.getString(MediaFormat.KEY_CODECS_STRING)) + .setFrameRate( + mediaFormat.getFloat( + MediaFormat.KEY_FRAME_RATE, /* defaultValue= */ Format.NO_VALUE)) + .setWidth( + mediaFormat.getInteger(MediaFormat.KEY_WIDTH, /* defaultValue= */ Format.NO_VALUE)) + .setHeight( + mediaFormat.getInteger(MediaFormat.KEY_HEIGHT, /* defaultValue= */ Format.NO_VALUE)) + .setInitializationData(getInitializationData(mediaFormat)) + .setLanguage(mediaFormat.getString(MediaFormat.KEY_LANGUAGE)) + .setMaxInputSize( + mediaFormat.getInteger( + MediaFormat.KEY_MAX_INPUT_SIZE, /* defaultValue= */ Format.NO_VALUE)) + .setPcmEncoding( + mediaFormat.getInteger("exo-pcm-encoding", /* defaultValue= */ Format.NO_VALUE)) + .setRotationDegrees( + mediaFormat.getInteger(MediaFormat.KEY_ROTATION, /* defaultValue= */ 0)) + .setSampleRate( + mediaFormat.getInteger( + MediaFormat.KEY_SAMPLE_RATE, /* defaultValue= */ Format.NO_VALUE)) + .setSelectionFlags(getSelectionFlags(mediaFormat)) + .setEncoderDelay( + mediaFormat.getInteger(MediaFormat.KEY_ENCODER_DELAY, /* defaultValue= */ 0)) + .setEncoderPadding( + mediaFormat.getInteger(MediaFormat.KEY_ENCODER_PADDING, /* defaultValue= */ 0)) + .setPixelWidthHeightRatio( + mediaFormat.getFloat("pixel-width-height-ratio-float", /* defaultValue= */ 1f)) + .setSubsampleOffsetUs( + mediaFormat.getLong( + "subsample-offset-us-long", /* defaultValue= */ Format.OFFSET_SAMPLE_RELATIVE)) + .setAccessibilityChannel(mediaFormatAccessibilityChannel); + for (int i = 0; i < muxedCaptionFormats.size(); i++) { + Format muxedCaptionFormat = muxedCaptionFormats.get(i); + if (Util.areEqual(muxedCaptionFormat.sampleMimeType, mediaFormatMimeType) + && muxedCaptionFormat.accessibilityChannel == mediaFormatAccessibilityChannel) { + // The track's format matches this muxedCaptionFormat, so we apply the manifest format + // information to the track. + formatBuilder + .setLanguage(muxedCaptionFormat.language) + .setRoleFlags(muxedCaptionFormat.roleFlags) + .setSelectionFlags(muxedCaptionFormat.selectionFlags) + .setLabel(muxedCaptionFormat.label) + .setMetadata(muxedCaptionFormat.metadata); + break; + } + } + return formatBuilder.build(); + } + + @Nullable + private static DrmInitData toExoPlayerDrmInitData( + @Nullable String schemeType, @Nullable android.media.DrmInitData drmInitData) { + if (drmInitData == null) { + return null; + } + SchemeData[] schemeDatas = new SchemeData[drmInitData.getSchemeInitDataCount()]; + for (int i = 0; i < schemeDatas.length; i++) { + SchemeInitData schemeInitData = drmInitData.getSchemeInitDataAt(i); + schemeDatas[i] = + new SchemeData(schemeInitData.uuid, schemeInitData.mimeType, schemeInitData.data); + } + return new DrmInitData(schemeType, schemeDatas); + } + + @SelectionFlags + private static int getSelectionFlags(MediaFormat mediaFormat) { + int selectionFlags = 0; + selectionFlags |= + getFlag( + mediaFormat, + /* key= */ MediaFormat.KEY_IS_AUTOSELECT, + /* returnValueIfPresent= */ C.SELECTION_FLAG_AUTOSELECT); + selectionFlags |= + getFlag( + mediaFormat, + /* key= */ MediaFormat.KEY_IS_DEFAULT, + /* returnValueIfPresent= */ C.SELECTION_FLAG_DEFAULT); + selectionFlags |= + getFlag( + mediaFormat, + /* key= */ MediaFormat.KEY_IS_FORCED_SUBTITLE, + /* returnValueIfPresent= */ C.SELECTION_FLAG_FORCED); + return selectionFlags; + } + + private static int getFlag(MediaFormat mediaFormat, String key, int returnValueIfPresent) { + return mediaFormat.getInteger(key, /* defaultValue= */ 0) != 0 ? returnValueIfPresent : 0; + } + + private static List getInitializationData(MediaFormat mediaFormat) { + ArrayList initData = new ArrayList<>(); + int i = 0; + while (true) { + @Nullable ByteBuffer byteBuffer = mediaFormat.getByteBuffer("csd-" + i++); + if (byteBuffer == null) { + break; + } + initData.add(getArray(byteBuffer)); + } + return initData; + } + + @Nullable + private static ColorInfo getColorInfo(MediaFormat mediaFormat) { + @Nullable + ByteBuffer hdrStaticInfoByteBuffer = mediaFormat.getByteBuffer(MediaFormat.KEY_HDR_STATIC_INFO); + @Nullable + byte[] hdrStaticInfo = + hdrStaticInfoByteBuffer != null ? getArray(hdrStaticInfoByteBuffer) : null; + int colorTransfer = + mediaFormat.getInteger(MediaFormat.KEY_COLOR_TRANSFER, /* defaultValue= */ Format.NO_VALUE); + int colorRange = + mediaFormat.getInteger(MediaFormat.KEY_COLOR_RANGE, /* defaultValue= */ Format.NO_VALUE); + int colorStandard = + mediaFormat.getInteger(MediaFormat.KEY_COLOR_STANDARD, /* defaultValue= */ Format.NO_VALUE); + + if (hdrStaticInfo != null + || colorTransfer != Format.NO_VALUE + || colorRange != Format.NO_VALUE + || colorStandard != Format.NO_VALUE) { + return new ColorInfo(colorStandard, colorRange, colorTransfer, hdrStaticInfo); + } + return null; + } + + private static byte[] getArray(ByteBuffer byteBuffer) { + byte[] array = new byte[byteBuffer.remaining()]; + byteBuffer.get(array); + return array; + } + + private static String getMimeType(String parserName) { + switch (parserName) { + case PARSER_NAME_MATROSKA: + return MimeTypes.VIDEO_WEBM; + case PARSER_NAME_FMP4: + case PARSER_NAME_MP4: + return MimeTypes.VIDEO_MP4; + case PARSER_NAME_MP3: + return MimeTypes.AUDIO_MPEG; + case PARSER_NAME_ADTS: + return MimeTypes.AUDIO_AAC; + case PARSER_NAME_AC3: + return MimeTypes.AUDIO_AC3; + case PARSER_NAME_TS: + return MimeTypes.VIDEO_MP2T; + case PARSER_NAME_FLV: + return MimeTypes.VIDEO_FLV; + case PARSER_NAME_OGG: + return MimeTypes.AUDIO_OGG; + case PARSER_NAME_PS: + return MimeTypes.VIDEO_PS; + case PARSER_NAME_WAV: + return MimeTypes.AUDIO_RAW; + case PARSER_NAME_AMR: + return MimeTypes.AUDIO_AMR; + case PARSER_NAME_AC4: + return MimeTypes.AUDIO_AC4; + case PARSER_NAME_FLAC: + return MimeTypes.AUDIO_FLAC; + default: + throw new IllegalArgumentException("Illegal parser name: " + parserName); + } + } + + private static final class SeekMapAdapter implements SeekMap { + + private final MediaParser.SeekMap adaptedSeekMap; + + public SeekMapAdapter(MediaParser.SeekMap adaptedSeekMap) { + this.adaptedSeekMap = adaptedSeekMap; + } + + @Override + public boolean isSeekable() { + return adaptedSeekMap.isSeekable(); + } + + @Override + public long getDurationUs() { + long durationMicros = adaptedSeekMap.getDurationMicros(); + return durationMicros != MediaParser.SeekMap.UNKNOWN_DURATION ? durationMicros : C.TIME_UNSET; + } + + @Override + @SuppressWarnings("ReferenceEquality") + public SeekPoints getSeekPoints(long timeUs) { + Pair seekPoints = + adaptedSeekMap.getSeekPoints(timeUs); + SeekPoints exoPlayerSeekPoints; + if (seekPoints.first == seekPoints.second) { + exoPlayerSeekPoints = new SeekPoints(asExoPlayerSeekPoint(seekPoints.first)); + } else { + exoPlayerSeekPoints = + new SeekPoints( + asExoPlayerSeekPoint(seekPoints.first), asExoPlayerSeekPoint(seekPoints.second)); + } + return exoPlayerSeekPoints; + } + + private static SeekPoint asExoPlayerSeekPoint(MediaParser.SeekPoint seekPoint) { + return new SeekPoint(seekPoint.timeMicros, seekPoint.position); + } + } + + private static final class DataReaderAdapter implements DataReader { + + @Nullable public MediaParser.InputReader input; + + @Override + public int read(byte[] target, int offset, int length) throws IOException { + return Util.castNonNull(input).read(target, offset, length); + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/package-info.java b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/package-info.java new file mode 100644 index 00000000000..3eedf0c7a4e --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/mediaparser/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.source.mediaparser; + +import com.google.android.exoplayer2.util.NonNullApi; 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 12a5bf85121..ad679ebe7f3 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 @@ -804,7 +804,6 @@ private static ChunkExtractor createChunkExtractor( List closedCaptionFormats, @Nullable TrackOutput playerEmsgTrackOutput) { String containerMimeType = representation.format.containerMimeType; - Extractor extractor; if (MimeTypes.isText(containerMimeType)) { if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) { 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 3a7a8de7918..8d3b633dc72 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 @@ -52,7 +52,6 @@ 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; @@ -546,5 +545,4 @@ public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { } refreshSourceInfo(timeline); } - } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/MediaParserHlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/MediaParserHlsMediaChunkExtractor.java new file mode 100644 index 00000000000..06de8544f21 --- /dev/null +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/MediaParserHlsMediaChunkExtractor.java @@ -0,0 +1,281 @@ +/* + * 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 android.media.MediaParser.PARAMETER_TS_IGNORE_AAC_STREAM; +import static android.media.MediaParser.PARAMETER_TS_IGNORE_AVC_STREAM; +import static android.media.MediaParser.PARAMETER_TS_IGNORE_SPLICE_INFO_STREAM; +import static android.media.MediaParser.PARAMETER_TS_MODE; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_EXPOSE_CAPTION_FORMATS; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_IGNORE_TIMESTAMP_OFFSET; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_IN_BAND_CRYPTO_INFO; +import static com.google.android.exoplayer2.source.mediaparser.MediaParserUtil.PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS; + +import android.annotation.SuppressLint; +import android.media.MediaFormat; +import android.media.MediaParser; +import android.media.MediaParser.OutputConsumer; +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.source.mediaparser.InputReaderAdapterV30; +import com.google.android.exoplayer2.source.mediaparser.MediaParserUtil; +import com.google.android.exoplayer2.source.mediaparser.OutputConsumerAdapterV30; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.FileTypes; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.io.IOException; + +/** {@link HlsMediaChunkExtractor} implemented on top of the platform's {@link MediaParser}. */ +@RequiresApi(30) +public final class MediaParserHlsMediaChunkExtractor implements HlsMediaChunkExtractor { + + /** + * {@link HlsExtractorFactory} implementation that produces {@link + * MediaParserHlsMediaChunkExtractor} for all container formats except WebVTT, for which a {@link + * BundledHlsMediaChunkExtractor} is returned. + */ + public static final HlsExtractorFactory FACTORY = + (uri, + format, + muxedCaptionFormats, + timestampAdjuster, + responseHeaders, + sniffingExtractorInput) -> { + if (FileTypes.inferFileTypeFromMimeType(format.sampleMimeType) == FileTypes.WEBVTT) { + // The segment contains WebVTT. MediaParser does not support WebVTT parsing, so we use the + // bundled extractor. + return new BundledHlsMediaChunkExtractor( + new WebvttExtractor(format.language, timestampAdjuster), format, timestampAdjuster); + } + + boolean overrideInBandCaptionDeclarations = muxedCaptionFormats != null; + ImmutableList.Builder muxedCaptionMediaFormatsBuilder = + ImmutableList.builder(); + if (muxedCaptionFormats != null) { + // The manifest contains captions declarations. We use those to determine which captions + // will be exposed by MediaParser. + for (int i = 0; i < muxedCaptionFormats.size(); i++) { + muxedCaptionMediaFormatsBuilder.add( + MediaParserUtil.toCaptionsMediaFormat(muxedCaptionFormats.get(i))); + } + } else { + // The manifest does not declare any captions in the stream. Imitate the default HLS + // extractor factory and declare a 608 track by default. + muxedCaptionMediaFormatsBuilder.add( + MediaParserUtil.toCaptionsMediaFormat( + new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build())); + } + + ImmutableList muxedCaptionMediaFormats = + muxedCaptionMediaFormatsBuilder.build(); + + // TODO: Factor out code for optimizing the sniffing order across both factories. + OutputConsumerAdapterV30 outputConsumerAdapter = new OutputConsumerAdapterV30(); + outputConsumerAdapter.setMuxedCaptionFormats( + muxedCaptionFormats != null ? muxedCaptionFormats : ImmutableList.of()); + outputConsumerAdapter.setTimestampAdjuster(timestampAdjuster); + MediaParser mediaParser = + createMediaParserInstance( + outputConsumerAdapter, + format, + overrideInBandCaptionDeclarations, + muxedCaptionMediaFormats, + MediaParser.PARSER_NAME_FMP4, + MediaParser.PARSER_NAME_AC3, + MediaParser.PARSER_NAME_AC4, + MediaParser.PARSER_NAME_ADTS, + MediaParser.PARSER_NAME_MP3, + MediaParser.PARSER_NAME_TS); + + PeekingInputReader peekingInputReader = new PeekingInputReader(sniffingExtractorInput); + // The chunk extractor constructor requires an instance with a known parser name, so we + // advance once for MediaParser to sniff the content. + mediaParser.advance(peekingInputReader); + outputConsumerAdapter.setSelectedParserName(mediaParser.getParserName()); + + return new MediaParserHlsMediaChunkExtractor( + mediaParser, + outputConsumerAdapter, + format, + overrideInBandCaptionDeclarations, + muxedCaptionMediaFormats, + /* leadingBytesToSkip= */ peekingInputReader.totalPeekedBytes); + }; + + private final OutputConsumerAdapterV30 outputConsumerAdapter; + private final InputReaderAdapterV30 inputReaderAdapter; + private final MediaParser mediaParser; + private final Format format; + private final boolean overrideInBandCaptionDeclarations; + private final ImmutableList muxedCaptionMediaFormats; + private int pendingSkipBytes; + + /** + * Creates a new instance. + * + * @param mediaParser The {@link MediaParser} instance to use for extraction of segments. The + * provided instance must have completed sniffing, or must have been created by name. + * @param outputConsumerAdapter The {@link OutputConsumerAdapterV30} with which {@code + * mediaParser} was created. + * @param format The {@link Format} associated with the segment. + * @param overrideInBandCaptionDeclarations Whether to ignore any in-band caption track + * declarations in favor of using the {@code muxedCaptionMediaFormats} instead. If false, + * caption declarations found in the extracted media will be used, causing {@code + * muxedCaptionMediaFormats} to be ignored instead. + * @param muxedCaptionMediaFormats The list of in-band caption {@link MediaFormat MediaFormats} + * that {@link MediaParser} should expose. + * @param leadingBytesToSkip The number of bytes to skip from the start of the input before + * starting extraction. + */ + public MediaParserHlsMediaChunkExtractor( + MediaParser mediaParser, + OutputConsumerAdapterV30 outputConsumerAdapter, + Format format, + boolean overrideInBandCaptionDeclarations, + ImmutableList muxedCaptionMediaFormats, + int leadingBytesToSkip) { + this.mediaParser = mediaParser; + this.outputConsumerAdapter = outputConsumerAdapter; + this.overrideInBandCaptionDeclarations = overrideInBandCaptionDeclarations; + this.muxedCaptionMediaFormats = muxedCaptionMediaFormats; + this.format = format; + pendingSkipBytes = leadingBytesToSkip; + inputReaderAdapter = new InputReaderAdapterV30(); + } + + // ChunkExtractor implementation. + + @Override + public void init(ExtractorOutput extractorOutput) { + outputConsumerAdapter.setExtractorOutput(extractorOutput); + } + + @Override + public boolean read(ExtractorInput extractorInput) throws IOException { + extractorInput.skipFully(pendingSkipBytes); + pendingSkipBytes = 0; + inputReaderAdapter.setDataReader(extractorInput, extractorInput.getLength()); + return mediaParser.advance(inputReaderAdapter); + } + + @Override + public boolean isPackedAudioExtractor() { + String parserName = mediaParser.getParserName(); + return MediaParser.PARSER_NAME_AC3.equals(parserName) + || MediaParser.PARSER_NAME_AC4.equals(parserName) + || MediaParser.PARSER_NAME_ADTS.equals(parserName) + || MediaParser.PARSER_NAME_MP3.equals(parserName); + } + + @Override + public boolean isReusable() { + String parserName = mediaParser.getParserName(); + return MediaParser.PARSER_NAME_FMP4.equals(parserName) + || MediaParser.PARSER_NAME_TS.equals(parserName); + } + + @Override + public HlsMediaChunkExtractor recreate() { + Assertions.checkState(!isReusable()); + return new MediaParserHlsMediaChunkExtractor( + createMediaParserInstance( + outputConsumerAdapter, + format, + overrideInBandCaptionDeclarations, + muxedCaptionMediaFormats, + mediaParser.getParserName()), + outputConsumerAdapter, + format, + overrideInBandCaptionDeclarations, + muxedCaptionMediaFormats, + /* leadingBytesToSkip= */ 0); + } + + // Allow constants that are not part of the public MediaParser API. + @SuppressLint({"WrongConstant"}) + private static MediaParser createMediaParserInstance( + OutputConsumer outputConsumer, + Format format, + boolean overrideInBandCaptionDeclarations, + ImmutableList muxedCaptionMediaFormats, + String... parserNames) { + MediaParser mediaParser = + parserNames.length == 1 + ? MediaParser.createByName(parserNames[0], outputConsumer) + : MediaParser.create(outputConsumer, parserNames); + mediaParser.setParameter(PARAMETER_EXPOSE_CAPTION_FORMATS, muxedCaptionMediaFormats); + mediaParser.setParameter( + PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS, overrideInBandCaptionDeclarations); + mediaParser.setParameter(PARAMETER_IN_BAND_CRYPTO_INFO, true); + mediaParser.setParameter(PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE, true); + mediaParser.setParameter(PARAMETER_IGNORE_TIMESTAMP_OFFSET, true); + mediaParser.setParameter(PARAMETER_TS_IGNORE_SPLICE_INFO_STREAM, true); + mediaParser.setParameter(PARAMETER_TS_MODE, "hls"); + @Nullable String codecs = format.codecs; + if (!TextUtils.isEmpty(codecs)) { + // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really + // exist. If we know from the codec attribute that they don't exist, then we can + // explicitly ignore them even if they're declared. + if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) { + mediaParser.setParameter(PARAMETER_TS_IGNORE_AAC_STREAM, true); + } + if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) { + mediaParser.setParameter(PARAMETER_TS_IGNORE_AVC_STREAM, true); + } + } + return mediaParser; + } + + private static final class PeekingInputReader implements MediaParser.SeekableInputReader { + + private final ExtractorInput extractorInput; + private int totalPeekedBytes; + + private PeekingInputReader(ExtractorInput extractorInput) { + this.extractorInput = extractorInput; + } + + @Override + public int read(@NonNull byte[] buffer, int offset, int readLength) throws IOException { + int peekedBytes = extractorInput.peek(buffer, offset, readLength); + totalPeekedBytes += peekedBytes; + return peekedBytes; + } + + @Override + public long getPosition() { + return extractorInput.getPeekPosition(); + } + + @Override + public long getLength() { + return extractorInput.getLength(); + } + + @Override + public void seekToPosition(long position) { + // Seeking is not allowed when sniffing the content. + throw new UnsupportedOperationException(); + } + } +} From e962d7172e31259ba1eb2728a7780402d08f37a5 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Thu, 29 Oct 2020 12:24:21 +0000 Subject: [PATCH 216/693] Add an MP4 sample for SEF slow motion to the test assests PiperOrigin-RevId: 339648625 --- .../assets/media/mp4/sample_sef_slowmotion.mp4 | Bin 0 -> 52720 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 testdata/src/test/assets/media/mp4/sample_sef_slowmotion.mp4 diff --git a/testdata/src/test/assets/media/mp4/sample_sef_slowmotion.mp4 b/testdata/src/test/assets/media/mp4/sample_sef_slowmotion.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..fc15b02a8e6d8945dd7012a1fe9d35bbaf6580cb GIT binary patch literal 52720 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 Date: Thu, 29 Oct 2020 12:34:57 +0000 Subject: [PATCH 217/693] Correct naming of SEF slow motion mp4 sample. PiperOrigin-RevId: 339649762 --- .../assets/media/mp4/sample_sef_slow_motion.mp4 | Bin 0 -> 52720 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 testdata/src/test/assets/media/mp4/sample_sef_slow_motion.mp4 diff --git a/testdata/src/test/assets/media/mp4/sample_sef_slow_motion.mp4 b/testdata/src/test/assets/media/mp4/sample_sef_slow_motion.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..fc15b02a8e6d8945dd7012a1fe9d35bbaf6580cb GIT binary patch literal 52720 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 Date: Thu, 29 Oct 2020 13:23:37 +0000 Subject: [PATCH 218/693] Add mdta metadata to SEF slow mo test sample PiperOrigin-RevId: 339655069 --- .../media/mp4/sample_sef_slow_motion.mp4 | Bin 52720 -> 52972 bytes .../media/mp4/sample_sef_slowmotion.mp4 | Bin 52720 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 testdata/src/test/assets/media/mp4/sample_sef_slowmotion.mp4 diff --git a/testdata/src/test/assets/media/mp4/sample_sef_slow_motion.mp4 b/testdata/src/test/assets/media/mp4/sample_sef_slow_motion.mp4 index fc15b02a8e6d8945dd7012a1fe9d35bbaf6580cb..aba8a0ff32011f5ede00745a318a970ad776d3d9 100644 GIT binary patch delta 277 zcmew`oB7RL<_&)iF>x17{&%RTp5ae!YDppk1A}5lN=^}w27}xbuo#RBrYo~kD~q8b z%s^Tiq$oK*S1&Oyr6@l$MXxNis5mn}4=AONA(feuny*)qnp==xl$aBrlUNB<8=stC zTAx<}R4mIWkb7Zr;y^Zoa{-8CTd}$5a2*!_>EA#em{&%QobIy?lE&zY`3q1e; diff --git a/testdata/src/test/assets/media/mp4/sample_sef_slowmotion.mp4 b/testdata/src/test/assets/media/mp4/sample_sef_slowmotion.mp4 deleted file mode 100644 index fc15b02a8e6d8945dd7012a1fe9d35bbaf6580cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52720 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 Date: Thu, 29 Oct 2020 16:03:18 +0000 Subject: [PATCH 219/693] Transformer: flatten slow mo video at normal speed Slow motion segments are not taken into account yet. PiperOrigin-RevId: 339678840 --- .../android/exoplayer2/metadata/mp4/MdtaMetadataEntry.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MdtaMetadataEntry.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MdtaMetadataEntry.java index 913056f3c26..0f41c46c74d 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MdtaMetadataEntry.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/mp4/MdtaMetadataEntry.java @@ -34,6 +34,11 @@ public final class MdtaMetadataEntry implements Metadata.Entry { public static final String KEY_ANDROID_TEMPORAL_LAYER_COUNT = "com.android.video.temporal_layers_count"; + /** Type indicator for a 32-bit floating point value. */ + public static final int TYPE_INDICATOR_FLOAT = 23; + /** Type indicator for a 32-bit integer. */ + public static final int TYPE_INDICATOR_INT = 67; + /** The metadata key name. */ public final String key; /** The payload. The interpretation of the value depends on {@link #typeIndicator}. */ From 0d6ec21b30e13d7aba8c1273019b54c63cad10c1 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 29 Oct 2020 20:44:49 +0000 Subject: [PATCH 220/693] Parse #EXT-X-PRELOAD-HINT tag Issue: #5011 PiperOrigin-RevId: 339738292 --- .../hls/playlist/HlsPlaylistParser.java | 53 +++++++ .../playlist/HlsMediaPlaylistParserTest.java | 149 ++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 1f9ad1b7035..03408776b3f 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -93,11 +93,14 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions = new HashMap<>(); HashMap urlToInferredInitSegment = new HashMap<>(); @@ -760,6 +769,42 @@ private static HlsMediaPlaylist parseMediaPlaylist( hasIndependentSegmentsTag = true; } else if (line.equals(TAG_ENDLIST)) { hasEndTag = true; + } else if (line.startsWith(TAG_PRELOAD_HINT) && !seenPreloadPart) { + String type = parseStringAttr(line, REGEX_PRELOAD_HINT_TYPE, variableDefinitions); + if (!TYPE_PART.equals(type)) { + continue; + } + String url = parseStringAttr(line, REGEX_URI, variableDefinitions); + long byteRangeStart = + parseOptionalLongAttr(line, REGEX_BYTERANGE_START, /* defaultValue= */ 0); + long byteRangeLength = + parseOptionalLongAttr(line, REGEX_BYTERANGE_LENGTH, /* defaultValue= */ C.TIME_UNSET); + @Nullable + String segmentEncryptionIV = + getSegmentEncryptionIV( + segmentMediaSequence, fullSegmentEncryptionKeyUri, fullSegmentEncryptionIV); + if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) { + SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]); + cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas); + if (playlistProtectionSchemes == null) { + playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas); + } + } + parts.add( + new Part( + url, + initializationSegment, + /* durationUs= */ 0, + relativeDiscontinuitySequence, + partStartTimeUs, + cachedDrmInitData, + fullSegmentEncryptionKeyUri, + segmentEncryptionIV, + byteRangeStart, + byteRangeLength, + /* hasGapTag= */ false, + /* isIndependent= */ false)); + seenPreloadPart = true; } else if (line.startsWith(TAG_PART)) { @Nullable String segmentEncryptionIV = @@ -1027,6 +1072,14 @@ private static long parseLongAttr(String line, Pattern pattern) throws ParserExc return Long.parseLong(parseStringAttr(line, pattern, Collections.emptyMap())); } + private static long parseOptionalLongAttr(String line, Pattern pattern, long defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return Long.parseLong(checkNotNull(matcher.group(1))); + } + return defaultValue; + } + private static double parseDoubleAttr(String line, Pattern pattern) throws ParserException { return Double.parseDouble(parseStringAttr(line, pattern, Collections.emptyMap())); } 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 88a2a2f2fbb..b917cd2c113 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 @@ -487,6 +487,155 @@ public void parseMediaPlaylist_withPartAndAes128WithoutPrecedingSegment_partHasD assertThat(part.encryptionIV).isEqualTo("0x410C8AC18AA42EFA18B5155484F5FC34"); } + @Test + public void parseMediaPlaylist_withPreloadHintTypePart_hasPreloadPartWithAllAttributes() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-MAP:URI=\"map.mp4\"\n" + + "#EXT-X-KEY:METHOD=AES-128,KEYFORMAT=\"identity\"" + + ", IV=0x410C8AC18AA42EFA18B5155484F5FC34,URI=\"fake://foo.bar/uri\"\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.1.ts\"\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,BYTERANGE-START=1234,BYTERANGE-LENGTH=1000," + + "URI=\"filePart267.2.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments.get(0).parts).isEmpty(); + assertThat(playlist.trailingParts).hasSize(2); + HlsMediaPlaylist.Part preloadPart = playlist.trailingParts.get(1); + assertThat(preloadPart.durationUs).isEqualTo(0L); + assertThat(preloadPart.url).isEqualTo("filePart267.2.ts"); + assertThat(preloadPart.byteRangeLength).isEqualTo(1000); + assertThat(preloadPart.byteRangeOffset).isEqualTo(1234); + assertThat(preloadPart.initializationSegment.url).isEqualTo("map.mp4"); + assertThat(preloadPart.encryptionIV).isEqualTo("0x410C8AC18AA42EFA18B5155484F5FC34"); + } + + @Test + public void parseMediaPlaylist_withMultiplePreloadHintTypeParts_picksOnlyFirstPreloadPart() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.1.ts\"\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.2.ts\"\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.3.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.trailingParts).hasSize(2); + assertThat(playlist.trailingParts.get(1).url).isEqualTo("filePart267.2.ts"); + } + + @Test + public void parseMediaPlaylist_withPreloadHintTypePartAndAesPlayReadyKey_inheritsDrmInitData() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-KEY:METHOD=SAMPLE-AES," + + "KEYFORMAT=\"com.microsoft.playready\"," + + "URI=\"data:text/plain;base64,RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==\"\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.2.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments.get(0).parts).isEmpty(); + assertThat(playlist.trailingParts).hasSize(1); + assertThat(playlist.protectionSchemes.schemeDataCount).isEqualTo(1); + HlsMediaPlaylist.Part preloadPart = playlist.trailingParts.get(0); + assertThat(preloadPart.drmInitData.schemeType).isEqualTo("cbcs"); + assertThat(preloadPart.drmInitData.schemeDataCount).isEqualTo(1); + assertThat(preloadPart.drmInitData.get(0).uuid).isEqualTo(C.PLAYREADY_UUID); + assertThat(preloadPart.drmInitData.get(0).data) + .isEqualTo( + PsshAtomUtil.buildPsshAtom( + C.PLAYREADY_UUID, + Base64.decode("RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==", Base64.DEFAULT))); + } + + @Test + public void parseMediaPlaylist_withPreloadHintTypePartAndNewAesPlayReadyKey_correctDrmInitData() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-KEY:METHOD=SAMPLE-AES," + + "KEYFORMAT=\"com.microsoft.playready\"," + + "URI=\"data:text/plain;base64,RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==\"\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.2.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments.get(0).parts).isEmpty(); + assertThat(playlist.trailingParts).hasSize(1); + assertThat(playlist.protectionSchemes.schemeDataCount).isEqualTo(1); + HlsMediaPlaylist.Part preloadPart = playlist.trailingParts.get(0); + assertThat(preloadPart.drmInitData.schemeType).isEqualTo("cbcs"); + assertThat(preloadPart.drmInitData.schemeDataCount).isEqualTo(1); + assertThat(preloadPart.drmInitData.get(0).uuid).isEqualTo(C.PLAYREADY_UUID); + assertThat(preloadPart.drmInitData.get(0).data) + .isEqualTo( + PsshAtomUtil.buildPsshAtom( + C.PLAYREADY_UUID, + Base64.decode("RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==", Base64.DEFAULT))); + } + + @Test + public void parseMediaPlaylist_withPreloadHintTypePartAndAes128_partHasDrmKeyUriAndIV() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-KEY:METHOD=AES-128,KEYFORMAT=\"identity\"" + + ", IV=0x410C8AC18AA42EFA18B5155484F5FC34,URI=\"fake://foo.bar/uri\"\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.2.ts\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.segments.get(0).parts).isEmpty(); + assertThat(playlist.trailingParts).hasSize(1); + HlsMediaPlaylist.Part preloadPart = playlist.trailingParts.get(0); + assertThat(preloadPart.drmInitData).isNull(); + assertThat(preloadPart.fullSegmentEncryptionKeyUri).isEqualTo("fake://foo.bar/uri"); + assertThat(preloadPart.encryptionIV).isEqualTo("0x410C8AC18AA42EFA18B5155484F5FC34"); + } + @Test public void multipleExtXKeysForSingleSegment() throws Exception { Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); From 6a5b53355b96529578aca9af99ac1f53f82d517a Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 30 Oct 2020 13:13:48 +0000 Subject: [PATCH 221/693] Add SVC extension to SEF test sample PiperOrigin-RevId: 339858492 --- .../media/mp4/sample_sef_slow_motion.mp4 | Bin 52972 -> 53069 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/testdata/src/test/assets/media/mp4/sample_sef_slow_motion.mp4 b/testdata/src/test/assets/media/mp4/sample_sef_slow_motion.mp4 index aba8a0ff32011f5ede00745a318a970ad776d3d9..8b436e0c94c076e595e3f900b5f1c77504b213ff 100644 GIT binary patch delta 2872 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 Date: Fri, 30 Oct 2020 14:02:31 +0000 Subject: [PATCH 222/693] Add time units to playing time failure message in ExoHostedTest PiperOrigin-RevId: 339864290 --- .../com/google/android/exoplayer2/testutil/ExoHostedTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 179162d4140..b446332d229 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 @@ -168,7 +168,7 @@ public final void onFinished() { long minAllowedActualPlayingTimeMs = playingTimeToAssertMs - MAX_PLAYING_TIME_DISCREPANCY_MS; long maxAllowedActualPlayingTimeMs = playingTimeToAssertMs + MAX_PLAYING_TIME_DISCREPANCY_MS; assertWithMessage( - "Total playing time: " + totalPlayingTimeMs + ". Expected: " + playingTimeToAssertMs) + "Total playing time: %sms. Expected: %sms", totalPlayingTimeMs, playingTimeToAssertMs) .that( minAllowedActualPlayingTimeMs <= totalPlayingTimeMs && totalPlayingTimeMs <= maxAllowedActualPlayingTimeMs) From 9962cf015b222926e8026b338add8a0beb1cc850 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Fri, 30 Oct 2020 16:32:04 +0000 Subject: [PATCH 223/693] Add SEF based test to MetadataRetrieverTest PiperOrigin-RevId: 339885432 --- .../exoplayer2/MetadataRetrieverTest.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java index 32230a5eaed..87225bc6f36 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java @@ -26,9 +26,12 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata; +import com.google.android.exoplayer2.metadata.mp4.SlowMotionData; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.util.MimeTypes; import com.google.common.util.concurrent.ListenableFuture; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.ExecutionException; import org.junit.Before; import org.junit.Test; @@ -124,6 +127,34 @@ public void retrieveMetadata_heicStillPhoto_outputsEmptyMetadata() throws Except assertThat(trackGroups.get(0).getFormat(0).metadata).isNull(); } + @Test + public void retrieveMetadata_sefSlowMotion_outputsExpectedMetadata() throws Exception { + MediaItem mediaItem = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample_sef_slow_motion.mp4")); + List segments = new ArrayList<>(); + segments.add( + new SlowMotionData.Segment( + /* startTimeMs= */ 88, /* endTimeMs= */ 879, /* speedDivisor= */ 2)); + segments.add( + new SlowMotionData.Segment( + /* startTimeMs= */ 1255, /* endTimeMs= */ 1970, /* speedDivisor= */ 8)); + SlowMotionData expectedSlowMotionData = new SlowMotionData(segments); + + ListenableFuture trackGroupsFuture = retrieveMetadata(context, mediaItem); + TrackGroupArray trackGroups = waitAndGetTrackGroups(trackGroupsFuture); + + assertThat(trackGroups.length).isEqualTo(2); // Video and audio + + // Audio + assertThat(trackGroups.get(0).getFormat(0).metadata.length()).isEqualTo(1); + assertThat(trackGroups.get(0).getFormat(0).metadata.get(0)).isEqualTo(expectedSlowMotionData); + + // Video + assertThat(trackGroups.get(1).getFormat(0).metadata.length()) + .isEqualTo(3); // 2 Mdta entries and 1 slow motion entry. + assertThat(trackGroups.get(1).getFormat(0).metadata.get(2)).isEqualTo(expectedSlowMotionData); + } + @Test public void retrieveMetadata_invalidMediaItem_throwsError() { MediaItem mediaItem = From df19725d58d8f81afc535387a6c8b68e65c7164b Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 30 Oct 2020 16:58:18 +0000 Subject: [PATCH 224/693] Move more IMA extension utils into ImaUtil These symbols will be referenced from AdTagLoader too in a later change. PiperOrigin-RevId: 339889990 --- .../android/exoplayer2/ext/ima/ImaAdsLoader.java | 12 +++--------- .../google/android/exoplayer2/ext/ima/ImaUtil.java | 11 +++++++++++ 2 files changed, 14 insertions(+), 9 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 265ffe585b9..dbfdabaa286 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,9 @@ */ package com.google.android.exoplayer2.ext.ima; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.BITRATE_UNSET; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.TIMEOUT_UNSET; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.getImaLooper; import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkState; @@ -461,9 +464,6 @@ public ImaAdsLoader build() { /** 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; - /** The state of ad playback. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -1661,12 +1661,6 @@ private static long getContentPeriodPositionMs( : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } - 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) { diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java index 6d69547278b..ae12819e841 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ext.ima; import android.content.Context; +import android.os.Looper; import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; @@ -130,6 +131,9 @@ public Configuration( } } + public static final int TIMEOUT_UNSET = -1; + public static final int BITRATE_UNSET = -1; + /** * Returns the IMA {@link FriendlyObstructionPurpose} corresponding to the given {@link * OverlayInfo#purpose}. @@ -203,6 +207,13 @@ public static boolean isAdGroupLoadError(AdError adError) { || adError.getErrorCode() == AdError.AdErrorCode.UNKNOWN_ERROR; } + /** Returns the looper on which all IMA SDK interaction must occur. */ + public 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(); + } + /** Returns a human-readable representation of a video progress update. */ public static String getStringForVideoProgressUpdate(VideoProgressUpdate videoProgressUpdate) { if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { From 27707e9c65e11660eb0b20d4c9476171a0ac46a2 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 30 Oct 2020 17:02:10 +0000 Subject: [PATCH 225/693] Clean up deprecated ad tag handling PiperOrigin-RevId: 339890695 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 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 dbfdabaa286..60db64c683b 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 @@ -487,8 +487,7 @@ public ImaAdsLoader build() { private final ImaUtil.Configuration configuration; private final Context context; private final ImaUtil.ImaFactory imaFactory; - @Nullable private final Uri adTagUri; - @Nullable private final String adsResponse; + @Nullable private final DataSpec deprecatedAdTagDataSpec; private final ImaSdkSettings imaSdkSettings; private final Timeline.Period period; private final Handler handler; @@ -605,8 +604,12 @@ private ImaAdsLoader( this.context = context.getApplicationContext(); this.configuration = configuration; this.imaFactory = imaFactory; - this.adTagUri = adTagUri; - this.adsResponse = adsResponse; + deprecatedAdTagDataSpec = + adTagUri != null + ? new DataSpec(adTagUri) + : adsResponse != null + ? new DataSpec(Util.getDataUriForString(adsResponse, "text/xml")) + : null; @Nullable ImaSdkSettings imaSdkSettings = configuration.imaSdkSettings; if (imaSdkSettings == null) { imaSdkSettings = imaFactory.createImaSdkSettings(); @@ -701,14 +704,7 @@ public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) } if (EMPTY_AD_TAG_DATA_SPEC.equals(adTagDataSpec)) { - // Handle deprecated ways of specifying the ad tag. - if (adTagUri != null) { - adTagDataSpec = new DataSpec(adTagUri); - } else if (adsResponse != null) { - adTagDataSpec = new DataSpec(Util.getDataUriForString(adsResponse, "text/xml")); - } else { - throw new IllegalStateException(); - } + adTagDataSpec = checkNotNull(deprecatedAdTagDataSpec); } AdsRequest request; From 32b710712ed3a96f9f488ae89dbf72967140438c Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 30 Oct 2020 17:06:59 +0000 Subject: [PATCH 226/693] Split AdTagLoader out of ImaAdsLoader In a later change ImaAdsLoader will use multiple AdTagLoaders. This change shouldn't have any substantial changes in behavior (it's almost entirely moving code around). An exception is that ImaSdkSettings is configured when making a request rather than at construction time. Issue: #3750 PiperOrigin-RevId: 339891712 --- .../exoplayer2/ext/ima/AdTagLoader.java | 1397 +++++++++++++++++ .../exoplayer2/ext/ima/ImaAdsLoader.java | 1312 +--------------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 1 + 3 files changed, 1420 insertions(+), 1290 deletions(-) create mode 100644 extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java new file mode 100644 index 00000000000..0b10cce3b1b --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java @@ -0,0 +1,1397 @@ +/* + * 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.ext.ima; + +import static com.google.android.exoplayer2.ext.ima.ImaUtil.BITRATE_UNSET; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.TIMEOUT_UNSET; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.getImaLooper; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static java.lang.Math.max; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; +import android.view.ViewGroup; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdError; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; +import com.google.ads.interactivemedia.v3.api.AdEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; +import com.google.ads.interactivemedia.v3.api.AdsLoader; +import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener; +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.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; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +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.AdsLoader.AdViewProvider; +import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener; +import com.google.android.exoplayer2.source.ads.AdsLoader.OverlayInfo; +import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Handles loading and playback of a single ad tag. */ +/* package */ final class AdTagLoader implements Player.EventListener { + + private static final String TAG = "AdTagLoader"; + + 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 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; + + /** + * 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 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; + /** The threshold below which ad cue points are treated as matching, in microseconds. */ + private static final long THRESHOLD_AD_MATCH_US = 1000; + + /** The state of ad playback. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED}) + private @interface ImaAdState {} + + /** The ad playback state when IMA is not playing an ad. */ + private static final int IMA_AD_STATE_NONE = 0; + /** + * 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 ComponentListener#pauseAd(AdMediaInfo)} while + * playing an ad. + */ + private static final int IMA_AD_STATE_PAUSED = 2; + + private final ImaUtil.Configuration configuration; + private final ImaUtil.ImaFactory imaFactory; + private final List supportedMimeTypes; + private final DataSpec adTagDataSpec; + private final Timeline.Period period; + private final Handler handler; + private final ComponentListener componentListener; + private final List adCallbacks; + private final Runnable updateAdProgressRunnable; + private final BiMap adInfoByAdMediaInfo; + private final AdDisplayContainer adDisplayContainer; + private final AdsLoader adsLoader; + + @Nullable private Object pendingAdRequestContext; + @Nullable private EventListener eventListener; + @Nullable private Player player; + private VideoProgressUpdate lastContentProgress; + private VideoProgressUpdate lastAdProgress; + private int lastVolumePercent; + + @Nullable private AdsManager adsManager; + private boolean isAdsManagerInitialized; + private boolean hasAdPlaybackState; + @Nullable private AdLoadException pendingAdLoadError; + private Timeline timeline; + private long contentDurationMs; + private AdPlaybackState adPlaybackState; + + // Fields tracking IMA's state. + + /** 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 IMA has been notified that playback of content has finished. */ + private boolean sentContentComplete; + + // Fields tracking the player/loader state. + + /** 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; + /** + * The ad info for a pending ad for which the media failed preparation, or {@code null} if no + * pending ads have failed to prepare. + */ + @Nullable private AdInfo pendingAdPrepareErrorAdInfo; + /** + * 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; + /** + * If {@link #fakeContentProgressElapsedRealtimeMs} is set, stores the offset from which the + * content progress should increase. {@link C#TIME_UNSET} otherwise. + */ + private long fakeContentProgressOffsetMs; + /** Stores the pending content position when a seek operation was intercepted to play an ad. */ + private long pendingContentPositionMs; + /** + * 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 + * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable. + */ + private long waitingForPreloadElapsedRealtimeMs; + + /** Creates a new ad tag loader, starting the ad request if the ad tag is valid. */ + @SuppressWarnings({"methodref.receiver.bound.invalid", "method.invocation.invalid"}) + public AdTagLoader( + Context context, + ImaUtil.Configuration configuration, + ImaUtil.ImaFactory imaFactory, + List supportedMimeTypes, + DataSpec adTagDataSpec, + @Nullable ViewGroup adViewGroup) { + this.configuration = configuration; + this.imaFactory = imaFactory; + @Nullable ImaSdkSettings imaSdkSettings = configuration.imaSdkSettings; + if (imaSdkSettings == null) { + imaSdkSettings = imaFactory.createImaSdkSettings(); + if (configuration.debugModeEnabled) { + imaSdkSettings.setDebugMode(true); + } + } + imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); + imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); + this.supportedMimeTypes = supportedMimeTypes; + this.adTagDataSpec = adTagDataSpec; + period = new Timeline.Period(); + handler = Util.createHandler(getImaLooper(), /* callback= */ null); + componentListener = new ComponentListener(); + adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); + if (configuration.applicationVideoAdPlayerCallback != null) { + adCallbacks.add(configuration.applicationVideoAdPlayerCallback); + } + updateAdProgressRunnable = this::updateAdProgress; + adInfoByAdMediaInfo = HashBiMap.create(); + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + 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; + if (adViewGroup != null) { + adDisplayContainer = + imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener); + } else { + adDisplayContainer = + imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener); + } + if (configuration.companionAdSlots != null) { + adDisplayContainer.setCompanionSlots(configuration.companionAdSlots); + } + adsLoader = requestAds(context, imaSdkSettings, adDisplayContainer); + } + + /** Returns the underlying IMA SDK ads loader. */ + public AdsLoader getAdsLoader() { + return adsLoader; + } + + /** Returns the IMA SDK ad display container. */ + public AdDisplayContainer getAdDisplayContainer() { + return adDisplayContainer; + } + + /** Skips the current skippable ad, if there is one. */ + public void skipAd() { + if (adsManager != null) { + adsManager.skip(); + } + } + + /** Starts using the ads loader for playback. */ + public void start(Player player, AdViewProvider adViewProvider, EventListener eventListener) { + this.player = player; + player.addListener(this); + boolean playWhenReady = player.getPlayWhenReady(); + this.eventListener = eventListener; + lastVolumePercent = 0; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + maybeNotifyPendingAdLoadError(); + if (hasAdPlaybackState) { + // Pass the ad playback state to the player, and resume ads if necessary. + eventListener.onAdPlaybackState(adPlaybackState); + if (adsManager != null && imaPausedContent && playWhenReady) { + adsManager.resume(); + } + } else if (adsManager != null) { + adPlaybackState = ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); + updateAdPlaybackState(); + } + if (adDisplayContainer != null) { + for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { + adDisplayContainer.registerFriendlyObstruction( + imaFactory.createFriendlyObstruction( + overlayInfo.view, + ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), + overlayInfo.reasonDetail)); + } + } + } + + /** Stops using the ads loader for playback. */ + 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); + } + lastVolumePercent = getPlayerVolumePercent(); + lastAdProgress = getAdVideoProgressUpdate(); + lastContentProgress = getContentVideoProgressUpdate(); + if (adDisplayContainer != null) { + adDisplayContainer.unregisterAllFriendlyObstructions(); + } + player.removeListener(this); + this.player = null; + eventListener = null; + } + + /** Releases all resources used by the ad tag loader. */ + public void release() { + pendingAdRequestContext = null; + destroyAdsManager(); + if (adsLoader != null) { + adsLoader.removeAdsLoadedListener(componentListener); + adsLoader.removeAdErrorListener(componentListener); + if (configuration.applicationAdErrorListener != null) { + adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener); + } + adsLoader.release(); + } + imaPausedContent = false; + imaAdState = IMA_AD_STATE_NONE; + imaAdMediaInfo = null; + stopUpdatingAdProgress(); + imaAdInfo = null; + pendingAdLoadError = null; + adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = true; + updateAdPlaybackState(); + } + + /** Notifies the IMA SDK that the specified ad has been prepared for playback. */ + public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) { + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Prepared ad " + adInfo); + } + @Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo); + if (adMediaInfo != null) { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onLoaded(adMediaInfo); + } + } else { + Log.w(TAG, "Unexpected prepared ad " + adInfo); + } + } + + /** Notifies the IMA SDK that the specified ad has failed to prepare for playback. */ + public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) { + if (player == null) { + return; + } + try { + handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception); + } catch (RuntimeException e) { + maybeNotifyInternalError("handlePrepareError", e); + } + } + + // Player.EventListener implementation. + + @Override + public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + if (timeline.isEmpty()) { + // The player is being reset or contains no media. + return; + } + checkArgument(timeline.getPeriodCount() == 1); + this.timeline = timeline; + long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs; + contentDurationMs = C.usToMs(contentDurationUs); + if (contentDurationUs != C.TIME_UNSET) { + adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); + } + @Nullable AdsManager adsManager = this.adsManager; + if (!isAdsManagerInitialized && adsManager != null) { + isAdsManagerInitialized = true; + @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } + } + updateAdPlaybackState(); + } + handleTimelineOrPositionChanged(); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + handleTimelineOrPositionChanged(); + } + + @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 < configuration.adPreloadTimeoutMs) { + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + } else if (playbackState == Player.STATE_READY) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + } + + handlePlayerStateChanged(player.getPlayWhenReady(), playbackState); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + if (adsManager == null || player == null) { + return; + } + + if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { + adsManager.pause(); + return; + } + + if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) { + adsManager.resume(); + return; + } + handlePlayerStateChanged(playWhenReady, player.getPlaybackState()); + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + if (imaAdState != IMA_AD_STATE_NONE) { + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + } + + // Internal methods. + + private AdsLoader requestAds( + Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { + AdsLoader adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); + adsLoader.addAdErrorListener(componentListener); + if (configuration.applicationAdErrorListener != null) { + adsLoader.addAdErrorListener(configuration.applicationAdErrorListener); + } + adsLoader.addAdsLoadedListener(componentListener); + AdsRequest request; + try { + request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec); + } catch (IOException e) { + hasAdPlaybackState = true; + updateAdPlaybackState(); + pendingAdLoadError = AdLoadException.createForAllAds(e); + maybeNotifyPendingAdLoadError(); + return adsLoader; + } + pendingAdRequestContext = new Object(); + request.setUserRequestContext(pendingAdRequestContext); + if (configuration.vastLoadTimeoutMs != TIMEOUT_UNSET) { + request.setVastLoadTimeout(configuration.vastLoadTimeoutMs); + } + request.setContentProgressProvider(componentListener); + adsLoader.requestAds(request); + return adsLoader; + } + + /** + * 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); + adsRenderingSettings.setMimeTypes( + configuration.adMediaMimeTypes != null + ? configuration.adMediaMimeTypes + : supportedMimeTypes); + if (configuration.mediaLoadTimeoutMs != TIMEOUT_UNSET) { + adsRenderingSettings.setLoadVideoTimeout(configuration.mediaLoadTimeoutMs); + } + if (configuration.mediaBitrate != BITRATE_UNSET) { + adsRenderingSettings.setBitrateKbps(configuration.mediaBitrate / 1000); + } + adsRenderingSettings.setFocusSkipButtonWhenAvailable( + configuration.focusSkipButtonWhenAvailable); + if (configuration.adUiElements != null) { + adsRenderingSettings.setUiElements(configuration.adUiElements); + } + + // Skip ads based on the start position as required. + long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; + long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period); + int adGroupForPositionIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); + if (adGroupForPositionIndex != C.INDEX_UNSET) { + boolean playAdWhenStartingPlayback = + configuration.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 (adGroupForPositionIndex > 0) { + for (int i = 0; i < adGroupForPositionIndex; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + 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; + } + + 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; + } 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(); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Ad progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate)); + } + + AdMediaInfo adMediaInfo = 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 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 handleAdEvent(AdEvent adEvent) { + if (adsManager == null) { + // Drop events after release. + return; + } + switch (adEvent.getType()) { + case AD_BREAK_FETCH_ERROR: + String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime")); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); + } + double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString); + int adGroupIndex = + adGroupTimeSeconds == -1.0 + ? adPlaybackState.adGroupCount - 1 + : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds); + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); + break; + case CONTENT_PAUSE_REQUESTED: + // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads + // before sending CONTENT_RESUME_REQUESTED. + imaPausedContent = true; + pauseContentInternal(); + break; + case TAPPED: + if (eventListener != null) { + eventListener.onAdTapped(); + } + break; + case CLICKED: + if (eventListener != null) { + eventListener.onAdClicked(); + } + break; + case CONTENT_RESUME_REQUESTED: + imaPausedContent = false; + resumeContentInternal(); + break; + case LOG: + Map adData = adEvent.getAdData(); + String message = "AdEvent: " + adData; + Log.i(TAG, message); + break; + default: + break; + } + } + + private void pauseContentInternal() { + imaAdState = IMA_AD_STATE_NONE; + if (sentPendingContentPositionMs) { + pendingContentPositionMs = C.TIME_UNSET; + sentPendingContentPositionMs = false; + } + } + + private void resumeContentInternal() { + if (imaAdInfo != null) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); + updateAdPlaybackState(); + } else { + // Mark any ads for the current/reported player position that haven't loaded as being in the + // error state, to force resuming content. This includes VPAID ads that never load. + long playerPositionUs; + if (player != null) { + playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period)); + } else if (!VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(lastContentProgress)) { + // Playback is backgrounded so use the last reported content position. + playerPositionUs = C.msToUs(lastContentProgress.getCurrentTimeMs()); + } else { + return; + } + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + playerPositionUs, C.msToUs(contentDurationMs)); + if (adGroupIndex != C.INDEX_UNSET) { + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); + } + } + } + + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { + if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { + AdMediaInfo adMediaInfo = 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) { + ensureSentContentCompleteIfAtEndOfStream(); + } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { + AdMediaInfo adMediaInfo = 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 (configuration.debugModeEnabled) { + Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged"); + } + } + } + + private void handleTimelineOrPositionChanged() { + @Nullable Player player = this.player; + if (adsManager == null || player == null) { + return; + } + if (!playingAd && !player.isPlayingAd()) { + ensureSentContentCompleteIfAtEndOfStream(); + if (!sentContentComplete && !timeline.isEmpty()) { + long positionMs = getContentPeriodPositionMs(player, timeline, period); + timeline.getPeriod(/* periodIndex= */ 0, period); + int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); + if (newAdGroupIndex != C.INDEX_UNSET) { + sentPendingContentPositionMs = false; + pendingContentPositionMs = positionMs; + } + } + } + + boolean wasPlayingAd = playingAd; + int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup; + playingAd = player.isPlayingAd(); + playingAdIndexInAdGroup = playingAd ? player.getCurrentAdIndexInAdGroup() : C.INDEX_UNSET; + boolean adFinished = wasPlayingAd && playingAdIndexInAdGroup != oldPlayingAdIndexInAdGroup; + 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. + @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo; + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + if (playingAdIndexInAdGroup == C.INDEX_UNSET + || (adInfo != null && adInfo.adIndexInAdGroup < playingAdIndexInAdGroup)) { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } + if (configuration.debugModeEnabled) { + Log.d( + TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); + } + } + } + } + if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { + int adGroupIndex = player.getCurrentAdGroupIndex(); + if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) { + sendContentComplete(); + } 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; + } + } + } + } + + private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + if (adsManager == null) { + // Drop events after release. + if (configuration.debugModeEnabled) { + Log.d( + TAG, + "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); + } + return; + } + + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (configuration.debugModeEnabled) { + 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. + 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]; + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, 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, /* adIndexInAdGroup= */ i); + } + } + + Uri adUri = Uri.parse(adMediaInfo.getUrl()); + adPlaybackState = + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); + updateAdPlaybackState(); + } + + private void playAdInternal(AdMediaInfo adMediaInfo) { + if (configuration.debugModeEnabled) { + 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"); + } + + 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 = 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; + checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (!checkNotNull(player).getPlayWhenReady()) { + checkNotNull(adsManager).pause(); + } + } + + private void pauseAdInternal(AdMediaInfo adMediaInfo) { + if (configuration.debugModeEnabled) { + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + if (imaAdState == IMA_AD_STATE_NONE) { + // 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; + } + checkState(adMediaInfo.equals(imaAdMediaInfo)); + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); + } + } + + private void stopAdInternal(AdMediaInfo adMediaInfo) { + if (configuration.debugModeEnabled) { + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // 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; + } + checkNotNull(player); + imaAdState = IMA_AD_STATE_NONE; + stopUpdatingAdProgress(); + // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. + 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(); + if (!playingAd) { + imaAdMediaInfo = null; + imaAdInfo = null; + } + } + + private void handleAdGroupLoadError(Exception error) { + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + Log.w(TAG, "Unable to determine ad group index for ad group load error", error); + return; + } + markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex); + } + } + + private void markAdGroupInErrorStateAndClearPendingContentPosition(int adGroupIndex) { + // Update the ad playback state so all ads in the ad group are in the error state. + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, 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 (configuration.debugModeEnabled) { + Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); + } + } + updateAdPlaybackState(); + // Clear any pending content position that triggered attempting to load the ad group. + pendingContentPositionMs = C.TIME_UNSET; + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + } + + private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Exception exception) { + if (configuration.debugModeEnabled) { + Log.d( + TAG, "Prepare error for ad " + adIndexInAdGroup + " in group " + adGroupIndex, exception); + } + if (adsManager == null) { + Log.w(TAG, "Ignoring ad prepare error after release"); + return; + } + if (imaAdState == IMA_AD_STATE_NONE) { + // Send IMA a content position at the ad group so that it will try to play it, at which point + // we can notify that it failed to load. + fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); + fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { + fakeContentProgressOffsetMs = contentDurationMs; + } + pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + } else { + AdMediaInfo adMediaInfo = 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(adMediaInfo); + } + } + playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(checkNotNull(adMediaInfo)); + } + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); + updateAdPlaybackState(); + } + + private void ensureSentContentCompleteIfAtEndOfStream() { + if (!sentContentComplete + && contentDurationMs != C.TIME_UNSET + && pendingContentPositionMs == C.TIME_UNSET + && getContentPeriodPositionMs(checkNotNull(player), timeline, period) + + THRESHOLD_END_OF_CONTENT_MS + >= contentDurationMs) { + sendContentComplete(); + } + } + + private void sendContentComplete() { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onContentComplete(); + } + sentContentComplete = true; + if (configuration.debugModeEnabled) { + 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) { + eventListener.onAdPlaybackState(adPlaybackState); + } + } + + private void maybeNotifyPendingAdLoadError() { + if (pendingAdLoadError != null && eventListener != null) { + eventListener.onAdLoadError(pendingAdLoadError, adTagDataSpec); + pendingAdLoadError = null; + } + } + + private void maybeNotifyInternalError(String name, Exception cause) { + 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. + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + updateAdPlaybackState(); + if (eventListener != null) { + eventListener.onAdLoadError( + AdLoadException.createForUnexpected(new RuntimeException(message, cause)), adTagDataSpec); + } + } + + private int getAdGroupIndexForAdPod(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. + return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset()); + } + + /** + * 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() { + if (player == null) { + return C.INDEX_UNSET; + } + long playerPositionUs = C.msToUs(getContentPeriodPositionMs(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 int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) { + // We receive initial 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). + float cuePointTimeSecondsFloat = (float) cuePointTimeSeconds; + long adPodTimeUs = Math.round((double) cuePointTimeSecondsFloat * C.MICROS_PER_SECOND); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex]; + if (adGroupTimeUs != C.TIME_END_OF_SOURCE + && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { + return adGroupIndex; + } + } + 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 long getContentPeriodPositionMs( + Player player, Timeline timeline, Timeline.Period period) { + long contentWindowPositionMs = player.getContentPosition(); + return contentWindowPositionMs + - (timeline.isEmpty() + ? 0 + : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); + } + + private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { + int count = adGroupTimesUs.length; + if (count == 1) { + return adGroupTimesUs[0] != 0 && adGroupTimesUs[0] != C.TIME_END_OF_SOURCE; + } else if (count == 2) { + return adGroupTimesUs[0] != 0 || adGroupTimesUs[1] != C.TIME_END_OF_SOURCE; + } else { + // There's at least one midroll ad group, as adGroupTimesUs is never empty. + return true; + } + } + + private void destroyAdsManager() { + if (adsManager != null) { + adsManager.removeAdErrorListener(componentListener); + if (configuration.applicationAdErrorListener != null) { + adsManager.removeAdErrorListener(configuration.applicationAdErrorListener); + } + adsManager.removeAdEventListener(componentListener); + if (configuration.applicationAdEventListener != null) { + adsManager.removeAdEventListener(configuration.applicationAdEventListener); + } + adsManager.destroy(); + adsManager = null; + } + } + + private final class ComponentListener + implements AdsLoadedListener, + ContentProgressProvider, + AdEventListener, + AdErrorListener, + VideoAdPlayer { + + // AdsLoader.AdsLoadedListener implementation. + + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { + AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); + if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { + adsManager.destroy(); + return; + } + pendingAdRequestContext = null; + AdTagLoader.this.adsManager = adsManager; + adsManager.addAdErrorListener(this); + if (configuration.applicationAdErrorListener != null) { + adsManager.addAdErrorListener(configuration.applicationAdErrorListener); + } + adsManager.addAdEventListener(this); + if (configuration.applicationAdEventListener != null) { + adsManager.addAdEventListener(configuration.applicationAdEventListener); + } + if (player != null) { + // If a player is attached already, start playback immediately. + try { + adPlaybackState = + ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); + hasAdPlaybackState = true; + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdsManagerLoaded", e); + } + } + } + + // ContentProgressProvider implementation. + + @Override + public VideoProgressUpdate getContentProgress() { + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (configuration.debugModeEnabled) { + Log.d( + TAG, + "Content progress: " + ImaUtil.getStringForVideoProgressUpdate(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; + } + + // AdEvent.AdEventListener implementation. + + @Override + public void onAdEvent(AdEvent adEvent) { + AdEventType adEventType = adEvent.getType(); + if (configuration.debugModeEnabled && adEventType != AdEventType.AD_PROGRESS) { + Log.d(TAG, "onAdEvent: " + adEventType); + } + try { + handleAdEvent(adEvent); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdEvent", e); + } + } + + // AdErrorEvent.AdErrorListener implementation. + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + AdError error = adErrorEvent.getError(); + if (configuration.debugModeEnabled) { + 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 (ImaUtil.isAdGroupLoadError(error)) { + try { + handleAdGroupLoadError(error); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdError", e); + } + } + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAllAds(error); + } + maybeNotifyPendingAdLoadError(); + } + + // VideoAdPlayer implementation. + + @Override + public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.add(videoAdPlayerCallback); + } + + @Override + public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.remove(videoAdPlayerCallback); + } + + @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 { + loadAdInternal(adMediaInfo, adPodInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("loadAd", e); + } + } + + @Override + public void playAd(AdMediaInfo adMediaInfo) { + try { + playAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); + } + } + + @Override + public void pauseAd(AdMediaInfo adMediaInfo) { + try { + pauseAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); + } + } + + @Override + public void stopAd(AdMediaInfo adMediaInfo) { + try { + stopAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("stopAd", e); + } + } + + @Override + public void release() { + // Do nothing. + } + } + + // 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 + ')'; + } + } +} 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 60db64c683b..852ab4e96ac 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 @@ -21,30 +21,19 @@ import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkState; -import static java.lang.Math.max; import android.content.Context; import android.net.Uri; -import android.os.Handler; import android.os.Looper; -import android.os.SystemClock; import android.view.View; import android.view.ViewGroup; -import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; -import com.google.ads.interactivemedia.v3.api.AdError; -import com.google.ads.interactivemedia.v3.api.AdErrorEvent; import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; -import com.google.ads.interactivemedia.v3.api.AdEvent; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; -import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType; -import com.google.ads.interactivemedia.v3.api.AdPodInfo; import com.google.ads.interactivemedia.v3.api.AdsLoader; -import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener; 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; @@ -53,40 +42,24 @@ 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; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaSourceFactory; -import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsMediaSource; -import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DataSpec; -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.collect.BiMap; -import com.google.common.collect.HashBiMap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import java.io.IOException; -import java.lang.annotation.Documented; -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.List; -import java.util.Map; import java.util.Set; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * {@link com.google.android.exoplayer2.source.ads.AdsLoader} using the IMA SDK. All methods must be @@ -435,141 +408,19 @@ public ImaAdsLoader build() { } } - private static final String TAG = "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 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; - - /** - * 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 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; - /** The threshold below which ad cue points are treated as matching, in microseconds. */ - private static final long THRESHOLD_AD_MATCH_US = 1000; - - /** The state of ad playback. */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED}) - private @interface ImaAdState {} - /** The ad playback state when IMA is not playing an ad. */ - private static final int IMA_AD_STATE_NONE = 0; - /** - * 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 ComponentListener#pauseAd(AdMediaInfo)} while - * playing an ad. - */ - private static final int IMA_AD_STATE_PAUSED = 2; - private static final DataSpec EMPTY_AD_TAG_DATA_SPEC = new DataSpec(Uri.EMPTY); private final ImaUtil.Configuration configuration; private final Context context; private final ImaUtil.ImaFactory imaFactory; @Nullable private final DataSpec deprecatedAdTagDataSpec; - private final ImaSdkSettings imaSdkSettings; - private final Timeline.Period period; - private final Handler handler; - private final ComponentListener componentListener; - private final List adCallbacks; - private final Runnable updateAdProgressRunnable; - private final BiMap adInfoByAdMediaInfo; - private @MonotonicNonNull AdDisplayContainer adDisplayContainer; - private @MonotonicNonNull AdsLoader adsLoader; private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; - @Nullable private Object pendingAdRequestContext; + @Nullable private AdTagLoader adTagLoader; private List supportedMimeTypes; - @Nullable private EventListener eventListener; - @Nullable private Player player; private DataSpec adTagDataSpec; - private VideoProgressUpdate lastContentProgress; - private VideoProgressUpdate lastAdProgress; - private int lastVolumePercent; - - @Nullable private AdsManager adsManager; - private boolean isAdsManagerInitialized; - private boolean hasAdPlaybackState; - @Nullable private AdLoadException pendingAdLoadError; - private Timeline timeline; - private long contentDurationMs; - private AdPlaybackState adPlaybackState; - - // Fields tracking IMA's state. - - /** 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 IMA has been notified that playback of content has finished. */ - private boolean sentContentComplete; - - // Fields tracking the player/loader state. - - /** 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; - /** - * The ad info for a pending ad for which the media failed preparation, or {@code null} if no - * pending ads have failed to prepare. - */ - @Nullable private AdInfo pendingAdPrepareErrorAdInfo; - /** - * 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; - /** - * If {@link #fakeContentProgressElapsedRealtimeMs} is set, stores the offset from which the - * content progress should increase. {@link C#TIME_UNSET} otherwise. - */ - private long fakeContentProgressOffsetMs; - /** Stores the pending content position when a seek operation was intercepted to play an ad. */ - private long pendingContentPositionMs; - /** - * 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 - * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable. - */ - private long waitingForPreloadElapsedRealtimeMs; + @Nullable private Player player; /** * Creates a new IMA ads loader. @@ -594,7 +445,6 @@ public ImaAdsLoader(Context context, Uri adTagUri) { /* adsResponse= */ null); } - @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader( Context context, ImaUtil.Configuration configuration, @@ -610,36 +460,8 @@ private ImaAdsLoader( : adsResponse != null ? new DataSpec(Util.getDataUriForString(adsResponse, "text/xml")) : null; - @Nullable ImaSdkSettings imaSdkSettings = configuration.imaSdkSettings; - if (imaSdkSettings == null) { - imaSdkSettings = imaFactory.createImaSdkSettings(); - if (configuration.debugModeEnabled) { - imaSdkSettings.setDebugMode(true); - } - } - imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); - imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); - this.imaSdkSettings = imaSdkSettings; - period = new Timeline.Period(); - handler = Util.createHandler(getImaLooper(), /* callback= */ null); - componentListener = new ComponentListener(); - adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); - if (configuration.applicationVideoAdPlayerCallback != null) { - adCallbacks.add(configuration.applicationVideoAdPlayerCallback); - } - updateAdProgressRunnable = this::updateAdProgress; - adInfoByAdMediaInfo = HashBiMap.create(); - supportedMimeTypes = Collections.emptyList(); adTagDataSpec = EMPTY_AD_TAG_DATA_SPEC; - lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; - lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; - 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; + supportedMimeTypes = ImmutableList.of(); } /** @@ -648,7 +470,7 @@ private ImaAdsLoader( */ @Nullable public AdsLoader getAdsLoader() { - return adsLoader; + return adTagLoader != null ? adTagLoader.getAdsLoader() : null; } /** @@ -664,7 +486,7 @@ public AdsLoader getAdsLoader() { */ @Nullable public AdDisplayContainer getAdDisplayContainer() { - return adDisplayContainer; + return adTagLoader != null ? adTagLoader.getAdDisplayContainer() : null; } /** @@ -698,51 +520,16 @@ public void requestAds(@Nullable ViewGroup adViewGroup) { * null} if playing audio-only ads. */ public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) { - if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) { - // Ads have already been requested. + if (adTagLoader != null) { return; } if (EMPTY_AD_TAG_DATA_SPEC.equals(adTagDataSpec)) { adTagDataSpec = checkNotNull(deprecatedAdTagDataSpec); } - - AdsRequest request; - try { - request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec); - } catch (IOException e) { - hasAdPlaybackState = true; - updateAdPlaybackState(); - pendingAdLoadError = AdLoadException.createForAllAds(e); - maybeNotifyPendingAdLoadError(); - return; - } - this.adTagDataSpec = adTagDataSpec; - pendingAdRequestContext = new Object(); - request.setUserRequestContext(pendingAdRequestContext); - if (configuration.vastLoadTimeoutMs != TIMEOUT_UNSET) { - request.setVastLoadTimeout(configuration.vastLoadTimeoutMs); - } - request.setContentProgressProvider(componentListener); - - if (adViewGroup != null) { - adDisplayContainer = - imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener); - } else { - adDisplayContainer = - imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener); - } - if (configuration.companionAdSlots != null) { - adDisplayContainer.setCompanionSlots(configuration.companionAdSlots); - } - - adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); - adsLoader.addAdErrorListener(componentListener); - if (configuration.applicationAdErrorListener != null) { - adsLoader.addAdErrorListener(configuration.applicationAdErrorListener); - } - adsLoader.addAdsLoadedListener(componentListener); - adsLoader.requestAds(request); + adTagLoader = + new AdTagLoader( + context, configuration, imaFactory, supportedMimeTypes, adTagDataSpec, adViewGroup); } /** @@ -753,8 +540,8 @@ public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) * IMA SDK provides the UI to skip ads in the ad view group passed via {@link AdViewProvider}. */ public void skipAd() { - if (adsManager != null) { - adsManager.skip(); + if (adTagLoader != null) { + adTagLoader.skipAd(); } } @@ -800,1096 +587,41 @@ public void start(EventListener eventListener, AdViewProvider adViewProvider) { checkState( wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player."); player = nextPlayer; + @Nullable Player player = this.player; if (player == null) { return; } - player.addListener(this); - boolean playWhenReady = player.getPlayWhenReady(); - this.eventListener = eventListener; - lastVolumePercent = 0; - lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; - lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; - maybeNotifyPendingAdLoadError(); - if (hasAdPlaybackState) { - // Pass the ad playback state to the player, and resume ads if necessary. - eventListener.onAdPlaybackState(adPlaybackState); - if (adsManager != null && imaPausedContent && playWhenReady) { - adsManager.resume(); - } - } else if (adsManager != null) { - adPlaybackState = ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); - updateAdPlaybackState(); - } else { - // Ads haven't loaded yet, so request them. + if (adTagLoader == null) { requestAds(adTagDataSpec, adViewProvider.getAdViewGroup()); } - if (adDisplayContainer != null) { - for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { - adDisplayContainer.registerFriendlyObstruction( - imaFactory.createFriendlyObstruction( - overlayInfo.view, - ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), - overlayInfo.reasonDetail)); - } - } + checkNotNull(adTagLoader).start(player, adViewProvider, eventListener); } @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); - } - lastVolumePercent = getPlayerVolumePercent(); - lastAdProgress = getAdVideoProgressUpdate(); - lastContentProgress = getContentVideoProgressUpdate(); - if (adDisplayContainer != null) { - adDisplayContainer.unregisterAllFriendlyObstructions(); + if (player != null && adTagLoader != null) { + adTagLoader.stop(); } - player.removeListener(this); - this.player = null; - eventListener = null; } @Override public void release() { - pendingAdRequestContext = null; - destroyAdsManager(); - if (adsLoader != null) { - adsLoader.removeAdsLoadedListener(componentListener); - adsLoader.removeAdErrorListener(componentListener); - if (configuration.applicationAdErrorListener != null) { - adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener); - } - adsLoader.release(); + if (adTagLoader != null) { + adTagLoader.release(); } - imaPausedContent = false; - imaAdState = IMA_AD_STATE_NONE; - imaAdMediaInfo = null; - stopUpdatingAdProgress(); - imaAdInfo = null; - pendingAdLoadError = null; - adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = true; - updateAdPlaybackState(); } @Override public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) { - AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - if (configuration.debugModeEnabled) { - Log.d(TAG, "Prepared ad " + adInfo); - } - @Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo); - if (adMediaInfo != null) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onLoaded(adMediaInfo); - } - } else { - Log.w(TAG, "Unexpected prepared ad " + adInfo); + if (adTagLoader != null) { + adTagLoader.handlePrepareComplete(adGroupIndex, adIndexInAdGroup); } } @Override public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) { - if (player == null) { - return; - } - try { - handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception); - } catch (RuntimeException e) { - maybeNotifyInternalError("handlePrepareError", e); - } - } - - // Player.EventListener implementation. - - @Override - public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { - if (timeline.isEmpty()) { - // The player is being reset or contains no media. - return; - } - checkArgument(timeline.getPeriodCount() == 1); - this.timeline = timeline; - long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs; - contentDurationMs = C.usToMs(contentDurationUs); - if (contentDurationUs != C.TIME_UNSET) { - adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); - } - @Nullable AdsManager adsManager = this.adsManager; - if (!isAdsManagerInitialized && adsManager != null) { - isAdsManagerInitialized = true; - @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); - if (adsRenderingSettings == null) { - // There are no ads to play. - destroyAdsManager(); - } else { - adsManager.init(adsRenderingSettings); - adsManager.start(); - if (configuration.debugModeEnabled) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); - } - } - updateAdPlaybackState(); - } - handleTimelineOrPositionChanged(); - } - - @Override - public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { - handleTimelineOrPositionChanged(); - } - - @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 < configuration.adPreloadTimeoutMs) { - waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); - } - } else if (playbackState == Player.STATE_READY) { - waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; - } - - handlePlayerStateChanged(player.getPlayWhenReady(), playbackState); - } - - @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { - if (adsManager == null || player == null) { - return; - } - - if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { - adsManager.pause(); - return; - } - - if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) { - adsManager.resume(); - return; - } - handlePlayerStateChanged(playWhenReady, player.getPlaybackState()); - } - - @Override - public void onPlayerError(ExoPlaybackException error) { - if (imaAdState != IMA_AD_STATE_NONE) { - AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(adMediaInfo); - } - } - } - - // Internal methods. - - /** - * 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); - adsRenderingSettings.setMimeTypes( - configuration.adMediaMimeTypes != null - ? configuration.adMediaMimeTypes - : supportedMimeTypes); - if (configuration.mediaLoadTimeoutMs != TIMEOUT_UNSET) { - adsRenderingSettings.setLoadVideoTimeout(configuration.mediaLoadTimeoutMs); - } - if (configuration.mediaBitrate != BITRATE_UNSET) { - adsRenderingSettings.setBitrateKbps(configuration.mediaBitrate / 1000); - } - adsRenderingSettings.setFocusSkipButtonWhenAvailable( - configuration.focusSkipButtonWhenAvailable); - if (configuration.adUiElements != null) { - adsRenderingSettings.setUiElements(configuration.adUiElements); - } - - // Skip ads based on the start position as required. - long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; - long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period); - int adGroupForPositionIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupForPositionIndex != C.INDEX_UNSET) { - boolean playAdWhenStartingPlayback = - configuration.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 (adGroupForPositionIndex > 0) { - for (int i = 0; i < adGroupForPositionIndex; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } - 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; - } - - 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; - } 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(); - if (configuration.debugModeEnabled) { - Log.d(TAG, "Ad progress: " + ImaUtil.getStringForVideoProgressUpdate(videoProgressUpdate)); - } - - AdMediaInfo adMediaInfo = 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 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 handleAdEvent(AdEvent adEvent) { - if (adsManager == null) { - // Drop events after release. - return; - } - switch (adEvent.getType()) { - case AD_BREAK_FETCH_ERROR: - String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime")); - if (configuration.debugModeEnabled) { - Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); - } - double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString); - int adGroupIndex = - adGroupTimeSeconds == -1.0 - ? adPlaybackState.adGroupCount - 1 - : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds); - markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); - break; - case CONTENT_PAUSE_REQUESTED: - // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads - // before sending CONTENT_RESUME_REQUESTED. - imaPausedContent = true; - pauseContentInternal(); - break; - case TAPPED: - if (eventListener != null) { - eventListener.onAdTapped(); - } - break; - case CLICKED: - if (eventListener != null) { - eventListener.onAdClicked(); - } - break; - case CONTENT_RESUME_REQUESTED: - imaPausedContent = false; - resumeContentInternal(); - break; - case LOG: - Map adData = adEvent.getAdData(); - String message = "AdEvent: " + adData; - Log.i(TAG, message); - break; - default: - break; - } - } - - private void pauseContentInternal() { - imaAdState = IMA_AD_STATE_NONE; - if (sentPendingContentPositionMs) { - pendingContentPositionMs = C.TIME_UNSET; - sentPendingContentPositionMs = false; - } - } - - private void resumeContentInternal() { - if (imaAdInfo != null) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); - updateAdPlaybackState(); - } else { - // Mark any ads for the current/reported player position that haven't loaded as being in the - // error state, to force resuming content. This includes VPAID ads that never load. - long playerPositionUs; - if (player != null) { - playerPositionUs = C.msToUs(getContentPeriodPositionMs(player, timeline, period)); - } else if (!VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(lastContentProgress)) { - // Playback is backgrounded so use the last reported content position. - playerPositionUs = C.msToUs(lastContentProgress.getCurrentTimeMs()); - } else { - return; - } - int adGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - playerPositionUs, C.msToUs(contentDurationMs)); - if (adGroupIndex != C.INDEX_UNSET) { - markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); - } - } - } - - private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { - if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { - if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { - AdMediaInfo adMediaInfo = 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) { - ensureSentContentCompleteIfAtEndOfStream(); - } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { - AdMediaInfo adMediaInfo = 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 (configuration.debugModeEnabled) { - Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged"); - } - } - } - - private void handleTimelineOrPositionChanged() { - @Nullable Player player = this.player; - if (adsManager == null || player == null) { - return; - } - if (!playingAd && !player.isPlayingAd()) { - ensureSentContentCompleteIfAtEndOfStream(); - if (!sentContentComplete && !timeline.isEmpty()) { - long positionMs = getContentPeriodPositionMs(player, timeline, period); - timeline.getPeriod(/* periodIndex= */ 0, period); - int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); - if (newAdGroupIndex != C.INDEX_UNSET) { - sentPendingContentPositionMs = false; - pendingContentPositionMs = positionMs; - } - } - } - - boolean wasPlayingAd = playingAd; - int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup; - playingAd = player.isPlayingAd(); - playingAdIndexInAdGroup = playingAd ? player.getCurrentAdIndexInAdGroup() : C.INDEX_UNSET; - boolean adFinished = wasPlayingAd && playingAdIndexInAdGroup != oldPlayingAdIndexInAdGroup; - 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. - @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo; - if (adMediaInfo == null) { - Log.w(TAG, "onEnded without ad media info"); - } else { - @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); - if (playingAdIndexInAdGroup == C.INDEX_UNSET - || (adInfo != null && adInfo.adIndexInAdGroup < playingAdIndexInAdGroup)) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(adMediaInfo); - } - if (configuration.debugModeEnabled) { - Log.d( - TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); - } - } - } - } - if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { - int adGroupIndex = player.getCurrentAdGroupIndex(); - if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) { - sendContentComplete(); - } 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; - } - } - } - } - - private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { - if (adsManager == null) { - // Drop events after release. - if (configuration.debugModeEnabled) { - Log.d( - TAG, - "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); - } - return; - } - - int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); - int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; - AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - adInfoByAdMediaInfo.put(adMediaInfo, adInfo); - if (configuration.debugModeEnabled) { - 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. - 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]; - adPlaybackState = - adPlaybackState.withAdCount( - adInfo.adGroupIndex, 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, /* adIndexInAdGroup= */ i); - } - } - - Uri adUri = Uri.parse(adMediaInfo.getUrl()); - adPlaybackState = - adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); - updateAdPlaybackState(); - } - - private void playAdInternal(AdMediaInfo adMediaInfo) { - if (configuration.debugModeEnabled) { - 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"); - } - - 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 = 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; - checkState(adMediaInfo.equals(imaAdMediaInfo)); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(adMediaInfo); - } - } - if (!checkNotNull(player).getPlayWhenReady()) { - checkNotNull(adsManager).pause(); - } - } - - private void pauseAdInternal(AdMediaInfo adMediaInfo) { - if (configuration.debugModeEnabled) { - Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop event after release. - return; - } - if (imaAdState == IMA_AD_STATE_NONE) { - // 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; - } - checkState(adMediaInfo.equals(imaAdMediaInfo)); - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(adMediaInfo); - } - } - - private void stopAdInternal(AdMediaInfo adMediaInfo) { - if (configuration.debugModeEnabled) { - Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // 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; - } - checkNotNull(player); - imaAdState = IMA_AD_STATE_NONE; - stopUpdatingAdProgress(); - // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. - 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(); - if (!playingAd) { - imaAdMediaInfo = null; - imaAdInfo = null; - } - } - - private void handleAdGroupLoadError(Exception error) { - int adGroupIndex = getLoadingAdGroupIndex(); - if (adGroupIndex == C.INDEX_UNSET) { - Log.w(TAG, "Unable to determine ad group index for ad group load error", error); - return; - } - markAdGroupInErrorStateAndClearPendingContentPosition(adGroupIndex); - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex); - } - } - - private void markAdGroupInErrorStateAndClearPendingContentPosition(int adGroupIndex) { - // Update the ad playback state so all ads in the ad group are in the error state. - AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, 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 (configuration.debugModeEnabled) { - Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); - } - adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); - } - } - updateAdPlaybackState(); - // Clear any pending content position that triggered attempting to load the ad group. - pendingContentPositionMs = C.TIME_UNSET; - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - } - - private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Exception exception) { - if (configuration.debugModeEnabled) { - Log.d( - TAG, "Prepare error for ad " + adIndexInAdGroup + " in group " + adGroupIndex, exception); - } - if (adsManager == null) { - Log.w(TAG, "Ignoring ad prepare error after release"); - return; - } - if (imaAdState == IMA_AD_STATE_NONE) { - // Send IMA a content position at the ad group so that it will try to play it, at which point - // we can notify that it failed to load. - fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); - fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); - if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { - fakeContentProgressOffsetMs = contentDurationMs; - } - pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - } else { - AdMediaInfo adMediaInfo = 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(adMediaInfo); - } - } - playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(checkNotNull(adMediaInfo)); - } - } - adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); - updateAdPlaybackState(); - } - - private void ensureSentContentCompleteIfAtEndOfStream() { - if (!sentContentComplete - && contentDurationMs != C.TIME_UNSET - && pendingContentPositionMs == C.TIME_UNSET - && getContentPeriodPositionMs(checkNotNull(player), timeline, period) - + THRESHOLD_END_OF_CONTENT_MS - >= contentDurationMs) { - sendContentComplete(); - } - } - - private void sendContentComplete() { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onContentComplete(); - } - sentContentComplete = true; - if (configuration.debugModeEnabled) { - 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) { - eventListener.onAdPlaybackState(adPlaybackState); - } - } - - private void maybeNotifyPendingAdLoadError() { - if (pendingAdLoadError != null && eventListener != null) { - eventListener.onAdLoadError(pendingAdLoadError, adTagDataSpec); - pendingAdLoadError = null; - } - } - - private void maybeNotifyInternalError(String name, Exception cause) { - 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. - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } - updateAdPlaybackState(); - if (eventListener != null) { - eventListener.onAdLoadError( - AdLoadException.createForUnexpected(new RuntimeException(message, cause)), adTagDataSpec); - } - } - - private int getAdGroupIndexForAdPod(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. - return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset()); - } - - /** - * 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() { - if (player == null) { - return C.INDEX_UNSET; - } - long playerPositionUs = C.msToUs(getContentPeriodPositionMs(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 int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) { - // We receive initial 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). - float cuePointTimeSecondsFloat = (float) cuePointTimeSeconds; - long adPodTimeUs = Math.round((double) cuePointTimeSecondsFloat * C.MICROS_PER_SECOND); - for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { - long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex]; - if (adGroupTimeUs != C.TIME_END_OF_SOURCE - && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { - return adGroupIndex; - } - } - 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 long getContentPeriodPositionMs( - Player player, Timeline timeline, Timeline.Period period) { - long contentWindowPositionMs = player.getContentPosition(); - return contentWindowPositionMs - - (timeline.isEmpty() - ? 0 - : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); - } - - private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { - int count = adGroupTimesUs.length; - if (count == 1) { - return adGroupTimesUs[0] != 0 && adGroupTimesUs[0] != C.TIME_END_OF_SOURCE; - } else if (count == 2) { - return adGroupTimesUs[0] != 0 || adGroupTimesUs[1] != C.TIME_END_OF_SOURCE; - } else { - // There's at least one midroll ad group, as adGroupTimesUs is never empty. - return true; - } - } - - private void destroyAdsManager() { - if (adsManager != null) { - adsManager.removeAdErrorListener(componentListener); - if (configuration.applicationAdErrorListener != null) { - adsManager.removeAdErrorListener(configuration.applicationAdErrorListener); - } - adsManager.removeAdEventListener(componentListener); - if (configuration.applicationAdEventListener != null) { - adsManager.removeAdEventListener(configuration.applicationAdEventListener); - } - adsManager.destroy(); - adsManager = null; - } - } - - private final class ComponentListener - implements AdsLoadedListener, - ContentProgressProvider, - AdEventListener, - AdErrorListener, - VideoAdPlayer { - - // 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); - if (configuration.applicationAdErrorListener != null) { - adsManager.addAdErrorListener(configuration.applicationAdErrorListener); - } - adsManager.addAdEventListener(this); - if (configuration.applicationAdEventListener != null) { - adsManager.addAdEventListener(configuration.applicationAdEventListener); - } - if (player != null) { - // If a player is attached already, start playback immediately. - try { - adPlaybackState = - ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); - hasAdPlaybackState = true; - updateAdPlaybackState(); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdsManagerLoaded", e); - } - } - } - - // ContentProgressProvider implementation. - - @Override - public VideoProgressUpdate getContentProgress() { - VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); - if (configuration.debugModeEnabled) { - Log.d( - TAG, - "Content progress: " + ImaUtil.getStringForVideoProgressUpdate(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; - } - - // AdEvent.AdEventListener implementation. - - @Override - public void onAdEvent(AdEvent adEvent) { - AdEventType adEventType = adEvent.getType(); - if (configuration.debugModeEnabled && adEventType != AdEventType.AD_PROGRESS) { - Log.d(TAG, "onAdEvent: " + adEventType); - } - try { - handleAdEvent(adEvent); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdEvent", e); - } - } - - // AdErrorEvent.AdErrorListener implementation. - - @Override - public void onAdError(AdErrorEvent adErrorEvent) { - AdError error = adErrorEvent.getError(); - if (configuration.debugModeEnabled) { - 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 (ImaUtil.isAdGroupLoadError(error)) { - try { - handleAdGroupLoadError(error); - } catch (RuntimeException e) { - maybeNotifyInternalError("onAdError", e); - } - } - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAllAds(error); - } - maybeNotifyPendingAdLoadError(); - } - - // VideoAdPlayer implementation. - - @Override - public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.add(videoAdPlayerCallback); - } - - @Override - public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.remove(videoAdPlayerCallback); - } - - @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 { - loadAdInternal(adMediaInfo, adPodInfo); - } catch (RuntimeException e) { - maybeNotifyInternalError("loadAd", e); - } - } - - @Override - public void playAd(AdMediaInfo adMediaInfo) { - try { - playAdInternal(adMediaInfo); - } catch (RuntimeException e) { - maybeNotifyInternalError("playAd", e); - } - } - - @Override - public void pauseAd(AdMediaInfo adMediaInfo) { - try { - pauseAdInternal(adMediaInfo); - } catch (RuntimeException e) { - maybeNotifyInternalError("pauseAd", e); - } - } - - @Override - public void stopAd(AdMediaInfo adMediaInfo) { - try { - stopAdInternal(adMediaInfo); - } catch (RuntimeException e) { - maybeNotifyInternalError("stopAd", e); - } - } - - @Override - public void release() { - // Do nothing. - } - } - - // 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 + ')'; + if (adTagLoader != null) { + adTagLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception); } } 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 d64f6c4b67a..5532b1c7ed6 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 @@ -172,6 +172,7 @@ public void teardown() { public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); + imaAdsLoader.start(adsLoaderListener, adViewProvider); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } From c1dc802050bc56457633dc1d9c93d2913ee0036c Mon Sep 17 00:00:00 2001 From: olly Date: Sun, 1 Nov 2020 19:51:56 +0000 Subject: [PATCH 227/693] Make defaultLicenseUrl optional Some content types always provide the license URL in the media. The PlayReady example in the demo app doesn't provide a default license URL for this reason, as an example. #minor-release PiperOrigin-RevId: 340125784 --- .../google/android/exoplayer2/MediaItem.java | 17 +++++----- .../exoplayer2/drm/HttpMediaDrmCallback.java | 32 ++++++++++++++----- .../source/MediaSourceDrmHelper.java | 5 ++- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java index 678822b7d28..14c1d6d1e75 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -231,7 +231,7 @@ public Builder setClipStartsAtKeyFrame(boolean startsAtKeyFrame) { } /** - * Sets the optional DRM license server URI. If this URI is set, the {@link + * Sets the optional default DRM license server URI. If this URI is set, the {@link * DrmConfiguration#uuid} needs to be specified as well. * *

    If {@link #setUri} is passed a non-null {@code uri}, the DRM license server URI is used to @@ -243,7 +243,7 @@ public Builder setDrmLicenseUri(@Nullable Uri licenseUri) { } /** - * Sets the optional DRM license server URI. If this URI is set, the {@link + * Sets the optional default DRM license server URI. If this URI is set, the {@link * DrmConfiguration#uuid} needs to be specified as well. * *

    If {@link #setUri} is passed a non-null {@code uri}, the DRM license server URI is used to @@ -294,8 +294,8 @@ public Builder setDrmMultiSession(boolean multiSession) { } /** - * Sets whether to use the DRM license server URI of the media item for key requests that - * include their own DRM license server URI. + * Sets whether to force use the default DRM license server URI even if the media specifies its + * own DRM license server URI. * *

    If {@link #setUri} is passed a non-null {@code uri}, the DRM force default license flag is * used to create a {@link PlaybackProperties} object. Otherwise it will be ignored. @@ -568,8 +568,8 @@ public static final class DrmConfiguration { public final UUID uuid; /** - * Optional DRM license server {@link Uri}. If {@code null} then the DRM license server must be - * specified by the media. + * Optional default DRM license server {@link Uri}. If {@code null} then the DRM license server + * must be specified by the media. */ @Nullable public final Uri licenseUri; @@ -586,8 +586,8 @@ public static final class DrmConfiguration { public final boolean playClearContentWithoutKey; /** - * Sets whether to use the DRM license server URI of the media item for key requests that - * include their own DRM license server URI. + * Whether to force use of {@link #licenseUri} even if the media specifies its own DRM license + * server URI. */ public final boolean forceDefaultLicenseUri; @@ -605,6 +605,7 @@ private DrmConfiguration( boolean playClearContentWithoutKey, List drmSessionForClearTypes, @Nullable byte[] keySetId) { + Assertions.checkArgument(!(forceDefaultLicenseUri && licenseUri == null)); this.uuid = uuid; this.licenseUri = licenseUri; this.requestHeaders = requestHeaders; 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 7ab90b023ee..6a20cf7bdaf 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.drm; +import android.net.Uri; import android.text.TextUtils; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -27,6 +28,7 @@ import com.google.android.exoplayer2.upstream.StatsDataSource; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableMap; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -39,29 +41,35 @@ public final class HttpMediaDrmCallback implements MediaDrmCallback { private static final int MAX_MANUAL_REDIRECTS = 5; private final HttpDataSource.Factory dataSourceFactory; - private final String defaultLicenseUrl; + @Nullable private final String defaultLicenseUrl; private final boolean forceDefaultLicenseUrl; private final Map keyRequestProperties; /** * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify - * their own license URL. + * their own license URL. May be {@code null} if it's known that all key requests will specify + * their own URLs. * @param dataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. */ - public HttpMediaDrmCallback(String defaultLicenseUrl, HttpDataSource.Factory dataSourceFactory) { + public HttpMediaDrmCallback( + @Nullable String defaultLicenseUrl, HttpDataSource.Factory dataSourceFactory) { this(defaultLicenseUrl, /* forceDefaultLicenseUrl= */ false, dataSourceFactory); } /** * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify - * their own license URL, or for all key requests if {@code forceDefaultLicenseUrl} is - * set to true. - * @param forceDefaultLicenseUrl Whether to use {@code defaultLicenseUrl} for key requests that - * include their own license URL. + * their own license URL, or for all key requests if {@code forceDefaultLicenseUrl} is set to + * true. May be {@code null} if {@code forceDefaultLicenseUrl} is {@code false} and if it's + * known that all key requests will specify their own URLs. + * @param forceDefaultLicenseUrl Whether to force use of {@code defaultLicenseUrl} for key + * requests that include their own license URL. * @param dataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. */ - public HttpMediaDrmCallback(String defaultLicenseUrl, boolean forceDefaultLicenseUrl, + public HttpMediaDrmCallback( + @Nullable String defaultLicenseUrl, + boolean forceDefaultLicenseUrl, HttpDataSource.Factory dataSourceFactory) { + Assertions.checkArgument(!(forceDefaultLicenseUrl && TextUtils.isEmpty(defaultLicenseUrl))); this.dataSourceFactory = dataSourceFactory; this.defaultLicenseUrl = defaultLicenseUrl; this.forceDefaultLicenseUrl = forceDefaultLicenseUrl; @@ -121,6 +129,14 @@ public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws MediaDrmCa if (forceDefaultLicenseUrl || TextUtils.isEmpty(url)) { url = defaultLicenseUrl; } + if (TextUtils.isEmpty(url)) { + throw new MediaDrmCallbackException( + new DataSpec.Builder().setUri(Uri.EMPTY).build(), + Uri.EMPTY, + /* responseHeaders= */ ImmutableMap.of(), + /* bytesLoaded= */ 0, + /* cause= */ new IllegalStateException("No license URL")); + } Map requestProperties = new HashMap<>(); // Add standard request properties for supported schemes. String contentType = C.PLAYREADY_UUID.equals(uuid) ? "text/xml" diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java index 7859254401f..f4a7b89fc7c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java @@ -17,7 +17,6 @@ import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; import static com.google.android.exoplayer2.drm.DefaultDrmSessionManager.MODE_PLAYBACK; -import static com.google.android.exoplayer2.util.Util.castNonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.MediaItem; @@ -68,7 +67,7 @@ public DrmSessionManager create(MediaItem mediaItem) { Assertions.checkNotNull(mediaItem.playbackProperties); @Nullable MediaItem.DrmConfiguration drmConfiguration = mediaItem.playbackProperties.drmConfiguration; - if (drmConfiguration == null || drmConfiguration.licenseUri == null || Util.SDK_INT < 18) { + if (drmConfiguration == null || Util.SDK_INT < 18) { return DrmSessionManager.getDummyDrmSessionManager(); } HttpDataSource.Factory dataSourceFactory = @@ -77,7 +76,7 @@ public DrmSessionManager create(MediaItem mediaItem) { : new DefaultHttpDataSourceFactory(userAgent != null ? userAgent : DEFAULT_USER_AGENT); HttpMediaDrmCallback httpDrmCallback = new HttpMediaDrmCallback( - castNonNull(drmConfiguration.licenseUri).toString(), + drmConfiguration.licenseUri == null ? null : drmConfiguration.licenseUri.toString(), drmConfiguration.forceDefaultLicenseUri, dataSourceFactory); for (Map.Entry entry : drmConfiguration.requestHeaders.entrySet()) { From 4289112947dfac41c1ea45f14c01153bf3fe2eab Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 2 Nov 2020 11:06:17 +0000 Subject: [PATCH 228/693] Fix buildForAdsResponse PiperOrigin-RevId: 340198099 --- RELEASENOTES.md | 1 + .../com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ecee8de2f01..3746e3650d9 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -37,6 +37,7 @@ ([#7832](https://github.com/google/ExoPlayer/issues/7832)). * Fix a bug that caused multiple ads in an ad pod to be skipped when one ad in the ad pod was skipped. + * Fix passing an ads response to the `ImaAdsLoader` builder. ### 2.12.1 (2020-10-23) ### 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 852ab4e96ac..2bd8e0c03d4 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 @@ -458,7 +458,8 @@ private ImaAdsLoader( adTagUri != null ? new DataSpec(adTagUri) : adsResponse != null - ? new DataSpec(Util.getDataUriForString(adsResponse, "text/xml")) + ? new DataSpec( + Util.getDataUriForString(/* mimeType= */ "text/xml", /* data= */ adsResponse)) : null; adTagDataSpec = EMPTY_AD_TAG_DATA_SPEC; supportedMimeTypes = ImmutableList.of(); From be1fd23666f7089271717d972be5ce336c21a3a3 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 2 Nov 2020 11:42:00 +0000 Subject: [PATCH 229/693] Rename setOperatingRate to setPlaybackSpeed This avoids confusion that currently exists between "operating rate" and "codec operating rate", which are different. It also tightens the requirement of the value being passed to be more than a "hint". It's already being used as more than a hint for setting the Surface frame rate. PiperOrigin-RevId: 340201829 --- .../exoplayer2/ExoPlayerImplInternal.java | 2 +- .../google/android/exoplayer2/Renderer.java | 11 ++++---- .../audio/MediaCodecAudioRenderer.java | 4 +-- .../mediacodec/MediaCodecRenderer.java | 26 +++++++++---------- .../video/MediaCodecVideoRenderer.java | 10 +++---- 5 files changed, 26 insertions(+), 27 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 2ecbf3731bb..21b7635b401 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 @@ -2116,7 +2116,7 @@ private void handlePlaybackParameters( updateTrackSelectionPlaybackSpeed(playbackParameters.speed); for (Renderer renderer : renderers) { if (renderer != null) { - renderer.setOperatingRate(playbackParameters.speed); + renderer.setPlaybackSpeed(playbackParameters.speed); } } } 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 c7b527481a9..2fcaa833689 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 @@ -403,16 +403,15 @@ void replaceStream(Format[] formats, SampleStream stream, long startPositionUs, void resetPosition(long positionUs) throws ExoPlaybackException; /** - * Sets the operating rate of this renderer, where 1 is the default rate, 2 is twice the default - * rate, 0.5 is half the default rate and so on. The operating rate is a hint to the renderer of - * the speed at which playback will proceed, and may be used for resource planning. + * Indicates the player's speed to this renderer, where 1 is the default rate, 2 is twice the + * default rate, 0.5 is half the default rate and so on. * *

    The default implementation is a no-op. * - * @param operatingRate The operating rate. - * @throws ExoPlaybackException If an error occurs handling the operating rate. + * @param playbackSpeed The playback speed. + * @throws ExoPlaybackException If an error occurs handling the playback speed. */ - default void setOperatingRate(float operatingRate) throws ExoPlaybackException {} + default void setPlaybackSpeed(float playbackSpeed) throws ExoPlaybackException {} /** * Incrementally renders the {@link SampleStream}. 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 50e7723cd8a..40551737392 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 @@ -347,7 +347,7 @@ public MediaClock getMediaClock() { @Override protected float getCodecOperatingRateV23( - float operatingRate, Format format, Format[] streamFormats) { + float playbackSpeed, Format format, Format[] streamFormats) { // Use the highest known stream sample-rate up front, to avoid having to reconfigure the codec // should an adaptive switch to that stream occur. int maxSampleRate = -1; @@ -357,7 +357,7 @@ protected float getCodecOperatingRateV23( maxSampleRate = max(maxSampleRate, streamSampleRate); } } - return maxSampleRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxSampleRate * operatingRate); + return maxSampleRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxSampleRate * playbackSpeed); } @Override 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 b21d2152778..90ec0b616ff 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 @@ -304,7 +304,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { @Nullable private MediaCrypto mediaCrypto; private boolean mediaCryptoRequiresSecureDecoder; private long renderTimeLimitMs; - private float operatingRate; + private float playbackSpeed; @Nullable private MediaCodec codec; @Nullable private MediaCodecAdapter codecAdapter; @Nullable private Format codecInputFormat; @@ -381,7 +381,7 @@ public MediaCodecRenderer( formatQueue = new TimedValueQueue<>(); decodeOnlyPresentationTimestamps = new ArrayList<>(); outputBufferInfo = new MediaCodec.BufferInfo(); - operatingRate = 1f; + playbackSpeed = 1f; renderTimeLimitMs = C.TIME_UNSET; pendingOutputStreamStartPositionsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; @@ -678,8 +678,8 @@ protected void onPositionReset(long positionUs, boolean joining) throws ExoPlayb } @Override - public void setOperatingRate(float operatingRate) throws ExoPlaybackException { - this.operatingRate = operatingRate; + public void setPlaybackSpeed(float playbackSpeed) throws ExoPlaybackException { + this.playbackSpeed = playbackSpeed; if (codec != null && codecDrainAction != DRAIN_ACTION_REINITIALIZE && getState() != STATE_DISABLED) { @@ -1043,7 +1043,7 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce float codecOperatingRate = Util.SDK_INT < 23 ? CODEC_OPERATING_RATE_UNSET - : getCodecOperatingRateV23(operatingRate, inputFormat, getStreamFormats()); + : getCodecOperatingRateV23(playbackSpeed, inputFormat, getStreamFormats()); if (codecOperatingRate <= assumedMinimumCodecOperatingRate) { codecOperatingRate = CODEC_OPERATING_RATE_UNSET; } @@ -1582,9 +1582,9 @@ public boolean isReady() { && SystemClock.elapsedRealtime() < codecHotswapDeadlineMs)); } - /** Returns the renderer operating rate, as set by {@link #setOperatingRate}. */ - protected float getOperatingRate() { - return operatingRate; + /** Returns the playback speed, as set by {@link #setPlaybackSpeed}. */ + protected float getPlaybackSpeed() { + return playbackSpeed; } /** Returns the operating rate used by the current codec */ @@ -1593,19 +1593,19 @@ protected float getCodecOperatingRate() { } /** - * Returns the {@link MediaFormat#KEY_OPERATING_RATE} value for a given renderer operating rate, - * current {@link Format} and set of possible stream formats. + * Returns the {@link MediaFormat#KEY_OPERATING_RATE} value for a given playback speed, current + * {@link Format} and set of possible stream formats. * *

    The default implementation returns {@link #CODEC_OPERATING_RATE_UNSET}. * - * @param operatingRate The renderer operating rate. + * @param playbackSpeed The playback speed. * @param format The {@link Format} for which the codec is being configured. * @param streamFormats The possible stream formats. * @return The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if no codec operating * rate should be set. */ protected float getCodecOperatingRateV23( - float operatingRate, Format format, Format[] streamFormats) { + float playbackSpeed, Format format, Format[] streamFormats) { return CODEC_OPERATING_RATE_UNSET; } @@ -1620,7 +1620,7 @@ private void updateCodecOperatingRate() throws ExoPlaybackException { } float newCodecOperatingRate = - getCodecOperatingRateV23(operatingRate, codecInputFormat, getStreamFormats()); + getCodecOperatingRateV23(playbackSpeed, codecInputFormat, getStreamFormats()); if (codecOperatingRate == newCodecOperatingRate) { // No change. } else if (newCodecOperatingRate == CODEC_OPERATING_RATE_UNSET) { 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 eb0cd994eba..831ed477162 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 @@ -586,14 +586,14 @@ protected void resetCodecStateForFlush() { } @Override - public void setOperatingRate(float operatingRate) throws ExoPlaybackException { - super.setOperatingRate(operatingRate); + public void setPlaybackSpeed(float playbackSpeed) throws ExoPlaybackException { + super.setPlaybackSpeed(playbackSpeed); updateSurfaceFrameRate(/* isNewSurface= */ false); } @Override protected float getCodecOperatingRateV23( - float operatingRate, Format format, Format[] streamFormats) { + float playbackSpeed, Format format, Format[] streamFormats) { // Use the highest known stream frame-rate up front, to avoid having to reconfigure the codec // should an adaptive switch to that stream occur. float maxFrameRate = -1; @@ -603,7 +603,7 @@ protected float getCodecOperatingRateV23( maxFrameRate = max(maxFrameRate, streamFrameRate); } } - return maxFrameRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxFrameRate * operatingRate); + return maxFrameRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxFrameRate * playbackSpeed); } @Override @@ -1081,7 +1081,7 @@ private void updateSurfaceFrameRate(boolean isNewSurface) { return; } boolean shouldSetFrameRate = getState() == STATE_STARTED && currentFrameRate != Format.NO_VALUE; - float surfaceFrameRate = shouldSetFrameRate ? currentFrameRate * getOperatingRate() : 0; + float surfaceFrameRate = shouldSetFrameRate ? currentFrameRate * getPlaybackSpeed() : 0; // We always set the frame-rate if we have a new surface, since we have no way of knowing what // it might have been set to previously. if (this.surfaceFrameRate == surfaceFrameRate && !isNewSurface) { From 1d12d03283e6aaffc9afc9d81bc43db98600d172 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 2 Nov 2020 12:00:37 +0000 Subject: [PATCH 230/693] Clarify DRM error deferral comments - I don't think the session recovering later would work, because the codec will be configured not to use it. - I'm not sure session recovery makes sense in general, and our implementations do not do this. Document it as a terminal state for now. PiperOrigin-RevId: 340204194 --- .../android/exoplayer2/audio/DecoderAudioRenderer.java | 4 ++-- .../java/com/google/android/exoplayer2/drm/DrmSession.java | 5 ++--- .../android/exoplayer2/mediacodec/MediaCodecRenderer.java | 4 ++-- .../android/exoplayer2/video/DecoderVideoRenderer.java | 4 ++-- 4 files changed, 8 insertions(+), 9 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 cff2cd5a570..5495c3d97bf 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 @@ -603,8 +603,8 @@ private void maybeInitDecoder() throws ExoPlaybackException { if (mediaCrypto == null) { DrmSessionException drmError = decoderDrmSession.getError(); if (drmError != null) { - // Continue for now. We may be able to avoid failure if the session recovers, or if a new - // input format causes the session to be replaced before it's used. + // Continue for now. We may be able to avoid failure if a new input format causes the + // session to be replaced without it having been used. } else { // The drm session isn't open yet. return; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java index 97bb4b3dd16..1706afcb350 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java @@ -66,12 +66,11 @@ public DrmSessionException(Throwable cause) { @Retention(RetentionPolicy.SOURCE) @IntDef({STATE_RELEASED, STATE_ERROR, STATE_OPENING, STATE_OPENED, STATE_OPENED_WITH_KEYS}) @interface State {} - /** - * The session has been released. - */ + /** The session has been released. This is a terminal state. */ int STATE_RELEASED = 0; /** * The session has encountered an error. {@link #getError()} can be used to retrieve the cause. + * This is a terminal state. */ int STATE_ERROR = 1; /** 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 90ec0b616ff..6648ae5fb28 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 @@ -499,8 +499,8 @@ protected final void maybeInitCodecOrBypass() throws ExoPlaybackException { if (sessionMediaCrypto == null) { @Nullable DrmSessionException drmError = codecDrmSession.getError(); if (drmError != null) { - // Continue for now. We may be able to avoid failure if the session recovers, or if a - // new input format causes the session to be replaced before it's used. + // Continue for now. We may be able to avoid failure if a new input format causes the + // session to be replaced without it having been used. } else { // The drm session isn't open yet. return; 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 1e4aafa71c8..c349813bdb0 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 @@ -661,8 +661,8 @@ private void maybeInitDecoder() throws ExoPlaybackException { if (mediaCrypto == null) { DrmSessionException drmError = decoderDrmSession.getError(); if (drmError != null) { - // Continue for now. We may be able to avoid failure if the session recovers, or if a new - // input format causes the session to be replaced before it's used. + // Continue for now. We may be able to avoid failure if a new input format causes the + // session to be replaced without it having been used. } else { // The drm session isn't open yet. return; From 8e9c5c67a3388c3ffb34342aa3f80453d44d2f7d Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 2 Nov 2020 17:17:17 +0000 Subject: [PATCH 231/693] Migrate Tx3gDecoderTest to Guava and SpannedSubject #minor-release PiperOrigin-RevId: 340249019 --- .../exoplayer2/text/tx3g/Tx3gDecoderTest.java | 159 ++++++++---------- 1 file changed, 70 insertions(+), 89 deletions(-) diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java index 58b9a853e70..ca84f901d86 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java @@ -15,24 +15,18 @@ */ package com.google.android.exoplayer2.text.tx3g; +import static com.google.android.exoplayer2.testutil.truth.SpannedSubject.assertThat; import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; import android.graphics.Color; -import android.graphics.Typeface; import android.text.SpannedString; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.text.style.TypefaceSpan; -import android.text.style.UnderlineSpan; import androidx.test.core.app.ApplicationProvider; 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.text.Cue; import com.google.android.exoplayer2.text.Subtitle; -import com.google.android.exoplayer2.text.SubtitleDecoderException; -import java.io.IOException; +import com.google.common.collect.ImmutableList; import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -57,197 +51,184 @@ public final class Tx3gDecoderTest { "media/tx3g/initialization_all_defaults"; @Test - public void decodeNoSubtitle() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeNoSubtitle() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), NO_SUBTITLE); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertThat(subtitle.getCues(0)).isEmpty(); } @Test - public void decodeJustText() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeJustText() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SAMPLE_JUST_TEXT); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("CC Test"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(0); + assertThat(text).hasNoSpans(); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } @Test - public void decodeWithStyl() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeWithStyl() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SAMPLE_WITH_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("CC Test"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(3); - StyleSpan styleSpan = findSpan(text, 0, 6, StyleSpan.class); - assertThat(styleSpan.getStyle()).isEqualTo(Typeface.BOLD_ITALIC); - findSpan(text, 0, 6, UnderlineSpan.class); - ForegroundColorSpan colorSpan = findSpan(text, 0, 6, ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.GREEN); + assertThat(text).hasBoldItalicSpanBetween(0, 6); + assertThat(text).hasUnderlineSpanBetween(0, 6); + assertThat(text).hasForegroundColorSpanBetween(0, 6).withColor(Color.GREEN); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } @Test - public void decodeWithStylAllDefaults() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeWithStylAllDefaults() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), SAMPLE_WITH_STYL_ALL_DEFAULTS); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("CC Test"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(0); + assertThat(text).hasNoSpans(); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } @Test - public void decodeUtf16BeNoStyl() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeUtf16BeNoStyl() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SAMPLE_UTF16_BE_NO_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("你好"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(0); + assertThat(text).hasNoSpans(); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } @Test - public void decodeUtf16LeNoStyl() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeUtf16LeNoStyl() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SAMPLE_UTF16_LE_NO_STYL); Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); + assertThat(text.toString()).isEqualTo("你好"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(0); + assertThat(text).hasNoSpans(); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } @Test - public void decodeWithMultipleStyl() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeWithMultipleStyl() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), SAMPLE_WITH_MULTIPLE_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("Line 2\nLine 3"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(4); - StyleSpan styleSpan = findSpan(text, 0, 5, StyleSpan.class); - assertThat(styleSpan.getStyle()).isEqualTo(Typeface.ITALIC); - findSpan(text, 7, 12, UnderlineSpan.class); - ForegroundColorSpan colorSpan = findSpan(text, 0, 5, ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.GREEN); - colorSpan = findSpan(text, 7, 12, ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.GREEN); + assertThat(text).hasItalicSpanBetween(0, 5); + assertThat(text).hasUnderlineSpanBetween(7, 12); + assertThat(text).hasForegroundColorSpanBetween(0, 5).withColor(Color.GREEN); + assertThat(text).hasForegroundColorSpanBetween(7, 12).withColor(Color.GREEN); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } @Test - public void decodeWithOtherExtension() throws IOException, SubtitleDecoderException { - Tx3gDecoder decoder = new Tx3gDecoder(Collections.emptyList()); + public void decodeWithOtherExtension() throws Exception { + Tx3gDecoder decoder = new Tx3gDecoder(ImmutableList.of()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), SAMPLE_WITH_OTHER_EXTENSION); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("CC Test"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(2); - StyleSpan styleSpan = findSpan(text, 0, 6, StyleSpan.class); - assertThat(styleSpan.getStyle()).isEqualTo(Typeface.BOLD); - ForegroundColorSpan colorSpan = findSpan(text, 0, 6, ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.GREEN); + assertThat(text).hasBoldSpanBetween(0, 6); + assertThat(text).hasForegroundColorSpanBetween(0, 6).withColor(Color.GREEN); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } @Test - public void initializationDecodeWithStyl() throws IOException, SubtitleDecoderException { + public void initializationDecodeWithStyl() throws Exception { byte[] initBytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), INITIALIZATION); Tx3gDecoder decoder = new Tx3gDecoder(Collections.singletonList(initBytes)); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SAMPLE_WITH_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("CC Test"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(5); - StyleSpan styleSpan = findSpan(text, 0, text.length(), StyleSpan.class); - assertThat(styleSpan.getStyle()).isEqualTo(Typeface.BOLD_ITALIC); - findSpan(text, 0, text.length(), UnderlineSpan.class); - TypefaceSpan typefaceSpan = findSpan(text, 0, text.length(), TypefaceSpan.class); - assertThat(typefaceSpan.getFamily()).isEqualTo(C.SERIF_NAME); - ForegroundColorSpan colorSpan = findSpan(text, 0, text.length(), ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.RED); - colorSpan = findSpan(text, 0, 6, ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.GREEN); + assertThat(text).hasBoldItalicSpanBetween(0, 7); + assertThat(text).hasUnderlineSpanBetween(0, 7); + assertThat(text).hasTypefaceSpanBetween(0, 7).withFamily(C.SERIF_NAME); + // TODO(internal b/171984212): Fix Tx3gDecoder to avoid overlapping spans of the same type. + assertThat(text).hasForegroundColorSpanBetween(0, 7).withColor(Color.RED); + assertThat(text).hasForegroundColorSpanBetween(0, 6).withColor(Color.GREEN); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.1f); } @Test - public void initializationDecodeWithTbox() throws IOException, SubtitleDecoderException { + public void initializationDecodeWithTbox() throws Exception { byte[] initBytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), INITIALIZATION); Tx3gDecoder decoder = new Tx3gDecoder(Collections.singletonList(initBytes)); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SAMPLE_WITH_TBOX); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("CC Test"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(4); - StyleSpan styleSpan = findSpan(text, 0, text.length(), StyleSpan.class); - assertThat(styleSpan.getStyle()).isEqualTo(Typeface.BOLD_ITALIC); - findSpan(text, 0, text.length(), UnderlineSpan.class); - TypefaceSpan typefaceSpan = findSpan(text, 0, text.length(), TypefaceSpan.class); - assertThat(typefaceSpan.getFamily()).isEqualTo(C.SERIF_NAME); - ForegroundColorSpan colorSpan = findSpan(text, 0, text.length(), ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.RED); + assertThat(text).hasBoldItalicSpanBetween(0, 7); + assertThat(text).hasUnderlineSpanBetween(0, 7); + assertThat(text).hasTypefaceSpanBetween(0, 7).withFamily(C.SERIF_NAME); + assertThat(text).hasForegroundColorSpanBetween(0, 7).withColor(Color.RED); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.1875f); } @Test - public void initializationAllDefaultsDecodeWithStyl() - throws IOException, SubtitleDecoderException { + public void initializationAllDefaultsDecodeWithStyl() throws Exception { byte[] initBytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), INITIALIZATION_ALL_DEFAULTS); Tx3gDecoder decoder = new Tx3gDecoder(Collections.singletonList(initBytes)); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), SAMPLE_WITH_STYL); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + SpannedString text = new SpannedString(subtitle.getCues(0).get(0).text); assertThat(text.toString()).isEqualTo("CC Test"); - assertThat(text.getSpans(0, text.length(), Object.class)).hasLength(3); - StyleSpan styleSpan = findSpan(text, 0, 6, StyleSpan.class); - assertThat(styleSpan.getStyle()).isEqualTo(Typeface.BOLD_ITALIC); - findSpan(text, 0, 6, UnderlineSpan.class); - ForegroundColorSpan colorSpan = findSpan(text, 0, 6, ForegroundColorSpan.class); - assertThat(colorSpan.getForegroundColor()).isEqualTo(Color.GREEN); + assertThat(text).hasBoldItalicSpanBetween(0, 6); + assertThat(text).hasUnderlineSpanBetween(0, 6); + assertThat(text).hasForegroundColorSpanBetween(0, 6).withColor(Color.GREEN); assertFractionalLinePosition(subtitle.getCues(0).get(0), 0.85f); } - private static T findSpan( - SpannedString testObject, int expectedStart, int expectedEnd, Class expectedType) { - T[] spans = testObject.getSpans(0, testObject.length(), expectedType); - for (T span : spans) { - if (testObject.getSpanStart(span) == expectedStart - && testObject.getSpanEnd(span) == expectedEnd) { - return span; - } - } - fail("Span not found."); - return null; - } - private static void assertFractionalLinePosition(Cue cue, float expectedFraction) { assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION); assertThat(cue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_START); - assertThat(Math.abs(expectedFraction - cue.line) < 1e-6).isTrue(); + assertThat(cue.line).isWithin(1e-6f).of(expectedFraction); } } From 42a2b9230ad35f101042d2b3800f422819f0bf96 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 2 Nov 2020 18:09:59 +0000 Subject: [PATCH 232/693] HLS: populate targetLiveOffset in MediaItem from server control Issue: #5011 PiperOrigin-RevId: 340260636 --- .../exoplayer2/source/hls/HlsMediaSource.java | 97 ++++- .../source/hls/HlsMediaSourceTest.java | 377 ++++++++++++++++++ 2 files changed, 459 insertions(+), 15 deletions(-) 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 8d3b633dc72..5d1c37e5fea 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 @@ -16,12 +16,13 @@ package com.google.android.exoplayer2.source.hls; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static java.lang.Math.max; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.net.Uri; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.MediaItem; @@ -52,6 +53,7 @@ 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; @@ -87,7 +89,6 @@ public final class HlsMediaSource extends BaseMediaSource 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 { @@ -105,6 +106,7 @@ public static final class Factory implements MediaSourceFactory { private boolean useSessionKeys; private List streamKeys; @Nullable private Object tag; + private long elapsedRealTimeOffsetMs; /** * Creates a new factory for {@link HlsMediaSource}s. @@ -133,6 +135,7 @@ public Factory(HlsDataSourceFactory hlsDataSourceFactory) { compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); metadataType = METADATA_TYPE_ID3; streamKeys = Collections.emptyList(); + elapsedRealTimeOffsetMs = C.TIME_UNSET; } /** @@ -316,6 +319,20 @@ public Factory setStreamKeys(@Nullable List streamKeys) { return this; } + /** + * Sets the offset between {@link SystemClock#elapsedRealtime()} and the time since the Unix + * epoch. By default, is it set to {@link C#TIME_UNSET}. + * + * @param elapsedRealTimeOffsetMs The offset between {@link SystemClock#elapsedRealtime()} and + * the time since the Unix epoch, in milliseconds. + * @return This factory, for convenience. + */ + @VisibleForTesting + /* package */ Factory setElapsedRealTimeOffsetMs(long elapsedRealTimeOffsetMs) { + this.elapsedRealTimeOffsetMs = elapsedRealTimeOffsetMs; + return this; + } + /** @deprecated Use {@link #createMediaSource(MediaItem)} instead. */ @SuppressWarnings("deprecation") @Deprecated @@ -364,6 +381,7 @@ public HlsMediaSource createMediaSource(MediaItem mediaItem) { loadErrorHandlingPolicy, playlistTrackerFactory.createTracker( hlsDataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory), + elapsedRealTimeOffsetMs, allowChunklessPreparation, metadataType, useSessionKeys); @@ -376,7 +394,6 @@ public int[] getSupportedTypes() { } private final HlsExtractorFactory extractorFactory; - private final MediaItem mediaItem; private final MediaItem.PlaybackProperties playbackProperties; private final HlsDataSourceFactory dataSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; @@ -386,7 +403,9 @@ public int[] getSupportedTypes() { private final @MetadataType int metadataType; private final boolean useSessionKeys; private final HlsPlaylistTracker playlistTracker; + private final long elapsedRealTimeOffsetMs; + private MediaItem mediaItem; @Nullable private TransferListener mediaTransferListener; private HlsMediaSource( @@ -397,6 +416,7 @@ private HlsMediaSource( DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, HlsPlaylistTracker playlistTracker, + long elapsedRealTimeOffsetMs, boolean allowChunklessPreparation, @MetadataType int metadataType, boolean useSessionKeys) { @@ -408,6 +428,7 @@ private HlsMediaSource( this.drmSessionManager = drmSessionManager; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.playlistTracker = playlistTracker; + this.elapsedRealTimeOffsetMs = elapsedRealTimeOffsetMs; this.allowChunklessPreparation = allowChunklessPreparation; this.metadataType = metadataType; this.useSessionKeys = useSessionKeys; @@ -491,25 +512,28 @@ public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { HlsManifest manifest = new HlsManifest(checkNotNull(playlistTracker.getMasterPlaylist()), playlist); if (playlistTracker.isLive()) { + long liveEdgeOffsetUs = getLiveEdgeOffsetUs(playlist); + long targetLiveOffsetUs = + mediaItem.liveConfiguration.targetLiveOffsetMs != C.TIME_UNSET + ? C.msToUs(mediaItem.liveConfiguration.targetLiveOffsetMs) + : getTargetLiveOffsetUs(playlist, liveEdgeOffsetUs); + // Ensure target live offset is within the live window and greater than the live edge offset. + targetLiveOffsetUs = + Util.constrainValue( + targetLiveOffsetUs, liveEdgeOffsetUs, playlist.durationUs + liveEdgeOffsetUs); + maybeUpdateMediaItem(targetLiveOffsetUs); + long offsetFromInitialStartTimeUs = playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); long periodDurationUs = playlist.hasEndTag ? offsetFromInitialStartTimeUs + playlist.durationUs : C.TIME_UNSET; List segments = playlist.segments; - if (windowDefaultStartPositionUs == C.TIME_UNSET) { + if (!segments.isEmpty()) { + windowDefaultStartPositionUs = getWindowDefaultStartPosition(playlist, liveEdgeOffsetUs); + } else if (windowDefaultStartPositionUs == C.TIME_UNSET) { windowDefaultStartPositionUs = 0; - if (!segments.isEmpty()) { - int defaultStartSegmentIndex = max(0, segments.size() - 3); - // We attempt to set the default start position to be at least twice the target duration - // behind the live edge. - long minStartPositionUs = playlist.durationUs - playlist.targetDurationUs * 2; - while (defaultStartSegmentIndex > 0 - && segments.get(defaultStartSegmentIndex).relativeStartTimeUs > minStartPositionUs) { - defaultStartSegmentIndex--; - } - windowDefaultStartPositionUs = segments.get(defaultStartSegmentIndex).relativeStartTimeUs; - } } + timeline = new SinglePeriodTimeline( presentationStartTimeMs, @@ -545,4 +569,47 @@ public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { } refreshSourceInfo(timeline); } + + private long getLiveEdgeOffsetUs(HlsMediaPlaylist playlist) { + return playlist.hasProgramDateTime + ? C.msToUs(Util.getNowUnixTimeMs(elapsedRealTimeOffsetMs)) - playlist.getEndTimeUs() + : 0; + } + + private long getWindowDefaultStartPosition(HlsMediaPlaylist playlist, long liveEdgeOffsetUs) { + List segments = playlist.segments; + int segmentIndex = segments.size() - 1; + long minStartPositionUs = + playlist.durationUs + + liveEdgeOffsetUs + - C.msToUs(mediaItem.liveConfiguration.targetLiveOffsetMs); + while (segmentIndex > 0 + && segments.get(segmentIndex).relativeStartTimeUs > minStartPositionUs) { + segmentIndex--; + } + return segments.get(segmentIndex).relativeStartTimeUs; + } + + private void maybeUpdateMediaItem(long targetLiveOffsetUs) { + long targetLiveOffsetMs = C.usToMs(targetLiveOffsetUs); + if (targetLiveOffsetMs != mediaItem.liveConfiguration.targetLiveOffsetMs) { + mediaItem = mediaItem.buildUpon().setLiveTargetOffsetMs(targetLiveOffsetMs).build(); + } + } + + private static long getTargetLiveOffsetUs(HlsMediaPlaylist playlist, long liveEdgeOffsetUs) { + HlsMediaPlaylist.ServerControl serverControl = playlist.serverControl; + // Select part hold back only if the playlist has a part target duration. + long offsetToEndOfPlaylistUs; + if (serverControl.partHoldBackUs != C.TIME_UNSET + && playlist.partTargetDurationUs != C.TIME_UNSET) { + offsetToEndOfPlaylistUs = serverControl.partHoldBackUs; + } else if (serverControl.holdBackUs != C.TIME_UNSET) { + offsetToEndOfPlaylistUs = serverControl.holdBackUs; + } else { + // Fallback, see RFC 8216, Section 4.4.3.8. + offsetToEndOfPlaylistUs = 3 * playlist.targetDurationUs; + } + return offsetToEndOfPlaylistUs + liveEdgeOffsetUs; + } } 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 index fd8d90c8b4b..e904425d3eb 100644 --- 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 @@ -15,15 +15,33 @@ */ package com.google.android.exoplayer2.source.hls; +import static com.google.android.exoplayer2.robolectric.RobolectricUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.mock; +import android.net.Uri; +import android.os.SystemClock; 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.ParserException; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.testutil.FakeDataSet; +import com.google.android.exoplayer2.testutil.FakeDataSource; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.util.Util; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; import org.junit.runner.RunWith; @@ -132,4 +150,363 @@ public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotOverrideMediaIte assertThat(hlsMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); assertThat(hlsMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); } + + @Test + public void loadPlaylist_noTargetLiveOffsetDefined_fallbackToThreeTargetDuration() + throws TimeoutException, ParserException { + String playlistUri = "fake://foo.bar/media0/playlist.m3u8"; + // The playlist has a duration of 16 seconds but not hold back or part hold back. + String playlist = + "#EXTM3U\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.0+00:00\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence1.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence2.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence3.ts\n" + + "#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24"; + // The playlist finishes 1 second before the the current time, therefore there's a live edge + // offset of 1 second. + SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:17.0+00:00")); + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + MediaItem mediaItem = MediaItem.fromUri(playlistUri); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + Timeline.Window window = timeline.getWindow(0, new Timeline.Window()); + // The target live offset is picked from target duration (3 * 4 = 12 seconds) and then expressed + // in relation to the live edge (12 + 1 seconds). + assertThat(window.mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(13000); + assertThat(window.defaultPositionUs).isEqualTo(4000000); + } + + @Test + public void loadPlaylist_holdBackInPlaylist_targetLiveOffsetFromHoldBack() + throws TimeoutException, ParserException { + String playlistUri = "fake://foo.bar/media0/playlist.m3u8"; + // The playlist has a duration of 16 seconds and a hold back of 12 seconds. + String playlist = + "#EXTM3U\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.0+00:00\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence1.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence2.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence3.ts\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK=12"; + // The playlist finishes 1 second before the the current time, therefore there's a live edge + // offset of 1 second. + SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:17.0+00:00")); + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + MediaItem mediaItem = MediaItem.fromUri(playlistUri); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + Timeline.Window window = timeline.getWindow(0, new Timeline.Window()); + // The target live offset is picked from hold back and then expressed in relation to the live + // edge (+1 seconds). + assertThat(window.mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(13000); + assertThat(window.defaultPositionUs).isEqualTo(4000000); + } + + @Test + public void + loadPlaylist_partHoldBackWithoutPartInformationInPlaylist_targetLiveOffsetFromHoldBack() + throws TimeoutException, ParserException { + String playlistUri = "fake://foo.bar/media0/playlist.m3u8"; + // The playlist has a part hold back but not EXT-X-PART-INF. We should pick up the hold back. + // The duration of the playlist is 16 seconds so that the defined hold back is within the live + // window. + String playlist = + "#EXTM3U\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.0+00:00\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence1.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence2.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence3.ts\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK=12,PART-HOLD-BACK=3"; + // The playlist finishes 1 second before the the current time. + SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:17.0+00:00")); + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + MediaItem mediaItem = MediaItem.fromUri(playlistUri); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + Timeline.Window window = timeline.getWindow(0, new Timeline.Window()); + // The target live offset is picked from hold back and then expressed in relation to the live + // edge (+1 seconds). + assertThat(window.mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(13000); + assertThat(window.defaultPositionUs).isEqualTo(4000000); + } + + @Test + public void + loadPlaylist_partHoldBackWithPartInformationInPlaylist_targetLiveOffsetFromPartHoldBack() + throws TimeoutException, ParserException { + String playlistUri = "fake://foo.bar/media0/playlist.m3u8"; + // The playlist has a duration of 4 seconds, part hold back and EXT-X-PART-INF defined. + String playlist = + "#EXTM3U\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.0+00:00\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXT-X-PART-INF:PART-TARGET=0.5\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK=12,PART-HOLD-BACK=3"; + // The playlist finishes 1 second before the the current time. + SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:05.0+00:00")); + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + MediaItem mediaItem = MediaItem.fromUri(playlistUri); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + Timeline.Window window = timeline.getWindow(0, new Timeline.Window()); + // The target live offset is picked from part hold back and then expressed in relation to the + // live edge (+1 seconds). + assertThat(window.mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(4000); + assertThat(window.defaultPositionUs).isEqualTo(0); + } + + @Test + public void loadPlaylist_targetLiveOffsetInMediaItem_targetLiveOffsetPickedFromMediaItem() + throws TimeoutException, ParserException { + String playlistUri = "fake://foo.bar/media0/playlist.m3u8"; + // The playlist has a hold back of 12 seconds and a part hold back of 3 seconds. + String playlist = + "#EXTM3U\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.0+00:00\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK=12,PART-HOLD-BACK=3"; + // The playlist finishes 1 second before the the current time. This should not affect the target + // live offset set in the media item. + SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:05.0+00:00")); + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + MediaItem mediaItem = + new MediaItem.Builder().setUri(playlistUri).setLiveTargetOffsetMs(1000).build(); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + Timeline.Window window = timeline.getWindow(0, new Timeline.Window()); + // The target live offset is picked from the media item and not adjusted. + assertThat(window.mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(1000); + assertThat(window.defaultPositionUs).isEqualTo(0); + } + + @Test + public void loadPlaylist_targetLiveOffsetLargerThanLiveWindow_targetLiveOffsetIsWithinLiveWindow() + throws TimeoutException, ParserException { + String playlistUri = "fake://foo.bar/media0/playlist.m3u8"; + // The playlist has a duration of 8 seconds and a hold back of 12 seconds. + String playlist = + "#EXTM3U\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.0+00:00\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence1.ts\n" + + "#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24"; + // The playlist finishes 1 second before the live edge, therefore the live window duration is + // 9 seconds (8 + 1). + SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:09.0+00:00")); + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + MediaItem mediaItem = + new MediaItem.Builder().setUri(playlistUri).setLiveTargetOffsetMs(20_000).build(); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + Timeline.Window window = timeline.getWindow(0, new Timeline.Window()); + assertThat(mediaItem.liveConfiguration.targetLiveOffsetMs) + .isGreaterThan(C.usToMs(window.durationUs)); + assertThat(window.mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(9000); + } + + @Test + public void + loadPlaylist_withoutProgramDateTime_targetLiveOffsetFromPlaylistNotAdjustedToLiveEdge() + throws TimeoutException { + String playlistUri = "fake://foo.bar/media0/playlist.m3u8"; + // The playlist has a duration of 16 seconds and a hold back of 12 seconds. + String playlist = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence1.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence2.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence3.ts\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK=12"; + // The playlist finishes 8 seconds before the current time. + SystemClock.setCurrentTimeMillis(20000); + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); + MediaItem mediaItem = new MediaItem.Builder().setUri(playlistUri).build(); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + + Timeline timeline = prepareAndWaitForTimeline(mediaSource); + + Timeline.Window window = timeline.getWindow(0, new Timeline.Window()); + // The target live offset is not adjusted to the live edge because the list does not have + // program date time. + assertThat(window.mediaItem.liveConfiguration.targetLiveOffsetMs).isEqualTo(12000); + assertThat(window.defaultPositionUs).isEqualTo(4000000); + } + + @Test + public void refreshPlaylist_targetLiveOffsetRemainsInWindow() + throws TimeoutException, IOException { + String playlistUri1 = "fake://foo.bar/media0/playlist1.m3u8"; + // The playlist has a duration of 16 seconds and a hold back of 12 seconds. + String playlist1 = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXTINF:4.00000,\n" + + "fileSequence0.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence1.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence2.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence3.ts\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK:12"; + // The second playlist defines a different hold back. + String playlistUri2 = "fake://foo.bar/media0/playlist2.m3u8"; + String playlist2 = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:4\n" + + "#EXTINF:4.00000,\n" + + "fileSequence4.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence5.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence6.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence7.ts\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK:14"; + // The third playlist has a duration of 8 seconds. + String playlistUri3 = "fake://foo.bar/media0/playlist3.m3u8"; + String playlist3 = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:4\n" + + "#EXTINF:4.00000,\n" + + "fileSequence8.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence9.ts\n" + + "#EXTINF:4.00000,\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK:12"; + // The third playlist has a duration of 16 seconds but the target live offset should remain at + // 8 seconds. + String playlistUri4 = "fake://foo.bar/media0/playlist4.m3u8"; + String playlist4 = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:4\n" + + "#EXTINF:4.00000,\n" + + "fileSequence10.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence11.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence12.ts\n" + + "#EXTINF:4.00000,\n" + + "fileSequence13.ts\n" + + "#EXTINF:4.00000,\n" + + "#EXT-X-SERVER-CONTROL:HOLD-BACK:12"; + + HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri1, playlist1); + MediaItem mediaItem = new MediaItem.Builder().setUri(playlistUri1).build(); + HlsMediaSource mediaSource = factory.createMediaSource(mediaItem); + HlsMediaPlaylist secondPlaylist = parseHlsMediaPlaylist(playlistUri2, playlist2); + HlsMediaPlaylist thirdPlaylist = parseHlsMediaPlaylist(playlistUri3, playlist3); + HlsMediaPlaylist fourthPlaylist = parseHlsMediaPlaylist(playlistUri4, playlist4); + List timelines = new ArrayList<>(); + MediaSource.MediaSourceCaller mediaSourceCaller = (source, timeline) -> timelines.add(timeline); + + mediaSource.prepareSource(mediaSourceCaller, null); + runMainLooperUntil(() -> timelines.size() == 1); + mediaSource.onPrimaryPlaylistRefreshed(secondPlaylist); + runMainLooperUntil(() -> timelines.size() == 2); + mediaSource.onPrimaryPlaylistRefreshed(thirdPlaylist); + runMainLooperUntil(() -> timelines.size() == 3); + mediaSource.onPrimaryPlaylistRefreshed(fourthPlaylist); + runMainLooperUntil(() -> timelines.size() == 4); + + Timeline.Window window = new Timeline.Window(); + assertThat(timelines.get(0).getWindow(0, window).mediaItem.liveConfiguration.targetLiveOffsetMs) + .isEqualTo(12000); + assertThat(timelines.get(1).getWindow(0, window).mediaItem.liveConfiguration.targetLiveOffsetMs) + .isEqualTo(12000); + assertThat(timelines.get(2).getWindow(0, window).mediaItem.liveConfiguration.targetLiveOffsetMs) + .isEqualTo(8000); + assertThat(timelines.get(3).getWindow(0, window).mediaItem.liveConfiguration.targetLiveOffsetMs) + .isEqualTo(8000); + } + + private static HlsMediaSource.Factory createHlsMediaSourceFactory( + String playlistUri, String playlist) { + FakeDataSet fakeDataSet = new FakeDataSet().setData(playlistUri, Util.getUtf8Bytes(playlist)); + return new HlsMediaSource.Factory( + dataType -> new FakeDataSource.Factory().setFakeDataSet(fakeDataSet).createDataSource()) + .setElapsedRealTimeOffsetMs(0); + } + + /** Prepares the media source and waits until the timeline is updated. */ + private static Timeline prepareAndWaitForTimeline(HlsMediaSource mediaSource) + throws TimeoutException { + AtomicReference receivedTimeline = new AtomicReference<>(); + mediaSource.prepareSource( + (source, timeline) -> receivedTimeline.set(timeline), /* mediaTransferListener= */ null); + runMainLooperUntil(() -> receivedTimeline.get() != null); + return receivedTimeline.get(); + } + + private static HlsMediaPlaylist parseHlsMediaPlaylist(String playlistUri, String playlist) + throws IOException { + return (HlsMediaPlaylist) + new HlsPlaylistParser() + .parse(Uri.parse(playlistUri), new ByteArrayInputStream(Util.getUtf8Bytes(playlist))); + } } From 9d3875a8600572744007837249095809144292ea Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 2 Nov 2020 18:26:17 +0000 Subject: [PATCH 233/693] Matroska: Support additional PCM codec modes - Support 32-bit A_PCM/FLOAT/IEEE PCM - Support 8-bit and 16-bit A_PCM/INT/BIG PCM #minor-release Issue: #8142 PiperOrigin-RevId: 340264679 --- RELEASENOTES.md | 4 + .../extractor/mkv/MatroskaExtractor.java | 109 +++++++++++++----- 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2cfba47972e..814b839fd86 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -30,6 +30,10 @@ enough space. * Audio: * Retry playback after some types of `AudioTrack` error. +* Extractors: + * Matroska: Add support for 32-bit floating point PCM, and 8-bit and + 16-bit big endian integer PCM + ([#8142](https://github.com/google/ExoPlayer/issues/8142)). * IMA extension: * Upgrade IMA SDK dependency to 3.21.0, and release the `AdsLoader` ([#7344](https://github.com/google/ExoPlayer/issues/7344)). 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 660605ebe5d..c8f4cadcb1d 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 @@ -128,6 +128,8 @@ public class MatroskaExtractor implements Extractor { private static final String CODEC_ID_FLAC = "A_FLAC"; private static final String CODEC_ID_ACM = "A_MS/ACM"; private static final String CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT"; + private static final String CODEC_ID_PCM_INT_BIG = "A_PCM/INT/BIG"; + private static final String CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE"; private static final String CODEC_ID_SUBRIP = "S_TEXT/UTF8"; private static final String CODEC_ID_ASS = "S_TEXT/ASS"; private static final String CODEC_ID_VOBSUB = "S_VOBSUB"; @@ -1743,36 +1745,43 @@ private long scaleTimecodeToUs(long unscaledTimecode) throws ParserException { } private static boolean isCodecSupported(String codecId) { - return CODEC_ID_VP8.equals(codecId) - || CODEC_ID_VP9.equals(codecId) - || CODEC_ID_AV1.equals(codecId) - || CODEC_ID_MPEG2.equals(codecId) - || CODEC_ID_MPEG4_SP.equals(codecId) - || CODEC_ID_MPEG4_ASP.equals(codecId) - || CODEC_ID_MPEG4_AP.equals(codecId) - || CODEC_ID_H264.equals(codecId) - || CODEC_ID_H265.equals(codecId) - || CODEC_ID_FOURCC.equals(codecId) - || CODEC_ID_THEORA.equals(codecId) - || CODEC_ID_OPUS.equals(codecId) - || CODEC_ID_VORBIS.equals(codecId) - || CODEC_ID_AAC.equals(codecId) - || CODEC_ID_MP2.equals(codecId) - || CODEC_ID_MP3.equals(codecId) - || CODEC_ID_AC3.equals(codecId) - || CODEC_ID_E_AC3.equals(codecId) - || CODEC_ID_TRUEHD.equals(codecId) - || CODEC_ID_DTS.equals(codecId) - || CODEC_ID_DTS_EXPRESS.equals(codecId) - || CODEC_ID_DTS_LOSSLESS.equals(codecId) - || CODEC_ID_FLAC.equals(codecId) - || CODEC_ID_ACM.equals(codecId) - || CODEC_ID_PCM_INT_LIT.equals(codecId) - || CODEC_ID_SUBRIP.equals(codecId) - || CODEC_ID_ASS.equals(codecId) - || CODEC_ID_VOBSUB.equals(codecId) - || CODEC_ID_PGS.equals(codecId) - || CODEC_ID_DVBSUB.equals(codecId); + switch (codecId) { + case CODEC_ID_VP8: + case CODEC_ID_VP9: + case CODEC_ID_AV1: + case CODEC_ID_MPEG2: + case CODEC_ID_MPEG4_SP: + case CODEC_ID_MPEG4_ASP: + case CODEC_ID_MPEG4_AP: + case CODEC_ID_H264: + case CODEC_ID_H265: + case CODEC_ID_FOURCC: + case CODEC_ID_THEORA: + case CODEC_ID_OPUS: + case CODEC_ID_VORBIS: + case CODEC_ID_AAC: + case CODEC_ID_MP2: + case CODEC_ID_MP3: + case CODEC_ID_AC3: + case CODEC_ID_E_AC3: + case CODEC_ID_TRUEHD: + case CODEC_ID_DTS: + case CODEC_ID_DTS_EXPRESS: + case CODEC_ID_DTS_LOSSLESS: + case CODEC_ID_FLAC: + case CODEC_ID_ACM: + case CODEC_ID_PCM_INT_LIT: + case CODEC_ID_PCM_INT_BIG: + case CODEC_ID_PCM_FLOAT: + case CODEC_ID_SUBRIP: + case CODEC_ID_ASS: + case CODEC_ID_VOBSUB: + case CODEC_ID_PGS: + case CODEC_ID_DVBSUB: + return true; + default: + return false; + } } /** @@ -2102,8 +2111,44 @@ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserE if (pcmEncoding == C.ENCODING_INVALID) { pcmEncoding = Format.NO_VALUE; mimeType = MimeTypes.AUDIO_UNKNOWN; - Log.w(TAG, "Unsupported PCM bit depth: " + audioBitDepth + ". Setting mimeType to " - + mimeType); + Log.w( + TAG, + "Unsupported little endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_PCM_INT_BIG: + mimeType = MimeTypes.AUDIO_RAW; + if (audioBitDepth == 8) { + pcmEncoding = C.ENCODING_PCM_8BIT; + } else if (audioBitDepth == 16) { + pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN; + } else { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported big endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_PCM_FLOAT: + mimeType = MimeTypes.AUDIO_RAW; + if (audioBitDepth == 32) { + pcmEncoding = C.ENCODING_PCM_FLOAT; + } else { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w( + TAG, + "Unsupported floating point PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType); } break; case CODEC_ID_SUBRIP: From 2c7473dc05e7cb24b99ec4d23cca201856bde82e Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 2 Nov 2020 21:09:21 +0000 Subject: [PATCH 234/693] Clean up logic for determining whether DRM reconfig needs codec re-init 1. Move logic to decide to re-initialize the codec rather than using MediaCodec.setMediaDrmSession if (a) PlayReady is in use, and (b) the new session is still provisioning. This would previously have happened asynchronously after an input format change, after the decoder has subsequently been flushed. After this change the logic executes synchronously when the input format changes. This helps with the ref'd bug, since we want to propagate reasons for codec re-initialization through inputFormatChanged events. 2. Whilst moving the logic for re-initialization if PlayReady is being used, I fixed a bug that would occur when switching from [PlayReady --> non-PlayReady]. Re-use doesn't work in this case. The old logic only checked for the [Something --> PlayReady] case. 3. Remove pointless codec flush if updating the DRM session having not queued anything to the codec. PiperOrigin-RevId: 340299790 --- RELEASENOTES.md | 3 + .../exoplayer2/drm/DefaultDrmSession.java | 5 + .../android/exoplayer2/drm/DrmSession.java | 4 + .../exoplayer2/drm/ErrorStateDrmSession.java | 7 + .../mediacodec/MediaCodecRenderer.java | 151 ++++++++++-------- 5 files changed, 103 insertions(+), 67 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 814b839fd86..5c02cbc567b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -34,6 +34,9 @@ * Matroska: Add support for 32-bit floating point PCM, and 8-bit and 16-bit big endian integer PCM ([#8142](https://github.com/google/ExoPlayer/issues/8142)). +* DRM: + * Fix playback failure when switching from PlayReady protected content to + Widevine or Clearkey protected content in a playlist. * IMA extension: * Upgrade IMA SDK dependency to 3.21.0, and release the `AdsLoader` ([#7344](https://github.com/google/ExoPlayer/issues/7344)). 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 bb3ad910f02..0cec4ab789d 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 @@ -256,6 +256,11 @@ public boolean playClearSamplesWithoutKeys() { return state == STATE_ERROR ? lastException : null; } + @Override + public final UUID getSchemeUuid() { + return uuid; + } + @Override public final @Nullable ExoMediaCrypto getMediaCrypto() { return mediaCrypto; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java index 1706afcb350..e72d552a684 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java @@ -23,6 +23,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.Map; +import java.util.UUID; /** A DRM session. */ public interface DrmSession { @@ -101,6 +102,9 @@ default boolean playClearSamplesWithoutKeys() { @Nullable DrmSessionException getError(); + /** Returns the DRM scheme UUID for this session. */ + UUID getSchemeUuid(); + /** * Returns an {@link ExoMediaCrypto} for the open session, or null if called before the session * has been opened or after it's been released. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java index 4253d3011c6..068f1b3782f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java @@ -16,8 +16,10 @@ package com.google.android.exoplayer2.drm; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; import java.util.Map; +import java.util.UUID; /** A {@link DrmSession} that's in a terminal error state. */ public final class ErrorStateDrmSession implements DrmSession { @@ -44,6 +46,11 @@ public DrmSessionException getError() { return error; } + @Override + public final UUID getSchemeUuid() { + return C.UUID_NIL; + } + @Override @Nullable public ExoMediaCrypto getMediaCrypto() { 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 6648ae5fb28..a29de96cebd 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 @@ -19,6 +19,7 @@ import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION; import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITH_FLUSH; import static com.google.android.exoplayer2.mediacodec.MediaCodecInfo.KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkState; import static java.lang.Math.max; @@ -373,7 +374,7 @@ public MediaCodecRenderer( boolean enableDecoderFallback, float assumedMinimumCodecOperatingRate) { super(trackType); - this.mediaCodecSelector = Assertions.checkNotNull(mediaCodecSelector); + this.mediaCodecSelector = checkNotNull(mediaCodecSelector); this.enableDecoderFallback = enableDecoderFallback; this.assumedMinimumCodecOperatingRate = assumedMinimumCodecOperatingRate; buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); @@ -1385,7 +1386,7 @@ protected void onCodecReleased(String name) { @CallSuper protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { waitingForFirstSampleInFormat = true; - Format newFormat = Assertions.checkNotNull(formatHolder.format); + Format newFormat = checkNotNull(formatHolder.format); setSourceDrmSession(formatHolder.drmSession); inputFormat = newFormat; @@ -1402,22 +1403,16 @@ protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybac return; } - // We have an existing codec that we may need to reconfigure or re-initialize or release it to - // switch to bypass. If the existing codec instance is being kept then its operating rate - // may need to be updated. + // We have an existing codec that we may need to reconfigure, re-initialize, or release to + // switch to bypass. If the existing codec instance is kept then its operating rate and DRM + // session may need to be updated. - if ((sourceDrmSession == null && codecDrmSession != null) - || (sourceDrmSession != null && codecDrmSession == null) - || (sourceDrmSession != codecDrmSession - && !codecInfo.secure - && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) - || (Util.SDK_INT < 23 && sourceDrmSession != codecDrmSession)) { - // We might need to switch between the clear and protected output paths, or we're using DRM - // prior to API level 23 where the codec needs to be re-initialized to switch to the new DRM - // session. + if (drmNeedsCodecReinitialization(codecInfo, newFormat, codecDrmSession, sourceDrmSession)) { drainAndReinitializeCodec(); return; } + boolean drainAndUpdateCodecDrmSession = sourceDrmSession != codecDrmSession; + Assertions.checkState(!drainAndUpdateCodecDrmSession || Util.SDK_INT >= 23); switch (canKeepCodec(codec, codecInfo, codecInputFormat, newFormat)) { case KEEP_CODEC_RESULT_NO: @@ -1426,8 +1421,8 @@ && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) case KEEP_CODEC_RESULT_YES_WITH_FLUSH: codecInputFormat = newFormat; updateCodecOperatingRate(); - if (sourceDrmSession != codecDrmSession) { - drainAndUpdateCodecDrmSession(); + if (drainAndUpdateCodecDrmSession) { + drainAndUpdateCodecDrmSessionV23(); } else { drainAndFlushCodec(); } @@ -1445,16 +1440,16 @@ && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) && newFormat.height == codecInputFormat.height); codecInputFormat = newFormat; updateCodecOperatingRate(); - if (sourceDrmSession != codecDrmSession) { - drainAndUpdateCodecDrmSession(); + if (drainAndUpdateCodecDrmSession) { + drainAndUpdateCodecDrmSessionV23(); } } break; case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: codecInputFormat = newFormat; updateCodecOperatingRate(); - if (sourceDrmSession != codecDrmSession) { - drainAndUpdateCodecDrmSession(); + if (drainAndUpdateCodecDrmSession) { + drainAndUpdateCodecDrmSessionV23(); } break; default: @@ -1652,18 +1647,14 @@ private void drainAndFlushCodec() { * * @throws ExoPlaybackException If an error occurs updating the codec's DRM session. */ - private void drainAndUpdateCodecDrmSession() throws ExoPlaybackException { - if (Util.SDK_INT < 23) { - // The codec needs to be re-initialized to switch to the source DRM session. - drainAndReinitializeCodec(); - return; - } + @TargetApi(23) // Only called when SDK_INT >= 23, but lint isn't clever enough to know. + private void drainAndUpdateCodecDrmSessionV23() throws ExoPlaybackException { if (codecReceivedBuffers) { codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; codecDrainAction = DRAIN_ACTION_UPDATE_DRM_SESSION; } else { // Nothing has been queued to the decoder, so we can do the update immediately. - updateDrmSessionOrReinitializeCodecV23(); + updateDrmSessionV23(); } } @@ -1902,7 +1893,9 @@ private void processEndOfStream() throws ExoPlaybackException { reinitializeCodec(); break; case DRAIN_ACTION_UPDATE_DRM_SESSION: - updateDrmSessionOrReinitializeCodecV23(); + if (!flushOrReinitializeCodec()) { + updateDrmSessionV23(); + } break; case DRAIN_ACTION_FLUSH: flushOrReinitializeCodec(); @@ -1951,23 +1944,71 @@ protected static boolean supportsFormatDrm(Format format) { } /** - * Returns whether a {@link DrmSession} may require a secure decoder for a given {@link Format}. - * - * @param drmSession The {@link DrmSession}. - * @param format The {@link Format}. - * @return Whether a secure decoder may be required. + * Returns whether it's necessary to re-initialize the codec to handle a DRM change. If {@code + * false} is returned then either {@code oldSession == newSession} (i.e., there was no change), or + * it's possible to update the existing codec using MediaCrypto.setMediaDrmSession. */ - private boolean maybeRequiresSecureDecoder(DrmSession drmSession, Format format) + private boolean drmNeedsCodecReinitialization( + MediaCodecInfo codecInfo, + Format newFormat, + @Nullable DrmSession oldSession, + @Nullable DrmSession newSession) throws ExoPlaybackException { - // MediaCrypto type is checked during track selection. - @Nullable FrameworkMediaCrypto sessionMediaCrypto = getFrameworkMediaCrypto(drmSession); - if (sessionMediaCrypto == null) { - // We'd only expect this to happen if the CDM from which the pending session is obtained needs + if (oldSession == newSession) { + // No need to re-initialize if the old and new sessions are the same. + return false; + } + + // Note: At least one of oldSession and newSession are non-null. + + if (newSession == null || oldSession == null) { + // Changing from DRM to no DRM and vice-versa always requires re-initialization. + return true; + } + + // Note: Both oldSession and newSession are non-null, and they are different sessions. + + if (Util.SDK_INT < 23) { + // MediaCrypto.setMediaDrmSession is only available from API level 23, so re-initialization is + // required to switch to newSession on older API levels. + return true; + } + if (C.PLAYREADY_UUID.equals(oldSession.getSchemeUuid()) + || C.PLAYREADY_UUID.equals(newSession.getSchemeUuid())) { + // The PlayReady CDM does not support MediaCrypto.setMediaDrmSession, either as the old or new + // session. + // TODO: Add an API check once [Internal ref: b/128835874] is fixed. + return true; + } + @Nullable FrameworkMediaCrypto newMediaCrypto = getFrameworkMediaCrypto(newSession); + if (newMediaCrypto == null) { + // We'd only expect this to happen if the CDM from which newSession is obtained needs // provisioning. This is unlikely to happen (it probably requires a switch from one DRM scheme - // to another, where the new CDM hasn't been used before and needs provisioning). Assume that - // a secure decoder may be required. + // to another, where the new CDM hasn't been used before and needs provisioning). It would be + // possible to handle this case without codec re-initialization, but it would require the + // re-use code path to be able to wait for provisioning to finish before calling + // MediaCrypto.setMediaDrmSession. The extra complexity is not warranted given how unlikely + // the case is to occur, so we re-initialize in this case. + return true; + } + if (!codecInfo.secure && maybeRequiresSecureDecoder(newMediaCrypto, newFormat)) { + // Re-initialization is required because newSession might require switching to the secure + // output path. return true; } + + return false; + } + + /** + * Returns whether a {@link DrmSession} may require a secure decoder for a given {@link Format}. + * + * @param sessionMediaCrypto The {@link DrmSession}'s {@link FrameworkMediaCrypto}. + * @param format The {@link Format}. + * @return Whether a secure decoder may be required. + */ + private boolean maybeRequiresSecureDecoder( + FrameworkMediaCrypto sessionMediaCrypto, Format format) { if (sessionMediaCrypto.forceAllowInsecureDecoderComponents) { return false; } @@ -2004,33 +2045,9 @@ private boolean isDecodeOnlyBuffer(long presentationTimeUs) { } @RequiresApi(23) - private void updateDrmSessionOrReinitializeCodecV23() throws ExoPlaybackException { - @Nullable FrameworkMediaCrypto sessionMediaCrypto = getFrameworkMediaCrypto(sourceDrmSession); - if (sessionMediaCrypto == null) { - // We'd only expect this to happen if the CDM from which the pending session is obtained needs - // provisioning. This is unlikely to happen (it probably requires a switch from one DRM scheme - // to another, where the new CDM hasn't been used before and needs provisioning). It would be - // possible to handle this case more efficiently (i.e. with a new renderer state that waits - // for provisioning to finish and then calls mediaCrypto.setMediaDrmSession), but the extra - // complexity is not warranted given how unlikely the case is to occur. - reinitializeCodec(); - return; - } - if (C.PLAYREADY_UUID.equals(sessionMediaCrypto.uuid)) { - // The PlayReady CDM does not implement setMediaDrmSession. - // TODO: Add API check once [Internal ref: b/128835874] is fixed. - reinitializeCodec(); - return; - } - - if (flushOrReinitializeCodec()) { - // The codec was reinitialized. The new codec will be using the new DRM session, so there's - // nothing more to do. - return; - } - + private void updateDrmSessionV23() throws ExoPlaybackException { try { - mediaCrypto.setMediaDrmSession(sessionMediaCrypto.sessionId); + mediaCrypto.setMediaDrmSession(getFrameworkMediaCrypto(sourceDrmSession).sessionId); } catch (MediaCryptoException e) { throw createRendererException(e, inputFormat); } @@ -2115,7 +2132,7 @@ private boolean bypassRender(long positionUs, long elapsedRealtimeUs) if (!batchBuffer.isEmpty() && waitingForFirstSampleInFormat) { // This is the first buffer in a new format, the output format must be updated. - outputFormat = Assertions.checkNotNull(inputFormat); + outputFormat = checkNotNull(inputFormat); onOutputFormatChanged(outputFormat, /* mediaFormat= */ null); waitingForFirstSampleInFormat = false; } From e139a4652a8307080502bf98482fa2ff3951f53a Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 2 Nov 2020 22:18:03 +0000 Subject: [PATCH 235/693] Short term fix for setFrameRate ISE when surface is not valid PiperOrigin-RevId: 340314496 --- .../android/exoplayer2/video/MediaCodecVideoRenderer.java | 6 +++++- 1 file changed, 5 insertions(+), 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 831ed477162..5c4ba382029 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 @@ -1106,7 +1106,11 @@ private static void setSurfaceFrameRateV30(Surface surface, float frameRate) { frameRate == 0 ? Surface.FRAME_RATE_COMPATIBILITY_DEFAULT : Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE; - surface.setFrameRate(frameRate, compatibility); + try { + surface.setFrameRate(frameRate, compatibility); + } catch (IllegalStateException e) { + Log.e(TAG, "Failed to call Surface.setFrameRate", e); + } } private boolean shouldUseDummySurface(MediaCodecInfo codecInfo) { From 99ddb4037bb658b3ecf4860514b7bc7750803310 Mon Sep 17 00:00:00 2001 From: Samoylenko Dmitry Date: Tue, 3 Nov 2020 15:15:33 +0300 Subject: [PATCH 236/693] Correctly handling Exception: java.nio.file.FileSystemException: No space left on device. By default methods File.makeDir() and File.makeDirs() can return 'false' if file aleady exists or can not be created. Such silent ignore of the situation propagates misbehavior to the caller: CacheDataSink#173 : new FileOutputStream(file). And then it throws not correct exception type 'FileNotFoundException'. While correct exception should be 'no space left on the device'. This can be fixed only with 'Files.createDirectories()' method that throws correct exception type. --- .../upstream/cache/SimpleCache.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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 29c09ff4868..db911a30fda 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream.cache; +import android.os.Build; import android.os.ConditionVariable; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; @@ -26,6 +27,7 @@ import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.security.SecureRandom; import java.util.ArrayList; import java.util.HashMap; @@ -410,14 +412,30 @@ public synchronized File startFile(String key, long position, long length) throw 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(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + Files.createDirectories(cacheDir.toPath()); + } catch (IOException e) { + throw new CacheException(e); + } + } else { + cacheDir.mkdirs(); + } removeStaleSpans(); } evictor.onStartFile(this, key, position, length); // Randomly distribute files into subdirectories with a uniform distribution. File fileDir = new File(cacheDir, Integer.toString(random.nextInt(SUBDIRECTORY_COUNT))); if (!fileDir.exists()) { - fileDir.mkdir(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + Files.createDirectories(fileDir.toPath()); + } catch (IOException e) { + throw new CacheException(e); + } + } else { + fileDir.mkdir(); + } } long lastTouchTimestamp = System.currentTimeMillis(); return SimpleCacheSpan.getCacheFile(fileDir, cachedContent.id, position, lastTouchTimestamp); From 57c53c5ac4b1c2ef26d34da5fdc2725624d323e1 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 3 Nov 2020 10:27:12 +0000 Subject: [PATCH 237/693] Fix ImaPlaybackTest This test is not run in presubmit as it was too flaky, and is currently broken due to assets moving. Also migrate off ImaPlaybackTest off deprecated APIs. #minor-release PiperOrigin-RevId: 340405666 --- .../exoplayer2/ext/ima/ImaPlaybackTest.java | 24 ++++++++++++------- .../ad-responses/midroll10s_midroll20s.xml | 4 ++-- .../ad-responses/midroll1s_midroll7s.xml | 4 ++-- .../assets/media/ad-responses/preroll.xml | 2 +- .../preroll_midroll6s_postroll.xml | 6 ++--- 5 files changed, 23 insertions(+), 17 deletions(-) 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 88bc4e14c53..9527d35cef9 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 @@ -47,8 +47,10 @@ import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; 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; @@ -78,7 +80,7 @@ public final class ImaPlaybackTest { @Test public void playbackWithPrerollAdTag_playsAdAndContent() throws Exception { String adsResponse = - TestUtil.getString(/* context= */ testRule.getActivity(), "ad-responses/preroll.xml"); + TestUtil.getString(/* context= */ testRule.getActivity(), "media/ad-responses/preroll.xml"); AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT}; ImaHostedTest hostedTest = new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds); @@ -90,7 +92,8 @@ public void playbackWithPrerollAdTag_playsAdAndContent() throws Exception { public void playbackWithMidrolls_playsAdAndContent() throws Exception { String adsResponse = TestUtil.getString( - /* context= */ testRule.getActivity(), "ad-responses/preroll_midroll6s_postroll.xml"); + /* context= */ testRule.getActivity(), + "media/ad-responses/preroll_midroll6s_postroll.xml"); AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT, ad(1), CONTENT, ad(2), CONTENT}; ImaHostedTest hostedTest = new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds); @@ -102,7 +105,7 @@ public void playbackWithMidrolls_playsAdAndContent() throws Exception { public void playbackWithMidrolls1And7_playsAdsAndContent() throws Exception { String adsResponse = TestUtil.getString( - /* context= */ testRule.getActivity(), "ad-responses/midroll1s_midroll7s.xml"); + /* context= */ testRule.getActivity(), "media/ad-responses/midroll1s_midroll7s.xml"); AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT}; ImaHostedTest hostedTest = new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds); @@ -114,7 +117,7 @@ public void playbackWithMidrolls1And7_playsAdsAndContent() throws Exception { public void playbackWithMidrolls10And20WithSeekTo12_playsAdsAndContent() throws Exception { String adsResponse = TestUtil.getString( - /* context= */ testRule.getActivity(), "ad-responses/midroll10s_midroll20s.xml"); + /* context= */ testRule.getActivity(), "media/ad-responses/midroll10s_midroll20s.xml"); AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT}; ImaHostedTest hostedTest = new ImaHostedTest(Uri.parse(CONTENT_URI_LONG), adsResponse, expectedAdIds); @@ -131,7 +134,7 @@ public void playbackWithMidrolls10And20WithSeekTo12_playsAdsAndContent() throws public void playbackWithMidrolls10And20WithSeekTo18_playsAdsAndContent() throws Exception { String adsResponse = TestUtil.getString( - /* context= */ testRule.getActivity(), "ad-responses/midroll10s_midroll20s.xml"); + /* context= */ testRule.getActivity(), "media/ad-responses/midroll10s_midroll20s.xml"); AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT}; ImaHostedTest hostedTest = new ImaHostedTest(Uri.parse(CONTENT_URI_LONG), adsResponse, expectedAdIds); @@ -190,7 +193,7 @@ public int hashCode() { private static final class ImaHostedTest extends ExoHostedTest implements EventListener { private final Uri contentUri; - private final String adsResponse; + private final DataSpec adTagDataSpec; private final List expectedAdIds; private final List seenAdIds; private @MonotonicNonNull ImaAdsLoader imaAdsLoader; @@ -201,7 +204,9 @@ private ImaHostedTest(Uri contentUri, String adsResponse, AdId... expectedAdIds) // duration due to ad playback, so the hosted test shouldn't assert the playing duration. super(ImaPlaybackTest.class.getSimpleName(), /* fullPlaybackNoSeeking= */ false); this.contentUri = contentUri; - this.adsResponse = adsResponse; + this.adTagDataSpec = + new DataSpec( + Util.getDataUriForString(/* mimeType= */ "text/xml", /* data= */ adsResponse)); this.expectedAdIds = Arrays.asList(expectedAdIds); seenAdIds = new ArrayList<>(); } @@ -226,7 +231,7 @@ public void onPositionDiscontinuity( } }); Context context = host.getApplicationContext(); - imaAdsLoader = new ImaAdsLoader.Builder(context).buildForAdsResponse(adsResponse); + imaAdsLoader = new ImaAdsLoader.Builder(context).build(); imaAdsLoader.setPlayer(player); return player; } @@ -242,7 +247,8 @@ protected MediaSource buildSource( new DefaultMediaSourceFactory(context).createMediaSource(MediaItem.fromUri(contentUri)); return new AdsMediaSource( contentMediaSource, - dataSourceFactory, + adTagDataSpec, + new DefaultMediaSourceFactory(dataSourceFactory), Assertions.checkNotNull(imaAdsLoader), new AdViewProvider() { diff --git a/testdata/src/test/assets/media/ad-responses/midroll10s_midroll20s.xml b/testdata/src/test/assets/media/ad-responses/midroll10s_midroll20s.xml index 1543e11d268..98c59ec45d8 100644 --- a/testdata/src/test/assets/media/ad-responses/midroll10s_midroll20s.xml +++ b/testdata/src/test/assets/media/ad-responses/midroll10s_midroll20s.xml @@ -17,7 +17,7 @@ @@ -48,7 +48,7 @@ file:///android_asset/mp4/midroll-5s.mp4 diff --git a/testdata/src/test/assets/media/ad-responses/midroll1s_midroll7s.xml b/testdata/src/test/assets/media/ad-responses/midroll1s_midroll7s.xml index 7b693747fc2..58c1834df3d 100644 --- a/testdata/src/test/assets/media/ad-responses/midroll1s_midroll7s.xml +++ b/testdata/src/test/assets/media/ad-responses/midroll1s_midroll7s.xml @@ -17,7 +17,7 @@ @@ -48,7 +48,7 @@ file:///android_asset/mp4/midroll-5s.mp4 diff --git a/testdata/src/test/assets/media/ad-responses/preroll.xml b/testdata/src/test/assets/media/ad-responses/preroll.xml index 3456649b291..d55f960381e 100644 --- a/testdata/src/test/assets/media/ad-responses/preroll.xml +++ b/testdata/src/test/assets/media/ad-responses/preroll.xml @@ -14,7 +14,7 @@ diff --git a/testdata/src/test/assets/media/ad-responses/preroll_midroll6s_postroll.xml b/testdata/src/test/assets/media/ad-responses/preroll_midroll6s_postroll.xml index bbf216bf125..01d62c3a82e 100644 --- a/testdata/src/test/assets/media/ad-responses/preroll_midroll6s_postroll.xml +++ b/testdata/src/test/assets/media/ad-responses/preroll_midroll6s_postroll.xml @@ -17,7 +17,7 @@ @@ -48,7 +48,7 @@ file:///android_asset/mp4/preroll-5s.mp4 @@ -79,7 +79,7 @@ file:///android_asset/mp4/midroll-5s.mp4 From f937e40eab1060813af81e4449a84b7c15a0966b Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 3 Nov 2020 11:36:55 +0000 Subject: [PATCH 238/693] Make Tx3gDecoder fields final, and remove unnecessary null-check PiperOrigin-RevId: 340412910 --- .../exoplayer2/text/tx3g/Tx3gDecoder.java | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) 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 907607f859f..ad1abdc7bc7 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 @@ -72,12 +72,12 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { private final ParsableByteArray parsableByteArray; - private boolean customVerticalPlacement; - private int defaultFontFace; - private int defaultColorRgba; - private String defaultFontFamily; - private float defaultVerticalPlacement; - private int calculatedVideoTrackHeight; + private final boolean customVerticalPlacement; + private final int defaultFontFace; + private final int defaultColorRgba; + private final String defaultFontFamily; + private final float defaultVerticalPlacement; + private final int calculatedVideoTrackHeight; /** * Sets up a new {@link Tx3gDecoder} with default values. @@ -88,7 +88,7 @@ public Tx3gDecoder(List initializationData) { super("Tx3gDecoder"); parsableByteArray = new ParsableByteArray(); - if (initializationData != null && initializationData.size() == 1 + if (initializationData.size() == 1 && (initializationData.get(0).length == 48 || initializationData.get(0).length == 53)) { byte[] initializationBytes = initializationData.get(0); defaultFontFace = initializationBytes[24]; @@ -105,8 +105,9 @@ public Tx3gDecoder(List initializationData) { if (customVerticalPlacement) { int requestedVerticalPlacement = ((initializationBytes[10] & 0xFF) << 8) | (initializationBytes[11] & 0xFF); - defaultVerticalPlacement = (float) requestedVerticalPlacement / calculatedVideoTrackHeight; - defaultVerticalPlacement = Util.constrainValue(defaultVerticalPlacement, 0.0f, 0.95f); + defaultVerticalPlacement = + Util.constrainValue( + (float) requestedVerticalPlacement / calculatedVideoTrackHeight, 0.0f, 0.95f); } else { defaultVerticalPlacement = DEFAULT_VERTICAL_PLACEMENT; } @@ -116,6 +117,7 @@ public Tx3gDecoder(List initializationData) { defaultFontFamily = DEFAULT_FONT_FAMILY; customVerticalPlacement = false; defaultVerticalPlacement = DEFAULT_VERTICAL_PLACEMENT; + calculatedVideoTrackHeight = C.LENGTH_UNSET; } } @@ -133,8 +135,7 @@ protected Subtitle decode(byte[] bytes, int length, boolean reset) SPAN_PRIORITY_LOW); attachColor(cueText, defaultColorRgba, DEFAULT_COLOR, 0, cueText.length(), SPAN_PRIORITY_LOW); - attachFontFamily(cueText, defaultFontFamily, DEFAULT_FONT_FAMILY, 0, cueText.length(), - SPAN_PRIORITY_LOW); + attachFontFamily(cueText, defaultFontFamily, 0, cueText.length()); float verticalPlacement = defaultVerticalPlacement; // Find and attach additional styles. while (parsableByteArray.bytesLeft() >= SIZE_ATOM_HEADER) { @@ -237,11 +238,14 @@ private static void attachColor(SpannableStringBuilder cueText, int colorRgba, } @SuppressWarnings("ReferenceEquality") - private static void attachFontFamily(SpannableStringBuilder cueText, String fontFamily, - String defaultFontFamily, int start, int end, int spanPriority) { - if (fontFamily != defaultFontFamily) { - cueText.setSpan(new TypefaceSpan(fontFamily), start, end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | spanPriority); + private static void attachFontFamily( + SpannableStringBuilder cueText, String fontFamily, int start, int end) { + if (fontFamily != Tx3gDecoder.DEFAULT_FONT_FAMILY) { + cueText.setSpan( + new TypefaceSpan(fontFamily), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Tx3gDecoder.SPAN_PRIORITY_LOW); } } From 5fd1601f91aa65391bda034f230545750d3c2e0f Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 3 Nov 2020 15:21:05 +0000 Subject: [PATCH 239/693] Signal an ads identifier to the AdsLoader In a later change, the AdPlaybackState will include the playing adsId (set by the AdsLoader) and the ads loader will use this to determine what ad information is associated with the playing/next periods, to allow loading ads in playlists. Apps can continue to pass just a URI for an ad tag with their MediaItem, in which case the associated playlist will request that ad tag just and the same state will be used for all occurrences of the ad tag. This change has breaking changes to the AdsLoader interface and removes deprecated ways of passing the ad tag, as it's very likely to go into a major release anyway and not needing to handle the deprecated cases simplifies ImaAdsLoader. Issue: #3750 PiperOrigin-RevId: 340438580 --- .../android/exoplayer2/demo/IntentUtil.java | 4 +- .../exoplayer2/demo/PlayerActivity.java | 15 +- .../demo/SampleChooserActivity.java | 2 +- extensions/ima/README.md | 24 +-- .../exoplayer2/ext/ima/ImaPlaybackTest.java | 1 + .../exoplayer2/ext/ima/ImaAdsLoader.java | 161 +++--------------- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 135 ++++++++++----- .../google/android/exoplayer2/MediaItem.java | 112 ++++++++++-- .../android/exoplayer2/MediaItemTest.java | 15 +- .../source/DefaultMediaSourceFactory.java | 32 ++-- .../exoplayer2/source/ads/AdsLoader.java | 39 ++--- .../exoplayer2/source/ads/AdsMediaSource.java | 83 ++------- .../android/exoplayer2/ExoPlayerTest.java | 20 +-- .../source/DefaultMediaSourceFactoryTest.java | 2 +- .../source/ads/AdsMediaSourceTest.java | 19 ++- 15 files changed, 334 insertions(+), 330 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 d2d962c568e..d1cb0357187 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 @@ -173,7 +173,9 @@ private static void addPlaybackPropertiesToIntent( .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, playbackProperties.mimeType) .putExtra( AD_TAG_URI_EXTRA + extrasKeySuffix, - playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null); + playbackProperties.adsConfiguration != null + ? playbackProperties.adsConfiguration.adTagUri.toString() + : null); 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 c35080c47fa..776ab68a799 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 @@ -19,7 +19,6 @@ import android.content.Intent; import android.content.pm.PackageManager; -import android.net.Uri; import android.os.Bundle; import android.util.Pair; import android.view.KeyEvent; @@ -102,12 +101,11 @@ public class PlayerActivity extends AppCompatActivity private int startWindow; private long startPosition; - // Fields used only for ad playback. + // For ad playback only. private AdsLoader adsLoader; - private Uri loadedAdTagUri; - // Activity lifecycle + // Activity lifecycle. @Override public void onCreate(Bundle savedInstanceState) { @@ -355,7 +353,7 @@ private List createMediaItems(Intent intent) { return Collections.emptyList(); } } - hasAds |= mediaItem.playbackProperties.adTagUri != null; + hasAds |= mediaItem.playbackProperties.adsConfiguration != null; } if (!hasAds) { releaseAdsLoader(); @@ -363,16 +361,12 @@ private List createMediaItems(Intent intent) { return mediaItems; } - private AdsLoader getAdsLoader(Uri adTagUri) { + private AdsLoader getAdsLoader(MediaItem.AdsConfiguration adsConfiguration) { if (mediaItems.size() > 1) { showToast(R.string.unsupported_ads_in_playlist); releaseAdsLoader(); return null; } - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; - } // The ads loader is reused for multiple playbacks, so that ad playback can resume. if (adsLoader == null) { adsLoader = new ImaAdsLoader.Builder(/* context= */ this).build(); @@ -401,7 +395,6 @@ private void releaseAdsLoader() { if (adsLoader != null) { adsLoader.release(); adsLoader = null; - loadedAdTagUri = null; playerView.getOverlayFrameLayout().removeAllViews(); } } 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 ea5b38ce8e8..a66a1e03014 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 @@ -252,7 +252,7 @@ private int getDownloadUnsupportedStringId(PlaylistHolder playlistHolder) { } MediaItem.PlaybackProperties playbackProperties = checkNotNull(playlistHolder.mediaItems.get(0).playbackProperties); - if (playbackProperties.adTagUri != null) { + if (playbackProperties.adsConfiguration != null) { return R.string.download_ads_unsupported; } String scheme = playbackProperties.uri.getScheme(); diff --git a/extensions/ima/README.md b/extensions/ima/README.md index c67dfdbb5d5..016f848c7af 100644 --- a/extensions/ima/README.md +++ b/extensions/ima/README.md @@ -33,17 +33,19 @@ of the developer guide. The `AdsLoaderProvider` passed to the player's extension only supports players which are accessed on the application's main thread. -Resuming the player after entering the background requires some special handling -when playing ads. The player and its media source are released on entering the -background, and are recreated when returning to the foreground. When playing ads -it is necessary to persist ad playback state while in the background by keeping -a reference to the `ImaAdsLoader`. When re-entering the foreground, pass the -same instance back when `AdsLoaderProvider.getAdsLoader(Uri adTagUri)` is called -to restore the state. It is also important to persist the player position when -entering the background by storing the value of `player.getContentPosition()`. -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 has finished and will not be resumed. +Resuming the player after entering the background requires some special +handling when playing ads. The player and its media source are released on +entering the background, and are recreated when returning to the foreground. +When playing ads it is necessary to persist ad playback state while in the +background by keeping a reference to the `ImaAdsLoader`. When re-entering the +foreground, pass the same instance back when +`AdsLoaderProvider.getAdsLoader(MediaItem.AdsConfiguration adsConfiguration)` +is called to restore the state. It is also important to persist the player +position when entering the background by storing the value of +`player.getContentPosition()`. 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 has finished and will not be +resumed. 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 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 9527d35cef9..839c8329516 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 @@ -248,6 +248,7 @@ protected MediaSource buildSource( return new AdsMediaSource( contentMediaSource, adTagDataSpec, + /* adsId= */ adTagDataSpec.uri, new DefaultMediaSourceFactory(dataSourceFactory), Assertions.checkNotNull(imaAdsLoader), new AdViewProvider() { 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 2bd8e0c03d4..c5c17c02d6f 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 @@ -23,7 +23,6 @@ import static com.google.android.exoplayer2.util.Assertions.checkState; import android.content.Context; -import android.net.Uri; import android.os.Looper; import android.view.View; import android.view.ViewGroup; @@ -343,125 +342,44 @@ public Builder setDebugModeEnabled(boolean debugModeEnabled) { return this; } - /** - * Returns a new {@link ImaAdsLoader} for the specified ad tag. - * - * @param adTagUri The URI of a compatible ad tag to load. See - * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for - * information on compatible ad tags. - * @return The new {@link ImaAdsLoader}. - * @deprecated Pass the ad tag URI when setting media item playback properties (if using the - * media item API) or as a {@link DataSpec} when constructing the {@link AdsMediaSource} (if - * using media sources directly). - */ - @Deprecated - public ImaAdsLoader buildForAdTag(Uri adTagUri) { - return new ImaAdsLoader( - context, - getConfiguration(), - imaFactory, - /* adTagUri= */ adTagUri, - /* adsResponse= */ null); - } - - /** - * Returns a new {@link ImaAdsLoader} with the specified sideloaded ads response. - * - * @param adsResponse The sideloaded VAST, VMAP, or ad rules response to be used instead of - * making a request via an ad tag URL. - * @return The new {@link ImaAdsLoader}. - * @deprecated Pass the ads response as a data URI when setting media item playback properties - * (if using the media item API) or as a {@link DataSpec} when constructing the {@link - * AdsMediaSource} (if using media sources directly). {@link - * Util#getDataUriForString(String, String)} can be used to construct a data URI from - * literal string ads response (with MIME type text/xml). - */ - @Deprecated - public ImaAdsLoader buildForAdsResponse(String adsResponse) { - return new ImaAdsLoader( - context, getConfiguration(), imaFactory, /* adTagUri= */ null, adsResponse); - } - /** Returns a new {@link ImaAdsLoader}. */ public ImaAdsLoader build() { return new ImaAdsLoader( - context, getConfiguration(), imaFactory, /* adTagUri= */ null, /* adsResponse= */ null); - } - - // TODO(internal: b/169646419): Remove/hide once the deprecated constructor has been removed. - /* package */ ImaUtil.Configuration getConfiguration() { - return new ImaUtil.Configuration( - adPreloadTimeoutMs, - vastLoadTimeoutMs, - mediaLoadTimeoutMs, - focusSkipButtonWhenAvailable, - playAdBeforeStartPosition, - mediaBitrate, - adMediaMimeTypes, - adUiElements, - companionAdSlots, - adErrorListener, - adEventListener, - videoAdPlayerCallback, - imaSdkSettings, - debugModeEnabled); + context, + new ImaUtil.Configuration( + adPreloadTimeoutMs, + vastLoadTimeoutMs, + mediaLoadTimeoutMs, + focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, + mediaBitrate, + adMediaMimeTypes, + adUiElements, + companionAdSlots, + adErrorListener, + adEventListener, + videoAdPlayerCallback, + imaSdkSettings, + debugModeEnabled), + imaFactory); } } - private static final DataSpec EMPTY_AD_TAG_DATA_SPEC = new DataSpec(Uri.EMPTY); - private final ImaUtil.Configuration configuration; private final Context context; private final ImaUtil.ImaFactory imaFactory; - @Nullable private final DataSpec deprecatedAdTagDataSpec; private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; @Nullable private AdTagLoader adTagLoader; private List supportedMimeTypes; - private DataSpec adTagDataSpec; @Nullable private Player player; - /** - * Creates a new IMA ads loader. - * - *

    If you need to customize the ad request, use {@link ImaAdsLoader.Builder} instead. - * - * @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. - * @deprecated Use {@link Builder} to create an instance. Pass the ad tag URI when setting media - * item playback properties (if using the media item API) or as a {@link DataSpec} when - * constructing the {@link AdsMediaSource} (if using media sources directly). - */ - @Deprecated - public ImaAdsLoader(Context context, Uri adTagUri) { - this( - context, - new Builder(context).getConfiguration(), - new DefaultImaFactory(), - adTagUri, - /* adsResponse= */ null); - } - private ImaAdsLoader( - Context context, - ImaUtil.Configuration configuration, - ImaUtil.ImaFactory imaFactory, - @Nullable Uri adTagUri, - @Nullable String adsResponse) { + Context context, ImaUtil.Configuration configuration, ImaUtil.ImaFactory imaFactory) { this.context = context.getApplicationContext(); this.configuration = configuration; this.imaFactory = imaFactory; - deprecatedAdTagDataSpec = - adTagUri != null - ? new DataSpec(adTagUri) - : adsResponse != null - ? new DataSpec( - Util.getDataUriForString(/* mimeType= */ "text/xml", /* data= */ adsResponse)) - : null; - adTagDataSpec = EMPTY_AD_TAG_DATA_SPEC; supportedMimeTypes = ImmutableList.of(); } @@ -490,24 +408,6 @@ public AdDisplayContainer getAdDisplayContainer() { return adTagLoader != null ? adTagLoader.getAdDisplayContainer() : null; } - /** - * Requests ads, if they have not already been requested. Must be called on the main thread. - * - *

    Ads will be requested automatically when the player is prepared if this method has not been - * called, so it is only necessary to call this method if you want to request ads before preparing - * the player. - * - * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code - * null} if playing audio-only ads. - * @deprecated Use {@link #requestAds(DataSpec, ViewGroup)}, specifying the ad tag data spec to - * request, and migrate off deprecated builder methods/constructor that require an ad tag or - * ads response. - */ - @Deprecated - public void requestAds(@Nullable ViewGroup adViewGroup) { - requestAds(adTagDataSpec, adViewGroup); - } - /** * Requests ads, if they have not already been requested. Must be called on the main thread. * @@ -521,16 +421,11 @@ public void requestAds(@Nullable ViewGroup adViewGroup) { * null} if playing audio-only ads. */ public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) { - if (adTagLoader != null) { - return; - } - - if (EMPTY_AD_TAG_DATA_SPEC.equals(adTagDataSpec)) { - adTagDataSpec = checkNotNull(deprecatedAdTagDataSpec); + if (adTagLoader == null) { + adTagLoader = + new AdTagLoader( + context, configuration, imaFactory, supportedMimeTypes, adTagDataSpec, adViewGroup); } - adTagLoader = - new AdTagLoader( - context, configuration, imaFactory, supportedMimeTypes, adTagDataSpec, adViewGroup); } /** @@ -579,12 +474,12 @@ public void setSupportedContentTypes(@C.ContentType int... contentTypes) { } @Override - public void setAdTagDataSpec(DataSpec adTagDataSpec) { - this.adTagDataSpec = adTagDataSpec; - } - - @Override - public void start(EventListener eventListener, AdViewProvider adViewProvider) { + public void start( + AdsMediaSource adsMediaSource, + DataSpec adTagDataSpec, + Object adsId, + AdViewProvider adViewProvider, + EventListener eventListener) { checkState( wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player."); player = nextPlayer; 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 5532b1c7ed6..ab7e9f34853 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 @@ -28,6 +28,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.content.Context; import android.net.Uri; import android.view.View; import android.view.ViewGroup; @@ -56,11 +57,14 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.ext.ima.ImaUtil.ImaFactory; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MaskingMediaSource.PlaceholderTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; +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.upstream.DataSpec; @@ -101,6 +105,7 @@ public final class ImaAdsLoaderTest { CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; private static final Uri TEST_URI = Uri.parse("https://www.google.com"); private static final DataSpec TEST_DATA_SPEC = new DataSpec(TEST_URI); + private static final Object TEST_ADS_ID = new Object(); private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo("https://www.google.com"); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; private static final ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f); @@ -119,6 +124,7 @@ public final class ImaAdsLoaderTest { @Mock private AdPodInfo mockAdPodInfo; @Mock private Ad mockPrerollSingleAd; + private AdsMediaSource adsMediaSource; private ViewGroup adViewGroup; private AdsLoader.AdViewProvider adViewProvider; private AdsLoader.AdViewProvider audioAdsAdViewProvider; @@ -172,7 +178,8 @@ public void teardown() { public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @@ -180,7 +187,8 @@ public void builder_overridesPlayerType() { @Test public void start_setsAdUiViewGroup() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockImaFactory, atLeastOnce()).createAdDisplayContainer(adViewGroup, videoAdPlayer); verify(mockImaFactory, never()).createAudioAdDisplayContainer(any(), any()); @@ -190,7 +198,8 @@ public void start_setsAdUiViewGroup() { @Test public void startForAudioOnlyAds_createsAudioOnlyAdDisplayContainer() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.start(adsLoaderListener, audioAdsAdViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, audioAdsAdViewProvider, adsLoaderListener); verify(mockImaFactory, atLeastOnce()) .createAudioAdDisplayContainer(getApplicationContext(), videoAdPlayer); @@ -202,7 +211,8 @@ public void startForAudioOnlyAds_createsAudioOnlyAdDisplayContainer() { public void start_withPlaceholderContent_initializedAdsLoader() { Timeline placeholderTimeline = new PlaceholderTimeline(MediaItem.fromUri(Uri.EMPTY)); setupPlayback(placeholderTimeline, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); // We'll only create the rendering settings when initializing the ads loader. verify(mockImaFactory).createAdsRenderingSettings(); @@ -211,7 +221,8 @@ public void start_withPlaceholderContent_initializedAdsLoader() { @Test public void start_updatesAdPlaybackState() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( @@ -223,16 +234,18 @@ public void start_updatesAdPlaybackState() { public void startAfterRelease() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); } @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.requestAds(TEST_DATA_SPEC, adViewGroup); imaAdsLoader.release(); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); @@ -240,7 +253,7 @@ public void startAndCallbacksAfterRelease() { // Note: we can't currently call getContentProgress/getAdProgress as a VerifyError is thrown // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. - imaAdsLoader.requestAds(adViewGroup); + imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup); adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); @@ -260,7 +273,8 @@ public void playback_withPrerollAd_marksAdAsPlayed() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); @@ -304,7 +318,8 @@ public void playback_withMidrollFetchError_marksAdAsInErrorState() { setupPlayback(CONTENT_TIMELINE, ImmutableList.of(20.5f)); // Simulate loading an empty midroll ad. - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); adEventListener.onAdEvent(mockMidrollFetchErrorAdEvent); assertThat(adsLoaderListener.adPlaybackState) @@ -325,7 +340,8 @@ public void playback_withMidrollFetchError_updatesContentProgress() { setupPlayback(CONTENT_TIMELINE, ImmutableList.of(5.5f)); // Simulate loading an empty midroll ad and advancing the player position. - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); adEventListener.onAdEvent(mockMidrollFetchErrorAdEvent); long playerPositionUs = CONTENT_DURATION_US - C.MICROS_PER_SECOND; long playerPositionInPeriodUs = @@ -350,7 +366,8 @@ public void playback_withPostrollFetchError_marksAdAsInErrorState() { setupPlayback(CONTENT_TIMELINE, ImmutableList.of(-1f)); // Simulate loading an empty postroll ad. - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); adEventListener.onAdEvent(mockPostrollFetchErrorAdEvent); assertThat(adsLoaderListener.adPlaybackState) @@ -373,7 +390,8 @@ public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { setupPlayback(CONTENT_TIMELINE, cuePoints); // Advance playback to just before the midroll and simulate buffering. - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance before the timeout and simulating polling content progress. @@ -397,7 +415,8 @@ public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { setupPlayback(CONTENT_TIMELINE, cuePoints); // Advance playback to just before the midroll and simulate buffering. - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); // Advance past the timeout and simulate polling content progress. @@ -423,7 +442,8 @@ public void resumePlaybackBeforeMidroll_playsPreroll() { setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) @@ -442,7 +462,8 @@ public void resumePlaybackAtMidroll_skipsPreroll() { setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); @@ -467,7 +488,8 @@ public void resumePlaybackAfterMidroll_skipsPreroll() { setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); @@ -499,7 +521,8 @@ public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() { setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) @@ -525,7 +548,8 @@ public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() { setupPlayback(CONTENT_TIMELINE, cuePoints); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); @@ -555,10 +579,12 @@ public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPr .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - TEST_DATA_SPEC); + TEST_DATA_SPEC, + TEST_ADS_ID); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); @@ -588,10 +614,12 @@ public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPrerol .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - TEST_DATA_SPEC); + TEST_DATA_SPEC, + TEST_ADS_ID); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); @@ -621,10 +649,12 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - TEST_DATA_SPEC); + TEST_DATA_SPEC, + TEST_ADS_ID); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockAdsManager).destroy(); assertThat(adsLoaderListener.adPlaybackState) @@ -658,10 +688,12 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - TEST_DATA_SPEC); + TEST_DATA_SPEC, + TEST_ADS_ID); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); @@ -698,10 +730,12 @@ public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skips .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - TEST_DATA_SPEC); + TEST_DATA_SPEC, + TEST_ADS_ID); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); @@ -735,8 +769,9 @@ public void requestAdTagWithDataScheme_requestsWithAdsResponse() throws Exceptio .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - adDataSpec); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + adDataSpec, + TEST_ADS_ID); + imaAdsLoader.start(adsMediaSource, adDataSpec, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockAdsRequest).setAdsResponse(adsResponse); } @@ -750,8 +785,10 @@ public void requestAdTagWithUri_requestsWithAdTagUrl() throws Exception { .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - TEST_DATA_SPEC); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + TEST_DATA_SPEC, + TEST_ADS_ID); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockAdsRequest).setAdTagUrl(TEST_DATA_SPEC.uri.toString()); } @@ -760,7 +797,8 @@ public void requestAdTagWithUri_requestsWithAdTagUrl() throws Exception { public void setsDefaultMimeTypes() throws Exception { setupPlayback(CONTENT_TIMELINE, ImmutableList.of(0f)); imaAdsLoader.setSupportedContentTypes(C.TYPE_DASH, C.TYPE_OTHER); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockAdsRenderingSettings) .setMimeTypes( @@ -783,9 +821,11 @@ public void buildWithAdMediaMimeTypes_setsMimeTypes() throws Exception { .setImaSdkSettings(mockImaSdkSettings) .setAdMediaMimeTypes(ImmutableList.of(MimeTypes.AUDIO_MPEG)) .build(), - TEST_DATA_SPEC); + TEST_DATA_SPEC, + TEST_ADS_ID); imaAdsLoader.setSupportedContentTypes(C.TYPE_OTHER); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); verify(mockAdsRenderingSettings).setMimeTypes(ImmutableList.of(MimeTypes.AUDIO_MPEG)); } @@ -793,7 +833,8 @@ public void buildWithAdMediaMimeTypes_setsMimeTypes() throws Exception { @Test public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup); imaAdsLoader.stop(); @@ -808,7 +849,8 @@ public void loadAd_withLargeAdCuePoint_updatesAdPlaybackStateWithLoadedAd() { float midrollTimeSecs = Float.MAX_VALUE; ImmutableList cuePoints = ImmutableList.of(midrollTimeSecs); setupPlayback(CONTENT_TIMELINE, cuePoints); - imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.start( + adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); videoAdPlayer.loadAd( TEST_AD_MEDIA_INFO, new AdPodInfo() { @@ -860,20 +902,29 @@ private void setupPlayback(Timeline contentTimeline, List cuePoints) { .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) .build(), - TEST_DATA_SPEC); + TEST_DATA_SPEC, + TEST_ADS_ID); } private void setupPlayback( Timeline contentTimeline, List cuePoints, ImaAdsLoader imaAdsLoader, - DataSpec adTagDataSpec) { + DataSpec adTagDataSpec, + Object adsId) { fakeExoPlayer = new FakePlayer(); adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); + adsMediaSource = + new AdsMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + adTagDataSpec, + adsId, + new DefaultMediaSourceFactory((Context) getApplicationContext()), + imaAdsLoader, + adViewProvider); when(mockAdsManager.getAdCuePoints()).thenReturn(cuePoints); this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); - imaAdsLoader.setAdTagDataSpec(adTagDataSpec); } private void setupMocks() { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java index 14c1d6d1e75..33e62c1bcf2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; + import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.offline.StreamKey; @@ -74,6 +77,7 @@ public static final class Builder { @Nullable private String customCacheKey; private List subtitles; @Nullable private Uri adTagUri; + @Nullable private Object adsId; @Nullable private Object tag; @Nullable private MediaMetadata mediaMetadata; private long liveTargetOffsetMs; @@ -112,7 +116,6 @@ private Builder(MediaItem mediaItem) { liveMaxPlaybackSpeed = mediaItem.liveConfiguration.maxPlaybackSpeed; @Nullable PlaybackProperties playbackProperties = mediaItem.playbackProperties; if (playbackProperties != null) { - adTagUri = playbackProperties.adTagUri; customCacheKey = playbackProperties.customCacheKey; mimeType = playbackProperties.mimeType; uri = playbackProperties.uri; @@ -130,6 +133,11 @@ private Builder(MediaItem mediaItem) { drmUuid = drmConfiguration.uuid; drmKeySetId = drmConfiguration.getKeySetId(); } + @Nullable AdsConfiguration adsConfiguration = playbackProperties.adsConfiguration; + if (adsConfiguration != null) { + adTagUri = adsConfiguration.adTagUri; + adsId = adsConfiguration.adsId; + } } } @@ -408,24 +416,56 @@ public Builder setSubtitles(@Nullable List subtitles) { } /** - * Sets the optional ad tag URI. + * Sets the optional ad tag {@link Uri}. + * + *

    All ads media items in the playlist with the same ad tag URI and loader will share the + * same ad playback state. To resume ad playback when recreating the playlist on returning from + * the background, pass the same ad tag URI. * *

    If {@link #setUri} is passed a non-null {@code uri}, the ad tag URI is used to create a * {@link PlaybackProperties} object. Otherwise it will be ignored. + * + * @param adTagUri The ad tag URI to load. */ public Builder setAdTagUri(@Nullable String adTagUri) { - this.adTagUri = adTagUri != null ? Uri.parse(adTagUri) : null; - return this; + return setAdTagUri(adTagUri != null ? Uri.parse(adTagUri) : null); } /** * Sets the optional ad tag {@link Uri}. * + *

    All ads media items in the playlist with the same ad tag URI and loader will share the + * same ad playback state. To resume ad playback when recreating the playlist on returning from + * the background, pass the same ad tag URI. + * *

    If {@link #setUri} is passed a non-null {@code uri}, the ad tag URI is used to create a * {@link PlaybackProperties} object. Otherwise it will be ignored. + * + * @param adTagUri The ad tag URI to load. */ public Builder setAdTagUri(@Nullable Uri adTagUri) { + return setAdTagUri(adTagUri, /* adsId= */ adTagUri); + } + + /** + * Sets the optional ad tag {@link Uri} and ads identifier. + * + *

    All ads media items in the playlist with the same ads identifier and loader will share the + * same ad playback state. + * + *

    If {@link #setUri} is passed a non-null {@code uri}, the ad tag URI is used to create a + * {@link PlaybackProperties} object. Otherwise it will be ignored. + * + * @param adTagUri The ad tag URI to load. + * @param adsId An opaque identifier for ad playback state associated with this item. Must be + * non-null if {@code adTagUri} is non-null. Ad loading and playback state is shared among + * all media items that have the same ads id (by {@link Object#equals(Object) equality}) and + * ads loader, so it is important to pass the same identifiers when constructing playlist + * items each time the player returns to the foreground. + */ + public Builder setAdTagUri(@Nullable Uri adTagUri, @Nullable Object adsId) { this.adTagUri = adTagUri; + this.adsId = adsId; return this; } @@ -517,8 +557,9 @@ public Builder setMediaMetadata(MediaMetadata mediaMetadata) { * Returns a new {@link MediaItem} instance with the current builder values. */ public MediaItem build() { - Assertions.checkState(drmLicenseUri == null || drmUuid != null); + checkState(drmLicenseUri == null || drmUuid != null); @Nullable PlaybackProperties playbackProperties = null; + @Nullable Uri uri = this.uri; if (uri != null) { playbackProperties = new PlaybackProperties( @@ -535,15 +576,15 @@ public MediaItem build() { drmSessionForClearTypes, drmKeySetId) : null, + adTagUri != null ? new AdsConfiguration(adTagUri, checkNotNull(adsId)) : null, streamKeys, customCacheKey, subtitles, - adTagUri, tag); mediaId = mediaId != null ? mediaId : uri.toString(); } return new MediaItem( - Assertions.checkNotNull(mediaId), + checkNotNull(mediaId), new ClippingProperties( clipStartPositionMs, clipEndPositionMs, @@ -656,6 +697,47 @@ public int hashCode() { } } + /** Configuration for playing back linear ads with a media item. */ + public static final class AdsConfiguration { + + public final Uri adTagUri; + public final Object adsId; + + /** + * Creates an ads configuration with the given ad tag URI and ads identifier. + * + * @param adTagUri The ad tag URI to load. + * @param adsId An opaque identifier for ad playback state associated with this item. Ad loading + * and playback state is shared among all media items that have the same ads id (by {@link + * Object#equals(Object) equality}), so it is important to pass the same identifiers when + * constructing playlist items each time the player returns to the foreground. + */ + private AdsConfiguration(Uri adTagUri, Object adsId) { + this.adTagUri = adTagUri; + this.adsId = adsId; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof AdsConfiguration)) { + return false; + } + + AdsConfiguration other = (AdsConfiguration) obj; + return adTagUri.equals(other.adTagUri) && adsId.equals(other.adsId); + } + + @Override + public int hashCode() { + int result = adTagUri.hashCode(); + result = 31 * result + adsId.hashCode(); + return result; + } + } + /** Properties for local playback. */ public static final class PlaybackProperties { @@ -673,6 +755,9 @@ public static final class PlaybackProperties { /** Optional {@link DrmConfiguration} for the media. */ @Nullable public final DrmConfiguration drmConfiguration; + /** Optional ads configuration. */ + @Nullable public final AdsConfiguration adsConfiguration; + /** Optional stream keys by which the manifest is filtered. */ public final List streamKeys; @@ -682,9 +767,6 @@ public static final class PlaybackProperties { /** Optional subtitles to be sideloaded. */ public final List subtitles; - /** Optional ad tag {@link Uri}. */ - @Nullable public final Uri adTagUri; - /** * Optional tag for custom attributes. The tag for the media source which will be published in * the {@code com.google.android.exoplayer2.Timeline} of the source as {@code @@ -696,18 +778,18 @@ private PlaybackProperties( Uri uri, @Nullable String mimeType, @Nullable DrmConfiguration drmConfiguration, + @Nullable AdsConfiguration adsConfiguration, List streamKeys, @Nullable String customCacheKey, List subtitles, - @Nullable Uri adTagUri, @Nullable Object tag) { this.uri = uri; this.mimeType = mimeType; this.drmConfiguration = drmConfiguration; + this.adsConfiguration = adsConfiguration; this.streamKeys = streamKeys; this.customCacheKey = customCacheKey; this.subtitles = subtitles; - this.adTagUri = adTagUri; this.tag = tag; } @@ -724,10 +806,10 @@ public boolean equals(@Nullable Object obj) { return uri.equals(other.uri) && Util.areEqual(mimeType, other.mimeType) && Util.areEqual(drmConfiguration, other.drmConfiguration) + && Util.areEqual(adsConfiguration, other.adsConfiguration) && streamKeys.equals(other.streamKeys) && Util.areEqual(customCacheKey, other.customCacheKey) && subtitles.equals(other.subtitles) - && Util.areEqual(adTagUri, other.adTagUri) && Util.areEqual(tag, other.tag); } @@ -736,10 +818,10 @@ public int hashCode() { int result = uri.hashCode(); result = 31 * result + (mimeType == null ? 0 : mimeType.hashCode()); result = 31 * result + (drmConfiguration == null ? 0 : drmConfiguration.hashCode()); + result = 31 * result + (adsConfiguration == null ? 0 : adsConfiguration.hashCode()); result = 31 * result + streamKeys.hashCode(); result = 31 * result + (customCacheKey == null ? 0 : customCacheKey.hashCode()); result = 31 * result + subtitles.hashCode(); - result = 31 * result + (adTagUri == null ? 0 : adTagUri.hashCode()); result = 31 * result + (tag == null ? 0 : tag.hashCode()); return result; } @@ -1004,7 +1086,7 @@ public int hashCode() { /** Identifies the media item. */ public final String mediaId; - /** Optional playback properties. Maybe be {@code null} if shared over process boundaries. */ + /** Optional playback properties. May be {@code null} if shared over process boundaries. */ @Nullable public final PlaybackProperties playbackProperties; /** The live playback configuration. */ diff --git a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java index 683e3cbf7f9..5cbc5f78cbd 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java @@ -281,7 +281,20 @@ public void builderSetAdTagUri_setsAdTagUri() { MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).setAdTagUri(adTagUri).build(); - assertThat(mediaItem.playbackProperties.adTagUri).isEqualTo(adTagUri); + assertThat(mediaItem.playbackProperties.adsConfiguration.adTagUri).isEqualTo(adTagUri); + assertThat(mediaItem.playbackProperties.adsConfiguration.adsId).isEqualTo(adTagUri); + } + + @Test + public void builderSetAdTagUriAndAdsId_setsAdsConfiguration() { + Uri adTagUri = Uri.parse(URI_STRING + "/ad"); + Object adsId = new Object(); + + MediaItem mediaItem = + new MediaItem.Builder().setUri(URI_STRING).setAdTagUri(adTagUri, adsId).build(); + + assertThat(mediaItem.playbackProperties.adsConfiguration.adTagUri).isEqualTo(adTagUri); + assertThat(mediaItem.playbackProperties.adsConfiguration.adsId).isEqualTo(adsId); } @Test 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 a4b97219d25..a04dbd215ff 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 @@ -18,7 +18,6 @@ import static com.google.android.exoplayer2.util.Util.castNonNull; import android.content.Context; -import android.net.Uri; import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -74,27 +73,28 @@ * *

    Ad support for media items with ad tag URIs

    * - *

    To support media items with {@link MediaItem.PlaybackProperties#adTagUri ad tag URIs}, {@link - * #setAdsLoaderProvider} and {@link #setAdViewProvider} need to be called to configure the factory - * with the required providers. + *

    To support media items with {@link MediaItem.PlaybackProperties#adsConfiguration ads + * configuration}, {@link #setAdsLoaderProvider} and {@link #setAdViewProvider} need to be called to + * configure the factory with the required providers. */ public final class DefaultMediaSourceFactory implements MediaSourceFactory { /** * Provides {@link AdsLoader} instances for media items that have {@link - * MediaItem.PlaybackProperties#adTagUri ad tag URIs}. + * MediaItem.PlaybackProperties#adsConfiguration ad tag URIs}. */ public interface AdsLoaderProvider { /** - * Returns an {@link AdsLoader} for the given {@link MediaItem.PlaybackProperties#adTagUri ad - * tag URI}, or null if no ads loader is available for the given ad tag URI. + * Returns an {@link AdsLoader} for the given {@link + * MediaItem.PlaybackProperties#adsConfiguration ads configuration}, or {@code null} if no ads + * loader is available for the given ads configuration. * *

    This method is called each time a {@link MediaSource} is created from a {@link MediaItem} - * that defines an {@link MediaItem.PlaybackProperties#adTagUri ad tag URI}. + * that defines an {@link MediaItem.PlaybackProperties#adsConfiguration ads configuration}. */ @Nullable - AdsLoader getAdsLoader(Uri adTagUri); + AdsLoader getAdsLoader(MediaItem.AdsConfiguration adsConfiguration); } private static final String TAG = "DefaultMediaSourceFactory"; @@ -171,7 +171,7 @@ public DefaultMediaSourceFactory( /** * Sets the {@link AdsLoaderProvider} that provides {@link AdsLoader} instances for media items - * that have {@link MediaItem.PlaybackProperties#adTagUri ad tag URIs}. + * that have {@link MediaItem.PlaybackProperties#adsConfiguration ads configurations}. * * @param adsLoaderProvider A provider for {@link AdsLoader} instances. * @return This factory, for convenience. @@ -389,8 +389,9 @@ private static MediaSource maybeClipMediaSource(MediaItem mediaItem, MediaSource private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource mediaSource) { Assertions.checkNotNull(mediaItem.playbackProperties); - @Nullable Uri adTagUri = mediaItem.playbackProperties.adTagUri; - if (adTagUri == null) { + @Nullable + MediaItem.AdsConfiguration adsConfiguration = mediaItem.playbackProperties.adsConfiguration; + if (adsConfiguration == null) { return mediaSource; } AdsLoaderProvider adsLoaderProvider = this.adsLoaderProvider; @@ -402,14 +403,15 @@ private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource + " setAdViewProvider."); return mediaSource; } - @Nullable AdsLoader adsLoader = adsLoaderProvider.getAdsLoader(adTagUri); + @Nullable AdsLoader adsLoader = adsLoaderProvider.getAdsLoader(adsConfiguration); if (adsLoader == null) { - Log.w(TAG, "Playing media without ads. No AdsLoader for provided adTagUri"); + Log.w(TAG, "Playing media without ads, as no AdsLoader was provided."); return mediaSource; } return new AdsMediaSource( mediaSource, - new DataSpec(adTagUri), + new DataSpec(adsConfiguration.adTagUri), + adsConfiguration.adsId, /* adMediaSourceFactory= */ this, adsLoader, adViewProvider); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java index fda5e15215d..f0bff82b1f2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java @@ -38,16 +38,17 @@ * with a new copy of the current {@link AdPlaybackState} whenever further information about ads * becomes known (for example, when an ad media URI is available, or an ad has played to the end). * - *

    {@link #start(EventListener, AdViewProvider)} will be called when the ads media source first - * initializes, at which point the loader can request ads. If the player enters the background, - * {@link #stop()} will be called. Loaders should maintain any ad playback state in preparation for - * a later call to {@link #start(EventListener, AdViewProvider)}. If an ad is playing when the - * player is detached, update the ad playback state with the current playback position using {@link + *

    {@link #start(AdsMediaSource, DataSpec, Object, AdViewProvider, EventListener)} will be called + * when an ads media source first initializes, at which point the loader can request ads. If the + * player enters the background, {@link #stop()} will be called. Loaders should maintain any ad + * playback state in preparation for a later call to {@link #start(AdsMediaSource, DataSpec, Object, + * AdViewProvider, EventListener)}. If an ad is playing when the player is detached, update the ad + * playback state with the current playback position using {@link * AdPlaybackState#withAdResumePositionUs(long)}. * *

    If {@link EventListener#onAdPlaybackState(AdPlaybackState)} has been called, the - * implementation of {@link #start(EventListener, AdViewProvider)} should invoke the same listener - * to provide the existing playback state to the new player. + * implementation of {@link #start(AdsMediaSource, DataSpec, Object, AdViewProvider, EventListener)} + * should invoke the same listener to provide the existing playback state to the new player. */ public interface AdsLoader { @@ -190,29 +191,29 @@ public OverlayInfo(View view, @Purpose int purpose, @Nullable String detailedRea /** * Sets the supported content types for ad media. Must be called before the first call to {@link - * #start(EventListener, AdViewProvider)}. Subsequent calls may be ignored. Called on the main - * thread by {@link AdsMediaSource}. + * #start(AdsMediaSource, DataSpec, Object, AdViewProvider, EventListener)}. Subsequent calls may + * be ignored. Called on the main thread by {@link AdsMediaSource}. * * @param contentTypes The supported content types for ad media. Each element must be one of * {@link C#TYPE_DASH}, {@link C#TYPE_HLS}, {@link C#TYPE_SS} and {@link C#TYPE_OTHER}. */ void setSupportedContentTypes(@C.ContentType int... contentTypes); - /** - * Sets the data spec of the ad tag to load. - * - * @param adTagDataSpec The data spec of the ad tag to load. See the implementation's - * documentation for information about compatible ad tag formats. - */ - void setAdTagDataSpec(DataSpec adTagDataSpec); - /** * Starts using the ads loader for playback. Called on the main thread by {@link AdsMediaSource}. * - * @param eventListener Listener for ads loader events. + * @param adsMediaSource The ads media source requesting to start loading ads. + * @param adTagDataSpec A data spec for the ad tag to load. + * @param adsId An opaque identifier for the ad playback state across start/stop calls. * @param adViewProvider Provider of views for the ad UI. + * @param eventListener Listener for ads loader events. */ - void start(EventListener eventListener, AdViewProvider adViewProvider); + void start( + AdsMediaSource adsMediaSource, + DataSpec adTagDataSpec, + Object adsId, + AdViewProvider adViewProvider, + EventListener eventListener); /** * Stops using the ads loader for playback and deregisters the event listener. Called on the main 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 7320f6f6c57..99805122f0c 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 @@ -33,9 +33,7 @@ import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.MediaSourceFactory; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; @@ -128,7 +126,8 @@ public RuntimeException getRuntimeExceptionForUnexpected() { private final MediaSourceFactory adMediaSourceFactory; private final AdsLoader adsLoader; private final AdsLoader.AdViewProvider adViewProvider; - @Nullable private final DataSpec adTagDataSpec; + private final DataSpec adTagDataSpec; + private final Object adsId; private final Handler mainHandler; private final Timeline.Period period; @@ -138,62 +137,16 @@ public RuntimeException getRuntimeExceptionForUnexpected() { @Nullable private AdPlaybackState adPlaybackState; private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders; - /** - * Constructs a new source that inserts ads linearly with the content specified by {@code - * contentMediaSource}. Ad media is loaded using {@link ProgressiveMediaSource}. - * - * @param contentMediaSource The {@link MediaSource} providing the content to play. - * @param dataSourceFactory Factory for data sources used to load ad media. - * @param adsLoader The loader for ads. - * @param adViewProvider Provider of views for the ad UI. - * @deprecated Use {@link AdsMediaSource#AdsMediaSource(MediaSource, DataSpec, MediaSourceFactory, - * AdsLoader, AdsLoader.AdViewProvider)} instead. - */ - @Deprecated - public AdsMediaSource( - MediaSource contentMediaSource, - DataSource.Factory dataSourceFactory, - AdsLoader adsLoader, - AdsLoader.AdViewProvider adViewProvider) { - this( - contentMediaSource, - new ProgressiveMediaSource.Factory(dataSourceFactory), - adsLoader, - adViewProvider, - /* adTagDataSpec= */ null); - } - - /** - * Constructs a new source that inserts ads linearly with the content specified by {@code - * contentMediaSource}. - * - * @param contentMediaSource The {@link MediaSource} providing the content to play. - * @param adMediaSourceFactory Factory for media sources used to load ad media. - * @param adsLoader The loader for ads. - * @param adViewProvider Provider of views for the ad UI. - * @deprecated Use {@link AdsMediaSource#AdsMediaSource(MediaSource, DataSpec, MediaSourceFactory, - * AdsLoader, AdsLoader.AdViewProvider)} instead. - */ - @Deprecated - public AdsMediaSource( - MediaSource contentMediaSource, - MediaSourceFactory adMediaSourceFactory, - AdsLoader adsLoader, - AdsLoader.AdViewProvider adViewProvider) { - this( - contentMediaSource, - adMediaSourceFactory, - adsLoader, - adViewProvider, - /* adTagDataSpec= */ null); - } - /** * Constructs a new source that inserts ads linearly with the content specified by {@code * contentMediaSource}. * * @param contentMediaSource The {@link MediaSource} providing the content to play. * @param adTagDataSpec The data specification of the ad tag to load. + * @param adsId An opaque identifier for ad playback state associated with this instance. Ad + * loading and playback state is shared among all playlist items that have the same ads id (by + * {@link Object#equals(Object) equality}), so it is important to pass the same identifiers + * when constructing playlist items each time the player returns to the foreground. * @param adMediaSourceFactory Factory for media sources used to load ad media. * @param adsLoader The loader for ads. * @param adViewProvider Provider of views for the ad UI. @@ -201,23 +154,16 @@ public AdsMediaSource( public AdsMediaSource( MediaSource contentMediaSource, DataSpec adTagDataSpec, + Object adsId, MediaSourceFactory adMediaSourceFactory, AdsLoader adsLoader, AdsLoader.AdViewProvider adViewProvider) { - this(contentMediaSource, adMediaSourceFactory, adsLoader, adViewProvider, adTagDataSpec); - } - - private AdsMediaSource( - MediaSource contentMediaSource, - MediaSourceFactory adMediaSourceFactory, - AdsLoader adsLoader, - AdsLoader.AdViewProvider adViewProvider, - @Nullable DataSpec adTagDataSpec) { this.contentMediaSource = contentMediaSource; this.adMediaSourceFactory = adMediaSourceFactory; this.adsLoader = adsLoader; this.adViewProvider = adViewProvider; this.adTagDataSpec = adTagDataSpec; + this.adsId = adsId; mainHandler = new Handler(Looper.getMainLooper()); period = new Timeline.Period(); adMediaSourceHolders = new AdMediaSourceHolder[0][]; @@ -247,12 +193,13 @@ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferLis this.componentListener = componentListener; prepareChildSource(CHILD_SOURCE_MEDIA_PERIOD_ID, contentMediaSource); mainHandler.post( - () -> { - if (adTagDataSpec != null) { - adsLoader.setAdTagDataSpec(adTagDataSpec); - } - adsLoader.start(componentListener, adViewProvider); - }); + () -> + adsLoader.start( + /* adsMediaSource= */ this, + adTagDataSpec, + adsId, + adViewProvider, + componentListener)); } @Override 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 27aa7f3b00c..3dab9d9a65d 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 @@ -5570,6 +5570,7 @@ public void setMediaSources_secondAdMediaSource_throws() throws Exception { new AdsMediaSource( new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + /* adsId= */ new Object(), new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); @@ -5608,6 +5609,7 @@ public void setMediaSources_multipleMediaSourcesWithAd_throws() throws Exception new AdsMediaSource( mediaSource, /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + /* adsId= */ new Object(), new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); @@ -5648,6 +5650,7 @@ public void setMediaSources_addingMediaSourcesWithAdToNonEmptyPlaylist_throws() new AdsMediaSource( mediaSource, /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + /* adsId= */ new Object(), new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); @@ -9018,10 +9021,12 @@ public void release() {} public void setSupportedContentTypes(int... contentTypes) {} @Override - public void setAdTagDataSpec(DataSpec adTagDataSpec) {} - - @Override - public void start(AdsLoader.EventListener eventListener, AdViewProvider adViewProvider) {} + public void start( + AdsMediaSource adsMediaSource, + DataSpec adTagDataSpec, + Object adsId, + AdViewProvider adViewProvider, + AdsLoader.EventListener eventListener) {} @Override public void stop() {} @@ -9050,11 +9055,6 @@ public ImmutableList getAdOverlayInfos() { * Returns an argument matcher for {@link Timeline} instances that ignores period and window uids. */ private static ArgumentMatcher noUid(Timeline timeline) { - return new ArgumentMatcher() { - @Override - public boolean matches(Timeline argument) { - return new NoUidTimeline(timeline).equals(new NoUidTimeline(argument)); - } - }; + return argument -> new NoUidTimeline(timeline).equals(new NoUidTimeline(argument)); } } 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 08200f93f33..2af226b23ad 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 @@ -204,7 +204,7 @@ public void createMediaSource_withAdTagUri_callsAdsLoader() { MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(adTagUri).build(); DefaultMediaSourceFactory defaultMediaSourceFactory = new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) - .setAdsLoaderProvider(ignoredAdTagUri -> mock(AdsLoader.class)) + .setAdsLoaderProvider(ignoredAdsConfiguration -> mock(AdsLoader.class)) .setAdViewProvider(mock(AdsLoader.AdViewProvider.class)); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); 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 8395fcb1f4e..7fcd740d5f8 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 @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener; import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DataSpec; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -83,6 +84,9 @@ public final class AdsMediaSourceTest { .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0); + private static final DataSpec TEST_ADS_DATA_SPEC = new DataSpec(Uri.EMPTY); + private static final Object TEST_ADS_ID = new Object(); + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); private FakeMediaSource contentMediaSource; @@ -107,10 +111,21 @@ public void setUp() { ArgumentCaptor.forClass(AdsLoader.EventListener.class); adsMediaSource = new AdsMediaSource( - contentMediaSource, adMediaSourceFactory, mockAdsLoader, mockAdViewProvider); + contentMediaSource, + TEST_ADS_DATA_SPEC, + TEST_ADS_ID, + adMediaSourceFactory, + mockAdsLoader, + mockAdViewProvider); adsMediaSource.prepareSource(mockMediaSourceCaller, /* mediaTransferListener= */ null); shadowOf(Looper.getMainLooper()).idle(); - verify(mockAdsLoader).start(eventListenerArgumentCaptor.capture(), eq(mockAdViewProvider)); + verify(mockAdsLoader) + .start( + eq(adsMediaSource), + eq(TEST_ADS_DATA_SPEC), + eq(TEST_ADS_ID), + eq(mockAdViewProvider), + eventListenerArgumentCaptor.capture()); // Simulate loading a preroll ad. AdsLoader.EventListener adsLoaderEventListener = eventListenerArgumentCaptor.getValue(); From c04dd8b32821db77f18623a0dcc8ed1411ca5400 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 3 Nov 2020 18:46:11 +0000 Subject: [PATCH 240/693] Use blocking HLS media playlist reload for segments Issue: #5011 PiperOrigin-RevId: 340477795 --- library/hls/build.gradle | 1 + .../playlist/DefaultHlsPlaylistTracker.java | 104 +++--- .../DefaultHlsPlaylistTrackerTest.java | 301 ++++++++++-------- .../live_low_latency_media_can_block_reload | 13 + ...ve_low_latency_media_can_block_reload_next | 13 + ...live_low_latency_media_can_skip_dateranges | 2 +- .../live_low_latency_media_can_skip_skipped | 2 +- ...skip_skipped_media_sequence_no_overlapping | 2 +- .../live_low_latency_media_can_skip_until | 2 +- ...ency_media_can_skip_until_and_block_reload | 17 + ...media_can_skip_until_and_block_reload_next | 17 + ...n_skip_until_and_block_reload_next_skipped | 14 + 12 files changed, 319 insertions(+), 169 deletions(-) create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_next create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next_skipped diff --git a/library/hls/build.gradle b/library/hls/build.gradle index cefa8418164..d31f1ce6a23 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -32,6 +32,7 @@ dependencies { testImplementation project(modulePrefix + 'robolectricutils') testImplementation project(modulePrefix + 'testutils') testImplementation project(modulePrefix + 'testdata') + testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } 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 c97cdd376a4..d4922216867 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source.hls.playlist; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; import static java.lang.Math.max; import android.net.Uri; @@ -31,6 +32,7 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.upstream.Loader; @@ -216,7 +218,7 @@ public void maybeThrowPlaylistRefreshError(Uri url) throws IOException { @Override public void refreshPlaylist(Uri url) { - playlistBundles.get(url).loadPlaylist(); + playlistBundles.get(url).loadPlaylist(url); } @Override @@ -241,7 +243,6 @@ public void onLoadCompleted( mediaPlaylistParser = playlistParserFactory.createPlaylistParser(masterPlaylist); primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url; createBundles(masterPlaylist.mediaPlaylistUrls); - MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -251,11 +252,12 @@ public void onLoadCompleted( elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + 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, loadEventInfo); } else { - primaryBundle.loadPlaylist(); + primaryBundle.loadPlaylist(primaryMediaPlaylistUrl); } loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); @@ -320,7 +322,7 @@ private boolean maybeSelectNewPrimaryUrl() { MediaPlaylistBundle bundle = playlistBundles.get(variants.get(i).url); if (currentTimeMs > bundle.excludeUntilMs) { primaryMediaPlaylistUrl = bundle.playlistUrl; - bundle.loadPlaylist(); + bundle.loadPlaylist(primaryMediaPlaylistUrl); return true; } } @@ -336,7 +338,7 @@ private void maybeSetPrimaryUrl(Uri url) { return; } primaryMediaPlaylistUrl = url; - playlistBundles.get(primaryMediaPlaylistUrl).loadPlaylist(); + playlistBundles.get(primaryMediaPlaylistUrl).loadPlaylist(url); } /** Returns whether any of the variants in the master playlist have the specified playlist URL. */ @@ -460,8 +462,10 @@ private static Segment getFirstOldOverlappingSegment( } /** Holds all information related to a specific Media Playlist. */ - private final class MediaPlaylistBundle - implements Loader.Callback>, Runnable { + private final class MediaPlaylistBundle implements Loader.Callback> { + + private static final String BLOCK_MSN_PARAM = "_HLS_msn"; + private static final String SKIP_PARAM = "_HLS_skip"; private final Uri playlistUrl; private final Loader mediaPlaylistLoader; @@ -502,7 +506,12 @@ public void release() { mediaPlaylistLoader.release(); } - public void loadPlaylist() { + /** + * Loads the playlist. + * + * @param requestUri The URI to be used for loading the playlist. + */ + public void loadPlaylist(Uri requestUri) { excludeUntilMs = 0; if (loadPending || mediaPlaylistLoader.isLoading() || mediaPlaylistLoader.hasFatalError()) { // Load already pending, in progress, or a fatal error has been encountered. Do nothing. @@ -511,9 +520,14 @@ public void loadPlaylist() { long currentTimeMs = SystemClock.elapsedRealtime(); if (currentTimeMs < earliestNextLoadTimeMs) { loadPending = true; - playlistRefreshHandler.postDelayed(this, earliestNextLoadTimeMs - currentTimeMs); + playlistRefreshHandler.postDelayed( + () -> { + loadPending = false; + loadPlaylistImmediately(requestUri); + }, + earliestNextLoadTimeMs - currentTimeMs); } else { - loadPlaylistImmediately(); + loadPlaylistImmediately(requestUri); } } @@ -585,6 +599,19 @@ public LoadErrorAction onLoadError( elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + boolean isBlockingRequest = loadable.getUri().getQueryParameter(BLOCK_MSN_PARAM) != null; + if (isBlockingRequest && error instanceof HttpDataSource.InvalidResponseCodeException) { + int responseCode = ((HttpDataSource.InvalidResponseCodeException) error).responseCode; + if (responseCode == 400 || responseCode == 503) { + // Intercept bad request and service unavailable to force a full, non-blocking request + // (see RFC 8216, section 6.2.5.2). + earliestNextLoadTimeMs = SystemClock.elapsedRealtime(); + loadPlaylist(/* requestUri= */ playlistUrl); + castNonNull(eventDispatcher) + .loadError(loadEventInfo, loadable.type, error, /* wasCanceled= */ true); + return Loader.DONT_RETRY; + } + } MediaLoadData mediaLoadData = new MediaLoadData(loadable.type); LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); @@ -616,21 +643,13 @@ public LoadErrorAction onLoadError( return loadErrorAction; } - // Runnable implementation. - - @Override - public void run() { - loadPending = false; - loadPlaylistImmediately(); - } - // Internal methods. - private void loadPlaylistImmediately() { + private void loadPlaylistImmediately(Uri playlistRequestUri) { ParsingLoadable mediaPlaylistLoadable = new ParsingLoadable<>( mediaPlaylistDataSource, - getMediaPlaylistUriForRequest(playlistUrl, playlistSnapshot), + playlistRequestUri, C.DATA_TYPE_MANIFEST, mediaPlaylistParser); long elapsedRealtime = @@ -685,31 +704,42 @@ private void processLoadedPlaylist( } } } - // Do not allow the playlist to load again within the target duration if we obtained a new - // snapshot, or half the target duration otherwise. - earliestNextLoadTimeMs = - currentTimeMs - + C.usToMs( - playlistSnapshot != oldPlaylist - ? playlistSnapshot.targetDurationUs - : (playlistSnapshot.targetDurationUs / 2)); + long durationUntilNextLoadUs = 0L; + if (!playlistSnapshot.serverControl.canBlockReload) { + // If blocking requests are not supported, do not allow the playlist to load again within + // the target duration if we obtained a new snapshot, or half the target duration otherwise. + durationUntilNextLoadUs = + playlistSnapshot != oldPlaylist + ? playlistSnapshot.targetDurationUs + : (playlistSnapshot.targetDurationUs / 2); + } + earliestNextLoadTimeMs = currentTimeMs + C.usToMs(durationUntilNextLoadUs); // Schedule a load if this is the primary playlist and it doesn't have an end tag. Else the // next load will be scheduled when refreshPlaylist is called, or when this playlist becomes // the primary. if (playlistUrl.equals(primaryMediaPlaylistUrl) && !playlistSnapshot.hasEndTag) { - loadPlaylist(); + loadPlaylist(getMediaPlaylistUriForReload()); } } - private Uri getMediaPlaylistUriForRequest( - Uri playlistUri, @Nullable HlsMediaPlaylist currentMediaPlaylist) { - if (currentMediaPlaylist == null - || currentMediaPlaylist.serverControl.skipUntilUs == C.TIME_UNSET) { - return playlistUri; + private Uri getMediaPlaylistUriForReload() { + if (playlistSnapshot == null + || (playlistSnapshot.serverControl.skipUntilUs == C.TIME_UNSET + && !playlistSnapshot.serverControl.canBlockReload)) { + return playlistUrl; + } + Uri.Builder uriBuilder = playlistUrl.buildUpon(); + if (playlistSnapshot.serverControl.skipUntilUs != C.TIME_UNSET) { + uriBuilder.appendQueryParameter( + SKIP_PARAM, playlistSnapshot.serverControl.canSkipDateRanges ? "v2" : "YES"); + } + if (playlistSnapshot.serverControl.canBlockReload) { + long reloadMediaSequence = + playlistSnapshot.mediaSequence + + playlistSnapshot.segments.size() + + playlistSnapshot.skippedSegmentCount; + uriBuilder.appendQueryParameter(BLOCK_MSN_PARAM, String.valueOf(reloadMediaSequence)); } - Uri.Builder uriBuilder = playlistUri.buildUpon(); - uriBuilder.appendQueryParameter( - "_HLS_skip", currentMediaPlaylist.serverControl.canSkipDateRanges ? "v2" : "YES"); return uriBuilder.build(); } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java index 798d4658b30..e741c1802f6 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java @@ -15,32 +15,28 @@ */ package com.google.android.exoplayer2.source.hls.playlist; -import static com.google.android.exoplayer2.util.Assertions.checkArgument; -import static com.google.android.exoplayer2.util.Assertions.checkState; import static com.google.common.truth.Truth.assertThat; 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.robolectric.RobolectricUtil; import com.google.android.exoplayer2.source.MediaSourceEventListener; -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.ByteArrayDataSource; import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; -import com.google.android.exoplayer2.upstream.TransferListener; import java.io.IOException; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.Queue; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -64,24 +60,51 @@ public class DefaultHlsPlaylistTrackerTest { "media/m3u8/live_low_latency_media_can_not_skip"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_NOT_SKIP_NEXT = "media/m3u8/live_low_latency_media_can_not_skip_next"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD = + "media/m3u8/live_low_latency_media_can_block_reload"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_NEXT = + "media/m3u8/live_low_latency_media_can_block_reload_next"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD = + "media/m3u8/live_low_latency_media_can_skip_until_and_block_reload"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD_NEXT = + "media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD_NEXT_SKIPPED = + "media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next_skipped"; + + private MockWebServer mockWebServer; + private int enqueueCounter; + private int assertedRequestCounter; + + @Before + public void setUp() { + mockWebServer = new MockWebServer(); + enqueueCounter = 0; + assertedRequestCounter = 0; + } - @Test - public void start_playlistCanNotSkip_requestsFullUpdate() throws IOException, TimeoutException { + @After + public void tearDown() throws IOException { + assertThat(assertedRequestCounter).isEqualTo(enqueueCounter); + mockWebServer.shutdown(); + } - Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); - Queue dataSourceQueue = new ArrayDeque<>(); - dataSourceQueue.add(new ByteArrayDataSource(getBytes(SAMPLE_M3U8_LIVE_MASTER))); - dataSourceQueue.add( - new DataSourceList( - new ByteArrayDataSource(getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_NOT_SKIP)), - new ByteArrayDataSource(getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_NOT_SKIP_NEXT)))); + @Test + public void start_playlistCanNotSkip_requestsFullUpdate() + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] {"master.m3u8", "/media0/playlist.m3u8", "/media0/playlist.m3u8"}, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_NOT_SKIP), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_NOT_SKIP_NEXT)); List mediaPlaylists = runPlaylistTrackerAndCollectMediaPlaylists( - /* dataSourceFactory= */ dataSourceQueue::remove, - masterPlaylistUri, + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), /* awaitedMediaPlaylistCount= */ 2); + assertRequestUrlsCalled(httpUrls); HlsMediaPlaylist firstFullPlaylist = mediaPlaylists.get(0); assertThat(firstFullPlaylist.mediaSequence).isEqualTo(10); assertThat(firstFullPlaylist.segments.get(0).url).isEqualTo("fileSequence10.ts"); @@ -98,22 +121,23 @@ public void start_playlistCanNotSkip_requestsFullUpdate() throws IOException, Ti @Test public void start_playlistCanSkip_requestsDeltaUpdateAndExpandsSkippedSegments() - throws IOException, TimeoutException { - Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); - Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8"); - Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "?_HLS_skip=YES"); - FakeDataSet fakeDataSet = - new FakeDataSet() - .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER)) - .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL)) - .setData(mediaPlaylistSkippedUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", "/media0/playlist.m3u8", "/media0/playlist.m3u8?_HLS_skip=YES" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); List mediaPlaylists = runPlaylistTrackerAndCollectMediaPlaylists( - new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), - masterPlaylistUri, + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), /* awaitedMediaPlaylistCount= */ 2); + assertRequestUrlsCalled(httpUrls); HlsMediaPlaylist initialPlaylistWithAllSegments = mediaPlaylists.get(0); assertThat(initialPlaylistWithAllSegments.mediaSequence).isEqualTo(10); assertThat(initialPlaylistWithAllSegments.segments).hasSize(6); @@ -131,24 +155,23 @@ public void start_playlistCanSkip_requestsDeltaUpdateAndExpandsSkippedSegments() @Test public void start_playlistCanSkip_missingSegments_correctedMediaSequence() - throws IOException, TimeoutException { - Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); - Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8"); - Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "?_HLS_skip=YES"); - FakeDataSet fakeDataSet = - new FakeDataSet() - .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER)) - .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL)) - .setData( - mediaPlaylistSkippedUri, - getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED_MEDIA_SEQUENCE_NO_OVERLAPPING)); + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", "/media0/playlist.m3u8", "/media0/playlist.m3u8?_HLS_skip=YES" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED_MEDIA_SEQUENCE_NO_OVERLAPPING)); List mediaPlaylists = runPlaylistTrackerAndCollectMediaPlaylists( - new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), - masterPlaylistUri, + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), /* awaitedMediaPlaylistCount= */ 2); + assertRequestUrlsCalled(httpUrls); HlsMediaPlaylist initialPlaylistWithAllSegments = mediaPlaylists.get(0); assertThat(initialPlaylistWithAllSegments.mediaSequence).isEqualTo(10); assertThat(initialPlaylistWithAllSegments.segments).hasSize(6); @@ -160,23 +183,23 @@ public void start_playlistCanSkip_missingSegments_correctedMediaSequence() @Test public void start_playlistCanSkipDataRanges_requestsDeltaUpdateV2() - throws IOException, TimeoutException { - Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); - Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8"); - // Expect _HLS_skip parameter with value v2. - Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "?_HLS_skip=v2"); - FakeDataSet fakeDataSet = - new FakeDataSet() - .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER)) - .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_DATERANGES)) - .setData(mediaPlaylistSkippedUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", "/media0/playlist.m3u8", "/media0/playlist.m3u8?_HLS_skip=v2" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_DATERANGES), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); List mediaPlaylists = runPlaylistTrackerAndCollectMediaPlaylists( - new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), - masterPlaylistUri, + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), /* awaitedMediaPlaylistCount= */ 2); + assertRequestUrlsCalled(httpUrls); // Finding the media sequence of the second playlist request asserts that the second request has // been made with the correct uri parameter appended. assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(11); @@ -184,29 +207,104 @@ public void start_playlistCanSkipDataRanges_requestsDeltaUpdateV2() @Test public void start_playlistCanSkipAndUriWithParams_preservesOriginalParams() - throws IOException, TimeoutException { - Uri masterPlaylistUri = Uri.parse("fake://foo.bar/master.m3u8"); - Uri mediaPlaylistUri = Uri.parse("fake://foo.bar/media0/playlist.m3u8?param1=1¶m2=2"); - // Expect _HLS_skip parameter appended with an ampersand. - Uri mediaPlaylistSkippedUri = Uri.parse(mediaPlaylistUri + "&_HLS_skip=YES"); - FakeDataSet fakeDataSet = - new FakeDataSet() - .setData(masterPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MASTER_MEDIA_URI_WITH_PARAM)) - .setData(mediaPlaylistUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL)) - .setData(mediaPlaylistSkippedUri, getBytes(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", + "/media0/playlist.m3u8?param1=1¶m2=2", + "/media0/playlist.m3u8?param1=1¶m2=2&_HLS_skip=YES" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER_MEDIA_URI_WITH_PARAM), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_SKIPPED)); List mediaPlaylists = runPlaylistTrackerAndCollectMediaPlaylists( - new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), - masterPlaylistUri, + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), /* awaitedMediaPlaylistCount= */ 2); + assertRequestUrlsCalled(httpUrls); // Finding the media sequence of the second playlist request asserts that the second request has // been made with the original uri parameters preserved and the additional param concatenated // correctly. assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(11); } + @Test + public void start_playlistCanBlockReload_requestBlockingReloadWithCorrectMediaSequence() + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", "/media0/playlist.m3u8", "/media0/playlist.m3u8?_HLS_msn=14" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_NEXT)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), + /* awaitedMediaPlaylistCount= */ 2); + + assertRequestUrlsCalled(httpUrls); + assertThat(mediaPlaylists.get(0).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(11); + } + + @Test + public void start_httpBadRequest_forcesFullNonBlockingPlaylistRequest() + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", + "/media0/playlist.m3u8", + "/media0/playlist.m3u8?_HLS_skip=YES&_HLS_msn=16", + "/media0/playlist.m3u8", + "/media0/playlist.m3u8?_HLS_skip=YES&_HLS_msn=17" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD), + new MockResponse().setResponseCode(400), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD_NEXT), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD_NEXT_SKIPPED)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + /* dataSourceFactory= */ new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), + /* awaitedMediaPlaylistCount= */ 3); + + assertRequestUrlsCalled(httpUrls); + assertThat(mediaPlaylists.get(0).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(11); + assertThat(mediaPlaylists.get(2).mediaSequence).isEqualTo(12); + } + + private List enqueueWebServerResponses(String[] paths, MockResponse... mockResponses) { + assertThat(paths).hasLength(mockResponses.length); + for (MockResponse mockResponse : mockResponses) { + enqueueCounter++; + mockWebServer.enqueue(mockResponse); + } + List urls = new ArrayList<>(); + for (String path : paths) { + urls.add(mockWebServer.url(path)); + } + return urls; + } + + private void assertRequestUrlsCalled(List httpUrls) throws InterruptedException { + for (HttpUrl url : httpUrls) { + assertedRequestCounter++; + assertThat(url.toString()).endsWith(mockWebServer.takeRequest().getPath()); + } + } + private static List runPlaylistTrackerAndCollectMediaPlaylists( DataSource.Factory dataSourceFactory, Uri masterPlaylistUri, int awaitedMediaPlaylistCount) throws TimeoutException { @@ -227,70 +325,17 @@ private static List runPlaylistTrackerAndCollectMediaPlaylists playlistCounter.addAndGet(1); }); - RobolectricUtil.runMainLooperUntil(() -> playlistCounter.get() == awaitedMediaPlaylistCount); + RobolectricUtil.runMainLooperUntil(() -> playlistCounter.get() >= awaitedMediaPlaylistCount); defaultHlsPlaylistTracker.stop(); return mediaPlaylists; } - private static byte[] getBytes(String filename) throws IOException { - return TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), filename); + private static MockResponse getMockResponse(String assetFile) throws IOException { + return new MockResponse().setResponseCode(200).setBody(new Buffer().write(getBytes(assetFile))); } - private static final class DataSourceList implements DataSource { - - private final DataSource[] dataSources; - - private DataSource delegate; - private int index; - - /** - * Creates an instance. - * - * @param dataSources The data sources to delegate to. - */ - public DataSourceList(DataSource... dataSources) { - checkArgument(dataSources.length > 0); - this.dataSources = dataSources; - delegate = dataSources[index++]; - } - - @Override - public void addTransferListener(TransferListener transferListener) { - for (DataSource dataSource : dataSources) { - dataSource.addTransferListener(transferListener); - } - } - - @Override - public long open(DataSpec dataSpec) throws IOException { - checkState(index <= dataSources.length); - return delegate.open(dataSpec); - } - - @Override - public int read(byte[] buffer, int offset, int readLength) throws IOException { - return delegate.read(buffer, offset, readLength); - } - - @Override - @Nullable - public Uri getUri() { - return delegate.getUri(); - } - - @Override - public Map> getResponseHeaders() { - return delegate.getResponseHeaders(); - } - - @Override - public void close() throws IOException { - delegate.close(); - if (index < dataSources.length) { - delegate = dataSources[index]; - } - index++; - } + private static byte[] getBytes(String filename) throws IOException { + return TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), filename); } } diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload new file mode 100644 index 00000000000..3dc699d246d --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_next b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_next new file mode 100644 index 00000000000..d635cb4c03c --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_next @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:11 +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges index b3ccbaad3c9..a2094126d7c 100644 --- a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_dateranges @@ -1,4 +1,5 @@ #EXTM3U +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24,CAN-SKIP-DATERANGES=YES #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:3 #EXT-X-MEDIA-SEQUENCE:10 @@ -14,4 +15,3 @@ fileSequence13.ts fileSequence14.ts #EXTINF:4.00000, fileSequence15.ts -#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24,CAN-SKIP-DATERANGES=YES diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped index 05a9fdefb14..78a0978d935 100644 --- a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped @@ -1,4 +1,5 @@ #EXTM3U +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:9 #EXT-X-MEDIA-SEQUENCE:11 @@ -11,4 +12,3 @@ fileSequence14.ts fileSequence15.ts #EXTINF:4.00000, fileSequence16.ts -#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping index 639b7f5af42..c81c948a212 100644 --- a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_skipped_media_sequence_no_overlapping @@ -1,4 +1,5 @@ #EXTM3U +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:9 #EXT-X-MEDIA-SEQUENCE:20 @@ -11,4 +12,3 @@ fileSequence23.ts fileSequence24.ts #EXTINF:4.00000, fileSequence25.ts -#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until index 140fe5556a0..5c439eb7e1b 100644 --- a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until @@ -1,4 +1,5 @@ #EXTM3U +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:3 #EXT-X-MEDIA-SEQUENCE:10 @@ -14,4 +15,3 @@ fileSequence13.ts fileSequence14.ts #EXTINF:4.00000, fileSequence15.ts -#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24 diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload new file mode 100644 index 00000000000..7ba49fa66b6 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24,CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next new file mode 100644 index 00000000000..4a2a30fa7d1 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24,CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:11 +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts +#EXTINF:4.00000, +fileSequence16.ts diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next_skipped b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next_skipped new file mode 100644 index 00000000000..4c2b636862b --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_skip_until_and_block_reload_next_skipped @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24,CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-SKIP:SKIPPED-SEGMENTS=2 +#EXT-X-MEDIA-SEQUENCE:12 +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts +#EXTINF:4.00000, +fileSequence16.ts +#EXTINF:4.00000, +fileSequence17.ts From ae17e6d6f83add0475f4a3ab0dff1c88fd8168b8 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 4 Nov 2020 09:42:57 +0000 Subject: [PATCH 241/693] Forward Timeline and period id to TrackSelection.Factory This information is already available in the MappingTrackSelector, but not currently forwarded to the TrackSelection.Factory. This makes it more complicated (or impossible) to depend on period or manifest information in the track selection (for example to only select tracks which are cached for the current format). PiperOrigin-RevId: 340605886 --- RELEASENOTES.md | 1 + .../exoplayer2/offline/DownloadHelper.java | 5 ++++- .../trackselection/AdaptiveTrackSelection.java | 7 ++++++- .../trackselection/DefaultTrackSelector.java | 14 ++++++++++---- .../trackselection/FixedTrackSelection.java | 7 ++++++- .../trackselection/MappingTrackSelector.java | 15 ++++++++++++--- .../trackselection/RandomTrackSelection.java | 7 ++++++- .../exoplayer2/trackselection/TrackSelection.java | 10 +++++++++- .../trackselection/MappingTrackSelectorTest.java | 8 ++++---- .../exoplayer2/testutil/FakeTrackSelector.java | 7 ++++++- 10 files changed, 64 insertions(+), 17 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5c02cbc567b..c8efd4c1f53 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -25,6 +25,7 @@ ([#8103](https://github.com/google/ExoPlayer/issues/8103)). * Track selection: * Add option to specify multiple preferred audio or text languages. + * Forward `Timeline` and `MediaPeriodId` to `TrackSelection.Factory`. * UI: * Show overflow button in `StyledPlayerControlView` only when there is not enough space. 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 ba8a799381a..19b6389a6ac 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 @@ -1070,7 +1070,10 @@ private static final class Factory implements TrackSelection.Factory { @Override public @NullableType TrackSelection[] createTrackSelections( - @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + @NullableType Definition[] definitions, + BandwidthMeter bandwidthMeter, + MediaPeriodId mediaPeriodId, + Timeline timeline) { @NullableType TrackSelection[] selections = new TrackSelection[definitions.length]; for (int i = 0; i < definitions.length; i++) { selections[i] = 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 3173188cac3..e6ad3e869d8 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 @@ -21,6 +21,8 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; @@ -129,7 +131,10 @@ public Factory( @Override public final @NullableType TrackSelection[] createTrackSelections( - @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + @NullableType Definition[] definitions, + BandwidthMeter bandwidthMeter, + MediaPeriodId mediaPeriodId, + Timeline timeline) { TrackSelection[] selections = new TrackSelection[definitions.length]; int totalFixedBandwidth = 0; for (int i = 0; i < definitions.length; i++) { 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 c9f0e290c99..8aec101e6d6 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 @@ -34,6 +34,8 @@ import com.google.android.exoplayer2.RendererCapabilities.Capabilities; import com.google.android.exoplayer2.RendererCapabilities.FormatSupport; import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.util.Assertions; @@ -1603,7 +1605,9 @@ public void experimentalAllowMultipleAdaptiveSelections() { selectTracks( MappedTrackInfo mappedTrackInfo, @Capabilities int[][][] rendererFormatSupports, - @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupports) + @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupports, + MediaPeriodId mediaPeriodId, + Timeline timeline) throws ExoPlaybackException { Parameters params = parametersReference.get(); int rendererCount = mappedTrackInfo.getRendererCount(); @@ -1636,7 +1640,8 @@ public void experimentalAllowMultipleAdaptiveSelections() { @NullableType TrackSelection[] rendererTrackSelections = - trackSelectionFactory.createTrackSelections(definitions, getBandwidthMeter()); + trackSelectionFactory.createTrackSelections( + definitions, getBandwidthMeter(), mediaPeriodId, timeline); // Initialize the renderer configurations to the default configuration for all renderers with // selections, and null otherwise. @@ -1665,8 +1670,9 @@ public void experimentalAllowMultipleAdaptiveSelections() { // Track selection prior to overrides and disabled flags being applied. /** - * Called from {@link #selectTracks(MappedTrackInfo, int[][][], int[])} to make a track selection - * for each renderer, prior to overrides and disabled flags being applied. + * Called from {@link #selectTracks(MappedTrackInfo, int[][][], int[], MediaPeriodId, Timeline)} + * to make a track selection for each renderer, prior to overrides and disabled flags being + * applied. * *

    The implementation should not account for overrides and disabled flags. Track selections * generated by this method will be overridden to account for these properties. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java index fefad00cbd1..91b21bac65f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java @@ -17,6 +17,8 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; @@ -57,7 +59,10 @@ public Factory(int reason, @Nullable Object data) { @Override public @NullableType TrackSelection[] createTrackSelections( - @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + @NullableType Definition[] definitions, + BandwidthMeter bandwidthMeter, + MediaPeriodId mediaPeriodId, + Timeline timeline) { return TrackSelectionUtil.createTrackSelectionsForDefinitions( definitions, definition -> diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java index 16c63353ee4..3086027e1de 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java @@ -355,7 +355,7 @@ public final void onSelectionActivated(@Nullable Object info) { public final TrackSelectorResult selectTracks( RendererCapabilities[] rendererCapabilities, TrackGroupArray trackGroups, - MediaPeriodId periodId, + MediaPeriodId mediaPeriodId, Timeline timeline) throws ExoPlaybackException { // Structures into which data will be written during the selection. The extra item at the end @@ -431,7 +431,11 @@ public final TrackSelectorResult selectTracks( Pair<@NullableType RendererConfiguration[], @NullableType TrackSelection[]> result = selectTracks( - mappedTrackInfo, rendererFormatSupports, rendererMixedMimeTypeAdaptationSupports); + mappedTrackInfo, + rendererFormatSupports, + rendererMixedMimeTypeAdaptationSupports, + mediaPeriodId, + timeline); return new TrackSelectorResult(result.first, result.second, mappedTrackInfo); } @@ -443,6 +447,9 @@ public final TrackSelectorResult selectTracks( * renderer, track group and track (in that order). * @param rendererMixedMimeTypeAdaptationSupport The {@link AdaptiveSupport} for mixed MIME type * adaptation for the renderer. + * @param mediaPeriodId The {@link MediaPeriodId} of the period for which tracks are to be + * selected. + * @param timeline The {@link Timeline} holding the period for which tracks are to be selected. * @return A pair consisting of the track selections and configurations for each renderer. A null * configuration indicates the renderer should be disabled, in which case the track selection * will also be null. A track selection may also be null for a non-disabled renderer if {@link @@ -453,7 +460,9 @@ public final TrackSelectorResult selectTracks( selectTracks( MappedTrackInfo mappedTrackInfo, @Capabilities int[][][] rendererFormatSupports, - @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupport) + @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupport, + MediaPeriodId mediaPeriodId, + Timeline timeline) throws ExoPlaybackException; /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java index 4b9b72715a4..5f0ab76d6f4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java @@ -18,6 +18,8 @@ import android.os.SystemClock; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; @@ -51,7 +53,10 @@ public Factory(int seed) { @Override public @NullableType TrackSelection[] createTrackSelections( - @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + @NullableType Definition[] definitions, + BandwidthMeter bandwidthMeter, + MediaPeriodId mediaPeriodId, + Timeline timeline) { return TrackSelectionUtil.createTrackSelectionsForDefinitions( definitions, definition -> new RandomTrackSelection(definition.group, definition.tracks, random)); 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 5e703438f86..c41d19f0839 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 @@ -18,6 +18,8 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.chunk.MediaChunk; @@ -84,12 +86,18 @@ interface Factory { * * @param definitions A {@link Definition} array. May include null values. * @param bandwidthMeter A {@link BandwidthMeter} which can be used to select tracks. + * @param mediaPeriodId The {@link MediaPeriodId} of the period for which tracks are to be + * selected. + * @param timeline The {@link Timeline} holding the period for which tracks are to be selected. * @return The created selections. Must have the same length as {@code definitions} and may * include null values. */ @NullableType TrackSelection[] createTrackSelections( - @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter); + @NullableType Definition[] definitions, + BandwidthMeter bandwidthMeter, + MediaPeriodId mediaPeriodId, + Timeline timeline); } /** diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/MappingTrackSelectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/MappingTrackSelectorTest.java index 5d5508f3cd1..2abff49fd09 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/MappingTrackSelectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/MappingTrackSelectorTest.java @@ -131,7 +131,7 @@ private static TrackGroup buildTrackGroup(String sampleMimeType) { /** * A {@link MappingTrackSelector} that stashes the {@link MappedTrackInfo} passed to {@link - * #selectTracks(MappedTrackInfo, int[][][], int[])}. + * #selectTracks(MappedTrackInfo, int[][][], int[], MediaPeriodId, Timeline)}. */ private static final class FakeMappingTrackSelector extends MappingTrackSelector { @@ -141,8 +141,9 @@ private static final class FakeMappingTrackSelector extends MappingTrackSelector protected Pair selectTracks( MappedTrackInfo mappedTrackInfo, @Capabilities int[][][] rendererFormatSupports, - @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupports) - throws ExoPlaybackException { + @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupports, + MediaPeriodId mediaPeriodId, + Timeline timeline) { int rendererCount = mappedTrackInfo.getRendererCount(); lastMappedTrackInfo = mappedTrackInfo; return Pair.create( @@ -156,7 +157,6 @@ public void assertMappedTrackGroups(int rendererIndex, TrackGroup... expected) { assertThat(rendererTrackGroupArray.get(i)).isEqualTo(expected[i]); } } - } /** diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelector.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelector.java index 3b44e621073..15d613563ee 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelector.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTrackSelector.java @@ -18,6 +18,8 @@ import androidx.test.core.app.ApplicationProvider; import com.google.android.exoplayer2.RendererCapabilities.AdaptiveSupport; import com.google.android.exoplayer2.RendererCapabilities.Capabilities; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; @@ -85,7 +87,10 @@ public FakeTrackSelectionFactory(boolean mayReuseTrackSelection) { @Override public TrackSelection[] createTrackSelections( - TrackSelection.@NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + TrackSelection.@NullableType Definition[] definitions, + BandwidthMeter bandwidthMeter, + MediaPeriodId mediaPeriodId, + Timeline timeline) { TrackSelection[] selections = new TrackSelection[definitions.length]; for (int i = 0; i < definitions.length; i++) { TrackSelection.Definition definition = definitions[i]; From 4332dc2304ff65a5e6117ac8e4470abc0a174611 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 4 Nov 2020 12:09:29 +0000 Subject: [PATCH 242/693] Parse HLS #EXT-X-RENDITION-REPORT tag Issue: #5011 PiperOrigin-RevId: 340621758 --- .../source/hls/playlist/HlsMediaPlaylist.java | 49 ++++- .../hls/playlist/HlsPlaylistParser.java | 47 ++++- .../playlist/HlsMediaPlaylistParserTest.java | 193 ++++++++++++++++++ 3 files changed, 274 insertions(+), 15 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java index 7fc6b11af11..75a5b95e7fe 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -17,17 +17,20 @@ import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import android.net.Uri; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.offline.StreamKey; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** Represents an HLS media playlist. */ public final class HlsMediaPlaylist extends HlsPlaylist { @@ -297,6 +300,36 @@ public int compareTo(Long relativeStartTimeUs) { } } + /** + * A rendition report for an alternative rendition defined in another media playlist. + * + *

    See RFC 8216, section 4.4.5.1.4. + */ + public static final class RenditionReport { + /** The URI of the media playlist of the reported rendition. */ + public final Uri playlistUri; + /** The last media sequence that is in the playlist of the reported rendition. */ + public final long lastMediaSequence; + /** + * The last part index that is in the playlist of the reported rendition, or {@link + * C#INDEX_UNSET} if the rendition does not contain partial segments. + */ + public final int lastPartIndex; + + /** + * Creates a new instance. + * + * @param playlistUri See {@link #playlistUri}. + * @param lastMediaSequence See {@link #lastMediaSequence}. + * @param lastPartIndex See {@link #lastPartIndex}. + */ + public RenditionReport(Uri playlistUri, long lastMediaSequence, int lastPartIndex) { + this.playlistUri = playlistUri; + this.lastMediaSequence = lastMediaSequence; + this.lastPartIndex = lastPartIndex; + } + } + /** * Type of the playlist, as defined by #EXT-X-PLAYLIST-TYPE. One of {@link * #PLAYLIST_TYPE_UNKNOWN}, {@link #PLAYLIST_TYPE_VOD} or {@link #PLAYLIST_TYPE_EVENT}. @@ -372,6 +405,8 @@ public int compareTo(Long relativeStartTimeUs) { * The list of parts at the end of the playlist for which the segment is not in the playlist yet. */ public final List trailingParts; + /** The rendition reports of alternative rendition playlists. */ + public final Map renditionReports; /** The total duration of the playlist in microseconds. */ public final long durationUs; /** The attributes of the #EXT-X-SERVER-CONTROL header. */ @@ -396,6 +431,7 @@ public int compareTo(Long relativeStartTimeUs) { * @param skippedSegmentCount See {@link #skippedSegmentCount}. * @param trailingParts See {@link #trailingParts}. * @param serverControl See {@link #serverControl} + * @param renditionReports See {@link #renditionReports}. */ public HlsMediaPlaylist( @PlaylistType int playlistType, @@ -416,7 +452,8 @@ public HlsMediaPlaylist( List segments, int skippedSegmentCount, List trailingParts, - ServerControl serverControl) { + ServerControl serverControl, + Map renditionReports) { super(baseUri, tags, hasIndependentSegments); this.playlistType = playlistType; this.startTimeUs = startTimeUs; @@ -432,6 +469,7 @@ public HlsMediaPlaylist( this.segments = ImmutableList.copyOf(segments); this.skippedSegmentCount = skippedSegmentCount; this.trailingParts = ImmutableList.copyOf(trailingParts); + this.renditionReports = ImmutableMap.copyOf(renditionReports); if (!segments.isEmpty()) { Segment last = segments.get(segments.size() - 1); durationUs = last.relativeStartTimeUs + last.durationUs; @@ -517,7 +555,8 @@ public HlsMediaPlaylist expandSkippedSegments(HlsMediaPlaylist previousPlaylist) mergedSegments, /* skippedSegmentCount= */ 0, trailingParts, - serverControl); + serverControl, + renditionReports); } /** @@ -549,7 +588,8 @@ public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { segments, skippedSegmentCount, trailingParts, - serverControl); + serverControl, + renditionReports); } /** @@ -579,7 +619,8 @@ public HlsMediaPlaylist copyWithEndTag() { segments, skippedSegmentCount, trailingParts, - serverControl); + serverControl, + renditionReports); } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 03408776b3f..9586244afc1 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -34,12 +34,14 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Part; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.RenditionReport; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.Iterables; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -94,6 +96,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions = new HashMap<>(); HashMap urlToInferredInitSegment = new HashMap<>(); List segments = new ArrayList<>(); - List parts = new ArrayList<>(); + List trailingParts = new ArrayList<>(); + @Nullable Part preloadPart = null; + Map renditionReports = new HashMap<>(); List tags = new ArrayList<>(); long segmentDurationUs = 0; @@ -769,7 +775,22 @@ private static HlsMediaPlaylist parseMediaPlaylist( hasIndependentSegmentsTag = true; } else if (line.equals(TAG_ENDLIST)) { hasEndTag = true; - } else if (line.startsWith(TAG_PRELOAD_HINT) && !seenPreloadPart) { + } else if (line.startsWith(TAG_RENDITION_REPORT)) { + long defaultValue = mediaSequence + segments.size() - (trailingParts.isEmpty() ? 1 : 0); + long lastMediaSequence = parseOptionalLongAttr(line, REGEX_LAST_MSN, defaultValue); + List lastParts = + trailingParts.isEmpty() ? Iterables.getLast(segments).parts : trailingParts; + int defaultPartIndex = + partTargetDurationUs != C.TIME_UNSET ? lastParts.size() - 1 : C.INDEX_UNSET; + int lastPartIndex = parseOptionalIntAttr(line, REGEX_LAST_PART, defaultPartIndex); + String uri = parseStringAttr(line, REGEX_URI, variableDefinitions); + Uri playlistUri = Uri.parse(UriUtil.resolve(baseUri, uri)); + renditionReports.put( + playlistUri, new RenditionReport(playlistUri, lastMediaSequence, lastPartIndex)); + } else if (line.startsWith(TAG_PRELOAD_HINT)) { + if (preloadPart != null) { + continue; + } String type = parseStringAttr(line, REGEX_PRELOAD_HINT_TYPE, variableDefinitions); if (!TYPE_PART.equals(type)) { continue; @@ -790,7 +811,7 @@ private static HlsMediaPlaylist parseMediaPlaylist( playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas); } } - parts.add( + preloadPart = new Part( url, initializationSegment, @@ -803,8 +824,7 @@ private static HlsMediaPlaylist parseMediaPlaylist( byteRangeStart, byteRangeLength, /* hasGapTag= */ false, - /* isIndependent= */ false)); - seenPreloadPart = true; + /* isIndependent= */ false); } else if (line.startsWith(TAG_PART)) { @Nullable String segmentEncryptionIV = @@ -836,7 +856,7 @@ private static HlsMediaPlaylist parseMediaPlaylist( playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas); } } - parts.add( + trailingParts.add( new Part( url, initializationSegment, @@ -903,12 +923,12 @@ private static HlsMediaPlaylist parseMediaPlaylist( segmentByteRangeOffset, segmentByteRangeLength, hasGapTag, - parts)); + trailingParts)); segmentStartTimeUs += segmentDurationUs; partStartTimeUs = segmentStartTimeUs; segmentDurationUs = 0; segmentTitle = ""; - parts = new ArrayList<>(); + trailingParts = new ArrayList<>(); if (segmentByteRangeLength != C.LENGTH_UNSET) { segmentByteRangeOffset += segmentByteRangeLength; } @@ -917,6 +937,10 @@ private static HlsMediaPlaylist parseMediaPlaylist( } } + if (preloadPart != null) { + trailingParts.add(preloadPart); + } + return new HlsMediaPlaylist( playlistType, baseUri, @@ -935,8 +959,9 @@ private static HlsMediaPlaylist parseMediaPlaylist( playlistProtectionSchemes, segments, skippedSegmentCount, - parts, - serverControl); + trailingParts, + serverControl, + renditionReports); } private static DrmInitData getPlaylistProtectionSchemes( 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 b917cd2c113..9f81d8db144 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 @@ -636,6 +636,199 @@ public void parseMediaPlaylist_withPreloadHintTypePartAndAes128_partHasDrmKeyUri assertThat(preloadPart.encryptionIV).isEqualTo("0x410C8AC18AA42EFA18B5155484F5FC34"); } + @Test + public void parseMediaPlaylist_withRenditionReportWithoutPartTargetDuration_lastPartIndexUnset() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-RENDITION-REPORT:URI=\"/rendition0.m3u8\",LAST-MSN=100\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.renditionReports).hasSize(1); + HlsMediaPlaylist.RenditionReport report0 = + playlist.renditionReports.get(Uri.parse("https://example.com/rendition0.m3u8")); + assertThat(report0.lastMediaSequence).isEqualTo(100); + assertThat(report0.lastPartIndex).isEqualTo(C.INDEX_UNSET); + } + + @Test + public void + parseMediaPlaylist_withRenditionReportWithoutPartTargetDurationWithoutLastMsn_sameLastMsnAsCurrentPlaylist() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-RENDITION-REPORT:URI=\"/rendition0.m3u8\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.renditionReports).hasSize(1); + HlsMediaPlaylist.RenditionReport report0 = + playlist.renditionReports.get(Uri.parse("https://example.com/rendition0.m3u8")); + assertThat(report0.lastMediaSequence).isEqualTo(266); + assertThat(report0.lastPartIndex).isEqualTo(C.INDEX_UNSET); + } + + @Test + public void parseMediaPlaylist_withRenditionReportLowLatency_parseAllAttributes() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-PART-INF:PART-TARGET=1\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-RENDITION-REPORT:URI=\"/rendition0.m3u8\",LAST-MSN=100,LAST-PART=2\n" + + "#EXT-X-RENDITION-REPORT:" + + "URI=\"http://foo.bar/rendition2.m3u8\",LAST-MSN=1000,LAST-PART=3\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.renditionReports).hasSize(2); + HlsMediaPlaylist.RenditionReport report0 = + playlist.renditionReports.get(Uri.parse("https://example.com/rendition0.m3u8")); + assertThat(report0.lastMediaSequence).isEqualTo(100); + assertThat(report0.lastPartIndex).isEqualTo(2); + HlsMediaPlaylist.RenditionReport report2 = + playlist.renditionReports.get(Uri.parse("http://foo.bar/rendition2.m3u8")); + assertThat(report2.lastMediaSequence).isEqualTo(1000); + assertThat(report2.lastPartIndex).isEqualTo(3); + } + + @Test + public void + parseMediaPlaylist_withRenditionReportLowLatencyWithoutLastMsn_sameMsnAsCurrentPlaylist() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-PART-INF:PART-TARGET=1\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.0.ts\"\n" + + "#EXT-X-RENDITION-REPORT:URI=\"/rendition0.m3u8\",LAST-PART=2\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.renditionReports).hasSize(1); + HlsMediaPlaylist.RenditionReport report0 = + playlist.renditionReports.get(Uri.parse("https://example.com/rendition0.m3u8")); + assertThat(report0.lastMediaSequence).isEqualTo(267); + assertThat(report0.lastPartIndex).isEqualTo(2); + } + + @Test + public void + parseMediaPlaylist_withRenditionReportLowLatencyWithoutLastPartIndex_sameLastPartIndexAsCurrentPlaylist() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-PART-INF:PART-TARGET=1\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.0.ts\"\n" + + "#EXT-X-RENDITION-REPORT:URI=\"/rendition0.m3u8\",LAST-MSN=100\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.renditionReports).hasSize(1); + HlsMediaPlaylist.RenditionReport report0 = + playlist.renditionReports.get(Uri.parse("https://example.com/rendition0.m3u8")); + assertThat(report0.lastMediaSequence).isEqualTo(100); + assertThat(report0.lastPartIndex).isEqualTo(0); + } + + @Test + public void + parseMediaPlaylist_withRenditionReportLowLatencyWithoutLastPartIndex_ignoredPreloadPart() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-PART-INF:PART-TARGET=1\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.0.ts\"\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.1.ts\"\n" + + "#EXT-X-RENDITION-REPORT:URI=\"/rendition0.m3u8\",LAST-MSN=100\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.trailingParts).hasSize(2); + assertThat(playlist.renditionReports).hasSize(1); + HlsMediaPlaylist.RenditionReport report0 = + playlist.renditionReports.get(Uri.parse("https://example.com/rendition0.m3u8")); + assertThat(report0.lastMediaSequence).isEqualTo(100); + assertThat(report0.lastPartIndex).isEqualTo(0); + } + + @Test + public void parseMediaPlaylist_withRenditionReportLowLatencyFullSegment_rollingPartIndexUriParam() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-PART-INF:PART-TARGET=1\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part266.0.ts\"\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part266.1.ts\"\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part266.2.ts\"\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part266.3.ts\"\n" + + "#EXTINF:4.00000,\n" + + "fileSequence266.mp4\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.0.ts\"\n" + + "#EXT-X-RENDITION-REPORT:URI=\"/rendition0.m3u8\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.renditionReports).hasSize(1); + HlsMediaPlaylist.RenditionReport report0 = + playlist.renditionReports.get(Uri.parse("https://example.com/rendition0.m3u8")); + assertThat(report0.lastMediaSequence).isEqualTo(266); + assertThat(report0.lastPartIndex).isEqualTo(3); + } + @Test public void multipleExtXKeysForSingleSegment() throws Exception { Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); From 7f49b33fea50f47387aa5b87202331ba595f38c3 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 4 Nov 2020 12:47:30 +0000 Subject: [PATCH 243/693] Block HLS playlist requests at part level Issue: #5011 PiperOrigin-RevId: 340625816 --- .../playlist/DefaultHlsPlaylistTracker.java | 24 +++- .../source/hls/playlist/HlsMediaPlaylist.java | 16 ++- .../hls/playlist/HlsPlaylistParser.java | 6 +- .../DefaultHlsPlaylistTrackerTest.java | 109 +++++++++++++++++- .../playlist/HlsMediaPlaylistParserTest.java | 3 + ...latency_media_can_block_reload_low_latency | 16 +++ ..._can_block_reload_low_latency_full_segment | 18 +++ ...block_reload_low_latency_full_segment_next | 19 +++ ...ck_reload_low_latency_full_segment_preload | 19 +++ ...load_low_latency_full_segment_preload_next | 20 ++++ ...cy_media_can_block_reload_low_latency_next | 17 +++ 11 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_next create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload_next create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_next 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 d4922216867..fb39f2f2b4f 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 @@ -30,6 +30,7 @@ import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Part; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.HttpDataSource; @@ -40,6 +41,7 @@ import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.Iterables; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; @@ -465,6 +467,7 @@ private static Segment getFirstOldOverlappingSegment( private final class MediaPlaylistBundle implements Loader.Callback> { private static final String BLOCK_MSN_PARAM = "_HLS_msn"; + private static final String BLOCK_PART_PARAM = "_HLS_part"; private static final String SKIP_PARAM = "_HLS_skip"; private final Uri playlistUrl; @@ -729,16 +732,25 @@ private Uri getMediaPlaylistUriForReload() { return playlistUrl; } Uri.Builder uriBuilder = playlistUrl.buildUpon(); - if (playlistSnapshot.serverControl.skipUntilUs != C.TIME_UNSET) { - uriBuilder.appendQueryParameter( - SKIP_PARAM, playlistSnapshot.serverControl.canSkipDateRanges ? "v2" : "YES"); - } if (playlistSnapshot.serverControl.canBlockReload) { - long reloadMediaSequence = + long targetMediaSequence = playlistSnapshot.mediaSequence + playlistSnapshot.segments.size() + playlistSnapshot.skippedSegmentCount; - uriBuilder.appendQueryParameter(BLOCK_MSN_PARAM, String.valueOf(reloadMediaSequence)); + uriBuilder.appendQueryParameter(BLOCK_MSN_PARAM, String.valueOf(targetMediaSequence)); + if (playlistSnapshot.partTargetDurationUs != C.TIME_UNSET) { + List trailingParts = playlistSnapshot.trailingParts; + int targetPartIndex = trailingParts.size(); + if (!trailingParts.isEmpty() && Iterables.getLast(trailingParts).isPreload) { + // Ignore the preload part. + targetPartIndex--; + } + uriBuilder.appendQueryParameter(BLOCK_PART_PARAM, String.valueOf(targetPartIndex)); + } + } + if (playlistSnapshot.serverControl.skipUntilUs != C.TIME_UNSET) { + uriBuilder.appendQueryParameter( + SKIP_PARAM, playlistSnapshot.serverControl.canSkipDateRanges ? "v2" : "YES"); } return uriBuilder.build(); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java index 75a5b95e7fe..14c369c3e7b 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -176,6 +176,8 @@ public static final class Part extends SegmentBase { /** Whether the part is independent. */ public final boolean isIndependent; + /** Whether the part is a preloading part. */ + public final boolean isPreload; /** * Creates an instance. @@ -192,6 +194,7 @@ public static final class Part extends SegmentBase { * @param byteRangeLength See {@link #byteRangeLength}. * @param hasGapTag See {@link #hasGapTag}. * @param isIndependent See {@link #isIndependent}. + * @param isPreload See {@link #isPreload}. */ public Part( String url, @@ -205,7 +208,8 @@ public Part( long byteRangeOffset, long byteRangeLength, boolean hasGapTag, - boolean isIndependent) { + boolean isIndependent, + boolean isPreload) { super( url, initializationSegment, @@ -219,6 +223,7 @@ public Part( byteRangeLength, hasGapTag); this.isIndependent = isIndependent; + this.isPreload = isPreload; } } @@ -502,8 +507,13 @@ public boolean isNewerThan(@Nullable HlsMediaPlaylist other) { // The media sequences are equal. int segmentCount = segments.size() + skippedSegmentCount; int otherSegmentCount = other.segments.size() + other.skippedSegmentCount; - return segmentCount > otherSegmentCount - || (segmentCount == otherSegmentCount && hasEndTag && !other.hasEndTag); + if (segmentCount != otherSegmentCount) { + return segmentCount > otherSegmentCount; + } + int partCount = trailingParts.size(); + int otherPartCount = other.trailingParts.size(); + return partCount > otherPartCount + || (partCount == otherPartCount && hasEndTag && !other.hasEndTag); } /** diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 9586244afc1..2fa01c43fcf 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -824,7 +824,8 @@ private static HlsMediaPlaylist parseMediaPlaylist( byteRangeStart, byteRangeLength, /* hasGapTag= */ false, - /* isIndependent= */ false); + /* isIndependent= */ false, + /* isPreload= */ true); } else if (line.startsWith(TAG_PART)) { @Nullable String segmentEncryptionIV = @@ -869,7 +870,8 @@ private static HlsMediaPlaylist parseMediaPlaylist( partByteRangeOffset, partByteRangeLength, isGap, - isIndependent)); + isIndependent, + /* isPreload= */ false)); partStartTimeUs += partDurationUs; if (partByteRangeLength != C.LENGTH_UNSET) { partByteRangeOffset += partByteRangeLength; diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java index e741c1802f6..93c94dd21b6 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTrackerTest.java @@ -64,6 +64,21 @@ public class DefaultHlsPlaylistTrackerTest { "media/m3u8/live_low_latency_media_can_block_reload"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_NEXT = "media/m3u8/live_low_latency_media_can_block_reload_next"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_NEXT = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_next"; + private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment"; + private static final String + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_NEXT = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_next"; + private static final String + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload"; + private static final String + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD_NEXT = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload_next"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD = "media/m3u8/live_low_latency_media_can_skip_until_and_block_reload"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD_NEXT = @@ -255,6 +270,96 @@ public void start_playlistCanBlockReload_requestBlockingReloadWithCorrectMediaSe assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(11); } + @Test + public void + start_playlistCanBlockReloadLowLatency_requestBlockingReloadWithCorrectMediaSequenceAndPart() + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", + "/media0/playlist.m3u8", + "/media0/playlist.m3u8?_HLS_msn=14&_HLS_part=1" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_NEXT)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), + /* awaitedMediaPlaylistCount= */ 2); + + assertRequestUrlsCalled(httpUrls); + assertThat(mediaPlaylists.get(0).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(0).segments).hasSize(4); + assertThat(mediaPlaylists.get(0).trailingParts).hasSize(2); + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(1).segments).hasSize(4); + assertThat(mediaPlaylists.get(1).trailingParts).hasSize(3); + } + + @Test + public void start_playlistCanBlockReloadLowLatencyFullSegment_correctMsnAndPartParams() + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", + "/media0/playlist.m3u8", + "/media0/playlist.m3u8?_HLS_msn=14&_HLS_part=0" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_NEXT)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), + /* awaitedMediaPlaylistCount= */ 2); + + assertRequestUrlsCalled(httpUrls); + assertThat(mediaPlaylists.get(0).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(0).segments).hasSize(4); + assertThat(mediaPlaylists.get(0).trailingParts).isEmpty(); + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(1).segments).hasSize(4); + assertThat(mediaPlaylists.get(1).trailingParts).hasSize(1); + } + + @Test + public void start_playlistCanBlockReloadLowLatencyFullSegmentWithPreloadPart_ignoresPreloadPart() + throws IOException, TimeoutException, InterruptedException { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/master.m3u8", + "/media0/playlist.m3u8", + "/media0/playlist.m3u8?_HLS_msn=14&_HLS_part=0" + }, + getMockResponse(SAMPLE_M3U8_LIVE_MASTER), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD_NEXT)); + + List mediaPlaylists = + runPlaylistTrackerAndCollectMediaPlaylists( + new DefaultHttpDataSourceFactory(), + Uri.parse(mockWebServer.url("/master.m3u8").toString()), + /* awaitedMediaPlaylistCount= */ 2); + + assertRequestUrlsCalled(httpUrls); + assertThat(mediaPlaylists.get(0).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(0).segments).hasSize(4); + assertThat(mediaPlaylists.get(0).trailingParts).hasSize(1); + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(1).segments).hasSize(4); + assertThat(mediaPlaylists.get(1).trailingParts).hasSize(2); + } + @Test public void start_httpBadRequest_forcesFullNonBlockingPlaylistRequest() throws IOException, TimeoutException, InterruptedException { @@ -263,9 +368,9 @@ public void start_httpBadRequest_forcesFullNonBlockingPlaylistRequest() new String[] { "/master.m3u8", "/media0/playlist.m3u8", - "/media0/playlist.m3u8?_HLS_skip=YES&_HLS_msn=16", + "/media0/playlist.m3u8?_HLS_msn=16&_HLS_skip=YES", "/media0/playlist.m3u8", - "/media0/playlist.m3u8?_HLS_skip=YES&_HLS_msn=17" + "/media0/playlist.m3u8?_HLS_msn=17&_HLS_skip=YES" }, getMockResponse(SAMPLE_M3U8_LIVE_MASTER), getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_AND_BLOCK_RELOAD), 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 9f81d8db144..0d9a75cce2d 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 @@ -354,6 +354,7 @@ public void parseMediaPlaylist_withParts_parsesPartWithAllAttributes() throws IO assertThat(firstPart.durationUs).isEqualTo(2_000_000); assertThat(firstPart.relativeStartTimeUs).isEqualTo(playlist.segments.get(0).durationUs); assertThat(firstPart.isIndependent).isTrue(); + assertThat(firstPart.isPreload).isFalse(); assertThat(firstPart.hasGapTag).isTrue(); assertThat(firstPart.url).isEqualTo("part267.1.ts"); HlsMediaPlaylist.Part secondPart = playlist.segments.get(1).parts.get(1); @@ -518,6 +519,7 @@ public void parseMediaPlaylist_withPreloadHintTypePart_hasPreloadPartWithAllAttr assertThat(preloadPart.byteRangeOffset).isEqualTo(1234); assertThat(preloadPart.initializationSegment.url).isEqualTo("map.mp4"); assertThat(preloadPart.encryptionIV).isEqualTo("0x410C8AC18AA42EFA18B5155484F5FC34"); + assertThat(preloadPart.isPreload).isTrue(); } @Test @@ -539,6 +541,7 @@ public void parseMediaPlaylist_withMultiplePreloadHintTypeParts_picksOnlyFirstPr assertThat(playlist.trailingParts).hasSize(2); assertThat(playlist.trailingParts.get(1).url).isEqualTo("filePart267.2.ts"); + assertThat(playlist.trailingParts.get(1).isPreload).isTrue(); } @Test diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency new file mode 100644 index 00000000000..cb39e1beadc --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency @@ -0,0 +1,16 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.0.ts" +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="fileSequence14.1.ts" diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment new file mode 100644 index 00000000000..4a13112d7ab --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment @@ -0,0 +1,18 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.1.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.2.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.3.ts" +#EXTINF:4.00000, +fileSequence13.ts diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_next b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_next new file mode 100644 index 00000000000..88315024bbf --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_next @@ -0,0 +1,19 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.1.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.2.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.3.ts" +#EXTINF:4.00000, +fileSequence13.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.0.ts" diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload new file mode 100644 index 00000000000..f7651fb6c90 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload @@ -0,0 +1,19 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.1.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.2.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.3.ts" +#EXTINF:4.00000, +fileSequence13.ts +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="fileSequence14.0.ts" diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload_next b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload_next new file mode 100644 index 00000000000..baa701e8eb0 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload_next @@ -0,0 +1,20 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.1.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.2.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.3.ts" +#EXTINF:4.00000, +fileSequence13.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.0.ts" +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="fileSequence14.1.ts" diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_next b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_next new file mode 100644 index 00000000000..537bc81b812 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_next @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.1.ts" +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="fileSequence14.2.ts" From c9e80a20e6c26e20b0c2e22df3a66a5bab65c459 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 4 Nov 2020 16:18:25 +0000 Subject: [PATCH 244/693] Move reconfiguration workaround to canKeepCodec PiperOrigin-RevId: 340651654 --- .../exoplayer2/mediacodec/MediaCodecInfo.java | 46 +++++++++++++++---- .../mediacodec/MediaCodecRenderer.java | 44 +++++------------- 2 files changed, 47 insertions(+), 43 deletions(-) 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 ced56b53f31..90f9517a8d2 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 @@ -393,9 +393,11 @@ public int canKeepCodec(Format oldFormat, Format newFormat) { && (adaptive || (oldFormat.width == newFormat.width && oldFormat.height == newFormat.height)) && Util.areEqual(oldFormat.colorInfo, newFormat.colorInfo)) { - return oldFormat.initializationDataEquals(newFormat) - ? KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION - : KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; + if (oldFormat.initializationDataEquals(newFormat)) { + return KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION; + } else if (!needsAdaptationReconfigureWorkaround(name)) { + return KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; + } } } else { if (oldFormat.channelCount != newFormat.channelCount @@ -423,11 +425,8 @@ public int canKeepCodec(Format oldFormat, Format newFormat) { } } - // For Opus, we don't flush and reuse the codec because the decoder may discard samples after - // flushing, which would result in audio being dropped just after a stream change (see - // [Internal: b/143450854]). For other formats, we allow reuse after flushing if the codec - // initialization data is unchanged. - if (!MimeTypes.AUDIO_OPUS.equals(mimeType) && oldFormat.initializationDataEquals(newFormat)) { + if (oldFormat.initializationDataEquals(newFormat) + && !needsAdaptationFlushWorkaround(mimeType)) { return KEEP_CODEC_RESULT_YES_WITH_FLUSH; } } @@ -459,7 +458,7 @@ public boolean isVideoSizeAndRateSupportedV21(int width, int height, double fram } if (!areSizeAndRateSupportedV21(videoCapabilities, width, height, frameRate)) { if (width >= height - || !enableRotatedVerticalResolutionWorkaround(name) + || !needsRotatedVerticalResolutionWorkaround(name) || !areSizeAndRateSupportedV21(videoCapabilities, height, width, frameRate)) { logNoSupport("sizeAndRate.support, " + width + "x" + height + "x" + frameRate); return false; @@ -654,6 +653,33 @@ private static int getMaxSupportedInstancesV23(CodecCapabilities capabilities) { return capabilities.getMaxSupportedInstances(); } + /** + * Returns whether the decoder is known to fail when an attempt is made to reconfigure it with a + * new format's configuration data. + * + * @param name The name of the decoder. + * @return Whether the decoder is known to fail when an attempt is made to reconfigure it with a + * new format's configuration data. + */ + private static boolean needsAdaptationReconfigureWorkaround(String name) { + return Util.MODEL.startsWith("SM-T230") && "OMX.MARVELL.VIDEO.HW.CODA7542DECODER".equals(name); + } + + /** + * Returns whether the decoder is known to behave incorrectly if flushed to adapt to a new format. + * + * @param mimeType The name of the MIME type. + * @return Whether the decoder is known to to behave incorrectly if flushed to adapt to a new + * format. + */ + private static boolean needsAdaptationFlushWorkaround(String mimeType) { + // For Opus, we don't flush and reuse the codec because the decoder may discard samples after + // flushing, which would result in audio being dropped just after a stream change (see + // [Internal: b/143450854]). For other formats, we allow reuse after flushing if the codec + // initialization data is unchanged. + return MimeTypes.AUDIO_OPUS.equals(mimeType); + } + /** * Capabilities are known to be inaccurately reported for vertical resolutions on some devices. * [Internal ref: b/31387661]. When this workaround is enabled, we also check whether the @@ -663,7 +689,7 @@ private static int getMaxSupportedInstancesV23(CodecCapabilities capabilities) { * @param name The name of the codec. * @return Whether to enable the workaround. */ - private static final boolean enableRotatedVerticalResolutionWorkaround(String name) { + private static final boolean needsRotatedVerticalResolutionWorkaround(String name) { if ("OMX.MTK.VIDEO.DECODER.HEVC".equals(name) && "mcv5a".equals(Util.DEVICE)) { // See https://github.com/google/ExoPlayer/issues/6612. return false; 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 a29de96cebd..987a5563457 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 @@ -316,7 +316,6 @@ private static String buildCustomDiagnosticInfo(int errorCode) { @Nullable private DecoderInitializationException preferredDecoderInitializationException; @Nullable private MediaCodecInfo codecInfo; @AdaptationWorkaroundMode private int codecAdaptationWorkaroundMode; - private boolean codecNeedsReconfigureWorkaround; private boolean codecNeedsDiscardToSpsWorkaround; private boolean codecNeedsFlushWorkaround; private boolean codecNeedsSosFlushWorkaround; @@ -894,7 +893,6 @@ protected void resetCodecStateForRelease() { codecHasOutputMediaFormat = false; codecOperatingRate = CODEC_OPERATING_RATE_UNSET; codecAdaptationWorkaroundMode = ADAPTATION_WORKAROUND_MODE_NEVER; - codecNeedsReconfigureWorkaround = false; codecNeedsDiscardToSpsWorkaround = false; codecNeedsFlushWorkaround = false; codecNeedsSosFlushWorkaround = false; @@ -1085,7 +1083,6 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce this.codecOperatingRate = codecOperatingRate; codecInputFormat = inputFormat; codecAdaptationWorkaroundMode = codecAdaptationWorkaroundMode(codecName); - codecNeedsReconfigureWorkaround = codecNeedsReconfigureWorkaround(codecName); codecNeedsDiscardToSpsWorkaround = codecNeedsDiscardToSpsWorkaround(codecName, codecInputFormat); codecNeedsFlushWorkaround = codecNeedsFlushWorkaround(codecName); @@ -1428,21 +1425,17 @@ protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybac } break; case KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION: - if (codecNeedsReconfigureWorkaround) { - drainAndReinitializeCodec(); - } else { - codecReconfigured = true; - codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; - codecNeedsAdaptationWorkaroundBuffer = - codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS - || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION - && newFormat.width == codecInputFormat.width - && newFormat.height == codecInputFormat.height); - codecInputFormat = newFormat; - updateCodecOperatingRate(); - if (drainAndUpdateCodecDrmSession) { - drainAndUpdateCodecDrmSessionV23(); - } + codecReconfigured = true; + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + codecNeedsAdaptationWorkaroundBuffer = + codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS + || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION + && newFormat.width == codecInputFormat.width + && newFormat.height == codecInputFormat.height); + codecInputFormat = newFormat; + updateCodecOperatingRate(); + if (drainAndUpdateCodecDrmSession) { + drainAndUpdateCodecDrmSessionV23(); } break; case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: @@ -2244,21 +2237,6 @@ private static boolean codecNeedsFlushWorkaround(String name) { } } - /** - * Returns whether the decoder is known to fail when an attempt is made to reconfigure it with a - * new format's configuration data. - * - *

    When enabled, the workaround will always release and recreate the decoder, rather than - * attempting to reconfigure the existing instance. - * - * @param name The name of the decoder. - * @return True if the decoder is known to fail when an attempt is made to reconfigure it with a - * new format's configuration data. - */ - private static boolean codecNeedsReconfigureWorkaround(String name) { - return Util.MODEL.startsWith("SM-T230") && "OMX.MARVELL.VIDEO.HW.CODA7542DECODER".equals(name); - } - /** * Returns whether the decoder is an H.264/AVC decoder known to fail if NAL units are queued * before the codec specific data. From 2416d9985718accfcc00ddc951afa217c261f7ae Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 4 Nov 2020 16:27:19 +0000 Subject: [PATCH 245/693] Limit target buffer to media configured min/max values. Issue: #4904 PiperOrigin-RevId: 340653126 --- .../DefaultLivePlaybackSpeedControl.java | 76 +++++++---- .../DefaultLivePlaybackSpeedControlTest.java | 119 ++++++++++++++++-- 2 files changed, 161 insertions(+), 34 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java index 1107d90aa4e..63de8cf5818 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java @@ -122,7 +122,7 @@ public Builder setFallbackMaxPlaybackSpeed(float fallbackMaxPlaybackSpeed) { * @return This builder, for convenience. */ public Builder setMinUpdateIntervalMs(long minUpdateIntervalMs) { - Assertions.checkArgument(minUpdateIntervalMs >= 0); + Assertions.checkArgument(minUpdateIntervalMs > 0); this.minUpdateIntervalMs = minUpdateIntervalMs; return this; } @@ -160,8 +160,14 @@ public DefaultLivePlaybackSpeedControl build() { private final long minUpdateIntervalMs; private final float proportionalControlFactor; - private LiveConfiguration mediaConfiguration; + private long mediaConfigurationTargetLiveOffsetUs; private long targetLiveOffsetOverrideUs; + private long minTargetLiveOffsetUs; + private long maxTargetLiveOffsetUs; + private long currentTargetLiveOffsetUs; + + private float maxPlaybackSpeed; + private float minPlaybackSpeed; private float adjustedPlaybackSpeed; private long lastPlaybackSpeedUpdateMs; @@ -174,28 +180,42 @@ private DefaultLivePlaybackSpeedControl( this.fallbackMaxPlaybackSpeed = fallbackMaxPlaybackSpeed; this.minUpdateIntervalMs = minUpdateIntervalMs; this.proportionalControlFactor = proportionalControlFactor; - mediaConfiguration = LiveConfiguration.UNSET; + mediaConfigurationTargetLiveOffsetUs = C.TIME_UNSET; targetLiveOffsetOverrideUs = C.TIME_UNSET; + minTargetLiveOffsetUs = C.TIME_UNSET; + maxTargetLiveOffsetUs = C.TIME_UNSET; + minPlaybackSpeed = fallbackMinPlaybackSpeed; + maxPlaybackSpeed = fallbackMaxPlaybackSpeed; adjustedPlaybackSpeed = 1.0f; lastPlaybackSpeedUpdateMs = C.TIME_UNSET; + currentTargetLiveOffsetUs = C.TIME_UNSET; } @Override public void setLiveConfiguration(LiveConfiguration liveConfiguration) { - this.mediaConfiguration = liveConfiguration; - lastPlaybackSpeedUpdateMs = C.TIME_UNSET; + mediaConfigurationTargetLiveOffsetUs = C.msToUs(liveConfiguration.targetLiveOffsetMs); + minTargetLiveOffsetUs = C.msToUs(liveConfiguration.minLiveOffsetMs); + maxTargetLiveOffsetUs = C.msToUs(liveConfiguration.maxLiveOffsetMs); + minPlaybackSpeed = + liveConfiguration.minPlaybackSpeed != C.RATE_UNSET + ? liveConfiguration.minPlaybackSpeed + : fallbackMinPlaybackSpeed; + maxPlaybackSpeed = + liveConfiguration.maxPlaybackSpeed != C.RATE_UNSET + ? liveConfiguration.maxPlaybackSpeed + : fallbackMaxPlaybackSpeed; + maybeResetTargetLiveOffsetUs(); } @Override public void setTargetLiveOffsetOverrideUs(long liveOffsetUs) { - this.targetLiveOffsetOverrideUs = liveOffsetUs; - lastPlaybackSpeedUpdateMs = C.TIME_UNSET; + targetLiveOffsetOverrideUs = liveOffsetUs; + maybeResetTargetLiveOffsetUs(); } @Override public float getAdjustedPlaybackSpeed(long liveOffsetUs) { - long targetLiveOffsetUs = getTargetLiveOffsetUs(); - if (targetLiveOffsetUs == C.TIME_UNSET) { + if (mediaConfigurationTargetLiveOffsetUs == C.TIME_UNSET) { return 1f; } if (lastPlaybackSpeedUpdateMs != C.TIME_UNSET @@ -204,34 +224,40 @@ public float getAdjustedPlaybackSpeed(long liveOffsetUs) { } lastPlaybackSpeedUpdateMs = SystemClock.elapsedRealtime(); - long liveOffsetErrorUs = liveOffsetUs - targetLiveOffsetUs; + long liveOffsetErrorUs = liveOffsetUs - currentTargetLiveOffsetUs; if (Math.abs(liveOffsetErrorUs) < MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED) { adjustedPlaybackSpeed = 1f; } else { float calculatedSpeed = 1f + proportionalControlFactor * liveOffsetErrorUs; adjustedPlaybackSpeed = - Util.constrainValue(calculatedSpeed, getMinPlaybackSpeed(), getMaxPlaybackSpeed()); + Util.constrainValue(calculatedSpeed, minPlaybackSpeed, maxPlaybackSpeed); } return adjustedPlaybackSpeed; } @Override public long getTargetLiveOffsetUs() { - return targetLiveOffsetOverrideUs != C.TIME_UNSET - && mediaConfiguration.targetLiveOffsetMs != C.TIME_UNSET - ? targetLiveOffsetOverrideUs - : C.msToUs(mediaConfiguration.targetLiveOffsetMs); - } - - private float getMinPlaybackSpeed() { - return mediaConfiguration.minPlaybackSpeed != C.RATE_UNSET - ? mediaConfiguration.minPlaybackSpeed - : fallbackMinPlaybackSpeed; + return currentTargetLiveOffsetUs; } - private float getMaxPlaybackSpeed() { - return mediaConfiguration.maxPlaybackSpeed != C.RATE_UNSET - ? mediaConfiguration.maxPlaybackSpeed - : fallbackMaxPlaybackSpeed; + private void maybeResetTargetLiveOffsetUs() { + long idealOffsetUs = C.TIME_UNSET; + if (mediaConfigurationTargetLiveOffsetUs != C.TIME_UNSET) { + idealOffsetUs = + targetLiveOffsetOverrideUs != C.TIME_UNSET + ? targetLiveOffsetOverrideUs + : mediaConfigurationTargetLiveOffsetUs; + if (minTargetLiveOffsetUs != C.TIME_UNSET && idealOffsetUs < minTargetLiveOffsetUs) { + idealOffsetUs = minTargetLiveOffsetUs; + } + if (maxTargetLiveOffsetUs != C.TIME_UNSET && idealOffsetUs > maxTargetLiveOffsetUs) { + idealOffsetUs = maxTargetLiveOffsetUs; + } + } + if (currentTargetLiveOffsetUs == idealOffsetUs) { + return; + } + currentTargetLiveOffsetUs = idealOffsetUs; + lastPlaybackSpeedUpdateMs = C.TIME_UNSET; } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java index 50fcf962838..8ec49ebabc5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java @@ -37,13 +37,13 @@ public void getTargetLiveOffsetUs_returnsUnset() { } @Test - public void getTargetLiveOffsetUs_afterUpdateLiveConfiguration_usesMediaLiveOffset() { + public void getTargetLiveOffsetUs_afterSetLiveConfiguration_usesMediaLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 42, - /* minLiveOffsetMs= */ 200, + /* minLiveOffsetMs= */ 5, /* maxLiveOffsetMs= */ 400, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); @@ -52,7 +52,59 @@ public void getTargetLiveOffsetUs_afterUpdateLiveConfiguration_usesMediaLiveOffs } @Test - public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() { + public void + getTargetLiveOffsetUs_afterSetLiveConfigurationWithTargetGreaterThanMax_usesMaxLiveOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder().build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 4321, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + assertThat(defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs()).isEqualTo(400_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterSetLiveConfigurationWithTargetLessThanMin_usesMinLiveOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder().build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 3, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + assertThat(defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs()).isEqualTo(5_000); + } + + @Test + public void getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUs_usesOverride() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder().build(); + + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(321_000); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetUs).isEqualTo(321_000); + } + + @Test + public void + getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUsGreaterThanMax_usesMaxLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); @@ -60,19 +112,39 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 42, - /* minLiveOffsetMs= */ 200, + /* minLiveOffsetMs= */ 5, /* maxLiveOffsetMs= */ 400, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); - assertThat(targetLiveOffsetUs).isEqualTo(123_456_789); + assertThat(targetLiveOffsetUs).isEqualTo(400_000); } @Test public void - getTargetLiveOffsetUs_afterOverrideTargetLiveOffset_withoutMediaConfiguration_returnsUnset() { + getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUsLessThanMin_usesMinLiveOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder().build(); + + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(3_141); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetUs).isEqualTo(5_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterSetTargetLiveOffsetOverrideWithoutMediaConfiguration_returnsUnset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); @@ -84,14 +156,14 @@ public void getTargetLiveOffsetUs_withOverrideTargetLiveOffsetUs_usesOverride() @Test public void - getTargetLiveOffsetUs_afterOverrideTargetLiveOffsetUsWithTimeUnset_usesMediaLiveOffset() { + getTargetLiveOffsetUs_afterSetTargetLiveOffsetOverrideWithTimeUnset_usesMediaLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 42, - /* minLiveOffsetMs= */ 200, + /* minLiveOffsetMs= */ 5, /* maxLiveOffsetMs= */ 400, /* minPlaybackSpeed= */ 1f, /* maxPlaybackSpeed= */ 1f)); @@ -294,7 +366,8 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA } @Test - public void adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfiguration_updatesSpeedAgain() { + public void + adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfigurationWithSameOffset_returnsSameAdjustedSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( @@ -317,6 +390,34 @@ public void adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfiguration_updates float adjustedSpeed2 = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + assertThat(adjustedSpeed1).isEqualTo(adjustedSpeed2); + } + + @Test + public void + adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfigurationWithNewOffset_updatesSpeedAgain() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, + /* minPlaybackSpeed= */ C.RATE_UNSET, + /* maxPlaybackSpeed= */ C.RATE_UNSET)); + + float adjustedSpeed1 = + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 1_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, + /* minPlaybackSpeed= */ C.RATE_UNSET, + /* maxPlaybackSpeed= */ C.RATE_UNSET)); + float adjustedSpeed2 = + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); } From effbc22a62b1a78dd22a13480bf8e11f44673b21 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 4 Nov 2020 16:33:05 +0000 Subject: [PATCH 246/693] Increase target live offset when rebuffering. Issue: #4904 PiperOrigin-RevId: 340654178 --- .../DefaultLivePlaybackSpeedControl.java | 54 ++++- .../exoplayer2/ExoPlayerImplInternal.java | 16 +- .../exoplayer2/LivePlaybackSpeedControl.java | 8 + .../DefaultLivePlaybackSpeedControlTest.java | 192 +++++++++++++++++- 4 files changed, 250 insertions(+), 20 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java index 63de8cf5818..6ea6f083217 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java @@ -33,6 +33,10 @@ * fallback values set with {@link Builder#setFallbackMinPlaybackSpeed(float)} and {@link * Builder#setFallbackMaxPlaybackSpeed(float)} or the {@link #DEFAULT_FALLBACK_MIN_PLAYBACK_SPEED * minimum} and {@link #DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED maximum} fallback default values. + * + *

    When the player rebuffers, the target live offset {@link + * Builder#setTargetLiveOffsetIncrementOnRebufferMs(long) is increased} to adjust to the reduced + * network capabilities. */ public final class DefaultLivePlaybackSpeedControl implements LivePlaybackSpeedControl { @@ -60,6 +64,12 @@ public final class DefaultLivePlaybackSpeedControl implements LivePlaybackSpeedC */ public static final float DEFAULT_PROPORTIONAL_CONTROL_FACTOR = 0.05f; + /** + * The default increment applied to the target live offset each time the player is rebuffering, in + * milliseconds + */ + public static final long DEFAULT_TARGET_LIVE_OFFSET_INCREMENT_ON_REBUFFER_MS = 500; + /** * The maximum difference between the current live offset and the target live offset for which * unit speed (1.0f) is used. @@ -73,6 +83,7 @@ public static final class Builder { private float fallbackMaxPlaybackSpeed; private long minUpdateIntervalMs; private float proportionalControlFactorUs; + private long targetLiveOffsetIncrementOnRebufferUs; /** Creates a builder. */ public Builder() { @@ -80,6 +91,8 @@ public Builder() { fallbackMaxPlaybackSpeed = DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED; minUpdateIntervalMs = DEFAULT_MIN_UPDATE_INTERVAL_MS; proportionalControlFactorUs = DEFAULT_PROPORTIONAL_CONTROL_FACTOR / C.MICROS_PER_SECOND; + targetLiveOffsetIncrementOnRebufferUs = + C.msToUs(DEFAULT_TARGET_LIVE_OFFSET_INCREMENT_ON_REBUFFER_MS); } /** @@ -145,13 +158,29 @@ public Builder setProportionalControlFactor(float proportionalControlFactor) { return this; } + /** + * Sets the increment applied to the target live offset each time the player is rebuffering, in + * milliseconds. + * + * @param targetLiveOffsetIncrementOnRebufferMs The increment applied to the target live offset + * when the player is rebuffering, in milliseconds + * @return This builder, for convenience. + */ + public Builder setTargetLiveOffsetIncrementOnRebufferMs( + long targetLiveOffsetIncrementOnRebufferMs) { + Assertions.checkArgument(targetLiveOffsetIncrementOnRebufferMs >= 0); + this.targetLiveOffsetIncrementOnRebufferUs = C.msToUs(targetLiveOffsetIncrementOnRebufferMs); + return this; + } + /** Builds an instance. */ public DefaultLivePlaybackSpeedControl build() { return new DefaultLivePlaybackSpeedControl( fallbackMinPlaybackSpeed, fallbackMaxPlaybackSpeed, minUpdateIntervalMs, - proportionalControlFactorUs); + proportionalControlFactorUs, + targetLiveOffsetIncrementOnRebufferUs); } } @@ -159,9 +188,11 @@ public DefaultLivePlaybackSpeedControl build() { private final float fallbackMaxPlaybackSpeed; private final long minUpdateIntervalMs; private final float proportionalControlFactor; + private final long targetLiveOffsetRebufferDeltaUs; private long mediaConfigurationTargetLiveOffsetUs; private long targetLiveOffsetOverrideUs; + private long idealTargetLiveOffsetUs; private long minTargetLiveOffsetUs; private long maxTargetLiveOffsetUs; private long currentTargetLiveOffsetUs; @@ -175,11 +206,13 @@ private DefaultLivePlaybackSpeedControl( float fallbackMinPlaybackSpeed, float fallbackMaxPlaybackSpeed, long minUpdateIntervalMs, - float proportionalControlFactor) { + float proportionalControlFactor, + long targetLiveOffsetRebufferDeltaUs) { this.fallbackMinPlaybackSpeed = fallbackMinPlaybackSpeed; this.fallbackMaxPlaybackSpeed = fallbackMaxPlaybackSpeed; this.minUpdateIntervalMs = minUpdateIntervalMs; this.proportionalControlFactor = proportionalControlFactor; + this.targetLiveOffsetRebufferDeltaUs = targetLiveOffsetRebufferDeltaUs; mediaConfigurationTargetLiveOffsetUs = C.TIME_UNSET; targetLiveOffsetOverrideUs = C.TIME_UNSET; minTargetLiveOffsetUs = C.TIME_UNSET; @@ -188,6 +221,7 @@ private DefaultLivePlaybackSpeedControl( maxPlaybackSpeed = fallbackMaxPlaybackSpeed; adjustedPlaybackSpeed = 1.0f; lastPlaybackSpeedUpdateMs = C.TIME_UNSET; + idealTargetLiveOffsetUs = C.TIME_UNSET; currentTargetLiveOffsetUs = C.TIME_UNSET; } @@ -213,6 +247,19 @@ public void setTargetLiveOffsetOverrideUs(long liveOffsetUs) { maybeResetTargetLiveOffsetUs(); } + @Override + public void notifyRebuffer() { + if (currentTargetLiveOffsetUs == C.TIME_UNSET) { + return; + } + currentTargetLiveOffsetUs += targetLiveOffsetRebufferDeltaUs; + if (maxTargetLiveOffsetUs != C.TIME_UNSET + && currentTargetLiveOffsetUs > maxTargetLiveOffsetUs) { + currentTargetLiveOffsetUs = maxTargetLiveOffsetUs; + } + lastPlaybackSpeedUpdateMs = C.TIME_UNSET; + } + @Override public float getAdjustedPlaybackSpeed(long liveOffsetUs) { if (mediaConfigurationTargetLiveOffsetUs == C.TIME_UNSET) { @@ -254,9 +301,10 @@ private void maybeResetTargetLiveOffsetUs() { idealOffsetUs = maxTargetLiveOffsetUs; } } - if (currentTargetLiveOffsetUs == idealOffsetUs) { + if (idealTargetLiveOffsetUs == idealOffsetUs) { return; } + idealTargetLiveOffsetUs = idealOffsetUs; currentTargetLiveOffsetUs = idealOffsetUs; lastPlaybackSpeedUpdateMs = C.TIME_UNSET; } 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 21b7635b401..20a5201e0d5 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 @@ -189,7 +189,7 @@ public interface PlaybackInfoUpdateListener { private boolean released; private boolean pauseAtEndOfWindow; private boolean pendingPauseAtEndOfPeriod; - private boolean rebuffering; + private boolean isRebuffering; private boolean shouldContinueLoading; @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; @@ -733,7 +733,7 @@ private void setPlayWhenReadyInternal( playbackInfoUpdate.incrementPendingOperationAcks(operationAck ? 1 : 0); playbackInfoUpdate.setPlayWhenReadyChangeReason(reason); playbackInfo = playbackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason); - rebuffering = false; + isRebuffering = false; if (!shouldPlayWhenReady()) { stopRenderers(); updatePlaybackPositions(); @@ -811,7 +811,7 @@ private void seekToCurrentPosition(boolean sendDiscontinuity) throws ExoPlayback } private void startRenderers() throws ExoPlaybackException { - rebuffering = false; + isRebuffering = false; mediaClock.start(); for (Renderer renderer : renderers) { if (isRendererEnabled(renderer)) { @@ -868,6 +868,7 @@ private void updatePlaybackPositions() throws ExoPlaybackException { // Adjust live playback speed to new position. if (playbackInfo.playWhenReady + && playbackInfo.playbackState == Player.STATE_READY && isCurrentPeriodInMovingLiveWindow() && playbackInfo.playbackParameters.speed == 1f) { float adjustedSpeed = @@ -960,8 +961,9 @@ && shouldTransitionToReadyState(renderersAllowPlayback)) { } } else if (playbackInfo.playbackState == Player.STATE_READY && !(enabledRendererCount == 0 ? isTimelineReady() : renderersAllowPlayback)) { - rebuffering = shouldPlayWhenReady(); + isRebuffering = shouldPlayWhenReady(); setState(Player.STATE_BUFFERING); + livePlaybackSpeedControl.notifyRebuffer(); stopRenderers(); } @@ -1168,7 +1170,7 @@ private long seekToPeriodPosition( boolean forceBufferingState) throws ExoPlaybackException { stopRenderers(); - rebuffering = false; + isRebuffering = false; if (forceBufferingState || playbackInfo.playbackState == Player.STATE_READY) { setState(Player.STATE_BUFFERING); } @@ -1311,7 +1313,7 @@ private void resetInternal( boolean releaseMediaSourceList, boolean resetError) { handler.removeMessages(MSG_DO_SOME_WORK); - rebuffering = false; + isRebuffering = false; mediaClock.stop(); rendererPositionUs = 0; for (Renderer renderer : renderers) { @@ -1701,7 +1703,7 @@ private boolean shouldTransitionToReadyState(boolean renderersReadyOrEnded) { || loadControl.shouldStartPlayback( getTotalBufferedDurationUs(), mediaClock.getPlaybackParameters().speed, - rebuffering, + isRebuffering, targetLiveOffsetUs); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java index 03aa3253079..8844c629087 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java @@ -40,6 +40,14 @@ public interface LivePlaybackSpeedControl { */ void setTargetLiveOffsetOverrideUs(long liveOffsetUs); + /** + * Notifies the live playback speed control that a rebuffer occurred. + * + *

    A rebuffer is defined to be caused by buffer depletion rather than a user action. Hence this + * method is not called during initial or when buffering as a result of a seek operation. + */ + void notifyRebuffer(); + /** * Returns the adjusted playback speed in order get closer towards the {@link * #getTargetLiveOffsetUs() target live offset}. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java index 8ec49ebabc5..9c08b9999be 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java @@ -19,7 +19,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.MediaItem.LiveConfiguration; +import com.google.common.collect.Iterables; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.shadows.ShadowSystemClock; @@ -37,7 +40,7 @@ public void getTargetLiveOffsetUs_returnsUnset() { } @Test - public void getTargetLiveOffsetUs_afterSetLiveConfiguration_usesMediaLiveOffset() { + public void getTargetLiveOffsetUs_afterSetLiveConfiguration_returnsMediaLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( @@ -53,7 +56,7 @@ public void getTargetLiveOffsetUs_afterSetLiveConfiguration_usesMediaLiveOffset( @Test public void - getTargetLiveOffsetUs_afterSetLiveConfigurationWithTargetGreaterThanMax_usesMaxLiveOffset() { + getTargetLiveOffsetUs_afterSetLiveConfigurationWithTargetGreaterThanMax_returnsMaxLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( @@ -69,7 +72,7 @@ public void getTargetLiveOffsetUs_afterSetLiveConfiguration_usesMediaLiveOffset( @Test public void - getTargetLiveOffsetUs_afterSetLiveConfigurationWithTargetLessThanMin_usesMinLiveOffset() { + getTargetLiveOffsetUs_afterSetLiveConfigurationWithTargetLessThanMin_returnsMinLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( @@ -84,7 +87,7 @@ public void getTargetLiveOffsetUs_afterSetLiveConfiguration_usesMediaLiveOffset( } @Test - public void getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUs_usesOverride() { + public void getTargetLiveOffsetUs_afterSetTargetLiveOffsetOverrideUs_returnsOverride() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); @@ -104,7 +107,7 @@ public void getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUs_usesOverride @Test public void - getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUsGreaterThanMax_usesMaxLiveOffset() { + getTargetLiveOffsetUs_afterSetTargetLiveOffsetOverrideUsGreaterThanMax_returnsMaxLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); @@ -124,7 +127,7 @@ public void getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUs_usesOverride @Test public void - getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUsLessThanMin_usesMinLiveOffset() { + getTargetLiveOffsetUs_afterSetTargetLiveOffsetOverrideUsLessThanMin_returnsMinLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); @@ -156,7 +159,7 @@ public void getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUs_usesOverride @Test public void - getTargetLiveOffsetUs_afterSetTargetLiveOffsetOverrideWithTimeUnset_usesMediaLiveOffset() { + getTargetLiveOffsetUs_afterSetTargetLiveOffsetOverrideWithTimeUnset_returnsMediaLiveOffset() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().build(); defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(123_456_789); @@ -174,6 +177,153 @@ public void getTargetLiveOffsetUs_withSetTargetLiveOffsetOverrideUs_usesOverride assertThat(targetLiveOffsetUs).isEqualTo(42_000); } + @Test + public void getTargetLiveOffsetUs_afterNotifyRebuffer_returnsIncreasedTargetOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + long targetLiveOffsetBeforeUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + defaultLivePlaybackSpeedControl.notifyRebuffer(); + long targetLiveOffsetAfterUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetAfterUs).isGreaterThan(targetLiveOffsetBeforeUs); + assertThat(targetLiveOffsetAfterUs - targetLiveOffsetBeforeUs).isEqualTo(3_000); + } + + @Test + public void getTargetLiveOffsetUs_afterRepeatedNotifyRebuffer_returnsMaxLiveOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + List targetOffsetsUs = new ArrayList<>(); + for (int i = 0; i < 500; i++) { + targetOffsetsUs.add(defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs()); + defaultLivePlaybackSpeedControl.notifyRebuffer(); + } + + assertThat(targetOffsetsUs).isInOrder(); + assertThat(Iterables.getLast(targetOffsetsUs)).isEqualTo(400_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterNotifyRebufferWithIncrementOfZero_returnsOriginalTargetOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(0) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + defaultLivePlaybackSpeedControl.notifyRebuffer(); + long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetUs).isEqualTo(42_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterNotifyRebufferAndSetTargetLiveOffsetOverrideUs_returnsOverride() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + defaultLivePlaybackSpeedControl.notifyRebuffer(); + defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(321_000); + long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetUs).isEqualTo(321_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterNotifyRebufferAndSetLiveConfigurationWithSameOffset_returnsIncreasedTargetOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + long targetLiveOffsetBeforeUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + defaultLivePlaybackSpeedControl.notifyRebuffer(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 3, + /* maxLiveOffsetMs= */ 450, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f)); + long targetLiveOffsetAfterUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetAfterUs).isGreaterThan(targetLiveOffsetBeforeUs); + assertThat(targetLiveOffsetAfterUs - targetLiveOffsetBeforeUs).isEqualTo(3_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterNotifyRebufferAndSetLiveConfigurationWithNewOffset_returnsNewOffset() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42, + /* minLiveOffsetMs= */ 5, + /* maxLiveOffsetMs= */ 400, + /* minPlaybackSpeed= */ 1f, + /* maxPlaybackSpeed= */ 1f)); + + defaultLivePlaybackSpeedControl.notifyRebuffer(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 39, + /* minLiveOffsetMs= */ 3, + /* maxLiveOffsetMs= */ 450, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f)); + long targetLiveOffsetUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetUs).isEqualTo(39_000); + } + @Test public void adjustPlaybackSpeed_liveOffsetMatchesTargetOffset_returnsUnitSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = @@ -367,7 +517,7 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA @Test public void - adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfigurationWithSameOffset_returnsSameAdjustedSpeed() { + adjustPlaybackSpeed_repeatedCallAfterSetLiveConfigurationWithSameOffset_returnsSameAdjustedSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( @@ -395,7 +545,7 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA @Test public void - adjustPlaybackSpeed_repeatedCallAfterUpdateLiveConfigurationWithNewOffset_updatesSpeedAgain() { + adjustPlaybackSpeed_repeatedCallAfterSetLiveConfigurationWithNewOffset_updatesSpeedAgain() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( @@ -422,7 +572,8 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA } @Test - public void adjustPlaybackSpeed_repeatedCallAfterNewTargetLiveOffset_updatesSpeedAgain() { + public void + adjustPlaybackSpeed_repeatedCallAfterSetTargetLiveOffsetOverrideUs_updatesSpeedAgain() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( @@ -441,4 +592,25 @@ public void adjustPlaybackSpeed_repeatedCallAfterNewTargetLiveOffset_updatesSpee assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); } + + @Test + public void adjustPlaybackSpeed_repeatedCallAfterNotifyRebuffer_updatesSpeedAgain() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder().setMinUpdateIntervalMs(123).build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 2_000, + /* minLiveOffsetMs= */ C.TIME_UNSET, + /* maxLiveOffsetMs= */ C.TIME_UNSET, + /* minPlaybackSpeed= */ C.RATE_UNSET, + /* maxPlaybackSpeed= */ C.RATE_UNSET)); + + float adjustedSpeed1 = + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.notifyRebuffer(); + float adjustedSpeed2 = + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + + assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); + } } From 773e890768c84df3076480fb9dd0c2fd53c86644 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 4 Nov 2020 16:33:18 +0000 Subject: [PATCH 247/693] Move another adaptation workaround into MediaCodecInfo PiperOrigin-RevId: 340654217 --- .../exoplayer2/mediacodec/MediaCodecInfo.java | 18 +++++++++++++++++- .../exoplayer2/mediacodec/MediaCodecUtil.java | 18 ++---------------- 2 files changed, 19 insertions(+), 17 deletions(-) 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 90f9517a8d2..cad6cc72f70 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 @@ -179,7 +179,10 @@ public static MediaCodecInfo newInstance( hardwareAccelerated, softwareOnly, vendor, - /* adaptive= */ !forceDisableAdaptive && capabilities != null && isAdaptive(capabilities), + /* adaptive= */ !forceDisableAdaptive + && capabilities != null + && isAdaptive(capabilities) + && !needsDisableAdaptationWorkaround(name), /* tunneling= */ capabilities != null && isTunneling(capabilities), /* secure= */ forceSecure || (capabilities != null && isSecure(capabilities))); } @@ -653,6 +656,19 @@ private static int getMaxSupportedInstancesV23(CodecCapabilities capabilities) { return capabilities.getMaxSupportedInstances(); } + /** + * Returns whether the decoder is known to fail when adapting, despite advertising itself as an + * adaptive decoder. + * + * @param name The decoder name. + * @return True if the decoder is known to fail when adapting. + */ + private static boolean needsDisableAdaptationWorkaround(String name) { + return Util.SDK_INT <= 22 + && ("ODROID-XU3".equals(Util.MODEL) || "Nexus 10".equals(Util.MODEL)) + && ("OMX.Exynos.AVC.Decoder".equals(name) || "OMX.Exynos.AVC.Decoder.secure".equals(name)); + } + /** * Returns whether the decoder is known to fail when an attempt is made to reconfigure it with a * new format's configuration data. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java index 64eb0bb8374..3183672c626 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java @@ -312,7 +312,6 @@ private static ArrayList getDecoderInfosInternal( boolean hardwareAccelerated = isHardwareAccelerated(codecInfo); boolean softwareOnly = isSoftwareOnly(codecInfo); boolean vendor = isVendor(codecInfo); - boolean forceDisableAdaptive = codecNeedsDisableAdaptationWorkaround(name); if ((secureDecodersExplicit && key.secure == secureSupported) || (!secureDecodersExplicit && !key.secure)) { decoderInfos.add( @@ -324,7 +323,7 @@ private static ArrayList getDecoderInfosInternal( hardwareAccelerated, softwareOnly, vendor, - forceDisableAdaptive, + /* forceDisableAdaptive= */ false, /* forceSecure= */ false)); } else if (!secureDecodersExplicit && secureSupported) { decoderInfos.add( @@ -336,7 +335,7 @@ private static ArrayList getDecoderInfosInternal( hardwareAccelerated, softwareOnly, vendor, - forceDisableAdaptive, + /* forceDisableAdaptive= */ false, /* forceSecure= */ true)); // It only makes sense to have one synthesized secure decoder, return immediately. return decoderInfos; @@ -651,19 +650,6 @@ private static boolean isVendorV29(android.media.MediaCodecInfo codecInfo) { return codecInfo.isVendor(); } - /** - * Returns whether the decoder is known to fail when adapting, despite advertising itself as an - * adaptive decoder. - * - * @param name The decoder name. - * @return True if the decoder is known to fail when adapting. - */ - private static boolean codecNeedsDisableAdaptationWorkaround(String name) { - return Util.SDK_INT <= 22 - && ("ODROID-XU3".equals(Util.MODEL) || "Nexus 10".equals(Util.MODEL)) - && ("OMX.Exynos.AVC.Decoder".equals(name) || "OMX.Exynos.AVC.Decoder.secure".equals(name)); - } - @Nullable private static Pair getDolbyVisionProfileAndLevel( String codec, String[] parts) { From 702e5cfb3e1ea698dda3e7c4e29d3813aff5b6ee Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 5 Nov 2020 12:29:31 +0000 Subject: [PATCH 248/693] Fix or suppress nullness warnings introduced by checkerframework 3.7.0 PiperOrigin-RevId: 340826532 --- .../exoplayer2/ui/PlayerNotificationManager.java | 10 ++++++---- .../android/exoplayer2/ui/StyledPlayerControlView.java | 10 ++++------ 2 files changed, 10 insertions(+), 10 deletions(-) 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 b52a3e6f82b..7c899c1ea21 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 @@ -610,10 +610,12 @@ public PlayerNotificationManager( controlDispatcher = new DefaultControlDispatcher(); window = new Timeline.Window(); instanceId = instanceIdCounter++; - //noinspection Convert2MethodRef - mainHandler = - Util.createHandler( - Looper.getMainLooper(), msg -> PlayerNotificationManager.this.handleMessage(msg)); + // This fails the nullness checker because handleMessage() is 'called' while `this` is still + // @UnderInitialization. No tasks are scheduled on mainHandler before the constructor completes, + // so this is safe and we can suppress the warning. + @SuppressWarnings("nullness:methodref.receiver.bound.invalid") + Handler mainHandler = Util.createHandler(Looper.getMainLooper(), this::handleMessage); + this.mainHandler = mainHandler; notificationManager = NotificationManagerCompat.from(context); playerListener = new PlayerListener(); notificationBroadcastReceiver = new NotificationBroadcastReceiver(); 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 ed2bad6eeb6..f17404b7a23 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 @@ -1920,7 +1920,7 @@ public void setSubTextAtPosition(int position, String subText) { } } - private class SettingViewHolder extends RecyclerView.ViewHolder { + private final class SettingViewHolder extends RecyclerView.ViewHolder { private final TextView mainTextView; private final TextView subTextView; private final ImageView iconView; @@ -1930,8 +1930,7 @@ public SettingViewHolder(View 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())); + itemView.setOnClickListener(v -> onSettingViewClicked(getAdapterPosition())); } } @@ -1969,7 +1968,7 @@ public void setCheckPosition(int checkPosition) { } } - private class SubSettingViewHolder extends RecyclerView.ViewHolder { + private final class SubSettingViewHolder extends RecyclerView.ViewHolder { private final TextView textView; private final View checkView; @@ -1977,8 +1976,7 @@ 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())); + itemView.setOnClickListener(v -> onSubSettingViewClicked(getAdapterPosition())); } } From 477eae3c57d9badf63da1e32f630280ee442409a Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 5 Nov 2020 20:45:38 +0000 Subject: [PATCH 249/693] Fix incorrect decoder non-reuse when operating rate needs clearing This fixes a case where updateCodecOperatingRate would configure the decoder to be drained and then released, only for onInputFormatChanged to override the drain action with something else. We've not seen any reports of this issue, which suggests that either it's OK to not release the decoder in such cases, or that the case doesn't happen very often. I suspect that it's both, but let's restore the intended behaviour for now. PiperOrigin-RevId: 340909132 --- .../mediacodec/MediaCodecRenderer.java | 69 ++++++++++++------- 1 file changed, 44 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 987a5563457..49bb5c35c10 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 @@ -683,7 +683,7 @@ public void setPlaybackSpeed(float playbackSpeed) throws ExoPlaybackException { if (codec != null && codecDrainAction != DRAIN_ACTION_REINITIALIZE && getState() != STATE_DISABLED) { - updateCodecOperatingRate(); + updateOperatingRateOrReinitializeCodec(codecInputFormat); } } @@ -1416,33 +1416,42 @@ protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybac drainAndReinitializeCodec(); break; case KEEP_CODEC_RESULT_YES_WITH_FLUSH: - codecInputFormat = newFormat; - updateCodecOperatingRate(); - if (drainAndUpdateCodecDrmSession) { - drainAndUpdateCodecDrmSessionV23(); + if (updateOperatingRateOrReinitializeCodec(newFormat)) { + // Codec re-initialization triggered. } else { - drainAndFlushCodec(); + codecInputFormat = newFormat; + if (drainAndUpdateCodecDrmSession) { + drainAndUpdateCodecDrmSessionV23(); + } else { + drainAndFlushCodec(); + } } break; case KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION: - codecReconfigured = true; - codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; - codecNeedsAdaptationWorkaroundBuffer = - codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS - || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION - && newFormat.width == codecInputFormat.width - && newFormat.height == codecInputFormat.height); - codecInputFormat = newFormat; - updateCodecOperatingRate(); - if (drainAndUpdateCodecDrmSession) { - drainAndUpdateCodecDrmSessionV23(); + if (updateOperatingRateOrReinitializeCodec(newFormat)) { + // Codec re-initialization triggered. + } else { + codecReconfigured = true; + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + codecNeedsAdaptationWorkaroundBuffer = + codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS + || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION + && newFormat.width == codecInputFormat.width + && newFormat.height == codecInputFormat.height); + codecInputFormat = newFormat; + if (drainAndUpdateCodecDrmSession) { + drainAndUpdateCodecDrmSessionV23(); + } } break; case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: - codecInputFormat = newFormat; - updateCodecOperatingRate(); - if (drainAndUpdateCodecDrmSession) { - drainAndUpdateCodecDrmSessionV23(); + if (updateOperatingRateOrReinitializeCodec(newFormat)) { + // Codec re-initialization triggered. + } else { + codecInputFormat = newFormat; + if (drainAndUpdateCodecDrmSession) { + drainAndUpdateCodecDrmSessionV23(); + } } break; default: @@ -1598,23 +1607,30 @@ protected float getCodecOperatingRateV23( } /** - * Updates the codec operating rate. + * Updates the codec operating rate, or triggers codec release and re-initialization if a + * previously set operating rate needs to be cleared. * + * @param format The {@link Format} for which the operating rate should be configured. * @throws ExoPlaybackException If an error occurs releasing or initializing a codec. + * @return Whether codec release and re-initialization was triggered, rather than the existing + * codec being updated. */ - private void updateCodecOperatingRate() throws ExoPlaybackException { + private boolean updateOperatingRateOrReinitializeCodec(Format format) + throws ExoPlaybackException { if (Util.SDK_INT < 23) { - return; + return false; } float newCodecOperatingRate = - getCodecOperatingRateV23(playbackSpeed, codecInputFormat, getStreamFormats()); + getCodecOperatingRateV23(playbackSpeed, format, getStreamFormats()); if (codecOperatingRate == newCodecOperatingRate) { // No change. + return false; } else if (newCodecOperatingRate == CODEC_OPERATING_RATE_UNSET) { // The only way to clear the operating rate is to instantiate a new codec instance. See // [Internal ref: b/111543954]. drainAndReinitializeCodec(); + return true; } else if (codecOperatingRate != CODEC_OPERATING_RATE_UNSET || newCodecOperatingRate > assumedMinimumCodecOperatingRate) { // We need to set the operating rate, either because we've set it previously or because it's @@ -1623,7 +1639,10 @@ private void updateCodecOperatingRate() throws ExoPlaybackException { codecParameters.putFloat(MediaFormat.KEY_OPERATING_RATE, newCodecOperatingRate); codec.setParameters(codecParameters); codecOperatingRate = newCodecOperatingRate; + return false; } + + return false; } /** Starts draining the codec for flush. */ From 1fb675e8769357ec161bcf268d5981ef1e108e25 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 5 Nov 2020 21:19:20 +0000 Subject: [PATCH 250/693] Move last-buffer timestamp fix to better location PiperOrigin-RevId: 340915538 --- .../audio/MediaCodecAudioRenderer.java | 27 ------ .../mediacodec/MediaCodecRenderer.java | 85 +++++++++++-------- 2 files changed, 50 insertions(+), 62 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 40551737392..c4dfaed7fe7 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 @@ -94,7 +94,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private int codecMaxInputSize; private boolean codecNeedsDiscardChannelsWorkaround; - private boolean codecNeedsEosBufferTimestampWorkaround; /** Codec used for DRM decryption only in passthrough and offload. */ @Nullable private Format decryptOnlyCodecFormat; @@ -318,7 +317,6 @@ protected void configureCodec( float codecOperatingRate) { codecMaxInputSize = getCodecMaxInputSize(codecInfo, format, getStreamFormats()); codecNeedsDiscardChannelsWorkaround = codecNeedsDiscardChannelsWorkaround(codecInfo.name); - codecNeedsEosBufferTimestampWorkaround = codecNeedsEosBufferTimestampWorkaround(codecInfo.name); MediaFormat mediaFormat = getMediaFormat(format, codecInfo.codecMimeType, codecMaxInputSize, codecOperatingRate); codecAdapter.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); @@ -571,13 +569,6 @@ protected boolean processOutputBuffer( Format format) throws ExoPlaybackException { checkNotNull(buffer); - if (codec != null - && codecNeedsEosBufferTimestampWorkaround - && bufferPresentationTimeUs == 0 - && (bufferFlags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 - && getLargestQueuedPresentationTimeUs() != C.TIME_UNSET) { - bufferPresentationTimeUs = getLargestQueuedPresentationTimeUs(); - } if (decryptOnlyCodecFormat != null && (bufferFlags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { @@ -782,24 +773,6 @@ private static boolean codecNeedsDiscardChannelsWorkaround(String codecName) { || Util.DEVICE.startsWith("heroqlte")); } - /** - * Returns whether the decoder may output a non-empty buffer with timestamp 0 as the end of stream - * buffer. - * - *

    If true is returned, the renderer will work around the issue by instantiating a new decoder + * when this case occurs. + * + *

    See [Internal: b/141097367]. + * + * @param name The name of the decoder. + * @return True if the decoder is known to behave incorrectly if flushed prior to having output a + * {@link MediaFormat}. False otherwise. + */ + private static boolean codecNeedsSosFlushWorkaround(String name) { + return Util.SDK_INT == 29 && "c2.android.aac.decoder".equals(name); + } + /** * Returns whether the decoder is known to handle the propagation of the {@link * MediaCodec#BUFFER_FLAG_END_OF_STREAM} flag incorrectly on the host device. @@ -2316,12 +2330,30 @@ private static boolean codecNeedsEosFlushWorkaround(String name) { } /** - * Returns whether the decoder may throw an {@link IllegalStateException} from - * {@link MediaCodec#dequeueOutputBuffer(MediaCodec.BufferInfo, long)} or - * {@link MediaCodec#releaseOutputBuffer(int, boolean)} after receiving an input - * buffer with {@link MediaCodec#BUFFER_FLAG_END_OF_STREAM} set. - *

    - * See [Internal: b/17933838]. + * Returns whether the decoder may output a non-empty buffer with timestamp 0 as the end of stream + * buffer. + * + *

    See GitHub issue #5045. + */ + private static boolean codecNeedsEosBufferTimestampWorkaround(String codecName) { + return Util.SDK_INT < 21 + && "OMX.SEC.mp3.dec".equals(codecName) + && "samsung".equals(Util.MANUFACTURER) + && (Util.DEVICE.startsWith("baffin") + || Util.DEVICE.startsWith("grand") + || Util.DEVICE.startsWith("fortuna") + || Util.DEVICE.startsWith("gprimelte") + || Util.DEVICE.startsWith("j2y18lte") + || Util.DEVICE.startsWith("ms01")); + } + + /** + * Returns whether the decoder may throw an {@link IllegalStateException} from {@link + * MediaCodec#dequeueOutputBuffer(MediaCodec.BufferInfo, long)} or {@link + * MediaCodec#releaseOutputBuffer(int, boolean)} after receiving an input buffer with {@link + * MediaCodec#BUFFER_FLAG_END_OF_STREAM} set. + * + *

    See [Internal: b/17933838]. * * @param name The name of the decoder. * @return True if the decoder may throw an exception after receiving an end-of-stream buffer. @@ -2348,21 +2380,4 @@ private static boolean codecNeedsMonoChannelCountWorkaround(String name, Format return Util.SDK_INT <= 18 && format.channelCount == 1 && "OMX.MTK.AUDIO.DECODER.MP3".equals(name); } - - /** - * Returns whether the decoder is known to behave incorrectly if flushed prior to having output a - * {@link MediaFormat}. - * - *

    If true is returned, the renderer will work around the issue by instantiating a new decoder - * when this case occurs. - * - *

    See [Internal: b/141097367]. - * - * @param name The name of the decoder. - * @return True if the decoder is known to behave incorrectly if flushed prior to having output a - * {@link MediaFormat}. False otherwise. - */ - private static boolean codecNeedsSosFlushWorkaround(String name) { - return Util.SDK_INT == 29 && "c2.android.aac.decoder".equals(name); - } } From 1bcf1cf9f7d1fddf7b72e98f469bc4a71f330378 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 6 Nov 2020 10:10:23 +0000 Subject: [PATCH 251/693] Decide whether to release rather than flush in onInputFormatChanged - This change removes the last piece of logic that could cause deferred codec release (i.e., where the decision to release was made in processEndOfStream rather than in onInputFormatChanged. - After this change, whether the codec will be released as a result of a format change is always established in onInputFormatChanged. PiperOrigin-RevId: 341012403 --- .../mediacodec/MediaCodecRenderer.java | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 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 4e7bd67eecd..39177b3e113 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 @@ -236,7 +236,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { @IntDef({ DRAIN_ACTION_NONE, DRAIN_ACTION_FLUSH, - DRAIN_ACTION_UPDATE_DRM_SESSION, + DRAIN_ACTION_FLUSH_AND_UPDATE_DRM_SESSION, DRAIN_ACTION_REINITIALIZE }) private @interface DrainAction {} @@ -245,7 +245,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { /** The codec should be flushed. */ private static final int DRAIN_ACTION_FLUSH = 1; /** The codec should be flushed and updated to use the pending DRM session. */ - private static final int DRAIN_ACTION_UPDATE_DRM_SESSION = 2; + private static final int DRAIN_ACTION_FLUSH_AND_UPDATE_DRM_SESSION = 2; /** The codec should be reinitialized. */ private static final int DRAIN_ACTION_REINITIALIZE = 3; @@ -839,12 +839,17 @@ protected boolean flushOrReleaseCodec() { releaseCodec(); return true; } + flushCodec(); + return false; + } + + /** Flushes the codec. */ + private void flushCodec() { try { codecAdapter.flush(); } finally { resetCodecStateForFlush(); } - return false; } /** Resets the renderer internal state after a codec flush. */ @@ -1652,7 +1657,11 @@ private boolean updateOperatingRateOrReinitializeCodec(Format format) private void drainAndFlushCodec() { if (codecReceivedBuffers) { codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; - codecDrainAction = DRAIN_ACTION_FLUSH; + if (codecNeedsFlushWorkaround || codecNeedsEosFlushWorkaround) { + codecDrainAction = DRAIN_ACTION_REINITIALIZE; + } else { + codecDrainAction = DRAIN_ACTION_FLUSH; + } } } @@ -1666,7 +1675,11 @@ private void drainAndFlushCodec() { private void drainAndUpdateCodecDrmSessionV23() throws ExoPlaybackException { if (codecReceivedBuffers) { codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; - codecDrainAction = DRAIN_ACTION_UPDATE_DRM_SESSION; + if (codecNeedsFlushWorkaround || codecNeedsEosFlushWorkaround) { + codecDrainAction = DRAIN_ACTION_REINITIALIZE; + } else { + codecDrainAction = DRAIN_ACTION_FLUSH_AND_UPDATE_DRM_SESSION; + } } else { // Nothing has been queued to the decoder, so we can do the update immediately. updateDrmSessionV23(); @@ -1913,13 +1926,12 @@ private void processEndOfStream() throws ExoPlaybackException { case DRAIN_ACTION_REINITIALIZE: reinitializeCodec(); break; - case DRAIN_ACTION_UPDATE_DRM_SESSION: - if (!flushOrReinitializeCodec()) { - updateDrmSessionV23(); - } + case DRAIN_ACTION_FLUSH_AND_UPDATE_DRM_SESSION: + flushCodec(); + updateDrmSessionV23(); break; case DRAIN_ACTION_FLUSH: - flushOrReinitializeCodec(); + flushCodec(); break; case DRAIN_ACTION_NONE: default: From 07e33a1395127096600bf17388d65e838f1dbf89 Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 6 Nov 2020 10:50:18 +0000 Subject: [PATCH 252/693] Add getInputBuffer/getOutputBuffer in MediaCodecAdapter PiperOrigin-RevId: 341016263 --- .../AsynchronousMediaCodecAdapter.java | 13 +++++ .../mediacodec/MediaCodecAdapter.java | 17 ++++++ .../mediacodec/MediaCodecRenderer.java | 52 +------------------ .../SynchronousMediaCodecAdapter.java | 45 +++++++++++++++- 4 files changed, 75 insertions(+), 52 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 3a0de6fab86..a705ec42088 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 @@ -30,6 +30,7 @@ import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; /** * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in asynchronous mode, @@ -122,6 +123,18 @@ public MediaFormat getOutputFormat() { return asynchronousMediaCodecCallback.getOutputFormat(); } + @Override + @Nullable + public ByteBuffer getInputBuffer(int index) { + return codec.getInputBuffer(index); + } + + @Override + @Nullable + public ByteBuffer getOutputBuffer(int index) { + return codec.getOutputBuffer(index); + } + @Override public void flush() { // The order of calls is important: 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 78bdeade81b..5d785d650cc 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 @@ -22,6 +22,7 @@ import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.decoder.CryptoInfo; +import java.nio.ByteBuffer; /** * Abstracts {@link MediaCodec} operations. @@ -77,6 +78,22 @@ void configure( */ MediaFormat getOutputFormat(); + /** + * Returns a writable ByteBuffer object for a dequeued input buffer index. + * + * @see MediaCodec#getInputBuffer(int) + */ + @Nullable + ByteBuffer getInputBuffer(int index); + + /** + * Returns a read-only ByteBuffer for a dequeued output buffer index. + * + * @see MediaCodec#getOutputBuffer(int) + */ + @Nullable + ByteBuffer getOutputBuffer(int index); + /** * Submit an input buffer for decoding. * 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 39177b3e113..93170310839 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 @@ -327,8 +327,6 @@ private static String buildCustomDiagnosticInfo(int errorCode) { private boolean shouldSkipAdaptationWorkaroundOutputBuffer; private boolean codecNeedsEosPropagation; @Nullable private C2Mp3TimestampTracker c2Mp3TimestampTracker; - private ByteBuffer[] inputBuffers; - private ByteBuffer[] outputBuffers; private long codecHotswapDeadlineMs; private int inputIndex; private int outputIndex; @@ -909,7 +907,6 @@ protected void resetCodecStateForRelease() { codecNeedsEosPropagation = false; codecReconfigured = false; codecReconfigurationState = RECONFIGURATION_STATE_NONE; - resetCodecBuffers(); mediaCryptoRequiresSecureDecoder = false; } @@ -1072,13 +1069,11 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce codecAdapter.start(); TraceUtil.endSection(); codecInitializedTimestamp = SystemClock.elapsedRealtime(); - getCodecBuffers(codec); } catch (Exception e) { if (codecAdapter != null) { codecAdapter.shutdown(); } if (codec != null) { - resetCodecBuffers(); codec.release(); } throw e; @@ -1119,37 +1114,6 @@ private boolean shouldContinueRendering(long renderStartTimeMs) { || SystemClock.elapsedRealtime() - renderStartTimeMs < renderTimeLimitMs; } - private void getCodecBuffers(MediaCodec codec) { - if (Util.SDK_INT < 21) { - inputBuffers = codec.getInputBuffers(); - outputBuffers = codec.getOutputBuffers(); - } - } - - private void resetCodecBuffers() { - if (Util.SDK_INT < 21) { - inputBuffers = null; - outputBuffers = null; - } - } - - private ByteBuffer getInputBuffer(int inputIndex) { - if (Util.SDK_INT >= 21) { - return codec.getInputBuffer(inputIndex); - } else { - return inputBuffers[inputIndex]; - } - } - - @Nullable - private ByteBuffer getOutputBuffer(int outputIndex) { - if (Util.SDK_INT >= 21) { - return codec.getOutputBuffer(outputIndex); - } else { - return outputBuffers[outputIndex]; - } - } - private boolean hasOutputBuffer() { return outputIndex >= 0; } @@ -1188,7 +1152,7 @@ private boolean feedInputBuffer() throws ExoPlaybackException { if (inputIndex < 0) { return false; } - buffer.data = getInputBuffer(inputIndex); + buffer.data = codecAdapter.getInputBuffer(inputIndex); buffer.clear(); } @@ -1729,9 +1693,6 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED /* (-2) */) { processOutputMediaFormatChanged(); return true; - } else if (outputIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED /* (-3) */) { - processOutputBuffersChanged(); - return true; } /* MediaCodec.INFO_TRY_AGAIN_LATER (-1) or unknown negative return value */ if (codecNeedsEosPropagation @@ -1754,7 +1715,7 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) } this.outputIndex = outputIndex; - outputBuffer = getOutputBuffer(outputIndex); + outputBuffer = codecAdapter.getOutputBuffer(outputIndex); // The dequeued buffer is a media buffer. Do some initial setup. // It will be processed by calling processOutputBuffer (possibly multiple times). @@ -1846,15 +1807,6 @@ private void processOutputMediaFormatChanged() { codecOutputMediaFormatChanged = true; } - /** - * Processes a change in the output buffers. - */ - private void processOutputBuffersChanged() { - if (Util.SDK_INT < 21) { - outputBuffers = codec.getOutputBuffers(); - } - } - /** * Processes an output media buffer. * 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 f5138e90f06..0c5f80bd199 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 @@ -16,12 +16,16 @@ package com.google.android.exoplayer2.mediacodec; +import static com.google.android.exoplayer2.util.Util.castNonNull; + 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; +import com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; /** * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in synchronous mode. @@ -29,6 +33,8 @@ /* package */ final class SynchronousMediaCodecAdapter implements MediaCodecAdapter { private final MediaCodec codec; + @Nullable private ByteBuffer[] inputByteBuffers; + @Nullable private ByteBuffer[] outputByteBuffers; public SynchronousMediaCodecAdapter(MediaCodec mediaCodec) { this.codec = mediaCodec; @@ -46,6 +52,10 @@ public void configure( @Override public void start() { codec.start(); + if (Util.SDK_INT < 21) { + inputByteBuffers = codec.getInputBuffers(); + outputByteBuffers = codec.getOutputBuffers(); + } } @Override @@ -55,7 +65,15 @@ public int dequeueInputBufferIndex() { @Override public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - return codec.dequeueOutputBuffer(bufferInfo, 0); + int index; + do { + index = codec.dequeueOutputBuffer(bufferInfo, 0); + if (index == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED && Util.SDK_INT < 21) { + outputByteBuffers = codec.getOutputBuffers(); + } + } while (index == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED); + + return index; } @Override @@ -63,6 +81,26 @@ public MediaFormat getOutputFormat() { return codec.getOutputFormat(); } + @Override + @Nullable + public ByteBuffer getInputBuffer(int index) { + if (Util.SDK_INT >= 21) { + return codec.getInputBuffer(index); + } else { + return castNonNull(inputByteBuffers)[index]; + } + } + + @Override + @Nullable + public ByteBuffer getOutputBuffer(int index) { + if (Util.SDK_INT >= 21) { + return codec.getOutputBuffer(index); + } else { + return castNonNull(outputByteBuffers)[index]; + } + } + @Override public void queueInputBuffer( int index, int offset, int size, long presentationTimeUs, int flags) { @@ -82,7 +120,10 @@ public void flush() { } @Override - public void shutdown() {} + public void shutdown() { + inputByteBuffers = null; + outputByteBuffers = null; + } @Override public MediaCodec getCodec() { From d94943f014e6523d042424414aac7860d2ca01f0 Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 6 Nov 2020 11:20:18 +0000 Subject: [PATCH 253/693] Add SEF super slomo test sample PiperOrigin-RevId: 341019272 --- .../media/mp4/sample_sef_super_slow_motion.mp4 | Bin 0 -> 64755 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 testdata/src/test/assets/media/mp4/sample_sef_super_slow_motion.mp4 diff --git a/testdata/src/test/assets/media/mp4/sample_sef_super_slow_motion.mp4 b/testdata/src/test/assets/media/mp4/sample_sef_super_slow_motion.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..ab3b2da1345553a2d6ee4fdf498ec53878b8d955 GIT binary patch literal 64755 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 literal 0 HcmV?d00001 From 0c301fefa82e331239ed697618ae096e45af5f77 Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 6 Nov 2020 11:34:21 +0000 Subject: [PATCH 254/693] Pass AdsMediaSource to other AdsLoader methods Issue: #3750 PiperOrigin-RevId: 341020676 --- .../exoplayer2/ext/ima/ImaAdsLoader.java | 11 ++++++++--- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 4 ++-- .../exoplayer2/source/ads/AdsLoader.java | 19 ++++++++++++------- .../exoplayer2/source/ads/AdsMediaSource.java | 11 ++++++++--- .../android/exoplayer2/ExoPlayerTest.java | 11 ++++++++--- 5 files changed, 38 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 c5c17c02d6f..ccffd1aca7d 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 @@ -494,7 +494,7 @@ public void start( } @Override - public void stop() { + public void stop(AdsMediaSource adsMediaSource) { if (player != null && adTagLoader != null) { adTagLoader.stop(); } @@ -508,14 +508,19 @@ public void release() { } @Override - public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) { + public void handlePrepareComplete( + AdsMediaSource adsMediaSource, int adGroupIndex, int adIndexInAdGroup) { if (adTagLoader != null) { adTagLoader.handlePrepareComplete(adGroupIndex, adIndexInAdGroup); } } @Override - public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) { + public void handlePrepareError( + AdsMediaSource adsMediaSource, + int adGroupIndex, + int adIndexInAdGroup, + IOException exception) { if (adTagLoader != null) { adTagLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception); } 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 ab7e9f34853..59c9718c6ea 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 @@ -265,7 +265,7 @@ public void startAndCallbacksAfterRelease() { imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); imaAdsLoader.handlePrepareError( - /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException()); + adsMediaSource, /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException()); } @Test @@ -836,7 +836,7 @@ public void stop_unregistersAllVideoControlOverlays() { imaAdsLoader.start( adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup); - imaAdsLoader.stop(); + imaAdsLoader.stop(adsMediaSource); InOrder inOrder = inOrder(mockAdDisplayContainer); inOrder.verify(mockAdDisplayContainer).registerFriendlyObstruction(mockFriendlyObstruction); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java index f0bff82b1f2..b982eaae581 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java @@ -40,10 +40,10 @@ * *

    {@link #start(AdsMediaSource, DataSpec, Object, AdViewProvider, EventListener)} will be called * when an ads media source first initializes, at which point the loader can request ads. If the - * player enters the background, {@link #stop()} will be called. Loaders should maintain any ad - * playback state in preparation for a later call to {@link #start(AdsMediaSource, DataSpec, Object, - * AdViewProvider, EventListener)}. If an ad is playing when the player is detached, update the ad - * playback state with the current playback position using {@link + * player enters the background, {@link #stop(AdsMediaSource)} will be called. Loaders should + * maintain any ad playback state in preparation for a later call to {@link #start(AdsMediaSource, + * DataSpec, Object, AdViewProvider, EventListener)}. If an ad is playing when the player is + * detached, update the ad playback state with the current playback position using {@link * AdPlaybackState#withAdResumePositionUs(long)}. * *

    If {@link EventListener#onAdPlaybackState(AdPlaybackState)} has been called, the @@ -218,26 +218,31 @@ void start( /** * Stops using the ads loader for playback and deregisters the event listener. Called on the main * thread by {@link AdsMediaSource}. + * + * @param adsMediaSource The ads media source requesting to stop loading/playing ads. */ - void stop(); + void stop(AdsMediaSource adsMediaSource); /** * Notifies the ads loader that preparation of an ad media period is complete. Called on the main * thread by {@link AdsMediaSource}. * + * @param adsMediaSource The ads media source for which preparation of ad media completed. * @param adGroupIndex The index of the ad group. * @param adIndexInAdGroup The index of the ad in the ad group. */ - void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup); + void handlePrepareComplete(AdsMediaSource adsMediaSource, int adGroupIndex, int adIndexInAdGroup); /** * Notifies the ads loader that the player was not able to prepare media for a given ad. * Implementations should update the ad playback state as the specified ad has failed to load. * Called on the main thread by {@link AdsMediaSource}. * + * @param adsMediaSource The ads media source for which preparation of ad media failed. * @param adGroupIndex The index of the ad group. * @param adIndexInAdGroup The index of the ad in the ad group. * @param exception The preparation error. */ - void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception); + void handlePrepareError( + AdsMediaSource adsMediaSource, int adGroupIndex, int adIndexInAdGroup, IOException exception); } 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 99805122f0c..23f3aea759c 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 @@ -259,7 +259,7 @@ protected void releaseSourceInternal() { contentTimeline = null; adPlaybackState = null; adMediaSourceHolders = new AdMediaSourceHolder[0][]; - mainHandler.post(adsLoader::stop); + mainHandler.post(() -> adsLoader.stop(/* adsMediaSource= */ this)); } @Override @@ -385,7 +385,9 @@ public void onPrepareComplete(MediaPeriodId mediaPeriodId) { mainHandler.post( () -> adsLoader.handlePrepareComplete( - mediaPeriodId.adGroupIndex, mediaPeriodId.adIndexInAdGroup)); + /* adsMediaSource= */ AdsMediaSource.this, + mediaPeriodId.adGroupIndex, + mediaPeriodId.adIndexInAdGroup)); } @Override @@ -402,7 +404,10 @@ public void onPrepareError(MediaPeriodId mediaPeriodId, IOException exception) { mainHandler.post( () -> adsLoader.handlePrepareError( - mediaPeriodId.adGroupIndex, mediaPeriodId.adIndexInAdGroup, exception)); + /* adsMediaSource= */ AdsMediaSource.this, + mediaPeriodId.adGroupIndex, + mediaPeriodId.adIndexInAdGroup, + exception)); } } 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 3dab9d9a65d..d5ebe951c30 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 @@ -9029,13 +9029,18 @@ public void start( AdsLoader.EventListener eventListener) {} @Override - public void stop() {} + public void stop(AdsMediaSource adsMediaSource) {} @Override - public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) {} + public void handlePrepareComplete( + AdsMediaSource adsMediaSource, int adGroupIndex, int adIndexInAdGroup) {} @Override - public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {} + public void handlePrepareError( + AdsMediaSource adsMediaSource, + int adGroupIndex, + int adIndexInAdGroup, + IOException exception) {} } private static class FakeAdViewProvider implements AdsLoader.AdViewProvider { From 764e5e814199fcb6fff179c5081dbe25dbf847bf Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 6 Nov 2020 11:38:22 +0000 Subject: [PATCH 255/693] Expose the ads identifier in the Timeline period Issue: #3750 PiperOrigin-RevId: 341021084 --- .../exoplayer2/ext/ima/AdTagLoader.java | 22 ++--- .../exoplayer2/ext/ima/ImaAdsLoader.java | 13 ++- .../android/exoplayer2/ext/ima/ImaUtil.java | 14 ++-- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 41 ++++----- .../google/android/exoplayer2/Timeline.java | 10 ++- .../source/ads/AdPlaybackState.java | 83 +++++++++++++------ .../android/exoplayer2/ExoPlayerTest.java | 6 +- .../exoplayer2/MediaPeriodQueueTest.java | 6 +- .../DefaultPlaybackSessionManagerTest.java | 21 +++-- .../source/ads/AdPlaybackStateTest.java | 3 +- .../source/ads/AdsMediaSourceTest.java | 2 +- .../exoplayer2/testutil/FakeTimeline.java | 3 +- 12 files changed, 143 insertions(+), 81 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java index 0b10cce3b1b..ac939c56082 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java @@ -128,6 +128,7 @@ private final ImaUtil.ImaFactory imaFactory; private final List supportedMimeTypes; private final DataSpec adTagDataSpec; + private final Object adsId; private final Timeline.Period period; private final Handler handler; private final ComponentListener componentListener; @@ -146,7 +147,6 @@ @Nullable private AdsManager adsManager; private boolean isAdsManagerInitialized; - private boolean hasAdPlaybackState; @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; private long contentDurationMs; @@ -214,6 +214,7 @@ public AdTagLoader( ImaUtil.ImaFactory imaFactory, List supportedMimeTypes, DataSpec adTagDataSpec, + Object adsId, @Nullable ViewGroup adViewGroup) { this.configuration = configuration; this.imaFactory = imaFactory; @@ -228,6 +229,7 @@ public AdTagLoader( imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); this.supportedMimeTypes = supportedMimeTypes; this.adTagDataSpec = adTagDataSpec; + this.adsId = adsId; period = new Timeline.Period(); handler = Util.createHandler(getImaLooper(), /* callback= */ null); componentListener = new ComponentListener(); @@ -286,14 +288,16 @@ public void start(Player player, AdViewProvider adViewProvider, EventListener ev lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; maybeNotifyPendingAdLoadError(); - if (hasAdPlaybackState) { + if (!AdPlaybackState.NONE.equals(adPlaybackState)) { // Pass the ad playback state to the player, and resume ads if necessary. eventListener.onAdPlaybackState(adPlaybackState); if (adsManager != null && imaPausedContent && playWhenReady) { adsManager.resume(); } } else if (adsManager != null) { - adPlaybackState = ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); + adPlaybackState = + new AdPlaybackState( + adsId, ImaUtil.getAdGroupTimesUsForCuePoints(adsManager.getAdCuePoints())); updateAdPlaybackState(); } if (adDisplayContainer != null) { @@ -348,8 +352,7 @@ public void release() { stopUpdatingAdProgress(); imaAdInfo = null; pendingAdLoadError = null; - adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = true; + adPlaybackState = new AdPlaybackState(adsId); updateAdPlaybackState(); } @@ -496,7 +499,7 @@ private AdsLoader requestAds( try { request = ImaUtil.getAdsRequestForAdTagDataSpec(imaFactory, adTagDataSpec); } catch (IOException e) { - hasAdPlaybackState = true; + adPlaybackState = new AdPlaybackState(adsId); updateAdPlaybackState(); pendingAdLoadError = AdLoadException.createForAllAds(e); maybeNotifyPendingAdLoadError(); @@ -1215,8 +1218,8 @@ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { // If a player is attached already, start playback immediately. try { adPlaybackState = - ImaUtil.getInitialAdPlaybackStateForCuePoints(adsManager.getAdCuePoints()); - hasAdPlaybackState = true; + new AdPlaybackState( + adsId, ImaUtil.getAdGroupTimesUsForCuePoints(adsManager.getAdCuePoints())); updateAdPlaybackState(); } catch (RuntimeException e) { maybeNotifyInternalError("onAdsManagerLoaded", e); @@ -1276,8 +1279,7 @@ public void onAdError(AdErrorEvent adErrorEvent) { if (adsManager == null) { // No ads were loaded, so allow playback to start without any ads. pendingAdRequestContext = null; - adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = true; + adPlaybackState = new AdPlaybackState(adsId); updateAdPlaybackState(); } else if (ImaUtil.isAdGroupLoadError(error)) { try { 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 ccffd1aca7d..a60b7147bce 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 @@ -417,14 +417,21 @@ public AdDisplayContainer getAdDisplayContainer() { * * @param adTagDataSpec The data specification of the ad tag to load. See class javadoc for * information about compatible ad tag formats. + * @param adsId A opaque identifier for the ad playback state across start/stop calls. * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code * null} if playing audio-only ads. */ - public void requestAds(DataSpec adTagDataSpec, @Nullable ViewGroup adViewGroup) { + public void requestAds(DataSpec adTagDataSpec, Object adsId, @Nullable ViewGroup adViewGroup) { if (adTagLoader == null) { adTagLoader = new AdTagLoader( - context, configuration, imaFactory, supportedMimeTypes, adTagDataSpec, adViewGroup); + context, + configuration, + imaFactory, + supportedMimeTypes, + adTagDataSpec, + adsId, + adViewGroup); } } @@ -488,7 +495,7 @@ public void start( return; } if (adTagLoader == null) { - requestAds(adTagDataSpec, adViewProvider.getAdViewGroup()); + requestAds(adTagDataSpec, adsId, adViewProvider.getAdViewGroup()); } checkNotNull(adTagLoader).start(player, adViewProvider, eventListener); } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java index ae12819e841..ed3d3c74e13 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java @@ -36,7 +36,6 @@ import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader.OverlayInfo; import com.google.android.exoplayer2.upstream.DataSchemeDataSource; import com.google.android.exoplayer2.upstream.DataSpec; @@ -154,15 +153,14 @@ public static FriendlyObstructionPurpose getFriendlyObstructionPurpose( } /** - * Returns an initial {@link AdPlaybackState} with ad groups at the provided {@code cuePoints}. + * Returns the microsecond ad group timestamps corresponding to the specified cue points. * - * @param cuePoints The cue points of the ads in seconds. - * @return The {@link AdPlaybackState}. + * @param cuePoints The cue points of the ads in seconds, provided by the IMA SDK. + * @return The corresponding microsecond ad group timestamps. */ - public static AdPlaybackState getInitialAdPlaybackStateForCuePoints(List cuePoints) { + public static long[] getAdGroupTimesUsForCuePoints(List cuePoints) { if (cuePoints.isEmpty()) { - // If no cue points are specified, there is a preroll ad. - return new AdPlaybackState(/* adGroupTimesUs...= */ 0); + return new long[] {0L}; } int count = cuePoints.size(); @@ -178,7 +176,7 @@ public static AdPlaybackState getInitialAdPlaybackStateForCuePoints(List } // Cue points may be out of order, so sort them. Arrays.sort(adGroupTimesUs, 0, adGroupIndex); - return new AdPlaybackState(adGroupTimesUs); + return adGroupTimesUs; } /** Returns an {@link AdsRequest} based on the specified ad tag {@link DataSpec}. */ 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 59c9718c6ea..c8110ea4e4e 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 androidx.test.core.app.ApplicationProvider.getApplicationContext; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.getAdGroupTimesUsForCuePoints; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; @@ -226,7 +227,7 @@ public void start_updatesAdPlaybackState() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0) + new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -242,7 +243,7 @@ public void startAfterRelease() { 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(TEST_DATA_SPEC, adViewGroup); + imaAdsLoader.requestAds(TEST_DATA_SPEC, TEST_ADS_ID, adViewGroup); imaAdsLoader.release(); imaAdsLoader.start( adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); @@ -253,7 +254,7 @@ public void startAndCallbacksAfterRelease() { // Note: we can't currently call getContentProgress/getAdProgress as a VerifyError is thrown // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. - imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup); + imaAdsLoader.requestAds(TEST_DATA_SPEC, TEST_ADS_ID, adViewGroup); adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); @@ -300,7 +301,7 @@ public void playback_withPrerollAd_marksAdAsPlayed() { // Verify that the preroll ad has been marked as played. assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 0) + new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI) @@ -324,7 +325,7 @@ public void playback_withMidrollFetchError_marksAdAsInErrorState() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ 20_500_000) + new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ 20_500_000) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -372,7 +373,7 @@ public void playback_withPostrollFetchError_marksAdAsInErrorState() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) + new AdPlaybackState(TEST_ADS_ID, /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -400,7 +401,7 @@ public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -425,7 +426,7 @@ public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -448,7 +449,7 @@ public void resumePlaybackBeforeMidroll_playsPreroll() { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -473,7 +474,7 @@ public void resumePlaybackAtMidroll_skipsPreroll() { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -499,7 +500,7 @@ public void resumePlaybackAfterMidroll_skipsPreroll() { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -527,7 +528,7 @@ public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -559,7 +560,7 @@ public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -594,7 +595,7 @@ public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPr .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -629,7 +630,7 @@ public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPrerol .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -659,7 +660,7 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid verify(mockAdsManager).destroy(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withSkippedAdGroup(/* adGroupIndex= */ 1)); @@ -703,7 +704,7 @@ public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMid .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -745,7 +746,7 @@ public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skips .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -835,7 +836,7 @@ public void stop_unregistersAllVideoControlOverlays() { setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start( adsMediaSource, TEST_DATA_SPEC, TEST_ADS_ID, adViewProvider, adsLoaderListener); - imaAdsLoader.requestAds(TEST_DATA_SPEC, adViewGroup); + imaAdsLoader.requestAds(TEST_DATA_SPEC, TEST_ADS_ID, adViewGroup); imaAdsLoader.stop(adsMediaSource); InOrder inOrder = inOrder(mockAdDisplayContainer); @@ -887,7 +888,7 @@ public double getTimeOffset() { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) + new AdPlaybackState(TEST_ADS_ID, getAdGroupTimesUsForCuePoints(cuePoints)) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI) 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 e992eb588d9..086ff817ea9 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 @@ -519,9 +519,13 @@ public long getPositionInWindowUs() { return positionInWindowUs; } - /** - * Returns the number of ad groups in the period. - */ + /** Returns the opaque identifier for ads played with this period, or {@code null} if unset. */ + @Nullable + public Object getAdsId() { + return adPlaybackState.adsId; + } + + /** Returns the number of ad groups in the period. */ public int getAdGroupCount() { return adPlaybackState.adGroupCount; } 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 9493746669c..a50fcd7d1dc 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 @@ -258,7 +258,18 @@ private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int public static final int AD_STATE_ERROR = 4; /** Ad playback state with no ads. */ - public static final AdPlaybackState NONE = new AdPlaybackState(); + public static final AdPlaybackState NONE = + new AdPlaybackState( + /* adsId= */ null, + /* adGroupTimesUs= */ new long[0], + /* adGroups= */ null, + /* adResumePositionUs= */ 0L, + /* contentDurationUs= */ C.TIME_UNSET); + + /** + * The opaque identifier for ads with which this instance is associated, or {@code null} if unset. + */ + @Nullable public final Object adsId; /** The number of ad groups. */ public final int adGroupCount; @@ -280,29 +291,38 @@ private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int /** * Creates a new ad playback state with the specified ad group times. * + * @param adsId The opaque identifier for ads with which this instance is associated. * @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; - adGroupCount = count; - this.adGroupTimesUs = Arrays.copyOf(adGroupTimesUs, count); - this.adGroups = new AdGroup[count]; - for (int i = 0; i < count; i++) { - adGroups[i] = new AdGroup(); - } - adResumePositionUs = 0; - contentDurationUs = C.TIME_UNSET; + public AdPlaybackState(Object adsId, long... adGroupTimesUs) { + this( + adsId, + adGroupTimesUs, + /* adGroups= */ null, + /* adResumePositionUs= */ 0, + /* contentDurationUs= */ C.TIME_UNSET); } private AdPlaybackState( - long[] adGroupTimesUs, AdGroup[] adGroups, long adResumePositionUs, long contentDurationUs) { - adGroupCount = adGroups.length; + @Nullable Object adsId, + long[] adGroupTimesUs, + @Nullable AdGroup[] adGroups, + long adResumePositionUs, + long contentDurationUs) { + this.adsId = adsId; this.adGroupTimesUs = adGroupTimesUs; - this.adGroups = adGroups; this.adResumePositionUs = adResumePositionUs; this.contentDurationUs = contentDurationUs; + adGroupCount = adGroupTimesUs.length; + if (adGroups == null) { + adGroups = new AdGroup[adGroupCount]; + for (int i = 0; i < adGroupCount; i++) { + adGroups[i] = new AdGroup(); + } + } + this.adGroups = adGroups; } /** @@ -378,7 +398,8 @@ public AdPlaybackState withAdCount(int adGroupIndex, int adCount) { } AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); adGroups[adGroupIndex] = this.adGroups[adGroupIndex].withAdCount(adCount); - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** Returns an instance with the specified ad URI. */ @@ -386,7 +407,8 @@ public AdPlaybackState withAdCount(int adGroupIndex, int adCount) { public AdPlaybackState withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri) { AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdUri(uri, adIndexInAdGroup); - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** Returns an instance with the specified ad marked as played. */ @@ -394,7 +416,8 @@ public AdPlaybackState withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri public AdPlaybackState withPlayedAd(int adGroupIndex, int adIndexInAdGroup) { AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_PLAYED, adIndexInAdGroup); - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** Returns an instance with the specified ad marked as skipped. */ @@ -402,7 +425,8 @@ public AdPlaybackState withPlayedAd(int adGroupIndex, int adIndexInAdGroup) { public AdPlaybackState withSkippedAd(int adGroupIndex, int adIndexInAdGroup) { AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_SKIPPED, adIndexInAdGroup); - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** Returns an instance with the specified ad marked as having a load error. */ @@ -410,7 +434,8 @@ public AdPlaybackState withSkippedAd(int adGroupIndex, int adIndexInAdGroup) { public AdPlaybackState withAdLoadError(int adGroupIndex, int adIndexInAdGroup) { AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_ERROR, adIndexInAdGroup); - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** @@ -421,7 +446,8 @@ public AdPlaybackState withAdLoadError(int adGroupIndex, int adIndexInAdGroup) { public AdPlaybackState withSkippedAdGroup(int adGroupIndex) { AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); adGroups[adGroupIndex] = adGroups[adGroupIndex].withAllAdsSkipped(); - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** Returns an instance with the specified ad durations, in microseconds. */ @@ -431,7 +457,8 @@ public AdPlaybackState withAdDurationsUs(long[][] adDurationUs) { for (int adGroupIndex = 0; adGroupIndex < adGroupCount; adGroupIndex++) { adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdDurationsUs(adDurationUs[adGroupIndex]); } - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } /** @@ -443,7 +470,8 @@ public AdPlaybackState withAdResumePositionUs(long adResumePositionUs) { if (this.adResumePositionUs == adResumePositionUs) { return this; } else { - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } } @@ -453,7 +481,8 @@ public AdPlaybackState withContentDurationUs(long contentDurationUs) { if (this.contentDurationUs == contentDurationUs) { return this; } else { - return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + return new AdPlaybackState( + adsId, adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } } @@ -466,7 +495,8 @@ public boolean equals(@Nullable Object o) { return false; } AdPlaybackState that = (AdPlaybackState) o; - return adGroupCount == that.adGroupCount + return Util.areEqual(adsId, that.adsId) + && adGroupCount == that.adGroupCount && adResumePositionUs == that.adResumePositionUs && contentDurationUs == that.contentDurationUs && Arrays.equals(adGroupTimesUs, that.adGroupTimesUs) @@ -476,6 +506,7 @@ public boolean equals(@Nullable Object o) { @Override public int hashCode() { int result = adGroupCount; + result = 31 * result + (adsId == null ? 0 : adsId.hashCode()); result = 31 * result + (int) adResumePositionUs; result = 31 * result + (int) contentDurationUs; result = 31 * result + Arrays.hashCode(adGroupTimesUs); @@ -486,7 +517,9 @@ public int hashCode() { @Override public String toString() { StringBuilder sb = new StringBuilder(); - sb.append("AdPlaybackState(adResumePositionUs="); + sb.append("AdPlaybackState(adsId="); + sb.append(adsId); + sb.append(", adResumePositionUs="); sb.append(adResumePositionUs); sb.append(", adGroups=["); for (int i = 0; i < adGroups.length; i++) { 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 d5ebe951c30..e4f17b351d1 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 @@ -4349,7 +4349,8 @@ protected FakeMediaPeriod createFakeMediaPeriod( public void addMediaSource_whilePlayingAd_correctMasking() throws Exception { long contentDurationMs = 10_000; long adDurationMs = 100_000; - AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0); + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0); adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); adPlaybackState = adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY); @@ -4455,7 +4456,8 @@ adsMediaSource, new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))) public void seekTo_whilePlayingAd_correctMasking() throws Exception { long contentDurationMs = 10_000; long adDurationMs = 4_000; - AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0); + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0); adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); adPlaybackState = adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.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 6b90dfab15b..41b980d7e66 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 @@ -404,7 +404,8 @@ public void getNextMediaPeriodInfo_inMultiPeriodWindow_returnsCorrectMediaPeriod private void setupAdTimeline(long... adGroupTimesUs) { adPlaybackState = - new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); + new AdPlaybackState(/* adsId= */ new Object(), adGroupTimesUs) + .withContentDurationUs(CONTENT_DURATION_US); SinglePeriodAdTimeline adTimeline = new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); setupTimeline(adTimeline); @@ -498,7 +499,8 @@ private void setAdGroupFailedToLoad(int adGroupIndex) { private void updateAdPlaybackStateAndTimeline(long... adGroupTimesUs) { adPlaybackState = - new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); + new AdPlaybackState(/* adsId= */ new Object(), adGroupTimesUs) + .withContentDurationUs(CONTENT_DURATION_US); updateTimeline(); } 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 5f97ad78f23..d804479dfa5 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 @@ -419,7 +419,9 @@ public void updateSessions_withoutMediaPeriodId_afterSessionForMediaPeriodId_ret /* isDynamic= */ false, /* durationUs =*/ 10 * C.MICROS_PER_SECOND, new AdPlaybackState( - /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND) + /* adsId= */ new Object(), + /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, + 5 * C.MICROS_PER_SECOND) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); EventTime adEventTime1 = @@ -701,7 +703,8 @@ public void timelineUpdate_withContent_doesNotFinishFuturePostrollAd() { /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - new AdPlaybackState(/* adGroupTimesUs=... */ C.TIME_END_OF_SOURCE) + new AdPlaybackState( + /* adsId= */ new Object(), /* adGroupTimesUs=... */ C.TIME_END_OF_SOURCE) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1))); EventTime adEventTime = createEventTime( @@ -903,7 +906,10 @@ public void positionDiscontinuity_fromAdToContent_finishesAd() { /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - new AdPlaybackState(/* adGroupTimesUs=... */ 0, 5 * C.MICROS_PER_SECOND) + new AdPlaybackState( + /* adsId= */ new Object(), /* adGroupTimesUs=... */ + 0, + 5 * C.MICROS_PER_SECOND) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); EventTime adEventTime1 = @@ -983,7 +989,9 @@ public void positionDiscontinuity_fromContentToAd_doesNotFinishSessions() { /* isDynamic= */ false, /* durationUs =*/ 10 * C.MICROS_PER_SECOND, new AdPlaybackState( - /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND) + /* adsId= */ new Object(), /* adGroupTimesUs=... */ + 2 * C.MICROS_PER_SECOND, + 5 * C.MICROS_PER_SECOND) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); EventTime adEventTime1 = @@ -1032,7 +1040,10 @@ public void positionDiscontinuity_fromAdToAd_finishesPastAds_andNotifiesAdPlayba /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - new AdPlaybackState(/* adGroupTimesUs=... */ 0, 5 * C.MICROS_PER_SECOND) + new AdPlaybackState( + /* adsId= */ new Object(), /* adGroupTimesUs=... */ + 0, + 5 * C.MICROS_PER_SECOND) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); EventTime adEventTime1 = 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 3a253b29761..de998bb8b16 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 @@ -31,12 +31,13 @@ public final class AdPlaybackStateTest { private static final long[] TEST_AD_GROUP_TMES_US = new long[] {0, C.msToUs(10_000)}; private static final Uri TEST_URI = Uri.EMPTY; + private static final Object TEST_ADS_ID = new Object(); private AdPlaybackState state; @Before public void setUp() { - state = new AdPlaybackState(TEST_AD_GROUP_TMES_US); + state = new AdPlaybackState(TEST_ADS_ID, TEST_AD_GROUP_TMES_US); } @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 7fcd740d5f8..83386673af2 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 @@ -77,7 +77,7 @@ public final class AdsMediaSourceTest { CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); private static final AdPlaybackState AD_PLAYBACK_STATE = - new AdPlaybackState(/* adGroupTimesUs...= */ 0) + new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0) .withContentDurationUs(CONTENT_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY) 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 a5f94202da0..3fb29f284d5 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 @@ -246,7 +246,8 @@ public TimelineWindowDefinition( */ public static AdPlaybackState createAdPlaybackState(int adsPerAdGroup, long... adGroupTimesUs) { int adGroupCount = adGroupTimesUs.length; - AdPlaybackState adPlaybackState = new AdPlaybackState(adGroupTimesUs); + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ new Object(), adGroupTimesUs); long[][] adDurationsUs = new long[adGroupCount][]; for (int i = 0; i < adGroupCount; i++) { adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ i, adsPerAdGroup); From ae4cf9f1da0c495132a28809b4b733b9fa7556db Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Fri, 6 Nov 2020 11:42:19 +0000 Subject: [PATCH 256/693] Clean up AdTagLoader and ImaAdsLoader In preparation for adding support for ads in playlists: - Make releasing a no-op if the instance was already released - Remove null checks on non-null `adDisplayContainer` and `adsLoader` - Move initializing the ads manager into a private method as it will need to be called from two places soon. - Misc other cleanup. Issue: #3750 PiperOrigin-RevId: 341021493 --- .../exoplayer2/ext/ima/AdTagLoader.java | 93 ++++++++++--------- .../exoplayer2/ext/ima/ImaAdsLoader.java | 26 +++--- 2 files changed, 63 insertions(+), 56 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java index ac939c56082..722e226786a 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdTagLoader.java @@ -152,6 +152,8 @@ private long contentDurationMs; private AdPlaybackState adPlaybackState; + private boolean released; + // Fields tracking IMA's state. /** Whether IMA has sent an ad event to pause content since the last resume content event. */ @@ -300,14 +302,12 @@ public void start(Player player, AdViewProvider adViewProvider, EventListener ev adsId, ImaUtil.getAdGroupTimesUsForCuePoints(adsManager.getAdCuePoints())); updateAdPlaybackState(); } - if (adDisplayContainer != null) { - for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { - adDisplayContainer.registerFriendlyObstruction( - imaFactory.createFriendlyObstruction( - overlayInfo.view, - ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), - overlayInfo.reasonDetail)); - } + for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { + adDisplayContainer.registerFriendlyObstruction( + imaFactory.createFriendlyObstruction( + overlayInfo.view, + ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), + overlayInfo.reasonDetail)); } } @@ -326,9 +326,7 @@ public void stop() { lastVolumePercent = getPlayerVolumePercent(); lastAdProgress = getAdVideoProgressUpdate(); lastContentProgress = getContentVideoProgressUpdate(); - if (adDisplayContainer != null) { - adDisplayContainer.unregisterAllFriendlyObstructions(); - } + adDisplayContainer.unregisterAllFriendlyObstructions(); player.removeListener(this); this.player = null; eventListener = null; @@ -336,16 +334,18 @@ public void stop() { /** Releases all resources used by the ad tag loader. */ public void release() { + if (released) { + return; + } + released = true; pendingAdRequestContext = null; destroyAdsManager(); - if (adsLoader != null) { - adsLoader.removeAdsLoadedListener(componentListener); - adsLoader.removeAdErrorListener(componentListener); - if (configuration.applicationAdErrorListener != null) { - adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener); - } - adsLoader.release(); + adsLoader.removeAdsLoadedListener(componentListener); + adsLoader.removeAdErrorListener(componentListener); + if (configuration.applicationAdErrorListener != null) { + adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener); } + adsLoader.release(); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; @@ -394,27 +394,15 @@ public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason in } checkArgument(timeline.getPeriodCount() == 1); this.timeline = timeline; - long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs; + Player player = checkNotNull(this.player); + long contentDurationUs = timeline.getPeriod(player.getCurrentPeriodIndex(), period).durationUs; contentDurationMs = C.usToMs(contentDurationUs); - if (contentDurationUs != C.TIME_UNSET) { + if (contentDurationUs != adPlaybackState.contentDurationUs) { adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); - } - @Nullable AdsManager adsManager = this.adsManager; - if (!isAdsManagerInitialized && adsManager != null) { - isAdsManagerInitialized = true; - @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); - if (adsRenderingSettings == null) { - // There are no ads to play. - destroyAdsManager(); - } else { - adsManager.init(adsRenderingSettings); - adsManager.start(); - if (configuration.debugModeEnabled) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); - } - } updateAdPlaybackState(); } + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + maybeInitializeAdsManager(contentPositionMs, contentDurationMs); handleTimelineOrPositionChanged(); } @@ -515,12 +503,33 @@ private AdsLoader requestAds( return adsLoader; } + private void maybeInitializeAdsManager(long contentPositionMs, long contentDurationMs) { + @Nullable AdsManager adsManager = this.adsManager; + if (!isAdsManagerInitialized && adsManager != null) { + isAdsManagerInitialized = true; + @Nullable + AdsRenderingSettings adsRenderingSettings = + setupAdsRendering(contentPositionMs, contentDurationMs); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (configuration.debugModeEnabled) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } + } + updateAdPlaybackState(); + } + } + /** * 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() { + private AdsRenderingSettings setupAdsRendering(long contentPositionMs, long contentDurationMs) { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); adsRenderingSettings.setMimeTypes( @@ -541,7 +550,6 @@ private AdsRenderingSettings setupAdsRendering() { // Skip ads based on the start position as required. long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; - long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period); int adGroupForPositionIndex = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); @@ -957,7 +965,6 @@ private void stopAdInternal(AdMediaInfo adMediaInfo) { } return; } - checkNotNull(player); imaAdState = IMA_AD_STATE_NONE; stopUpdatingAdProgress(); // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. @@ -1155,10 +1162,12 @@ private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { private static long getContentPeriodPositionMs( Player player, Timeline timeline, Timeline.Period period) { long contentWindowPositionMs = player.getContentPosition(); - return contentWindowPositionMs - - (timeline.isEmpty() - ? 0 - : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); + if (timeline.isEmpty()) { + return contentWindowPositionMs; + } else { + return contentWindowPositionMs + - timeline.getPeriod(player.getCurrentPeriodIndex(), period).getPositionInWindowMs(); + } } private static boolean hasMidrollAdGroups(long[] 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 a60b7147bce..b536cd3892c 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 @@ -31,7 +31,6 @@ import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; -import com.google.ads.interactivemedia.v3.api.AdsLoader; import com.google.ads.interactivemedia.v3.api.AdsManager; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; @@ -46,6 +45,7 @@ import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.MimeTypes; @@ -61,8 +61,7 @@ import java.util.Set; /** - * {@link com.google.android.exoplayer2.source.ads.AdsLoader} using the IMA SDK. All methods must be - * called on the main thread. + * {@link AdsLoader} using the IMA SDK. All methods must be called on the main thread. * *

    The player instance that will play the loaded ads must be set before playback using {@link * #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling @@ -83,8 +82,7 @@ * href="https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/omsdk">IMA * SDK Open Measurement documentation for more information. */ -public final class ImaAdsLoader - implements Player.EventListener, com.google.android.exoplayer2.source.ads.AdsLoader { +public final class ImaAdsLoader implements Player.EventListener, AdsLoader { static { ExoPlayerLibraryInfo.registerModule("goog.exo.ima"); @@ -154,8 +152,8 @@ public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) { /** * Sets a listener for ad errors that will be passed to {@link - * AdsLoader#addAdErrorListener(AdErrorListener)} and {@link - * AdsManager#addAdErrorListener(AdErrorListener)}. + * com.google.ads.interactivemedia.v3.api.AdsLoader#addAdErrorListener(AdErrorListener)} and + * {@link AdsManager#addAdErrorListener(AdErrorListener)}. * * @param adErrorListener The ad error listener. * @return This builder, for convenience. @@ -384,11 +382,11 @@ private ImaAdsLoader( } /** - * Returns the underlying {@link AdsLoader} wrapped by this instance, or {@code null} if ads have - * not been requested yet. + * Returns the underlying {@link com.google.ads.interactivemedia.v3.api.AdsLoader} wrapped by this + * instance, or {@code null} if ads have not been requested yet. */ @Nullable - public AdsLoader getAdsLoader() { + public com.google.ads.interactivemedia.v3.api.AdsLoader getAdsLoader() { return adTagLoader != null ? adTagLoader.getAdsLoader() : null; } @@ -400,8 +398,8 @@ public AdsLoader getAdsLoader() { * AdDisplayContainer#registerFriendlyObstruction(FriendlyObstruction)} will be unregistered * automatically when the media source detaches from this instance. It is therefore necessary to * re-register views each time the ads loader is reused. Alternatively, provide overlay views via - * the {@link com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider} when creating the - * media source to benefit from automatic registration. + * the {@link AdViewProvider} when creating the media source to benefit from automatic + * registration. */ @Nullable public AdDisplayContainer getAdDisplayContainer() { @@ -448,7 +446,7 @@ public void skipAd() { } } - // com.google.android.exoplayer2.source.ads.AdsLoader implementation. + // AdsLoader implementation. @Override public void setPlayer(@Nullable Player player) { @@ -576,7 +574,7 @@ public AdsRequest createAdsRequest() { } @Override - public AdsLoader createAdsLoader( + public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { return ImaSdkFactory.getInstance() .createAdsLoader(context, imaSdkSettings, adDisplayContainer); From 92ec1ab628e54aa46c286d0f1402f5481b027015 Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 6 Nov 2020 12:02:01 +0000 Subject: [PATCH 257/693] Add more MediaCodec methods to MediaCodecAdapter Add more MediaCodec methods to MediaCodedAdapter so that renderers interact with the MediaCodec through the MediaCodecAdapter. PiperOrigin-RevId: 341023452 --- .../AsynchronousMediaCodecAdapter.java | 51 +++++++++++-- .../mediacodec/MediaCodecAdapter.java | 73 +++++++++++++++++-- .../mediacodec/MediaCodecRenderer.java | 4 +- .../SynchronousMediaCodecAdapter.java | 44 ++++++++++- .../AsynchronousMediaCodecAdapterTest.java | 7 +- 5 files changed, 158 insertions(+), 21 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 a705ec42088..8bc720b3702 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 @@ -19,6 +19,8 @@ import android.media.MediaCodec; import android.media.MediaCrypto; import android.media.MediaFormat; +import android.os.Bundle; +import android.os.Handler; import android.os.HandlerThread; import android.view.Surface; import androidx.annotation.IntDef; @@ -26,6 +28,7 @@ import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Renderer.VideoScalingMode; import com.google.android.exoplayer2.decoder.CryptoInfo; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -108,6 +111,16 @@ public void queueSecureInputBuffer( bufferEnqueuer.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); } + @Override + public void releaseOutputBuffer(int index, boolean render) { + codec.releaseOutputBuffer(index, render); + } + + @Override + public void releaseOutputBuffer(int index, long renderTimeStampNs) { + codec.releaseOutputBuffer(index, renderTimeStampNs); + } + @Override public int dequeueInputBufferIndex() { return asynchronousMediaCodecCallback.dequeueInputBufferIndex(); @@ -148,14 +161,14 @@ public void flush() { } @Override - public void shutdown() { - if (state == STATE_STARTED) { - bufferEnqueuer.shutdown(); - } - if (state == STATE_CONFIGURED || state == STATE_STARTED) { + public void release() { + if (state == STATE_STARTED) { + bufferEnqueuer.shutdown(); + } + if (state == STATE_CONFIGURED || state == STATE_STARTED) { asynchronousMediaCodecCallback.shutdown(); - } - state = STATE_SHUT_DOWN; + } + state = STATE_SHUT_DOWN; } @Override @@ -163,6 +176,30 @@ public MediaCodec getCodec() { return codec; } + @Override + public void setOnFrameRenderedListener(OnFrameRenderedListener listener, Handler handler) { + codec.setOnFrameRenderedListener( + (codec, presentationTimeUs, nanoTime) -> + listener.onFrameRendered( + AsynchronousMediaCodecAdapter.this, presentationTimeUs, nanoTime), + handler); + } + + @Override + public void setOutputSurface(Surface surface) { + codec.setOutputSurface(surface); + } + + @Override + public void setParameters(Bundle params) { + codec.setParameters(params); + } + + @Override + public void setVideoScalingMode(@VideoScalingMode int scalingMode) { + codec.setVideoScalingMode(scalingMode); + } + @VisibleForTesting /* package */ void onError(MediaCodec.CodecException error) { asynchronousMediaCodecCallback.onError(codec, error); 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 5d785d650cc..6c43a529c69 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 @@ -19,8 +19,12 @@ import android.media.MediaCodec; import android.media.MediaCrypto; import android.media.MediaFormat; +import android.os.Bundle; +import android.os.Handler; import android.view.Surface; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.Renderer.VideoScalingMode; import com.google.android.exoplayer2.decoder.CryptoInfo; import java.nio.ByteBuffer; @@ -32,6 +36,15 @@ */ public interface MediaCodecAdapter { + /** + * Listener to be called when an output frame has rendered on the output surface. + * + * @see MediaCodec.OnFrameRenderedListener + */ + interface OnFrameRenderedListener { + void onFrameRendered(MediaCodecAdapter codec, long presentationTimeUs, long nanoTime); + } + /** * Configures this adapter and the underlying {@link MediaCodec}. Needs to be called before {@link * #start()}. @@ -118,18 +131,64 @@ void configure( void queueSecureInputBuffer( int index, int offset, CryptoInfo info, long presentationTimeUs, int flags); - /** Flushes both the adapter and the underlying {@link MediaCodec}. */ - void flush(); + /** + * Returns the buffer to the {@link MediaCodec}. If the {@link MediaCodec} was configured with an + * output surface, setting {@code render} to {@code true} will first send the buffer to the output + * surface. The surface will release the buffer back to the codec once it is no longer + * used/displayed. + * + * @see MediaCodec#releaseOutputBuffer(int, boolean) + */ + void releaseOutputBuffer(int index, boolean render); /** - * Shuts down the adapter. + * Updates the output buffer's surface timestamp and sends it to the {@link MediaCodec} to render + * it on the output surface. If the {@link MediaCodec} is not configured with an output surface, + * this call will simply return the buffer to the {@link MediaCodec}. * - *

    This method does not stop or release the underlying {@link MediaCodec}. It should be called - * before stopping or releasing the {@link MediaCodec} to avoid the possibility of the adapter - * interacting with a stopped or released {@link MediaCodec}. + * @see MediaCodec#releaseOutputBuffer(int, long) */ - void shutdown(); + @RequiresApi(21) + void releaseOutputBuffer(int index, long renderTimeStampNs); + + /** Flushes the adapter and the underlying {@link MediaCodec}. */ + void flush(); + + /** Releases the adapter and the underlying {@link MediaCodec}. */ + void release(); /** Returns the {@link MediaCodec} instance of this adapter. */ MediaCodec getCodec(); + + /** + * Registers a callback to be invoked when an output frame is rendered on the output surface. + * + * @see MediaCodec#setOnFrameRenderedListener + */ + @RequiresApi(23) + void setOnFrameRenderedListener(OnFrameRenderedListener listener, Handler handler); + + /** + * Dynamically sets the output surface of a {@link MediaCodec}. + * + * @see MediaCodec#setOutputSurface(Surface) + */ + @RequiresApi(23) + void setOutputSurface(Surface surface); + + /** + * Communicate additional parameter changes to the {@link MediaCodec} instance. + * + * @see MediaCodec#setParameters(Bundle) + */ + @RequiresApi(19) + void setParameters(Bundle params); + + /** + * Specifies the scaling mode to use, if a surface has been specified in a previous call to {@link + * #configure}. + * + * @see MediaCodec#setVideoScalingMode(int) + */ + void setVideoScalingMode(@VideoScalingMode int scalingMode); } 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 93170310839..39abd44b7e1 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 @@ -719,7 +719,7 @@ private void disableBypass() { protected void releaseCodec() { try { if (codecAdapter != null) { - codecAdapter.shutdown(); + codecAdapter.release(); } if (codec != null) { decoderCounters.decoderReleaseCount++; @@ -1071,7 +1071,7 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce codecInitializedTimestamp = SystemClock.elapsedRealtime(); } catch (Exception e) { if (codecAdapter != null) { - codecAdapter.shutdown(); + codecAdapter.release(); } if (codec != null) { codec.release(); 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 0c5f80bd199..9619a7f5c3b 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 @@ -21,8 +21,12 @@ import android.media.MediaCodec; import android.media.MediaCrypto; import android.media.MediaFormat; +import android.os.Bundle; +import android.os.Handler; import android.view.Surface; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.Renderer.VideoScalingMode; import com.google.android.exoplayer2.decoder.CryptoInfo; import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; @@ -114,13 +118,24 @@ public void queueSecureInputBuffer( index, offset, info.getFrameworkCryptoInfo(), presentationTimeUs, flags); } + @Override + public void releaseOutputBuffer(int index, boolean render) { + codec.releaseOutputBuffer(index, render); + } + + @Override + @RequiresApi(21) + public void releaseOutputBuffer(int index, long renderTimeStampNs) { + codec.releaseOutputBuffer(index, renderTimeStampNs); + } + @Override public void flush() { codec.flush(); } @Override - public void shutdown() { + public void release() { inputByteBuffers = null; outputByteBuffers = null; } @@ -129,4 +144,31 @@ public void shutdown() { public MediaCodec getCodec() { return codec; } + + @Override + @RequiresApi(23) + public void setOnFrameRenderedListener(OnFrameRenderedListener listener, Handler handler) { + codec.setOnFrameRenderedListener( + (codec, presentationTimeUs, nanoTime) -> + listener.onFrameRendered( + SynchronousMediaCodecAdapter.this, presentationTimeUs, nanoTime), + handler); + } + + @Override + @RequiresApi(23) + public void setOutputSurface(Surface surface) { + codec.setOutputSurface(surface); + } + + @Override + @RequiresApi(19) + public void setParameters(Bundle params) { + codec.setParameters(params); + } + + @Override + public void setVideoScalingMode(@VideoScalingMode int scalingMode) { + codec.setVideoScalingMode(scalingMode); + } } 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 6c3294c2aa9..60e9c8b77f0 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 @@ -52,8 +52,7 @@ public void setUp() throws IOException { @After public void tearDown() { - adapter.shutdown(); - codec.release(); + adapter.release(); } @Test @@ -106,7 +105,7 @@ public void dequeueInputBufferIndex_afterShutdown_returnsTryAgainLater() { // non-empty adapter. shadowOf(callbackThread.getLooper()).idle(); - adapter.shutdown(); + adapter.release(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @@ -183,7 +182,7 @@ public void dequeueOutputBufferIndex_afterShutdown_returnsTryAgainLater() { adapter.queueInputBuffer(index, 0, 0, 0, 0); // Progress the looper so that the ShadowMediaCodec processes the input buffer. shadowLooper.idle(); - adapter.shutdown(); + adapter.release(); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); From 8b5ecdb98d939495c124e1aba00dae776c122b8b Mon Sep 17 00:00:00 2001 From: claincly Date: Fri, 6 Nov 2020 16:13:23 +0000 Subject: [PATCH 258/693] Fix javadoc formatting PiperOrigin-RevId: 341051348 --- .../android/exoplayer2/upstream/UdpDataSource.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java index e2b8ba1b31a..77a2c6ffeed 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java @@ -31,20 +31,15 @@ /** A UDP {@link DataSource}. */ public final class UdpDataSource extends BaseDataSource { - /** - * Thrown when an error is encountered when trying to read from a {@link UdpDataSource}. - */ + /** Thrown when an error is encountered when trying to read from a {@link UdpDataSource}. */ public static final class UdpDataSourceException extends IOException { public UdpDataSourceException(IOException cause) { super(cause); } - } - /** - * The default maximum datagram packet size, in bytes. - */ + /** The default maximum datagram packet size, in bytes. */ public static final int DEFAULT_MAX_PACKET_SIZE = 2000; /** The default socket timeout, in milliseconds. */ @@ -174,5 +169,4 @@ public void close() { transferEnded(); } } - } From 1d4321b86e77a8178691e4964dbffe3450501a76 Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 6 Nov 2020 17:51:37 +0000 Subject: [PATCH 259/693] Move ownership of MediaCodec to MediaCodecAdapter Move ownership of MediaCodec to MediaCodecAdapter so that all MediaCodec interactions go through MediaCodecAdapter. PiperOrigin-RevId: 341066926 --- .../audio/MediaCodecAudioRenderer.java | 8 +-- .../AsynchronousMediaCodecAdapter.java | 25 ++++---- .../mediacodec/MediaCodecAdapter.java | 3 - .../mediacodec/MediaCodecRenderer.java | 63 ++++++++----------- .../SynchronousMediaCodecAdapter.java | 6 +- .../video/MediaCodecVideoRenderer.java | 38 +++++------ .../gts/DebugRenderersFactory.java | 11 ++-- 7 files changed, 70 insertions(+), 84 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 c4dfaed7fe7..675516cb032 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 @@ -311,7 +311,7 @@ protected boolean shouldUseBypass(Format format) { @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodecAdapter codecAdapter, + MediaCodecAdapter codec, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate) { @@ -319,7 +319,7 @@ protected void configureCodec( codecNeedsDiscardChannelsWorkaround = codecNeedsDiscardChannelsWorkaround(codecInfo.name); MediaFormat mediaFormat = getMediaFormat(format, codecInfo.codecMimeType, codecMaxInputSize, codecOperatingRate); - codecAdapter.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); + codec.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); // Store the input MIME type if we're only using the codec for decryption. boolean decryptOnlyCodecEnabled = MimeTypes.AUDIO_RAW.equals(codecInfo.mimeType) @@ -330,7 +330,7 @@ protected void configureCodec( @Override @KeepCodecResult protected int canKeepCodec( - MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { + MediaCodecAdapter codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { if (getCodecMaxInputSize(codecInfo, newFormat) > codecMaxInputSize) { return KEEP_CODEC_RESULT_NO; } @@ -558,7 +558,7 @@ protected void onProcessedStreamChange() { protected boolean processOutputBuffer( long positionUs, long elapsedRealtimeUs, - @Nullable MediaCodec codec, + @Nullable MediaCodecAdapter codec, @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, 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 8bc720b3702..2deb5fa2bf1 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 @@ -56,6 +56,7 @@ private final MediaCodec codec; private final AsynchronousMediaCodecCallback asynchronousMediaCodecCallback; private final AsynchronousMediaCodecBufferEnqueuer bufferEnqueuer; + private boolean codecReleased; @State private int state; /** @@ -162,18 +163,20 @@ public void flush() { @Override public void release() { - if (state == STATE_STARTED) { - bufferEnqueuer.shutdown(); + try { + if (state == STATE_STARTED) { + bufferEnqueuer.shutdown(); + } + if (state == STATE_CONFIGURED || state == STATE_STARTED) { + asynchronousMediaCodecCallback.shutdown(); + } + state = STATE_SHUT_DOWN; + } finally { + if (!codecReleased) { + codec.release(); + codecReleased = true; + } } - if (state == STATE_CONFIGURED || state == STATE_STARTED) { - asynchronousMediaCodecCallback.shutdown(); - } - state = STATE_SHUT_DOWN; - } - - @Override - public MediaCodec getCodec() { - return codec; } @Override 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 6c43a529c69..432d2aedaf2 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 @@ -157,9 +157,6 @@ void queueSecureInputBuffer( /** Releases the adapter and the underlying {@link MediaCodec}. */ void release(); - /** Returns the {@link MediaCodec} instance of this adapter. */ - MediaCodec getCodec(); - /** * Registers a callback to be invoked when an output frame is rendered on the output surface. * 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 39abd44b7e1..834923e0daf 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 @@ -306,8 +306,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { private boolean mediaCryptoRequiresSecureDecoder; private long renderTimeLimitMs; private float playbackSpeed; - @Nullable private MediaCodec codec; - @Nullable private MediaCodecAdapter codecAdapter; + @Nullable private MediaCodecAdapter codec; @Nullable private Format codecInputFormat; @Nullable private MediaFormat codecOutputMediaFormat; private boolean codecOutputMediaFormatChanged; @@ -464,7 +463,7 @@ protected abstract List getDecoderInfos( * Configures a newly created {@link MediaCodec}. * * @param codecInfo Information about the {@link MediaCodec} being configured. - * @param codecAdapter The {@link MediaCodecAdapter} to configure. + * @param codec 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 @@ -472,7 +471,7 @@ protected abstract List getDecoderInfos( */ protected abstract void configureCodec( MediaCodecInfo codecInfo, - MediaCodecAdapter codecAdapter, + MediaCodecAdapter codec, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate); @@ -608,7 +607,7 @@ protected final Format getOutputFormat() { } @Nullable - protected final MediaCodec getCodec() { + protected final MediaCodecAdapter getCodec() { return codec; } @@ -718,17 +717,13 @@ private void disableBypass() { protected void releaseCodec() { try { - if (codecAdapter != null) { - codecAdapter.release(); - } if (codec != null) { - decoderCounters.decoderReleaseCount++; codec.release(); + decoderCounters.decoderReleaseCount++; onCodecReleased(codecInfo.name); } } finally { codec = null; - codecAdapter = null; try { if (mediaCrypto != null) { mediaCrypto.release(); @@ -844,7 +839,7 @@ protected boolean flushOrReleaseCodec() { /** Flushes the codec. */ private void flushCodec() { try { - codecAdapter.flush(); + codec.flush(); } finally { resetCodecStateForFlush(); } @@ -1040,7 +1035,7 @@ private void initBypass(Format format) { private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exception { long codecInitializingTimestamp; long codecInitializedTimestamp; - MediaCodec codec = null; + @Nullable MediaCodecAdapter codecAdapter = null; String codecName = codecInfo.name; float codecOperatingRate = @@ -1051,11 +1046,10 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce codecOperatingRate = CODEC_OPERATING_RATE_UNSET; } - @Nullable MediaCodecAdapter codecAdapter = null; try { codecInitializingTimestamp = SystemClock.elapsedRealtime(); TraceUtil.beginSection("createCodec:" + codecName); - codec = MediaCodec.createByCodecName(codecName); + MediaCodec codec = MediaCodec.createByCodecName(codecName); if (enableAsynchronousBufferQueueing && Util.SDK_INT >= 23) { codecAdapter = new AsynchronousMediaCodecAdapter(codec, getTrackType()); } else { @@ -1073,14 +1067,10 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce if (codecAdapter != null) { codecAdapter.release(); } - if (codec != null) { - codec.release(); - } throw e; } - this.codec = codec; - this.codecAdapter = codecAdapter; + this.codec = codecAdapter; this.codecInfo = codecInfo; this.codecOperatingRate = codecOperatingRate; codecInputFormat = inputFormat; @@ -1148,11 +1138,11 @@ private boolean feedInputBuffer() throws ExoPlaybackException { } if (inputIndex < 0) { - inputIndex = codecAdapter.dequeueInputBufferIndex(); + inputIndex = codec.dequeueInputBufferIndex(); if (inputIndex < 0) { return false; } - buffer.data = codecAdapter.getInputBuffer(inputIndex); + buffer.data = codec.getInputBuffer(inputIndex); buffer.clear(); } @@ -1163,7 +1153,7 @@ private boolean feedInputBuffer() throws ExoPlaybackException { // Do nothing. } else { codecReceivedEos = true; - codecAdapter.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); resetInputBuffer(); } codecDrainState = DRAIN_STATE_WAIT_END_OF_STREAM; @@ -1173,7 +1163,7 @@ private boolean feedInputBuffer() throws ExoPlaybackException { if (codecNeedsAdaptationWorkaroundBuffer) { codecNeedsAdaptationWorkaroundBuffer = false; buffer.data.put(ADAPTATION_WORKAROUND_BUFFER); - codecAdapter.queueInputBuffer(inputIndex, 0, ADAPTATION_WORKAROUND_BUFFER.length, 0, 0); + codec.queueInputBuffer(inputIndex, 0, ADAPTATION_WORKAROUND_BUFFER.length, 0, 0); resetInputBuffer(); codecReceivedBuffers = true; return true; @@ -1232,7 +1222,7 @@ private boolean feedInputBuffer() throws ExoPlaybackException { // Do nothing. } else { codecReceivedEos = true; - codecAdapter.queueInputBuffer( + codec.queueInputBuffer( inputIndex, /* offset= */ 0, /* size= */ 0, @@ -1303,10 +1293,10 @@ private boolean feedInputBuffer() throws ExoPlaybackException { onQueueInputBuffer(buffer); try { if (bufferEncrypted) { - codecAdapter.queueSecureInputBuffer( + codec.queueSecureInputBuffer( inputIndex, /* offset= */ 0, buffer.cryptoInfo, presentationTimeUs, /* flags= */ 0); } else { - codecAdapter.queueInputBuffer( + codec.queueInputBuffer( inputIndex, /* offset= */ 0, buffer.data.limit(), presentationTimeUs, /* flags= */ 0); } } catch (CryptoException e) { @@ -1525,7 +1515,7 @@ protected void onProcessedStreamChange() { * *

    The default implementation returns {@link MediaCodecInfo#KEEP_CODEC_RESULT_NO}. * - * @param codec The existing {@link MediaCodec} instance. + * @param codec The existing {@link MediaCodecAdapter} instance. * @param codecInfo A {@link MediaCodecInfo} describing the decoder. * @param oldFormat The {@link Format} for which the existing instance is configured. * @param newFormat The new {@link Format}. @@ -1533,7 +1523,7 @@ protected void onProcessedStreamChange() { */ @KeepCodecResult protected int canKeepCodec( - MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { + MediaCodecAdapter codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { return KEEP_CODEC_RESULT_NO; } @@ -1676,7 +1666,7 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) int outputIndex; if (codecNeedsEosOutputExceptionWorkaround && codecReceivedEos) { try { - outputIndex = codecAdapter.dequeueOutputBufferIndex(outputBufferInfo); + outputIndex = codec.dequeueOutputBufferIndex(outputBufferInfo); } catch (IllegalStateException e) { processEndOfStream(); if (outputStreamEnded) { @@ -1686,7 +1676,7 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) return false; } } else { - outputIndex = codecAdapter.dequeueOutputBufferIndex(outputBufferInfo); + outputIndex = codec.dequeueOutputBufferIndex(outputBufferInfo); } if (outputIndex < 0) { @@ -1715,7 +1705,7 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) } this.outputIndex = outputIndex; - outputBuffer = codecAdapter.getOutputBuffer(outputIndex); + outputBuffer = codec.getOutputBuffer(outputIndex); // The dequeued buffer is a media buffer. Do some initial setup. // It will be processed by calling processOutputBuffer (possibly multiple times). @@ -1791,7 +1781,7 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) /** Processes a change in the decoder output {@link MediaFormat}. */ private void processOutputMediaFormatChanged() { codecHasOutputMediaFormat = true; - MediaFormat mediaFormat = codecAdapter.getOutputFormat(); + MediaFormat mediaFormat = codec.getOutputFormat(); if (codecAdaptationWorkaroundMode != ADAPTATION_WORKAROUND_MODE_NEVER && mediaFormat.getInteger(MediaFormat.KEY_WIDTH) == ADAPTATION_WORKAROUND_SLICE_WIDTH_HEIGHT && mediaFormat.getInteger(MediaFormat.KEY_HEIGHT) @@ -1825,7 +1815,8 @@ private void processOutputMediaFormatChanged() { * 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 bypass mode were no codec is used. + * @param codec The {@link MediaCodecAdapter} instance, or null in bypass mode were no codec is + * used. * @param buffer The output buffer to process, or null if the buffer data is not made available to * the application layer (see {@link MediaCodec#getOutputBuffer(int)}). This {@code buffer} * can only be null for video data. Note that the buffer data can still be rendered in this @@ -1845,7 +1836,7 @@ private void processOutputMediaFormatChanged() { protected abstract boolean processOutputBuffer( long positionUs, long elapsedRealtimeUs, - @Nullable MediaCodec codec, + @Nullable MediaCodecAdapter codec, @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, @@ -1903,8 +1894,8 @@ protected final void setPendingOutputEndOfStream() { /** * Returns the offset that should be subtracted from {@code bufferPresentationTimeUs} in {@link - * #processOutputBuffer(long, long, MediaCodec, ByteBuffer, int, int, int, long, boolean, boolean, - * Format)} to get the playback position with respect to the media. + * #processOutputBuffer(long, long, MediaCodecAdapter, ByteBuffer, int, int, int, long, boolean, + * boolean, Format)} to get the playback position with respect to the media. */ protected final long getOutputStreamOffsetUs() { return outputStreamOffsetUs; 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 9619a7f5c3b..9a51f01847f 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 @@ -138,11 +138,7 @@ public void flush() { public void release() { inputByteBuffers = null; outputByteBuffers = null; - } - - @Override - public MediaCodec getCodec() { - return codec; + codec.release(); } @Override 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 5c4ba382029..5ce86437bad 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 @@ -461,7 +461,7 @@ public void handleMessage(int messageType, @Nullable Object message) throws ExoP setSurface((Surface) message); } else if (messageType == MSG_SET_SCALING_MODE) { scalingMode = (Integer) message; - MediaCodec codec = getCodec(); + @Nullable MediaCodecAdapter codec = getCodec(); if (codec != null) { codec.setVideoScalingMode(scalingMode); } @@ -493,7 +493,7 @@ private void setSurface(Surface surface) throws ExoPlaybackException { updateSurfaceFrameRate(/* isNewSurface= */ true); @State int state = getState(); - MediaCodec codec = getCodec(); + @Nullable MediaCodecAdapter codec = getCodec(); if (codec != null) { if (Util.SDK_INT >= 23 && surface != null && !codecNeedsSetOutputSurfaceWorkaround) { setOutputSurfaceV23(codec, surface); @@ -537,7 +537,7 @@ protected boolean getCodecNeedsEosPropagation() { @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodecAdapter codecAdapter, + MediaCodecAdapter codec, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate) { @@ -560,16 +560,16 @@ protected void configureCodec( } surface = dummySurface; } - codecAdapter.configure(mediaFormat, surface, crypto, 0); + codec.configure(mediaFormat, surface, crypto, 0); if (Util.SDK_INT >= 23 && tunneling) { - tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codecAdapter.getCodec()); + tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); } } @Override @KeepCodecResult protected int canKeepCodec( - MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { + MediaCodecAdapter codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { if (newFormat.width > codecMaxValues.width || newFormat.height > codecMaxValues.height || getMaxInputSize(codecInfo, newFormat) > codecMaxValues.inputSize) { @@ -651,7 +651,7 @@ protected void onQueueInputBuffer(DecoderInputBuffer buffer) throws ExoPlaybackE @Override protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaFormat) { - @Nullable MediaCodec codec = getCodec(); + @Nullable MediaCodecAdapter codec = getCodec(); if (codec != null) { // Must be applied each time the output format changes. codec.setVideoScalingMode(scalingMode); @@ -729,7 +729,7 @@ protected void handleInputBufferSupplementalData(DecoderInputBuffer buffer) protected boolean processOutputBuffer( long positionUs, long elapsedRealtimeUs, - @Nullable MediaCodec codec, + @Nullable MediaCodecAdapter codec, @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, @@ -942,7 +942,7 @@ protected boolean shouldForceRenderOutputBuffer(long earlyUs, long elapsedSinceL * @param index The index of the output buffer to skip. * @param presentationTimeUs The presentation time of the output buffer, in microseconds. */ - protected void skipOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + protected void skipOutputBuffer(MediaCodecAdapter codec, int index, long presentationTimeUs) { TraceUtil.beginSection("skipVideoBuffer"); codec.releaseOutputBuffer(index, false); TraceUtil.endSection(); @@ -956,7 +956,7 @@ protected void skipOutputBuffer(MediaCodec codec, int index, long presentationTi * @param index The index of the output buffer to drop. * @param presentationTimeUs The presentation time of the output buffer, in microseconds. */ - protected void dropOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + protected void dropOutputBuffer(MediaCodecAdapter codec, int index, long presentationTimeUs) { TraceUtil.beginSection("dropVideoBuffer"); codec.releaseOutputBuffer(index, false); TraceUtil.endSection(); @@ -978,7 +978,7 @@ protected void dropOutputBuffer(MediaCodec codec, int index, long presentationTi * @throws ExoPlaybackException If an error occurs flushing the codec. */ protected boolean maybeDropBuffersToKeyframe( - MediaCodec codec, + MediaCodecAdapter codec, int index, long presentationTimeUs, long positionUs, @@ -1037,7 +1037,7 @@ protected void updateVideoFrameProcessingOffsetCounters(long processingOffsetUs) * @param index The index of the output buffer to drop. * @param presentationTimeUs The presentation time of the output buffer, in microseconds. */ - protected void renderOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + protected void renderOutputBuffer(MediaCodecAdapter codec, int index, long presentationTimeUs) { maybeNotifyVideoSizeChanged(); TraceUtil.beginSection("releaseOutputBuffer"); codec.releaseOutputBuffer(index, true); @@ -1059,7 +1059,7 @@ protected void renderOutputBuffer(MediaCodec codec, int index, long presentation */ @RequiresApi(21) protected void renderOutputBufferV21( - MediaCodec codec, int index, long presentationTimeUs, long releaseTimeNs) { + MediaCodecAdapter codec, int index, long presentationTimeUs, long releaseTimeNs) { maybeNotifyVideoSizeChanged(); TraceUtil.beginSection("releaseOutputBuffer"); codec.releaseOutputBuffer(index, releaseTimeNs); @@ -1132,7 +1132,7 @@ private void clearRenderedFirstFrame() { // OnFrameRenderedListenerV23.onFrameRenderedListener for tunneled playback on API level 23 and // above. if (Util.SDK_INT >= 23 && tunneling) { - MediaCodec codec = getCodec(); + @Nullable MediaCodecAdapter codec = getCodec(); // If codec is null then the listener will be instantiated in configureCodec. if (codec != null) { tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); @@ -1213,14 +1213,14 @@ private static boolean isBufferVeryLate(long earlyUs) { } @RequiresApi(29) - private static void setHdr10PlusInfoV29(MediaCodec codec, byte[] hdr10PlusInfo) { + private static void setHdr10PlusInfoV29(MediaCodecAdapter codec, byte[] hdr10PlusInfo) { Bundle codecParameters = new Bundle(); codecParameters.putByteArray(MediaCodec.PARAMETER_KEY_HDR10_PLUS_INFO, hdr10PlusInfo); codec.setParameters(codecParameters); } @RequiresApi(23) - protected void setOutputSurfaceV23(MediaCodec codec, Surface surface) { + protected void setOutputSurfaceV23(MediaCodecAdapter codec, Surface surface) { codec.setOutputSurface(surface); } @@ -1762,19 +1762,19 @@ private static boolean evaluateDeviceNeedsSetOutputSurfaceWorkaround() { @RequiresApi(23) private final class OnFrameRenderedListenerV23 - implements MediaCodec.OnFrameRenderedListener, Handler.Callback { + implements MediaCodecAdapter.OnFrameRenderedListener, Handler.Callback { private static final int HANDLE_FRAME_RENDERED = 0; private final Handler handler; - public OnFrameRenderedListenerV23(MediaCodec codec) { + public OnFrameRenderedListenerV23(MediaCodecAdapter codec) { handler = Util.createHandlerForCurrentLooper(/* callback= */ this); codec.setOnFrameRenderedListener(/* listener= */ this, handler); } @Override - public void onFrameRendered(MediaCodec codec, long presentationTimeUs, long nanoTime) { + public void onFrameRendered(MediaCodecAdapter codec, long presentationTimeUs, long nanoTime) { // Workaround bug in MediaCodec that causes deadlock if you call directly back into the // MediaCodec from this listener method. // Deadlock occurs because MediaCodec calls this listener method holding a lock, 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 c34390bc036..08539814432 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 @@ -18,7 +18,6 @@ import static java.lang.Math.max; import android.content.Context; -import android.media.MediaCodec; import android.media.MediaCrypto; import android.media.MediaFormat; import android.os.Handler; @@ -133,7 +132,7 @@ public String getName() { @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodecAdapter codecAdapter, + MediaCodecAdapter codec, Format format, MediaCrypto crypto, float operatingRate) { @@ -143,7 +142,7 @@ protected void configureCodec( // 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, codecAdapter, format, crypto, operatingRate); + super.configureCodec(codecInfo, codec, format, crypto, operatingRate); } @Override @@ -200,7 +199,7 @@ protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaF protected boolean processOutputBuffer( long positionUs, long elapsedRealtimeUs, - @Nullable MediaCodec codec, + @Nullable MediaCodecAdapter codec, ByteBuffer buffer, int bufferIndex, int bufferFlags, @@ -231,7 +230,7 @@ protected boolean processOutputBuffer( } @Override - protected void renderOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + protected void renderOutputBuffer(MediaCodecAdapter codec, int index, long presentationTimeUs) { skipToPositionBeforeRenderingFirstFrame = false; super.renderOutputBuffer(codec, index, presentationTimeUs); } @@ -239,7 +238,7 @@ protected void renderOutputBuffer(MediaCodec codec, int index, long presentation @RequiresApi(21) @Override protected void renderOutputBufferV21( - MediaCodec codec, int index, long presentationTimeUs, long releaseTimeNs) { + MediaCodecAdapter codec, int index, long presentationTimeUs, long releaseTimeNs) { skipToPositionBeforeRenderingFirstFrame = false; super.renderOutputBufferV21(codec, index, presentationTimeUs, releaseTimeNs); } From b03df4e8b5404e88a7ce71c1781148b3a3e8f286 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 9 Nov 2020 14:35:16 +0000 Subject: [PATCH 260/693] Add dispatchPrepare(player) to ControlDispatcher Issue: #7882 PiperOrigin-RevId: 341394254 --- RELEASENOTES.md | 7 +++++++ .../exoplayer2/demo/PlayerActivity.java | 11 +---------- .../ext/leanback/LeanbackPlayerAdapter.java | 15 ++++++++++++--- .../mediasession/MediaSessionConnector.java | 2 ++ .../android/exoplayer2/ControlDispatcher.java | 8 ++++++++ .../exoplayer2/DefaultControlDispatcher.java | 6 ++++++ .../android/exoplayer2/PlaybackPreparer.java | 6 ++++-- .../exoplayer2/ui/PlayerControlView.java | 15 +++++++++++---- .../ui/PlayerNotificationManager.java | 18 +++++++++++++----- .../android/exoplayer2/ui/PlayerView.java | 12 ++++++++---- .../exoplayer2/ui/StyledPlayerControlView.java | 15 +++++++++++---- .../exoplayer2/ui/StyledPlayerView.java | 13 +++++++++---- 12 files changed, 92 insertions(+), 36 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c8efd4c1f53..9b070b0fa0b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -29,6 +29,13 @@ * UI: * Show overflow button in `StyledPlayerControlView` only when there is not enough space. + * Add `dispatchPrepare(Player)` to `ControlDispatcher` and implement it in + `DefaultControlDispatcher`. Deprecate `PlaybackPreparer` and + `setPlaybackPreparer` in `StyledPlayerView`, `StyledPlayerControlView`, + `PlayerView`, `PlayerControlView`, `PlayerNotificationManager` and + `LeanbackPlayerAdapter` and use `ControlDispatcher` for dispatching + prepare instead + ([#7882](https://github.com/google/ExoPlayer/issues/7882)). * Audio: * Retry playback after some types of `AudioTrack` error. * Extractors: 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 776ab68a799..78dd6e11a8b 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 @@ -34,7 +34,6 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; @@ -68,7 +67,7 @@ /** An activity that plays media using {@link SimpleExoPlayer}. */ public class PlayerActivity extends AppCompatActivity - implements OnClickListener, PlaybackPreparer, StyledPlayerControlView.VisibilityListener { + implements OnClickListener, StyledPlayerControlView.VisibilityListener { // Saved instance state keys. @@ -250,13 +249,6 @@ public void onClick(View view) { } } - // PlaybackPreparer implementation - - @Override - public void preparePlayback() { - player.prepare(); - } - // PlayerControlView.VisibilityListener implementation @Override @@ -302,7 +294,6 @@ protected boolean initializePlayer() { player.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true); player.setPlayWhenReady(startAutoPlay); playerView.setPlayer(player); - playerView.setPlaybackPreparer(this); debugViewHelper = new DebugTextViewHelper(player, debugTextView); debugViewHelper.start(); } 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 6538160b8b9..6da02bb3242 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 @@ -78,10 +78,15 @@ public LeanbackPlayerAdapter(Context context, Player player, final int updatePer } /** - * Sets the {@link PlaybackPreparer}. - * - * @param playbackPreparer The {@link PlaybackPreparer}. + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} instead. The adapter calls + * {@link ControlDispatcher#dispatchPrepare(Player)} instead of {@link + * PlaybackPreparer#preparePlayback()}. The {@link DefaultControlDispatcher} that the adapter + * uses by default, calls {@link Player#prepare()}. If you wish to customize this behaviour, + * you can provide a custom implementation of {@link + * ControlDispatcher#dispatchPrepare(Player)}. */ + @SuppressWarnings("deprecation") + @Deprecated public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { this.playbackPreparer = playbackPreparer; } @@ -167,11 +172,15 @@ public long getCurrentPosition() { return player.getPlaybackState() == Player.STATE_IDLE ? -1 : player.getCurrentPosition(); } + // Calls deprecated method to provide backwards compatibility. + @SuppressWarnings("deprecation") @Override public void play() { if (player.getPlaybackState() == Player.STATE_IDLE) { if (playbackPreparer != null) { playbackPreparer.preparePlayback(); + } else { + controlDispatcher.dispatchPrepare(player); } } else if (player.getPlaybackState() == Player.STATE_ENDED) { controlDispatcher.dispatchSeekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); 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 85d0155bd77..e78c55b2afb 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 @@ -1147,6 +1147,8 @@ public void onPlay() { if (player.getPlaybackState() == Player.STATE_IDLE) { if (playbackPreparer != null) { playbackPreparer.onPrepare(/* playWhenReady= */ true); + } else { + controlDispatcher.dispatchPrepare(player); } } else if (player.getPlaybackState() == Player.STATE_ENDED) { seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ControlDispatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/ControlDispatcher.java index 7b78147e129..0d5e55fc833 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ControlDispatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ControlDispatcher.java @@ -26,6 +26,14 @@ */ public interface ControlDispatcher { + /** + * Dispatches a {@link Player#prepare()} operation. + * + * @param player The {@link Player} to which the operation should be dispatched. + * @return True if the operation was dispatched. False if suppressed. + */ + boolean dispatchPrepare(Player player); + /** * Dispatches a {@link Player#setPlayWhenReady(boolean)} operation. * 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 d46b939c1fc..25c468330c9 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 @@ -52,6 +52,12 @@ public DefaultControlDispatcher(long fastForwardIncrementMs, long rewindIncremen window = new Timeline.Window(); } + @Override + public boolean dispatchPrepare(Player player) { + player.prepare(); + return true; + } + @Override public boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady) { player.setPlayWhenReady(playWhenReady); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackPreparer.java b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackPreparer.java index 8ff7f504025..3ef38c8520a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackPreparer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackPreparer.java @@ -15,9 +15,11 @@ */ package com.google.android.exoplayer2; -/** Called to prepare a playback. */ +/** @deprecated Use {@link ControlDispatcher} instead. */ +@Deprecated public interface PlaybackPreparer { - /** Called to prepare a playback. */ + /** @deprecated Use {@link ControlDispatcher#dispatchPrepare(Player)} instead. */ + @Deprecated void preparePlayback(); } 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 65a9a5ed8f7..1ae7812bd46 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 @@ -611,11 +611,15 @@ public void setProgressUpdateListener(@Nullable ProgressUpdateListener listener) } /** - * Sets the {@link PlaybackPreparer}. - * - * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback - * preparer. + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} instead. The view calls {@link + * ControlDispatcher#dispatchPrepare(Player)} instead of {@link + * PlaybackPreparer#preparePlayback()}. The {@link DefaultControlDispatcher} that the view + * uses by default, calls {@link Player#prepare()}. If you wish to customize this behaviour, + * you can provide a custom implementation of {@link + * ControlDispatcher#dispatchPrepare(Player)}. */ + @SuppressWarnings("deprecation") + @Deprecated public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { this.playbackPreparer = playbackPreparer; } @@ -1254,11 +1258,14 @@ private void dispatchPlayPause(Player player) { } } + @SuppressWarnings("deprecation") private void dispatchPlay(Player player) { @State int state = player.getPlaybackState(); if (state == Player.STATE_IDLE) { if (playbackPreparer != null) { playbackPreparer.preparePlayback(); + } else { + controlDispatcher.dispatchPrepare(player); } } else if (state == Player.STATE_ENDED) { seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); 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 7c899c1ea21..b183fddbb6c 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 @@ -682,10 +682,16 @@ public final void setPlayer(@Nullable Player player) { } /** - * Sets the {@link PlaybackPreparer}. - * - * @param playbackPreparer The {@link PlaybackPreparer}. + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} instead. The manager calls + * {@link ControlDispatcher#dispatchPrepare(Player)} instead of {@link + * PlaybackPreparer#preparePlayback()}. The {@link DefaultControlDispatcher} that this manager + * uses by default, calls {@link Player#prepare()}. If you wish to intercept or customize this + * behaviour, you can provide a custom implementation of {@link + * ControlDispatcher#dispatchPrepare(Player)} and pass it to {@link + * #setControlDispatcher(ControlDispatcher)}. */ + @SuppressWarnings("deprecation") + @Deprecated public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { this.playbackPreparer = playbackPreparer; } @@ -1039,8 +1045,7 @@ protected NotificationCompat.Builder createNotification( @Nullable NotificationCompat.Builder builder, boolean ongoing, @Nullable Bitmap largeIcon) { - if (player.getPlaybackState() == Player.STATE_IDLE - && (player.getCurrentTimeline().isEmpty() || playbackPreparer == null)) { + if (player.getPlaybackState() == Player.STATE_IDLE && player.getCurrentTimeline().isEmpty()) { builderActions = null; return null; } @@ -1369,6 +1374,7 @@ public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { private class NotificationBroadcastReceiver extends BroadcastReceiver { + @SuppressWarnings("deprecation") @Override public void onReceive(Context context, Intent intent) { Player player = PlayerNotificationManager.this.player; @@ -1382,6 +1388,8 @@ public void onReceive(Context context, Intent intent) { if (player.getPlaybackState() == Player.STATE_IDLE) { if (playbackPreparer != null) { playbackPreparer.preparePlayback(); + } else { + controlDispatcher.dispatchPrepare(player); } } else if (player.getPlaybackState() == Player.STATE_ENDED) { controlDispatcher.dispatchSeekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); 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 bb9306c7a95..3143d3d70b8 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 @@ -982,11 +982,15 @@ public void setControllerVisibilityListener( } /** - * Sets the {@link PlaybackPreparer}. - * - * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback - * preparer. + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} instead. The view calls {@link + * ControlDispatcher#dispatchPrepare(Player)} instead of {@link + * PlaybackPreparer#preparePlayback()}. The {@link DefaultControlDispatcher} that the view + * uses by default, calls {@link Player#prepare()}. If you wish to customize this behaviour, + * you can provide a custom implementation of {@link + * ControlDispatcher#dispatchPrepare(Player)}. */ + @SuppressWarnings("deprecation") + @Deprecated public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { Assertions.checkStateNotNull(controller); controller.setPlaybackPreparer(playbackPreparer); 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 f17404b7a23..3cff0ef3cb5 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 @@ -834,11 +834,15 @@ public void setProgressUpdateListener(@Nullable ProgressUpdateListener listener) } /** - * Sets the {@link PlaybackPreparer}. - * - * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback - * preparer. + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} instead. The view calls {@link + * ControlDispatcher#dispatchPrepare(Player)} instead of {@link + * PlaybackPreparer#preparePlayback()}. The {@link DefaultControlDispatcher} that the view + * uses by default, calls {@link Player#prepare()}. If you wish to customize this behaviour, + * you can provide a custom implementation of {@link + * ControlDispatcher#dispatchPrepare(Player)}. */ + @SuppressWarnings("deprecation") + @Deprecated public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { this.playbackPreparer = playbackPreparer; } @@ -1698,11 +1702,14 @@ private void dispatchPlayPause(Player player) { } } + @SuppressWarnings("deprecation") private void dispatchPlay(Player player) { @State int state = player.getPlaybackState(); if (state == Player.STATE_IDLE) { if (playbackPreparer != null) { playbackPreparer.preparePlayback(); + } else { + controlDispatcher.dispatchPrepare(player); } } else if (state == Player.STATE_ENDED) { seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); 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 index 0c018a30682..871ed8919cc 100644 --- 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 @@ -45,6 +45,7 @@ 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; @@ -977,11 +978,15 @@ public void setControllerOnFullScreenModeChangedListener( } /** - * Sets the {@link PlaybackPreparer}. - * - * @param playbackPreparer The {@link PlaybackPreparer}, or null to remove the current playback - * preparer. + * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} instead. The view calls {@link + * ControlDispatcher#dispatchPrepare(Player)} instead of {@link + * PlaybackPreparer#preparePlayback()}. The {@link DefaultControlDispatcher} that the view + * uses by default, calls {@link Player#prepare()}. If you wish to customize this behaviour, + * you can provide a custom implementation of {@link + * ControlDispatcher#dispatchPrepare(Player)}. */ + @SuppressWarnings("deprecation") + @Deprecated public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) { Assertions.checkStateNotNull(controller); controller.setPlaybackPreparer(playbackPreparer); From 86ae7ebac4eab3be1d88a7f26d367f1806f1bf90 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 9 Nov 2020 14:53:50 +0000 Subject: [PATCH 261/693] Decrease target live offset if safely possible. To check what is safely possible we keep track of the live offset corresponding to the buffered duration and only deecrease the target offset to a safe margin from the buffered duration. Also, while still possible (i.e. while the actual offset is larger than the safe margin), we increase the target offset to the safe margin to avoid rebuffers to start with. Issue: #4904 PiperOrigin-RevId: 341396492 --- .../DefaultLivePlaybackSpeedControl.java | 119 +++++++++- .../exoplayer2/ExoPlayerImplInternal.java | 3 +- .../exoplayer2/LivePlaybackSpeedControl.java | 3 +- .../DefaultLivePlaybackSpeedControlTest.java | 222 ++++++++++++++++-- 4 files changed, 321 insertions(+), 26 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java index 6ea6f083217..9bb3d1cd38f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2; +import static com.google.common.primitives.Longs.max; +import static java.lang.Math.abs; +import static java.lang.Math.max; + import android.os.SystemClock; import com.google.android.exoplayer2.MediaItem.LiveConfiguration; import com.google.android.exoplayer2.util.Assertions; @@ -36,7 +40,10 @@ * *

    When the player rebuffers, the target live offset {@link * Builder#setTargetLiveOffsetIncrementOnRebufferMs(long) is increased} to adjust to the reduced - * network capabilities. + * network capabilities. The live playback speed control also {@link + * Builder#setMinPossibleLiveOffsetSmoothingFactor(float) keeps track} of the minimum possible live + * offset to decrease the target live offset again if conditions improve. The minimum possible live + * offset is derived from the current offset and the duration of buffered media. */ public final class DefaultLivePlaybackSpeedControl implements LivePlaybackSpeedControl { @@ -70,6 +77,12 @@ public final class DefaultLivePlaybackSpeedControl implements LivePlaybackSpeedC */ public static final long DEFAULT_TARGET_LIVE_OFFSET_INCREMENT_ON_REBUFFER_MS = 500; + /** + * The default smoothing factor when smoothing the minimum possible live offset that can be + * achieved during playback. + */ + public static final float DEFAULT_MIN_POSSIBLE_LIVE_OFFSET_SMOOTHING_FACTOR = 0.999f; + /** * The maximum difference between the current live offset and the target live offset for which * unit speed (1.0f) is used. @@ -84,6 +97,7 @@ public static final class Builder { private long minUpdateIntervalMs; private float proportionalControlFactorUs; private long targetLiveOffsetIncrementOnRebufferUs; + private float minPossibleLiveOffsetSmoothingFactor; /** Creates a builder. */ public Builder() { @@ -93,6 +107,7 @@ public Builder() { proportionalControlFactorUs = DEFAULT_PROPORTIONAL_CONTROL_FACTOR / C.MICROS_PER_SECOND; targetLiveOffsetIncrementOnRebufferUs = C.msToUs(DEFAULT_TARGET_LIVE_OFFSET_INCREMENT_ON_REBUFFER_MS); + minPossibleLiveOffsetSmoothingFactor = DEFAULT_MIN_POSSIBLE_LIVE_OFFSET_SMOOTHING_FACTOR; } /** @@ -173,6 +188,28 @@ public Builder setTargetLiveOffsetIncrementOnRebufferMs( return this; } + /** + * Sets the smoothing factor when smoothing the minimum possible live offset that can be + * achieved during playback. + * + *

    The live playback speed control keeps track of the minimum possible live offset achievable + * during playback to know whether it can reduce the current target live offset. The minimum + * possible live offset is defined as {@code currentLiveOffset - bufferedDuration}. As the + * minimum possible live offset is constantly changing, it is smoothed over recent samples by + * applying exponential smoothing: {@code smoothedMinPossibleOffset = smoothingFactor x + * smoothedMinPossibleOffset + (1-smoothingFactor) x currentMinPossibleOffset}. + * + * @param minPossibleLiveOffsetSmoothingFactor The smoothing factor. Must be ≥ 0 and < 1. + * @return This builder, for convenience. + */ + public Builder setMinPossibleLiveOffsetSmoothingFactor( + float minPossibleLiveOffsetSmoothingFactor) { + Assertions.checkArgument( + minPossibleLiveOffsetSmoothingFactor >= 0 && minPossibleLiveOffsetSmoothingFactor < 1f); + this.minPossibleLiveOffsetSmoothingFactor = minPossibleLiveOffsetSmoothingFactor; + return this; + } + /** Builds an instance. */ public DefaultLivePlaybackSpeedControl build() { return new DefaultLivePlaybackSpeedControl( @@ -180,7 +217,8 @@ public DefaultLivePlaybackSpeedControl build() { fallbackMaxPlaybackSpeed, minUpdateIntervalMs, proportionalControlFactorUs, - targetLiveOffsetIncrementOnRebufferUs); + targetLiveOffsetIncrementOnRebufferUs, + minPossibleLiveOffsetSmoothingFactor); } } @@ -189,6 +227,7 @@ public DefaultLivePlaybackSpeedControl build() { private final long minUpdateIntervalMs; private final float proportionalControlFactor; private final long targetLiveOffsetRebufferDeltaUs; + private final float minPossibleLiveOffsetSmoothingFactor; private long mediaConfigurationTargetLiveOffsetUs; private long targetLiveOffsetOverrideUs; @@ -202,17 +241,22 @@ public DefaultLivePlaybackSpeedControl build() { private float adjustedPlaybackSpeed; private long lastPlaybackSpeedUpdateMs; + private long smoothedMinPossibleLiveOffsetUs; + private long smoothedMinPossibleLiveOffsetDeviationUs; + private DefaultLivePlaybackSpeedControl( float fallbackMinPlaybackSpeed, float fallbackMaxPlaybackSpeed, long minUpdateIntervalMs, float proportionalControlFactor, - long targetLiveOffsetRebufferDeltaUs) { + long targetLiveOffsetRebufferDeltaUs, + float minPossibleLiveOffsetSmoothingFactor) { this.fallbackMinPlaybackSpeed = fallbackMinPlaybackSpeed; this.fallbackMaxPlaybackSpeed = fallbackMaxPlaybackSpeed; this.minUpdateIntervalMs = minUpdateIntervalMs; this.proportionalControlFactor = proportionalControlFactor; this.targetLiveOffsetRebufferDeltaUs = targetLiveOffsetRebufferDeltaUs; + this.minPossibleLiveOffsetSmoothingFactor = minPossibleLiveOffsetSmoothingFactor; mediaConfigurationTargetLiveOffsetUs = C.TIME_UNSET; targetLiveOffsetOverrideUs = C.TIME_UNSET; minTargetLiveOffsetUs = C.TIME_UNSET; @@ -223,6 +267,8 @@ private DefaultLivePlaybackSpeedControl( lastPlaybackSpeedUpdateMs = C.TIME_UNSET; idealTargetLiveOffsetUs = C.TIME_UNSET; currentTargetLiveOffsetUs = C.TIME_UNSET; + smoothedMinPossibleLiveOffsetUs = C.TIME_UNSET; + smoothedMinPossibleLiveOffsetDeviationUs = C.TIME_UNSET; } @Override @@ -261,16 +307,20 @@ public void notifyRebuffer() { } @Override - public float getAdjustedPlaybackSpeed(long liveOffsetUs) { + public float getAdjustedPlaybackSpeed(long liveOffsetUs, long bufferedDurationUs) { if (mediaConfigurationTargetLiveOffsetUs == C.TIME_UNSET) { return 1f; } + + updateSmoothedMinPossibleLiveOffsetUs(liveOffsetUs, bufferedDurationUs); + if (lastPlaybackSpeedUpdateMs != C.TIME_UNSET && SystemClock.elapsedRealtime() - lastPlaybackSpeedUpdateMs < minUpdateIntervalMs) { return adjustedPlaybackSpeed; } lastPlaybackSpeedUpdateMs = SystemClock.elapsedRealtime(); + adjustTargetLiveOffsetUs(liveOffsetUs); long liveOffsetErrorUs = liveOffsetUs - currentTargetLiveOffsetUs; if (Math.abs(liveOffsetErrorUs) < MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED) { adjustedPlaybackSpeed = 1f; @@ -306,6 +356,67 @@ private void maybeResetTargetLiveOffsetUs() { } idealTargetLiveOffsetUs = idealOffsetUs; currentTargetLiveOffsetUs = idealOffsetUs; + smoothedMinPossibleLiveOffsetUs = C.TIME_UNSET; + smoothedMinPossibleLiveOffsetDeviationUs = C.TIME_UNSET; lastPlaybackSpeedUpdateMs = C.TIME_UNSET; } + + private void updateSmoothedMinPossibleLiveOffsetUs(long liveOffsetUs, long bufferedDurationUs) { + long minPossibleLiveOffsetUs = liveOffsetUs - bufferedDurationUs; + if (smoothedMinPossibleLiveOffsetUs == C.TIME_UNSET) { + smoothedMinPossibleLiveOffsetUs = minPossibleLiveOffsetUs; + smoothedMinPossibleLiveOffsetDeviationUs = 0; + } else { + // Use the maximum here to ensure we keep track of the upper bound of what is safely possible, + // not the average. + smoothedMinPossibleLiveOffsetUs = + max( + minPossibleLiveOffsetUs, + smooth( + smoothedMinPossibleLiveOffsetUs, + minPossibleLiveOffsetUs, + minPossibleLiveOffsetSmoothingFactor)); + long minPossibleLiveOffsetDeviationUs = + abs(minPossibleLiveOffsetUs - smoothedMinPossibleLiveOffsetUs); + smoothedMinPossibleLiveOffsetDeviationUs = + smooth( + smoothedMinPossibleLiveOffsetDeviationUs, + minPossibleLiveOffsetDeviationUs, + minPossibleLiveOffsetSmoothingFactor); + } + } + + private void adjustTargetLiveOffsetUs(long liveOffsetUs) { + // Stay in a safe distance (3 standard deviations = >99%) to the minimum possible live offset. + long safeOffsetUs = + smoothedMinPossibleLiveOffsetUs + 3 * smoothedMinPossibleLiveOffsetDeviationUs; + if (currentTargetLiveOffsetUs > safeOffsetUs) { + // There is room for decreasing the target offset towards the ideal or safe offset (whichever + // is larger). We want to limit the decrease so that the playback speed delta we achieve is + // the same as the maximum delta when slowing down towards the target. + long minUpdateIntervalUs = C.msToUs(minUpdateIntervalMs); + long decrementToOffsetCurrentSpeedUs = + (long) ((adjustedPlaybackSpeed - 1f) * minUpdateIntervalUs); + long decrementToIncreaseSpeedUs = (long) ((maxPlaybackSpeed - 1f) * minUpdateIntervalUs); + long maxDecrementUs = decrementToOffsetCurrentSpeedUs + decrementToIncreaseSpeedUs; + currentTargetLiveOffsetUs = + max(safeOffsetUs, idealTargetLiveOffsetUs, currentTargetLiveOffsetUs - maxDecrementUs); + } else { + // We'd like to reach a stable condition where the current live offset stays just below the + // safe offset. But don't increase the target offset to more than what would allow us to slow + // down gradually from the current offset. + long offsetWhenSlowingDownNowUs = + liveOffsetUs - (long) (max(0f, adjustedPlaybackSpeed - 1f) / proportionalControlFactor); + currentTargetLiveOffsetUs = + Util.constrainValue(offsetWhenSlowingDownNowUs, currentTargetLiveOffsetUs, safeOffsetUs); + if (maxTargetLiveOffsetUs != C.TIME_UNSET + && currentTargetLiveOffsetUs > maxTargetLiveOffsetUs) { + currentTargetLiveOffsetUs = maxTargetLiveOffsetUs; + } + } + } + + private static long smooth(long smoothedValue, long newValue, float smoothingFactor) { + return (long) (smoothingFactor * smoothedValue + (1f - smoothingFactor) * newValue); + } } 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 20a5201e0d5..f5cdcdc8c8a 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 @@ -872,7 +872,8 @@ private void updatePlaybackPositions() throws ExoPlaybackException { && isCurrentPeriodInMovingLiveWindow() && playbackInfo.playbackParameters.speed == 1f) { float adjustedSpeed = - livePlaybackSpeedControl.getAdjustedPlaybackSpeed(getCurrentLiveOffsetUs()); + livePlaybackSpeedControl.getAdjustedPlaybackSpeed( + getCurrentLiveOffsetUs(), getTotalBufferedDurationUs()); if (mediaClock.getPlaybackParameters().speed != adjustedSpeed) { mediaClock.setPlaybackParameters(playbackInfo.playbackParameters.withSpeed(adjustedSpeed)); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java index 8844c629087..6796185edd3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/LivePlaybackSpeedControl.java @@ -53,9 +53,10 @@ public interface LivePlaybackSpeedControl { * #getTargetLiveOffsetUs() target live offset}. * * @param liveOffsetUs The current live offset, in microseconds. + * @param bufferedDurationUs The duration of media that's currently buffered, in microseconds. * @return The adjusted playback speed. */ - float getAdjustedPlaybackSpeed(long liveOffsetUs); + float getAdjustedPlaybackSpeed(long liveOffsetUs, long bufferedDurationUs); /** * Returns the current target live offset, in microseconds, or {@link C#TIME_UNSET} if no target diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java index 9c08b9999be..2e2731b0652 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java @@ -324,6 +324,172 @@ public void getTargetLiveOffsetUs_afterRepeatedNotifyRebuffer_returnsMaxLiveOffs assertThat(targetLiveOffsetUs).isEqualTo(39_000); } + @Test + public void + getTargetLiveOffsetUs_afterNotifyRebufferAndAdjustPlaybackSpeedWithLargeBufferedDuration_returnsDecreasedOffsetToIdealTarget() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3_000) + .setMinUpdateIntervalMs(100) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42_000, + /* minLiveOffsetMs= */ 5_000, + /* maxLiveOffsetMs= */ 400_000, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f)); + + defaultLivePlaybackSpeedControl.notifyRebuffer(); + long targetLiveOffsetAfterRebufferUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 45_000_000, /* bufferedDurationUs= */ 9_000_000); + long targetLiveOffsetAfterOneAdjustmentUs = + defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + for (int i = 0; i < 500; i++) { + ShadowSystemClock.advanceBy(Duration.ofMillis(100)); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 45_000_000, /* bufferedDurationUs= */ 9_000_000); + } + long targetLiveOffsetAfterManyAdjustmentsUs = + defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetAfterOneAdjustmentUs).isLessThan(targetLiveOffsetAfterRebufferUs); + assertThat(targetLiveOffsetAfterManyAdjustmentsUs) + .isLessThan(targetLiveOffsetAfterOneAdjustmentUs); + assertThat(targetLiveOffsetAfterManyAdjustmentsUs).isEqualTo(42_000_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterNotifyRebufferAndAdjustPlaybackSpeedWithSmallBufferedDuration_returnsDecreasedOffsetToSafeTarget() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3_000) + .setMinUpdateIntervalMs(100) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42_000, + /* minLiveOffsetMs= */ 5_000, + /* maxLiveOffsetMs= */ 400_000, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f)); + + defaultLivePlaybackSpeedControl.notifyRebuffer(); + long targetLiveOffsetAfterRebufferUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 45_000_000, /* bufferedDurationUs= */ 1_000_000); + long targetLiveOffsetAfterOneAdjustmentUs = + defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + for (int i = 0; i < 500; i++) { + ShadowSystemClock.advanceBy(Duration.ofMillis(100)); + long noiseUs = ((i % 10) - 5) * 1_000; + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 45_000_000, /* bufferedDurationUs= */ 1_000_000 + noiseUs); + } + long targetLiveOffsetAfterManyAdjustmentsUs = + defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetAfterOneAdjustmentUs).isLessThan(targetLiveOffsetAfterRebufferUs); + assertThat(targetLiveOffsetAfterManyAdjustmentsUs) + .isLessThan(targetLiveOffsetAfterOneAdjustmentUs); + // Should be at least be at the minimum buffered position. + assertThat(targetLiveOffsetAfterManyAdjustmentsUs).isGreaterThan(44_005_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterAdjustPlaybackSpeedWithLiveOffsetAroundCurrentTarget_returnsSafeTarget() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder().build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42_000, + /* minLiveOffsetMs= */ 5_000, + /* maxLiveOffsetMs= */ 400_000, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f)); + + long targetLiveOffsetBeforeUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + // Pretend to have a buffered duration at around the target duration with some artificial noise. + for (int i = 0; i < 500; i++) { + ShadowSystemClock.advanceBy(Duration.ofMillis(100)); + long noiseUs = ((i % 10) - 5) * 1_000; + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 49_000_000, /* bufferedDurationUs= */ 7_000_000 + noiseUs); + } + ShadowSystemClock.advanceBy(Duration.ofMillis(100)); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 49_000_000, /* bufferedDurationUs= */ 7_000_000); + long targetLiveOffsetAfterUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetBeforeUs).isEqualTo(42_000_000); + assertThat(targetLiveOffsetAfterUs).isGreaterThan(42_005_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterAdjustPlaybackSpeedAndSmoothingFactorOfZero_ignoresSafeTargetAndReturnsCurrentTarget() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setMinPossibleLiveOffsetSmoothingFactor(0f) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42_000, + /* minLiveOffsetMs= */ 5_000, + /* maxLiveOffsetMs= */ 400_000, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f)); + + long targetLiveOffsetBeforeUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + // Pretend to have a buffered duration at around the target duration with some artificial noise. + for (int i = 0; i < 500; i++) { + ShadowSystemClock.advanceBy(Duration.ofMillis(100)); + long noiseUs = ((i % 10) - 5) * 1_000; + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 49_000_000, /* bufferedDurationUs= */ 7_000_000 + noiseUs); + } + ShadowSystemClock.advanceBy(Duration.ofMillis(100)); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 49_000_000, /* bufferedDurationUs= */ 7_000_000); + long targetLiveOffsetAfterUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetBeforeUs).isEqualTo(42_000_000); + // Despite the noise indicating it's unsafe here, we still return the target offset. + assertThat(targetLiveOffsetAfterUs).isEqualTo(42_000_000); + } + + @Test + public void + getTargetLiveOffsetUs_afterAdjustPlaybackSpeedWithLiveOffsetLessThanCurrentTarget_returnsCurrentTarget() { + DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = + new DefaultLivePlaybackSpeedControl.Builder() + .setTargetLiveOffsetIncrementOnRebufferMs(3_000) + .setMinUpdateIntervalMs(100) + .build(); + defaultLivePlaybackSpeedControl.setLiveConfiguration( + new LiveConfiguration( + /* targetLiveOffsetMs= */ 42_000, + /* minLiveOffsetMs= */ 5_000, + /* maxLiveOffsetMs= */ 400_000, + /* minPlaybackSpeed= */ 0.9f, + /* maxPlaybackSpeed= */ 1.1f)); + + long targetLiveOffsetBeforeUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 39_000_000, /* bufferedDurationUs= */ 1_000_000); + long targetLiveOffsetAfterUs = defaultLivePlaybackSpeedControl.getTargetLiveOffsetUs(); + + assertThat(targetLiveOffsetBeforeUs).isEqualTo(42_000_000); + assertThat(targetLiveOffsetAfterUs).isEqualTo(42_000_000); + } + @Test public void adjustPlaybackSpeed_liveOffsetMatchesTargetOffset_returnsUnitSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = @@ -337,7 +503,8 @@ public void adjustPlaybackSpeed_liveOffsetMatchesTargetOffset_returnsUnitSpeed() /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_000_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_000_000, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed).isEqualTo(1f); } @@ -358,12 +525,14 @@ public void adjustPlaybackSpeed_liveOffsetWithinAcceptableErrorMargin_returnsUni defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( /* liveOffsetUs= */ 2_000_000 - DefaultLivePlaybackSpeedControl.MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED - + 1); + + 1, + /* bufferedDurationUs= */ 1_000_000); float adjustedSpeedJustBelowUpperErrorMargin = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( /* liveOffsetUs= */ 2_000_000 + DefaultLivePlaybackSpeedControl.MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED - - 1); + - 1, + /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeedJustAboveLowerErrorMargin).isEqualTo(1f); assertThat(adjustedSpeedJustBelowUpperErrorMargin).isEqualTo(1f); @@ -382,7 +551,8 @@ public void adjustPlaybackSpeed_withLiveOffsetGreaterThanTargetOffset_returnsAdj /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_500_000, /* bufferedDurationUs= */ 1_000_000); float expectedSpeedAccordingToDocumentation = 1f + 0.01f * (2.5f - 2f); assertThat(adjustedSpeed).isEqualTo(expectedSpeedAccordingToDocumentation); @@ -403,7 +573,8 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 1_500_000, /* bufferedDurationUs= */ 1_000_000); float expectedSpeedAccordingToDocumentation = 1f + 0.01f * (1.5f - 2f); assertThat(adjustedSpeed).isEqualTo(expectedSpeedAccordingToDocumentation); @@ -425,7 +596,7 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus float adjustedSpeed = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( - /* liveOffsetUs= */ 999_999_999_999L); + /* liveOffsetUs= */ 999_999_999_999L, /* bufferedDurationUs= */ 999_999_999_999L); assertThat(adjustedSpeed).isEqualTo(1.5f); } @@ -445,7 +616,7 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus float adjustedSpeed = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( - /* liveOffsetUs= */ -999_999_999_999L); + /* liveOffsetUs= */ -999_999_999_999L, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed).isEqualTo(0.5f); } @@ -465,7 +636,7 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus float adjustedSpeed = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( - /* liveOffsetUs= */ 999_999_999_999L); + /* liveOffsetUs= */ 999_999_999_999L, /* bufferedDurationUs= */ 999_999_999_999L); assertThat(adjustedSpeed).isEqualTo(2f); } @@ -485,7 +656,7 @@ public void adjustPlaybackSpeed_withLiveOffsetLowerThanTargetOffset_returnsAdjus float adjustedSpeed = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( - /* liveOffsetUs= */ -999_999_999_999L); + /* liveOffsetUs= */ -999_999_999_999L, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed).isEqualTo(0.2f); } @@ -503,13 +674,16 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 1_500_000, /* bufferedDurationUs= */ 1_000_000); ShadowSystemClock.advanceBy(Duration.ofMillis(122)); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_500_000, /* bufferedDurationUs= */ 1_000_000); ShadowSystemClock.advanceBy(Duration.ofMillis(2)); float adjustedSpeed3 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_500_000, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed1).isEqualTo(adjustedSpeed2); assertThat(adjustedSpeed3).isNotEqualTo(adjustedSpeed2); @@ -529,7 +703,8 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 1_500_000, /* bufferedDurationUs= */ 1_000_000); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, @@ -538,7 +713,8 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_500_000, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed1).isEqualTo(adjustedSpeed2); } @@ -557,7 +733,8 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 1_500_000, /* bufferedDurationUs= */ 1_000_000); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 1_000, @@ -566,7 +743,8 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA /* minPlaybackSpeed= */ C.RATE_UNSET, /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_500_000, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); } @@ -585,10 +763,12 @@ public void adjustPlaybackSpeed_repeatedCallWithinMinUpdateInterval_returnsSameA /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 1_500_000, /* bufferedDurationUs= */ 1_000_000); defaultLivePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(2_000_001); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_500_000, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); } @@ -606,10 +786,12 @@ public void adjustPlaybackSpeed_repeatedCallAfterNotifyRebuffer_updatesSpeedAgai /* maxPlaybackSpeed= */ C.RATE_UNSET)); float adjustedSpeed1 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 1_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 1_500_000, /* bufferedDurationUs= */ 1_000_000); defaultLivePlaybackSpeedControl.notifyRebuffer(); float adjustedSpeed2 = - defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed(/* liveOffsetUs= */ 2_500_000); + defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( + /* liveOffsetUs= */ 2_500_000, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeed1).isNotEqualTo(adjustedSpeed2); } From 9473fda0568d94082a3f32197772de7248d2f353 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 9 Nov 2020 17:40:32 +0000 Subject: [PATCH 262/693] Synchronize codec interaction with buffer queueing Add experimental method to synchronize MediaCodec interactions with asynchronous queueing. When the feature is enabled, interactions such as MediaCodec.setOutputSurface() triggered by the MediaCodecRenderer will wait until all input buffers pending queueing are first submitted to the MediaCodec. PiperOrigin-RevId: 341423837 --- .../exoplayer2/DefaultRenderersFactory.java | 26 +++++++++-- .../AsynchronousMediaCodecAdapter.java | 34 ++++++++++++-- .../AsynchronousMediaCodecBufferEnqueuer.java | 27 ++++++++---- .../mediacodec/MediaCodecRenderer.java | 21 ++++++++- .../AsynchronousMediaCodecAdapterTest.java | 7 ++- ...nchronousMediaCodecBufferEnqueuerTest.java | 44 +++++++++---------- 6 files changed, 119 insertions(+), 40 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 5d130442b32..ad589171601 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 @@ -92,6 +92,7 @@ public class DefaultRenderersFactory implements RenderersFactory { private boolean enableDecoderFallback; private MediaCodecSelector mediaCodecSelector; private boolean enableAsyncQueueing; + private boolean enableSynchronizeCodecInteractionsWithQueueing; private boolean enableFloatOutput; private boolean enableAudioTrackPlaybackParams; private boolean enableOffload; @@ -155,11 +156,26 @@ public DefaultRenderersFactory setExtensionRendererMode( * @param enabled Whether asynchronous queueing is enabled. * @return This factory, for convenience. */ - public DefaultRenderersFactory experimentalEnableAsynchronousBufferQueueing(boolean enabled) { + public DefaultRenderersFactory experimentalSetAsynchronousBufferQueueingEnabled(boolean enabled) { enableAsyncQueueing = enabled; return this; } + /** + * Enable synchronizing codec interactions with asynchronous buffer queueing. + * + *

    This method is experimental, and will be renamed or removed in a future release. + * + * @param enabled Whether codec interactions will be synchronized with asynchronous buffer + * queueing. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory experimentalSetSynchronizeCodecInteractionsWithQueueingEnabled( + boolean enabled) { + enableSynchronizeCodecInteractionsWithQueueing = enabled; + return this; + } + /** * Sets whether to enable fallback to lower-priority decoders if decoder initialization fails. * This may result in using a decoder that is less efficient or slower than the primary decoder. @@ -336,7 +352,9 @@ protected void buildVideoRenderers( eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); - videoRenderer.experimentalEnableAsynchronousBufferQueueing(enableAsyncQueueing); + videoRenderer.experimentalSetAsynchronousBufferQueueingEnabled(enableAsyncQueueing); + videoRenderer.experimentalSetSynchronizeCodecInteractionsWithQueueingEnabled( + enableSynchronizeCodecInteractionsWithQueueing); out.add(videoRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { @@ -461,7 +479,9 @@ protected void buildAudioRenderers( eventHandler, eventListener, audioSink); - audioRenderer.experimentalEnableAsynchronousBufferQueueing(enableAsyncQueueing); + audioRenderer.experimentalSetAsynchronousBufferQueueingEnabled(enableAsyncQueueing); + audioRenderer.experimentalSetSynchronizeCodecInteractionsWithQueueingEnabled( + enableSynchronizeCodecInteractionsWithQueueing); out.add(audioRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { 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 2deb5fa2bf1..54ad57cafe2 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 @@ -56,6 +56,7 @@ private final MediaCodec codec; private final AsynchronousMediaCodecCallback asynchronousMediaCodecCallback; private final AsynchronousMediaCodecBufferEnqueuer bufferEnqueuer; + private final boolean synchronizeCodecInteractionsWithQueueing; private boolean codecReleased; @State private int state; @@ -65,20 +66,30 @@ * @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. + * @param synchronizeCodecInteractionsWithQueueing Whether the adapter should synchronize {@link + * MediaCodec} interactions with asynchronous buffer queueing. When {@code true}, codec + * interactions will wait until all input buffers pending queueing wil be submitted to the + * {@link MediaCodec}. */ - /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, int trackType) { + /* package */ AsynchronousMediaCodecAdapter( + MediaCodec codec, int trackType, boolean synchronizeCodecInteractionsWithQueueing) { this( codec, new HandlerThread(createCallbackThreadLabel(trackType)), - new HandlerThread(createQueueingThreadLabel(trackType))); + new HandlerThread(createQueueingThreadLabel(trackType)), + synchronizeCodecInteractionsWithQueueing); } @VisibleForTesting /* package */ AsynchronousMediaCodecAdapter( - MediaCodec codec, HandlerThread callbackThread, HandlerThread enqueueingThread) { + MediaCodec codec, + HandlerThread callbackThread, + HandlerThread enqueueingThread, + boolean synchronizeCodecInteractionsWithQueueing) { this.codec = codec; this.asynchronousMediaCodecCallback = new AsynchronousMediaCodecCallback(callbackThread); this.bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, enqueueingThread); + this.synchronizeCodecInteractionsWithQueueing = synchronizeCodecInteractionsWithQueueing; this.state = STATE_CREATED; } @@ -181,6 +192,7 @@ public void release() { @Override public void setOnFrameRenderedListener(OnFrameRenderedListener listener, Handler handler) { + maybeBlockOnQueueing(); codec.setOnFrameRenderedListener( (codec, presentationTimeUs, nanoTime) -> listener.onFrameRendered( @@ -190,16 +202,19 @@ public void setOnFrameRenderedListener(OnFrameRenderedListener listener, Handler @Override public void setOutputSurface(Surface surface) { + maybeBlockOnQueueing(); codec.setOutputSurface(surface); } @Override public void setParameters(Bundle params) { + maybeBlockOnQueueing(); codec.setParameters(params); } @Override public void setVideoScalingMode(@VideoScalingMode int scalingMode) { + maybeBlockOnQueueing(); codec.setVideoScalingMode(scalingMode); } @@ -213,6 +228,19 @@ public void setVideoScalingMode(@VideoScalingMode int scalingMode) { asynchronousMediaCodecCallback.onOutputFormatChanged(codec, format); } + private void maybeBlockOnQueueing() { + if (synchronizeCodecInteractionsWithQueueing) { + try { + bufferEnqueuer.waitUntilQueueingComplete(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + // The playback thread should not be interrupted. Raising this as an + // IllegalStateException. + throw new IllegalStateException(e); + } + } + } + private static String createCallbackThreadLabel(int trackType) { return createThreadLabel(trackType, /* prefix= */ "ExoPlayer:MediaCodecAsyncAdapter:"); } 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 10d59d347c4..79bb9819558 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 @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.mediacodec; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; import android.media.MediaCodec; import android.os.Handler; @@ -46,7 +47,7 @@ class AsynchronousMediaCodecBufferEnqueuer { private static final int MSG_QUEUE_INPUT_BUFFER = 0; private static final int MSG_QUEUE_SECURE_INPUT_BUFFER = 1; - private static final int MSG_FLUSH = 2; + private static final int MSG_OPEN_CV = 2; @GuardedBy("MESSAGE_PARAMS_INSTANCE_POOL") private static final ArrayDeque MESSAGE_PARAMS_INSTANCE_POOL = new ArrayDeque<>(); @@ -110,8 +111,7 @@ public void queueInputBuffer( maybeThrowException(); MessageParams messageParams = getMessageParams(); messageParams.setQueueParams(index, offset, size, presentationTimeUs, flags); - Message message = - Util.castNonNull(handler).obtainMessage(MSG_QUEUE_INPUT_BUFFER, messageParams); + Message message = castNonNull(handler).obtainMessage(MSG_QUEUE_INPUT_BUFFER, messageParams); message.sendToTarget(); } @@ -131,7 +131,7 @@ public void queueSecureInputBuffer( messageParams.setQueueParams(index, offset, /* size= */ 0, presentationTimeUs, flags); copy(info, messageParams.cryptoInfo); Message message = - Util.castNonNull(handler).obtainMessage(MSG_QUEUE_SECURE_INPUT_BUFFER, messageParams); + castNonNull(handler).obtainMessage(MSG_QUEUE_SECURE_INPUT_BUFFER, messageParams); message.sendToTarget(); } @@ -158,6 +158,11 @@ public void shutdown() { started = false; } + /** Blocks the current thread until all input buffers pending queueing are submitted. */ + public void waitUntilQueueingComplete() throws InterruptedException { + blockUntilHandlerThreadIsIdle(); + } + private void maybeThrowException() { @Nullable RuntimeException exception = pendingRuntimeException.getAndSet(null); if (exception != null) { @@ -170,15 +175,19 @@ private void maybeThrowException() { * blocks until the {@link #handlerThread} is idle. */ private void flushHandlerThread() throws InterruptedException { - Handler handler = Util.castNonNull(this.handler); + Handler handler = castNonNull(this.handler); handler.removeCallbacksAndMessages(null); - conditionVariable.close(); - handler.obtainMessage(MSG_FLUSH).sendToTarget(); - conditionVariable.block(); + blockUntilHandlerThreadIsIdle(); // Check if any exceptions happened during the last queueing action. maybeThrowException(); } + private void blockUntilHandlerThreadIsIdle() throws InterruptedException { + conditionVariable.close(); + castNonNull(handler).obtainMessage(MSG_OPEN_CV).sendToTarget(); + conditionVariable.block(); + } + // Called from the handler thread @VisibleForTesting @@ -203,7 +212,7 @@ private void doHandleMessage(Message msg) { params.presentationTimeUs, params.flags); break; - case MSG_FLUSH: + case MSG_OPEN_CV: conditionVariable.open(); break; default: 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 834923e0daf..63069b5320d 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 @@ -348,6 +348,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { private boolean waitingForFirstSampleInFormat; private boolean pendingOutputEndOfStream; private boolean enableAsynchronousBufferQueueing; + private boolean enableSynchronizeCodecInteractionsWithQueueing; @Nullable private ExoPlaybackException pendingPlaybackException; protected DecoderCounters decoderCounters; private long outputStreamStartPositionUs; @@ -412,10 +413,24 @@ public void setRenderTimeLimitMs(long renderTimeLimitMs) { *

    This method is experimental, and will be renamed or removed in a future release. It should * only be called before the renderer is used. */ - public void experimentalEnableAsynchronousBufferQueueing(boolean enabled) { + public void experimentalSetAsynchronousBufferQueueingEnabled(boolean enabled) { enableAsynchronousBufferQueueing = enabled; } + /** + * Enable synchronizing codec interactions with asynchronous buffer queueing. + * + *

    When enabled, codec interactions will wait until all input buffers pending for asynchronous + * queueing are submitted to the {@link MediaCodec} first. This method is effective only if {@link + * #experimentalSetAsynchronousBufferQueueingEnabled asynchronous buffer queueing} is enabled. + * + *

    This method is experimental, and will be renamed or removed in a future release. It should + * only be called before the renderer is used. + */ + public void experimentalSetSynchronizeCodecInteractionsWithQueueingEnabled(boolean enabled) { + enableSynchronizeCodecInteractionsWithQueueing = enabled; + } + @Override @AdaptiveSupport public final int supportsMixedMimeTypeAdaptation() { @@ -1051,7 +1066,9 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce TraceUtil.beginSection("createCodec:" + codecName); MediaCodec codec = MediaCodec.createByCodecName(codecName); if (enableAsynchronousBufferQueueing && Util.SDK_INT >= 23) { - codecAdapter = new AsynchronousMediaCodecAdapter(codec, getTrackType()); + codecAdapter = + new AsynchronousMediaCodecAdapter( + codec, getTrackType(), enableSynchronizeCodecInteractionsWithQueueing); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec); } 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 60e9c8b77f0..8874d5ec7c3 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 @@ -46,7 +46,12 @@ public void setUp() throws IOException { codec = MediaCodec.createByCodecName("h264"); callbackThread = new HandlerThread("TestCallbackThread"); queueingThread = new HandlerThread("TestQueueingThread"); - adapter = new AsynchronousMediaCodecAdapter(codec, callbackThread, queueingThread); + adapter = + new AsynchronousMediaCodecAdapter( + codec, + callbackThread, + queueingThread, + /* synchronizeCodecInteractionsWithQueueing= */ false); bufferInfo = new MediaCodec.BufferInfo(); } 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 9e2c715b314..f3a08df819d 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 @@ -219,6 +219,28 @@ public void shutdown_onInterruptedException_throwsIllegalStateException() assertThrows(IllegalStateException.class, () -> enqueuer.shutdown()); } + private static CryptoInfo createCryptoInfo() { + CryptoInfo info = new CryptoInfo(); + int numSubSamples = 5; + int[] numBytesOfClearData = new int[] {0, 1, 2, 3}; + int[] numBytesOfEncryptedData = new int[] {4, 5, 6, 7}; + byte[] key = new byte[] {0, 1, 2, 3}; + byte[] iv = new byte[] {4, 5, 6, 7}; + @C.CryptoMode int mode = C.CRYPTO_MODE_AES_CBC; + int encryptedBlocks = 16; + int clearBlocks = 8; + info.set( + numSubSamples, + numBytesOfClearData, + numBytesOfEncryptedData, + key, + iv, + mode, + encryptedBlocks, + clearBlocks); + return info; + } + private static class TestHandlerThread extends HandlerThread { private boolean started; private boolean quit; @@ -247,26 +269,4 @@ public boolean quit() { return super.quit(); } } - - private static CryptoInfo createCryptoInfo() { - CryptoInfo info = new CryptoInfo(); - int numSubSamples = 5; - int[] numBytesOfClearData = new int[] {0, 1, 2, 3}; - int[] numBytesOfEncryptedData = new int[] {4, 5, 6, 7}; - byte[] key = new byte[] {0, 1, 2, 3}; - byte[] iv = new byte[] {4, 5, 6, 7}; - @C.CryptoMode int mode = C.CRYPTO_MODE_AES_CBC; - int encryptedBlocks = 16; - int clearBlocks = 8; - info.set( - numSubSamples, - numBytesOfClearData, - numBytesOfEncryptedData, - key, - iv, - mode, - encryptedBlocks, - clearBlocks); - return info; - } } From 9e98a680da64b06aff3442b18fc69900735a2c30 Mon Sep 17 00:00:00 2001 From: christosts Date: Mon, 9 Nov 2020 18:12:39 +0000 Subject: [PATCH 263/693] Add flag to force synchronization in async queueing Add experiment flag to force synchronization between queueing threads in AsynchronousMediaCodecAdapter. PiperOrigin-RevId: 341431481 --- .../exoplayer2/DefaultRenderersFactory.java | 23 +++++++++++++++++++ .../AsynchronousMediaCodecAdapter.java | 11 +++++++-- .../AsynchronousMediaCodecBufferEnqueuer.java | 19 +++++++++++---- .../mediacodec/MediaCodecRenderer.java | 19 ++++++++++++++- .../AsynchronousMediaCodecAdapterTest.java | 1 + ...nchronousMediaCodecBufferEnqueuerTest.java | 6 ++++- 6 files changed, 71 insertions(+), 8 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 ad589171601..4657922d45d 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 @@ -92,6 +92,7 @@ public class DefaultRenderersFactory implements RenderersFactory { private boolean enableDecoderFallback; private MediaCodecSelector mediaCodecSelector; private boolean enableAsyncQueueing; + private boolean forceAsyncQueueingSynchronizationWorkaround; private boolean enableSynchronizeCodecInteractionsWithQueueing; private boolean enableFloatOutput; private boolean enableAudioTrackPlaybackParams; @@ -161,6 +162,24 @@ public DefaultRenderersFactory experimentalSetAsynchronousBufferQueueingEnabled( return this; } + /** + * Enable the asynchronous queueing synchronization workaround. + * + *

    When enabled, the queueing threads for {@link MediaCodec} instances will synchronize on a + * shared lock when submitting buffers to the respective {@link MediaCodec}. + * + *

    This method is experimental, and will be renamed or removed in a future release. + * + * @param enabled Whether the asynchronous queueing synchronization workaround is enabled by + * default. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory experimentalSetForceAsyncQueueingSynchronizationWorkaround( + boolean enabled) { + this.forceAsyncQueueingSynchronizationWorkaround = enabled; + return this; + } + /** * Enable synchronizing codec interactions with asynchronous buffer queueing. * @@ -353,6 +372,8 @@ protected void buildVideoRenderers( eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); videoRenderer.experimentalSetAsynchronousBufferQueueingEnabled(enableAsyncQueueing); + videoRenderer.experimentalSetForceAsyncQueueingSynchronizationWorkaround( + forceAsyncQueueingSynchronizationWorkaround); videoRenderer.experimentalSetSynchronizeCodecInteractionsWithQueueingEnabled( enableSynchronizeCodecInteractionsWithQueueing); out.add(videoRenderer); @@ -480,6 +501,8 @@ protected void buildAudioRenderers( eventListener, audioSink); audioRenderer.experimentalSetAsynchronousBufferQueueingEnabled(enableAsyncQueueing); + audioRenderer.experimentalSetForceAsyncQueueingSynchronizationWorkaround( + forceAsyncQueueingSynchronizationWorkaround); audioRenderer.experimentalSetSynchronizeCodecInteractionsWithQueueingEnabled( enableSynchronizeCodecInteractionsWithQueueing); out.add(audioRenderer); 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 54ad57cafe2..ef20db46143 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 @@ -72,11 +72,15 @@ * {@link MediaCodec}. */ /* package */ AsynchronousMediaCodecAdapter( - MediaCodec codec, int trackType, boolean synchronizeCodecInteractionsWithQueueing) { + MediaCodec codec, + int trackType, + boolean forceQueueingSynchronizationWorkaround, + boolean synchronizeCodecInteractionsWithQueueing) { this( codec, new HandlerThread(createCallbackThreadLabel(trackType)), new HandlerThread(createQueueingThreadLabel(trackType)), + forceQueueingSynchronizationWorkaround, synchronizeCodecInteractionsWithQueueing); } @@ -85,10 +89,13 @@ MediaCodec codec, HandlerThread callbackThread, HandlerThread enqueueingThread, + boolean forceQueueingSynchronizationWorkaround, boolean synchronizeCodecInteractionsWithQueueing) { this.codec = codec; this.asynchronousMediaCodecCallback = new AsynchronousMediaCodecCallback(callbackThread); - this.bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, enqueueingThread); + this.bufferEnqueuer = + new AsynchronousMediaCodecBufferEnqueuer( + codec, enqueueingThread, forceQueueingSynchronizationWorkaround); this.synchronizeCodecInteractionsWithQueueing = synchronizeCodecInteractionsWithQueueing; this.state = STATE_CREATED; } 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 79bb9819558..21f79a78a28 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 @@ -68,18 +68,29 @@ class AsynchronousMediaCodecBufferEnqueuer { * @param codec The {@link MediaCodec} to submit input buffers to. * @param queueingThread The {@link HandlerThread} to use for queueing buffers. */ - public AsynchronousMediaCodecBufferEnqueuer(MediaCodec codec, HandlerThread queueingThread) { - this(codec, queueingThread, /* conditionVariable= */ new ConditionVariable()); + public AsynchronousMediaCodecBufferEnqueuer( + MediaCodec codec, + HandlerThread queueingThread, + boolean forceQueueingSynchronizationWorkaround) { + this( + codec, + queueingThread, + forceQueueingSynchronizationWorkaround, + /* conditionVariable= */ new ConditionVariable()); } @VisibleForTesting /* package */ AsynchronousMediaCodecBufferEnqueuer( - MediaCodec codec, HandlerThread handlerThread, ConditionVariable conditionVariable) { + MediaCodec codec, + HandlerThread handlerThread, + boolean forceQueueingSynchronizationWorkaround, + ConditionVariable conditionVariable) { this.codec = codec; this.handlerThread = handlerThread; this.conditionVariable = conditionVariable; pendingRuntimeException = new AtomicReference<>(); - needsSynchronizationWorkaround = needsSynchronizationWorkaround(); + needsSynchronizationWorkaround = + forceQueueingSynchronizationWorkaround || needsSynchronizationWorkaround(); } /** 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 63069b5320d..00914fe0926 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 @@ -348,6 +348,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { private boolean waitingForFirstSampleInFormat; private boolean pendingOutputEndOfStream; private boolean enableAsynchronousBufferQueueing; + private boolean forceAsyncQueueingSynchronizationWorkaround; private boolean enableSynchronizeCodecInteractionsWithQueueing; @Nullable private ExoPlaybackException pendingPlaybackException; protected DecoderCounters decoderCounters; @@ -417,6 +418,19 @@ public void experimentalSetAsynchronousBufferQueueingEnabled(boolean enabled) { enableAsynchronousBufferQueueing = enabled; } + /** + * Enable the asynchronous queueing synchronization workaround. + * + *

    When enabled, the queueing threads for {@link MediaCodec} instance will synchronize on a + * shared lock when submitting buffers to the respective {@link MediaCodec}. + * + *

    This method is experimental, and will be renamed or removed in a future release. It should + * only be called before the renderer is used. + */ + public void experimentalSetForceAsyncQueueingSynchronizationWorkaround(boolean enabled) { + this.forceAsyncQueueingSynchronizationWorkaround = enabled; + } + /** * Enable synchronizing codec interactions with asynchronous buffer queueing. * @@ -1068,7 +1082,10 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce if (enableAsynchronousBufferQueueing && Util.SDK_INT >= 23) { codecAdapter = new AsynchronousMediaCodecAdapter( - codec, getTrackType(), enableSynchronizeCodecInteractionsWithQueueing); + codec, + getTrackType(), + forceAsyncQueueingSynchronizationWorkaround, + enableSynchronizeCodecInteractionsWithQueueing); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec); } 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 8874d5ec7c3..040e8a576b9 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 @@ -51,6 +51,7 @@ public void setUp() throws IOException { codec, callbackThread, queueingThread, + /* forceQueueingSynchronizationWorkaround= */ false, /* synchronizeCodecInteractionsWithQueueing= */ false); bufferInfo = new MediaCodec.BufferInfo(); } 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 f3a08df819d..e5fdd126f41 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 @@ -56,7 +56,11 @@ public void setUp() throws IOException { codec.start(); handlerThread = new TestHandlerThread("TestHandlerThread"); enqueuer = - new AsynchronousMediaCodecBufferEnqueuer(codec, handlerThread, mockConditionVariable); + new AsynchronousMediaCodecBufferEnqueuer( + codec, + handlerThread, + /* forceQueueingSynchronizationWorkaround= */ false, + mockConditionVariable); } @After From f13058942a84fd3b73e982f045c3a7434612f81c Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 10 Nov 2020 09:29:11 +0000 Subject: [PATCH 264/693] Add Robolectric playback tests for existing FLV assets PiperOrigin-RevId: 341573808 --- .../exoplayer2/e2etest/FlvPlaybackTest.java | 74 +++++++++++++++++ .../flv/sample-with-key-frame-index.flv.dump | 74 +++++++++++++++++ .../assets/playbackdumps/flv/sample.flv.dump | 81 +++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlvPlaybackTest.java create mode 100644 testdata/src/test/assets/playbackdumps/flv/sample-with-key-frame-index.flv.dump create mode 100644 testdata/src/test/assets/playbackdumps/flv/sample.flv.dump diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlvPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlvPlaybackTest.java new file mode 100644 index 00000000000..98bba2cc45e --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/FlvPlaybackTest.java @@ -0,0 +1,74 @@ +/* + * 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.e2etest; + +import android.graphics.SurfaceTexture; +import android.view.Surface; +import androidx.test.core.app.ApplicationProvider; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.robolectric.PlaybackOutput; +import com.google.android.exoplayer2.robolectric.ShadowMediaCodecConfig; +import com.google.android.exoplayer2.robolectric.TestPlayerRunHelper; +import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; +import com.google.android.exoplayer2.testutil.DumpFileAsserts; +import com.google.common.collect.ImmutableList; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; +import org.robolectric.annotation.Config; + +/** End-to-end tests using FLV samples. */ +// TODO(b/143232359): Remove once https://issuetracker.google.com/143232359 is resolved. +@Config(sdk = 29) +@RunWith(ParameterizedRobolectricTestRunner.class) +public final class FlvPlaybackTest { + @Parameters(name = "{0}") + public static ImmutableList mediaSamples() { + return ImmutableList.of("sample.flv", "sample-with-key-frame-index.flv"); + } + + @ParameterizedRobolectricTestRunner.Parameter public String inputFile; + + @Rule + public ShadowMediaCodecConfig mediaCodecConfig = + ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + + @Test + public void test() throws Exception { + SimpleExoPlayer player = + new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) + .setClock(new AutoAdvancingFakeClock()) + .build(); + player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, mediaCodecConfig); + + player.setMediaItem(MediaItem.fromUri("asset:///media/flv/" + inputFile)); + player.prepare(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + ApplicationProvider.getApplicationContext(), + playbackOutput, + "playbackdumps/flv/" + inputFile + ".dump"); + } +} diff --git a/testdata/src/test/assets/playbackdumps/flv/sample-with-key-frame-index.flv.dump b/testdata/src/test/assets/playbackdumps/flv/sample-with-key-frame-index.flv.dump new file mode 100644 index 00000000000..a6942b63456 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/flv/sample-with-key-frame-index.flv.dump @@ -0,0 +1,74 @@ +MediaCodec (video/avc): + buffers.length = 72 + buffers[0] = length 747, hash 59AEB08 + buffers[1] = length 117, hash 57A315CB + buffers[2] = length 16, hash 7E8FA845 + buffers[3] = length 13, hash 7AEB9BE0 + buffers[4] = length 16, hash 644DA4CC + buffers[5] = length 200, hash E5BF3A39 + buffers[6] = length 167, hash 74FCA726 + buffers[7] = length 134, hash 3B12FEC0 + buffers[8] = length 264, hash 7D9323C7 + buffers[9] = length 141, hash B5AAF09F + buffers[10] = length 229, hash 181AD475 + buffers[11] = length 279, hash 11A95D98 + buffers[12] = length 39, hash 6F87AFD9 + buffers[13] = length 88, hash 6E7EC1EF + buffers[14] = length 481, hash 82246706 + buffers[15] = length 103, hash A201C852 + buffers[16] = length 85, hash 7D6F33C4 + buffers[17] = length 659, hash 3BF583EF + buffers[18] = length 134, hash 46C97FD9 + buffers[19] = length 153, hash 5E737D26 + buffers[20] = length 652, hash E3151CCE + buffers[21] = length 86, hash A1884AD8 + buffers[22] = length 150, hash 6C7DEF31 + buffers[23] = length 316, hash E7867 + buffers[24] = length 1950, hash 28E6760E + buffers[25] = length 561, hash 8394BDB5 + buffers[26] = length 130, hash B50D0F26 + buffers[27] = length 185, hash 359FC134 + buffers[28] = length 130, hash C53797EC + buffers[29] = length 867, hash B87AD770 + buffers[30] = length 155, hash 73B4B0E7 + buffers[31] = length 168, hash 9C9C9994 + buffers[32] = length 145, hash 2D3F2527 + buffers[33] = length 991, hash 78143488 + buffers[34] = length 174, hash 6C778CE7 + buffers[35] = length 82, hash D605F20D + buffers[36] = length 125, hash 248E8190 + buffers[37] = length 1095, hash 21B08B6C + buffers[38] = length 238, hash AE5854DF + buffers[39] = length 151, hash DF20C082 + buffers[40] = length 45, hash 35165468 + buffers[41] = length 1425, hash D20DA4F0 + buffers[42] = length 67, hash 49E25397 + buffers[43] = length 72, hash EEDD2F83 + buffers[44] = length 1382, hash 6C35D237 + buffers[45] = length 186, hash CDE97917 + buffers[46] = length 80, hash 923EC2C + buffers[47] = length 122, hash EB3EEF54 + buffers[48] = length 4380, hash 9221E054 + buffers[49] = length 819, hash 46A722D6 + buffers[50] = length 140, hash E9AA6D8B + buffers[51] = length 711, hash C49CB26 + buffers[52] = length 111, hash A53830B7 + buffers[53] = length 1062, hash B95BF284 + buffers[54] = length 312, hash 1C667DA3 + buffers[55] = length 132, hash 59C0F906 + buffers[56] = length 149, hash B7F8F4A5 + buffers[57] = length 1040, hash 82D4CCFD + buffers[58] = length 349, hash C1236BA4 + buffers[59] = length 140, hash 67015D1B + buffers[60] = length 186, hash C0DF4AB0 + buffers[61] = length 811, hash EEA6FBFF + buffers[62] = length 356, hash A9847C12 + buffers[63] = length 156, hash 61E72801 + buffers[64] = length 212, hash F1F7EBE8 + buffers[65] = length 681, hash FA95355F + buffers[66] = length 351, hash F771D4DD + buffers[67] = length 174, hash 325A7512 + buffers[68] = length 199, hash A22FAA40 + buffers[69] = length 322, hash C494E5A7 + buffers[70] = length 171, hash 2DA9ECEC + buffers[71] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/flv/sample.flv.dump b/testdata/src/test/assets/playbackdumps/flv/sample.flv.dump new file mode 100644 index 00000000000..d65c8edaf1f --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/flv/sample.flv.dump @@ -0,0 +1,81 @@ +MediaCodec (audio/mp4a-latm): + buffers.length = 46 + buffers[0] = length 23, hash 47DE9131 + buffers[1] = length 6, hash 31EC5206 + buffers[2] = length 148, hash 894A176B + buffers[3] = length 189, hash CEF235A1 + buffers[4] = length 205, hash BBF5F7B0 + buffers[5] = length 210, hash F278B193 + buffers[6] = length 210, hash 82DA1589 + buffers[7] = length 207, hash 5BE231DF + buffers[8] = length 225, hash 18819EE1 + buffers[9] = length 215, hash CA7FA67B + buffers[10] = length 211, hash 581A1C18 + buffers[11] = length 216, hash ADB88187 + buffers[12] = length 229, hash 2E8BA4DC + buffers[13] = length 232, hash 22F0C510 + buffers[14] = length 235, hash 867AD0DC + buffers[15] = length 231, hash 84E823A8 + buffers[16] = length 226, hash 1BEF3A95 + buffers[17] = length 216, hash EAA345AE + buffers[18] = length 229, hash 6957411F + buffers[19] = length 219, hash 41275022 + buffers[20] = length 241, hash 6495DF96 + buffers[21] = length 228, hash 63D95906 + buffers[22] = length 238, hash 34F676F9 + buffers[23] = length 234, hash E5CBC045 + buffers[24] = length 231, hash 5FC43661 + buffers[25] = length 217, hash 682708ED + buffers[26] = length 239, hash D43780FC + buffers[27] = length 243, hash C5E17980 + buffers[28] = length 231, hash AC5837BA + buffers[29] = length 230, hash 169EE895 + buffers[30] = length 238, hash C48FF3F1 + buffers[31] = length 225, hash 531E4599 + buffers[32] = length 232, hash CB3C6B8D + buffers[33] = length 243, hash F8C94C7 + buffers[34] = length 232, hash A646A7D0 + buffers[35] = length 237, hash E8B787A5 + buffers[36] = length 228, hash 3FA7A29F + buffers[37] = length 235, hash B9B33B0A + buffers[38] = length 264, hash 71A4869E + buffers[39] = length 257, hash D049B54C + buffers[40] = length 227, hash 66757231 + buffers[41] = length 227, hash BD374F1B + buffers[42] = length 235, hash 999477F6 + buffers[43] = length 229, hash FFF98DF0 + buffers[44] = length 6, hash 31B22286 + buffers[45] = length 0, hash 1 +MediaCodec (video/avc): + buffers.length = 31 + buffers[0] = length 36477, hash F0F36CFE + buffers[1] = length 5341, hash 40B85E2 + buffers[2] = length 596, hash 357B4D92 + buffers[3] = length 7704, hash A39EDA06 + buffers[4] = length 989, hash 2813C72D + buffers[5] = length 721, hash C50D1C73 + buffers[6] = length 519, hash 65FE1911 + buffers[7] = length 6160, hash E1CAC0EC + buffers[8] = length 953, hash 7160C661 + buffers[9] = length 620, hash 7A7AE07C + buffers[10] = length 405, hash 5CC7F4E7 + buffers[11] = length 4852, hash 9DB6979D + buffers[12] = length 547, hash E31A6979 + buffers[13] = length 570, hash FEC40D00 + buffers[14] = length 5525, hash 7C478F7E + buffers[15] = length 1082, hash DA07059A + buffers[16] = length 807, hash 93478E6B + buffers[17] = length 744, hash 9A8E6026 + buffers[18] = length 4732, hash C73B23C0 + buffers[19] = length 1004, hash 8A19A228 + buffers[20] = length 794, hash 8126022C + buffers[21] = length 645, hash F08300E5 + buffers[22] = length 2684, hash 727FE378 + buffers[23] = length 787, hash 419A7821 + buffers[24] = length 649, hash 5C159346 + buffers[25] = length 509, hash F912D655 + buffers[26] = length 1226, hash 29815C21 + buffers[27] = length 898, hash D997AD0A + buffers[28] = length 476, hash A0423645 + buffers[29] = length 486, hash DDF32CBB + buffers[30] = length 0, hash 1 From 337c412d2b550e8282d1f9be2a561e9cb0e52401 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 10 Nov 2020 09:29:30 +0000 Subject: [PATCH 265/693] Add multi-channel audio samples to TsPlaybackTest Multi-channel audio is now supported by Robolectric: https://github.com/robolectric/robolectric/commit/9d84ceb6d588d5f53be29b PiperOrigin-RevId: 341573838 --- .../exoplayer2/e2etest/TsPlaybackTest.java | 8 ++- .../assets/playbackdumps/ts/sample.ac3.dump | 11 +++ .../assets/playbackdumps/ts/sample.eac3.dump | 57 ++++++++++++++++ .../playbackdumps/ts/sample_ac3.ts.dump | 11 +++ .../playbackdumps/ts/sample_eac3.ts.dump | 57 ++++++++++++++++ .../playbackdumps/ts/sample_eac3joc.ec3.dump | 67 +++++++++++++++++++ .../playbackdumps/ts/sample_eac3joc.ts.dump | 67 +++++++++++++++++++ 7 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample.ac3.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample.eac3.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_ac3.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_eac3.ts.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ec3.dump create mode 100644 testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ts.dump diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java index 2407631b90e..c57a443f474 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java @@ -41,15 +41,19 @@ @RunWith(ParameterizedRobolectricTestRunner.class) public class TsPlaybackTest { - // TODO: Add samples with >2 audio channels when supported (sample.ac3, sample_ac3.ts, - // sample.eac3, sample_eac3joc.ec3, sample_eac3joc.ts, sample_eac3.ts). @Parameters(name = "{0}") public static ImmutableList mediaSamples() { return ImmutableList.of( "bbb_2500ms.ts", "elephants_dream.mpg", + "sample.ac3", + "sample_ac3.ts", "sample.ac4", "sample_ac4.ts", + "sample.eac3", + "sample_eac3.ts", + "sample_eac3joc.ec3", + "sample_eac3joc.ts", "sample.adts", "sample_ait.ts", "sample_cbs_truncated.adts", diff --git a/testdata/src/test/assets/playbackdumps/ts/sample.ac3.dump b/testdata/src/test/assets/playbackdumps/ts/sample.ac3.dump new file mode 100644 index 00000000000..5d6268356f6 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample.ac3.dump @@ -0,0 +1,11 @@ +MediaCodec (audio/ac3): + buffers.length = 9 + buffers[0] = length 1536, hash 7108D5C2 + buffers[1] = length 1536, hash 80BF3B34 + buffers[2] = length 1536, hash 5D09685 + buffers[3] = length 1536, hash A9A24E44 + buffers[4] = length 1536, hash 6F856273 + buffers[5] = length 1536, hash B1737D3C + buffers[6] = length 1536, hash 98FDEB9D + buffers[7] = length 1536, hash 99B9B943 + buffers[8] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample.eac3.dump b/testdata/src/test/assets/playbackdumps/ts/sample.eac3.dump new file mode 100644 index 00000000000..64f09a752bc --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample.eac3.dump @@ -0,0 +1,57 @@ +MediaCodec (audio/eac3): + buffers.length = 55 + buffers[0] = length 4000, hash BAEAFB2A + buffers[1] = length 4000, hash E3C5EBF0 + buffers[2] = length 4000, hash 32E0F957 + buffers[3] = length 4000, hash 5354CC5D + buffers[4] = length 4000, hash FF834906 + buffers[5] = length 4000, hash 6F571E61 + buffers[6] = length 4000, hash 5C931F6B + buffers[7] = length 4000, hash B1FB2E57 + buffers[8] = length 4000, hash C71240EB + buffers[9] = length 4000, hash C3E302EE + buffers[10] = length 4000, hash 7994C27B + buffers[11] = length 4000, hash 1ED4E6F3 + buffers[12] = length 4000, hash 1D5E6AAC + buffers[13] = length 4000, hash 30058F51 + buffers[14] = length 4000, hash 15DD0E4A + buffers[15] = length 4000, hash 37BE7C15 + buffers[16] = length 4000, hash 7CFDD34B + buffers[17] = length 4000, hash 27F20D29 + buffers[18] = length 4000, hash 6F565894 + buffers[19] = length 4000, hash A6F07C4A + buffers[20] = length 4000, hash 3A0CA15C + buffers[21] = length 4000, hash DB365414 + buffers[22] = length 4000, hash 31E08469 + buffers[23] = length 4000, hash 315F5C28 + buffers[24] = length 4000, hash CC65DF80 + buffers[25] = length 4000, hash 503FB64C + buffers[26] = length 4000, hash 817CF735 + buffers[27] = length 4000, hash 37391ADA + buffers[28] = length 4000, hash 37391ADA + buffers[29] = length 4000, hash 64DBF751 + buffers[30] = length 4000, hash 81AE828E + buffers[31] = length 4000, hash 767D6C98 + buffers[32] = length 4000, hash A5F6D4E + buffers[33] = length 4000, hash EABC6B0D + buffers[34] = length 4000, hash F47EF742 + buffers[35] = length 4000, hash 9B2549DA + buffers[36] = length 4000, hash A12733C9 + buffers[37] = length 4000, hash 95F62E99 + buffers[38] = length 4000, hash A4D858 + buffers[39] = length 4000, hash A4D858 + buffers[40] = length 4000, hash 22C1A129 + buffers[41] = length 4000, hash 2C51E4A1 + buffers[42] = length 4000, hash 3782E8BB + buffers[43] = length 4000, hash 2C51E4A1 + buffers[44] = length 4000, hash BDB3D129 + buffers[45] = length 4000, hash F642A55 + buffers[46] = length 4000, hash 32F259F4 + buffers[47] = length 4000, hash 4C987B7C + buffers[48] = length 4000, hash 57C98E1C + buffers[49] = length 4000, hash 4C987B7C + buffers[50] = length 4000, hash 4C987B7C + buffers[51] = length 4000, hash 4C987B7C + buffers[52] = length 4000, hash 4C987B7C + buffers[53] = length 4000, hash 4C987B7C + buffers[54] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_ac3.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_ac3.ts.dump new file mode 100644 index 00000000000..5d6268356f6 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_ac3.ts.dump @@ -0,0 +1,11 @@ +MediaCodec (audio/ac3): + buffers.length = 9 + buffers[0] = length 1536, hash 7108D5C2 + buffers[1] = length 1536, hash 80BF3B34 + buffers[2] = length 1536, hash 5D09685 + buffers[3] = length 1536, hash A9A24E44 + buffers[4] = length 1536, hash 6F856273 + buffers[5] = length 1536, hash B1737D3C + buffers[6] = length 1536, hash 98FDEB9D + buffers[7] = length 1536, hash 99B9B943 + buffers[8] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_eac3.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_eac3.ts.dump new file mode 100644 index 00000000000..64f09a752bc --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_eac3.ts.dump @@ -0,0 +1,57 @@ +MediaCodec (audio/eac3): + buffers.length = 55 + buffers[0] = length 4000, hash BAEAFB2A + buffers[1] = length 4000, hash E3C5EBF0 + buffers[2] = length 4000, hash 32E0F957 + buffers[3] = length 4000, hash 5354CC5D + buffers[4] = length 4000, hash FF834906 + buffers[5] = length 4000, hash 6F571E61 + buffers[6] = length 4000, hash 5C931F6B + buffers[7] = length 4000, hash B1FB2E57 + buffers[8] = length 4000, hash C71240EB + buffers[9] = length 4000, hash C3E302EE + buffers[10] = length 4000, hash 7994C27B + buffers[11] = length 4000, hash 1ED4E6F3 + buffers[12] = length 4000, hash 1D5E6AAC + buffers[13] = length 4000, hash 30058F51 + buffers[14] = length 4000, hash 15DD0E4A + buffers[15] = length 4000, hash 37BE7C15 + buffers[16] = length 4000, hash 7CFDD34B + buffers[17] = length 4000, hash 27F20D29 + buffers[18] = length 4000, hash 6F565894 + buffers[19] = length 4000, hash A6F07C4A + buffers[20] = length 4000, hash 3A0CA15C + buffers[21] = length 4000, hash DB365414 + buffers[22] = length 4000, hash 31E08469 + buffers[23] = length 4000, hash 315F5C28 + buffers[24] = length 4000, hash CC65DF80 + buffers[25] = length 4000, hash 503FB64C + buffers[26] = length 4000, hash 817CF735 + buffers[27] = length 4000, hash 37391ADA + buffers[28] = length 4000, hash 37391ADA + buffers[29] = length 4000, hash 64DBF751 + buffers[30] = length 4000, hash 81AE828E + buffers[31] = length 4000, hash 767D6C98 + buffers[32] = length 4000, hash A5F6D4E + buffers[33] = length 4000, hash EABC6B0D + buffers[34] = length 4000, hash F47EF742 + buffers[35] = length 4000, hash 9B2549DA + buffers[36] = length 4000, hash A12733C9 + buffers[37] = length 4000, hash 95F62E99 + buffers[38] = length 4000, hash A4D858 + buffers[39] = length 4000, hash A4D858 + buffers[40] = length 4000, hash 22C1A129 + buffers[41] = length 4000, hash 2C51E4A1 + buffers[42] = length 4000, hash 3782E8BB + buffers[43] = length 4000, hash 2C51E4A1 + buffers[44] = length 4000, hash BDB3D129 + buffers[45] = length 4000, hash F642A55 + buffers[46] = length 4000, hash 32F259F4 + buffers[47] = length 4000, hash 4C987B7C + buffers[48] = length 4000, hash 57C98E1C + buffers[49] = length 4000, hash 4C987B7C + buffers[50] = length 4000, hash 4C987B7C + buffers[51] = length 4000, hash 4C987B7C + buffers[52] = length 4000, hash 4C987B7C + buffers[53] = length 4000, hash 4C987B7C + buffers[54] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ec3.dump b/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ec3.dump new file mode 100644 index 00000000000..6b2dc20c4f5 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ec3.dump @@ -0,0 +1,67 @@ +MediaCodec (audio/eac3-joc): + buffers.length = 65 + buffers[0] = length 2560, hash 882594AD + buffers[1] = length 2560, hash 41EC8B22 + buffers[2] = length 2560, hash 67E6EFD4 + buffers[3] = length 2560, hash A7E66AFD + buffers[4] = length 2560, hash 3924116 + buffers[5] = length 2560, hash 64DCE40B + buffers[6] = length 2560, hash F2E0DA64 + buffers[7] = length 2560, hash C156258B + buffers[8] = length 2560, hash D8DBDCDE + buffers[9] = length 2560, hash C11B2F25 + buffers[10] = length 2560, hash B3C5612 + buffers[11] = length 2560, hash A94B15D0 + buffers[12] = length 2560, hash 12E4E306 + buffers[13] = length 2560, hash 11CB959F + buffers[14] = length 2560, hash B6433844 + buffers[15] = length 2560, hash EA6DEB89 + buffers[16] = length 2560, hash 6D65CBD9 + buffers[17] = length 2560, hash A5D635C5 + buffers[18] = length 2560, hash 992E36AB + buffers[19] = length 2560, hash 1EC4E5AF + buffers[20] = length 2560, hash DCFEB7D2 + buffers[21] = length 2560, hash 45EFC639 + buffers[22] = length 2560, hash F598673 + buffers[23] = length 2560, hash 89E4E5EC + buffers[24] = length 2560, hash FBE2532B + buffers[25] = length 2560, hash 9CE5F83B + buffers[26] = length 2560, hash 6ED49E2C + buffers[27] = length 2560, hash BC52F8F3 + buffers[28] = length 2560, hash 759203E2 + buffers[29] = length 2560, hash D5D31AE9 + buffers[30] = length 2560, hash 640A24ED + buffers[31] = length 2560, hash 19B52B8B + buffers[32] = length 2560, hash 5DA977C3 + buffers[33] = length 2560, hash 982879DD + buffers[34] = length 2560, hash A7656B9C + buffers[35] = length 2560, hash 445CCC67 + buffers[36] = length 2560, hash ACD5CB5C + buffers[37] = length 2560, hash 175BBF26 + buffers[38] = length 2560, hash DBCBEB0 + buffers[39] = length 2560, hash DA39D991 + buffers[40] = length 2560, hash F08CC8E2 + buffers[41] = length 2560, hash 6B0842D7 + buffers[42] = length 2560, hash 9FE87594 + buffers[43] = length 2560, hash 8E62CE19 + buffers[44] = length 2560, hash 5FDC4084 + buffers[45] = length 2560, hash C32DAEE1 + buffers[46] = length 2560, hash BBEFB568 + buffers[47] = length 2560, hash 20504279 + buffers[48] = length 2560, hash 3B8192D2 + buffers[49] = length 2560, hash 4206B48 + buffers[50] = length 2560, hash B195AB53 + buffers[51] = length 2560, hash 3AA8E25F + buffers[52] = length 2560, hash BC227D7B + buffers[53] = length 2560, hash 6A34F7EA + buffers[54] = length 2560, hash F1E731C4 + buffers[55] = length 2560, hash 9CC406 + buffers[56] = length 2560, hash A1532233 + buffers[57] = length 2560, hash 98E49039 + buffers[58] = length 2560, hash 3F8B6DC0 + buffers[59] = length 2560, hash 4E7BF79F + buffers[60] = length 2560, hash 6DD6F2D7 + buffers[61] = length 2560, hash A05C0EC2 + buffers[62] = length 2560, hash 10C62F30 + buffers[63] = length 2560, hash EE4F848A + buffers[64] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ts.dump b/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ts.dump new file mode 100644 index 00000000000..6b2dc20c4f5 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/ts/sample_eac3joc.ts.dump @@ -0,0 +1,67 @@ +MediaCodec (audio/eac3-joc): + buffers.length = 65 + buffers[0] = length 2560, hash 882594AD + buffers[1] = length 2560, hash 41EC8B22 + buffers[2] = length 2560, hash 67E6EFD4 + buffers[3] = length 2560, hash A7E66AFD + buffers[4] = length 2560, hash 3924116 + buffers[5] = length 2560, hash 64DCE40B + buffers[6] = length 2560, hash F2E0DA64 + buffers[7] = length 2560, hash C156258B + buffers[8] = length 2560, hash D8DBDCDE + buffers[9] = length 2560, hash C11B2F25 + buffers[10] = length 2560, hash B3C5612 + buffers[11] = length 2560, hash A94B15D0 + buffers[12] = length 2560, hash 12E4E306 + buffers[13] = length 2560, hash 11CB959F + buffers[14] = length 2560, hash B6433844 + buffers[15] = length 2560, hash EA6DEB89 + buffers[16] = length 2560, hash 6D65CBD9 + buffers[17] = length 2560, hash A5D635C5 + buffers[18] = length 2560, hash 992E36AB + buffers[19] = length 2560, hash 1EC4E5AF + buffers[20] = length 2560, hash DCFEB7D2 + buffers[21] = length 2560, hash 45EFC639 + buffers[22] = length 2560, hash F598673 + buffers[23] = length 2560, hash 89E4E5EC + buffers[24] = length 2560, hash FBE2532B + buffers[25] = length 2560, hash 9CE5F83B + buffers[26] = length 2560, hash 6ED49E2C + buffers[27] = length 2560, hash BC52F8F3 + buffers[28] = length 2560, hash 759203E2 + buffers[29] = length 2560, hash D5D31AE9 + buffers[30] = length 2560, hash 640A24ED + buffers[31] = length 2560, hash 19B52B8B + buffers[32] = length 2560, hash 5DA977C3 + buffers[33] = length 2560, hash 982879DD + buffers[34] = length 2560, hash A7656B9C + buffers[35] = length 2560, hash 445CCC67 + buffers[36] = length 2560, hash ACD5CB5C + buffers[37] = length 2560, hash 175BBF26 + buffers[38] = length 2560, hash DBCBEB0 + buffers[39] = length 2560, hash DA39D991 + buffers[40] = length 2560, hash F08CC8E2 + buffers[41] = length 2560, hash 6B0842D7 + buffers[42] = length 2560, hash 9FE87594 + buffers[43] = length 2560, hash 8E62CE19 + buffers[44] = length 2560, hash 5FDC4084 + buffers[45] = length 2560, hash C32DAEE1 + buffers[46] = length 2560, hash BBEFB568 + buffers[47] = length 2560, hash 20504279 + buffers[48] = length 2560, hash 3B8192D2 + buffers[49] = length 2560, hash 4206B48 + buffers[50] = length 2560, hash B195AB53 + buffers[51] = length 2560, hash 3AA8E25F + buffers[52] = length 2560, hash BC227D7B + buffers[53] = length 2560, hash 6A34F7EA + buffers[54] = length 2560, hash F1E731C4 + buffers[55] = length 2560, hash 9CC406 + buffers[56] = length 2560, hash A1532233 + buffers[57] = length 2560, hash 98E49039 + buffers[58] = length 2560, hash 3F8B6DC0 + buffers[59] = length 2560, hash 4E7BF79F + buffers[60] = length 2560, hash 6DD6F2D7 + buffers[61] = length 2560, hash A05C0EC2 + buffers[62] = length 2560, hash 10C62F30 + buffers[63] = length 2560, hash EE4F848A + buffers[64] = length 0, hash 1 From 1e776a864b62f198ca43b3f6d496e0901121e364 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 10 Nov 2020 09:30:30 +0000 Subject: [PATCH 266/693] Fix some typos PiperOrigin-RevId: 341573964 --- .../google/android/exoplayer2/offline/DownloadCursor.java | 2 +- .../google/android/exoplayer2/offline/DownloadHelper.java | 6 +++--- .../exoplayer2/extractor/mp4/FragmentedMp4Extractor.java | 4 ++-- .../exoplayer2/source/hls/HlsSampleStreamWrapper.java | 2 +- .../android/exoplayer2/source/hls/HlsMediaSourceTest.java | 4 ++-- .../com/google/android/exoplayer2/testutil/FakeDataSet.java | 2 +- .../google/android/exoplayer2/testutil/FakeTimeline.java | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadCursor.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadCursor.java index a1822fca979..ce0c84d7004 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadCursor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadCursor.java @@ -28,7 +28,7 @@ public interface DownloadCursor extends Closeable { /** * Returns the current position of the cursor in the download set. The value is zero-based. When - * the download set is first returned the cursor will be at positon -1, which is before the first + * the download set is first returned the cursor will be at position -1, which is before the first * download. After the last download is returned another call to next() will leave the cursor past * the last entry, at a position of count(). * 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 19b6389a6ac..0b8a6682002 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 @@ -341,7 +341,7 @@ public static DownloadHelper forMediaItem(Context context, MediaItem mediaItem) * 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 + * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or * SmoothStreaming media items. * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ @@ -370,7 +370,7 @@ public static DownloadHelper forMediaItem( * 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 + * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or * SmoothStreaming media items. * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ @@ -401,7 +401,7 @@ public static DownloadHelper forMediaItem( * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which * tracks can be selected. * @return A {@link DownloadHelper}. - * @throws IllegalStateException If the the corresponding module is missing for DASH, HLS or + * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or * SmoothStreaming media items. * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ 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 859ce49b26f..32d3bc0677d 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 @@ -1051,7 +1051,7 @@ private static int parseTrun( private static int checkNonNegative(int value) throws ParserException { if (value < 0) { - throw new ParserException("Unexpected negtive value: " + value); + throw new ParserException("Unexpected negative value: " + value); } return value; } @@ -1659,7 +1659,7 @@ public int getCurrentSampleSize() { : fragment.sampleSizeTable[currentSampleIndex]; } - /** Returns the {@link C.BufferFlags} corresponding to the the current sample. */ + /** Returns the {@link C.BufferFlags} corresponding to the current sample. */ @C.BufferFlags public int getCurrentSampleFlags() { int flags = 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 7f1af4496f9..01f03e9d988 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 @@ -974,7 +974,7 @@ public TrackOutput track(int id, int type) { * * @param id The ID of the track. * @param type The type of the track, must be one of {@link #MAPPABLE_TYPES}. - * @return The the mapped {@link TrackOutput}, or null if it's not been created yet. + * @return The mapped {@link TrackOutput}, or null if it's not been created yet. */ @Nullable private TrackOutput getMappedTrackOutput(int id, int type) { 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 index e904425d3eb..4194c0b4d0a 100644 --- 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 @@ -171,7 +171,7 @@ public void loadPlaylist_noTargetLiveOffsetDefined_fallbackToThreeTargetDuration + "#EXTINF:4.00000,\n" + "fileSequence3.ts\n" + "#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=24"; - // The playlist finishes 1 second before the the current time, therefore there's a live edge + // The playlist finishes 1 second before the current time, therefore there's a live edge // offset of 1 second. SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:17.0+00:00")); HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); @@ -277,7 +277,7 @@ public void loadPlaylist_holdBackInPlaylist_targetLiveOffsetFromHoldBack() + "fileSequence0.ts\n" + "#EXT-X-PART-INF:PART-TARGET=0.5\n" + "#EXT-X-SERVER-CONTROL:HOLD-BACK=12,PART-HOLD-BACK=3"; - // The playlist finishes 1 second before the the current time. + // The playlist finishes 1 second before the current time. SystemClock.setCurrentTimeMillis(Util.parseXsDateTime("2020-01-01T00:00:05.0+00:00")); HlsMediaSource.Factory factory = createHlsMediaSourceFactory(playlistUri, playlist); MediaItem mediaItem = MediaItem.fromUri(playlistUri); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java index 13cc6b6ae03..550220a563c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeDataSet.java @@ -213,7 +213,7 @@ public List getSegments() { return segments; } - /** Retuns whether unknown length is simulated */ + /** Returns whether unknown length is simulated */ public boolean isSimulatingUnknownLength() { return simulateUnknownLength; } 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 3fb29f284d5..122593b48bd 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 @@ -256,7 +256,7 @@ public static AdPlaybackState createAdPlaybackState(int adsPerAdGroup, long... a adPlaybackState.withAdUri( /* adGroupIndex= */ i, /* adIndexInAdGroup= */ j, - Uri.parse("https://ad/" + i + "/" + j)); + Uri.parse("https://example.com/ad/" + i + "/" + j)); } adDurationsUs[i] = new long[adsPerAdGroup]; Arrays.fill(adDurationsUs[i], AD_DURATION_US); From 363693d8ecb5c1ee208aae22959892d106b18413 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 10 Nov 2020 09:34:29 +0000 Subject: [PATCH 267/693] Add multi-channel audio samples to Mp4PlaybackTest Robolectric now supports multi-channel audio: https://github.com/robolectric/robolectric/commit/9d84ceb6d588d5f53be29b PiperOrigin-RevId: 341574417 --- .../exoplayer2/e2etest/Mp4PlaybackTest.java | 9 ++- .../playbackdumps/mp4/sample_ac3.mp4.dump | 12 ++++ .../mp4/sample_ac3_fragmented.mp4.dump | 12 ++++ .../playbackdumps/mp4/sample_eac3.mp4.dump | 57 ++++++++++++++++ .../mp4/sample_eac3_fragmented.mp4.dump | 57 ++++++++++++++++ .../playbackdumps/mp4/sample_eac3joc.mp4.dump | 67 +++++++++++++++++++ .../mp4/sample_eac3joc_fragmented.mp4.dump | 67 +++++++++++++++++++ 7 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 testdata/src/test/assets/playbackdumps/mp4/sample_ac3.mp4.dump create mode 100644 testdata/src/test/assets/playbackdumps/mp4/sample_ac3_fragmented.mp4.dump create mode 100644 testdata/src/test/assets/playbackdumps/mp4/sample_eac3.mp4.dump create mode 100644 testdata/src/test/assets/playbackdumps/mp4/sample_eac3_fragmented.mp4.dump create mode 100644 testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc.mp4.dump create mode 100644 testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc_fragmented.mp4.dump diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java index 499aa3105de..fc12ce6d1fb 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java @@ -42,18 +42,21 @@ @RunWith(ParameterizedRobolectricTestRunner.class) public class Mp4PlaybackTest { - // TODO: Add samples with >2 audio channels when supported (sample_ac3_fragmented.mp4, - // sample_ac3.mp4sample_eac3.mp4, sample_eac3_fragmented.mp4, sample_eac3joc.mp4, - // sample_eac3joc_fragmented.mp4). @Parameters(name = "{0}") public static ImmutableList mediaSamples() { return ImmutableList.of( "midroll-5s.mp4", "postroll-5s.mp4", "preroll-5s.mp4", + "sample_ac3_fragmented.mp4", + "sample_ac3.mp4", "sample_ac4_fragmented.mp4", "sample_ac4.mp4", "sample_android_slow_motion.mp4", + "sample_eac3_fragmented.mp4", + "sample_eac3.mp4", + "sample_eac3joc_fragmented.mp4", + "sample_eac3joc.mp4", "sample_fragmented.mp4", "sample_fragmented_seekable.mp4", "sample_fragmented_sei.mp4", diff --git a/testdata/src/test/assets/playbackdumps/mp4/sample_ac3.mp4.dump b/testdata/src/test/assets/playbackdumps/mp4/sample_ac3.mp4.dump new file mode 100644 index 00000000000..ef247edfee1 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mp4/sample_ac3.mp4.dump @@ -0,0 +1,12 @@ +MediaCodec (audio/ac3): + buffers.length = 10 + buffers[0] = length 1536, hash 7108D5C2 + buffers[1] = length 1536, hash 80BF3B34 + buffers[2] = length 1536, hash 5D09685 + buffers[3] = length 1536, hash A9A24E44 + buffers[4] = length 1536, hash 6F856273 + buffers[5] = length 1536, hash B1737D3C + buffers[6] = length 1536, hash 98FDEB9D + buffers[7] = length 1536, hash 99B9B943 + buffers[8] = length 1536, hash AAD9FCD2 + buffers[9] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mp4/sample_ac3_fragmented.mp4.dump b/testdata/src/test/assets/playbackdumps/mp4/sample_ac3_fragmented.mp4.dump new file mode 100644 index 00000000000..ef247edfee1 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mp4/sample_ac3_fragmented.mp4.dump @@ -0,0 +1,12 @@ +MediaCodec (audio/ac3): + buffers.length = 10 + buffers[0] = length 1536, hash 7108D5C2 + buffers[1] = length 1536, hash 80BF3B34 + buffers[2] = length 1536, hash 5D09685 + buffers[3] = length 1536, hash A9A24E44 + buffers[4] = length 1536, hash 6F856273 + buffers[5] = length 1536, hash B1737D3C + buffers[6] = length 1536, hash 98FDEB9D + buffers[7] = length 1536, hash 99B9B943 + buffers[8] = length 1536, hash AAD9FCD2 + buffers[9] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mp4/sample_eac3.mp4.dump b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3.mp4.dump new file mode 100644 index 00000000000..64f09a752bc --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3.mp4.dump @@ -0,0 +1,57 @@ +MediaCodec (audio/eac3): + buffers.length = 55 + buffers[0] = length 4000, hash BAEAFB2A + buffers[1] = length 4000, hash E3C5EBF0 + buffers[2] = length 4000, hash 32E0F957 + buffers[3] = length 4000, hash 5354CC5D + buffers[4] = length 4000, hash FF834906 + buffers[5] = length 4000, hash 6F571E61 + buffers[6] = length 4000, hash 5C931F6B + buffers[7] = length 4000, hash B1FB2E57 + buffers[8] = length 4000, hash C71240EB + buffers[9] = length 4000, hash C3E302EE + buffers[10] = length 4000, hash 7994C27B + buffers[11] = length 4000, hash 1ED4E6F3 + buffers[12] = length 4000, hash 1D5E6AAC + buffers[13] = length 4000, hash 30058F51 + buffers[14] = length 4000, hash 15DD0E4A + buffers[15] = length 4000, hash 37BE7C15 + buffers[16] = length 4000, hash 7CFDD34B + buffers[17] = length 4000, hash 27F20D29 + buffers[18] = length 4000, hash 6F565894 + buffers[19] = length 4000, hash A6F07C4A + buffers[20] = length 4000, hash 3A0CA15C + buffers[21] = length 4000, hash DB365414 + buffers[22] = length 4000, hash 31E08469 + buffers[23] = length 4000, hash 315F5C28 + buffers[24] = length 4000, hash CC65DF80 + buffers[25] = length 4000, hash 503FB64C + buffers[26] = length 4000, hash 817CF735 + buffers[27] = length 4000, hash 37391ADA + buffers[28] = length 4000, hash 37391ADA + buffers[29] = length 4000, hash 64DBF751 + buffers[30] = length 4000, hash 81AE828E + buffers[31] = length 4000, hash 767D6C98 + buffers[32] = length 4000, hash A5F6D4E + buffers[33] = length 4000, hash EABC6B0D + buffers[34] = length 4000, hash F47EF742 + buffers[35] = length 4000, hash 9B2549DA + buffers[36] = length 4000, hash A12733C9 + buffers[37] = length 4000, hash 95F62E99 + buffers[38] = length 4000, hash A4D858 + buffers[39] = length 4000, hash A4D858 + buffers[40] = length 4000, hash 22C1A129 + buffers[41] = length 4000, hash 2C51E4A1 + buffers[42] = length 4000, hash 3782E8BB + buffers[43] = length 4000, hash 2C51E4A1 + buffers[44] = length 4000, hash BDB3D129 + buffers[45] = length 4000, hash F642A55 + buffers[46] = length 4000, hash 32F259F4 + buffers[47] = length 4000, hash 4C987B7C + buffers[48] = length 4000, hash 57C98E1C + buffers[49] = length 4000, hash 4C987B7C + buffers[50] = length 4000, hash 4C987B7C + buffers[51] = length 4000, hash 4C987B7C + buffers[52] = length 4000, hash 4C987B7C + buffers[53] = length 4000, hash 4C987B7C + buffers[54] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mp4/sample_eac3_fragmented.mp4.dump b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3_fragmented.mp4.dump new file mode 100644 index 00000000000..64f09a752bc --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3_fragmented.mp4.dump @@ -0,0 +1,57 @@ +MediaCodec (audio/eac3): + buffers.length = 55 + buffers[0] = length 4000, hash BAEAFB2A + buffers[1] = length 4000, hash E3C5EBF0 + buffers[2] = length 4000, hash 32E0F957 + buffers[3] = length 4000, hash 5354CC5D + buffers[4] = length 4000, hash FF834906 + buffers[5] = length 4000, hash 6F571E61 + buffers[6] = length 4000, hash 5C931F6B + buffers[7] = length 4000, hash B1FB2E57 + buffers[8] = length 4000, hash C71240EB + buffers[9] = length 4000, hash C3E302EE + buffers[10] = length 4000, hash 7994C27B + buffers[11] = length 4000, hash 1ED4E6F3 + buffers[12] = length 4000, hash 1D5E6AAC + buffers[13] = length 4000, hash 30058F51 + buffers[14] = length 4000, hash 15DD0E4A + buffers[15] = length 4000, hash 37BE7C15 + buffers[16] = length 4000, hash 7CFDD34B + buffers[17] = length 4000, hash 27F20D29 + buffers[18] = length 4000, hash 6F565894 + buffers[19] = length 4000, hash A6F07C4A + buffers[20] = length 4000, hash 3A0CA15C + buffers[21] = length 4000, hash DB365414 + buffers[22] = length 4000, hash 31E08469 + buffers[23] = length 4000, hash 315F5C28 + buffers[24] = length 4000, hash CC65DF80 + buffers[25] = length 4000, hash 503FB64C + buffers[26] = length 4000, hash 817CF735 + buffers[27] = length 4000, hash 37391ADA + buffers[28] = length 4000, hash 37391ADA + buffers[29] = length 4000, hash 64DBF751 + buffers[30] = length 4000, hash 81AE828E + buffers[31] = length 4000, hash 767D6C98 + buffers[32] = length 4000, hash A5F6D4E + buffers[33] = length 4000, hash EABC6B0D + buffers[34] = length 4000, hash F47EF742 + buffers[35] = length 4000, hash 9B2549DA + buffers[36] = length 4000, hash A12733C9 + buffers[37] = length 4000, hash 95F62E99 + buffers[38] = length 4000, hash A4D858 + buffers[39] = length 4000, hash A4D858 + buffers[40] = length 4000, hash 22C1A129 + buffers[41] = length 4000, hash 2C51E4A1 + buffers[42] = length 4000, hash 3782E8BB + buffers[43] = length 4000, hash 2C51E4A1 + buffers[44] = length 4000, hash BDB3D129 + buffers[45] = length 4000, hash F642A55 + buffers[46] = length 4000, hash 32F259F4 + buffers[47] = length 4000, hash 4C987B7C + buffers[48] = length 4000, hash 57C98E1C + buffers[49] = length 4000, hash 4C987B7C + buffers[50] = length 4000, hash 4C987B7C + buffers[51] = length 4000, hash 4C987B7C + buffers[52] = length 4000, hash 4C987B7C + buffers[53] = length 4000, hash 4C987B7C + buffers[54] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc.mp4.dump b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc.mp4.dump new file mode 100644 index 00000000000..6b2dc20c4f5 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc.mp4.dump @@ -0,0 +1,67 @@ +MediaCodec (audio/eac3-joc): + buffers.length = 65 + buffers[0] = length 2560, hash 882594AD + buffers[1] = length 2560, hash 41EC8B22 + buffers[2] = length 2560, hash 67E6EFD4 + buffers[3] = length 2560, hash A7E66AFD + buffers[4] = length 2560, hash 3924116 + buffers[5] = length 2560, hash 64DCE40B + buffers[6] = length 2560, hash F2E0DA64 + buffers[7] = length 2560, hash C156258B + buffers[8] = length 2560, hash D8DBDCDE + buffers[9] = length 2560, hash C11B2F25 + buffers[10] = length 2560, hash B3C5612 + buffers[11] = length 2560, hash A94B15D0 + buffers[12] = length 2560, hash 12E4E306 + buffers[13] = length 2560, hash 11CB959F + buffers[14] = length 2560, hash B6433844 + buffers[15] = length 2560, hash EA6DEB89 + buffers[16] = length 2560, hash 6D65CBD9 + buffers[17] = length 2560, hash A5D635C5 + buffers[18] = length 2560, hash 992E36AB + buffers[19] = length 2560, hash 1EC4E5AF + buffers[20] = length 2560, hash DCFEB7D2 + buffers[21] = length 2560, hash 45EFC639 + buffers[22] = length 2560, hash F598673 + buffers[23] = length 2560, hash 89E4E5EC + buffers[24] = length 2560, hash FBE2532B + buffers[25] = length 2560, hash 9CE5F83B + buffers[26] = length 2560, hash 6ED49E2C + buffers[27] = length 2560, hash BC52F8F3 + buffers[28] = length 2560, hash 759203E2 + buffers[29] = length 2560, hash D5D31AE9 + buffers[30] = length 2560, hash 640A24ED + buffers[31] = length 2560, hash 19B52B8B + buffers[32] = length 2560, hash 5DA977C3 + buffers[33] = length 2560, hash 982879DD + buffers[34] = length 2560, hash A7656B9C + buffers[35] = length 2560, hash 445CCC67 + buffers[36] = length 2560, hash ACD5CB5C + buffers[37] = length 2560, hash 175BBF26 + buffers[38] = length 2560, hash DBCBEB0 + buffers[39] = length 2560, hash DA39D991 + buffers[40] = length 2560, hash F08CC8E2 + buffers[41] = length 2560, hash 6B0842D7 + buffers[42] = length 2560, hash 9FE87594 + buffers[43] = length 2560, hash 8E62CE19 + buffers[44] = length 2560, hash 5FDC4084 + buffers[45] = length 2560, hash C32DAEE1 + buffers[46] = length 2560, hash BBEFB568 + buffers[47] = length 2560, hash 20504279 + buffers[48] = length 2560, hash 3B8192D2 + buffers[49] = length 2560, hash 4206B48 + buffers[50] = length 2560, hash B195AB53 + buffers[51] = length 2560, hash 3AA8E25F + buffers[52] = length 2560, hash BC227D7B + buffers[53] = length 2560, hash 6A34F7EA + buffers[54] = length 2560, hash F1E731C4 + buffers[55] = length 2560, hash 9CC406 + buffers[56] = length 2560, hash A1532233 + buffers[57] = length 2560, hash 98E49039 + buffers[58] = length 2560, hash 3F8B6DC0 + buffers[59] = length 2560, hash 4E7BF79F + buffers[60] = length 2560, hash 6DD6F2D7 + buffers[61] = length 2560, hash A05C0EC2 + buffers[62] = length 2560, hash 10C62F30 + buffers[63] = length 2560, hash EE4F848A + buffers[64] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc_fragmented.mp4.dump b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc_fragmented.mp4.dump new file mode 100644 index 00000000000..6b2dc20c4f5 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mp4/sample_eac3joc_fragmented.mp4.dump @@ -0,0 +1,67 @@ +MediaCodec (audio/eac3-joc): + buffers.length = 65 + buffers[0] = length 2560, hash 882594AD + buffers[1] = length 2560, hash 41EC8B22 + buffers[2] = length 2560, hash 67E6EFD4 + buffers[3] = length 2560, hash A7E66AFD + buffers[4] = length 2560, hash 3924116 + buffers[5] = length 2560, hash 64DCE40B + buffers[6] = length 2560, hash F2E0DA64 + buffers[7] = length 2560, hash C156258B + buffers[8] = length 2560, hash D8DBDCDE + buffers[9] = length 2560, hash C11B2F25 + buffers[10] = length 2560, hash B3C5612 + buffers[11] = length 2560, hash A94B15D0 + buffers[12] = length 2560, hash 12E4E306 + buffers[13] = length 2560, hash 11CB959F + buffers[14] = length 2560, hash B6433844 + buffers[15] = length 2560, hash EA6DEB89 + buffers[16] = length 2560, hash 6D65CBD9 + buffers[17] = length 2560, hash A5D635C5 + buffers[18] = length 2560, hash 992E36AB + buffers[19] = length 2560, hash 1EC4E5AF + buffers[20] = length 2560, hash DCFEB7D2 + buffers[21] = length 2560, hash 45EFC639 + buffers[22] = length 2560, hash F598673 + buffers[23] = length 2560, hash 89E4E5EC + buffers[24] = length 2560, hash FBE2532B + buffers[25] = length 2560, hash 9CE5F83B + buffers[26] = length 2560, hash 6ED49E2C + buffers[27] = length 2560, hash BC52F8F3 + buffers[28] = length 2560, hash 759203E2 + buffers[29] = length 2560, hash D5D31AE9 + buffers[30] = length 2560, hash 640A24ED + buffers[31] = length 2560, hash 19B52B8B + buffers[32] = length 2560, hash 5DA977C3 + buffers[33] = length 2560, hash 982879DD + buffers[34] = length 2560, hash A7656B9C + buffers[35] = length 2560, hash 445CCC67 + buffers[36] = length 2560, hash ACD5CB5C + buffers[37] = length 2560, hash 175BBF26 + buffers[38] = length 2560, hash DBCBEB0 + buffers[39] = length 2560, hash DA39D991 + buffers[40] = length 2560, hash F08CC8E2 + buffers[41] = length 2560, hash 6B0842D7 + buffers[42] = length 2560, hash 9FE87594 + buffers[43] = length 2560, hash 8E62CE19 + buffers[44] = length 2560, hash 5FDC4084 + buffers[45] = length 2560, hash C32DAEE1 + buffers[46] = length 2560, hash BBEFB568 + buffers[47] = length 2560, hash 20504279 + buffers[48] = length 2560, hash 3B8192D2 + buffers[49] = length 2560, hash 4206B48 + buffers[50] = length 2560, hash B195AB53 + buffers[51] = length 2560, hash 3AA8E25F + buffers[52] = length 2560, hash BC227D7B + buffers[53] = length 2560, hash 6A34F7EA + buffers[54] = length 2560, hash F1E731C4 + buffers[55] = length 2560, hash 9CC406 + buffers[56] = length 2560, hash A1532233 + buffers[57] = length 2560, hash 98E49039 + buffers[58] = length 2560, hash 3F8B6DC0 + buffers[59] = length 2560, hash 4E7BF79F + buffers[60] = length 2560, hash 6DD6F2D7 + buffers[61] = length 2560, hash A05C0EC2 + buffers[62] = length 2560, hash 10C62F30 + buffers[63] = length 2560, hash EE4F848A + buffers[64] = length 0, hash 1 From 4ae0401c34240790ca6c3d47fe88390653cad9e6 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 10 Nov 2020 17:18:02 +0000 Subject: [PATCH 268/693] Work around AudioManager#getStreamVolume crashes #minor-release Issue:#8191 PiperOrigin-RevId: 341632732 --- RELEASENOTES.md | 2 ++ .../android/exoplayer2/StreamVolumeManager.java | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9b070b0fa0b..5f5df4377cc 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -38,6 +38,8 @@ ([#7882](https://github.com/google/ExoPlayer/issues/7882)). * Audio: * Retry playback after some types of `AudioTrack` error. + * Work around `AudioManager` crashes when calling `getStreamVolume` + ([#8191](https://github.com/google/ExoPlayer/issues/8191)). * Extractors: * Matroska: Add support for 32-bit floating point PCM, and 8-bit and 16-bit big endian integer PCM diff --git a/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java b/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java index fa5d316b60b..fe7f8c0f403 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java @@ -188,7 +188,14 @@ private void updateVolumeAndNotifyIfChanged() { } private static int getVolumeFromManager(AudioManager audioManager, @C.StreamType int streamType) { - return audioManager.getStreamVolume(streamType); + // AudioManager#getStreamVolume(int) throws an exception on some devices. See + // https://github.com/google/ExoPlayer/issues/8191. + try { + return audioManager.getStreamVolume(streamType); + } catch (RuntimeException e) { + Log.w(TAG, "Could not retrieve stream volume for stream type " + streamType, e); + return audioManager.getStreamMaxVolume(streamType); + } } private static boolean getMutedFromManager( @@ -196,7 +203,7 @@ private static boolean getMutedFromManager( if (Util.SDK_INT >= 23) { return audioManager.isStreamMute(streamType); } else { - return audioManager.getStreamVolume(streamType) == 0; + return getVolumeFromManager(audioManager, streamType) == 0; } } From b1eef00b80dc8848ce61a8a1d43ab4629c88d9f3 Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 10 Nov 2020 20:01:35 +0000 Subject: [PATCH 269/693] Fix incorrect IntDef usage #minor-release PiperOrigin-RevId: 341668326 --- .../google/android/exoplayer2/ui/spherical/SceneRenderer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java index 5080e863450..674826e3873 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java @@ -54,8 +54,8 @@ private @MonotonicNonNull SurfaceTexture surfaceTexture; // Used by other threads only - private volatile @C.StreamType int defaultStereoMode; - private @C.StreamType int lastStereoMode; + @C.StereoMode private volatile int defaultStereoMode; + @C.StereoMode private int lastStereoMode; @Nullable private byte[] lastProjectionData; // Methods called on any thread. From 6f7c97a7293e8acc7b6be7badef9fb39a091cc07 Mon Sep 17 00:00:00 2001 From: claincly Date: Tue, 10 Nov 2020 23:15:37 +0000 Subject: [PATCH 270/693] Add method to expose the locally opened port for UdpDataSource. PiperOrigin-RevId: 341707809 --- .../android/exoplayer2/upstream/UdpDataSource.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java index 77a2c6ffeed..2da837e788f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java @@ -45,6 +45,8 @@ public UdpDataSourceException(IOException cause) { /** The default socket timeout, in milliseconds. */ public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 8 * 1000; + public static final int UDP_PORT_UNSET = -1; + private final int socketTimeoutMillis; private final byte[] packetBuffer; private final DatagramPacket packet; @@ -169,4 +171,15 @@ public void close() { transferEnded(); } } + + /** + * Returns the local port number opened for the UDP connection, or {@link #UDP_PORT_UNSET} if no + * connection is open + */ + public int getLocalPort() { + if (socket == null) { + return UDP_PORT_UNSET; + } + return socket.getLocalPort(); + } } From 4b1b924cf1ec7b54e8d9e21b5b2d5bd9ffd8740f Mon Sep 17 00:00:00 2001 From: mdobrzyn71 Date: Tue, 6 Oct 2020 10:51:49 -0700 Subject: [PATCH 271/693] Fix for Apple's I-Frame-only stream playback. See bug: https://github.com/google/ExoPlayer/issues/7512 --- .../source/hls/BundledHlsMediaChunkExtractor.java | 5 +++++ .../android/exoplayer2/source/hls/HlsMediaChunk.java | 9 +++++++++ .../exoplayer2/source/hls/HlsMediaChunkExtractor.java | 8 ++++++++ 3 files changed, 22 insertions(+) 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 78fc9ae732f..6c5ece1c088 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 @@ -67,6 +67,11 @@ public boolean read(ExtractorInput extractorInput) throws IOException { return extractor.read(extractorInput, POSITION_HOLDER) == Extractor.RESULT_CONTINUE; } + @Override + public void seek(long position, long timeUs) { + extractor.seek( position, timeUs ); + } + @Override public boolean isPackedAudioExtractor() { return extractor instanceof AdtsExtractor 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 9994ede1cf4..a275abf503e 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 @@ -403,6 +403,15 @@ private void feedDataToExtractor( } try { while (!loadCanceled && extractor.read(input)) {} + } catch(EOFException e) { + // See bug: https://github.com/google/ExoPlayer/issues/7512 for more details. + if( input.getPosition() == dataSpec.position + input.getLength() ) { + extractor.seek(0, C.TIME_UNSET); + } + else { + e.fillInStackTrace(); + throw e; + } } finally { nextLoadPosition = (int) (input.getPosition() - dataSpec.position); } 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 0ca5c5d0ad6..09028e91b1d 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 @@ -48,6 +48,14 @@ public interface HlsMediaChunkExtractor { */ boolean read(ExtractorInput extractorInput) throws IOException; + /** + * Notifies the extractor that a seek has occurred. + * + * @param position The byte offset in the stream from which data will be provided. + * @param timeUs The seek time in microseconds. + */ + void seek(long position, long timeUs); + /** Returns whether this is a packed audio extractor, as defined in RFC 8216, Section 3.4. */ boolean isPackedAudioExtractor(); From 3b8b2f707b729a406882bfe208a8f7903e95549b Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 11 Nov 2020 16:14:12 +0000 Subject: [PATCH 272/693] Remove C.StreamType constant that's not a real stream type #minor-release PiperOrigin-RevId: 341833274 --- .../main/java/com/google/android/exoplayer2/C.java | 14 +++----------- .../com/google/android/exoplayer2/util/Util.java | 2 -- 2 files changed, 3 insertions(+), 13 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 c0baa4cbdc0..dbfcbde0eb1 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 @@ -254,8 +254,7 @@ private C() {} /** * Stream types for an {@link android.media.AudioTrack}. One of {@link #STREAM_TYPE_ALARM}, {@link * #STREAM_TYPE_DTMF}, {@link #STREAM_TYPE_MUSIC}, {@link #STREAM_TYPE_NOTIFICATION}, {@link - * #STREAM_TYPE_RING}, {@link #STREAM_TYPE_SYSTEM}, {@link #STREAM_TYPE_VOICE_CALL} or {@link - * #STREAM_TYPE_USE_DEFAULT}. + * #STREAM_TYPE_RING}, {@link #STREAM_TYPE_SYSTEM} or {@link #STREAM_TYPE_VOICE_CALL}. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -266,8 +265,7 @@ private C() {} STREAM_TYPE_NOTIFICATION, STREAM_TYPE_RING, STREAM_TYPE_SYSTEM, - STREAM_TYPE_VOICE_CALL, - STREAM_TYPE_USE_DEFAULT + STREAM_TYPE_VOICE_CALL }) public @interface StreamType {} /** @@ -298,13 +296,7 @@ private C() {} * @see AudioManager#STREAM_VOICE_CALL */ public static final int STREAM_TYPE_VOICE_CALL = AudioManager.STREAM_VOICE_CALL; - /** - * @see AudioManager#USE_DEFAULT_STREAM_TYPE - */ - public static final int STREAM_TYPE_USE_DEFAULT = AudioManager.USE_DEFAULT_STREAM_TYPE; - /** - * The default stream type used by audio renderers. - */ + /** The default stream type used by audio renderers. Equal to {@link #STREAM_TYPE_MUSIC}. */ public static final int STREAM_TYPE_DEFAULT = STREAM_TYPE_MUSIC; /** 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 7c865edf9ab..973f900282e 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 @@ -1692,7 +1692,6 @@ public static int getAudioUsageForStreamType(@C.StreamType int streamType) { return C.USAGE_ASSISTANCE_SONIFICATION; case C.STREAM_TYPE_VOICE_CALL: return C.USAGE_VOICE_COMMUNICATION; - case C.STREAM_TYPE_USE_DEFAULT: case C.STREAM_TYPE_MUSIC: default: return C.USAGE_MEDIA; @@ -1713,7 +1712,6 @@ public static int getAudioContentTypeForStreamType(@C.StreamType int streamType) return C.CONTENT_TYPE_SONIFICATION; case C.STREAM_TYPE_VOICE_CALL: return C.CONTENT_TYPE_SPEECH; - case C.STREAM_TYPE_USE_DEFAULT: case C.STREAM_TYPE_MUSIC: default: return C.CONTENT_TYPE_MUSIC; From 51c8ffbb0e204edc7bddd4c645b4c04bfaa7fcd9 Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 11 Nov 2020 16:21:47 +0000 Subject: [PATCH 273/693] Tweak DefaultLivePlaybackSpeedControl parameters. Changing them to have fewer updates when adjusting the playback speed. PiperOrigin-RevId: 341834423 --- .../DefaultLivePlaybackSpeedControl.java | 38 +++++++++++++++---- .../DefaultLivePlaybackSpeedControlTest.java | 14 +++---- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java index 9bb3d1cd38f..f9400231298 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControl.java @@ -30,8 +30,8 @@ * *

    The control mechanism calculates the adjusted speed as {@code 1.0 + proportionalControlFactor * x (currentLiveOffsetSec - targetLiveOffsetSec)}. Unit speed (1.0f) is used, if the {@code - * currentLiveOffsetSec} is marginally close to {@code targetLiveOffsetSec}, i.e. {@code - * |currentLiveOffsetSec - targetLiveOffsetSec| <= MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED}. + * currentLiveOffsetSec} is closer to {@code targetLiveOffsetSec} than the value set with {@link + * Builder#setMaxLiveOffsetErrorMsForUnitSpeed(long)}. * *

    The resulting speed is clamped to a minimum and maximum speed defined by the media, the * fallback values set with {@link Builder#setFallbackMinPlaybackSpeed(float)} and {@link @@ -63,13 +63,13 @@ public final class DefaultLivePlaybackSpeedControl implements LivePlaybackSpeedC * The default {@link Builder#setMinUpdateIntervalMs(long) minimum interval} between playback * speed changes, in milliseconds. */ - public static final long DEFAULT_MIN_UPDATE_INTERVAL_MS = 500; + public static final long DEFAULT_MIN_UPDATE_INTERVAL_MS = 1_000; /** * The default {@link Builder#setProportionalControlFactor(float) proportional control factor} * used to adjust the playback speed. */ - public static final float DEFAULT_PROPORTIONAL_CONTROL_FACTOR = 0.05f; + public static final float DEFAULT_PROPORTIONAL_CONTROL_FACTOR = 0.1f; /** * The default increment applied to the target live offset each time the player is rebuffering, in @@ -84,10 +84,10 @@ public final class DefaultLivePlaybackSpeedControl implements LivePlaybackSpeedC public static final float DEFAULT_MIN_POSSIBLE_LIVE_OFFSET_SMOOTHING_FACTOR = 0.999f; /** - * The maximum difference between the current live offset and the target live offset for which - * unit speed (1.0f) is used. + * The default maximum difference between the current live offset and the target live offset, in + * milliseconds, for which unit speed (1.0f) is used. */ - public static final long MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED = 5_000; + public static final long DEFAULT_MAX_LIVE_OFFSET_ERROR_MS_FOR_UNIT_SPEED = 20; /** Builder for a {@link DefaultLivePlaybackSpeedControl}. */ public static final class Builder { @@ -96,6 +96,7 @@ public static final class Builder { private float fallbackMaxPlaybackSpeed; private long minUpdateIntervalMs; private float proportionalControlFactorUs; + private long maxLiveOffsetErrorUsForUnitSpeed; private long targetLiveOffsetIncrementOnRebufferUs; private float minPossibleLiveOffsetSmoothingFactor; @@ -105,6 +106,7 @@ public Builder() { fallbackMaxPlaybackSpeed = DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED; minUpdateIntervalMs = DEFAULT_MIN_UPDATE_INTERVAL_MS; proportionalControlFactorUs = DEFAULT_PROPORTIONAL_CONTROL_FACTOR / C.MICROS_PER_SECOND; + maxLiveOffsetErrorUsForUnitSpeed = C.msToUs(DEFAULT_MAX_LIVE_OFFSET_ERROR_MS_FOR_UNIT_SPEED); targetLiveOffsetIncrementOnRebufferUs = C.msToUs(DEFAULT_TARGET_LIVE_OFFSET_INCREMENT_ON_REBUFFER_MS); minPossibleLiveOffsetSmoothingFactor = DEFAULT_MIN_POSSIBLE_LIVE_OFFSET_SMOOTHING_FACTOR; @@ -173,6 +175,22 @@ public Builder setProportionalControlFactor(float proportionalControlFactor) { return this; } + /** + * Sets the maximum difference between the current live offset and the target live offset, in + * milliseconds, for which unit speed (1.0f) is used. + * + *

    The default is {@link #DEFAULT_MAX_LIVE_OFFSET_ERROR_MS_FOR_UNIT_SPEED}. + * + * @param maxLiveOffsetErrorMsForUnitSpeed The maximum live offset error for which unit speed is + * used, in milliseconds. + * @return This builder, for convenience. + */ + public Builder setMaxLiveOffsetErrorMsForUnitSpeed(long maxLiveOffsetErrorMsForUnitSpeed) { + Assertions.checkArgument(maxLiveOffsetErrorMsForUnitSpeed > 0); + this.maxLiveOffsetErrorUsForUnitSpeed = C.msToUs(maxLiveOffsetErrorMsForUnitSpeed); + return this; + } + /** * Sets the increment applied to the target live offset each time the player is rebuffering, in * milliseconds. @@ -217,6 +235,7 @@ public DefaultLivePlaybackSpeedControl build() { fallbackMaxPlaybackSpeed, minUpdateIntervalMs, proportionalControlFactorUs, + maxLiveOffsetErrorUsForUnitSpeed, targetLiveOffsetIncrementOnRebufferUs, minPossibleLiveOffsetSmoothingFactor); } @@ -226,6 +245,7 @@ public DefaultLivePlaybackSpeedControl build() { private final float fallbackMaxPlaybackSpeed; private final long minUpdateIntervalMs; private final float proportionalControlFactor; + private final long maxLiveOffsetErrorUsForUnitSpeed; private final long targetLiveOffsetRebufferDeltaUs; private final float minPossibleLiveOffsetSmoothingFactor; @@ -249,12 +269,14 @@ private DefaultLivePlaybackSpeedControl( float fallbackMaxPlaybackSpeed, long minUpdateIntervalMs, float proportionalControlFactor, + long maxLiveOffsetErrorUsForUnitSpeed, long targetLiveOffsetRebufferDeltaUs, float minPossibleLiveOffsetSmoothingFactor) { this.fallbackMinPlaybackSpeed = fallbackMinPlaybackSpeed; this.fallbackMaxPlaybackSpeed = fallbackMaxPlaybackSpeed; this.minUpdateIntervalMs = minUpdateIntervalMs; this.proportionalControlFactor = proportionalControlFactor; + this.maxLiveOffsetErrorUsForUnitSpeed = maxLiveOffsetErrorUsForUnitSpeed; this.targetLiveOffsetRebufferDeltaUs = targetLiveOffsetRebufferDeltaUs; this.minPossibleLiveOffsetSmoothingFactor = minPossibleLiveOffsetSmoothingFactor; mediaConfigurationTargetLiveOffsetUs = C.TIME_UNSET; @@ -322,7 +344,7 @@ public float getAdjustedPlaybackSpeed(long liveOffsetUs, long bufferedDurationUs adjustTargetLiveOffsetUs(liveOffsetUs); long liveOffsetErrorUs = liveOffsetUs - currentTargetLiveOffsetUs; - if (Math.abs(liveOffsetErrorUs) < MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED) { + if (Math.abs(liveOffsetErrorUs) < maxLiveOffsetErrorUsForUnitSpeed) { adjustedPlaybackSpeed = 1f; } else { float calculatedSpeed = 1f + proportionalControlFactor * liveOffsetErrorUs; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java index 2e2731b0652..62ab1ee85a3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLivePlaybackSpeedControlTest.java @@ -512,7 +512,9 @@ public void adjustPlaybackSpeed_liveOffsetMatchesTargetOffset_returnsUnitSpeed() @Test public void adjustPlaybackSpeed_liveOffsetWithinAcceptableErrorMargin_returnsUnitSpeed() { DefaultLivePlaybackSpeedControl defaultLivePlaybackSpeedControl = - new DefaultLivePlaybackSpeedControl.Builder().build(); + new DefaultLivePlaybackSpeedControl.Builder() + .setMaxLiveOffsetErrorMsForUnitSpeed(5) + .build(); defaultLivePlaybackSpeedControl.setLiveConfiguration( new LiveConfiguration( /* targetLiveOffsetMs= */ 2_000, @@ -523,16 +525,10 @@ public void adjustPlaybackSpeed_liveOffsetWithinAcceptableErrorMargin_returnsUni float adjustedSpeedJustAboveLowerErrorMargin = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( - /* liveOffsetUs= */ 2_000_000 - - DefaultLivePlaybackSpeedControl.MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED - + 1, - /* bufferedDurationUs= */ 1_000_000); + /* liveOffsetUs= */ 2_000_000 - 5_000 + 1, /* bufferedDurationUs= */ 1_000_000); float adjustedSpeedJustBelowUpperErrorMargin = defaultLivePlaybackSpeedControl.getAdjustedPlaybackSpeed( - /* liveOffsetUs= */ 2_000_000 - + DefaultLivePlaybackSpeedControl.MAXIMUM_LIVE_OFFSET_ERROR_US_FOR_UNIT_SPEED - - 1, - /* bufferedDurationUs= */ 1_000_000); + /* liveOffsetUs= */ 2_000_000 + 5_000 - 1, /* bufferedDurationUs= */ 1_000_000); assertThat(adjustedSpeedJustAboveLowerErrorMargin).isEqualTo(1f); assertThat(adjustedSpeedJustBelowUpperErrorMargin).isEqualTo(1f); From 2693a107cd5879bce24a0bc7e8f838fe57e9c1f6 Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 12 Nov 2020 10:35:31 +0000 Subject: [PATCH 274/693] Fix frame release timing to be aware of playback speed PiperOrigin-RevId: 342007987 --- .../video/MediaCodecVideoRenderer.java | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 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 5ce86437bad..1e4c111963c 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 @@ -133,7 +133,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private int droppedFrames; private int consecutiveDroppedFrameCount; private int buffersInCodecCount; - private long lastRenderTimeUs; + private long lastRenderRealtimeUs; private long totalVideoFrameProcessingOffsetUs; private int videoFrameProcessingOffsetCount; @@ -411,7 +411,7 @@ protected void onStarted() { super.onStarted(); droppedFrames = 0; droppedFrameAccumulationStartTimeMs = SystemClock.elapsedRealtime(); - lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; + lastRenderRealtimeUs = SystemClock.elapsedRealtime() * 1000; totalVideoFrameProcessingOffsetUs = 0; videoFrameProcessingOffsetCount = 0; updateSurfaceFrameRate(/* isNewSurface= */ false); @@ -753,7 +753,20 @@ protected boolean processOutputBuffer( return true; } - long earlyUs = bufferPresentationTimeUs - positionUs; + // Note: Use of double rather than float is intentional for accuracy in the calculations below. + double playbackSpeed = getPlaybackSpeed(); + boolean isStarted = getState() == STATE_STARTED; + long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000; + + // Calculate how early we are. In other words, the realtime duration that needs to elapse whilst + // the renderer is started before the frame should be rendered. A negative value means that + // we're already late. + long earlyUs = (long) ((bufferPresentationTimeUs - positionUs) / playbackSpeed); + if (isStarted) { + // Account for the elapsed time since the start of this iteration of the rendering loop. + earlyUs -= elapsedRealtimeNowUs - elapsedRealtimeUs; + } + if (surface == dummySurface) { // Skip frames in sync with playback, so we'll be at the right frame if the mode changes. if (isBufferLate(earlyUs)) { @@ -764,9 +777,7 @@ protected boolean processOutputBuffer( return false; } - long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000; - long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderTimeUs; - boolean isStarted = getState() == STATE_STARTED; + long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderRealtimeUs; boolean shouldRenderFirstFrame = !renderedFirstFrameAfterEnable ? (isStarted || mayRenderFirstFrameAfterEnableIfNotStarted) @@ -793,11 +804,6 @@ protected boolean processOutputBuffer( return false; } - // Fine-grained adjustment of earlyUs based on the elapsed time since the start of the current - // iteration of the rendering loop. - long elapsedSinceStartOfLoopUs = elapsedRealtimeNowUs - elapsedRealtimeUs; - earlyUs -= elapsedSinceStartOfLoopUs; - // Compute the buffer's desired release time in nanoseconds. long systemTimeNs = System.nanoTime(); long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000); @@ -1042,7 +1048,7 @@ protected void renderOutputBuffer(MediaCodecAdapter codec, int index, long prese TraceUtil.beginSection("releaseOutputBuffer"); codec.releaseOutputBuffer(index, true); TraceUtil.endSection(); - lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; + lastRenderRealtimeUs = SystemClock.elapsedRealtime() * 1000; decoderCounters.renderedOutputBufferCount++; consecutiveDroppedFrameCount = 0; maybeNotifyRenderedFirstFrame(); @@ -1064,7 +1070,7 @@ protected void renderOutputBufferV21( TraceUtil.beginSection("releaseOutputBuffer"); codec.releaseOutputBuffer(index, releaseTimeNs); TraceUtil.endSection(); - lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; + lastRenderRealtimeUs = SystemClock.elapsedRealtime() * 1000; decoderCounters.renderedOutputBufferCount++; consecutiveDroppedFrameCount = 0; maybeNotifyRenderedFirstFrame(); From e3c725aa382b0e8c088f914d2b1017f5c09d3057 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 12 Nov 2020 12:39:12 +0000 Subject: [PATCH 275/693] Create chunks from parts in HlsChunkSource Issue: #5011 PiperOrigin-RevId: 342022947 --- .../exoplayer2/source/hls/HlsChunkSource.java | 273 +++++++++++++----- .../exoplayer2/source/hls/HlsMediaChunk.java | 28 +- .../hls/playlist/HlsPlaylistParser.java | 37 +-- .../HlsMediaPlaylistSegmentIteratorTest.java | 222 ++++++++++++++ .../playlist/HlsMediaPlaylistParserTest.java | 23 ++ .../m3u8/live_low_latency_segments_and_parts | 28 ++ .../media/m3u8/live_low_latency_segments_only | 16 + 7 files changed, 535 insertions(+), 92 deletions(-) create mode 100644 library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPlaylistSegmentIteratorTest.java create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_segments_and_parts create mode 100644 testdata/src/test/assets/media/m3u8/live_low_latency_segments_only 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 2ab4852339b..653dc20a7b0 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 @@ -19,7 +19,9 @@ import android.net.Uri; import android.os.SystemClock; +import android.util.Pair; 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.source.BehindLiveWindowException; @@ -41,10 +43,13 @@ 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.ImmutableList; +import com.google.common.collect.Iterables; import com.google.common.primitives.Ints; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -242,7 +247,7 @@ public void getNextChunk( List queue, boolean allowEndOfStream, HlsChunkHolder out) { - HlsMediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1); + @Nullable HlsMediaChunk previous = queue.isEmpty() ? null : Iterables.getLast(queue); int oldTrackIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat); long bufferedDurationUs = loadPositionUs - playbackPositionUs; long timeToLiveEdgeUs = resolveTimeToLiveEdgeUs(playbackPositionUs); @@ -275,6 +280,7 @@ public void getNextChunk( // Retry when playlist is refreshed. return; } + @Nullable HlsMediaPlaylist mediaPlaylist = playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true); // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be non-null. @@ -286,22 +292,33 @@ public void getNextChunk( // Select the chunk. long startOfPlaylistInPeriodUs = mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); - long chunkMediaSequence = - getChunkMediaSequence( + Pair nextMediaSequenceAndPartIndex = + getNextMediaSequenceAndPartIndex( previous, switchingTrack, mediaPlaylist, startOfPlaylistInPeriodUs, loadPositionUs); + long chunkMediaSequence = nextMediaSequenceAndPartIndex.first; + int partIndex = nextMediaSequenceAndPartIndex.second; if (chunkMediaSequence < mediaPlaylist.mediaSequence && previous != null && switchingTrack) { - // We try getting the next chunk without adapting in case that's the reason for falling - // behind the live window. - selectedTrackIndex = oldTrackIndex; - selectedPlaylistUrl = playlistUrls[selectedTrackIndex]; + // We try getting the next chunk without adapting in case that's the reason for falling + // behind the live window. + selectedTrackIndex = oldTrackIndex; + selectedPlaylistUrl = playlistUrls[selectedTrackIndex]; mediaPlaylist = playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true); // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be // non-null. Assertions.checkNotNull(mediaPlaylist); - startOfPlaylistInPeriodUs = - mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); - chunkMediaSequence = previous.getNextChunkIndex(); + startOfPlaylistInPeriodUs = + mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + // Get the next segment/part without switching tracks. + Pair nextMediaSequenceAndPartIndexWithoutAdapting = + getNextMediaSequenceAndPartIndex( + previous, + /* switchingTrack= */ false, + mediaPlaylist, + startOfPlaylistInPeriodUs, + loadPositionUs); + chunkMediaSequence = nextMediaSequenceAndPartIndexWithoutAdapting.first; + partIndex = nextMediaSequenceAndPartIndexWithoutAdapting.second; } if (chunkMediaSequence < mediaPlaylist.mediaSequence) { @@ -309,36 +326,42 @@ public void getNextChunk( return; } - int segmentIndexInPlaylist = (int) (chunkMediaSequence - mediaPlaylist.mediaSequence); - int availableSegmentCount = mediaPlaylist.segments.size(); - if (segmentIndexInPlaylist >= availableSegmentCount) { - if (mediaPlaylist.hasEndTag) { - if (allowEndOfStream || availableSegmentCount == 0) { - out.endOfStream = true; - return; - } - segmentIndexInPlaylist = availableSegmentCount - 1; - } else /* Live */ { + @Nullable + SegmentBaseHolder segmentBaseHolder = + getNextSegmentHolder(mediaPlaylist, chunkMediaSequence, partIndex); + if (segmentBaseHolder == null) { + if (!mediaPlaylist.hasEndTag) { + // Reload the playlist in case of a live stream. out.playlistUrl = selectedPlaylistUrl; seenExpectedPlaylistError &= selectedPlaylistUrl.equals(expectedPlaylistUrl); expectedPlaylistUrl = selectedPlaylistUrl; return; + } else if (allowEndOfStream || mediaPlaylist.segments.isEmpty()) { + out.endOfStream = true; + return; } + // Use the last segment available in case of a VOD stream. + segmentBaseHolder = + new SegmentBaseHolder( + Iterables.getLast(mediaPlaylist.segments), + mediaPlaylist.mediaSequence + mediaPlaylist.segments.size() - 1, + /* partIndex= */ C.INDEX_UNSET); } - // We have a valid playlist snapshot, we can discard any playlist errors at this point. + + // We have a valid media segment, we can discard any playlist errors at this point. seenExpectedPlaylistError = false; expectedPlaylistUrl = null; - // Handle encryption. - HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(segmentIndexInPlaylist); - - // Check if the segment or its initialization segment are fully encrypted. - Uri initSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment.initializationSegment); + // Check if the media segment or its initialization segment are fully encrypted. + @Nullable + Uri initSegmentKeyUri = + getFullEncryptionKeyUri(mediaPlaylist, segmentBaseHolder.segmentBase.initializationSegment); out.chunk = maybeCreateEncryptionChunkFor(initSegmentKeyUri, selectedTrackIndex); if (out.chunk != null) { return; } - Uri mediaSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment); + @Nullable + Uri mediaSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segmentBaseHolder.segmentBase); out.chunk = maybeCreateEncryptionChunkFor(mediaSegmentKeyUri, selectedTrackIndex); if (out.chunk != null) { return; @@ -351,7 +374,7 @@ public void getNextChunk( playlistFormats[selectedTrackIndex], startOfPlaylistInPeriodUs, mediaPlaylist, - segmentIndexInPlaylist, + segmentBaseHolder, selectedPlaylistUrl, muxedCaptionFormats, trackSelection.getSelectionReason(), @@ -363,6 +386,40 @@ public void getNextChunk( /* initSegmentKey= */ keyCache.get(initSegmentKeyUri)); } + @Nullable + private static SegmentBaseHolder getNextSegmentHolder( + HlsMediaPlaylist mediaPlaylist, long nextMediaSequence, int nextPartIndex) { + int segmentIndexInPlaylist = (int) (nextMediaSequence - mediaPlaylist.mediaSequence); + if (segmentIndexInPlaylist == mediaPlaylist.segments.size()) { + int index = nextPartIndex != C.INDEX_UNSET ? nextPartIndex : 0; + return index < mediaPlaylist.trailingParts.size() + ? new SegmentBaseHolder(mediaPlaylist.trailingParts.get(index), nextMediaSequence, index) + : null; + } + + Segment mediaSegment = mediaPlaylist.segments.get(segmentIndexInPlaylist); + if (nextPartIndex == C.INDEX_UNSET) { + return new SegmentBaseHolder(mediaSegment, nextMediaSequence, nextPartIndex); + } + + if (nextPartIndex < mediaSegment.parts.size()) { + // The requested part is available in the requested segment. + return new SegmentBaseHolder( + mediaSegment.parts.get(nextPartIndex), nextMediaSequence, nextPartIndex); + } else if (segmentIndexInPlaylist + 1 < mediaPlaylist.segments.size()) { + // The first part of the next segment is requested, but we can use the next full segment. + return new SegmentBaseHolder( + mediaPlaylist.segments.get(segmentIndexInPlaylist + 1), + nextMediaSequence + 1, + /* partIndex= */ C.INDEX_UNSET); + } else if (!mediaPlaylist.trailingParts.isEmpty()) { + // The part index is rolling over to the first trailing part. + return new SegmentBaseHolder( + mediaPlaylist.trailingParts.get(0), nextMediaSequence + 1, /* partIndex= */ 0); + } + return null; + } + /** * Called when the {@link HlsSampleStreamWrapper} has finished loading a chunk obtained from this * source. @@ -438,6 +495,7 @@ public MediaChunkIterator[] createMediaChunkIterators( chunkIterators[i] = MediaChunkIterator.EMPTY; continue; } + @Nullable HlsMediaPlaylist playlist = playlistTracker.getPlaylistSnapshot(playlistUrl, /* isForPlayback= */ false); // Playlist snapshot is valid (checked by if() above) so playlist must be non-null. @@ -445,16 +503,16 @@ public MediaChunkIterator[] createMediaChunkIterators( long startOfPlaylistInPeriodUs = playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); boolean switchingTrack = trackIndex != oldTrackIndex; - long chunkMediaSequence = - getChunkMediaSequence( + Pair chunkMediaSequenceAndPartIndex = + getNextMediaSequenceAndPartIndex( previous, switchingTrack, playlist, startOfPlaylistInPeriodUs, loadPositionUs); - if (chunkMediaSequence < playlist.mediaSequence) { - chunkIterators[i] = MediaChunkIterator.EMPTY; - continue; - } - int chunkIndex = (int) (chunkMediaSequence - playlist.mediaSequence); + long chunkMediaSequence = chunkMediaSequenceAndPartIndex.first; + int partIndex = chunkMediaSequenceAndPartIndex.second; chunkIterators[i] = - new HlsMediaPlaylistSegmentIterator(playlist, startOfPlaylistInPeriodUs, chunkIndex); + new HlsMediaPlaylistSegmentIterator( + playlist.baseUri, + startOfPlaylistInPeriodUs, + getSegmentBaseList(playlist, chunkMediaSequence, partIndex)); } return chunkIterators; } @@ -495,10 +553,56 @@ public boolean shouldCancelLoad( return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, queue); } + // Package methods. + + /** + * Returns a list with all segment bases in the playlist starting from {@code mediaSequence} and + * {@code partIndex} in the given playlist. The list may be empty if the starting point is not in + * the playlist. + */ + @VisibleForTesting + /* package */ static List getSegmentBaseList( + HlsMediaPlaylist playlist, long mediaSequence, int partIndex) { + int firstSegmentIndexInPlaylist = (int) (mediaSequence - playlist.mediaSequence); + if (firstSegmentIndexInPlaylist < 0 || playlist.segments.size() < firstSegmentIndexInPlaylist) { + // The first media sequence is not in the playlist. + return ImmutableList.of(); + } + List segmentBases = new ArrayList<>(); + if (firstSegmentIndexInPlaylist < playlist.segments.size()) { + if (partIndex != C.INDEX_UNSET) { + // The iterator starts with a part that belongs to a segment. + Segment firstSegment = playlist.segments.get(firstSegmentIndexInPlaylist); + if (partIndex == 0) { + // Use the full segment instead of the first part. + segmentBases.add(firstSegment); + } else if (partIndex < firstSegment.parts.size()) { + // Add the parts from the first requested segment. + segmentBases.addAll(firstSegment.parts.subList(partIndex, firstSegment.parts.size())); + } + firstSegmentIndexInPlaylist++; + } + partIndex = 0; + // Add all remaining segments. + segmentBases.addAll( + playlist.segments.subList(firstSegmentIndexInPlaylist, playlist.segments.size())); + } + + if (playlist.partTargetDurationUs != C.TIME_UNSET) { + // That's a low latency playlist. + partIndex = partIndex == C.INDEX_UNSET ? 0 : partIndex; + if (partIndex < playlist.trailingParts.size()) { + segmentBases.addAll( + playlist.trailingParts.subList(partIndex, playlist.trailingParts.size())); + } + } + return Collections.unmodifiableList(segmentBases); + } + // Private methods. /** - * Returns the media sequence number of the segment to load next in {@code mediaPlaylist}. + * Returns the media sequence number and part index to load next in the {@code mediaPlaylist}. * * @param previous The last (at least partially) loaded segment. * @param switchingTrack Whether the segment to load is not preceded by a segment in the same @@ -507,9 +611,9 @@ public boolean shouldCancelLoad( * @param startOfPlaylistInPeriodUs The start of {@code mediaPlaylist} relative to the period * start in microseconds. * @param loadPositionUs The current load position relative to the period start in microseconds. - * @return The media sequence of the segment to load. + * @return The media sequence and part index to load. */ - private long getChunkMediaSequence( + private Pair getNextMediaSequenceAndPartIndex( @Nullable HlsMediaChunk previous, boolean switchingTrack, HlsMediaPlaylist mediaPlaylist, @@ -521,17 +625,28 @@ private long getChunkMediaSequence( (previous == null || independentSegments) ? loadPositionUs : previous.startTimeUs; if (!mediaPlaylist.hasEndTag && targetPositionInPeriodUs >= endOfPlaylistInPeriodUs) { // If the playlist is too old to contain the chunk, we need to refresh it. - return mediaPlaylist.mediaSequence + mediaPlaylist.segments.size(); + return new Pair<>( + mediaPlaylist.mediaSequence + mediaPlaylist.segments.size(), + /* partIndex */ C.INDEX_UNSET); } long targetPositionInPlaylistUs = targetPositionInPeriodUs - startOfPlaylistInPeriodUs; - return Util.binarySearchFloor( - mediaPlaylist.segments, - /* value= */ targetPositionInPlaylistUs, - /* inclusive= */ true, - /* stayInBounds= */ !playlistTracker.isLive() || previous == null) - + mediaPlaylist.mediaSequence; - } - return previous.isLoadCompleted() ? previous.getNextChunkIndex() : previous.chunkIndex; + long mediaSequence = + Util.binarySearchFloor( + mediaPlaylist.segments, + /* value= */ targetPositionInPlaylistUs, + /* inclusive= */ true, + /* stayInBounds= */ !playlistTracker.isLive() || previous == null) + + mediaPlaylist.mediaSequence; + return new Pair<>(mediaSequence, /* partIndex */ C.INDEX_UNSET); + } + // If loading has not completed, we return the previous chunk again. + return (previous.isLoadCompleted() + ? new Pair<>( + previous.partIndex == C.INDEX_UNSET + ? previous.getNextChunkIndex() + : previous.chunkIndex, + previous.partIndex == C.INDEX_UNSET ? C.INDEX_UNSET : previous.partIndex + 1) + : new Pair<>(previous.chunkIndex, previous.partIndex)); } private long resolveTimeToLiveEdgeUs(long playbackPositionUs) { @@ -574,11 +689,29 @@ private Chunk maybeCreateEncryptionChunkFor(@Nullable Uri keyUri, int selectedTr } @Nullable - private static Uri getFullEncryptionKeyUri(HlsMediaPlaylist playlist, @Nullable Segment segment) { - if (segment == null || segment.fullSegmentEncryptionKeyUri == null) { + private static Uri getFullEncryptionKeyUri( + HlsMediaPlaylist playlist, @Nullable HlsMediaPlaylist.SegmentBase segmentBase) { + if (segmentBase == null || segmentBase.fullSegmentEncryptionKeyUri == null) { return null; } - return UriUtil.resolveToUri(playlist.baseUri, segment.fullSegmentEncryptionKeyUri); + return UriUtil.resolveToUri(playlist.baseUri, segmentBase.fullSegmentEncryptionKeyUri); + } + + // Package classes. + + /* package */ static final class SegmentBaseHolder { + + public final HlsMediaPlaylist.SegmentBase segmentBase; + public final long mediaSequence; + public final int partIndex; + + /** Creates a new instance. */ + public SegmentBaseHolder( + HlsMediaPlaylist.SegmentBase segmentBase, long mediaSequence, int partIndex) { + this.segmentBase = segmentBase; + this.mediaSequence = mediaSequence; + this.partIndex = partIndex; + } } // Private classes. @@ -665,48 +798,52 @@ public byte[] getResult() { } - /** {@link MediaChunkIterator} wrapping a {@link HlsMediaPlaylist}. */ - private static final class HlsMediaPlaylistSegmentIterator extends BaseMediaChunkIterator { + @VisibleForTesting + /* package */ static final class HlsMediaPlaylistSegmentIterator extends BaseMediaChunkIterator { - private final HlsMediaPlaylist playlist; + private final List segmentBases; private final long startOfPlaylistInPeriodUs; + private final String playlistBaseUri; /** - * Creates iterator. + * Creates an iterator instance wrapping a list of {@link HlsMediaPlaylist.SegmentBase}. * - * @param playlist The {@link HlsMediaPlaylist} to wrap. + * @param playlistBaseUri The base URI of the {@link HlsMediaPlaylist}. * @param startOfPlaylistInPeriodUs The start time of the playlist in the period, in * microseconds. - * @param chunkIndex The index of the first available chunk in the playlist. + * @param segmentBases The list of {@link HlsMediaPlaylist.SegmentBase segment bases} to wrap. */ public HlsMediaPlaylistSegmentIterator( - HlsMediaPlaylist playlist, long startOfPlaylistInPeriodUs, int chunkIndex) { - super(/* fromIndex= */ chunkIndex, /* toIndex= */ playlist.segments.size() - 1); - this.playlist = playlist; + String playlistBaseUri, + long startOfPlaylistInPeriodUs, + List segmentBases) { + super(/* fromIndex= */ 0, segmentBases.size() - 1); + this.playlistBaseUri = playlistBaseUri; this.startOfPlaylistInPeriodUs = startOfPlaylistInPeriodUs; + this.segmentBases = segmentBases; } @Override public DataSpec getDataSpec() { checkInBounds(); - Segment segment = playlist.segments.get((int) getCurrentIndex()); - Uri chunkUri = UriUtil.resolveToUri(playlist.baseUri, segment.url); - return new DataSpec(chunkUri, segment.byteRangeOffset, segment.byteRangeLength); + HlsMediaPlaylist.SegmentBase segmentBase = segmentBases.get((int) getCurrentIndex()); + Uri chunkUri = UriUtil.resolveToUri(playlistBaseUri, segmentBase.url); + return new DataSpec(chunkUri, segmentBase.byteRangeOffset, segmentBase.byteRangeLength); } @Override public long getChunkStartTimeUs() { checkInBounds(); - Segment segment = playlist.segments.get((int) getCurrentIndex()); - return startOfPlaylistInPeriodUs + segment.relativeStartTimeUs; + return startOfPlaylistInPeriodUs + + segmentBases.get((int) getCurrentIndex()).relativeStartTimeUs; } @Override public long getChunkEndTimeUs() { checkInBounds(); - Segment segment = playlist.segments.get((int) getCurrentIndex()); - long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + segment.relativeStartTimeUs; - return segmentStartTimeInPeriodUs + segment.durationUs; + HlsMediaPlaylist.SegmentBase segmentBase = segmentBases.get((int) getCurrentIndex()); + long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + segmentBase.relativeStartTimeUs; + return segmentStartTimeInPeriodUs + segmentBase.durationUs; } } } 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 9994ede1cf4..7b96e8a2180 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 @@ -59,7 +59,7 @@ * @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 segmentBaseHolder The segment holder. * @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. @@ -79,7 +79,7 @@ public static HlsMediaChunk createInstance( Format format, long startOfPlaylistInPeriodUs, HlsMediaPlaylist mediaPlaylist, - int segmentIndexInPlaylist, + HlsChunkSource.SegmentBaseHolder segmentBaseHolder, Uri playlistUrl, @Nullable List muxedCaptionFormats, int trackSelectionReason, @@ -90,7 +90,7 @@ public static HlsMediaChunk createInstance( @Nullable byte[] mediaSegmentKey, @Nullable byte[] initSegmentKey) { // Media segment. - HlsMediaPlaylist.Segment mediaSegment = mediaPlaylist.segments.get(segmentIndexInPlaylist); + HlsMediaPlaylist.SegmentBase mediaSegment = segmentBaseHolder.segmentBase; DataSpec dataSpec = new DataSpec( UriUtil.resolveToUri(mediaPlaylist.baseUri, mediaSegment.url), @@ -136,10 +136,10 @@ public static HlsMediaChunk createInstance( playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted; id3Decoder = previousChunk.id3Decoder; scratchId3Data = previousChunk.scratchId3Data; + boolean isIndependent = isIndependent(segmentBaseHolder, mediaPlaylist); boolean canContinueWithoutSplice = isFollowingChunk - || (mediaPlaylist.hasIndependentSegments - && segmentStartTimeInPeriodUs >= previousChunk.endTimeUs); + || (isIndependent && segmentStartTimeInPeriodUs >= previousChunk.endTimeUs); shouldSpliceIn = !canContinueWithoutSplice; previousExtractor = isFollowingChunk @@ -152,7 +152,6 @@ public static HlsMediaChunk createInstance( scratchId3Data = new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); shouldSpliceIn = false; } - return new HlsMediaChunk( extractorFactory, mediaDataSource, @@ -168,7 +167,8 @@ public static HlsMediaChunk createInstance( trackSelectionData, segmentStartTimeInPeriodUs, segmentEndTimeInPeriodUs, - /* chunkMediaSequence= */ mediaPlaylist.mediaSequence + segmentIndexInPlaylist, + segmentBaseHolder.mediaSequence, + segmentBaseHolder.partIndex, discontinuitySequenceNumber, mediaSegment.hasGapTag, isMasterTimestampSource, @@ -201,6 +201,9 @@ public static HlsMediaChunk createInstance( /** Whether samples for this chunk should be spliced into existing samples. */ public final boolean shouldSpliceIn; + /** The part index or {@link C#INDEX_UNSET} if the chunk is a full segment */ + public final int partIndex; + @Nullable private final DataSource initDataSource; @Nullable private final DataSpec initDataSpec; @Nullable private final HlsMediaChunkExtractor previousExtractor; @@ -243,6 +246,7 @@ private HlsMediaChunk( long startTimeUs, long endTimeUs, long chunkMediaSequence, + int partIndex, int discontinuitySequenceNumber, boolean hasGapTag, boolean isMasterTimestampSource, @@ -262,6 +266,7 @@ private HlsMediaChunk( endTimeUs, chunkMediaSequence); this.mediaSegmentEncrypted = mediaSegmentEncrypted; + this.partIndex = partIndex; this.discontinuitySequenceNumber = discontinuitySequenceNumber; this.initDataSpec = initDataSpec; this.initDataSource = initDataSource; @@ -541,4 +546,13 @@ private static DataSource buildDataSource( } return dataSource; } + + private static boolean isIndependent( + HlsChunkSource.SegmentBaseHolder segmentBaseHolder, HlsMediaPlaylist mediaPlaylist) { + if (segmentBaseHolder.segmentBase instanceof HlsMediaPlaylist.Part) { + return ((HlsMediaPlaylist.Part) segmentBaseHolder.segmentBase).isIndependent + || (segmentBaseHolder.partIndex == 0 && mediaPlaylist.hasIndependentSegments); + } + return mediaPlaylist.hasIndependentSegments; + } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 2fa01c43fcf..475df1987bf 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -797,9 +797,9 @@ private static HlsMediaPlaylist parseMediaPlaylist( } String url = parseStringAttr(line, REGEX_URI, variableDefinitions); long byteRangeStart = - parseOptionalLongAttr(line, REGEX_BYTERANGE_START, /* defaultValue= */ 0); + parseOptionalLongAttr(line, REGEX_BYTERANGE_START, /* defaultValue= */ C.LENGTH_UNSET); long byteRangeLength = - parseOptionalLongAttr(line, REGEX_BYTERANGE_LENGTH, /* defaultValue= */ C.TIME_UNSET); + parseOptionalLongAttr(line, REGEX_BYTERANGE_LENGTH, /* defaultValue= */ C.LENGTH_UNSET); @Nullable String segmentEncryptionIV = getSegmentEncryptionIV( @@ -811,21 +811,24 @@ private static HlsMediaPlaylist parseMediaPlaylist( playlistProtectionSchemes = getPlaylistProtectionSchemes(encryptionScheme, schemeDatas); } } - preloadPart = - new Part( - url, - initializationSegment, - /* durationUs= */ 0, - relativeDiscontinuitySequence, - partStartTimeUs, - cachedDrmInitData, - fullSegmentEncryptionKeyUri, - segmentEncryptionIV, - byteRangeStart, - byteRangeLength, - /* hasGapTag= */ false, - /* isIndependent= */ false, - /* isPreload= */ true); + if (byteRangeStart == C.LENGTH_UNSET || byteRangeLength != C.LENGTH_UNSET) { + // Skip preload part if it is an unbounded range request. + preloadPart = + new Part( + url, + initializationSegment, + /* durationUs= */ 0, + relativeDiscontinuitySequence, + partStartTimeUs, + cachedDrmInitData, + fullSegmentEncryptionKeyUri, + segmentEncryptionIV, + byteRangeStart != C.LENGTH_UNSET ? byteRangeStart : 0, + byteRangeLength, + /* hasGapTag= */ false, + /* isIndependent= */ false, + /* isPreload= */ true); + } } else if (line.startsWith(TAG_PART)) { @Nullable String segmentEncryptionIV = diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPlaylistSegmentIteratorTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPlaylistSegmentIteratorTest.java new file mode 100644 index 00000000000..d4b4b1b4aa2 --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPlaylistSegmentIteratorTest.java @@ -0,0 +1,222 @@ +/* + * 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.junit.Assert.fail; + +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.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.Iterables; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link HlsChunkSource.HlsMediaPlaylistSegmentIterator}. */ +@RunWith(AndroidJUnit4.class) +public class HlsMediaPlaylistSegmentIteratorTest { + + public static final String LOW_LATENCY_SEGMENTS_AND_PARTS = + "media/m3u8/live_low_latency_segments_and_parts"; + public static final String SEGMENTS_ONLY = "media/m3u8/live_low_latency_segments_only"; + + @Test + public void create_withMediaSequenceBehindLiveWindow_isEmpty() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(LOW_LATENCY_SEGMENTS_AND_PARTS); + + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, mediaPlaylist.mediaSequence - 1, /* partIndex= */ C.INDEX_UNSET)); + + assertThat(hlsMediaPlaylistSegmentIterator.next()).isFalse(); + } + + @Test + public void create_withMediaSequenceBeforeTrailingPartSegment_isEmpty() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(LOW_LATENCY_SEGMENTS_AND_PARTS); + + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, + mediaPlaylist.mediaSequence + mediaPlaylist.segments.size() + 1, + /* partIndex= */ C.INDEX_UNSET)); + + assertThat(hlsMediaPlaylistSegmentIterator.next()).isFalse(); + } + + @Test + public void create_withPartIndexBeforeLastTrailingPartSegment_isEmpty() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(LOW_LATENCY_SEGMENTS_AND_PARTS); + + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, + mediaPlaylist.mediaSequence + mediaPlaylist.segments.size(), + /* partIndex= */ 3)); + + assertThat(hlsMediaPlaylistSegmentIterator.next()).isFalse(); + } + + @Test + public void next_conventionalLiveStartIteratorAtSecondSegment_correctElements() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(SEGMENTS_ONLY); + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, /* mediaSequence= */ 11, /* partIndex= */ C.INDEX_UNSET)); + + List datasSpecs = new ArrayList<>(); + while (hlsMediaPlaylistSegmentIterator.next()) { + datasSpecs.add(hlsMediaPlaylistSegmentIterator.getDataSpec()); + } + + assertThat(datasSpecs).hasSize(5); + assertThat(datasSpecs.get(0).uri.toString()).isEqualTo("fileSequence11.ts"); + assertThat(Iterables.getLast(datasSpecs).uri.toString()).isEqualTo("fileSequence15.ts"); + } + + @Test + public void next_startIteratorAtFirstSegment_correctElements() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(LOW_LATENCY_SEGMENTS_AND_PARTS); + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, /* mediaSequence= */ 10, /* partIndex= */ C.INDEX_UNSET)); + + List datasSpecs = new ArrayList<>(); + while (hlsMediaPlaylistSegmentIterator.next()) { + datasSpecs.add(hlsMediaPlaylistSegmentIterator.getDataSpec()); + } + + assertThat(datasSpecs).hasSize(9); + // The iterator starts with 6 segments. + assertThat(datasSpecs.get(0).uri.toString()).isEqualTo("fileSequence10.ts"); + // Followed by trailing parts. + assertThat(datasSpecs.get(6).uri.toString()).isEqualTo("fileSequence16.0.ts"); + // The preload part is the last. + assertThat(Iterables.getLast(datasSpecs).uri.toString()).isEqualTo("fileSequence16.2.ts"); + } + + @Test + public void next_startIteratorAtFirstPartInaSegment_usesFullSegment() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(LOW_LATENCY_SEGMENTS_AND_PARTS); + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, /* mediaSequence= */ 14, /* partIndex= */ 0)); + + List datasSpecs = new ArrayList<>(); + while (hlsMediaPlaylistSegmentIterator.next()) { + datasSpecs.add(hlsMediaPlaylistSegmentIterator.getDataSpec()); + } + + assertThat(datasSpecs).hasSize(5); + // The iterator starts with 6 segments. + assertThat(datasSpecs.get(0).uri.toString()).isEqualTo("fileSequence14.ts"); + assertThat(datasSpecs.get(1).uri.toString()).isEqualTo("fileSequence15.ts"); + // Followed by trailing parts. + assertThat(datasSpecs.get(2).uri.toString()).isEqualTo("fileSequence16.0.ts"); + assertThat(datasSpecs.get(3).uri.toString()).isEqualTo("fileSequence16.1.ts"); + // The preload part is the last. + assertThat(Iterables.getLast(datasSpecs).uri.toString()).isEqualTo("fileSequence16.2.ts"); + } + + @Test + public void next_startIteratorAtTrailingPart_correctElements() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(LOW_LATENCY_SEGMENTS_AND_PARTS); + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, /* mediaSequence= */ 16, /* partIndex= */ 1)); + + List datasSpecs = new ArrayList<>(); + while (hlsMediaPlaylistSegmentIterator.next()) { + datasSpecs.add(hlsMediaPlaylistSegmentIterator.getDataSpec()); + } + + assertThat(datasSpecs).hasSize(2); + // The iterator starts with 2 parts. + assertThat(datasSpecs.get(0).uri.toString()).isEqualTo("fileSequence16.1.ts"); + // The preload part is the last. + assertThat(Iterables.getLast(datasSpecs).uri.toString()).isEqualTo("fileSequence16.2.ts"); + } + + @Test + public void next_startIteratorAtPartWithinSegment_correctElements() { + HlsMediaPlaylist mediaPlaylist = getHlsMediaPlaylist(LOW_LATENCY_SEGMENTS_AND_PARTS); + HlsChunkSource.HlsMediaPlaylistSegmentIterator hlsMediaPlaylistSegmentIterator = + new HlsChunkSource.HlsMediaPlaylistSegmentIterator( + mediaPlaylist.baseUri, + /* startOfPlaylistInPeriodUs= */ 0, + HlsChunkSource.getSegmentBaseList( + mediaPlaylist, /* mediaSequence= */ 14, /* partIndex= */ 1)); + + List datasSpecs = new ArrayList<>(); + while (hlsMediaPlaylistSegmentIterator.next()) { + datasSpecs.add(hlsMediaPlaylistSegmentIterator.getDataSpec()); + } + + assertThat(datasSpecs).hasSize(7); + // The iterator starts with 11 parts. + assertThat(datasSpecs.get(0).uri.toString()).isEqualTo("fileSequence14.1.ts"); + assertThat(datasSpecs.get(1).uri.toString()).isEqualTo("fileSequence14.2.ts"); + assertThat(datasSpecs.get(2).uri.toString()).isEqualTo("fileSequence14.3.ts"); + // Use a segment in between if possible. + assertThat(datasSpecs.get(3).uri.toString()).isEqualTo("fileSequence15.ts"); + // Then parts again. + assertThat(datasSpecs.get(4).uri.toString()).isEqualTo("fileSequence16.0.ts"); + assertThat(datasSpecs.get(5).uri.toString()).isEqualTo("fileSequence16.1.ts"); + assertThat(datasSpecs.get(6).uri.toString()).isEqualTo("fileSequence16.2.ts"); + } + + private static HlsMediaPlaylist getHlsMediaPlaylist(String file) { + try { + return (HlsMediaPlaylist) + new HlsPlaylistParser() + .parse( + Uri.EMPTY, + TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), file)); + } catch (IOException e) { + fail(e.getMessage()); + } + return null; + } +} 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 0d9a75cce2d..3246dbb16c3 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 @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.Iterables; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -362,6 +363,7 @@ public void parseMediaPlaylist_withParts_parsesPartWithAllAttributes() throws IO assertThat(secondPart.byteRangeOffset).isEqualTo(1234); // Assert trailing parts. HlsMediaPlaylist.Part thirdPart = playlist.trailingParts.get(0); + // Assert tailing parts. assertThat(thirdPart.byteRangeLength).isEqualTo(1000); assertThat(thirdPart.byteRangeOffset).isEqualTo(1234); assertThat(thirdPart.relativeStartTimeUs).isEqualTo(8_000_000); @@ -544,6 +546,27 @@ public void parseMediaPlaylist_withMultiplePreloadHintTypeParts_picksOnlyFirstPr assertThat(playlist.trailingParts.get(1).isPreload).isTrue(); } + @Test + public void parseMediaPlaylist_withUnboundedPreloadHintTypePart_ignoresPreloadPart() + throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:4\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MEDIA-SEQUENCE:266\n" + + "#EXT-X-PART:DURATION=2.00000,URI=\"part267.1.ts\"\n" + + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"filePart267.2.ts,BYTERANGE-START=0\"\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + + assertThat(playlist.trailingParts).hasSize(1); + assertThat(Iterables.getLast(playlist.trailingParts).url).isEqualTo("part267.1.ts"); + assertThat(Iterables.getLast(playlist.trailingParts).isPreload).isFalse(); + } + @Test public void parseMediaPlaylist_withPreloadHintTypePartAndAesPlayReadyKey_inheritsDrmInitData() throws IOException { diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_segments_and_parts b/testdata/src/test/assets/media/m3u8/live_low_latency_segments_and_parts new file mode 100644 index 00000000000..5e0e7bc1de0 --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_segments_and_parts @@ -0,0 +1,28 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000400 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.1.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.2.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.3.ts" +#EXTINF:4.00000, +fileSequence14.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence15.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence15.1.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence15.2.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence15.3.ts" +#EXTINF:4.00000, +fileSequence15.ts +#EXT-X-PART:DURATION=1.00000,URI="fileSequence16.0.ts" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence16.1.ts" +#EXT-X-PRELOAD-HINT:TYPE=PART,URI="fileSequence16.2.ts" diff --git a/testdata/src/test/assets/media/m3u8/live_low_latency_segments_only b/testdata/src/test/assets/media/m3u8/live_low_latency_segments_only new file mode 100644 index 00000000000..410366456cc --- /dev/null +++ b/testdata/src/test/assets/media/m3u8/live_low_latency_segments_only @@ -0,0 +1,16 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.ts +#EXTINF:4.00000, +fileSequence11.ts +#EXTINF:4.00000, +fileSequence12.ts +#EXTINF:4.00000, +fileSequence13.ts +#EXTINF:4.00000, +fileSequence14.ts +#EXTINF:4.00000, +fileSequence15.ts From a038b421dd6373d63afe107f294176d4ab4123d0 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Thu, 12 Nov 2020 14:18:42 +0000 Subject: [PATCH 276/693] Add additional SEF data types. PiperOrigin-RevId: 342034166 --- .../exoplayer2/extractor/mp4/SefReader.java | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java index c44671a2557..1b2e6a445f0 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java @@ -63,10 +63,20 @@ /** Supported data types. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({TYPE_SLOW_MOTION_DATA}) + @IntDef({ + TYPE_SLOW_MOTION_DATA, + TYPE_SUPER_SLOW_MOTION_DATA, + TYPE_SUPER_SLOW_MOTION_BGM, + TYPE_SUPER_SLOW_MOTION_EDIT_DATA, + TYPE_SUPER_SLOW_DEFLICKERING_ON + }) private @interface DataType {} - private static final int TYPE_SLOW_MOTION_DATA = 0x0890; + private static final int TYPE_SLOW_MOTION_DATA = 0x0890; // 2192 + private static final int TYPE_SUPER_SLOW_MOTION_DATA = 0x0b00; // 2816 + private static final int TYPE_SUPER_SLOW_MOTION_BGM = 0x0b01; // 2817 + private static final int TYPE_SUPER_SLOW_MOTION_EDIT_DATA = 0x0b03; // 2819 + private static final int TYPE_SUPER_SLOW_DEFLICKERING_ON = 0x0b04; // 2820 private static final String TAG = "SefReader"; @@ -150,14 +160,20 @@ private void readSdrs(ExtractorInput input, PositionHolder seekPosition) throws for (int i = 0; i < sdrsLength / LENGTH_OF_ONE_SDR; i++) { scratch.skipBytes(2); // SDR data sub info flag and reserved bits (2). @DataType int dataType = scratch.readLittleEndianShort(); - if (dataType == TYPE_SLOW_MOTION_DATA) { - // The read int is the distance from the tail info to the start of the metadata. - // Calculated as an offset from the start by working backwards. - long startOffset = streamLength - tailLength - scratch.readLittleEndianInt(); - int size = scratch.readLittleEndianInt(); - dataReferences.add(new DataReference(dataType, startOffset, size)); - } else { - scratch.skipBytes(8); // startPosition (4), size (4). + switch (dataType) { + case TYPE_SLOW_MOTION_DATA: + case TYPE_SUPER_SLOW_MOTION_DATA: + case TYPE_SUPER_SLOW_MOTION_BGM: + case TYPE_SUPER_SLOW_MOTION_EDIT_DATA: + case TYPE_SUPER_SLOW_DEFLICKERING_ON: + // The read int is the distance from the tail info to the start of the metadata. + // Calculated as an offset from the start by working backwards. + long startOffset = streamLength - tailLength - scratch.readLittleEndianInt(); + int size = scratch.readLittleEndianInt(); + dataReferences.add(new DataReference(dataType, startOffset, size)); + break; + default: + scratch.skipBytes(8); // startPosition (4), size (4). } } @@ -208,6 +224,24 @@ private void readSefData(ExtractorInput input, List slowMotionMe } } + @DataType + private static int nameToDataType(String name) throws ParserException { + switch (name) { + case "SlowMotion_Data": + return TYPE_SLOW_MOTION_DATA; + case "Super_SlowMotion_Data": + return TYPE_SUPER_SLOW_MOTION_DATA; + case "Super_SlowMotion_BGM": + return TYPE_SUPER_SLOW_MOTION_BGM; + case "Super_SlowMotion_Edit_Data": + return TYPE_SUPER_SLOW_MOTION_EDIT_DATA; + case "Super_SlowMotion_Deflickering_On": + return TYPE_SUPER_SLOW_DEFLICKERING_ON; + default: + throw new ParserException("Invalid SEF name"); + } + } + private static final class DataReference { @DataType public final int dataType; public final long startOffset; From 99b87139dfe2415b9d8b3fe8c3beef5ff743d07b Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 12 Nov 2020 15:07:21 +0000 Subject: [PATCH 277/693] Change Truth assertions in DashWidevineOfflineTest to increase clarity PiperOrigin-RevId: 342040610 --- .../playbacktests/gts/DashWidevineOfflineTest.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java index b657c10d224..8e6a9ce5bc8 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java @@ -171,9 +171,9 @@ public void widevineOfflineExpiredLicenseV22() throws Exception { long licenseDuration = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId).first; assertWithMessage( - "License duration should be less than 30 sec. " + "Server settings might have changed.") - .that(licenseDuration < 30) - .isTrue(); + "License duration should be less than 30 sec. Server settings might have changed.") + .that(licenseDuration) + .isLessThan(30); while (licenseDuration > 0) { synchronized (this) { wait(licenseDuration * 1000 + 2000); @@ -182,8 +182,8 @@ public void widevineOfflineExpiredLicenseV22() throws Exception { licenseDuration = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId).first; assertWithMessage("License duration should be decreasing.") - .that(previousDuration > licenseDuration) - .isTrue(); + .that(licenseDuration) + .isLessThan(previousDuration); } // DefaultDrmSessionManager should renew the license and stream play fine @@ -203,8 +203,8 @@ public void widevineOfflineLicenseExpiresOnPauseV22() throws Exception { long licenseDuration = licenseDurationRemainingSec.first; assertWithMessage( "License duration should be less than 30 sec. Server settings might have changed.") - .that(licenseDuration < 30) - .isTrue(); + .that(licenseDuration) + .isLessThan(30); ActionSchedule schedule = new ActionSchedule.Builder(TAG) .waitForPlaybackState(Player.STATE_READY) .delay(3000).pause().delay(licenseDuration * 1000 + 2000).play().build(); From 55afddf05c6aa59c50655df077cfb04821b0809c Mon Sep 17 00:00:00 2001 From: samrobinson Date: Thu, 12 Nov 2020 16:06:20 +0000 Subject: [PATCH 278/693] Adjust SEF slow motion parsing to base data type off name. PiperOrigin-RevId: 342050008 --- .../exoplayer2/extractor/mp4/SefReader.java | 96 ++++++++++++------- 1 file changed, 59 insertions(+), 37 deletions(-) diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java index 1b2e6a445f0..aaf4975352f 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java @@ -16,10 +16,8 @@ package com.google.android.exoplayer2.extractor.mp4; import static com.google.android.exoplayer2.extractor.Extractor.RESULT_SEEK; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import androidx.annotation.IntDef; -import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.Extractor; @@ -34,7 +32,6 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; -import java.util.Collections; import java.util.List; /** @@ -80,15 +77,20 @@ private static final String TAG = "SefReader"; - // Hex representation of `SEFT` (in ASCII). This is the last byte of a file that has Samsung - // Extension Format (SEF) data. + /** + * Hex representation of `SEFT` (in ASCII). + * + *

    This is the last 4 bytes of a file that has Samsung Extension Format (SEF) data. + */ private static final int SAMSUNG_TAIL_SIGNATURE = 0x53454654; - - // Start signature (4 bytes), SEF version (4 bytes), SDR count (4 bytes). + /** Start signature (4 bytes), SEF version (4 bytes), SDR count (4 bytes). */ private static final int TAIL_HEADER_LENGTH = 12; - // Tail offset (4 bytes), tail signature (4 bytes). + /** Tail offset (4 bytes), tail signature (4 bytes). */ private static final int TAIL_FOOTER_LENGTH = 8; + private static final int LENGTH_OF_ONE_SDR = 12; + private static final Splitter COLON_SPLITTER = Splitter.on(':'); + private static final Splitter ASTERISK_SPLITTER = Splitter.on('*'); private final List dataReferences; @State private int readerState; @@ -145,7 +147,7 @@ private void checkForSefData(ExtractorInput input, PositionHolder seekPosition) return; } - // input.getPosition is at the very end of the tail, so jump forward by sefTailLength, but + // input.getPosition is at the very end of the tail, so jump forward by tailLength, but // account for the tail header, which needs to be ignored. seekPosition.position = input.getPosition() - (tailLength - TAIL_HEADER_LENGTH); readerState = STATE_READING_SDRS; @@ -182,46 +184,66 @@ private void readSdrs(ExtractorInput input, PositionHolder seekPosition) throws return; } - Collections.sort(dataReferences, (o1, o2) -> Long.compare(o1.startOffset, o2.startOffset)); readerState = STATE_READING_SEF_DATA; seekPosition.position = dataReferences.get(0).startOffset; } private void readSefData(ExtractorInput input, List slowMotionMetadataEntries) throws IOException { - checkNotNull(dataReferences); - Splitter splitter = Splitter.on(':'); + long dataStartOffset = input.getPosition(); int totalDataLength = (int) (input.getLength() - input.getPosition() - tailLength); - ParsableByteArray scratch = new ParsableByteArray(/* limit= */ totalDataLength); - input.readFully(scratch.getData(), 0, totalDataLength); + ParsableByteArray data = new ParsableByteArray(/* limit= */ totalDataLength); + input.readFully(data.getData(), 0, totalDataLength); - int totalDataReferenceBytesConsumed = 0; for (int i = 0; i < dataReferences.size(); i++) { DataReference dataReference = dataReferences.get(i); - if (dataReference.dataType == TYPE_SLOW_MOTION_DATA) { - scratch.skipBytes(23); // data type (2), data sub info (2), name len (4), name (15). - List segments = new ArrayList<>(); - int dataReferenceEndPosition = totalDataReferenceBytesConsumed + dataReference.size; - while (scratch.getPosition() < dataReferenceEndPosition) { - @Nullable String data = scratch.readDelimiterTerminatedString('*'); - List values = splitter.splitToList(checkNotNull(data)); - if (values.size() != 3) { - throw new ParserException(); - } - try { - int startTimeMs = Integer.parseInt(values.get(0)); - int endTimeMs = Integer.parseInt(values.get(1)); - int speedMode = Integer.parseInt(values.get(2)); - int speedDivisor = 1 << (speedMode - 1); - segments.add(new SlowMotionData.Segment(startTimeMs, endTimeMs, speedDivisor)); - } catch (NumberFormatException e) { - throw new ParserException(e); - } - } - totalDataReferenceBytesConsumed += dataReference.size; - slowMotionMetadataEntries.add(new SlowMotionData(segments)); + int intendedPosition = (int) (dataReference.startOffset - dataStartOffset); + data.setPosition(intendedPosition); + + // The data type is derived from the name because the SEF format has inconsistent data type + // values. + data.skipBytes(4); // data type (2), data sub info (2). + int nameLength = data.readLittleEndianInt(); + String name = data.readString(nameLength); + @DataType int dataType = nameToDataType(name); + + int remainingDataLength = dataReference.size - (8 + nameLength); + switch (dataType) { + case TYPE_SLOW_MOTION_DATA: + slowMotionMetadataEntries.add(readSlowMotionData(data, remainingDataLength)); + break; + case TYPE_SUPER_SLOW_MOTION_DATA: + case TYPE_SUPER_SLOW_MOTION_BGM: + case TYPE_SUPER_SLOW_MOTION_EDIT_DATA: + case TYPE_SUPER_SLOW_DEFLICKERING_ON: + break; + default: + throw new IllegalStateException(); + } + } + } + + private static SlowMotionData readSlowMotionData(ParsableByteArray data, int dataLength) + throws ParserException { + List segments = new ArrayList<>(); + String dataString = data.readString(dataLength); + List segmentStrings = ASTERISK_SPLITTER.splitToList(dataString); + for (int i = 0; i < segmentStrings.size(); i++) { + List values = COLON_SPLITTER.splitToList(segmentStrings.get(i)); + if (values.size() != 3) { + throw new ParserException(); + } + try { + int startTimeMs = Integer.parseInt(values.get(0)); + int endTimeMs = Integer.parseInt(values.get(1)); + int speedMode = Integer.parseInt(values.get(2)); + int speedDivisor = 1 << (speedMode - 1); + segments.add(new SlowMotionData.Segment(startTimeMs, endTimeMs, speedDivisor)); + } catch (NumberFormatException e) { + throw new ParserException(e); } } + return new SlowMotionData(segments); } @DataType From 7fd78666d1f6f532bc43e482af37f11abda1a31d Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 12 Nov 2020 17:10:17 +0000 Subject: [PATCH 279/693] Add Robolectric playback tests for existing MKA assets PiperOrigin-RevId: 342060794 --- .../exoplayer2/e2etest/MkaPlaybackTest.java | 78 ++++++++++ .../mka/bear-flac-16bit.mka.dump | 32 ++++ .../mka/bear-flac-24bit.mka.dump | 32 ++++ .../mka/bear-opus-negative-gain.mka.dump | 140 ++++++++++++++++++ .../playbackdumps/mka/bear-opus.mka.dump | 140 ++++++++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/e2etest/MkaPlaybackTest.java create mode 100644 testdata/src/test/assets/playbackdumps/mka/bear-flac-16bit.mka.dump create mode 100644 testdata/src/test/assets/playbackdumps/mka/bear-flac-24bit.mka.dump create mode 100644 testdata/src/test/assets/playbackdumps/mka/bear-opus-negative-gain.mka.dump create mode 100644 testdata/src/test/assets/playbackdumps/mka/bear-opus.mka.dump diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/MkaPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/MkaPlaybackTest.java new file mode 100644 index 00000000000..b58acd5f793 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/MkaPlaybackTest.java @@ -0,0 +1,78 @@ +/* + * 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.e2etest; + +import android.graphics.SurfaceTexture; +import android.view.Surface; +import androidx.test.core.app.ApplicationProvider; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.robolectric.PlaybackOutput; +import com.google.android.exoplayer2.robolectric.ShadowMediaCodecConfig; +import com.google.android.exoplayer2.robolectric.TestPlayerRunHelper; +import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; +import com.google.android.exoplayer2.testutil.DumpFileAsserts; +import com.google.common.collect.ImmutableList; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; +import org.robolectric.annotation.Config; + +/** End-to-end tests using MKA samples. */ +// TODO(b/143232359): Remove once https://issuetracker.google.com/143232359 is resolved. +@Config(sdk = 29) +@RunWith(ParameterizedRobolectricTestRunner.class) +public final class MkaPlaybackTest { + @Parameters(name = "{0}") + public static ImmutableList mediaSamples() { + return ImmutableList.of( + "bear-flac-16bit.mka", + "bear-flac-24bit.mka", + "bear-opus.mka", + "bear-opus-negative-gain.mka"); + } + + @ParameterizedRobolectricTestRunner.Parameter public String inputFile; + + @Rule + public ShadowMediaCodecConfig mediaCodecConfig = + ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + + @Test + public void test() throws Exception { + SimpleExoPlayer player = + new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) + .setClock(new AutoAdvancingFakeClock()) + .build(); + player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, mediaCodecConfig); + + player.setMediaItem(MediaItem.fromUri("asset:///media/mka/" + inputFile)); + player.prepare(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + ApplicationProvider.getApplicationContext(), + playbackOutput, + "playbackdumps/mka/" + inputFile + ".dump"); + } +} diff --git a/testdata/src/test/assets/playbackdumps/mka/bear-flac-16bit.mka.dump b/testdata/src/test/assets/playbackdumps/mka/bear-flac-16bit.mka.dump new file mode 100644 index 00000000000..17b7a890858 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mka/bear-flac-16bit.mka.dump @@ -0,0 +1,32 @@ +MediaCodec (audio/flac): + buffers.length = 30 + buffers[0] = length 3608, hash EE7EFC6C + buffers[1] = length 4278, hash 7BAF8A9 + buffers[2] = length 3763, hash 40C70127 + buffers[3] = length 3568, hash 51FBCC44 + buffers[4] = length 4339, hash FFA8D5A2 + buffers[5] = length 3788, hash 5F5CFFDD + buffers[6] = length 3714, hash DD46DF94 + buffers[7] = length 3766, hash 4D96187C + buffers[8] = length 3806, hash 86DB14D4 + buffers[9] = length 3755, hash AF217312 + buffers[10] = length 3824, hash 37A61E58 + buffers[11] = length 4785, hash F5200FA7 + buffers[12] = length 4619, hash 8D64F73D + buffers[13] = length 3915, hash 73500C2C + buffers[14] = length 4763, hash 2FCDE683 + buffers[15] = length 4154, hash A5AB5A5D + buffers[16] = length 4547, hash BF9A246A + buffers[17] = length 4005, hash 83EE3482 + buffers[18] = length 3905, hash E6EE5342 + buffers[19] = length 3797, hash AE6F03B2 + buffers[20] = length 3863, hash E022115D + buffers[21] = length 3958, hash B3F0059 + buffers[22] = length 3856, hash 81DEB55F + buffers[23] = length 3958, hash EC7E2889 + buffers[24] = length 3937, hash 2CC2FD97 + buffers[25] = length 3934, hash F80FF227 + buffers[26] = length 3954, hash 17C9E273 + buffers[27] = length 2299, hash 3F1A98BF + buffers[28] = length 333, hash FC52D488 + buffers[29] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mka/bear-flac-24bit.mka.dump b/testdata/src/test/assets/playbackdumps/mka/bear-flac-24bit.mka.dump new file mode 100644 index 00000000000..75bfed7e793 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mka/bear-flac-24bit.mka.dump @@ -0,0 +1,32 @@ +MediaCodec (audio/flac): + buffers.length = 30 + buffers[0] = length 3610, hash C0423D1B + buffers[1] = length 4280, hash 2E8BEA95 + buffers[2] = length 3765, hash EDE8EB27 + buffers[3] = length 3570, hash 49DC5122 + buffers[4] = length 4341, hash 54B19279 + buffers[5] = length 3790, hash 2A45E2ED + buffers[6] = length 3716, hash 7C9037F7 + buffers[7] = length 3768, hash AD0272F9 + buffers[8] = length 3808, hash 5EA67F93 + buffers[9] = length 3757, hash 49A182E0 + buffers[10] = length 3826, hash 3B1D36A5 + buffers[11] = length 4787, hash BAD9FD83 + buffers[12] = length 4621, hash FF75E71D + buffers[13] = length 3917, hash 576128F0 + buffers[14] = length 4765, hash 533216EE + buffers[15] = length 4156, hash CABB5909 + buffers[16] = length 4549, hash 7E10A60C + buffers[17] = length 4007, hash 10851893 + buffers[18] = length 3907, hash E54710ED + buffers[19] = length 3799, hash 2A466310 + buffers[20] = length 3865, hash E0187577 + buffers[21] = length 3960, hash BD8BAE69 + buffers[22] = length 3858, hash A5AFCF25 + buffers[23] = length 3960, hash 68C635 + buffers[24] = length 3939, hash 6D7A7AB9 + buffers[25] = length 3936, hash 70272966 + buffers[26] = length 3956, hash 6E328CC8 + buffers[27] = length 2476, hash AF155313 + buffers[28] = length 335, hash 1B7D092B + buffers[29] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mka/bear-opus-negative-gain.mka.dump b/testdata/src/test/assets/playbackdumps/mka/bear-opus-negative-gain.mka.dump new file mode 100644 index 00000000000..5c3a9f92862 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mka/bear-opus-negative-gain.mka.dump @@ -0,0 +1,140 @@ +MediaCodec (audio/opus): + buffers.length = 138 + buffers[0] = length 375, hash 147EA9B + buffers[1] = length 187, hash C8ADD7C2 + buffers[2] = length 175, hash A6D94D6E + buffers[3] = length 162, hash 45359884 + buffers[4] = length 163, hash CBB836AF + buffers[5] = length 293, hash EEB23890 + buffers[6] = length 160, hash 7843AFDA + buffers[7] = length 162, hash 607E26A4 + buffers[8] = length 164, hash C1423D63 + buffers[9] = length 169, hash 90CEDF8C + buffers[10] = length 165, hash 97A6A3F7 + buffers[11] = length 179, hash 2EA2049F + buffers[12] = length 168, hash FCD51794 + buffers[13] = length 162, hash 80D9FBC0 + buffers[14] = length 162, hash BB673AC7 + buffers[15] = length 161, hash 8D5CC41B + buffers[16] = length 161, hash 5F5E6270 + buffers[17] = length 165, hash 117B14D9 + buffers[18] = length 166, hash D8BFD4 + buffers[19] = length 162, hash 61D76007 + buffers[20] = length 165, hash 78245BE8 + buffers[21] = length 165, hash A5F5B919 + buffers[22] = length 255, hash 1F42ECE2 + buffers[23] = length 165, hash D89D3EF0 + buffers[24] = length 164, hash C44C8E79 + buffers[25] = length 163, hash FFCE2E84 + buffers[26] = length 184, hash FD7BF02A + buffers[27] = length 162, hash 59074C0F + buffers[28] = length 162, hash 41CAF78D + buffers[29] = length 163, hash 50F0BCBD + buffers[30] = length 163, hash FABC49B3 + buffers[31] = length 256, hash 8515E521 + buffers[32] = length 244, hash D5F80618 + buffers[33] = length 162, hash A23FA880 + buffers[34] = length 163, hash 5D99DCD2 + buffers[35] = length 163, hash 37A4EB87 + buffers[36] = length 164, hash 4C190996 + buffers[37] = length 164, hash A2F6E788 + buffers[38] = length 162, hash E7353EFB + buffers[39] = length 161, hash FFF24D5F + buffers[40] = length 162, hash 95B27AB0 + buffers[41] = length 163, hash C43CB498 + buffers[42] = length 164, hash 438F5714 + buffers[43] = length 163, hash BDB72F57 + buffers[44] = length 162, hash 3194B57A + buffers[45] = length 163, hash D7CC025 + buffers[46] = length 162, hash F9E19F4D + buffers[47] = length 194, hash EED4C2BD + buffers[48] = length 164, hash ABFAEEFE + buffers[49] = length 163, hash 7487380A + buffers[50] = length 163, hash D4BFFB76 + buffers[51] = length 164, hash F3EB6797 + buffers[52] = length 163, hash 82B7ABB7 + buffers[53] = length 177, hash 921FEDAE + buffers[54] = length 162, hash BC7D176B + buffers[55] = length 165, hash 32DAEB04 + buffers[56] = length 164, hash 55FDBC77 + buffers[57] = length 230, hash FC32522D + buffers[58] = length 177, hash DF834667 + buffers[59] = length 161, hash F2ADFBCA + buffers[60] = length 161, hash 13CB7679 + buffers[61] = length 164, hash A12B20AC + buffers[62] = length 163, hash 38D448B + buffers[63] = length 164, hash BFE96C9A + buffers[64] = length 161, hash 921431E3 + buffers[65] = length 162, hash 9DDE27E0 + buffers[66] = length 165, hash 42C01110 + buffers[67] = length 163, hash C244C6B1 + buffers[68] = length 162, hash 288A7D7A + buffers[69] = length 164, hash 6DDF8E96 + buffers[70] = length 312, hash DD1760ED + buffers[71] = length 164, hash 40BD6AB0 + buffers[72] = length 167, hash 45FEB94 + buffers[73] = length 164, hash 1783D8D9 + buffers[74] = length 165, hash 7F68CB47 + buffers[75] = length 163, hash 431D98B9 + buffers[76] = length 164, hash 2F7F0A03 + buffers[77] = length 164, hash 330E9D40 + buffers[78] = length 161, hash 670A6D84 + buffers[79] = length 162, hash 55CEAB6A + buffers[80] = length 161, hash 690C1C44 + buffers[81] = length 311, hash 507DC3E7 + buffers[82] = length 226, hash 2D0C0942 + buffers[83] = length 163, hash 47A75060 + buffers[84] = length 163, hash 198A78EB + buffers[85] = length 165, hash F7AF184 + buffers[86] = length 163, hash 7EC009AE + buffers[87] = length 163, hash 7ACF600A + buffers[88] = length 170, hash 67F513C9 + buffers[89] = length 162, hash E0116535 + buffers[90] = length 164, hash 6C4C8BC1 + buffers[91] = length 163, hash 73E55623 + buffers[92] = length 162, hash 614AB0EE + buffers[93] = length 162, hash 49E038A6 + buffers[94] = length 162, hash 45BBCDDF + buffers[95] = length 163, hash 94E6047A + buffers[96] = length 162, hash FA40E646 + buffers[97] = length 163, hash 54F3E885 + buffers[98] = length 163, hash 42EA2C3C + buffers[99] = length 164, hash 11E5DC72 + buffers[100] = length 161, hash FB697FB7 + buffers[101] = length 164, hash 45137460 + buffers[102] = length 232, hash F8A33CF3 + buffers[103] = length 163, hash B2562537 + buffers[104] = length 163, hash D07ADBF + buffers[105] = length 163, hash 2AE2FC1E + buffers[106] = length 162, hash F574ABD + buffers[107] = length 162, hash 8A20D2FC + buffers[108] = length 162, hash BD37BF40 + buffers[109] = length 163, hash 81DF11E8 + buffers[110] = length 165, hash 236877C0 + buffers[111] = length 226, hash 6B5CD992 + buffers[112] = length 162, hash 7F697CCA + buffers[113] = length 161, hash 4C2993B4 + buffers[114] = length 163, hash 1DE49094 + buffers[115] = length 162, hash DCA5BB9B + buffers[116] = length 165, hash 66B62984 + buffers[117] = length 161, hash 994C6D54 + buffers[118] = length 163, hash DA5BA1F1 + buffers[119] = length 187, hash 7F6C5537 + buffers[120] = length 161, hash D0AF4628 + buffers[121] = length 161, hash 8A49A435 + buffers[122] = length 163, hash 90D7B180 + buffers[123] = length 162, hash C459D78E + buffers[124] = length 161, hash D7766E6B + buffers[125] = length 187, hash E0449F61 + buffers[126] = length 162, hash 203F238E + buffers[127] = length 163, hash 15F81805 + buffers[128] = length 161, hash 8496E779 + buffers[129] = length 163, hash DF6A28D0 + buffers[130] = length 233, hash 39CAC5CB + buffers[131] = length 250, hash 40F8863A + buffers[132] = length 248, hash BB880EB4 + buffers[133] = length 247, hash A93865FE + buffers[134] = length 244, hash ED7E6DB5 + buffers[135] = length 252, hash 2DD353C4 + buffers[136] = length 244, hash CE73B41E + buffers[137] = length 0, hash 1 diff --git a/testdata/src/test/assets/playbackdumps/mka/bear-opus.mka.dump b/testdata/src/test/assets/playbackdumps/mka/bear-opus.mka.dump new file mode 100644 index 00000000000..5c3a9f92862 --- /dev/null +++ b/testdata/src/test/assets/playbackdumps/mka/bear-opus.mka.dump @@ -0,0 +1,140 @@ +MediaCodec (audio/opus): + buffers.length = 138 + buffers[0] = length 375, hash 147EA9B + buffers[1] = length 187, hash C8ADD7C2 + buffers[2] = length 175, hash A6D94D6E + buffers[3] = length 162, hash 45359884 + buffers[4] = length 163, hash CBB836AF + buffers[5] = length 293, hash EEB23890 + buffers[6] = length 160, hash 7843AFDA + buffers[7] = length 162, hash 607E26A4 + buffers[8] = length 164, hash C1423D63 + buffers[9] = length 169, hash 90CEDF8C + buffers[10] = length 165, hash 97A6A3F7 + buffers[11] = length 179, hash 2EA2049F + buffers[12] = length 168, hash FCD51794 + buffers[13] = length 162, hash 80D9FBC0 + buffers[14] = length 162, hash BB673AC7 + buffers[15] = length 161, hash 8D5CC41B + buffers[16] = length 161, hash 5F5E6270 + buffers[17] = length 165, hash 117B14D9 + buffers[18] = length 166, hash D8BFD4 + buffers[19] = length 162, hash 61D76007 + buffers[20] = length 165, hash 78245BE8 + buffers[21] = length 165, hash A5F5B919 + buffers[22] = length 255, hash 1F42ECE2 + buffers[23] = length 165, hash D89D3EF0 + buffers[24] = length 164, hash C44C8E79 + buffers[25] = length 163, hash FFCE2E84 + buffers[26] = length 184, hash FD7BF02A + buffers[27] = length 162, hash 59074C0F + buffers[28] = length 162, hash 41CAF78D + buffers[29] = length 163, hash 50F0BCBD + buffers[30] = length 163, hash FABC49B3 + buffers[31] = length 256, hash 8515E521 + buffers[32] = length 244, hash D5F80618 + buffers[33] = length 162, hash A23FA880 + buffers[34] = length 163, hash 5D99DCD2 + buffers[35] = length 163, hash 37A4EB87 + buffers[36] = length 164, hash 4C190996 + buffers[37] = length 164, hash A2F6E788 + buffers[38] = length 162, hash E7353EFB + buffers[39] = length 161, hash FFF24D5F + buffers[40] = length 162, hash 95B27AB0 + buffers[41] = length 163, hash C43CB498 + buffers[42] = length 164, hash 438F5714 + buffers[43] = length 163, hash BDB72F57 + buffers[44] = length 162, hash 3194B57A + buffers[45] = length 163, hash D7CC025 + buffers[46] = length 162, hash F9E19F4D + buffers[47] = length 194, hash EED4C2BD + buffers[48] = length 164, hash ABFAEEFE + buffers[49] = length 163, hash 7487380A + buffers[50] = length 163, hash D4BFFB76 + buffers[51] = length 164, hash F3EB6797 + buffers[52] = length 163, hash 82B7ABB7 + buffers[53] = length 177, hash 921FEDAE + buffers[54] = length 162, hash BC7D176B + buffers[55] = length 165, hash 32DAEB04 + buffers[56] = length 164, hash 55FDBC77 + buffers[57] = length 230, hash FC32522D + buffers[58] = length 177, hash DF834667 + buffers[59] = length 161, hash F2ADFBCA + buffers[60] = length 161, hash 13CB7679 + buffers[61] = length 164, hash A12B20AC + buffers[62] = length 163, hash 38D448B + buffers[63] = length 164, hash BFE96C9A + buffers[64] = length 161, hash 921431E3 + buffers[65] = length 162, hash 9DDE27E0 + buffers[66] = length 165, hash 42C01110 + buffers[67] = length 163, hash C244C6B1 + buffers[68] = length 162, hash 288A7D7A + buffers[69] = length 164, hash 6DDF8E96 + buffers[70] = length 312, hash DD1760ED + buffers[71] = length 164, hash 40BD6AB0 + buffers[72] = length 167, hash 45FEB94 + buffers[73] = length 164, hash 1783D8D9 + buffers[74] = length 165, hash 7F68CB47 + buffers[75] = length 163, hash 431D98B9 + buffers[76] = length 164, hash 2F7F0A03 + buffers[77] = length 164, hash 330E9D40 + buffers[78] = length 161, hash 670A6D84 + buffers[79] = length 162, hash 55CEAB6A + buffers[80] = length 161, hash 690C1C44 + buffers[81] = length 311, hash 507DC3E7 + buffers[82] = length 226, hash 2D0C0942 + buffers[83] = length 163, hash 47A75060 + buffers[84] = length 163, hash 198A78EB + buffers[85] = length 165, hash F7AF184 + buffers[86] = length 163, hash 7EC009AE + buffers[87] = length 163, hash 7ACF600A + buffers[88] = length 170, hash 67F513C9 + buffers[89] = length 162, hash E0116535 + buffers[90] = length 164, hash 6C4C8BC1 + buffers[91] = length 163, hash 73E55623 + buffers[92] = length 162, hash 614AB0EE + buffers[93] = length 162, hash 49E038A6 + buffers[94] = length 162, hash 45BBCDDF + buffers[95] = length 163, hash 94E6047A + buffers[96] = length 162, hash FA40E646 + buffers[97] = length 163, hash 54F3E885 + buffers[98] = length 163, hash 42EA2C3C + buffers[99] = length 164, hash 11E5DC72 + buffers[100] = length 161, hash FB697FB7 + buffers[101] = length 164, hash 45137460 + buffers[102] = length 232, hash F8A33CF3 + buffers[103] = length 163, hash B2562537 + buffers[104] = length 163, hash D07ADBF + buffers[105] = length 163, hash 2AE2FC1E + buffers[106] = length 162, hash F574ABD + buffers[107] = length 162, hash 8A20D2FC + buffers[108] = length 162, hash BD37BF40 + buffers[109] = length 163, hash 81DF11E8 + buffers[110] = length 165, hash 236877C0 + buffers[111] = length 226, hash 6B5CD992 + buffers[112] = length 162, hash 7F697CCA + buffers[113] = length 161, hash 4C2993B4 + buffers[114] = length 163, hash 1DE49094 + buffers[115] = length 162, hash DCA5BB9B + buffers[116] = length 165, hash 66B62984 + buffers[117] = length 161, hash 994C6D54 + buffers[118] = length 163, hash DA5BA1F1 + buffers[119] = length 187, hash 7F6C5537 + buffers[120] = length 161, hash D0AF4628 + buffers[121] = length 161, hash 8A49A435 + buffers[122] = length 163, hash 90D7B180 + buffers[123] = length 162, hash C459D78E + buffers[124] = length 161, hash D7766E6B + buffers[125] = length 187, hash E0449F61 + buffers[126] = length 162, hash 203F238E + buffers[127] = length 163, hash 15F81805 + buffers[128] = length 161, hash 8496E779 + buffers[129] = length 163, hash DF6A28D0 + buffers[130] = length 233, hash 39CAC5CB + buffers[131] = length 250, hash 40F8863A + buffers[132] = length 248, hash BB880EB4 + buffers[133] = length 247, hash A93865FE + buffers[134] = length 244, hash ED7E6DB5 + buffers[135] = length 252, hash 2DD353C4 + buffers[136] = length 244, hash CE73B41E + buffers[137] = length 0, hash 1 From 8d84a50fa1c86f730105cd648bb2454f365830d4 Mon Sep 17 00:00:00 2001 From: olly Date: Fri, 13 Nov 2020 01:00:36 +0000 Subject: [PATCH 280/693] Update Styled non bottom buttons to be borderless. This requires the parent of the background to draw and have padding large enough to support the size of the ripple. The bottom buttons must remained bordered as the space around them is constrained. PiperOrigin-RevId: 342162231 --- RELEASENOTES.md | 1 + ..._selectable_item_background_borderless.xml} | 14 ++++---------- .../src/main/res/drawable/exo_ripple_ffwd.xml | 18 +++++------------- .../src/main/res/drawable/exo_ripple_rew.xml | 18 +++++------------- ..._selectable_item_background_borderless.xml} | 14 ++++---------- .../exo_styled_embedded_transport_controls.xml | 2 ++ library/ui/src/main/res/values/colors.xml | 2 -- library/ui/src/main/res/values/styles.xml | 3 ++- 8 files changed, 23 insertions(+), 49 deletions(-) rename library/ui/src/main/res/drawable-v21/{exo_ripple_rew.xml => exo_selectable_item_background_borderless.xml} (63%) rename library/ui/src/main/res/{drawable-v21/exo_ripple_ffwd.xml => drawable/exo_selectable_item_background_borderless.xml} (63%) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5f5df4377cc..94d173da4e7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -36,6 +36,7 @@ `LeanbackPlayerAdapter` and use `ControlDispatcher` for dispatching prepare instead ([#7882](https://github.com/google/ExoPlayer/issues/7882)). + * Switch StyledPlayerView button controls to borderless ripples. * Audio: * Retry playback after some types of `AudioTrack` error. * Work around `AudioManager` crashes when calling `getStreamVolume` diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml b/library/ui/src/main/res/drawable-v21/exo_selectable_item_background_borderless.xml similarity index 63% rename from library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml rename to library/ui/src/main/res/drawable-v21/exo_selectable_item_background_borderless.xml index ee43206b4ad..e9b1474003a 100644 --- a/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml +++ b/library/ui/src/main/res/drawable-v21/exo_selectable_item_background_borderless.xml @@ -14,13 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - - - - - + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml index 9f7e1fd0279..f28eef2839f 100644 --- a/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml +++ b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml @@ -14,16 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - - - - - - - - - - + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_rew.xml b/library/ui/src/main/res/drawable/exo_ripple_rew.xml index 5562b1352cb..a2cc2f89081 100644 --- a/library/ui/src/main/res/drawable/exo_ripple_rew.xml +++ b/library/ui/src/main/res/drawable/exo_ripple_rew.xml @@ -14,16 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - - - - - - - - - - + + + + diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable/exo_selectable_item_background_borderless.xml similarity index 63% rename from library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml rename to library/ui/src/main/res/drawable/exo_selectable_item_background_borderless.xml index 5e4dd5550f9..7a267a0a528 100644 --- a/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml +++ b/library/ui/src/main/res/drawable/exo_selectable_item_background_borderless.xml @@ -14,13 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - - - - - + + + + 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 index 75db3e4527b..ab82d5350d8 100644 --- 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 @@ -16,8 +16,10 @@ - #808080 - #80808080 #ffffff #B3ffffff #B3000000 diff --git a/library/ui/src/main/res/values/styles.xml b/library/ui/src/main/res/values/styles.xml index 38daccb3772..8b48f2fcb5e 100644 --- a/library/ui/src/main/res/values/styles.xml +++ b/library/ui/src/main/res/values/styles.xml @@ -59,7 +59,7 @@ + + diff --git a/library/ui/src/main/res/values/colors.xml b/library/ui/src/main/res/values/colors.xml index 64ad87ebad3..0aa4ce98713 100644 --- a/library/ui/src/main/res/values/colors.xml +++ b/library/ui/src/main/res/values/colors.xml @@ -15,8 +15,6 @@ limitations under the License. --> - #808080 - #80808080 #ffffff #B3ffffff #B3000000 diff --git a/library/ui/src/main/res/values/dimens.xml b/library/ui/src/main/res/values/dimens.xml index 1dd966605a6..2028d62edbf 100644 --- a/library/ui/src/main/res/values/dimens.xml +++ b/library/ui/src/main/res/values/dimens.xml @@ -44,6 +44,7 @@ 60dp 10dp 10dp + 0dp 4dp 32dp diff --git a/library/ui/src/main/res/values/styles.xml b/library/ui/src/main/res/values/styles.xml index 7133b78f700..a7d44ec59b4 100644 --- a/library/ui/src/main/res/values/styles.xml +++ b/library/ui/src/main/res/values/styles.xml @@ -89,7 +89,7 @@

    See GitHub issue #5045. - */ - private static boolean codecNeedsEosBufferTimestampWorkaround(String codecName) { - return Util.SDK_INT < 21 - && "OMX.SEC.mp3.dec".equals(codecName) - && "samsung".equals(Util.MANUFACTURER) - && (Util.DEVICE.startsWith("baffin") - || Util.DEVICE.startsWith("grand") - || Util.DEVICE.startsWith("fortuna") - || Util.DEVICE.startsWith("gprimelte") - || Util.DEVICE.startsWith("j2y18lte") - || Util.DEVICE.startsWith("ms01")); - } - private final class AudioSinkListener implements AudioSink.Listener { @Override 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 49bb5c35c10..4e7bd67eecd 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 @@ -321,6 +321,7 @@ private static String buildCustomDiagnosticInfo(int errorCode) { private boolean codecNeedsSosFlushWorkaround; private boolean codecNeedsEosFlushWorkaround; private boolean codecNeedsEosOutputExceptionWorkaround; + private boolean codecNeedsEosBufferTimestampWorkaround; private boolean codecNeedsMonoChannelCountWorkaround; private boolean codecNeedsAdaptationWorkaroundBuffer; private boolean shouldSkipAdaptationWorkaroundOutputBuffer; @@ -898,6 +899,7 @@ protected void resetCodecStateForRelease() { codecNeedsSosFlushWorkaround = false; codecNeedsEosFlushWorkaround = false; codecNeedsEosOutputExceptionWorkaround = false; + codecNeedsEosBufferTimestampWorkaround = false; codecNeedsMonoChannelCountWorkaround = false; codecNeedsEosPropagation = false; codecReconfigured = false; @@ -1089,6 +1091,7 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce codecNeedsSosFlushWorkaround = codecNeedsSosFlushWorkaround(codecName); codecNeedsEosFlushWorkaround = codecNeedsEosFlushWorkaround(codecName); codecNeedsEosOutputExceptionWorkaround = codecNeedsEosOutputExceptionWorkaround(codecName); + codecNeedsEosBufferTimestampWorkaround = codecNeedsEosBufferTimestampWorkaround(codecName); codecNeedsMonoChannelCountWorkaround = codecNeedsMonoChannelCountWorkaround(codecName, codecInputFormat); codecNeedsEosPropagation = @@ -1746,6 +1749,12 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) outputBuffer.position(outputBufferInfo.offset); outputBuffer.limit(outputBufferInfo.offset + outputBufferInfo.size); } + if (codecNeedsEosBufferTimestampWorkaround + && outputBufferInfo.presentationTimeUs == 0 + && (outputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 + && largestQueuedPresentationTimeUs != C.TIME_UNSET) { + outputBufferInfo.presentationTimeUs = largestQueuedPresentationTimeUs; + } isDecodeOnlyOutputBuffer = isDecodeOnlyBuffer(outputBufferInfo.presentationTimeUs); isLastOutputBuffer = lastBufferInStreamPresentationTimeUs == outputBufferInfo.presentationTimeUs; @@ -1928,18 +1937,6 @@ protected final void setPendingOutputEndOfStream() { pendingOutputEndOfStream = true; } - /** Returns the largest queued input presentation time, in microseconds. */ - protected final long getLargestQueuedPresentationTimeUs() { - 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, @@ -2272,6 +2269,23 @@ private static boolean codecNeedsDiscardToSpsWorkaround(String name, Format form && "OMX.MTK.VIDEO.DECODER.AVC".equals(name); } + /** + * Returns whether the decoder is known to behave incorrectly if flushed prior to having output a + * {@link MediaFormat}. + * + *