diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c698c2ee70..90040fc231 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,77 @@ # Release notes +### 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 + * 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 playback of WebVTT subtitles in MP4 containers in DASH streams + ([#7985](https://github.com/google/ExoPlayer/issues/7985)). + * Fix `NullPointerException` in `TextRenderer` when playing content with a + single subtitle buffer + ([#8017](https://github.com/google/ExoPlayer/issues/8017)). +* UI: + * Fix animation when `StyledPlayerView` first shows its playback controls. + * 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)). +* Audio: + * Fix the default audio sink position not advancing correctly when using + `AudioTrack` based speed adjustment + ([#7982](https://github.com/google/ExoPlayer/issues/7982)). + * Fix `NoClassDefFoundError` warning for `AudioTrack$StreamEventCallback` + ([#8058](https://github.com/google/ExoPlayer/issues/8058)). +* Extractors: + * 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)). + * Ogg: Fix regression playing files with packets that span multiple pages + ([#7992](https://github.com/google/ExoPlayer/issues/7992)). + * FLV: Make files seekable by using the key frame index + ([#7378](https://github.com/google/ExoPlayer/issues/7378)). +* 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)). + * 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)). + * Add a way to override ad media MIME types + ([#7961)(https://github.com/google/ExoPlayer/issues/7961)). + * 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 + ([#6432](https://github.com/google/ExoPlayer/issues/6432)). + ### 2.12.0 (2020-09-11) ### To learn more about what's new in 2.12, read the corresponding @@ -163,7 +235,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) @@ -178,10 +250,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/build.gradle b/build.gradle index 00277b8cee..8c044f2fda 100644 --- a/build.gradle +++ b/build.gradle @@ -17,7 +17,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:4.0.0' + classpath 'com.android.tools.build:gradle:4.0.1' classpath 'com.novoda:bintray-release:0.9.1' classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.1' } diff --git a/constants.gradle b/constants.gradle index c2b0000368..44a61d6baa 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/core_settings.gradle b/core_settings.gradle index b508243371..bd217a37e5 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/demos/main/build.gradle b/demos/main/build.gradle index 716b3c1f99..c5554993dc 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -70,13 +70,6 @@ dependencies { implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion implementation 'androidx.multidex:multidex:' + androidxMultidexVersion 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' - 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/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index ce1854db85..24213918f5 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" } ] }, 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 eae302887e..c35080c47f 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; @@ -375,7 +375,7 @@ public class PlayerActivity extends AppCompatActivity } // 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/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 5cf2353f21..d3f9b3880d 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 @@ -354,7 +354,12 @@ public final class TrackSelectionDialog extends DialogFragment { trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides); trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); trackSelectionView.init( - mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this); + mappedTrackInfo, + rendererIndex, + isDisabled, + overrides, + /* trackFormatComparator= */ null, + /* listener= */ this); return rootView; } 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 80d9817a46..eeda98d2d9 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 @@ -307,6 +307,13 @@ public final class CastPlayer extends BasePlayer { } } + @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/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java index ebadb0a08a..d6644e6bb3 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.ext.cast; import android.content.Context; -import com.google.android.gms.cast.CastMediaControlIntent; import com.google.android.gms.cast.framework.CastOptions; import com.google.android.gms.cast.framework.OptionsProvider; import com.google.android.gms.cast.framework.SessionProvider; @@ -29,24 +28,12 @@ import java.util.List; public final class DefaultCastOptionsProvider implements OptionsProvider { /** - * App id of the Default Media Receiver app. Apps that do not require DRM support may use this - * receiver receiver app ID. + * App id that points to the Default Media Receiver app with basic DRM support. * - *

See https://developers.google.com/cast/docs/caf_receiver/#default_media_receiver. + *

Applications that require more complex DRM authentication should create a + * custom receiver application. */ - public static final String APP_ID_DEFAULT_RECEIVER = - CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID; - - /** - * App id for receiver app with rudimentary support for DRM. - * - *

This app id is only suitable for ExoPlayer's Cast Demo app, and it is not intended for - * production use. In order to use DRM, custom receiver apps should be used. For environments that - * do not require DRM, the default receiver app should be used (see {@link - * #APP_ID_DEFAULT_RECEIVER}). - */ - // TODO: Add a documentation resource link for DRM support in the receiver app [Internal ref: - // b/128603245]. public static final String APP_ID_DEFAULT_RECEIVER_WITH_DRM = "A12D4273"; @Override diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index 0dd1d42d72..c0f443d5df 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -17,13 +17,6 @@ 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) { - 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 f7b2b3f77c..ed20dedb10 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -25,39 +25,18 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.19.4' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.20.1' 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) { - 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) { - 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) { - 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/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 a97307a419..0000000000 --- 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 88b0daac49..4185a158f7 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 @@ package com.google.android.exoplayer2.ext.ima; 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; @@ -33,7 +32,6 @@ 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.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; @@ -61,7 +59,9 @@ 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; @@ -93,12 +93,20 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * #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 + * 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 - * com.google.android.exoplayer2.source.ads.AdsMediaSource}. See the - * IMA SDK Open Measurement documentation for more information. + * be registered via the {@link AdViewProvider} passed to the {@link AdsMediaSource}. See the IMA + * SDK Open Measurement documentation for more information. */ public final class ImaAdsLoader implements Player.EventListener, com.google.android.exoplayer2.source.ads.AdsLoader { @@ -126,6 +134,8 @@ public final class ImaAdsLoader @Nullable private ImaSdkSettings imaSdkSettings; @Nullable private AdErrorListener adErrorListener; @Nullable private AdEventListener adEventListener; + @Nullable private VideoAdPlayer.VideoAdPlayerCallback videoAdPlayerCallback; + @Nullable private List adMediaMimeTypes; @Nullable private Set adUiElements; @Nullable private Collection companionAdSlots; private long adPreloadTimeoutMs; @@ -134,7 +144,8 @@ public final class ImaAdsLoader private int mediaBitrate; private boolean focusSkipButtonWhenAvailable; private boolean playAdBeforeStartPosition; - private ImaFactory imaFactory; + private boolean debugModeEnabled; + private ImaUtil.ImaFactory imaFactory; /** * Creates a new builder for {@link ImaAdsLoader}. @@ -142,7 +153,7 @@ public final class ImaAdsLoader * @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; @@ -191,6 +202,22 @@ public final class ImaAdsLoader 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. * @@ -215,6 +242,23 @@ public final class ImaAdsLoader return this; } + /** + * Sets the MIME types to prioritize for linear ad media. If not specified, MIME types supported + * by the {@link MediaSourceFactory adMediaSourceFactory} used to construct the {@link + * AdsMediaSource} will be used. + * + * @param adMediaMimeTypes The MIME types to prioritize for linear ad media. May contain {@link + * MimeTypes#APPLICATION_MPD}, {@link MimeTypes#APPLICATION_M3U8}, {@link + * MimeTypes#VIDEO_MP4}, {@link MimeTypes#VIDEO_WEBM}, {@link MimeTypes#VIDEO_H263}, {@link + * MimeTypes#AUDIO_MP4} and {@link MimeTypes#AUDIO_MPEG}. + * @return This builder, for convenience. + * @see AdsRenderingSettings#setMimeTypes(List) + */ + public Builder setAdMediaMimeTypes(List adMediaMimeTypes) { + this.adMediaMimeTypes = ImmutableList.copyOf(checkNotNull(adMediaMimeTypes)); + return this; + } + /** * Sets the duration in milliseconds for which the player must buffer while preloading an ad * group before that ad group is skipped and marked as having failed to load. Pass {@link @@ -302,8 +346,23 @@ public final class ImaAdsLoader return this; } + /** + * Sets whether to enable outputting verbose logs for the IMA extension and IMA SDK. The default + * value is {@code false}. This setting is intended for debugging only, and should not be + * enabled in production applications. + * + * @param debugModeEnabled Whether to enable outputting verbose logs for the IMA extension and + * IMA SDK. + * @return This builder, for convenience. + * @see ImaSdkSettings#setDebugMode(boolean) + */ + public Builder setDebugModeEnabled(boolean debugModeEnabled) { + this.debugModeEnabled = debugModeEnabled; + return this; + } + @VisibleForTesting - /* package */ Builder setImaFactory(ImaFactory imaFactory) { + /* package */ Builder setImaFactory(ImaUtil.ImaFactory imaFactory) { this.imaFactory = checkNotNull(imaFactory); return this; } @@ -315,24 +374,18 @@ public final class ImaAdsLoader * 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, - adTagUri, - imaSdkSettings, - /* adsResponse= */ null, - adPreloadTimeoutMs, - vastLoadTimeoutMs, - mediaLoadTimeoutMs, - mediaBitrate, - focusSkipButtonWhenAvailable, - playAdBeforeStartPosition, - adUiElements, - companionAdSlots, - adErrorListener, - adEventListener, - imaFactory); + getConfiguration(), + imaFactory, + /* adTagUri= */ adTagUri, + /* adsResponse= */ null); } /** @@ -341,28 +394,44 @@ public final class ImaAdsLoader * @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, - /* adTagUri= */ null, - imaSdkSettings, - adsResponse, + 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, - mediaBitrate, focusSkipButtonWhenAvailable, playAdBeforeStartPosition, + mediaBitrate, + adMediaMimeTypes, adUiElements, companionAdSlots, adErrorListener, adEventListener, - imaFactory); + videoAdPlayerCallback, + imaSdkSettings, + debugModeEnabled); } } - private static final boolean DEBUG = false; private static final String TAG = "ImaAdsLoader"; private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima"; @@ -413,20 +482,13 @@ public final class ImaAdsLoader */ 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 Uri adTagUri; @Nullable private final String adsResponse; - private final long adPreloadTimeoutMs; - private final int vastLoadTimeoutMs; - private final int mediaLoadTimeoutMs; - private final boolean focusSkipButtonWhenAvailable; - private final boolean playAdBeforeStartPosition; - private final int mediaBitrate; - @Nullable private final Set adUiElements; - @Nullable private final Collection companionAdSlots; - @Nullable private final AdErrorListener adErrorListener; - @Nullable private final AdEventListener adEventListener; - private final ImaFactory imaFactory; private final ImaSdkSettings imaSdkSettings; private final Timeline.Period period; private final Handler handler; @@ -443,6 +505,7 @@ public final class ImaAdsLoader private List supportedMimeTypes; @Nullable private EventListener eventListener; @Nullable private Player player; + private DataSpec adTagDataSpec; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; private int lastVolumePercent; @@ -518,61 +581,36 @@ public final class ImaAdsLoader * @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, - /* 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()); + /* adsResponse= */ null); } @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader( Context context, + ImaUtil.Configuration configuration, + ImaUtil.ImaFactory imaFactory, @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) { - checkArgument(adTagUri != null || adsResponse != null); + @Nullable String adsResponse) { this.context = context.getApplicationContext(); + this.configuration = configuration; + this.imaFactory = imaFactory; 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; + @Nullable ImaSdkSettings imaSdkSettings = configuration.imaSdkSettings; if (imaSdkSettings == null) { imaSdkSettings = imaFactory.createImaSdkSettings(); - if (DEBUG) { + if (configuration.debugModeEnabled) { imaSdkSettings.setDebugMode(true); } } @@ -583,9 +621,13 @@ public final class ImaAdsLoader 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; @@ -631,12 +673,62 @@ public final class ImaAdsLoader * * @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 (configuration.vastLoadTimeoutMs != TIMEOUT_UNSET) { + request.setVastLoadTimeout(configuration.vastLoadTimeoutMs); + } + request.setContentProgressProvider(componentListener); + if (adViewGroup != null) { adDisplayContainer = imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener); @@ -644,27 +736,16 @@ public final class ImaAdsLoader adDisplayContainer = imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener); } - if (companionAdSlots != null) { - adDisplayContainer.setCompanionSlots(companionAdSlots); + if (configuration.companionAdSlots != null) { + adDisplayContainer.setCompanionSlots(configuration.companionAdSlots); } + adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(componentListener); - if (adErrorListener != null) { - adsLoader.addAdErrorListener(adErrorListener); + if (configuration.applicationAdErrorListener != null) { + adsLoader.addAdErrorListener(configuration.applicationAdErrorListener); } 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); } @@ -713,6 +794,11 @@ public final class ImaAdsLoader this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes); } + @Override + public void setAdTagDataSpec(DataSpec adTagDataSpec) { + this.adTagDataSpec = adTagDataSpec; + } + @Override public void start(EventListener eventListener, AdViewProvider adViewProvider) { checkState( @@ -735,18 +821,18 @@ public final class ImaAdsLoader 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. - requestAds(adViewProvider.getAdViewGroup()); + requestAds(adTagDataSpec, adViewProvider.getAdViewGroup()); } if (adDisplayContainer != null) { for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { adDisplayContainer.registerFriendlyObstruction( imaFactory.createFriendlyObstruction( overlayInfo.view, - getFriendlyObstructionPurpose(overlayInfo.purpose), + ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), overlayInfo.reasonDetail)); } } @@ -782,8 +868,8 @@ public final class ImaAdsLoader if (adsLoader != null) { adsLoader.removeAdsLoadedListener(componentListener); adsLoader.removeAdErrorListener(componentListener); - if (adErrorListener != null) { - adsLoader.removeAdErrorListener(adErrorListener); + if (configuration.applicationAdErrorListener != null) { + adsLoader.removeAdErrorListener(configuration.applicationAdErrorListener); } } imaPausedContent = false; @@ -800,7 +886,7 @@ public final class ImaAdsLoader @Override public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) { AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "Prepared ad " + adInfo); } @Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo); @@ -850,7 +936,7 @@ public final class ImaAdsLoader } else { adsManager.init(adsRenderingSettings); adsManager.start(); - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); } } @@ -887,7 +973,7 @@ public final class ImaAdsLoader long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); long timeUntilAdMs = adGroupTimeMs - contentPositionMs; - if (timeUntilAdMs < adPreloadTimeoutMs) { + if (timeUntilAdMs < configuration.adPreloadTimeoutMs) { waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); } } else if (playbackState == Player.STATE_READY) { @@ -936,16 +1022,20 @@ public final class ImaAdsLoader private AdsRenderingSettings setupAdsRendering() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); - adsRenderingSettings.setMimeTypes(supportedMimeTypes); - if (mediaLoadTimeoutMs != TIMEOUT_UNSET) { - adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs); + adsRenderingSettings.setMimeTypes( + configuration.adMediaMimeTypes != null + ? configuration.adMediaMimeTypes + : supportedMimeTypes); + if (configuration.mediaLoadTimeoutMs != TIMEOUT_UNSET) { + adsRenderingSettings.setLoadVideoTimeout(configuration.mediaLoadTimeoutMs); } - if (mediaBitrate != BITRATE_UNSET) { - adsRenderingSettings.setBitrateKbps(mediaBitrate / 1000); + if (configuration.mediaBitrate != BITRATE_UNSET) { + adsRenderingSettings.setBitrateKbps(configuration.mediaBitrate / 1000); } - adsRenderingSettings.setFocusSkipButtonWhenAvailable(focusSkipButtonWhenAvailable); - if (adUiElements != null) { - adsRenderingSettings.setUiElements(adUiElements); + adsRenderingSettings.setFocusSkipButtonWhenAvailable( + configuration.focusSkipButtonWhenAvailable); + if (configuration.adUiElements != null) { + adsRenderingSettings.setUiElements(configuration.adUiElements); } // Skip ads based on the start position as required. @@ -956,7 +1046,7 @@ public final class ImaAdsLoader C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); if (adGroupForPositionIndex != C.INDEX_UNSET) { boolean playAdWhenStartingPlayback = - playAdBeforeStartPosition + configuration.playAdBeforeStartPosition || adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs); if (!playAdWhenStartingPlayback) { adGroupForPositionIndex++; @@ -1069,7 +1159,7 @@ public final class ImaAdsLoader switch (adEvent.getType()) { case AD_BREAK_FETCH_ERROR: String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime")); - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); } double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString); @@ -1077,7 +1167,7 @@ public final class ImaAdsLoader 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 @@ -1156,7 +1246,7 @@ public final class ImaAdsLoader adCallbacks.get(i).onEnded(adMediaInfo); } } - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged"); } } @@ -1196,7 +1286,7 @@ public final class ImaAdsLoader adCallbacks.get(i).onEnded(adMediaInfo); } } - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); } } @@ -1218,7 +1308,7 @@ public final class ImaAdsLoader private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { if (adsManager == null) { // Drop events after release. - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d( TAG, "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); @@ -1230,7 +1320,7 @@ public final class ImaAdsLoader int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); adInfoByAdMediaInfo.put(adMediaInfo, adInfo); - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo)); } if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { @@ -1261,7 +1351,7 @@ public final class ImaAdsLoader } private void playAdInternal(AdMediaInfo adMediaInfo) { - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { @@ -1305,7 +1395,7 @@ public final class ImaAdsLoader } private void pauseAdInternal(AdMediaInfo adMediaInfo) { - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { @@ -1325,7 +1415,7 @@ public final class ImaAdsLoader } private void stopAdInternal(AdMediaInfo adMediaInfo) { - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { @@ -1364,35 +1454,20 @@ public final class ImaAdsLoader } } - 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)); @@ -1400,22 +1475,20 @@ public final class ImaAdsLoader } for (int i = 0; i < adGroup.count; i++) { if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); } adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); } } 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; } private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Exception exception) { - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d( TAG, "Prepare error for ad " + adIndexInAdGroup + " in group " + adGroupIndex, exception); } @@ -1467,7 +1540,7 @@ public final class ImaAdsLoader adCallbacks.get(i).onContentComplete(); } sentContentComplete = true; - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "adsLoader.contentComplete"); } for (int i = 0; i < adPlaybackState.adGroupCount; i++) { @@ -1487,7 +1560,7 @@ public final class ImaAdsLoader private void maybeNotifyPendingAdLoadError() { if (pendingAdLoadError != null && eventListener != null) { - eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri)); + eventListener.onAdLoadError(pendingAdLoadError, adTagDataSpec); pendingAdLoadError = null; } } @@ -1502,8 +1575,7 @@ public final class ImaAdsLoader updateAdPlaybackState(); if (eventListener != null) { eventListener.onAdLoadError( - AdLoadException.createForUnexpected(new RuntimeException(message, cause)), - getAdsDataSpec(adTagUri)); + AdLoadException.createForUnexpected(new RuntimeException(message, cause)), adTagDataSpec); } } @@ -1522,8 +1594,10 @@ public final class ImaAdsLoader * 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) { @@ -1538,7 +1612,8 @@ public final class ImaAdsLoader // 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); + 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 @@ -1554,25 +1629,6 @@ public final class ImaAdsLoader 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); - } - private static long getContentPeriodPositionMs( Player player, Timeline timeline, Timeline.Period period) { long contentWindowPositionMs = player.getContentPosition(); @@ -1582,13 +1638,6 @@ public final class ImaAdsLoader : 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. @@ -1610,50 +1659,18 @@ public final class ImaAdsLoader private void destroyAdsManager() { if (adsManager != null) { adsManager.removeAdErrorListener(componentListener); - if (adErrorListener != null) { - adsManager.removeAdErrorListener(adErrorListener); + if (configuration.applicationAdErrorListener != null) { + adsManager.removeAdErrorListener(configuration.applicationAdErrorListener); } adsManager.removeAdEventListener(componentListener); - if (adEventListener != null) { - adsManager.removeAdEventListener(adEventListener); + if (configuration.applicationAdEventListener != null) { + adsManager.removeAdEventListener(configuration.applicationAdEventListener); } adsManager.destroy(); adsManager = null; } } - /** 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, @@ -1673,17 +1690,18 @@ public final class ImaAdsLoader pendingAdRequestContext = null; ImaAdsLoader.this.adsManager = adsManager; adsManager.addAdErrorListener(this); - if (adErrorListener != null) { - adsManager.addAdErrorListener(adErrorListener); + if (configuration.applicationAdErrorListener != null) { + adsManager.addAdErrorListener(configuration.applicationAdErrorListener); } adsManager.addAdEventListener(this); - if (adEventListener != null) { - adsManager.addAdEventListener(adEventListener); + if (configuration.applicationAdEventListener != null) { + adsManager.addAdEventListener(configuration.applicationAdEventListener); } 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) { @@ -1697,7 +1715,7 @@ public final class ImaAdsLoader @Override public VideoProgressUpdate getContentProgress() { VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); - if (DEBUG) { + if (configuration.debugModeEnabled) { if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { Log.d(TAG, "Content progress: not ready"); } else { @@ -1729,7 +1747,7 @@ public final class ImaAdsLoader @Override public void onAdEvent(AdEvent adEvent) { AdEventType adEventType = adEvent.getType(); - if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { + if (configuration.debugModeEnabled && adEventType != AdEventType.AD_PROGRESS) { Log.d(TAG, "onAdEvent: " + adEventType); } try { @@ -1744,7 +1762,7 @@ public final class ImaAdsLoader @Override public void onAdError(AdErrorEvent adErrorEvent) { AdError error = adErrorEvent.getError(); - if (DEBUG) { + if (configuration.debugModeEnabled) { Log.d(TAG, "onAdError", error); } if (adsManager == null) { @@ -1753,7 +1771,7 @@ public final class ImaAdsLoader adPlaybackState = AdPlaybackState.NONE; hasAdPlaybackState = true; updateAdPlaybackState(); - } else if (isAdGroupLoadError(error)) { + } else if (ImaUtil.isAdGroupLoadError(error)) { try { handleAdGroupLoadError(error); } catch (RuntimeException e) { @@ -1868,8 +1886,11 @@ public final class ImaAdsLoader } } - /** 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 0000000000..a4f1ec92cc --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java @@ -0,0 +1,206 @@ +/* + * 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.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent; +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.CompanionAdSlot; +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.UiElement; +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 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.Collection; +import java.util.List; +import java.util.Set; + +/** 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); + } + + /** Stores configuration for ad loading and playback. */ + public static final class Configuration { + + public final long adPreloadTimeoutMs; + public final int vastLoadTimeoutMs; + public final int mediaLoadTimeoutMs; + public final boolean focusSkipButtonWhenAvailable; + public final boolean playAdBeforeStartPosition; + public final int mediaBitrate; + @Nullable public final List adMediaMimeTypes; + @Nullable public final Set adUiElements; + @Nullable public final Collection companionAdSlots; + @Nullable public final AdErrorEvent.AdErrorListener applicationAdErrorListener; + @Nullable public final AdEvent.AdEventListener applicationAdEventListener; + @Nullable public final VideoAdPlayer.VideoAdPlayerCallback applicationVideoAdPlayerCallback; + @Nullable public final ImaSdkSettings imaSdkSettings; + public final boolean debugModeEnabled; + + public Configuration( + long adPreloadTimeoutMs, + int vastLoadTimeoutMs, + int mediaLoadTimeoutMs, + boolean focusSkipButtonWhenAvailable, + boolean playAdBeforeStartPosition, + int mediaBitrate, + @Nullable List adMediaMimeTypes, + @Nullable Set adUiElements, + @Nullable Collection companionAdSlots, + @Nullable AdErrorEvent.AdErrorListener applicationAdErrorListener, + @Nullable AdEvent.AdEventListener applicationAdEventListener, + @Nullable VideoAdPlayer.VideoAdPlayerCallback applicationVideoAdPlayerCallback, + @Nullable ImaSdkSettings imaSdkSettings, + boolean debugModeEnabled) { + this.adPreloadTimeoutMs = adPreloadTimeoutMs; + this.vastLoadTimeoutMs = vastLoadTimeoutMs; + this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; + this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + this.playAdBeforeStartPosition = playAdBeforeStartPosition; + this.mediaBitrate = mediaBitrate; + this.adMediaMimeTypes = adMediaMimeTypes; + this.adUiElements = adUiElements; + this.companionAdSlots = companionAdSlots; + this.applicationAdErrorListener = applicationAdErrorListener; + this.applicationAdEventListener = applicationAdEventListener; + this.applicationVideoAdPlayerCallback = applicationVideoAdPlayerCallback; + this.imaSdkSettings = imaSdkSettings; + this.debugModeEnabled = debugModeEnabled; + } + } + + /** + * 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 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 + // 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 e32a199200..d64f6c4b67 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,13 +48,14 @@ 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.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; -import com.google.android.exoplayer2.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; @@ -63,6 +64,8 @@ import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.io.IOException; @@ -96,8 +99,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); @@ -284,7 +288,7 @@ public final class ImaAdsLoaderTest { 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)); @@ -311,6 +315,31 @@ public final class ImaAdsLoaderTest { .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); @@ -352,7 +381,7 @@ public final class ImaAdsLoaderTest { assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -376,7 +405,7 @@ public final class ImaAdsLoaderTest { 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) @@ -398,7 +427,7 @@ public final class ImaAdsLoaderTest { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -422,7 +451,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -447,7 +476,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -474,7 +503,7 @@ public final class ImaAdsLoaderTest { verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -505,7 +534,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -524,7 +553,8 @@ public final class ImaAdsLoaderTest { .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -537,7 +567,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -556,7 +586,8 @@ public final class ImaAdsLoaderTest { .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -569,7 +600,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0)); } @@ -588,7 +619,8 @@ public final class ImaAdsLoaderTest { .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -596,7 +628,7 @@ public final class ImaAdsLoaderTest { verify(mockAdsManager).destroy(); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withSkippedAdGroup(/* adGroupIndex= */ 1)); @@ -624,7 +656,8 @@ public final class ImaAdsLoaderTest { .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -637,7 +670,7 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withSkippedAdGroup(/* adGroupIndex= */ 0) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @@ -663,7 +696,8 @@ public final class ImaAdsLoaderTest { .setPlayAdBeforeStartPosition(false) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI)); + .build(), + TEST_DATA_SPEC); fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -676,16 +710,90 @@ public final class ImaAdsLoaderTest { .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - AdPlaybackStateFactory.fromCuePoints(cuePoints) + ImaUtil.getInitialAdPlaybackStateForCuePoints(cuePoints) .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .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 setsDefaultMimeTypes() throws Exception { + setupPlayback(CONTENT_TIMELINE, ImmutableList.of(0f)); + imaAdsLoader.setSupportedContentTypes(C.TYPE_DASH, C.TYPE_OTHER); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings) + .setMimeTypes( + ImmutableList.of( + MimeTypes.APPLICATION_MPD, + MimeTypes.VIDEO_MP4, + MimeTypes.VIDEO_WEBM, + MimeTypes.VIDEO_H263, + MimeTypes.AUDIO_MP4, + MimeTypes.AUDIO_MPEG)); + } + + @Test + public void buildWithAdMediaMimeTypes_setsMimeTypes() throws Exception { + setupPlayback( + CONTENT_TIMELINE, + ImmutableList.of(0f), + new ImaAdsLoader.Builder(getApplicationContext()) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .setAdMediaMimeTypes(ImmutableList.of(MimeTypes.AUDIO_MPEG)) + .build(), + TEST_DATA_SPEC); + imaAdsLoader.setSupportedContentTypes(C.TYPE_OTHER); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings).setMimeTypes(ImmutableList.of(MimeTypes.AUDIO_MPEG)); + } + @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); @@ -695,7 +803,8 @@ public final class ImaAdsLoaderTest { @Test public void loadAd_withLargeAdCuePoint_updatesAdPlaybackStateWithLoadedAd() { - float midrollTimeSecs = 1_765f; + // Use a large enough value to test correct truncating of large cue points. + float midrollTimeSecs = Float.MAX_VALUE; ImmutableList cuePoints = ImmutableList.of(midrollTimeSecs); setupPlayback(CONTENT_TIMELINE, cuePoints); imaAdsLoader.start(adsLoaderListener, adViewProvider); @@ -735,7 +844,7 @@ public final class ImaAdsLoaderTest { 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) @@ -749,16 +858,21 @@ public final class ImaAdsLoaderTest { 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/extensions/media2/build.gradle b/extensions/media2/build.gradle index 744d79980b..bdafee5558 100644 --- a/extensions/media2/build.gradle +++ b/extensions/media2/build.gradle @@ -19,13 +19,6 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation 'androidx.collection:collection:' + androidxCollectionVersion 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' - 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/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 c23bdd5669..e6d4550d88 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 class DefaultMediaItemConverter implements MediaItemConverter { 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 218c2a737e..99b284af3c 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 @@ import com.google.android.exoplayer2.MediaItem; */ 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/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index f16e382aa1..032fb0fded 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -16,13 +16,6 @@ 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) { - 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 1882ebac81..b3624e75dc 100644 --- a/extensions/workmanager/build.gradle +++ b/extensions/workmanager/build.gradle @@ -18,16 +18,6 @@ apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') 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 - 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 2888b7e24c..de0df42506 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -16,14 +16,16 @@ apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android.buildTypes.debug.testCoverageEnabled true dependencies { - implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - implementation ('com.google.guava:guava:' + guavaVersion) { + api ('com.google.guava:guava:' + guavaVersion) { + // Exclude dependencies that are only used by Guava at compile time + // (but declared as runtime deps) [internal b/168188131]. 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 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/library/common/proguard-rules.txt b/library/common/proguard-rules.txt index c83dbaee2d..18e5264c20 100644 --- a/library/common/proguard-rules.txt +++ b/library/common/proguard-rules.txt @@ -4,3 +4,6 @@ -dontwarn org.checkerframework.** -dontwarn kotlin.annotations.jvm.** -dontwarn javax.annotation.** + +# From https://github.com/google/guava/wiki/UsingProGuardWithGuava +-dontwarn java.lang.ClassValue 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 15c4bf1c1d..b751fff7bd 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 = 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 dfff9a9e73..556b04b8ca 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 @@ -669,6 +669,10 @@ public final class MediaItem { @Nullable public final String language; /** The selection flags. */ @C.SelectionFlags public final int selectionFlags; + /** The role flags. */ + @C.RoleFlags public final int roleFlags; + /** The label. */ + @Nullable public final String label; /** * Creates an instance. @@ -682,7 +686,7 @@ public final class MediaItem { } /** - * Creates an instance with the given selection flags. + * Creates an instance. * * @param uri The {@link Uri URI} to the subtitle file. * @param mimeType The MIME type. @@ -691,10 +695,32 @@ public final class MediaItem { */ public Subtitle( Uri uri, String mimeType, @Nullable String language, @C.SelectionFlags int selectionFlags) { + this(uri, mimeType, language, selectionFlags, /* roleFlags= */ 0, /* label= */ null); + } + + /** + * Creates an instance. + * + * @param uri The {@link Uri URI} to the subtitle file. + * @param mimeType The MIME type. + * @param language The optional language. + * @param selectionFlags The selection flags. + * @param roleFlags The role flags. + * @param label The optional label. + */ + public Subtitle( + Uri uri, + String mimeType, + @Nullable String language, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String label) { this.uri = uri; this.mimeType = mimeType; this.language = language; this.selectionFlags = selectionFlags; + this.roleFlags = roleFlags; + this.label = label; } @Override @@ -711,7 +737,9 @@ public final class MediaItem { return uri.equals(other.uri) && mimeType.equals(other.mimeType) && Util.areEqual(language, other.language) - && selectionFlags == other.selectionFlags; + && selectionFlags == other.selectionFlags + && roleFlags == other.roleFlags + && Util.areEqual(label, other.label); } @Override @@ -720,6 +748,8 @@ public final class MediaItem { result = 31 * result + mimeType.hashCode(); result = 31 * result + (language == null ? 0 : language.hashCode()); result = 31 * result + selectionFlags; + result = 31 * result + roleFlags; + result = 31 * result + (label == null ? 0 : label.hashCode()); return result; } } 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 f954b60c45..745c44395f 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 @@ package com.google.android.exoplayer2.util; 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; @@ -47,6 +48,7 @@ import android.os.SystemClock; 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; @@ -530,6 +532,54 @@ public final class Util { 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. * @@ -1844,13 +1894,16 @@ public final class Util { 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(); } /** @@ -1952,6 +2005,14 @@ public final class Util { 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/MediaItemTest.java b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java index 86f03a3ddb..5d00b1e3dd 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 @@ -173,7 +173,14 @@ public class MediaItemTest { Uri.parse(URI_STRING + "/de"), MimeTypes.APPLICATION_TTML, /* language= */ null, - C.SELECTION_FLAG_DEFAULT)); + C.SELECTION_FLAG_DEFAULT), + new MediaItem.Subtitle( + Uri.parse(URI_STRING + "/fr"), + MimeTypes.APPLICATION_SUBRIP, + /* language= */ "fr", + C.SELECTION_FLAG_DEFAULT, + C.ROLE_FLAG_ALTERNATE, + "label")); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).setSubtitles(subtitles).build(); @@ -317,7 +324,10 @@ public class MediaItemTest { new MediaItem.Subtitle( Uri.parse(URI_STRING + "/en"), MimeTypes.APPLICATION_TTML, - /* language= */ "en"))) + /* language= */ "en", + C.SELECTION_FLAG_FORCED, + C.ROLE_FLAG_ALTERNATE, + "label"))) .setTag(new Object()) .build(); 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 162dcbae9d..dd2ee7af89 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.binarySearchCeil; 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.ByteBuffer; 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; @@ -874,6 +876,14 @@ public class UtilTest { } } + @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}; @@ -1082,6 +1092,12 @@ public class UtilTest { 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); diff --git a/library/core/build.gradle b/library/core/build.gradle index ddeb734947..ae8e7b773f 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -37,40 +37,20 @@ dependencies { api project(modulePrefix + 'library-common') api project(modulePrefix + 'library-extractor') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - 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) { - 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) { - 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') + testImplementation project(modulePrefix + 'robolectricutils') } ext { diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index 64c8cb2435..35a7fdfeaa 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -71,8 +71,3 @@ -keepclasseswithmembers class com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory { (com.google.android.exoplayer2.upstream.DataSource$Factory); } - -# Don't warn about checkerframework and Kotlin annotations --dontwarn org.checkerframework.** --dontwarn kotlin.annotations.jvm.** --dontwarn javax.annotation.** 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 9d7af2dce6..4f89925121 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 abstract class BasePlayer implements Player { 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/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index b5489186bc..ccb67866a4 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 interface ExoPlayer extends Player { * 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. 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 b1f5736465..377863a083 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 @@ -153,7 +153,7 @@ import java.util.concurrent.TimeoutException; 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); @@ -347,6 +347,11 @@ import java.util.concurrent.TimeoutException; 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/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 9739680e79..e33b93ac0e 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 @@ import java.util.concurrent.atomic.AtomicBoolean; // 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/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index b64a9c8087..fa6201bf37 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 @@ import com.google.common.collect.ImmutableList; 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 @@ import com.google.common.collect.ImmutableList; 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/audio/AudioTrackPositionTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java index 540ee098ee..8891a6d8d1 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 @@ import java.lang.reflect.Method; 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; 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 1e04b1e8d7..4181bba1ca 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 @@ -28,6 +28,7 @@ import android.os.Handler; import android.os.SystemClock; import android.util.Pair; import androidx.annotation.IntDef; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; @@ -335,6 +336,7 @@ public final class DefaultAudioSink implements AudioSink { private boolean tunneling; private long lastFeedElapsedRealtimeMs; private boolean offloadDisabledUntilNextConfiguration; + private boolean isWaitingForOffloadEndOfStreamHandled; /** * Creates a new default audio sink. @@ -711,6 +713,7 @@ public final class DefaultAudioSink implements AudioSink { audioTrack.setOffloadEndOfStream(); audioTrack.setOffloadDelayPadding( configuration.inputFormat.encoderDelay, configuration.inputFormat.encoderPadding); + isWaitingForOffloadEndOfStreamHandled = true; } } // Re-apply playback parameters. @@ -931,13 +934,26 @@ public final class DefaultAudioSink implements AudioSink { throw new WriteException(bytesWritten); } - if (playing - && listener != null - && bytesWritten < bytesRemaining - && isOffloadedPlayback(audioTrack)) { - long pendingDurationMs = - audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); - listener.onOffloadBufferFull(pendingDurationMs); + if (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) { @@ -1220,6 +1236,7 @@ public final class DefaultAudioSink implements AudioSink { submittedEncodedFrames = 0; writtenPcmBytes = 0; writtenEncodedFrames = 0; + isWaitingForOffloadEndOfStreamHandled = false; framesPerEncodedSample = 0; mediaPositionParameters = new MediaPositionParameters( @@ -1679,27 +1696,43 @@ public final class DefaultAudioSink implements AudioSink { } @RequiresApi(29) - private final class StreamEventCallbackV29 extends AudioTrack.StreamEventCallback { + private final class StreamEventCallbackV29 { private final Handler handler; + private final AudioTrack.StreamEventCallback callback; public StreamEventCallbackV29() { handler = new Handler(); - } + // Avoid StreamEventCallbackV29 inheriting directly from AudioTrack.StreamEventCallback as it + // would cause a NoClassDefFoundError warning on load of DefaultAudioSink for SDK < 29. + // See: https://github.com/google/ExoPlayer/issues/8058 + callback = + new AudioTrack.StreamEventCallback() { + @Override + public void onDataRequest(AudioTrack track, int size) { + Assertions.checkState(track == DefaultAudioSink.this.audioTrack); + if (listener != null) { + listener.onOffloadBufferEmptying(); + } + } - @Override - public void onDataRequest(AudioTrack track, int size) { - Assertions.checkState(track == DefaultAudioSink.this.audioTrack); - if (listener != null) { - listener.onOffloadBufferEmptying(); - } + @Override + public void onTearDown(@NonNull AudioTrack track) { + 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. Request this call explicitly in case ExoPlayer is + // sleeping waiting for a data request. + listener.onOffloadBufferEmptying(); + } + } + }; } public void register(AudioTrack audioTrack) { - audioTrack.registerStreamEventCallback(handler::post, this); + audioTrack.registerStreamEventCallback(handler::post, callback); } public void unregister(AudioTrack audioTrack) { - audioTrack.unregisterStreamEventCallback(this); + audioTrack.unregisterStreamEventCallback(callback); handler.removeCallbacksAndMessages(/* token= */ null); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index de3f595976..a2ba72dbc0 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 @@ -363,6 +363,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Nullable private DrmSession sourceDrmSession; @Nullable private MediaCrypto mediaCrypto; private boolean mediaCryptoRequiresSecureDecoder; + private long renderTimeLimitMs; private float operatingRate; @Nullable private MediaCodec codec; @Nullable private MediaCodecAdapter codecAdapter; @@ -442,6 +443,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { outputBufferInfo = new MediaCodec.BufferInfo(); operatingRate = 1f; mediaCodecOperationMode = OPERATION_MODE_SYNCHRONOUS; + 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]; @@ -451,6 +453,19 @@ public abstract class MediaCodecRenderer extends BaseRenderer { resetCodecStateForRelease(); } + /** + * Set a limit on the time a single {@link #render(long, long)} call can spend draining and + * filling the decoder. + * + *

This method 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; + } + /** * Set the mode of operation of the underlying {@link MediaCodec}. * @@ -837,9 +852,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { while (bypassRender(positionUs, elapsedRealtimeUs)) {} TraceUtil.endSection(); } else if (codec != null) { + long renderStartTimeMs = SystemClock.elapsedRealtime(); TraceUtil.beginSection("drainAndFeed"); - while (drainOutputBuffer(positionUs, elapsedRealtimeUs)) {} - while (feedInputBuffer()) {} + while (drainOutputBuffer(positionUs, elapsedRealtimeUs) + && shouldContinueRendering(renderStartTimeMs)) {} + while (feedInputBuffer() && shouldContinueRendering(renderStartTimeMs)) {} TraceUtil.endSection(); } else { decoderCounters.skippedInputBufferCount += skipSource(positionUs); @@ -1171,6 +1188,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { onCodecInitialized(codecName, codecInitializedTimestamp, elapsed); } + private boolean shouldContinueRendering(long renderStartTimeMs) { + return renderTimeLimitMs == C.TIME_UNSET + || SystemClock.elapsedRealtime() - renderStartTimeMs < renderTimeLimitMs; + } + private void getCodecBuffers(MediaCodec codec) { if (Util.SDK_INT < 21) { inputBuffers = codec.getInputBuffers(); 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 3f1c03d3b1..8f85a0ac17 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; 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 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { 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 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { + " 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/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index 4d7230cc3a..19f09fde22 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 @@ public final class ProgressiveMediaSource extends BaseMediaSource /* 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/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index ab63ed83e6..6cb8a451b3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -281,6 +281,8 @@ public final class SingleSampleMediaSource extends BaseMediaSource { .setSampleMimeType(subtitle.mimeType) .setLanguage(subtitle.language) .setSelectionFlags(subtitle.selectionFlags) + .setRoleFlags(subtitle.roleFlags) + .setLabel(subtitle.label) .build(); dataSpec = new DataSpec.Builder().setUri(subtitle.uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); 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 f1c17c1093..fda5e15215 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 interface AdsLoader { */ 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 62c3e2ed17..7320f6f6c5 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 final class AdsMediaSource extends CompositeMediaSource { 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 final class AdsMediaSource extends CompositeMediaSource { * @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,8 @@ public final class AdsMediaSource extends CompositeMediaSource { contentMediaSource, new ProgressiveMediaSource.Factory(dataSourceFactory), adsLoader, - adViewProvider); + adViewProvider, + /* adTagDataSpec= */ null); } /** @@ -166,16 +171,53 @@ public final class AdsMediaSource extends CompositeMediaSource { * @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 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 @@ public final class AdsMediaSource extends CompositeMediaSource { 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/text/TextRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java index 6c140c74d1..76c1360045 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 @@ public final class TextRenderer extends BaseRenderer implements Callback { } 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) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java index 3bb39aba9c..f44db4924f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -263,8 +263,9 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { SsaStyle.Overrides styleOverrides = SsaStyle.Overrides.parseFromDialogue(rawText); String text = SsaStyle.Overrides.stripStyleOverrides(rawText) - .replaceAll("\\\\N", "\n") - .replaceAll("\\\\n", "\n"); + .replace("\\N", "\n") + .replace("\\n", "\n") + .replace("\\h", "\u00A0"); Cue cue = createCue(text, style, styleOverrides, screenWidth, screenHeight); int startTimeIndex = addCuePlacerholderByTime(startTimeUs, cueTimesUs, cues); 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 9949a370ed..16c63353ee 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 abstract class MappingTrackSelector extends TrackSelector { * 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 8ee9d29d3d..59c5d5447b 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 class TrackSelector { * * @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 9228f3af62..67623c2cf6 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/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java index 680ebbb2b1..2c3670f52a 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 final class DataSchemeDataSource extends BaseDataSource { 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/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java index 7efa89eaa0..12fea3898c 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 @@ import java.util.Map; *

  • 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 final class DefaultDataSource implements DataSource { 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/DefaultLoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java index 366bd6509e..dc5aefac6d 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 class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { 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/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java index 0595cb84bc..7538cc67a4 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 @@ package com.google.android.exoplayer2.upstream; 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 @@ import java.io.InputStream; /** * 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 final class RawResourceDataSource extends BaseDataSource { 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 final class RawResourceDataSource extends BaseDataSource { 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); - } + Uri uri = dataSpec.uri; + this.uri = uri; - int resourceId; + 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); } - FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); - this.inputStream = inputStream; + @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."); + } + } 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 final class RawResourceDataSource extends BaseDataSource { // 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); 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 19159ede6e..03336fdeba 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.DatagramPacket; 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 @@ import java.util.Arrays; */ 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 final class SntpClient { 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 final class SntpClient { @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 final class SntpClient { } 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 final class SntpClient { @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(); + } } } 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 5b26588244..0f6ea28ed9 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 @@ -1551,180 +1551,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } synchronized (MediaCodecVideoRenderer.class) { if (!evaluatedDeviceNeedsSetOutputSurfaceWorkaround) { - if ("dangal".equals(Util.DEVICE)) { - // Workaround for MiTV devices: - // https://github.com/google/ExoPlayer/issues/5169, - // https://github.com/google/ExoPlayer/issues/6899. - deviceNeedsSetOutputSurfaceWorkaround = true; - } else if (Util.SDK_INT <= 27 && "HWEML".equals(Util.DEVICE)) { - // Workaround for Huawei P20: - // https://github.com/google/ExoPlayer/issues/4468#issuecomment-459291645. - deviceNeedsSetOutputSurfaceWorkaround = true; - } else if (Util.SDK_INT >= 27) { - // In general, devices running API level 27 or later should be unaffected. Do nothing. - } else { - // Enable the workaround on a per-device basis. Works around: - // https://github.com/google/ExoPlayer/issues/3236, - // https://github.com/google/ExoPlayer/issues/3355, - // https://github.com/google/ExoPlayer/issues/3439, - // https://github.com/google/ExoPlayer/issues/3724, - // https://github.com/google/ExoPlayer/issues/3835, - // https://github.com/google/ExoPlayer/issues/4006, - // https://github.com/google/ExoPlayer/issues/4084, - // https://github.com/google/ExoPlayer/issues/4104, - // https://github.com/google/ExoPlayer/issues/4134, - // https://github.com/google/ExoPlayer/issues/4315, - // https://github.com/google/ExoPlayer/issues/4419, - // https://github.com/google/ExoPlayer/issues/4460, - // https://github.com/google/ExoPlayer/issues/4468, - // https://github.com/google/ExoPlayer/issues/5312, - // https://github.com/google/ExoPlayer/issues/6503. - switch (Util.DEVICE) { - case "1601": - case "1713": - case "1714": - case "A10-70F": - case "A10-70L": - case "A1601": - case "A2016a40": - case "A7000-a": - case "A7000plus": - case "A7010a48": - case "A7020a48": - case "AquaPowerM": - case "ASUS_X00AD_2": - case "Aura_Note_2": - case "BLACK-1X": - case "BRAVIA_ATV2": - case "BRAVIA_ATV3_4K": - case "C1": - case "ComioS1": - case "CP8676_I02": - case "CPH1609": - case "CPY83_I00": - case "cv1": - case "cv3": - case "deb": - case "E5643": - case "ELUGA_A3_Pro": - case "ELUGA_Note": - case "ELUGA_Prim": - case "ELUGA_Ray_X": - case "EverStar_S": - case "F02H": - case "F03H": - case "F3111": - case "F3113": - case "F3116": - case "F3211": - case "F3213": - case "F3215": - case "F3311": - case "flo": - case "fugu": - case "GiONEE_CBL7513": - case "GiONEE_GBL7319": - case "GIONEE_GBL7360": - case "GIONEE_SWW1609": - case "GIONEE_SWW1627": - case "GIONEE_SWW1631": - case "GIONEE_WBL5708": - case "GIONEE_WBL7365": - case "GIONEE_WBL7519": - case "griffin": - case "htc_e56ml_dtul": - case "hwALE-H": - case "HWBLN-H": - case "HWCAM-H": - case "HWVNS-H": - case "HWWAS-H": - case "i9031": - case "iball8735_9806": - case "Infinix-X572": - case "iris60": - case "itel_S41": - case "j2xlteins": - case "JGZ": - case "K50a40": - case "kate": - case "l5460": - case "le_x6": - case "LS-5017": - case "M5c": - case "manning": - case "marino_f": - case "MEIZU_M5": - case "mh": - case "mido": - case "MX6": - case "namath": - case "nicklaus_f": - case "NX541J": - case "NX573J": - case "OnePlus5T": - case "p212": - case "P681": - case "P85": - case "panell_d": - case "panell_dl": - case "panell_ds": - case "panell_dt": - case "PB2-670M": - case "PGN528": - case "PGN610": - case "PGN611": - case "Phantom6": - case "Pixi4-7_3G": - case "Pixi5-10_4G": - case "PLE": - case "PRO7S": - case "Q350": - case "Q4260": - case "Q427": - case "Q4310": - case "Q5": - case "QM16XE_U": - case "QX1": - case "santoni": - case "Slate_Pro": - case "SVP-DTV15": - case "s905x018": - case "taido_row": - case "TB3-730F": - case "TB3-730X": - case "TB3-850F": - case "TB3-850M": - case "tcl_eu": - case "V1": - case "V23GB": - case "V5": - case "vernee_M5": - case "watson": - case "whyred": - case "woods_f": - case "woods_fn": - case "X3_HK": - case "XE2X": - case "XT1663": - case "Z12_PRO": - case "Z80": - deviceNeedsSetOutputSurfaceWorkaround = true; - break; - default: - // Do nothing. - break; - } - switch (Util.MODEL) { - case "AFTA": - case "AFTN": - case "JSN-L21": - deviceNeedsSetOutputSurfaceWorkaround = true; - break; - default: - // Do nothing. - break; - } - } + deviceNeedsSetOutputSurfaceWorkaround = evaluateDeviceNeedsSetOutputSurfaceWorkaround(); evaluatedDeviceNeedsSetOutputSurfaceWorkaround = true; } } @@ -1746,7 +1573,205 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { this.height = height; this.inputSize = inputSize; } + } + private static boolean evaluateDeviceNeedsSetOutputSurfaceWorkaround() { + if (Util.SDK_INT <= 28) { + // Workaround for MiTV devices which have been observed broken up to API 28. + // https://github.com/google/ExoPlayer/issues/5169, + // https://github.com/google/ExoPlayer/issues/6899. + // https://github.com/google/ExoPlayer/issues/8014. + switch (Util.DEVICE) { + case "dangal": + case "dangalUHD": + case "dangalFHD": + case "magnolia": + case "machuca": + return true; + default: + break; // Do nothing. + } + } + if (Util.SDK_INT <= 27 && "HWEML".equals(Util.DEVICE)) { + // Workaround for Huawei P20: + // https://github.com/google/ExoPlayer/issues/4468#issuecomment-459291645. + return true; + } + if (Util.SDK_INT <= 26) { + // In general, devices running API level 27 or later should be unaffected unless observed + // otherwise. Enable the workaround on a per-device basis. Works around: + // https://github.com/google/ExoPlayer/issues/3236, + // https://github.com/google/ExoPlayer/issues/3355, + // https://github.com/google/ExoPlayer/issues/3439, + // https://github.com/google/ExoPlayer/issues/3724, + // https://github.com/google/ExoPlayer/issues/3835, + // https://github.com/google/ExoPlayer/issues/4006, + // https://github.com/google/ExoPlayer/issues/4084, + // https://github.com/google/ExoPlayer/issues/4104, + // https://github.com/google/ExoPlayer/issues/4134, + // https://github.com/google/ExoPlayer/issues/4315, + // https://github.com/google/ExoPlayer/issues/4419, + // https://github.com/google/ExoPlayer/issues/4460, + // https://github.com/google/ExoPlayer/issues/4468, + // https://github.com/google/ExoPlayer/issues/5312, + // https://github.com/google/ExoPlayer/issues/6503. + // https://github.com/google/ExoPlayer/issues/8014, + // https://github.com/google/ExoPlayer/pull/8030. + switch (Util.DEVICE) { + case "1601": + case "1713": + case "1714": + case "601LV": + case "602LV": + case "A10-70F": + case "A10-70L": + case "A1601": + case "A2016a40": + case "A7000-a": + case "A7000plus": + case "A7010a48": + case "A7020a48": + case "AquaPowerM": + case "ASUS_X00AD_2": + case "Aura_Note_2": + case "b5": + case "BLACK-1X": + case "BRAVIA_ATV2": + case "BRAVIA_ATV3_4K": + case "C1": + 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": + case "F3211": + case "F3213": + case "F3215": + case "F3311": + case "flo": + case "fugu": + case "GiONEE_CBL7513": + case "GiONEE_GBL7319": + case "GIONEE_GBL7360": + case "GIONEE_SWW1609": + case "GIONEE_SWW1627": + case "GIONEE_SWW1631": + case "GIONEE_WBL5708": + case "GIONEE_WBL7365": + case "GIONEE_WBL7519": + case "griffin": + case "htc_e56ml_dtul": + case "hwALE-H": + case "HWBLN-H": + case "HWCAM-H": + case "HWVNS-H": + case "HWWAS-H": + case "i9031": + case "iball8735_9806": + case "Infinix-X572": + case "iris60": + case "itel_S41": + case "j2xlteins": + case "JGZ": + case "K50a40": + case "kate": + case "l5460": + case "le_x6": + case "LS-5017": + case "M04": + case "M5c": + case "manning": + case "marino_f": + case "MEIZU_M5": + case "mh": + case "mido": + case "MX6": + case "namath": + case "nicklaus_f": + case "NX541J": + case "NX573J": + case "OnePlus5T": + case "p212": + case "P681": + case "P85": + case "pacificrim": + case "panell_d": + case "panell_dl": + case "panell_ds": + case "panell_dt": + case "PB2-670M": + case "PGN528": + case "PGN610": + case "PGN611": + case "Phantom6": + case "Pixi4-7_3G": + case "Pixi5-10_4G": + case "PLE": + case "PRO7S": + case "Q350": + case "Q4260": + case "Q427": + case "Q4310": + case "Q5": + case "QM16XE_U": + case "QX1": + case "RAIJIN": + case "santoni": + case "Slate_Pro": + case "SVP-DTV15": + case "s905x018": + case "taido_row": + case "TB3-730F": + case "TB3-730X": + case "TB3-850F": + case "TB3-850M": + case "tcl_eu": + case "V1": + case "V23GB": + case "V5": + case "vernee_M5": + case "watson": + case "whyred": + case "woods_f": + case "woods_fn": + case "X3_HK": + case "XE2X": + case "XT1663": + case "Z12_PRO": + case "Z80": + return true; + default: + break; // Do nothing. + } + switch (Util.MODEL) { + case "AFTA": + case "AFTN": + case "JSN-L21": + return true; + default: + break; // Do nothing. + } + } + return false; } @RequiresApi(23) 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 444640256f..45ba914d34 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 @@ -58,6 +58,7 @@ import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.ClippingMediaSource; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +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; @@ -98,12 +99,13 @@ import com.google.android.exoplayer2.trackselection.TrackSelection; 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; import com.google.android.exoplayer2.util.Clock; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -5553,7 +5555,8 @@ public final class ExoPlayerTest { 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}; @@ -5590,7 +5593,8 @@ public final class ExoPlayerTest { AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory(context), + /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); final Exception[] exception = {null}; @@ -5629,7 +5633,8 @@ public final class ExoPlayerTest { AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory(context), + /* adTagDataSpec= */ new DataSpec(Uri.EMPTY), + new DefaultMediaSourceFactory(context), new FakeAdsLoader(), new FakeAdViewProvider()); final Exception[] exception = {null}; @@ -5699,6 +5704,42 @@ public final class ExoPlayerTest { 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 { @@ -7334,6 +7375,8 @@ public final class ExoPlayerTest { 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 +7391,11 @@ public final class ExoPlayerTest { @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 +7428,8 @@ public final class ExoPlayerTest { }; } }; - 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(); @@ -8512,6 +8552,9 @@ public final class ExoPlayerTest { @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/e2etest/Mp4PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java index f37610d982..5fd7453beb 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 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.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 d57f06ff52..52184f5751 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,8 +22,8 @@ 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.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/trackselection/TrackSelectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/TrackSelectorTest.java index 477f7226a4..f407331711 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 @@ package com.google.android.exoplayer2.trackselection; 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 class TrackSelectorTest { } @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/DataSchemeDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DataSchemeDataSourceTest.java index 0acbd74891..7a99b97bd5 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 static org.junit.Assert.fail; 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; @@ -146,6 +145,14 @@ public final class DataSchemeDataSourceTest { } } + @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); } @@ -167,7 +174,7 @@ public final class DataSchemeDataSourceTest { 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/DefaultBandwidthMeterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java index 23f5a17e93..0b807c487a 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 final class DefaultBandwidthMeterTest { /* isAvailable= */ true, CONNECTED); } - + @Test public void defaultInitialBitrateEstimate_forWifi_isGreaterThanEstimateFor2G() { setActiveNetworkInfo(networkInfoWifi); 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 50b06c14db..02a7210683 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 @@ -47,41 +47,46 @@ public final class DefaultLoadErrorHandlingPolicyTest { private static final MediaLoadData PLACEHOLDER_MEDIA_LOAD_DATA = new MediaLoadData(/* dataType= */ C.DATA_TYPE_UNKNOWN); + @Test + public void getExclusionDurationMsFor_responseCode403() { + InvalidResponseCodeException exception = buildInvalidResponseCodeException(403, "Forbidden"); + assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) + .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); + } + @Test public void getExclusionDurationMsFor_responseCode404() { - InvalidResponseCodeException exception = - new InvalidResponseCodeException( - 404, - "Not Found", - Collections.emptyMap(), - new DataSpec(Uri.EMPTY), - /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + InvalidResponseCodeException exception = buildInvalidResponseCodeException(404, "Not found"); assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); } @Test public void getExclusionDurationMsFor_responseCode410() { + InvalidResponseCodeException exception = buildInvalidResponseCodeException(410, "Gone"); + assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) + .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); + } + + @Test + public void getExclusionDurationMsFor_responseCode500() { InvalidResponseCodeException exception = - new InvalidResponseCodeException( - 410, - "Gone", - Collections.emptyMap(), - new DataSpec(Uri.EMPTY), - /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + buildInvalidResponseCodeException(500, "Internal server error"); + assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) + .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); + } + + @Test + public void getExclusionDurationMsFor_responseCode503() { + InvalidResponseCodeException exception = + buildInvalidResponseCodeException(503, "Service unavailable"); assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); } @Test public void getExclusionDurationMsFor_dontExcludeUnexpectedHttpCodes() { - InvalidResponseCodeException exception = - new InvalidResponseCodeException( - 500, - "Internal Server Error", - Collections.emptyMap(), - new DataSpec(Uri.EMPTY), - /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + InvalidResponseCodeException exception = buildInvalidResponseCodeException(418, "I'm a teapot"); assertThat(getDefaultPolicyExclusionDurationMsFor(exception)).isEqualTo(C.TIME_UNSET); } @@ -120,4 +125,14 @@ public final class DefaultLoadErrorHandlingPolicyTest { PLACEHOLDER_LOAD_EVENT_INFO, PLACEHOLDER_MEDIA_LOAD_DATA, exception, errorCount); return new DefaultLoadErrorHandlingPolicy().getRetryDelayMsFor(loadErrorInfo); } + + private static InvalidResponseCodeException buildInvalidResponseCodeException( + int statusCode, String message) { + return new InvalidResponseCodeException( + statusCode, + message, + Collections.emptyMap(), + new DataSpec(Uri.EMPTY), + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + } } 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 cadd9e43ab..5ee8e423b7 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 final class CacheDataSourceTest { 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 final class CacheDataSourceTest { 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 final class CacheDataSourceTest { cacheWriter.cache(); // Read the rest of the data. - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); } @@ -419,7 +419,7 @@ public final class CacheDataSourceTest { cacheWriter.cache(); // Read the rest of the data. - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); } @@ -449,14 +449,14 @@ public final class CacheDataSourceTest { // 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 final class CacheDataSourceTest { 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 final class CacheDataSourceTest { } // Read the rest of the data. - TestUtil.readToEnd(cacheDataSource); + Util.readToEnd(cacheDataSource); cacheDataSource.close(); } 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 4ba5eb34b1..74d110516b 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.DrmSessionEventListener; 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 class MediaCodecVideoRendererTest { /* 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/dash/build.gradle b/library/dash/build.gradle index e6cb20d933..dd1a939fb7 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -25,17 +25,11 @@ android { dependencies { implementation project(modulePrefix + 'library-core') - 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 implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + testImplementation project(modulePrefix + 'robolectricutils') testImplementation project(modulePrefix + 'testutils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion } 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 e9e9c66df2..83e6556fe1 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 @@ -70,6 +70,16 @@ public class DashManifestParser extends DefaultHandler private static final Pattern CEA_708_ACCESSIBILITY_PATTERN = Pattern.compile("([1-9]|[1-5][0-9]|6[0-3])=.*"); + /** + * Maps the value attribute of an AudioElementConfiguration with schemeIdUri + * "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1, to a channel + * count. + */ + private static final int[] MPEG_CHANNEL_CONFIGURATION_MAPPING = + new int[] { + Format.NO_VALUE, 1, 2, 3, 4, 5, 6, 8, 2, 3, 4, 7, 8, 24, 8, 12, 10, 12, 14, 12, 14 + }; + private final XmlPullParserFactory xmlParserFactory; public DashManifestParser() { @@ -1156,13 +1166,22 @@ public class DashManifestParser extends DefaultHandler protected int parseAudioChannelConfiguration(XmlPullParser xpp) throws XmlPullParserException, IOException { String schemeIdUri = parseString(xpp, "schemeIdUri", null); - int audioChannels = - "urn:mpeg:dash:23003:3:audio_channel_configuration:2011".equals(schemeIdUri) - ? parseInt(xpp, "value", Format.NO_VALUE) - : ("tag:dolby.com,2014:dash:audio_channel_configuration:2011".equals(schemeIdUri) - || "urn:dolby:dash:audio_channel_configuration:2011".equals(schemeIdUri) - ? parseDolbyChannelConfiguration(xpp) - : Format.NO_VALUE); + int audioChannels; + switch (schemeIdUri) { + case "urn:mpeg:dash:23003:3:audio_channel_configuration:2011": + audioChannels = parseInt(xpp, "value", Format.NO_VALUE); + break; + case "urn:mpeg:mpegB:cicp:ChannelConfiguration": + audioChannels = parseMpegChannelConfiguration(xpp); + break; + case "tag:dolby.com,2014:dash:audio_channel_configuration:2011": + case "urn:dolby:dash:audio_channel_configuration:2011": + audioChannels = parseDolbyChannelConfiguration(xpp); + break; + default: + audioChannels = Format.NO_VALUE; + break; + } do { xpp.next(); } while (!XmlPullParserUtil.isEndTag(xpp, "AudioChannelConfiguration")); @@ -1344,7 +1363,8 @@ public class DashManifestParser extends DefaultHandler // All other text types are raw formats. return containerMimeType; } else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) { - return MimeTypes.getMediaMimeType(codecs); + @Nullable String mimeType = MimeTypes.getMediaMimeType(codecs); + return MimeTypes.TEXT_VTT.equals(mimeType) ? MimeTypes.APPLICATION_MP4VTT : mimeType; } return null; } @@ -1528,6 +1548,21 @@ public class DashManifestParser extends DefaultHandler return value == null ? defaultValue : value; } + /** + * Parses the number of channels from the value attribute of an AudioElementConfiguration with + * schemeIdUri "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1. + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseMpegChannelConfiguration(XmlPullParser xpp) { + int index = parseInt(xpp, "value", C.INDEX_UNSET); + return 0 <= index && index < MPEG_CHANNEL_CONFIGURATION_MAPPING.length + ? MPEG_CHANNEL_CONFIGURATION_MAPPING[index] + : Format.NO_VALUE; + } + /** * Parses the number of channels from the value attribute of an AudioElementConfiguration with * schemeIdUri "tag:dolby.com,2014:dash:audio_channel_configuration:2011", as defined by table E.5 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 0000000000..e0ea43b114 --- /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/library/extractor/build.gradle b/library/extractor/build.gradle index 82c2309c5f..e7f20051cd 100644 --- a/library/extractor/build.gradle +++ b/library/extractor/build.gradle @@ -26,13 +26,6 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation project(modulePrefix + 'library-common') - 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/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 2eba1b1cca..2068853d9e 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 final class DefaultExtractorsFactory implements ExtractorsFactory { /** * 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 final class DefaultExtractorsFactory implements ExtractorsFactory { 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 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { extractors.add(new PsExtractor()); break; case FileTypes.TS: - extractors.add(new TsExtractor(tsMode, tsFlags)); + extractors.add(new TsExtractor(tsMode, tsFlags, tsTimestampSearchBytes)); break; case FileTypes.WAV: extractors.add(new WavExtractor()); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/IndexSeekMap.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/IndexSeekMap.java new file mode 100644 index 0000000000..df56072eb3 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/IndexSeekMap.java @@ -0,0 +1,85 @@ +/* + * 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.extractor; + +import static com.google.android.exoplayer2.util.Assertions.checkArgument; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Util; + +/** + * A {@link SeekMap} implementation based on a mapping between times and positions in the input + * stream. + */ +public final class IndexSeekMap implements SeekMap { + + private final long[] positions; + private final long[] timesUs; + private final long durationUs; + private final boolean isSeekable; + + /** + * Creates an instance. + * + * @param positions The positions in the stream corresponding to {@code timesUs}, in bytes. + * @param timesUs The times corresponding to {@code positions}, in microseconds. + * @param durationUs The duration of the input stream, or {@link C#TIME_UNSET} if it is unknown. + */ + public IndexSeekMap(long[] positions, long[] timesUs, long durationUs) { + checkArgument(positions.length == timesUs.length); + int length = timesUs.length; + isSeekable = length > 0; + if (isSeekable && timesUs[0] > 0) { + // Add (position = 0, timeUs = 0) as first entry. + this.positions = new long[length + 1]; + this.timesUs = new long[length + 1]; + System.arraycopy(positions, 0, this.positions, 1, length); + System.arraycopy(timesUs, 0, this.timesUs, 1, length); + } else { + this.positions = positions; + this.timesUs = timesUs; + } + this.durationUs = durationUs; + } + + @Override + public boolean isSeekable() { + return isSeekable; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public SeekMap.SeekPoints getSeekPoints(long timeUs) { + if (!isSeekable) { + return new SeekMap.SeekPoints(SeekPoint.START); + } + int targetIndex = + Util.binarySearchFloor(timesUs, timeUs, /* inclusive= */ true, /* stayInBounds= */ true); + SeekPoint leftSeekPoint = new SeekPoint(timesUs[targetIndex], positions[targetIndex]); + if (leftSeekPoint.timeUs == timeUs || targetIndex == timesUs.length - 1) { + return new SeekMap.SeekPoints(leftSeekPoint); + } else { + SeekPoint rightSeekPoint = + new SeekPoint(timesUs[targetIndex + 1], positions[targetIndex + 1]); + return new SeekMap.SeekPoints(leftSeekPoint, rightSeekPoint); + } + } +} 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 a90410c02d..6f9c5b9c40 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 @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.IndexSeekMap; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.util.Assertions; @@ -135,8 +136,12 @@ public final class FlvExtractor implements Extractor { @Override public void seek(long position, long timeUs) { - state = STATE_READING_FLV_HEADER; - outputFirstSample = false; + if (position == 0) { + state = STATE_READING_FLV_HEADER; + outputFirstSample = false; + } else { + state = STATE_READING_TAG_HEADER; + } bytesToNextTagHeader = 0; } @@ -267,7 +272,11 @@ public final class FlvExtractor implements Extractor { wasSampleOutput = metadataReader.consume(prepareTagData(input), timestampUs); long durationUs = metadataReader.getDurationUs(); if (durationUs != C.TIME_UNSET) { - extractorOutput.seekMap(new SeekMap.Unseekable(durationUs)); + extractorOutput.seekMap( + new IndexSeekMap( + metadataReader.getKeyFrameTagPositions(), + metadataReader.getKeyFrameTimesUs(), + durationUs)); outputSeekMap = true; } } else { 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 54594ed50f..f0b4efb106 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 @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -31,6 +32,9 @@ import java.util.Map; 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; @@ -43,16 +47,28 @@ import java.util.Map; private static final int AMF_TYPE_DATE = 11; private long durationUs; + private long[] keyFrameTimesUs; + private long[] keyFrameTagPositions; public ScriptTagPayloadReader() { super(new DummyTrackOutput()); durationUs = C.TIME_UNSET; + keyFrameTimesUs = new long[0]; + keyFrameTagPositions = new long[0]; } public long getDurationUs() { return durationUs; } + public long[] getKeyFrameTimesUs() { + return keyFrameTimesUs; + } + + public long[] getKeyFrameTagPositions() { + return keyFrameTagPositions; + } + @Override public void seek() { // Do nothing. @@ -80,14 +96,41 @@ import java.util.Map; // We're not interested in this metadata. return false; } - // Set the duration to the value contained in the metadata, if present. Map metadata = readAmfEcmaArray(data); - if (metadata.containsKey(KEY_DURATION)) { - double durationSeconds = (double) metadata.get(KEY_DURATION); + // Set the duration to the value contained in the metadata, if present. + @Nullable Object durationSecondsObj = metadata.get(KEY_DURATION); + if (durationSecondsObj instanceof Double) { + double durationSeconds = (double) durationSecondsObj; if (durationSeconds > 0.0) { durationUs = (long) (durationSeconds * C.MICROS_PER_SECOND); } } + // Set the key frame times and positions to the value contained in the metadata, if present. + @Nullable Object keyFramesObj = metadata.get(KEY_KEY_FRAMES); + if (keyFramesObj instanceof Map) { + Map keyFrames = (Map) keyFramesObj; + @Nullable Object positionsObj = keyFrames.get(KEY_FILE_POSITIONS); + @Nullable Object timesSecondsObj = keyFrames.get(KEY_TIMES); + if (positionsObj instanceof List && timesSecondsObj instanceof List) { + List positions = (List) positionsObj; + List timesSeconds = (List) timesSecondsObj; + int keyFrameCount = timesSeconds.size(); + keyFrameTimesUs = new long[keyFrameCount]; + keyFrameTagPositions = new long[keyFrameCount]; + for (int i = 0; i < keyFrameCount; i++) { + Object positionObj = positions.get(i); + Object timeSecondsObj = timesSeconds.get(i); + if (timeSecondsObj instanceof Double && positionObj instanceof Double) { + keyFrameTimesUs[i] = (long) (((Double) timeSecondsObj) * C.MICROS_PER_SECOND); + keyFrameTagPositions[i] = ((Double) positionObj).longValue(); + } else { + keyFrameTimesUs = new long[0]; + keyFrameTagPositions = new long[0]; + break; + } + } + } + } return false; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/IndexSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/IndexSeeker.java index f8c63ff8e2..4b9d2e46e8 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/IndexSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/IndexSeeker.java @@ -70,7 +70,7 @@ import com.google.android.exoplayer2.util.Util; int targetIndex = Util.binarySearchFloor(timesUs, timeUs, /* inclusive= */ true, /* stayInBounds= */ true); SeekPoint seekPoint = new SeekPoint(timesUs.get(targetIndex), positions.get(targetIndex)); - if (seekPoint.timeUs >= timeUs || targetIndex == timesUs.size() - 1) { + if (seekPoint.timeUs == timeUs || targetIndex == timesUs.size() - 1) { return new SeekPoints(seekPoint); } else { SeekPoint nextSeekPoint = 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 1b627483f0..f30b830249 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 @@ import com.google.android.exoplayer2.util.Util; * * @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 @@ import com.google.android.exoplayer2.util.Util; 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 59d128ab9b..c2aba6d7bd 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.Metadata; 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 @@ public final class Mp3Extractor implements Extractor { @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 @@ public final class Mp3Extractor implements Extractor { } else if (seekFrameSeeker != null) { durationUs = seekFrameSeeker.getDurationUs(); dataEndPosition = seekFrameSeeker.getDataEndPosition(); + } else { + durationUs = getId3TlenUs(metadata); } resultSeeker = new IndexSeeker( @@ -554,10 +557,24 @@ public final class Mp3Extractor implements Extractor { 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; + } } 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 e86a873ed5..325dc24aec 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 @@ import java.util.List; @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; @@ -274,9 +277,6 @@ import java.util.List; @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; @@ -358,6 +358,9 @@ import java.util.List; @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 6eed09760e..58cb57f261 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 @@ -384,9 +384,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } // Fixed sample size raw audio may need to be rechunked. - boolean isFixedSampleSizeRawAudio = - sampleSizeBox.isFixedSampleSize() - && MimeTypes.AUDIO_RAW.equals(track.format.sampleMimeType) + int fixedSampleSize = sampleSizeBox.getFixedSampleSize(); + @Nullable String sampleMimeType = track.format.sampleMimeType; + boolean rechunkFixedSizeSamples = + fixedSampleSize != C.LENGTH_UNSET + && (MimeTypes.AUDIO_RAW.equals(sampleMimeType) + || MimeTypes.AUDIO_MLAW.equals(sampleMimeType) + || MimeTypes.AUDIO_ALAW.equals(sampleMimeType)) && remainingTimestampDeltaChanges == 0 && remainingTimestampOffsetChanges == 0 && remainingSynchronizationSamples == 0; @@ -399,15 +403,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; long timestampTimeUnits = 0; long duration; - if (isFixedSampleSizeRawAudio) { + if (rechunkFixedSizeSamples) { long[] chunkOffsetsBytes = new long[chunkIterator.length]; int[] chunkSampleCounts = new int[chunkIterator.length]; while (chunkIterator.moveNext()) { chunkOffsetsBytes[chunkIterator.index] = chunkIterator.offset; chunkSampleCounts[chunkIterator.index] = chunkIterator.numSamples; } - int fixedSampleSize = - Util.getPcmFrameSize(track.format.pcmEncoding, track.format.channelCount); FixedSampleSizeRechunker.Results rechunkedResults = FixedSampleSizeRechunker.rechunk( fixedSampleSize, chunkOffsetsBytes, chunkSampleCounts, timestampDeltaInTimeUnits); @@ -878,6 +880,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; || 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 @@ -891,6 +894,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; || 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 +1102,18 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; .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). * @@ -1229,7 +1246,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } 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; @@ -1646,16 +1663,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; */ int getSampleCount(); - /** - * Returns the size for the next sample. - */ + /** Returns the size of each sample if fixed, or {@link C#LENGTH_UNSET} otherwise. */ + int getFixedSampleSize(); + + /** Returns the size for the next sample. */ int readNextSampleSize(); - - /** - * Returns whether samples have a fixed size. - */ - boolean isFixedSampleSize(); - } /** @@ -1670,7 +1682,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; public StszSampleSizeBox(Atom.LeafAtom stszAtom) { data = stszAtom.data; data.setPosition(Atom.FULL_HEADER_SIZE); - fixedSampleSize = data.readUnsignedIntToInt(); + int fixedSampleSize = data.readUnsignedIntToInt(); + this.fixedSampleSize = fixedSampleSize == 0 ? C.LENGTH_UNSET : fixedSampleSize; sampleCount = data.readUnsignedIntToInt(); } @@ -1680,15 +1693,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } @Override - public int readNextSampleSize() { - return fixedSampleSize == 0 ? data.readUnsignedIntToInt() : fixedSampleSize; + public int getFixedSampleSize() { + return fixedSampleSize; } @Override - public boolean isFixedSampleSize() { - return fixedSampleSize != 0; + public int readNextSampleSize() { + return fixedSampleSize == C.LENGTH_UNSET ? data.readUnsignedIntToInt() : fixedSampleSize; } - } /** @@ -1716,6 +1728,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return sampleCount; } + @Override + public int getFixedSampleSize() { + return C.LENGTH_UNSET; + } + @Override public int readNextSampleSize() { if (fieldSize == 8) { @@ -1735,12 +1752,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } } - - @Override - public boolean isFixedSampleSize() { - return false; - } - } } 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 450bff4a36..c7718e7fa9 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 @@ import java.util.Arrays; 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 @@ import java.util.Arrays; } 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/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java index 7cc193e698..6a8068eedb 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java @@ -25,6 +25,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; /** * {@link StreamReader} to extract Vorbis data out of Ogg byte stream. @@ -160,8 +161,11 @@ import java.util.ArrayList; @VisibleForTesting /* package */ static void appendNumberOfSamples( ParsableByteArray buffer, long packetSampleCount) { - - buffer.setLimit(buffer.limit() + 4); + if (buffer.capacity() < buffer.limit() + 4) { + buffer.reset(Arrays.copyOf(buffer.getData(), buffer.limit() + 4)); + } else { + buffer.setLimit(buffer.limit() + 4); + } // The vorbis decoder expects the number of samples in the packet // to be appended to the audio data as an int32 byte[] data = buffer.getData(); 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 0764087b59..97fe7a7336 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 class PesReader implements TsPayloadReader { 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 @@ public final class PesReader implements TsPayloadReader { 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 @@ public final class PesReader implements TsPayloadReader { 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 @@ public final class PesReader implements TsPayloadReader { 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; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java index 8286189780..fa9792079c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java @@ -37,13 +37,16 @@ import java.io.IOException; private static final long SEEK_TOLERANCE_US = 100_000; private static final int MINIMUM_SEARCH_RANGE_BYTES = 5 * TsExtractor.TS_PACKET_SIZE; - private static final int TIMESTAMP_SEARCH_BYTES = 600 * TsExtractor.TS_PACKET_SIZE; public TsBinarySearchSeeker( - TimestampAdjuster pcrTimestampAdjuster, long streamDurationUs, long inputLength, int pcrPid) { + TimestampAdjuster pcrTimestampAdjuster, + long streamDurationUs, + long inputLength, + int pcrPid, + int timestampSearchBytes) { super( new DefaultSeekTimestampConverter(), - new TsPcrSeeker(pcrPid, pcrTimestampAdjuster), + new TsPcrSeeker(pcrPid, pcrTimestampAdjuster, timestampSearchBytes), streamDurationUs, /* floorTimePosition= */ 0, /* ceilingTimePosition= */ streamDurationUs + 1, @@ -58,7 +61,7 @@ import java.io.IOException; * position in a TS stream. * *

    Given 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 @@ import java.io.IOException; 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 @@ import java.io.IOException; 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 5020f4c76d..504b84d575 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 @@ import java.io.IOException; */ /* 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 @@ import java.io.IOException; 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 @@ import java.io.IOException; 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 @@ import java.io.IOException; 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 2fcfd422a0..2a9613f7f4 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 final class TsExtractor implements Extractor { * {@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 final class TsExtractor implements Extractor { * 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 final class TsExtractor implements Extractor { @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 final class TsExtractor implements Extractor { trackPids = new SparseBooleanArray(); tsPayloadReaders = new SparseArray<>(); continuityCounters = new SparseIntArray(); - durationReader = new TsDurationReader(); + durationReader = new TsDurationReader(timestampSearchBytes); pcrPid = -1; resetPayloadReaders(); } @@ -365,7 +401,8 @@ public final class TsExtractor implements Extractor { 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/flv/FlvExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorSeekTest.java new file mode 100644 index 0000000000..e03b7ec6d6 --- /dev/null +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorSeekTest.java @@ -0,0 +1,179 @@ +/* + * 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 static com.google.android.exoplayer2.testutil.TestUtil.extractAllSamplesFromFile; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.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.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Seeking tests for {@link FlvExtractor}. */ +@RunWith(AndroidJUnit4.class) +public class FlvExtractorSeekTest { + + private static final String TEST_FILE_KEY_FRAME_INDEX = + "media/flv/sample-with-key-frame-index.flv"; + private static final long DURATION_US = 3_042_000; + private static final long KEY_FRAMES_INTERVAL_US = C.MICROS_PER_SECOND; + + 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_KEY_FRAME_INDEX); + + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + + assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US); + assertThat(seekMap.isSeekable()).isTrue(); + } + + @Test + public void seeking_handlesSeekToZero() throws Exception { + String fileName = TEST_FILE_KEY_FRAME_INDEX; + Uri fileUri = TestUtil.buildAssetUri(fileName); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + int trackId = extractorOutput.trackOutputs.keyAt(0); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(trackId); + + long targetSeekTimeUs = 0; + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(C.INDEX_UNSET); + assertFirstFrameAfterSeekIsWithinKeyFrameInterval( + fileName, trackId, trackOutput, extractedFrameIndex, targetSeekTimeUs); + } + + @Test + public void seeking_handlesSeekToEof() throws Exception { + String fileName = TEST_FILE_KEY_FRAME_INDEX; + Uri fileUri = TestUtil.buildAssetUri(fileName); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + int trackId = extractorOutput.trackOutputs.keyAt(0); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(trackId); + + long targetSeekTimeUs = seekMap.getDurationUs(); + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(C.INDEX_UNSET); + assertFirstFrameAfterSeekIsWithinKeyFrameInterval( + fileName, trackId, trackOutput, extractedFrameIndex, targetSeekTimeUs); + } + + @Test + public void seeking_handlesSeekingBackward() throws Exception { + String fileName = TEST_FILE_KEY_FRAME_INDEX; + Uri fileUri = TestUtil.buildAssetUri(fileName); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + int trackId = extractorOutput.trackOutputs.keyAt(0); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(trackId); + + 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); + assertFirstFrameAfterSeekIsWithinKeyFrameInterval( + fileName, trackId, trackOutput, extractedFrameIndex, targetSeekTimeUs); + } + + @Test + public void seeking_handlesSeekingForward() throws Exception { + String fileName = TEST_FILE_KEY_FRAME_INDEX; + Uri fileUri = TestUtil.buildAssetUri(fileName); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + int trackId = extractorOutput.trackOutputs.keyAt(0); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(trackId); + + 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); + assertFirstFrameAfterSeekIsWithinKeyFrameInterval( + fileName, trackId, trackOutput, extractedFrameIndex, targetSeekTimeUs); + } + + private static void assertFirstFrameAfterSeekIsWithinKeyFrameInterval( + String fileName, + int trackId, + FakeTrackOutput trackOutput, + int firstFrameIndexAfterSeek, + long targetSeekTimeUs) + throws IOException { + long foundFrameTimeUs = trackOutput.getSampleTimeUs(firstFrameIndexAfterSeek); + assertThat(targetSeekTimeUs - foundFrameTimeUs).isAtMost(KEY_FRAMES_INTERVAL_US); + + FakeTrackOutput expectedTrackOutput = getTrackOutput(fileName, trackId); + int foundFrameIndex = getFrameIndex(expectedTrackOutput, foundFrameTimeUs); + + 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); + } +} diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java index 06678ae912..248e4b378d 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java @@ -38,4 +38,10 @@ public final class FlvExtractorTest { public void sample() throws Exception { ExtractorAsserts.assertBehavior(FlvExtractor::new, "media/flv/sample.flv", simulationConfig); } + + @Test + public void sampleSeekable() throws Exception { + ExtractorAsserts.assertBehavior( + FlvExtractor::new, "media/flv/sample-with-key-frame-index.flv", simulationConfig); + } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/AtomParsersTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/AtomParsersTest.java index 9ca974afb8..651384094f 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/AtomParsersTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/AtomParsersTest.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.extractor.mp4; 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.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import org.junit.Test; @@ -64,7 +65,7 @@ public final class AtomParsersTest { private static void verifyStz2Parsing(Atom.LeafAtom stz2Atom) { AtomParsers.Stz2SampleSizeBox box = new AtomParsers.Stz2SampleSizeBox(stz2Atom); assertThat(box.getSampleCount()).isEqualTo(4); - assertThat(box.isFixedSampleSize()).isFalse(); + assertThat(box.getFixedSampleSize()).isEqualTo(C.LENGTH_UNSET); for (int i = 0; i < box.getSampleCount(); i++) { assertThat(box.readNextSampleSize()).isEqualTo(i + 1); } 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 cc78d59bf4..0731cfd95e 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 final class OggExtractorParameterizedTest { 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/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 e8bc727222..dca8ba9938 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 final class AdtsExtractorTest { 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/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 8f744e855d..0e55d292b8 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(); } diff --git a/library/hls/build.gradle b/library/hls/build.gradle index df3b6d3586..2cc91a5105 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -25,13 +25,6 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - 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/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 530d56fa9c..2ab4852339 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 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; 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 diff --git a/library/ui/build.gradle b/library/ui/build.gradle index f63e55b3b3..81e1e5e126 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -20,13 +20,6 @@ dependencies { api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.recyclerview:recyclerview:' + androidxRecyclerViewVersion - 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/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 24d890134a..f7a99a50dc 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -227,13 +227,22 @@ public class DefaultTimeBar extends View implements TimeBar { this(context, attrs, defStyleAttr, attrs); } + public DefaultTimeBar( + Context context, + @Nullable AttributeSet attrs, + int defStyleAttr, + @Nullable AttributeSet timebarAttrs) { + this(context, attrs, defStyleAttr, timebarAttrs, 0); + } + // Suppress warnings due to usage of View methods in the constructor. @SuppressWarnings("nullness:method.invocation.invalid") public DefaultTimeBar( Context context, @Nullable AttributeSet attrs, int defStyleAttr, - @Nullable AttributeSet timebarAttrs) { + @Nullable AttributeSet timebarAttrs, + int defStyleRes) { super(context, attrs, defStyleAttr); seekBounds = new Rect(); progressBar = new Rect(); @@ -262,7 +271,10 @@ public class DefaultTimeBar extends View implements TimeBar { int defaultScrubberDraggedSize = dpToPx(density, DEFAULT_SCRUBBER_DRAGGED_SIZE_DP); if (timebarAttrs != null) { TypedArray a = - context.getTheme().obtainStyledAttributes(timebarAttrs, R.styleable.DefaultTimeBar, 0, 0); + context + .getTheme() + .obtainStyledAttributes( + timebarAttrs, R.styleable.DefaultTimeBar, defStyleAttr, defStyleRes); try { scrubberDrawable = a.getDrawable(R.styleable.DefaultTimeBar_scrubber_drawable); if (scrubberDrawable != null) { 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 e23c91cd16..b52a3e6f82 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 @@ public class PlayerNotificationManager { 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 @@ public class PlayerNotificationManager { } @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. 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 97652ad01f..ed2bad6eeb 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 @@ -459,6 +459,7 @@ public class StyledPlayerControlView extends FrameLayout { @SuppressWarnings({ "nullness:argument.type.incompatible", + "nullness:assignment.type.incompatible", "nullness:method.invocation.invalid", "nullness:methodref.receiver.bound.invalid" }) @@ -526,8 +527,11 @@ public class StyledPlayerControlView extends FrameLayout { a.recycle(); } } - controlViewLayoutManager = new StyledPlayerControlViewLayoutManager(); - controlViewLayoutManager.setAnimationEnabled(animationEnabled); + + LayoutInflater.from(context).inflate(controllerLayoutId, /* root= */ this); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + + componentListener = new ComponentListener(); visibilityListeners = new CopyOnWriteArrayList<>(); period = new Timeline.Period(); window = new Timeline.Window(); @@ -537,13 +541,9 @@ public class StyledPlayerControlView extends FrameLayout { playedAdGroups = new boolean[0]; extraAdGroupTimesMs = new long[0]; extraPlayedAdGroups = new boolean[0]; - componentListener = new ComponentListener(); controlDispatcher = new DefaultControlDispatcher(fastForwardMs, rewindMs); updateProgressAction = this::updateProgress; - LayoutInflater.from(context).inflate(controllerLayoutId, /* root= */ this); - setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); - // Relating to Bottom Bar Left View durationView = findViewById(R.id.exo_duration); positionView = findViewById(R.id.exo_position); @@ -570,7 +570,8 @@ public class StyledPlayerControlView extends FrameLayout { } else if (timeBarPlaceholder != null) { // Propagate attrs as timebarAttrs so that DefaultTimeBar's custom attributes are transferred, // but standard attributes (e.g. background) are not. - DefaultTimeBar defaultTimeBar = new DefaultTimeBar(context, null, 0, playbackAttrs); + DefaultTimeBar defaultTimeBar = + new DefaultTimeBar(context, null, 0, playbackAttrs, R.style.ExoStyledControls_TimeBar); defaultTimeBar.setId(R.id.exo_progress); defaultTimeBar.setLayoutParams(timeBarPlaceholder.getLayoutParams()); ViewGroup parent = ((ViewGroup) timeBarPlaceholder.getParent()); @@ -581,10 +582,10 @@ public class StyledPlayerControlView extends FrameLayout { } else { timeBar = null; } - if (timeBar != null) { timeBar.addListener(componentListener); } + playPauseButton = findViewById(R.id.exo_play_pause); if (playPauseButton != null) { playPauseButton.setOnClickListener(componentListener); @@ -626,7 +627,6 @@ public class StyledPlayerControlView extends FrameLayout { } resources = context.getResources(); - buttonAlphaEnabled = (float) resources.getInteger(R.integer.exo_media_button_opacity_percentage_enabled) / 100; buttonAlphaDisabled = @@ -634,10 +634,12 @@ public class StyledPlayerControlView extends FrameLayout { vrButton = findViewById(R.id.exo_vr); if (vrButton != null) { - setShowVrButton(showVrButton); updateButton(/* enabled= */ false, vrButton); } + controlViewLayoutManager = new StyledPlayerControlViewLayoutManager(this); + controlViewLayoutManager.setAnimationEnabled(animationEnabled); + // Related to Settings List View String[] settingTexts = new String[2]; Drawable[] settingIcons = new Drawable[2]; @@ -1071,6 +1073,11 @@ public class StyledPlayerControlView extends FrameLayout { controlViewLayoutManager.hide(); } + /** Hides the controller without any animation. */ + public void hideImmediately() { + controlViewLayoutManager.hideImmediately(); + } + /** Returns whether the controller is fully visible, which means all UI controls are visible. */ public boolean isFullyVisible() { return controlViewLayoutManager.isFullyVisible(); @@ -1159,13 +1166,14 @@ public class StyledPlayerControlView extends FrameLayout { 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 +1181,16 @@ public class StyledPlayerControlView extends FrameLayout { 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)); } } @@ -1604,7 +1614,7 @@ public class StyledPlayerControlView extends FrameLayout { @Override public void onAttachedToWindow() { super.onAttachedToWindow(); - controlViewLayoutManager.onViewAttached(this); + controlViewLayoutManager.onAttachedToWindow(); isAttachedToWindow = true; if (isFullyVisible()) { controlViewLayoutManager.resetHideCallbacks(); @@ -1615,7 +1625,7 @@ public class StyledPlayerControlView extends FrameLayout { @Override public void onDetachedFromWindow() { super.onDetachedFromWindow(); - controlViewLayoutManager.onViewDetached(this); + controlViewLayoutManager.onDetachedFromWindow(); isAttachedToWindow = false; removeCallbacks(updateProgressAction); controlViewLayoutManager.removeHideCallbacks(); @@ -2002,11 +2012,13 @@ public class StyledPlayerControlView extends FrameLayout { 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; 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 9435d2b5ba..9f035c6241 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 @@ -47,6 +47,26 @@ import java.util.List; // Int for defining the UX state where the views are being animated to be shown. private static final int UX_STATE_ANIMATING_SHOW = 4; + private final StyledPlayerControlView styledPlayerControlView; + + @Nullable private final ViewGroup embeddedTransportControls; + @Nullable private final ViewGroup bottomBar; + @Nullable private final ViewGroup minimalControls; + @Nullable private final ViewGroup basicControls; + @Nullable private final ViewGroup extraControls; + @Nullable private final ViewGroup extraControlsScrollView; + @Nullable private final ViewGroup timeView; + @Nullable private final View timeBar; + @Nullable private final View overflowShowButton; + + private final AnimatorSet hideMainBarsAnimator; + private final AnimatorSet hideProgressBarAnimator; + private final AnimatorSet hideAllBarsAnimator; + private final AnimatorSet showMainBarsAnimator; + private final AnimatorSet showAllBarsAnimator; + private final ValueAnimator overflowShowAnimator; + private final ValueAnimator overflowHideAnimator; + private final Runnable showAllBarsRunnable; private final Runnable hideAllBarsRunnable; private final Runnable hideProgressBarRunnable; @@ -57,32 +77,16 @@ import java.util.List; private final List shownButtons; private int uxState; - private boolean initiallyHidden; private boolean isMinimalMode; private boolean needToShowBars; private boolean animationEnabled; - @Nullable private StyledPlayerControlView styledPlayerControlView; - - @Nullable private ViewGroup embeddedTransportControls; - @Nullable private ViewGroup bottomBar; - @Nullable private ViewGroup minimalControls; - @Nullable private ViewGroup basicControls; - @Nullable private ViewGroup extraControls; - @Nullable private ViewGroup extraControlsScrollView; - @Nullable private ViewGroup timeView; - @Nullable private View timeBar; - @Nullable private View overflowShowButton; - - @Nullable private AnimatorSet hideMainBarsAnimator; - @Nullable private AnimatorSet hideProgressBarAnimator; - @Nullable private AnimatorSet hideAllBarsAnimator; - @Nullable private AnimatorSet showMainBarsAnimator; - @Nullable private AnimatorSet showAllBarsAnimator; - @Nullable private ValueAnimator overflowShowAnimator; - @Nullable private ValueAnimator overflowHideAnimator; - - public StyledPlayerControlViewLayoutManager() { + @SuppressWarnings({ + "nullness:method.invocation.invalid", + "nullness:methodref.receiver.bound.invalid" + }) + public StyledPlayerControlViewLayoutManager(StyledPlayerControlView styledPlayerControlView) { + this.styledPlayerControlView = styledPlayerControlView; showAllBarsRunnable = this::showAllBars; hideAllBarsRunnable = this::hideAllBars; hideProgressBarRunnable = this::hideProgressBar; @@ -92,121 +96,48 @@ import java.util.List; animationEnabled = true; uxState = UX_STATE_ALL_VISIBLE; shownButtons = new ArrayList<>(); - } - - public void show() { - initiallyHidden = false; - if (this.styledPlayerControlView == null) { - return; - } - StyledPlayerControlView styledPlayerControlView = this.styledPlayerControlView; - if (!styledPlayerControlView.isVisible()) { - styledPlayerControlView.setVisibility(View.VISIBLE); - styledPlayerControlView.updateAll(); - styledPlayerControlView.requestPlayPauseFocus(); - } - styledPlayerControlView.post(showAllBarsRunnable); - } - - public void hide() { - initiallyHidden = true; - if (styledPlayerControlView == null - || uxState == UX_STATE_ANIMATING_HIDE - || uxState == UX_STATE_NONE_VISIBLE) { - return; - } - removeHideCallbacks(); - if (!animationEnabled) { - postDelayedRunnable(hideControllerRunnable, 0); - } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { - postDelayedRunnable(hideProgressBarRunnable, 0); - } else { - postDelayedRunnable(hideAllBarsRunnable, 0); - } - } - - public void setAnimationEnabled(boolean animationEnabled) { - this.animationEnabled = animationEnabled; - } - - public boolean isAnimationEnabled() { - return animationEnabled; - } - - public void resetHideCallbacks() { - if (uxState == UX_STATE_ANIMATING_HIDE) { - return; - } - removeHideCallbacks(); - int showTimeoutMs = - styledPlayerControlView != null ? styledPlayerControlView.getShowTimeoutMs() : 0; - if (showTimeoutMs > 0) { - if (!animationEnabled) { - postDelayedRunnable(hideControllerRunnable, showTimeoutMs); - } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { - postDelayedRunnable(hideProgressBarRunnable, ANIMATION_INTERVAL_MS); - } else { - postDelayedRunnable(hideMainBarsRunnable, showTimeoutMs); - } - } - } - - public void removeHideCallbacks() { - if (styledPlayerControlView == null) { - return; - } - styledPlayerControlView.removeCallbacks(hideControllerRunnable); - styledPlayerControlView.removeCallbacks(hideAllBarsRunnable); - styledPlayerControlView.removeCallbacks(hideMainBarsRunnable); - styledPlayerControlView.removeCallbacks(hideProgressBarRunnable); - } - - // TODO(insun): Pass StyledPlayerControlView to constructor and reduce multiple nullchecks. - public void onViewAttached(StyledPlayerControlView v) { - styledPlayerControlView = v; - - v.setVisibility(initiallyHidden ? View.GONE : View.VISIBLE); - - v.addOnLayoutChangeListener(onLayoutChangeListener); // Relating to Center View - ViewGroup centerView = v.findViewById(R.id.exo_center_view); - embeddedTransportControls = v.findViewById(R.id.exo_embedded_transport_controls); + ViewGroup centerView = styledPlayerControlView.findViewById(R.id.exo_center_view); + embeddedTransportControls = + styledPlayerControlView.findViewById(R.id.exo_embedded_transport_controls); // Relating to Minimal Layout - minimalControls = v.findViewById(R.id.exo_minimal_controls); + minimalControls = styledPlayerControlView.findViewById(R.id.exo_minimal_controls); // Relating to Bottom Bar View - ViewGroup bottomBar = v.findViewById(R.id.exo_bottom_bar); + bottomBar = styledPlayerControlView.findViewById(R.id.exo_bottom_bar); // Relating to Bottom Bar Left View - timeView = v.findViewById(R.id.exo_time); - View timeBar = v.findViewById(R.id.exo_progress); + timeView = styledPlayerControlView.findViewById(R.id.exo_time); + timeBar = styledPlayerControlView.findViewById(R.id.exo_progress); // Relating to Bottom Bar Right View - basicControls = v.findViewById(R.id.exo_basic_controls); - extraControls = v.findViewById(R.id.exo_extra_controls); - extraControlsScrollView = v.findViewById(R.id.exo_extra_controls_scroll_view); - overflowShowButton = v.findViewById(R.id.exo_overflow_show); - View overflowHideButton = v.findViewById(R.id.exo_overflow_hide); + basicControls = styledPlayerControlView.findViewById(R.id.exo_basic_controls); + extraControls = styledPlayerControlView.findViewById(R.id.exo_extra_controls); + extraControlsScrollView = + styledPlayerControlView.findViewById(R.id.exo_extra_controls_scroll_view); + overflowShowButton = styledPlayerControlView.findViewById(R.id.exo_overflow_show); + View overflowHideButton = styledPlayerControlView.findViewById(R.id.exo_overflow_hide); if (overflowShowButton != null && overflowHideButton != null) { overflowShowButton.setOnClickListener(this::onOverflowButtonClick); overflowHideButton.setOnClickListener(this::onOverflowButtonClick); } - this.bottomBar = bottomBar; - this.timeBar = timeBar; - - Resources resources = v.getResources(); - float progressBarHeight = resources.getDimension(R.dimen.exo_custom_progress_thumb_size); - float bottomBarHeight = resources.getDimension(R.dimen.exo_bottom_bar_height); + Resources resources = styledPlayerControlView.getResources(); + float bottomBarHeight = + resources.getDimension(R.dimen.exo_bottom_bar_height) + - resources.getDimension(R.dimen.exo_styled_progress_bar_height); + float progressBarHeight = + resources.getDimension(R.dimen.exo_styled_progress_margin_bottom) + + resources.getDimension(R.dimen.exo_styled_progress_layout_height) + - bottomBarHeight; ValueAnimator fadeOutAnimator = ValueAnimator.ofFloat(1.0f, 0.0f); fadeOutAnimator.setInterpolator(new LinearInterpolator()); fadeOutAnimator.addUpdateListener( animation -> { float animatedValue = (float) animation.getAnimatedValue(); - if (centerView != null) { centerView.setAlpha(animatedValue); } @@ -239,7 +170,6 @@ import java.util.List; fadeInAnimator.addUpdateListener( animation -> { float animatedValue = (float) animation.getAnimatedValue(); - if (centerView != null) { centerView.setAlpha(animatedValue); } @@ -276,9 +206,7 @@ import java.util.List; public void onAnimationEnd(Animator animation) { setUxState(UX_STATE_ONLY_PROGRESS_VISIBLE); if (needToShowBars) { - if (styledPlayerControlView != null) { - styledPlayerControlView.post(showAllBarsRunnable); - } + styledPlayerControlView.post(showAllBarsRunnable); needToShowBars = false; } } @@ -301,9 +229,7 @@ import java.util.List; public void onAnimationEnd(Animator animation) { setUxState(UX_STATE_NONE_VISIBLE); if (needToShowBars) { - if (styledPlayerControlView != null) { - styledPlayerControlView.post(showAllBarsRunnable); - } + styledPlayerControlView.post(showAllBarsRunnable); needToShowBars = false; } } @@ -325,9 +251,7 @@ import java.util.List; public void onAnimationEnd(Animator animation) { setUxState(UX_STATE_NONE_VISIBLE); if (needToShowBars) { - if (styledPlayerControlView != null) { - styledPlayerControlView.post(showAllBarsRunnable); - } + styledPlayerControlView.post(showAllBarsRunnable); needToShowBars = false; } } @@ -420,14 +344,78 @@ import java.util.List; }); } - public void onViewDetached(StyledPlayerControlView v) { - v.removeOnLayoutChangeListener(onLayoutChangeListener); + public void show() { + if (!styledPlayerControlView.isVisible()) { + styledPlayerControlView.setVisibility(View.VISIBLE); + styledPlayerControlView.updateAll(); + styledPlayerControlView.requestPlayPauseFocus(); + } + showAllBars(); + } + + public void hide() { + if (uxState == UX_STATE_ANIMATING_HIDE || uxState == UX_STATE_NONE_VISIBLE) { + return; + } + removeHideCallbacks(); + if (!animationEnabled) { + hideController(); + } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { + hideProgressBar(); + } else { + hideAllBars(); + } + } + + public void hideImmediately() { + if (uxState == UX_STATE_ANIMATING_HIDE || uxState == UX_STATE_NONE_VISIBLE) { + return; + } + removeHideCallbacks(); + hideController(); + } + + public void setAnimationEnabled(boolean animationEnabled) { + this.animationEnabled = animationEnabled; + } + + public boolean isAnimationEnabled() { + return animationEnabled; + } + + public void resetHideCallbacks() { + if (uxState == UX_STATE_ANIMATING_HIDE) { + return; + } + removeHideCallbacks(); + int showTimeoutMs = styledPlayerControlView.getShowTimeoutMs(); + if (showTimeoutMs > 0) { + if (!animationEnabled) { + postDelayedRunnable(hideControllerRunnable, showTimeoutMs); + } else if (uxState == UX_STATE_ONLY_PROGRESS_VISIBLE) { + postDelayedRunnable(hideProgressBarRunnable, ANIMATION_INTERVAL_MS); + } else { + postDelayedRunnable(hideMainBarsRunnable, showTimeoutMs); + } + } + } + + public void removeHideCallbacks() { + styledPlayerControlView.removeCallbacks(hideControllerRunnable); + styledPlayerControlView.removeCallbacks(hideAllBarsRunnable); + styledPlayerControlView.removeCallbacks(hideMainBarsRunnable); + styledPlayerControlView.removeCallbacks(hideProgressBarRunnable); + } + + public void onAttachedToWindow() { + styledPlayerControlView.addOnLayoutChangeListener(onLayoutChangeListener); + } + + public void onDetachedFromWindow() { + styledPlayerControlView.removeOnLayoutChangeListener(onLayoutChangeListener); } public boolean isFullyVisible() { - if (styledPlayerControlView == null) { - return false; - } return uxState == UX_STATE_ALL_VISIBLE && styledPlayerControlView.isVisible(); } @@ -455,18 +443,15 @@ import java.util.List; private void setUxState(int uxState) { int prevUxState = this.uxState; this.uxState = uxState; - if (styledPlayerControlView != null) { - StyledPlayerControlView styledPlayerControlView = this.styledPlayerControlView; - if (uxState == UX_STATE_NONE_VISIBLE) { - styledPlayerControlView.setVisibility(View.GONE); - } else if (prevUxState == UX_STATE_NONE_VISIBLE) { - styledPlayerControlView.setVisibility(View.VISIBLE); - } - // TODO(insun): Notify specific uxState. Currently reuses legacy visibility listener for API - // compatibility. - if (prevUxState != uxState) { - styledPlayerControlView.notifyOnVisibilityChange(); - } + if (uxState == UX_STATE_NONE_VISIBLE) { + styledPlayerControlView.setVisibility(View.GONE); + } else if (prevUxState == UX_STATE_NONE_VISIBLE) { + styledPlayerControlView.setVisibility(View.VISIBLE); + } + // TODO(insun): Notify specific uxState. Currently reuses legacy visibility listener for API + // compatibility. + if (prevUxState != uxState) { + styledPlayerControlView.notifyOnVisibilityChange(); } } @@ -494,9 +479,9 @@ import java.util.List; private void onOverflowButtonClick(View v) { resetHideCallbacks(); - if (v.getId() == R.id.exo_overflow_show && overflowShowAnimator != null) { + if (v.getId() == R.id.exo_overflow_show) { overflowShowAnimator.start(); - } else if (v.getId() == R.id.exo_overflow_hide && overflowHideAnimator != null) { + } else if (v.getId() == R.id.exo_overflow_hide) { overflowHideAnimator.start(); } } @@ -510,14 +495,10 @@ import java.util.List; switch (uxState) { case UX_STATE_NONE_VISIBLE: - if (showAllBarsAnimator != null) { - showAllBarsAnimator.start(); - } + showAllBarsAnimator.start(); break; case UX_STATE_ONLY_PROGRESS_VISIBLE: - if (showMainBarsAnimator != null) { - showMainBarsAnimator.start(); - } + showMainBarsAnimator.start(); break; case UX_STATE_ANIMATING_HIDE: needToShowBars = true; @@ -531,23 +512,14 @@ import java.util.List; } private void hideAllBars() { - if (hideAllBarsAnimator == null) { - return; - } hideAllBarsAnimator.start(); } private void hideProgressBar() { - if (hideProgressBarAnimator == null) { - return; - } hideProgressBarAnimator.start(); } private void hideMainBars() { - if (hideMainBarsAnimator == null) { - return; - } hideMainBarsAnimator.start(); postDelayedRunnable(hideProgressBarRunnable, ANIMATION_INTERVAL_MS); } @@ -561,7 +533,7 @@ import java.util.List; } private void postDelayedRunnable(Runnable runnable, long interval) { - if (styledPlayerControlView != null && interval >= 0) { + if (interval >= 0) { styledPlayerControlView.postDelayed(runnable, interval); } } @@ -582,19 +554,14 @@ import java.util.List; } private boolean shouldBeMinimalMode() { - if (this.styledPlayerControlView == null) { - return isMinimalMode; - } - ViewGroup playerControlView = this.styledPlayerControlView; - int width = - playerControlView.getWidth() - - playerControlView.getPaddingLeft() - - playerControlView.getPaddingRight(); + styledPlayerControlView.getWidth() + - styledPlayerControlView.getPaddingLeft() + - styledPlayerControlView.getPaddingRight(); int height = - playerControlView.getHeight() - - playerControlView.getPaddingBottom() - - playerControlView.getPaddingTop(); + styledPlayerControlView.getHeight() + - styledPlayerControlView.getPaddingBottom() + - styledPlayerControlView.getPaddingTop(); int defaultModeWidth = Math.max( getWidth(embeddedTransportControls), getWidth(timeView) + getWidth(overflowShowButton)); @@ -605,16 +572,11 @@ import java.util.List; } private void updateLayoutForSizeChange() { - if (this.styledPlayerControlView == null) { - return; - } - StyledPlayerControlView playerControlView = this.styledPlayerControlView; - if (minimalControls != null) { minimalControls.setVisibility(isMinimalMode ? View.VISIBLE : View.INVISIBLE); } - View fullScreenButton = playerControlView.findViewById(R.id.exo_fullscreen); + View fullScreenButton = styledPlayerControlView.findViewById(R.id.exo_fullscreen); if (fullScreenButton != null) { ViewGroup parent = (ViewGroup) fullScreenButton.getParent(); parent.removeView(fullScreenButton); @@ -629,12 +591,11 @@ import java.util.List; } } if (timeBar != null) { - View timeBar = this.timeBar; MarginLayoutParams timeBarParams = (MarginLayoutParams) timeBar.getLayoutParams(); int timeBarMarginBottom = - playerControlView + styledPlayerControlView .getResources() - .getDimensionPixelSize(R.dimen.exo_custom_progress_margin_bottom); + .getDimensionPixelSize(R.dimen.exo_styled_progress_margin_bottom); timeBarParams.bottomMargin = (isMinimalMode ? 0 : timeBarMarginBottom); timeBar.setLayoutParams(timeBarParams); if (timeBar instanceof DefaultTimeBar @@ -668,18 +629,14 @@ import java.util.List; if (basicControls == null || extraControls == null) { return; } - ViewGroup basicControls = this.basicControls; - ViewGroup extraControls = this.extraControls; int width = - (styledPlayerControlView != null - ? styledPlayerControlView.getWidth() - - styledPlayerControlView.getPaddingLeft() - - styledPlayerControlView.getPaddingRight() - : 0); - int basicBottomBarWidth = getWidth(timeView); + styledPlayerControlView.getWidth() + - styledPlayerControlView.getPaddingLeft() + - styledPlayerControlView.getPaddingRight(); + 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. @@ -687,7 +644,7 @@ import java.util.List; // ExtraControls keeps overflow button and settings button at least. int minExtraControlsChildCount = 2; - if (basicBottomBarWidth > width) { + if (bottomBarWidth > width) { // move control views from basicControls to extraControls ArrayList movingChildren = new ArrayList<>(); int movingWidth = 0; @@ -696,7 +653,7 @@ import java.util.List; View child = basicControls.getChildAt(index); movingWidth += child.getWidth(); movingChildren.add(child); - if (basicBottomBarWidth - movingWidth <= width) { + if (bottomBarWidth - movingWidth <= width) { break; } } @@ -711,14 +668,14 @@ import java.util.List; } } else { - // move controls from extraControls to basicControls if possible, else do nothing + // Move controls from extraControls to basicControls if possible, else do nothing. ArrayList movingChildren = new ArrayList<>(); int movingWidth = 0; int startIndex = extraControls.getChildCount() - minExtraControlsChildCount - 1; for (int index = startIndex; index >= 0; index--) { View child = extraControls.getChildAt(index); movingWidth += child.getWidth(); - if (basicBottomBarWidth + movingWidth > width) { + if (bottomBarWidth + movingWidth > width) { break; } movingChildren.add(child); 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 8b6c5983c6..38d8bc9710 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 @@ -511,11 +511,11 @@ public class StyledPlayerView extends FrameLayout implements AdsLoader.AdViewPro this.controllerAutoShow = controllerAutoShow; this.controllerHideDuringAds = controllerHideDuringAds; this.useController = useController && controller != null; - hideController(); - updateContentDescription(); if (controller != null) { + controller.hideImmediately(); controller.addVisibilityListener(/* listener= */ componentListener); } + updateContentDescription(); } /** 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 452be5a3b7..bfd18aead7 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 @@ public final class SubtitleView extends FrameLayout implements TextOutput { return cue; } - } 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 520b2d7580..be3fb9bc90 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 @@ -25,6 +25,7 @@ import android.view.LayoutInflater; import android.view.View; import androidx.annotation.Nullable; import androidx.annotation.StyleRes; +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; @@ -32,6 +33,7 @@ import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedT 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}. */ @@ -62,6 +64,7 @@ public final class TrackSelectionDialogBuilder { @Nullable private TrackNameProvider trackNameProvider; private boolean isDisabled; private List overrides; + @Nullable private Comparator trackFormatComparator; /** * Creates a builder for a track selection dialog. @@ -208,6 +211,16 @@ public final class TrackSelectionDialogBuilder { return this; } + /** + * Sets a {@link Comparator} used to determine the display order of the tracks within each track + * group. + * + * @param trackFormatComparator The comparator, or {@code null} to use the original order. + */ + public void setTrackFormatComparator(@Nullable Comparator trackFormatComparator) { + this.trackFormatComparator = trackFormatComparator; + } + /** * 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. @@ -287,7 +300,13 @@ public final class TrackSelectionDialogBuilder { if (trackNameProvider != null) { selectionView.setTrackNameProvider(trackNameProvider); } - selectionView.init(mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ null); + selectionView.init( + mappedTrackInfo, + rendererIndex, + isDisabled, + overrides, + trackFormatComparator, + /* listener= */ null); 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 b47feb2a71..8a8f3d3c76 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 @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.ui; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; -import android.util.Pair; import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; @@ -26,6 +25,7 @@ import android.widget.CheckedTextView; 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 +35,7 @@ import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedT 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; @@ -71,6 +72,7 @@ public class TrackSelectionView extends LinearLayout { private int rendererIndex; private TrackGroupArray trackGroups; private boolean isDisabled; + @Nullable private Comparator trackInfoComparator; @Nullable private TrackSelectionListener listener; /** Creates a track selection view. */ @@ -196,6 +198,8 @@ public class TrackSelectionView extends LinearLayout { * @param overrides List of initial overrides to be shown for this renderer. There must be at most * one override for each track group. If {@link #setAllowMultipleOverrides(boolean)} hasn't * been set to {@code true}, only the first override is used. + * @param trackFormatComparator An optional comparator used to determine the display order of the + * tracks within each track group. * @param listener An optional listener for track selection updates. */ public void init( @@ -203,10 +207,15 @@ public class TrackSelectionView extends LinearLayout { int rendererIndex, boolean isDisabled, List overrides, + @Nullable Comparator trackFormatComparator, @Nullable TrackSelectionListener listener) { this.mappedTrackInfo = mappedTrackInfo; this.rendererIndex = rendererIndex; this.isDisabled = isDisabled; + this.trackInfoComparator = + trackFormatComparator == null + ? null + : (o1, o2) -> trackFormatComparator.compare(o1.format, o2.format); this.listener = listener; int maxOverrides = allowMultipleOverrides ? overrides.size() : Math.min(overrides.size(), 1); for (int i = 0; i < maxOverrides; i++) { @@ -259,7 +268,16 @@ public class TrackSelectionView extends LinearLayout { TrackGroup group = trackGroups.get(groupIndex); boolean enableMultipleChoiceForAdaptiveSelections = shouldEnableAdaptiveSelection(groupIndex); trackViews[groupIndex] = new CheckedTextView[group.length]; + + TrackInfo[] trackInfos = new TrackInfo[group.length]; for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + trackInfos[trackIndex] = new TrackInfo(groupIndex, trackIndex, group.getFormat(trackIndex)); + } + if (trackInfoComparator != null) { + Arrays.sort(trackInfos, trackInfoComparator); + } + + for (int trackIndex = 0; trackIndex < trackInfos.length; trackIndex++) { if (trackIndex == 0) { addView(inflater.inflate(R.layout.exo_list_divider, this, false)); } @@ -270,11 +288,11 @@ public class TrackSelectionView extends LinearLayout { CheckedTextView trackView = (CheckedTextView) inflater.inflate(trackViewLayoutId, this, false); trackView.setBackgroundResource(selectableItemBackgroundResourceId); - trackView.setText(trackNameProvider.getTrackName(group.getFormat(trackIndex))); + trackView.setText(trackNameProvider.getTrackName(trackInfos[trackIndex].format)); if (mappedTrackInfo.getTrackSupport(rendererIndex, groupIndex, trackIndex) == RendererCapabilities.FORMAT_HANDLED) { trackView.setFocusable(true); - trackView.setTag(Pair.create(groupIndex, trackIndex)); + trackView.setTag(trackInfos[trackIndex]); trackView.setOnClickListener(componentListener); } else { trackView.setFocusable(false); @@ -294,7 +312,12 @@ public class TrackSelectionView extends LinearLayout { 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) { + TrackInfo trackInfo = (TrackInfo) Assertions.checkNotNull(trackViews[i][j].getTag()); + trackViews[i][j].setChecked(override.containsTrack(trackInfo.trackIndex)); + } else { + trackViews[i][j].setChecked(false); + } } } } @@ -325,10 +348,9 @@ public class TrackSelectionView extends LinearLayout { private void onTrackViewClicked(View view) { isDisabled = false; - @SuppressWarnings("unchecked") - Pair tag = (Pair) Assertions.checkNotNull(view.getTag()); - int groupIndex = tag.first; - int trackIndex = tag.second; + TrackInfo trackInfo = (TrackInfo) Assertions.checkNotNull(view.getTag()); + int groupIndex = trackInfo.groupIndex; + int trackIndex = trackInfo.trackIndex; SelectionOverride override = overrides.get(groupIndex); Assertions.checkNotNull(mappedTrackInfo); if (override == null) { @@ -406,4 +428,16 @@ public class TrackSelectionView extends LinearLayout { TrackSelectionView.this.onClick(view); } } + + private static final class TrackInfo { + public final int groupIndex; + public final int trackIndex; + public final Format format; + + public TrackInfo(int groupIndex, int trackIndex, Format format) { + this.groupIndex = groupIndex; + this.trackIndex = trackIndex; + this.format = format; + } + } } diff --git a/library/ui/src/main/res/drawable/exo_progress.xml b/library/ui/src/main/res/drawable/exo_progress.xml deleted file mode 100644 index 2ba05326f0..0000000000 --- a/library/ui/src/main/res/drawable/exo_progress.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - 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 e0703ab394..75db3e4527 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 @@ -20,9 +20,19 @@ android:orientation="horizontal" android:visibility="visible"> - -