diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 8820273a73..9055d86943 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,84 @@ # Release notes # +### 2.11.5 (2020-06-05) ### + +* Improve the smoothness of video playback immediately after starting, seeking + or resuming a playback + ([#6901](https://github.com/google/ExoPlayer/issues/6901)). +* Add `SilenceMediaSource.Factory` to support tags. +* Enable the configuration of `SilenceSkippingAudioProcessor` + ([#6705](https://github.com/google/ExoPlayer/issues/6705)). +* Fix bug where `PlayerMessages` throw an exception after `MediaSources` + are removed from the playlist + ([#7278](https://github.com/google/ExoPlayer/issues/7278)). +* Fix "Not allowed to start service" `IllegalStateException` in + `DownloadService` + ([#7306](https://github.com/google/ExoPlayer/issues/7306)). +* Fix issue in `AudioTrackPositionTracker` that could cause negative positions + to be reported at the start of playback and immediately after seeking + ([#7456](https://github.com/google/ExoPlayer/issues/7456). +* Fix further cases where downloads would sometimes not resume after their + network requirements are met + ([#7453](https://github.com/google/ExoPlayer/issues/7453). +* DASH: + * Merge trick play adaptation sets (i.e., adaptation sets marked with + `http://dashif.org/guidelines/trickmode`) into the same `TrackGroup` as + the main adaptation sets to which they refer. Trick play tracks are + marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. + * Fix assertion failure in `SampleQueue` when playing DASH streams with + EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). +* MP4: Store the Android capture frame rate only in `Format.metadata`. + `Format.frameRate` now stores the calculated frame rate. +* FMP4: Avoid throwing an exception while parsing default sample values whose + most significant bits are set + ([#7207](https://github.com/google/ExoPlayer/issues/7207)). +* MP3: Fix issue parsing the XING headers belonging to files larger than 2GB + ([#7337](https://github.com/google/ExoPlayer/issues/7337)). +* MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 + samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). +* UI: + * Fix `DefaultTimeBar` to respect touch transformations + ([#7303](https://github.com/google/ExoPlayer/issues/7303)). + * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. +* Text: + * Use anti-aliasing and bitmap filtering when displaying bitmap + subtitles. + * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct + color. +* IMA extension: + * Upgrade to IMA SDK version 3.19.0, and migrate to new + preloading APIs + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes + several issues involving preloading and handling of ad loading error + cases: ([#4140](https://github.com/google/ExoPlayer/issues/4140), + [#5006](https://github.com/google/ExoPlayer/issues/5006), + [#6030](https://github.com/google/ExoPlayer/issues/6030), + [#6097](https://github.com/google/ExoPlayer/issues/6097), + [#6425](https://github.com/google/ExoPlayer/issues/6425), + [#6967](https://github.com/google/ExoPlayer/issues/6967), + [#7041](https://github.com/google/ExoPlayer/issues/7041), + [#7161](https://github.com/google/ExoPlayer/issues/7161), + [#7212](https://github.com/google/ExoPlayer/issues/7212), + [#7340](https://github.com/google/ExoPlayer/issues/7340)). + * Add support for timing out ad preloading, to avoid playback getting + stuck if an ad group unexpectedly fails to load + ([#5444](https://github.com/google/ExoPlayer/issues/5444), + [#5966](https://github.com/google/ExoPlayer/issues/5966), + [#7002](https://github.com/google/ExoPlayer/issues/7002)). + * Fix `AdsMediaSource` child `MediaSource`s not being released. +* Cronet extension: Default to using the Cronet implementation in Google Play + Services rather than Cronet Embedded. This allows Cronet to be used with a + negligible increase in application size, compared to approximately 8MB when + embedding the library. +* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* MediaSession extension: + * Only set the playback state to `BUFFERING` if `playWhenReady` is true + ([#7206](https://github.com/google/ExoPlayer/issues/7206)). + * Add missing `@Nullable` annotations to `MediaSessionConnector` + ([#7234](https://github.com/google/ExoPlayer/issues/7234)). +* AV1 extension: Add a heuristic to determine the default number of threads + used for AV1 playback using the extension. + ### 2.11.4 (2020-04-08) ### * Add `SimpleExoPlayer.setWakeMode` to allow automatic `WifiLock` and `WakeLock` diff --git a/constants.gradle b/constants.gradle index c79130cacb..1d7a0f0ebd 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,8 +13,8 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.4' - releaseVersionCode = 2011004 + releaseVersion = '2.11.5' + releaseVersionCode = 2011005 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved diff --git a/demos/README.md b/demos/README.md index 7e62249db1..2360e01137 100644 --- a/demos/README.md +++ b/demos/README.md @@ -2,3 +2,24 @@ This directory contains applications that demonstrate how to use ExoPlayer. Browse the individual demos and their READMEs to learn more. + +## Running a demo ## + +### From Android Studio ### + +* File -> New -> Import Project -> Specify the root ExoPlayer folder. +* Choose the demo from the run configuration dropdown list. +* Click Run. + +### Using gradle from the command line: ### + +* Open a Terminal window at the root ExoPlayer folder. +* Run `./gradlew projects` to show all projects. Demo projects start with `demo`. +* Run `./gradlew ::tasks` to view the list of available tasks for +the demo project. Choose an install option from the `Install tasks` section. +* Run `./gradlew ::`. + +**Example**: + +`./gradlew :demo:installNoExtensionsDebug` installs the main ExoPlayer demo app + in debug mode with no extensions. diff --git a/demos/cast/README.md b/demos/cast/README.md index 2c68a5277a..fd682433f9 100644 --- a/demos/cast/README.md +++ b/demos/cast/README.md @@ -2,3 +2,6 @@ This folder contains a demo application that showcases ExoPlayer integration with Google Cast. + +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. diff --git a/demos/gl/README.md b/demos/gl/README.md index 12dabe902b..9bffc3edea 100644 --- a/demos/gl/README.md +++ b/demos/gl/README.md @@ -8,4 +8,7 @@ drawn using an Android canvas, and includes the current frame's presentation timestamp, to show how to get the timestamp of the frame currently in the off-screen surface texture. +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. + [GLSurfaceView]: https://developer.android.com/reference/android/opengl/GLSurfaceView diff --git a/demos/main/README.md b/demos/main/README.md index bdb04e5ba8..00072c070b 100644 --- a/demos/main/README.md +++ b/demos/main/README.md @@ -3,3 +3,6 @@ This is the main ExoPlayer demo application. It uses ExoPlayer to play a number of test streams. It can be used as a starting point or reference project when developing other applications that make use of the ExoPlayer library. + +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. diff --git a/demos/main/src/main/assets/media.exolist.json b/demos/main/src/main/assets/media.exolist.json index 4375bdf3a7..ac5737d195 100644 --- a/demos/main/src/main/assets/media.exolist.json +++ b/demos/main/src/main/assets/media.exolist.json @@ -3,208 +3,147 @@ "name": "YouTube DASH", "samples": [ { - "name": "Google Glass (MP4,H264)", + "name": "Google Glass H264 (MP4)", "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0", "extension": "mpd" }, { - "name": "Google Play (MP4,H264)", + "name": "Google Play H264 (MP4)", "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=A2716F75795F5D2AF0E88962FFCD10DB79384F29.84308FF04844498CE6FBCE4731507882B8307798&key=ik0", "extension": "mpd" }, { - "name": "Google Glass (WebM,VP9)", + "name": "Google Glass VP9 (WebM)", "uri": "https://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=249B04F79E984D7F86B4D8DB48AE6FAF41C17AB3.7B9F0EC0505E1566E59B8E488E9419F253DDF413&key=ik0", "extension": "mpd" }, { - "name": "Google Play (WebM,VP9)", + "name": "Google Play VP9 (WebM)", "uri": "https://www.youtube.com/api/manifest/dash/id/3aa39fa2cc27967f/source/youtube?as=fmp4_audio_clear,webm2_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=B1C2A74783AC1CC4865EB312D7DD2D48230CC9FD.BD153B9882175F1F94BFE5141A5482313EA38E8D&key=ik0", "extension": "mpd" } ] }, { - "name": "Widevine DASH Policy Tests (GTS)", + "name": "Widevine GTS policy tests", "samples": [ { - "name": "WV: HDCP not specified", + "name": "SW secure crypto (L3)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=d286538032258a1c&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" }, { - "name": "WV: HDCP not required", + "name": "SW secure decode", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=48fcc369939ac96c&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_DECODE&provider=widevine_test" }, { - "name": "WV: HDCP required", + "name": "HW secure crypto", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=e06c39f1151da3df&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_CRYPTO&provider=widevine_test" }, { - "name": "WV: Secure video path required (MP4,H264)", + "name": "HW secure decode", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_DECODE&provider=widevine_test" }, { - "name": "WV: Secure video path required (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" - }, - { - "name": "WV: Secure video path required (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=0894c7c8719b28a0&provider=widevine_test" - }, - { - "name": "WV: HDCP + secure video path required", + "name": "HW secure all (L1)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=efd045b1eb61888a&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" }, { - "name": "WV: 30s license duration (fails at ~30s)", + "name": "30s license (fails at ~30s)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=f9a34cab7b05881a&provider=widevine_test" + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_CAN_RENEW_FALSE_LICENSE_30S_PLAYBACK_30S&provider=widevine_test" + }, + { + "name": "HDCP not required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NONE&provider=widevine_test" + }, + { + "name": "HDCP 1.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V1&provider=widevine_test" + }, + { + "name": "HDCP 2.0 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2&provider=widevine_test" + }, + { + "name": "HDCP 2.1 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_1&provider=widevine_test" + }, + { + "name": "HDCP 2.2 required", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_V2_2&provider=widevine_test" + }, + { + "name": "HDCP no digital output", + "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO_HDCP_NO_DIGITAL_OUTPUT&provider=widevine_test" } ] }, { - "name": "Widevine HDCP Capabilities Tests", + "name": "Widevine DASH H264 (MP4)", "samples": [ { - "name": "WV: HDCP: None (not required)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_None&provider=widevine_test" - }, - { - "name": "WV: HDCP: 1.0 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V1&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.0 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.1 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2_1&provider=widevine_test" - }, - { - "name": "WV: HDCP: 2.2 required", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_V2_2&provider=widevine_test" - }, - { - "name": "WV: HDCP: No digital output", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=HDCP_NO_DIGTAL_OUTPUT&provider=widevine_test" - } - ] - }, - { - "name": "Widevine DASH: MP4,H264", - "samples": [ - { - "name": "WV: Clear SD & HD (MP4,H264)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd" }, { - "name": "WV: Clear SD (MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (MP4,H264)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/h264/tears/tears_uhd.mpd" }, { - "name": "WV: Secure SD & HD (cenc,MP4,H264)", + "name": "Secure (cenc)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cenc,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cenc,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cenc,MP4,H264)", + "name": "Secure UHD (cenc)", "uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD & HD (cbc1,MP4,H264)", + "name": "Secure (cbc1)", "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cbc1,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cbc1,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cbc1,MP4,H264)", + "name": "Secure UHD (cbc1)", "uri": "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD & HD (cbcs,MP4,H264)", + "name": "Secure (cbcs)", "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (cbcs,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (cbcs,MP4,H264)", - "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (cbcs,MP4,H264)", + "name": "Secure UHD (cbcs)", "uri": "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" @@ -212,68 +151,36 @@ ] }, { - "name": "Widevine DASH: WebM,VP9", + "name": "Widevine DASH VP9 (WebM)", "samples": [ { - "name": "WV: Clear SD & HD (WebM,VP9)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears.mpd" }, { - "name": "WV: Clear SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (WebM,VP9)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/vp9/tears/tears_uhd.mpd" }, { - "name": "WV: Secure Fullsample SD & HD (WebM,VP9)", + "name": "Secure (full-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Fullsample SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Fullsample HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Fullsample UHD (WebM,VP9)", + "name": "Secure UHD (full-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Subsample SD & HD (WebM,VP9)", + "name": "Secure (sub-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure Subsample SD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Subsample HD (WebM,VP9)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure Subsample UHD (WebM,VP9)", + "name": "Secure UHD (sub-sample)", "uri": "https://storage.googleapis.com/wvmedia/cenc/vp9/subsample/24fps/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" @@ -281,50 +188,51 @@ ] }, { - "name": "Widevine DASH: MP4,H265", + "name": "Widevine DASH H265 (MP4)", "samples": [ { - "name": "WV: Clear SD & HD (MP4,H265)", + "name": "Clear", "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears.mpd" }, { - "name": "WV: Clear SD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_sd.mpd" - }, - { - "name": "WV: Clear HD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_hd.mpd" - }, - { - "name": "WV: Clear UHD (MP4,H265)", + "name": "Clear UHD", "uri": "https://storage.googleapis.com/wvmedia/clear/hevc/tears/tears_uhd.mpd" }, { - "name": "WV: Secure SD & HD (MP4,H265)", + "name": "Secure", "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { - "name": "WV: Secure SD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_sd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure HD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_hd.mpd", - "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" - }, - { - "name": "WV: Secure UHD (MP4,H265)", + "name": "Secure UHD", "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_uhd.mpd", "drm_scheme": "widevine", "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" } ] }, + { + "name": "Widevine AV1 (WebM)", + "samples": [ + { + "name": "Clear", + "uri": "https://storage.googleapis.com/wvmedia/2019/clear/av1/24/webm/llama_av1_480p_400.webm" + }, + { + "name": "Secure L3", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" + }, + { + "name": "Secure L1", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", + "drm_scheme": "widevine", + "drm_license_url": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" + } + ] + }, { "name": "SmoothStreaming", "samples": [ @@ -355,7 +263,7 @@ "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8" }, { - "name": "Apple master playlist advanced (fMP4)", + "name": "Apple master playlist advanced (FMP4)", "uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8" }, { diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 5a577449fa..0454472abf 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -628,7 +628,10 @@ public class PlayerActivity extends AppCompatActivity @Override public MediaSourceFactory setDrmSessionManager(DrmSessionManager drmSessionManager) { - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/demos/surface/README.md b/demos/surface/README.md index 312259dbf6..3febb23feb 100644 --- a/demos/surface/README.md +++ b/demos/surface/README.md @@ -18,4 +18,7 @@ called, and because you can move output off-screen easily (`setOutputSurface` can't take a `null` surface, so the player has to use a `DummySurface`, which doesn't handle protected output on all devices). +Please see the [demos README](../README.md) for instructions on how to build and +run this demo. + [SurfaceControl]: https://developer.android.com/reference/android/view/SurfaceControl diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java index 687ac47f2a..cdff8581f1 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.av1; +import static java.lang.Runtime.getRuntime; + import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -44,7 +46,9 @@ import java.nio.ByteBuffer; * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. * @param initialInputBufferSize The initial size of each input buffer, in bytes. - * @param threads Number of threads libgav1 will use to decode. + * @param threads Number of threads libgav1 will use to decode. If {@link + * Libgav1VideoRenderer#THREAD_COUNT_AUTODETECT} is passed, then this class will auto detect + * the number of threads to be used. * @throws Gav1DecoderException Thrown if an exception occurs when initializing the decoder. */ public Gav1Decoder( @@ -56,6 +60,16 @@ import java.nio.ByteBuffer; if (!Gav1Library.isAvailable()) { throw new Gav1DecoderException("Failed to load decoder native library."); } + + if (threads == Libgav1VideoRenderer.THREAD_COUNT_AUTODETECT) { + // Try to get the optimal number of threads from the AV1 heuristic. + threads = gav1GetThreads(); + if (threads <= 0) { + // If that is not available, default to the number of available processors. + threads = getRuntime().availableProcessors(); + } + } + gav1DecoderContext = gav1Init(threads); if (gav1DecoderContext == GAV1_ERROR || gav1CheckError(gav1DecoderContext) == GAV1_ERROR) { throw new Gav1DecoderException( @@ -231,4 +245,11 @@ import java.nio.ByteBuffer; * @return {@link #GAV1_OK} if there was no error, {@link #GAV1_ERROR} if an error occured. */ private native int gav1CheckError(long context); + + /** + * Returns the optimal number of threads to be used for AV1 decoding. + * + * @return Optimal number of threads if there was no error, 0 if an error occurred. + */ + private native int gav1GetThreads(); } diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java index 3d10c2579b..122a94b7b1 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer2.ext.av1; -import static java.lang.Runtime.getRuntime; - import android.os.Handler; import android.view.Surface; import androidx.annotation.Nullable; @@ -55,6 +53,13 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; */ public class Libgav1VideoRenderer extends SimpleDecoderVideoRenderer { + /** + * Attempts to use as many threads as performance processors available on the device. If the + * number of performance processors cannot be detected, the number of available processors is + * used. + */ + public static final int THREAD_COUNT_AUTODETECT = 0; + private static final int DEFAULT_NUM_OF_INPUT_BUFFERS = 4; private static final int DEFAULT_NUM_OF_OUTPUT_BUFFERS = 4; /* Default size based on 720p resolution video compressed by a factor of two. */ @@ -94,7 +99,7 @@ public class Libgav1VideoRenderer extends SimpleDecoderVideoRenderer { eventHandler, eventListener, maxDroppedFramesToNotify, - /* threads= */ getRuntime().availableProcessors(), + THREAD_COUNT_AUTODETECT, DEFAULT_NUM_OF_INPUT_BUFFERS, DEFAULT_NUM_OF_OUTPUT_BUFFERS); } @@ -109,7 +114,9 @@ public class Libgav1VideoRenderer extends SimpleDecoderVideoRenderer { * @param eventListener A listener of events. May be null if delivery of events is not required. * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. - * @param threads Number of threads libgav1 will use to decode. + * @param threads Number of threads libgav1 will use to decode. If + * {@link #THREAD_COUNT_AUTODETECT} is passed, then the number of threads to use is + * auto-detected based on CPU capabilities. * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. */ diff --git a/extensions/av1/src/main/jni/CMakeLists.txt b/extensions/av1/src/main/jni/CMakeLists.txt index c7989d4ef2..075773a70e 100644 --- a/extensions/av1/src/main/jni/CMakeLists.txt +++ b/extensions/av1/src/main/jni/CMakeLists.txt @@ -44,7 +44,9 @@ add_subdirectory("${libgav1_root}" # Build libgav1JNI. add_library(gav1JNI SHARED - gav1_jni.cc) + gav1_jni.cc + cpu_info.cc + cpu_info.h) # Locate NDK log library. find_library(android_log_lib log) diff --git a/extensions/av1/src/main/jni/cpu_info.cc b/extensions/av1/src/main/jni/cpu_info.cc new file mode 100644 index 0000000000..8f4a405f4f --- /dev/null +++ b/extensions/av1/src/main/jni/cpu_info.cc @@ -0,0 +1,153 @@ +#include "cpu_info.h" // NOLINT + +#include + +#include +#include +#include +#include +#include + +namespace gav1_jni { +namespace { + +// Note: The code in this file needs to use the 'long' type because it is the +// return type of the Standard C Library function strtol(). The linter warnings +// are suppressed with NOLINT comments since they are integers at runtime. + +// Returns the number of online processor cores. +int GetNumberOfProcessorsOnline() { + // See https://developer.android.com/ndk/guides/cpu-features. + long num_cpus = sysconf(_SC_NPROCESSORS_ONLN); // NOLINT + if (num_cpus < 0) { + return 0; + } + // It is safe to cast num_cpus to int. sysconf(_SC_NPROCESSORS_ONLN) returns + // the return value of get_nprocs(), which is an int. + return static_cast(num_cpus); +} + +} // namespace + +// These CPUs support heterogeneous multiprocessing. +#if defined(__arm__) || defined(__aarch64__) + +// A helper function used by GetNumberOfPerformanceCoresOnline(). +// +// Returns the cpuinfo_max_freq value (in kHz) of the given CPU. Returns 0 on +// failure. +long GetCpuinfoMaxFreq(int cpu_index) { // NOLINT + char buffer[128]; + const int rv = snprintf( + buffer, sizeof(buffer), + "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", cpu_index); + if (rv < 0 || rv >= sizeof(buffer)) { + return 0; + } + FILE* file = fopen(buffer, "r"); + if (file == nullptr) { + return 0; + } + char* const str = fgets(buffer, sizeof(buffer), file); + fclose(file); + if (str == nullptr) { + return 0; + } + const long freq = strtol(str, nullptr, 10); // NOLINT + if (freq <= 0 || freq == LONG_MAX) { + return 0; + } + return freq; +} + +// Returns the number of performance CPU cores that are online. The number of +// efficiency CPU cores is subtracted from the total number of CPU cores. Uses +// cpuinfo_max_freq to determine whether a CPU is a performance core or an +// efficiency core. +// +// This function is not perfect. For example, the Snapdragon 632 SoC used in +// Motorola Moto G7 has performance and efficiency cores with the same +// cpuinfo_max_freq but different cpuinfo_min_freq. This function fails to +// differentiate the two kinds of cores and reports all the cores as +// performance cores. +int GetNumberOfPerformanceCoresOnline() { + // Get the online CPU list. Some examples of the online CPU list are: + // "0-7" + // "0" + // "0-1,2,3,4-7" + FILE* file = fopen("/sys/devices/system/cpu/online", "r"); + if (file == nullptr) { + return 0; + } + char online[512]; + char* const str = fgets(online, sizeof(online), file); + fclose(file); + file = nullptr; + if (str == nullptr) { + return 0; + } + + // Count the number of the slowest CPUs. Some SoCs such as Snapdragon 855 + // have performance cores with different max frequencies, so only the slowest + // CPUs are efficiency cores. If we count the number of the fastest CPUs, we + // will fail to count the second fastest performance cores. + long slowest_cpu_freq = LONG_MAX; // NOLINT + int num_slowest_cpus = 0; + int num_cpus = 0; + const char* cp = online; + int range_begin = -1; + while (true) { + char* str_end; + const int cpu = static_cast(strtol(cp, &str_end, 10)); // NOLINT + if (str_end == cp) { + break; + } + cp = str_end; + if (*cp == '-') { + range_begin = cpu; + } else { + if (range_begin == -1) { + range_begin = cpu; + } + + num_cpus += cpu - range_begin + 1; + for (int i = range_begin; i <= cpu; ++i) { + const long freq = GetCpuinfoMaxFreq(i); // NOLINT + if (freq <= 0) { + return 0; + } + if (freq < slowest_cpu_freq) { + slowest_cpu_freq = freq; + num_slowest_cpus = 0; + } + if (freq == slowest_cpu_freq) { + ++num_slowest_cpus; + } + } + + range_begin = -1; + } + if (*cp == '\0') { + break; + } + ++cp; + } + + // If there are faster CPU cores than the slowest CPU cores, exclude the + // slowest CPU cores. + if (num_slowest_cpus < num_cpus) { + num_cpus -= num_slowest_cpus; + } + return num_cpus; +} + +#else + +// Assume symmetric multiprocessing. +int GetNumberOfPerformanceCoresOnline() { + return GetNumberOfProcessorsOnline(); +} + +#endif + +} // namespace gav1_jni diff --git a/extensions/av1/src/main/jni/cpu_info.h b/extensions/av1/src/main/jni/cpu_info.h new file mode 100644 index 0000000000..77f869a93e --- /dev/null +++ b/extensions/av1/src/main/jni/cpu_info.h @@ -0,0 +1,13 @@ +#ifndef EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ +#define EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ + +namespace gav1_jni { + +// Returns the number of performance cores that are available for AV1 decoding. +// This is a heuristic that works on most common android devices. Returns 0 on +// error or if the number of performance cores cannot be determined. +int GetNumberOfPerformanceCoresOnline(); + +} // namespace gav1_jni + +#endif // EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index e0cef86d22..714ab499b1 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -32,6 +32,7 @@ #include // NOLINT #include +#include "cpu_info.h" // NOLINT #include "gav1/decoder.h" #define LOG_TAG "gav1_jni" @@ -774,5 +775,9 @@ DECODER_FUNC(jint, gav1CheckError, jlong jContext) { return kStatusOk; } +DECODER_FUNC(jint, gav1GetThreads) { + return gav1_jni::GetNumberOfPerformanceCoresOnline(); +} + // TODO(b/139902005): Add functions for getting libgav1 version and build // configuration once libgav1 ABI provides this information. diff --git a/extensions/cronet/README.md b/extensions/cronet/README.md index dc64b862b6..112ad26bba 100644 --- a/extensions/cronet/README.md +++ b/extensions/cronet/README.md @@ -20,6 +20,10 @@ Alternatively, you can clone the ExoPlayer repository and depend on the module locally. Instructions for doing this can be found in ExoPlayer's [top level README][]. +Note that by default, the extension will use the Cronet implementation in +Google Play Services. If you prefer, it's also possible to embed the Cronet +implementation directly into your application. See below for more details. + [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md ## Using the extension ## @@ -47,6 +51,46 @@ new DefaultDataSourceFactory( ``` respectively. +## Choosing between Google Play Services Cronet and Cronet Embedded ## + +The underlying Cronet implementation is available both via a [Google Play +Services](https://developers.google.com/android/guides/overview) API, and as a +library that can be embedded directly into your application. When you depend on +`com.google.android.exoplayer:extension-cronet:2.X.X`, the library will _not_ be +embedded into your application by default. The extension will attempt to use the +Cronet implementation in Google Play Services. The benefits of this approach +are: + +* A negligible increase in the size of your application. +* The Cronet implementation is updated automatically by Google Play Services. + +If Google Play Services is not available on a device, `CronetDataSourceFactory` +will fall back to creating `DefaultHttpDataSource` instances, or +`HttpDataSource` instances created by a `fallbackFactory` that you can specify. + +It's also possible to embed the Cronet implementation directly into your +application. To do this, add an additional gradle dependency to the Cronet +Embedded library: + +```gradle +implementation 'com.google.android.exoplayer:extension-cronet:2.X.X' +implementation 'org.chromium.net:cronet-embedded:XX.XXXX.XXX' +``` + +where `XX.XXXX.XXX` is the version of the library that you wish to use. The +extension will automatically detect and use the library. Embedding will add +approximately 8MB to your application, however it may be suitable if: + +* Your application is likely to be used in markets where Google Play Services is + not widely available. +* You want to control the exact version of the Cronet implementation being used. + +If you do embed the library, you can specify which implementation should +be preferred if the Google Play Services implementation is also available. This +is controlled by a `preferGMSCoreCronet` parameter, which can be passed to the +`CronetEngineWrapper` constructor (GMS Core is another name for Google Play +Services). + ## Links ## * [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.cronet.*` diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index d5b7a99f96..1c80a21ecc 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -31,7 +31,7 @@ android { } dependencies { - api 'org.chromium.net:cronet-embedded:76.3809.111' + api "com.google.android.gms:play-services-cronet:17.0.0" implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java index 1903e33995..a70de17939 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -32,6 +32,7 @@ import com.google.android.exoplayer2.util.ConditionVariable; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Predicate; import java.io.IOException; +import java.io.InterruptedIOException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.ByteBuffer; @@ -83,14 +84,6 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } - /** Thrown on catching an InterruptedException. */ - public static final class InterruptedIOException extends IOException { - - public InterruptedIOException(InterruptedException e) { - super(e); - } - } - static { ExoPlayerLibraryInfo.registerModule("goog.exo.cronet"); } @@ -440,7 +433,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new OpenException(new InterruptedIOException(e), dataSpec, Status.INVALID); + throw new OpenException(new InterruptedIOException(), dataSpec, Status.INVALID); } // Check for a valid response code. @@ -705,7 +698,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) { throw new IOException("HTTP request with non-empty body must set Content-Type"); } - + // Set the Range header. if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) { StringBuilder rangeValue = new StringBuilder(); @@ -769,7 +762,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } Thread.currentThread().interrupt(); throw new HttpDataSourceException( - new InterruptedIOException(e), + new InterruptedIOException(), castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ); } catch (SocketTimeoutException e) { diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java index 47f6fa7d2f..093a09499d 100644 --- a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java +++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java @@ -40,6 +40,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.io.InterruptedIOException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.ByteBuffer; @@ -47,6 +48,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; @@ -56,7 +58,6 @@ import org.chromium.net.CronetEngine; import org.chromium.net.NetworkException; import org.chromium.net.UrlRequest; import org.chromium.net.UrlResponseInfo; -import org.chromium.net.impl.UrlResponseInfoImpl; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -139,15 +140,62 @@ public final class CronetDataSourceTest { private UrlResponseInfo createUrlResponseInfoWithUrl(String url, int statusCode) { ArrayList> responseHeaderList = new ArrayList<>(); - responseHeaderList.addAll(testResponseHeader.entrySet()); - return new UrlResponseInfoImpl( - Collections.singletonList(url), - statusCode, - null, // httpStatusText - responseHeaderList, - false, // wasCached - null, // negotiatedProtocol - null); // proxyServer + Map> responseHeaderMap = new HashMap<>(); + for (Map.Entry entry : testResponseHeader.entrySet()) { + responseHeaderList.add(entry); + responseHeaderMap.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + return new UrlResponseInfo() { + @Override + public String getUrl() { + return url; + } + + @Override + public List getUrlChain() { + return Collections.singletonList(url); + } + + @Override + public int getHttpStatusCode() { + return statusCode; + } + + @Override + public String getHttpStatusText() { + return null; + } + + @Override + public List> getAllHeadersAsList() { + return responseHeaderList; + } + + @Override + public Map> getAllHeaders() { + return responseHeaderMap; + } + + @Override + public boolean wasCached() { + return false; + } + + @Override + public String getNegotiatedProtocol() { + return null; + } + + @Override + public String getProxyServer() { + return null; + } + + @Override + public long getReceivedByteCount() { + return 0; + } + }; } @Test @@ -282,7 +330,7 @@ public final class CronetDataSourceTest { fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { // Check for connection not automatically closed. - assertThat(e.getCause() instanceof UnknownHostException).isFalse(); + assertThat(e).hasCauseThat().isNotInstanceOf(UnknownHostException.class); verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); @@ -320,7 +368,7 @@ public final class CronetDataSourceTest { fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { // Check for connection not automatically closed. - assertThat(e.getCause() instanceof UnknownHostException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(UnknownHostException.class); verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); @@ -336,7 +384,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.open(testDataSpec); fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { - assertThat(e instanceof HttpDataSource.InvalidResponseCodeException).isTrue(); + assertThat(e).isInstanceOf(HttpDataSource.InvalidResponseCodeException.class); // Check for connection not automatically closed. verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) @@ -359,7 +407,7 @@ public final class CronetDataSourceTest { dataSourceUnderTest.open(testDataSpec); fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { - assertThat(e instanceof HttpDataSource.InvalidContentTypeException).isTrue(); + assertThat(e).isInstanceOf(HttpDataSource.InvalidContentTypeException.class); // Check for connection not automatically closed. verify(mockUrlRequest, never()).cancel(); assertThat(testedContentTypes).hasSize(1); @@ -890,8 +938,8 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) .isEqualTo(TEST_CONNECTION_STATUS); timedOutLatch.countDown(); @@ -928,8 +976,8 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) .isEqualTo(TEST_INVALID_CONNECTION_STATUS); timedOutLatch.countDown(); @@ -999,8 +1047,8 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); openExceptions.getAndIncrement(); timedOutLatch.countDown(); } @@ -1224,7 +1272,7 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); timedOutLatch.countDown(); } } @@ -1255,7 +1303,7 @@ public final class CronetDataSourceTest { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); timedOutLatch.countDown(); } } diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index e2292aed8f..b83caf62ee 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -32,11 +32,12 @@ android { } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.11.3' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.19.0' implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' testImplementation project(modulePrefix + 'testutils') + testImplementation 'com.google.guava:guava:' + guavaVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 98dbef7c6c..19109d9c04 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -15,8 +15,11 @@ */ package com.google.android.exoplayer2.ext.ima; +import static com.google.android.exoplayer2.util.Util.castNonNull; + import android.content.Context; import android.net.Uri; +import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import android.view.View; @@ -24,7 +27,6 @@ import android.view.ViewGroup; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import com.google.ads.interactivemedia.v3.api.Ad; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; import com.google.ads.interactivemedia.v3.api.AdError; import com.google.ads.interactivemedia.v3.api.AdError.AdErrorCode; @@ -39,10 +41,10 @@ import com.google.ads.interactivemedia.v3.api.AdsManager; import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.UiElement; +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; @@ -52,7 +54,6 @@ import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; -import com.google.android.exoplayer2.source.ads.AdPlaybackState.AdState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; @@ -67,8 +68,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -101,11 +102,23 @@ public final class ImaAdsLoader /** Builder for {@link ImaAdsLoader}. */ public static final class Builder { + /** + * The default duration in milliseconds for which the player must buffer while preloading an ad + * group before that ad group is skipped and marked as having failed to load. + * + *

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

The purpose of this timeout is to avoid playback getting stuck in the unexpected case that + * the IMA SDK does not load an ad break based on the player's reported content position. + * + * @param adPreloadTimeoutMs The timeout buffering duration in milliseconds, or {@link + * C#TIME_UNSET} for no timeout. + * @return This builder, for convenience. + */ + public Builder setAdPreloadTimeoutMs(long adPreloadTimeoutMs) { + Assertions.checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0); + this.adPreloadTimeoutMs = adPreloadTimeoutMs; + return this; + } + /** * Sets the VAST load timeout, in milliseconds. * @@ -236,7 +269,8 @@ public final class ImaAdsLoader context, adTagUri, imaSdkSettings, - null, + /* adsResponse= */ null, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -256,9 +290,10 @@ public final class ImaAdsLoader public ImaAdsLoader buildForAdsResponse(String adsResponse) { return new ImaAdsLoader( context, - null, + /* adTagUri= */ null, imaSdkSettings, adsResponse, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, @@ -272,14 +307,17 @@ public final class ImaAdsLoader private static final boolean DEBUG = false; private static final String TAG = "ImaAdsLoader"; - /** - * Whether to enable preloading of ads in {@link AdsRenderingSettings}. - */ - private static final boolean ENABLE_PRELOADING = true; - private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima"; private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION; + /** + * Interval at which ad progress updates are provided to the IMA SDK, in milliseconds. 100 ms is + * the interval recommended by the IMA documentation. + * + * @see com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer.VideoAdPlayerCallback + */ + private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100; + /** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */ private static final long IMA_DURATION_UNSET = -1L; @@ -287,10 +325,12 @@ public final class ImaAdsLoader * Threshold before the end of content at which IMA is notified that content is complete if the * player buffers, in milliseconds. */ - private static final long END_OF_CONTENT_POSITION_THRESHOLD_MS = 5000; - - /** The maximum duration before an ad break that IMA may start preloading the next ad. */ - private static final long MAXIMUM_PRELOAD_DURATION_MS = 8000; + private static final long THRESHOLD_END_OF_CONTENT_MS = 5000; + /** + * Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in + * milliseconds. + */ + private static final long THRESHOLD_AD_PRELOAD_MS = 4000; private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -305,16 +345,18 @@ public final class ImaAdsLoader */ private static final int IMA_AD_STATE_NONE = 0; /** - * The ad playback state when IMA has called {@link #playAd()} and not {@link #pauseAd()}. + * The ad playback state when IMA has called {@link #playAd(AdMediaInfo)} and not {@link + * #pauseAd(AdMediaInfo)}. */ private static final int IMA_AD_STATE_PLAYING = 1; /** - * The ad playback state when IMA has called {@link #pauseAd()} while playing an ad. + * The ad playback state when IMA has called {@link #pauseAd(AdMediaInfo)} while playing an ad. */ private static final int IMA_AD_STATE_PAUSED = 2; @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; @@ -323,13 +365,16 @@ public final class ImaAdsLoader @Nullable private final AdEventListener adEventListener; private final ImaFactory imaFactory; private final Timeline.Period period; + private final Handler handler; private final List adCallbacks; private final AdDisplayContainer adDisplayContainer; private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + private final Runnable updateAdProgressRunnable; + private final Map adInfoByAdMediaInfo; private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; - private Object pendingAdRequestContext; + @Nullable private Object pendingAdRequestContext; private List supportedMimeTypes; @Nullable private EventListener eventListener; @Nullable private Player player; @@ -337,24 +382,24 @@ public final class ImaAdsLoader private VideoProgressUpdate lastAdProgress; private int lastVolumePercentage; - private AdsManager adsManager; + @Nullable private AdsManager adsManager; private boolean initializedAdsManager; - private AdLoadException pendingAdLoadError; + private boolean hasAdPlaybackState; + @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; private long contentDurationMs; - private int podIndexOffset; private AdPlaybackState adPlaybackState; // Fields tracking IMA's state. - /** The expected ad group index that IMA should load next. */ - private int expectedAdGroupIndex; - /** The index of the current ad group that IMA is loading. */ - private int adGroupIndex; /** Whether IMA has sent an ad event to pause content since the last resume content event. */ private boolean imaPausedContent; /** The current ad playback state. */ private @ImaAdState int imaAdState; + /** The current ad media info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdMediaInfo imaAdMediaInfo; + /** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdInfo imaAdInfo; /** * Whether {@link com.google.ads.interactivemedia.v3.api.AdsLoader#contentComplete()} has been * called since starting ad playback. @@ -365,20 +410,23 @@ public final class ImaAdsLoader /** Whether the player is playing an ad. */ private boolean playingAd; + /** Whether the player is buffering an ad. */ + private boolean bufferingAd; /** * If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET} * otherwise. */ private int playingAdIndexInAdGroup; /** - * Whether there's a pending ad preparation error which IMA needs to be notified of when it - * transitions from playing content to playing the ad. + * The ad info for a pending ad for which the media failed preparation, or {@code null} if no + * pending ads have failed to prepare. */ - private boolean shouldNotifyAdPrepareError; + @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd()}, stores the value - * of {@link SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to - * determine a fake, increasing content position. {@link C#TIME_UNSET} otherwise. + * If a content period has finished but IMA has not yet called {@link #playAd(AdMediaInfo)}, + * stores the value of {@link SystemClock#elapsedRealtime()} when the content stopped playing. + * This can be used to determine a fake, increasing content position. {@link C#TIME_UNSET} + * otherwise. */ private long fakeContentProgressElapsedRealtimeMs; /** @@ -390,6 +438,11 @@ public final class ImaAdsLoader private long pendingContentPositionMs; /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */ private boolean sentPendingContentPositionMs; + /** + * Stores the real time in milliseconds at which the player started buffering, possibly due to not + * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable. + */ + private long waitingForPreloadElapsedRealtimeMs; /** * Creates a new IMA ads loader. @@ -407,6 +460,7 @@ public final class ImaAdsLoader adTagUri, /* imaSdkSettings= */ null, /* adsResponse= */ null, + /* adPreloadTimeoutMs= */ Builder.DEFAULT_AD_PRELOAD_TIMEOUT_MS, /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, @@ -416,38 +470,13 @@ public final class ImaAdsLoader /* imaFactory= */ new DefaultImaFactory()); } - /** - * Creates a new IMA ads loader. - * - * @param context The context. - * @param adTagUri The {@link Uri} of an ad tag compatible with the Android IMA SDK. See - * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for - * more information. - * @param imaSdkSettings {@link ImaSdkSettings} used to configure the IMA SDK, or {@code null} to - * use the default settings. If set, the player type and version fields may be overwritten. - * @deprecated Use {@link ImaAdsLoader.Builder}. - */ - @Deprecated - public ImaAdsLoader(Context context, Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings) { - this( - context, - adTagUri, - imaSdkSettings, - /* adsResponse= */ null, - /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaBitrate= */ BITRATE_UNSET, - /* focusSkipButtonWhenAvailable= */ true, - /* adUiElements= */ null, - /* adEventListener= */ null, - /* imaFactory= */ new DefaultImaFactory()); - } - + @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader( Context context, @Nullable Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings, @Nullable String adsResponse, + long adPreloadTimeoutMs, int vastLoadTimeoutMs, int mediaLoadTimeoutMs, int mediaBitrate, @@ -458,6 +487,7 @@ public final class ImaAdsLoader Assertions.checkArgument(adTagUri != null || adsResponse != null); this.adTagUri = adTagUri; this.adsResponse = adsResponse; + this.adPreloadTimeoutMs = adPreloadTimeoutMs; this.vastLoadTimeoutMs = vastLoadTimeoutMs; this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; @@ -474,18 +504,27 @@ public final class ImaAdsLoader imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); period = new Timeline.Period(); + handler = Util.createHandler(getImaLooper(), /* callback= */ null); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); adDisplayContainer = imaFactory.createAdDisplayContainer(); adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); - adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); + adsLoader = + imaFactory.createAdsLoader( + context.getApplicationContext(), imaSdkSettings, adDisplayContainer); adsLoader.addAdErrorListener(/* adErrorListener= */ this); adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + updateAdProgressRunnable = this::updateAdProgress; + adInfoByAdMediaInfo = new HashMap<>(); + supportedMimeTypes = Collections.emptyList(); + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; fakeContentProgressOffsetMs = C.TIME_UNSET; pendingContentPositionMs = C.TIME_UNSET; - adGroupIndex = C.INDEX_UNSET; + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; contentDurationMs = C.TIME_UNSET; timeline = Timeline.EMPTY; + adPlaybackState = AdPlaybackState.NONE; } /** @@ -510,19 +549,6 @@ public final class ImaAdsLoader return adDisplayContainer; } - /** - * Sets the slots for displaying companion ads. Individual slots can be created using {@link - * ImaSdkFactory#createCompanionAdSlot()}. - * - * @param companionSlots Slots for displaying companion ads. - * @see AdDisplayContainer#setCompanionSlots(Collection) - * @deprecated Use {@code getAdDisplayContainer().setCompanionSlots(...)}. - */ - @Deprecated - public void setCompanionSlots(Collection companionSlots) { - adDisplayContainer.setCompanionSlots(companionSlots); - } - /** * Requests ads, if they have not already been requested. Must be called on the main thread. * @@ -533,22 +559,22 @@ public final class ImaAdsLoader * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI. */ public void requestAds(ViewGroup adViewGroup) { - if (adPlaybackState != null || adsManager != null || pendingAdRequestContext != null) { + if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) { // Ads have already been requested. return; } adDisplayContainer.setAdContainer(adViewGroup); - pendingAdRequestContext = new Object(); AdsRequest request = imaFactory.createAdsRequest(); if (adTagUri != null) { request.setAdTagUrl(adTagUri.toString()); - } else /* adsResponse != null */ { - request.setAdsResponse(adsResponse); + } else { + request.setAdsResponse(castNonNull(adsResponse)); } if (vastLoadTimeoutMs != TIMEOUT_UNSET) { request.setVastLoadTimeout(vastLoadTimeoutMs); } request.setContentProgressProvider(this); + pendingAdRequestContext = new Object(); request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); } @@ -557,9 +583,8 @@ public final class ImaAdsLoader @Override public void setPlayer(@Nullable Player player) { - Assertions.checkState(Looper.getMainLooper() == Looper.myLooper()); - Assertions.checkState( - player == null || player.getApplicationLooper() == Looper.getMainLooper()); + Assertions.checkState(Looper.myLooper() == getImaLooper()); + Assertions.checkState(player == null || player.getApplicationLooper() == getImaLooper()); nextPlayer = player; wasSetPlayerCalled = true; } @@ -568,6 +593,7 @@ public final class ImaAdsLoader public void setSupportedContentTypes(@C.ContentType int... contentTypes) { List supportedMimeTypes = new ArrayList<>(); for (@C.ContentType int contentType : contentTypes) { + // IMA does not support Smooth Streaming ad media. if (contentType == C.TYPE_DASH) { supportedMimeTypes.add(MimeTypes.APPLICATION_MPD); } else if (contentType == C.TYPE_HLS) { @@ -580,8 +606,6 @@ public final class ImaAdsLoader MimeTypes.VIDEO_H263, MimeTypes.AUDIO_MP4, MimeTypes.AUDIO_MPEG)); - } else if (contentType == C.TYPE_SS) { - // IMA does not support Smooth Streaming ad media. } } this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes); @@ -595,22 +619,23 @@ public final class ImaAdsLoader if (player == null) { return; } + player.addListener(this); + boolean playWhenReady = player.getPlayWhenReady(); this.eventListener = eventListener; lastVolumePercentage = 0; - lastAdProgress = null; - lastContentProgress = null; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; ViewGroup adViewGroup = adViewProvider.getAdViewGroup(); adDisplayContainer.setAdContainer(adViewGroup); View[] adOverlayViews = adViewProvider.getAdOverlayViews(); for (View view : adOverlayViews) { adDisplayContainer.registerVideoControlsOverlay(view); } - player.addListener(this); maybeNotifyPendingAdLoadError(); - if (adPlaybackState != null) { + if (hasAdPlaybackState) { // Pass the ad playback state to the player, and resume ads if necessary. eventListener.onAdPlaybackState(adPlaybackState); - if (imaPausedContent && player.getPlayWhenReady()) { + if (adsManager != null && imaPausedContent && playWhenReady) { adsManager.resume(); } } else if (adsManager != null) { @@ -624,21 +649,22 @@ public final class ImaAdsLoader @Override public void stop() { + @Nullable Player player = this.player; if (player == null) { return; } if (adsManager != null && imaPausedContent) { + adsManager.pause(); adPlaybackState = adPlaybackState.withAdResumePositionUs( playingAd ? C.msToUs(player.getCurrentPosition()) : 0); - adsManager.pause(); } lastVolumePercentage = getVolume(); - lastAdProgress = getAdProgress(); + lastAdProgress = getAdVideoProgressUpdate(); lastContentProgress = getContentProgress(); adDisplayContainer.unregisterAllVideoControlsOverlays(); player.removeListener(this); - player = null; + this.player = null; eventListener = null; } @@ -658,8 +684,12 @@ public final class ImaAdsLoader adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; + imaAdMediaInfo = null; + stopUpdatingAdProgress(); + imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = false; updateAdPlaybackState(); } @@ -695,6 +725,7 @@ public final class ImaAdsLoader // If a player is attached already, start playback immediately. try { adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); + hasAdPlaybackState = true; updateAdPlaybackState(); } catch (Exception e) { maybeNotifyInternalError("onAdsManagerLoaded", e); @@ -707,11 +738,11 @@ public final class ImaAdsLoader @Override public void onAdEvent(AdEvent adEvent) { AdEventType adEventType = adEvent.getType(); - if (DEBUG) { + if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { Log.d(TAG, "onAdEvent: " + adEventType); } if (adsManager == null) { - Log.w(TAG, "Ignoring AdEvent after release: " + adEvent); + // Drop events after release. return; } try { @@ -732,7 +763,8 @@ public final class ImaAdsLoader if (adsManager == null) { // No ads were loaded, so allow playback to start without any ads. pendingAdRequestContext = null; - adPlaybackState = new AdPlaybackState(); + adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = true; updateAdPlaybackState(); } else if (isAdGroupLoadError(error)) { try { @@ -751,67 +783,41 @@ public final class ImaAdsLoader @Override public VideoProgressUpdate getContentProgress() { - if (player == null) { - return lastContentProgress; + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (DEBUG) { + Log.d(TAG, "Content progress: " + videoProgressUpdate); } - boolean hasContentDuration = contentDurationMs != C.TIME_UNSET; - long contentPositionMs; - if (pendingContentPositionMs != C.TIME_UNSET) { - sentPendingContentPositionMs = true; - contentPositionMs = pendingContentPositionMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs)); - } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { - long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; - contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs)); - } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { - contentPositionMs = player.getCurrentPosition(); - // Update the expected ad group index for the current content position. The update is delayed - // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered - // just after an ad group isn't incorrectly attributed to the next ad group. - int nextAdGroupIndex = - adPlaybackState.getAdGroupIndexAfterPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (nextAdGroupIndex != expectedAdGroupIndex && nextAdGroupIndex != C.INDEX_UNSET) { - long nextAdGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[nextAdGroupIndex]); - if (nextAdGroupTimeMs == C.TIME_END_OF_SOURCE) { - nextAdGroupTimeMs = contentDurationMs; - } - if (nextAdGroupTimeMs - contentPositionMs < MAXIMUM_PRELOAD_DURATION_MS) { - expectedAdGroupIndex = nextAdGroupIndex; - } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); } - } else { - return VideoProgressUpdate.VIDEO_TIME_NOT_READY; } - long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET; - return new VideoProgressUpdate(contentPositionMs, contentDurationMs); + + return videoProgressUpdate; } // VideoAdPlayer implementation. @Override public VideoProgressUpdate getAdProgress() { - if (player == null) { - return lastAdProgress; - } else if (imaAdState != IMA_AD_STATE_NONE && playingAd) { - long adDuration = player.getDuration(); - return adDuration == C.TIME_UNSET ? VideoProgressUpdate.VIDEO_TIME_NOT_READY - : new VideoProgressUpdate(player.getCurrentPosition(), adDuration); - } else { - return VideoProgressUpdate.VIDEO_TIME_NOT_READY; - } + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); } @Override public int getVolume() { + @Nullable Player player = this.player; if (player == null) { return lastVolumePercentage; } - Player.AudioComponent audioComponent = player.getAudioComponent(); + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); if (audioComponent != null) { return (int) (audioComponent.getVolume() * 100); } @@ -827,30 +833,42 @@ public final class ImaAdsLoader } @Override - public void loadAd(String adUriString) { + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { try { if (DEBUG) { - Log.d(TAG, "loadAd in ad group " + adGroupIndex); + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); } if (adsManager == null) { - Log.w(TAG, "Ignoring loadAd after release"); + // Drop events after release. return; } - if (adGroupIndex == C.INDEX_UNSET) { - Log.w( - TAG, - "Unexpected loadAd without LOADED event; assuming ad group index is actually " - + expectedAdGroupIndex); - adGroupIndex = expectedAdGroupIndex; - adsManager.start(); - } - int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex); - if (adIndexInAdGroup == C.INDEX_UNSET) { - Log.w(TAG, "Unexpected loadAd in an ad group with no remaining unavailable ads"); + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA will + // timeout after its media load timeout. return; } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + } + for (int i = 0; i < adIndexInAdGroup; i++) { + // Any preceding ads that haven't loaded are not going to load. + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + adPlaybackState = + adPlaybackState.withAdLoadError( + /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); + } + } + Uri adUri = Uri.parse(adMediaInfo.getUrl()); adPlaybackState = - adPlaybackState.withAdUri(adGroupIndex, adIndexInAdGroup, Uri.parse(adUriString)); + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); updateAdPlaybackState(); } catch (Exception e) { maybeNotifyInternalError("loadAd", e); @@ -868,69 +886,62 @@ public final class ImaAdsLoader } @Override - public void playAd() { + public void playAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "playAd"); + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { - Log.w(TAG, "Ignoring playAd after release"); + // Drop events after release. return; } - switch (imaAdState) { - case IMA_AD_STATE_PLAYING: - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028, b/63320878]. - Log.w(TAG, "Unexpected playAd without stopAd"); - break; - case IMA_AD_STATE_NONE: - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(); - } - if (shouldNotifyAdPrepareError) { - shouldNotifyAdPrepareError = false; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); - } - } - break; - case IMA_AD_STATE_PAUSED: - imaAdState = IMA_AD_STATE_PLAYING; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(); - } - break; - default: - throw new IllegalStateException(); + + if (imaAdState == IMA_AD_STATE_PLAYING) { + // IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + Log.w(TAG, "Unexpected playAd without stopAd"); } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected playAd while detached"); - } else if (!player.getPlayWhenReady()) { - adsManager.pause(); + + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = Assertions.checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (!Assertions.checkNotNull(player).getPlayWhenReady()) { + Assertions.checkNotNull(adsManager).pause(); } } @Override - public void stopAd() { + public void stopAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "stopAd"); + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); } if (adsManager == null) { - Log.w(TAG, "Ignoring stopAd after release"); - return; - } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected stopAd while detached"); - } - if (imaAdState == IMA_AD_STATE_NONE) { - Log.w(TAG, "Unexpected stopAd"); + // Drop event after release. return; } + + Assertions.checkNotNull(player); + Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); try { stopAdInternal(); } catch (Exception e) { @@ -939,26 +950,21 @@ public final class ImaAdsLoader } @Override - public void pauseAd() { + public void pauseAd(AdMediaInfo adMediaInfo) { if (DEBUG) { - Log.d(TAG, "pauseAd"); + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); } if (imaAdState == IMA_AD_STATE_NONE) { // This method is called after content is resumed. return; } + Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); imaAdState = IMA_AD_STATE_PAUSED; for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(); + adCallbacks.get(i).onPause(adMediaInfo); } } - @Override - public void resumeAd() { - // This method is never called. See [Internal: b/18931719]. - maybeNotifyInternalError("resumeAd", new IllegalStateException("Unexpected call to resumeAd")); - } - // Player.EventListener implementation. @Override @@ -969,24 +975,53 @@ public final class ImaAdsLoader } Assertions.checkArgument(timeline.getPeriodCount() == 1); this.timeline = timeline; - long contentDurationUs = timeline.getPeriod(0, period).durationUs; + long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs; contentDurationMs = C.usToMs(contentDurationUs); if (contentDurationUs != C.TIME_UNSET) { adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); } if (!initializedAdsManager && adsManager != null) { initializedAdsManager = true; - initializeAdsManager(); + initializeAdsManager(adsManager); } - onPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL); + handleTimelineOrPositionChanged(); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + handleTimelineOrPositionChanged(); } @Override public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { - if (adsManager == null) { + @Nullable Player player = this.player; + if (adsManager == null || player == null) { return; } + if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) { + // Check whether we are waiting for an ad to preload. + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count != C.LENGTH_UNSET + && adGroup.count != 0 + && adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) { + // An ad is available already so we must be buffering for some other reason. + return; + } + long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + long timeUntilAdMs = adGroupTimeMs - contentPositionMs; + if (timeUntilAdMs < adPreloadTimeoutMs) { + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + } else if (playbackState == Player.STATE_READY) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + } + if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { adsManager.pause(); return; @@ -997,63 +1032,24 @@ public final class ImaAdsLoader return; } - if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING - && playWhenReady) { - checkForContentComplete(); - } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); - } - if (DEBUG) { - Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlayerStateChanged"); - } - } + handlePlayerStateChanged(playWhenReady, playbackState); } @Override public void onPlayerError(ExoPlaybackException error) { if (imaAdState != IMA_AD_STATE_NONE) { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); + adCallbacks.get(i).onError(adMediaInfo); } } } - @Override - public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { - if (adsManager == null) { - return; - } - if (!playingAd && !player.isPlayingAd()) { - checkForContentComplete(); - if (sentContentComplete) { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } - } - updateAdPlaybackState(); - } else if (!timeline.isEmpty()) { - long positionMs = player.getCurrentPosition(); - timeline.getPeriod(0, period); - int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); - if (newAdGroupIndex != C.INDEX_UNSET) { - sentPendingContentPositionMs = false; - pendingContentPositionMs = positionMs; - if (newAdGroupIndex != adGroupIndex) { - shouldNotifyAdPrepareError = false; - } - } - } - } - updateImaStateForPlayerState(); - } - // Internal methods. - private void initializeAdsManager() { + private void initializeAdsManager(AdsManager adsManager) { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); - adsRenderingSettings.setEnablePreloading(ENABLE_PRELOADING); + adsRenderingSettings.setEnablePreloading(true); adsRenderingSettings.setMimeTypes(supportedMimeTypes); if (mediaLoadTimeoutMs != TIMEOUT_UNSET) { adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs); @@ -1068,9 +1064,11 @@ public final class ImaAdsLoader // Skip ads based on the start position as required. long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints()); - long contentPositionMs = player.getContentPosition(); + long contentPositionMs = + getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); int adGroupIndexForPosition = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs)); + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) { // Skip any ad groups before the one at or immediately before the playback position. for (int i = 0; i < adGroupIndexForPosition; i++) { @@ -1084,25 +1082,13 @@ public final class ImaAdsLoader adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); } - // IMA indexes any remaining midroll ad pods from 1. A preroll (if present) has index 0. - // Store an index offset as we want to index all ads (including skipped ones) from 0. - if (adGroupIndexForPosition == 0 && adGroupTimesUs[0] == 0) { - // We are playing a preroll. - podIndexOffset = 0; - } else if (adGroupIndexForPosition == C.INDEX_UNSET) { - // There's no ad to play which means there's no preroll. - podIndexOffset = -1; - } else { - // We are playing a midroll and any ads before it were skipped. - podIndexOffset = adGroupIndexForPosition - 1; - } - if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) { // Provide the player's initial position to trigger loading and playing the ad. pendingContentPositionMs = contentPositionMs; } adsManager.init(adsRenderingSettings); + adsManager.start(); updateAdPlaybackState(); if (DEBUG) { Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); @@ -1110,39 +1096,34 @@ public final class ImaAdsLoader } private void handleAdEvent(AdEvent adEvent) { - Ad ad = adEvent.getAd(); switch (adEvent.getType()) { - case LOADED: - // The ad position is not always accurate when using preloading. See [Internal: b/62613240]. - AdPodInfo adPodInfo = ad.getAdPodInfo(); - int podIndex = adPodInfo.getPodIndex(); - adGroupIndex = - podIndex == -1 ? (adPlaybackState.adGroupCount - 1) : (podIndex + podIndexOffset); - int adPosition = adPodInfo.getAdPosition(); - int adCount = adPodInfo.getTotalAds(); - adsManager.start(); + case AD_BREAK_FETCH_ERROR: + String adGroupTimeSecondsString = + Assertions.checkNotNull(adEvent.getAdData().get("adBreakTime")); if (DEBUG) { - Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex); + Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); } - int oldAdCount = adPlaybackState.adGroups[adGroupIndex].count; - if (adCount != oldAdCount) { - if (oldAdCount == C.LENGTH_UNSET) { - adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, adCount); - updateAdPlaybackState(); - } else { - // IMA sometimes unexpectedly decreases the ad count in an ad group. - Log.w(TAG, "Unexpected ad count in LOADED, " + adCount + ", expected " + oldAdCount); + int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString); + int adGroupIndex = + adGroupTimeSeconds == -1 + ? adPlaybackState.adGroupCount - 1 + : Util.linearSearch( + adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = + adPlaybackState.withAdCount(adGroupIndex, Math.max(1, adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adGroupIndex]; + } + for (int i = 0; i < adGroup.count; i++) { + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + if (DEBUG) { + Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); } } - if (adGroupIndex != expectedAdGroupIndex) { - Log.w( - TAG, - "Expected ad group index " - + expectedAdGroupIndex - + ", actual ad group index " - + adGroupIndex); - expectedAdGroupIndex = adGroupIndex; - } + updateAdPlaybackState(); break; case CONTENT_PAUSE_REQUESTED: // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads @@ -1168,18 +1149,118 @@ public final class ImaAdsLoader Map adData = adEvent.getAdData(); String message = "AdEvent: " + adData; Log.i(TAG, message); - if ("adLoadError".equals(adData.get("type"))) { - handleAdGroupLoadError(new IOException(message)); - } break; - case STARTED: - case ALL_ADS_COMPLETED: default: break; } } - private void updateImaStateForPlayerState() { + private VideoProgressUpdate getContentVideoProgressUpdate() { + if (player == null) { + return lastContentProgress; + } + boolean hasContentDuration = contentDurationMs != C.TIME_UNSET; + long contentPositionMs; + if (pendingContentPositionMs != C.TIME_UNSET) { + sentPendingContentPositionMs = true; + contentPositionMs = pendingContentPositionMs; + } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { + long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; + contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; + } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { + contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + } else { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET; + return new VideoProgressUpdate(contentPositionMs, contentDurationMs); + } + + private VideoProgressUpdate getAdVideoProgressUpdate() { + if (player == null) { + return lastAdProgress; + } else if (imaAdState != IMA_AD_STATE_NONE && playingAd) { + long adDuration = player.getDuration(); + return adDuration == C.TIME_UNSET + ? VideoProgressUpdate.VIDEO_TIME_NOT_READY + : new VideoProgressUpdate(player.getCurrentPosition(), adDuration); + } else { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } + } + + private void updateAdProgress() { + VideoProgressUpdate videoProgressUpdate = getAdVideoProgressUpdate(); + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate); + } + handler.removeCallbacks(updateAdProgressRunnable); + handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS); + } + + private void stopUpdatingAdProgress() { + handler.removeCallbacks(updateAdProgressRunnable); + } + + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { + if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onBuffering(adMediaInfo); + } + stopUpdatingAdProgress(); + } else if (bufferingAd && playbackState == Player.STATE_READY) { + bufferingAd = false; + updateAdProgress(); + } + } + + if (imaAdState == IMA_AD_STATE_NONE + && playbackState == Player.STATE_BUFFERING + && playWhenReady) { + checkForContentComplete(); + } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } + } + if (DEBUG) { + Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlayerStateChanged"); + } + } + } + + private void handleTimelineOrPositionChanged() { + @Nullable Player player = this.player; + if (adsManager == null || player == null) { + return; + } + if (!playingAd && !player.isPlayingAd()) { + checkForContentComplete(); + if (sentContentComplete) { + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); + } + } + updateAdPlaybackState(); + } else if (!timeline.isEmpty()) { + long positionMs = getContentPeriodPositionMs(player, timeline, period); + timeline.getPeriod(/* periodIndex= */ 0, period); + int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); + if (newAdGroupIndex != C.INDEX_UNSET) { + sentPendingContentPositionMs = false; + pendingContentPositionMs = positionMs; + } + } + } + boolean wasPlayingAd = playingAd; int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup; playingAd = player.isPlayingAd(); @@ -1188,8 +1269,13 @@ public final class ImaAdsLoader if (adFinished) { // IMA is waiting for the ad playback to finish so invoke the callback now. // Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again. - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo; + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } } if (DEBUG) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); @@ -1207,15 +1293,8 @@ public final class ImaAdsLoader } private void resumeContentInternal() { - if (imaAdState != IMA_AD_STATE_NONE) { - imaAdState = IMA_AD_STATE_NONE; - if (DEBUG) { - Log.d(TAG, "Unexpected CONTENT_RESUME_REQUESTED without stopAd"); - } - } - if (adGroupIndex != C.INDEX_UNSET) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex); - adGroupIndex = C.INDEX_UNSET; + if (imaAdInfo != null) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); updateAdPlaybackState(); } } @@ -1230,23 +1309,36 @@ public final class ImaAdsLoader private void stopAdInternal() { imaAdState = IMA_AD_STATE_NONE; - int adIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); + stopUpdatingAdProgress(); // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. + Assertions.checkNotNull(imaAdInfo); + int adGroupIndex = imaAdInfo.adGroupIndex; + int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup; + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. + return; + } adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); updateAdPlaybackState(); if (!playingAd) { - adGroupIndex = C.INDEX_UNSET; + imaAdMediaInfo = null; + imaAdInfo = null; } } private void handleAdGroupLoadError(Exception error) { - int adGroupIndex = - this.adGroupIndex == C.INDEX_UNSET ? expectedAdGroupIndex : this.adGroupIndex; - if (adGroupIndex == C.INDEX_UNSET) { - // Drop the error, as we don't know which ad group it relates to. + if (player == null) { return; } + + // TODO: Once IMA signals which ad group failed to load, 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; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { adPlaybackState = @@ -1286,19 +1378,20 @@ public final class ImaAdsLoader if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { fakeContentProgressOffsetMs = contentDurationMs; } - shouldNotifyAdPrepareError = true; + pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); } else { + AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); // We're already playing an ad. if (adIndexInAdGroup > playingAdIndexInAdGroup) { // Mark the playing ad as ended so we can notify the error on the next ad and remove it, // which means that the ad after will load (if any). for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + adCallbacks.get(i).onEnded(adMediaInfo); } } playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); + adCallbacks.get(i).onError(Assertions.checkNotNull(adMediaInfo)); } } adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); @@ -1306,18 +1399,16 @@ public final class ImaAdsLoader } private void checkForContentComplete() { - if (contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && player.getContentPosition() + END_OF_CONTENT_POSITION_THRESHOLD_MS >= contentDurationMs - && !sentContentComplete) { + long positionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); + if (!sentContentComplete + && contentDurationMs != C.TIME_UNSET + && pendingContentPositionMs == C.TIME_UNSET + && positionMs + THRESHOLD_END_OF_CONTENT_MS >= contentDurationMs) { adsLoader.contentComplete(); if (DEBUG) { Log.d(TAG, "adsLoader.contentComplete"); } sentContentComplete = true; - // After sending content complete IMA will not poll the content position, so set the expected - // ad group index. - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentDurationMs)); } } @@ -1328,24 +1419,9 @@ public final class ImaAdsLoader } } - /** - * Returns the next ad index in the specified ad group to load, or {@link C#INDEX_UNSET} if all - * ads in the ad group have loaded. - */ - private int getAdIndexInAdGroupToLoad(int adGroupIndex) { - @AdState int[] states = adPlaybackState.adGroups[adGroupIndex].states; - int adIndexInAdGroup = 0; - // IMA loads ads in order. - while (adIndexInAdGroup < states.length - && states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE) { - adIndexInAdGroup++; - } - return adIndexInAdGroup == states.length ? C.INDEX_UNSET : adIndexInAdGroup; - } - private void maybeNotifyPendingAdLoadError() { if (pendingAdLoadError != null && eventListener != null) { - eventListener.onAdLoadError(pendingAdLoadError, new DataSpec(adTagUri)); + eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri)); pendingAdLoadError = null; } } @@ -1354,21 +1430,68 @@ public final class ImaAdsLoader String message = "Internal error in " + name; Log.e(TAG, message, cause); // We can't recover from an unexpected error in general, so skip all remaining ads. - if (adPlaybackState == null) { - adPlaybackState = AdPlaybackState.NONE; - } else { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } updateAdPlaybackState(); if (eventListener != null) { eventListener.onAdLoadError( AdLoadException.createForUnexpected(new RuntimeException(message, cause)), - new DataSpec(adTagUri)); + getAdsDataSpec(adTagUri)); } } + private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { + if (adPodInfo.getPodIndex() == -1) { + // This is a postroll ad. + return adPlaybackState.adGroupCount - 1; + } + + // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. + long adGroupTimeUs = (long) (((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + if (adPlaybackState.adGroupTimesUs[adGroupIndex] == adGroupTimeUs) { + return adGroupIndex; + } + } + throw new IllegalStateException("Failed to find cue point"); + } + + /** + * Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is + * no such ad group. + */ + private int getLoadingAdGroupIndex() { + long playerPositionUs = + C.msToUs(getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period)); + int adGroupIndex = + adPlaybackState.getAdGroupIndexForPositionUs(playerPositionUs, C.msToUs(contentDurationMs)); + if (adGroupIndex == C.INDEX_UNSET) { + adGroupIndex = + adPlaybackState.getAdGroupIndexAfterPositionUs( + playerPositionUs, C.msToUs(contentDurationMs)); + } + return adGroupIndex; + } + + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; + } + + private static DataSpec getAdsDataSpec(@Nullable Uri adTagUri) { + return new DataSpec(adTagUri != null ? adTagUri : Uri.EMPTY); + } + + private static long getContentPeriodPositionMs( + Player player, Timeline timeline, Timeline.Period period) { + long contentWindowPositionMs = player.getContentPosition(); + return contentWindowPositionMs + - (timeline.isEmpty() + ? 0 + : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); + } + private static long[] getAdGroupTimesUs(List cuePoints) { if (cuePoints.isEmpty()) { // If no cue points are specified, there is a preroll ad. @@ -1398,6 +1521,12 @@ public final class ImaAdsLoader || adError.getErrorCode() == AdErrorCode.UNKNOWN_ERROR; } + private static Looper getImaLooper() { + // IMA SDK callbacks occur on the main thread. This method can be used to check that the player + // is using the same looper, to ensure all interaction with this class is on the main thread. + return Looper.getMainLooper(); + } + private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { int count = adGroupTimesUs.length; if (count == 1) { @@ -1426,6 +1555,44 @@ public final class ImaAdsLoader Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } + // TODO: Consider moving this into AdPlaybackState. + private static final class AdInfo { + public final int adGroupIndex; + public final int adIndexInAdGroup; + + public AdInfo(int adGroupIndex, int adIndexInAdGroup) { + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdInfo adInfo = (AdInfo) o; + if (adGroupIndex != adInfo.adGroupIndex) { + return false; + } + return adIndexInAdGroup == adInfo.adIndexInAdGroup; + } + + @Override + public int hashCode() { + int result = adGroupIndex; + result = 31 * result + adIndexInAdGroup; + return result; + } + + @Override + public String toString() { + return "(" + adGroupIndex + ", " + adIndexInAdGroup + ')'; + } + } + /** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */ private static final class DefaultImaFactory implements ImaFactory { @Override diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java deleted file mode 100644 index 59dfc6473c..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.Ad; -import com.google.ads.interactivemedia.v3.api.AdPodInfo; -import com.google.ads.interactivemedia.v3.api.CompanionAd; -import com.google.ads.interactivemedia.v3.api.UiElement; -import java.util.List; -import java.util.Set; - -/** A fake ad for testing. */ -/* package */ final class FakeAd implements Ad { - - private final boolean skippable; - private final AdPodInfo adPodInfo; - - public FakeAd(boolean skippable, int podIndex, int totalAds, int adPosition) { - this.skippable = skippable; - adPodInfo = - new AdPodInfo() { - @Override - public int getTotalAds() { - return totalAds; - } - - @Override - public int getAdPosition() { - return adPosition; - } - - @Override - public int getPodIndex() { - return podIndex; - } - - @Override - public boolean isBumper() { - throw new UnsupportedOperationException(); - } - - @Override - public double getMaxDuration() { - throw new UnsupportedOperationException(); - } - - @Override - public double getTimeOffset() { - throw new UnsupportedOperationException(); - } - }; - } - - @Override - public int getVastMediaWidth() { - throw new UnsupportedOperationException(); - } - - @Override - public int getVastMediaHeight() { - throw new UnsupportedOperationException(); - } - - @Override - public int getVastMediaBitrate() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isSkippable() { - return skippable; - } - - @Override - public AdPodInfo getAdPodInfo() { - return adPodInfo; - } - - @Override - public String getAdId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getCreativeId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getCreativeAdId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getUniversalAdIdValue() { - throw new UnsupportedOperationException(); - } - - @Override - public String getUniversalAdIdRegistry() { - throw new UnsupportedOperationException(); - } - - @Override - public String getAdSystem() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperIds() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperSystems() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperCreativeIds() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isLinear() { - throw new UnsupportedOperationException(); - } - - @Override - public double getSkipTimeOffset() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isUiDisabled() { - throw new UnsupportedOperationException(); - } - - @Override - public String getDescription() { - throw new UnsupportedOperationException(); - } - - @Override - public String getTitle() { - throw new UnsupportedOperationException(); - } - - @Override - public String getContentType() { - throw new UnsupportedOperationException(); - } - - @Override - public String getAdvertiserName() { - throw new UnsupportedOperationException(); - } - - @Override - public String getSurveyUrl() { - throw new UnsupportedOperationException(); - } - - @Override - public String getDealId() { - throw new UnsupportedOperationException(); - } - - @Override - public int getWidth() { - throw new UnsupportedOperationException(); - } - - @Override - public int getHeight() { - throw new UnsupportedOperationException(); - } - - @Override - public String getTraffickingParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public double getDuration() { - throw new UnsupportedOperationException(); - } - - @Override - public Set getUiElements() { - throw new UnsupportedOperationException(); - } - - @Override - public List getCompanionAds() { - throw new UnsupportedOperationException(); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java deleted file mode 100644 index a8f3daae33..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; -import com.google.ads.interactivemedia.v3.api.AdsManager; -import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; -import com.google.ads.interactivemedia.v3.api.StreamManager; -import com.google.ads.interactivemedia.v3.api.StreamRequest; -import com.google.android.exoplayer2.util.Assertions; -import java.util.ArrayList; - -/** Fake {@link com.google.ads.interactivemedia.v3.api.AdsLoader} implementation for tests. */ -public final class FakeAdsLoader implements com.google.ads.interactivemedia.v3.api.AdsLoader { - - private final ImaSdkSettings imaSdkSettings; - private final AdsManager adsManager; - private final ArrayList adsLoadedListeners; - private final ArrayList adErrorListeners; - - public FakeAdsLoader(ImaSdkSettings imaSdkSettings, AdsManager adsManager) { - this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings); - this.adsManager = Assertions.checkNotNull(adsManager); - adsLoadedListeners = new ArrayList<>(); - adErrorListeners = new ArrayList<>(); - } - - @Override - public void contentComplete() { - // Do nothing. - } - - @Override - public ImaSdkSettings getSettings() { - return imaSdkSettings; - } - - @Override - public void requestAds(AdsRequest adsRequest) { - for (AdsLoadedListener listener : adsLoadedListeners) { - listener.onAdsManagerLoaded( - new AdsManagerLoadedEvent() { - @Override - public AdsManager getAdsManager() { - return adsManager; - } - - @Override - public StreamManager getStreamManager() { - throw new UnsupportedOperationException(); - } - - @Override - public Object getUserRequestContext() { - return adsRequest.getUserRequestContext(); - } - }); - } - } - - @Override - public String requestStream(StreamRequest streamRequest) { - throw new UnsupportedOperationException(); - } - - @Override - public void addAdsLoadedListener(AdsLoadedListener adsLoadedListener) { - adsLoadedListeners.add(adsLoadedListener); - } - - @Override - public void removeAdsLoadedListener(AdsLoadedListener adsLoadedListener) { - adsLoadedListeners.remove(adsLoadedListener); - } - - @Override - public void addAdErrorListener(AdErrorListener adErrorListener) { - adErrorListeners.add(adErrorListener); - } - - @Override - public void removeAdErrorListener(AdErrorListener adErrorListener) { - adErrorListeners.remove(adErrorListener); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java deleted file mode 100644 index 7c2c8a6e0b..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; -import java.util.List; -import java.util.Map; - -/** Fake {@link AdsRequest} implementation for tests. */ -public final class FakeAdsRequest implements AdsRequest { - - private String adTagUrl; - private String adsResponse; - private Object userRequestContext; - private AdDisplayContainer adDisplayContainer; - private ContentProgressProvider contentProgressProvider; - - @Override - public void setAdTagUrl(String adTagUrl) { - this.adTagUrl = adTagUrl; - } - - @Override - public String getAdTagUrl() { - return adTagUrl; - } - - @Override - public void setExtraParameter(String s, String s1) { - throw new UnsupportedOperationException(); - } - - @Override - public String getExtraParameter(String s) { - throw new UnsupportedOperationException(); - } - - @Override - public Map getExtraParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public void setUserRequestContext(Object userRequestContext) { - this.userRequestContext = userRequestContext; - } - - @Override - public Object getUserRequestContext() { - return userRequestContext; - } - - @Override - public AdDisplayContainer getAdDisplayContainer() { - return adDisplayContainer; - } - - @Override - public void setAdDisplayContainer(AdDisplayContainer adDisplayContainer) { - this.adDisplayContainer = adDisplayContainer; - } - - @Override - public ContentProgressProvider getContentProgressProvider() { - return contentProgressProvider; - } - - @Override - public void setContentProgressProvider(ContentProgressProvider contentProgressProvider) { - this.contentProgressProvider = contentProgressProvider; - } - - @Override - public String getAdsResponse() { - return adsResponse; - } - - @Override - public void setAdsResponse(String adsResponse) { - this.adsResponse = adsResponse; - } - - @Override - public void setAdWillAutoPlay(boolean b) { - throw new UnsupportedOperationException(); - } - - @Override - public void setAdWillPlayMuted(boolean b) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentDuration(float v) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentKeywords(List list) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentTitle(String s) { - throw new UnsupportedOperationException(); - } - - @Override - public void setVastLoadTimeout(float v) { - throw new UnsupportedOperationException(); - } - - @Override - public void setLiveStreamPrefetchSeconds(float v) { - throw new UnsupportedOperationException(); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index edaa4cde29..804434b835 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -16,7 +16,10 @@ package com.google.android.exoplayer2.ext.ima; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,51 +35,79 @@ import com.google.ads.interactivemedia.v3.api.Ad; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; import com.google.ads.interactivemedia.v3.api.AdEvent; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.Timeline.Period; +import com.google.android.exoplayer2.ext.ima.ImaAdsLoader.ImaFactory; +import com.google.android.exoplayer2.source.MaskingMediaSource.DummyTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; +import com.google.android.exoplayer2.testutil.FakeTimeline; +import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.ImmutableMap; import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.stubbing.Answer; +import org.robolectric.shadows.ShadowSystemClock; -/** Test for {@link ImaAdsLoader}. */ +/** Tests for {@link ImaAdsLoader}. */ @RunWith(AndroidJUnit4.class) -public class ImaAdsLoaderTest { +public final class ImaAdsLoaderTest { private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND; private static final Timeline CONTENT_TIMELINE = - new SinglePeriodTimeline( - CONTENT_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false); + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, CONTENT_DURATION_US)); + private static final long CONTENT_PERIOD_DURATION_US = + CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; private static final Uri TEST_URI = Uri.EMPTY; + private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; + private static final long[][] ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; - private static final FakeAd UNSKIPPABLE_AD = - new FakeAd(/* skippable= */ false, /* podIndex= */ 0, /* totalAds= */ 1, /* adPosition= */ 1); - private @Mock ImaSdkSettings imaSdkSettings; - private @Mock AdsRenderingSettings adsRenderingSettings; - private @Mock AdDisplayContainer adDisplayContainer; - private @Mock AdsManager adsManager; - private SingletonImaFactory testImaFactory; + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock private ImaSdkSettings mockImaSdkSettings; + @Mock private AdsRenderingSettings mockAdsRenderingSettings; + @Mock private AdDisplayContainer mockAdDisplayContainer; + @Mock private AdsManager mockAdsManager; + @Mock private AdsRequest mockAdsRequest; + @Mock private AdsManagerLoadedEvent mockAdsManagerLoadedEvent; + @Mock private com.google.ads.interactivemedia.v3.api.AdsLoader mockAdsLoader; + @Mock private ImaFactory mockImaFactory; + @Mock private AdPodInfo mockAdPodInfo; + @Mock private Ad mockPrerollSingleAd; + @Mock private AdEvent mockPostrollFetchErrorAdEvent; + private ViewGroup adViewGroup; private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; @@ -86,16 +117,7 @@ public class ImaAdsLoaderTest { @Before public void setUp() { - MockitoAnnotations.initMocks(this); - FakeAdsRequest fakeAdsRequest = new FakeAdsRequest(); - FakeAdsLoader fakeAdsLoader = new FakeAdsLoader(imaSdkSettings, adsManager); - testImaFactory = - new SingletonImaFactory( - imaSdkSettings, - adsRenderingSettings, - adDisplayContainer, - fakeAdsRequest, - fakeAdsLoader); + setupMocks(); adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext()); adOverlayView = new View(ApplicationProvider.getApplicationContext()); adViewProvider = @@ -120,44 +142,54 @@ public class ImaAdsLoaderTest { } @Test - public void testBuilder_overridesPlayerType() { - when(imaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + public void builder_overridesPlayerType() { + when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); - verify(imaSdkSettings).setPlayerType("google/exo.ext.ima"); + verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test - public void testStart_setsAdUiViewGroup() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + public void start_setsAdUiViewGroup() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); - verify(adDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); - verify(adDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView); + verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); + verify(mockAdDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView); } @Test - public void testStart_updatesAdPlaybackState() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + public void start_withPlaceholderContent_initializedAdsLoader() { + Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null); + setupPlayback(placeholderTimeline, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + // We'll only create the rendering settings when initializing the ads loader. + verify(mockImaFactory).createAdsRenderingSettings(); + } + + @Test + public void start_updatesAdPlaybackState() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs= */ 0) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) - .withContentDurationUs(CONTENT_DURATION_US)); + new AdPlaybackState(/* adGroupTimesUs...= */ 0) + .withAdDurationsUs(ADS_DURATIONS_US) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test - public void testStartAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + public void startAfterRelease() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test - public void testStartAndCallbacksAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + public void startAndCallbacksAfterRelease() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -168,13 +200,13 @@ public class ImaAdsLoaderTest { // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD)); - imaAdsLoader.loadAd(TEST_URI.toString()); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD)); - imaAdsLoader.playAd(); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD)); - imaAdsLoader.pauseAd(); - imaAdsLoader.stopAd(); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO); + imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); @@ -183,69 +215,188 @@ public class ImaAdsLoaderTest { } @Test - public void testPlayback_withPrerollAd_marksAdAsPlayed() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + public void playback_withPrerollAd_marksAdAsPlayed() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD)); - imaAdsLoader.loadAd(TEST_URI.toString()); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. - imaAdsLoader.playAd(); + imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); fakeExoPlayer.setPlayingAdPosition( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* position= */ 0, /* contentPosition= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, UNSKIPPABLE_AD)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); + imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); // Play the content. fakeExoPlayer.setPlayingContentPosition(0); - imaAdsLoader.stopAd(); + imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); // Verify that the preroll ad has been marked as played. assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( - new AdPlaybackState(/* adGroupTimesUs= */ 0) - .withContentDurationUs(CONTENT_DURATION_US) + new AdPlaybackState(/* adGroupTimesUs...= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(ADS_DURATIONS_US) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } @Test - public void testStop_unregistersAllVideoControlOverlays() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + public void playback_withPostrollFetchError_marksAdAsInErrorState() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, new Float[] {-1f}); + + // Simulate loading an empty postroll ad. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + imaAdsLoader.onAdEvent(mockPostrollFetchErrorAdEvent); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + + @Test + public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance before the timeout and simulating polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US)); + } + + @Test + public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + setupPlayback( + CONTENT_TIMELINE, + ADS_DURATIONS_US, + new Float[] {(float) adGroupPositionInWindowUs / C.MICROS_PER_SECOND}); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + fakeExoPlayer.setPlayingContentPosition(C.usToMs(adGroupPositionInWindowUs)); + fakeExoPlayer.setState(Player.STATE_BUFFERING, /* playWhenReady= */ true); + // Advance past the timeout and simulate polling content progress. + ShadowSystemClock.advanceBy(Duration.ofSeconds(5)); + imaAdsLoader.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ adGroupPositionInWindowUs) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(ADS_DURATIONS_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + + @Test + public void stop_unregistersAllVideoControlOverlays() { + setupPlayback(CONTENT_TIMELINE, ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); - InOrder inOrder = inOrder(adDisplayContainer); - inOrder.verify(adDisplayContainer).registerVideoControlsOverlay(adOverlayView); - inOrder.verify(adDisplayContainer).unregisterAllVideoControlsOverlays(); + InOrder inOrder = inOrder(mockAdDisplayContainer); + inOrder.verify(mockAdDisplayContainer).registerVideoControlsOverlay(adOverlayView); + inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); } private void setupPlayback(Timeline contentTimeline, long[][] adDurationsUs, Float[] cuePoints) { fakeExoPlayer = new FakePlayer(); adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline, adDurationsUs); - when(adsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); + when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); imaAdsLoader = new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) - .setImaFactory(testImaFactory) - .setImaSdkSettings(imaSdkSettings) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) .buildForAdTag(TEST_URI); imaAdsLoader.setPlayer(fakeExoPlayer); } + private void setupMocks() { + ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class); + doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture()); + when(mockAdsRequest.getUserRequestContext()) + .thenAnswer((Answer) invocation -> userRequestContextCaptor.getValue()); + List adsLoadedListeners = + new ArrayList<>(); + doAnswer( + invocation -> { + adsLoadedListeners.add(invocation.getArgument(0)); + return null; + }) + .when(mockAdsLoader) + .addAdsLoadedListener(any()); + doAnswer( + invocation -> { + adsLoadedListeners.remove(invocation.getArgument(0)); + return null; + }) + .when(mockAdsLoader) + .removeAdsLoadedListener(any()); + when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); + when(mockAdsManagerLoadedEvent.getUserRequestContext()) + .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); + doAnswer( + (Answer) + invocation -> { + for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener : + adsLoadedListeners) { + listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent); + } + return null; + }) + .when(mockAdsLoader) + .requestAds(mockAdsRequest); + + when(mockImaFactory.createAdDisplayContainer()).thenReturn(mockAdDisplayContainer); + when(mockImaFactory.createAdsRenderingSettings()).thenReturn(mockAdsRenderingSettings); + when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest); + when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(mockAdsLoader); + + when(mockAdPodInfo.getPodIndex()).thenReturn(0); + when(mockAdPodInfo.getTotalAds()).thenReturn(1); + when(mockAdPodInfo.getAdPosition()).thenReturn(1); + + when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockAdPodInfo); + + when(mockPostrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockPostrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "-1")); + } + private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { return new AdEvent() { @Override @@ -286,7 +437,8 @@ public class ImaAdsLoaderTest { public void onAdPlaybackState(AdPlaybackState adPlaybackState) { adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); this.adPlaybackState = adPlaybackState; - fakeExoPlayer.updateTimeline(new SinglePeriodAdTimeline(contentTimeline, adPlaybackState)); + fakeExoPlayer.updateTimeline( + new SinglePeriodAdTimeline(contentTimeline, adPlaybackState)); } @Override diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java deleted file mode 100644 index 4efd8cf38c..0000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import android.content.Context; -import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; -import com.google.ads.interactivemedia.v3.api.AdsLoader; -import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; - -/** {@link ImaAdsLoader.ImaFactory} that returns provided instances from each getter, for tests. */ -final class SingletonImaFactory implements ImaAdsLoader.ImaFactory { - - private final ImaSdkSettings imaSdkSettings; - private final AdsRenderingSettings adsRenderingSettings; - private final AdDisplayContainer adDisplayContainer; - private final AdsRequest adsRequest; - private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; - - public SingletonImaFactory( - ImaSdkSettings imaSdkSettings, - AdsRenderingSettings adsRenderingSettings, - AdDisplayContainer adDisplayContainer, - AdsRequest adsRequest, - com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) { - this.imaSdkSettings = imaSdkSettings; - this.adsRenderingSettings = adsRenderingSettings; - this.adDisplayContainer = adDisplayContainer; - this.adsRequest = adsRequest; - this.adsLoader = adsLoader; - } - - @Override - public ImaSdkSettings createImaSdkSettings() { - return imaSdkSettings; - } - - @Override - public AdsRenderingSettings createAdsRenderingSettings() { - return adsRenderingSettings; - } - - @Override - public AdDisplayContainer createAdDisplayContainer() { - return adDisplayContainer; - } - - @Override - public AdsRequest createAdsRequest() { - return adsRequest; - } - - @Override - public AdsLoader createAdsLoader( - Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { - return adsLoader; - } -} diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index 41a2071827..646351aefe 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -218,25 +218,25 @@ public final class MediaSessionConnector { * * @param mediaId The media id of the media item to be prepared. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromMediaId(String mediaId, boolean playWhenReady, Bundle extras); + void onPrepareFromMediaId(String mediaId, boolean playWhenReady, @Nullable Bundle extras); /** * See {@link MediaSessionCompat.Callback#onPrepareFromSearch(String, Bundle)}. * * @param query The search query. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromSearch(String query, boolean playWhenReady, Bundle extras); + void onPrepareFromSearch(String query, boolean playWhenReady, @Nullable Bundle extras); /** * See {@link MediaSessionCompat.Callback#onPrepareFromUri(Uri, Bundle)}. * * @param uri The {@link Uri} of the media item to be prepared. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromUri(Uri uri, boolean playWhenReady, Bundle extras); + void onPrepareFromUri(Uri uri, boolean playWhenReady, @Nullable Bundle extras); } /** @@ -336,7 +336,7 @@ public final class MediaSessionConnector { void onSetRating(Player player, RatingCompat rating); /** See {@link MediaSessionCompat.Callback#onSetRating(RatingCompat, Bundle)}. */ - void onSetRating(Player player, RatingCompat rating, Bundle extras); + void onSetRating(Player player, RatingCompat rating, @Nullable Bundle extras); } /** Handles requests for enabling or disabling captions. */ @@ -381,7 +381,7 @@ public final class MediaSessionConnector { * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching * changes to the player. * @param action The name of the action which was sent by a media controller. - * @param extras Optional extras sent by a media controller. + * @param extras Optional extras sent by a media controller, may be null. */ void onCustomAction( Player player, ControlDispatcher controlDispatcher, String action, @Nullable Bundle extras); @@ -987,7 +987,9 @@ public final class MediaSessionConnector { @Player.State int exoPlayerPlaybackState, boolean playWhenReady) { switch (exoPlayerPlaybackState) { case Player.STATE_BUFFERING: - return PlaybackStateCompat.STATE_BUFFERING; + return playWhenReady + ? PlaybackStateCompat.STATE_BUFFERING + : PlaybackStateCompat.STATE_PAUSED; case Player.STATE_READY: return playWhenReady ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED; case Player.STATE_ENDED: @@ -1319,42 +1321,42 @@ public final class MediaSessionConnector { } @Override - public void onPrepareFromMediaId(String mediaId, Bundle extras) { + public void onPrepareFromMediaId(String mediaId, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID)) { playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ false, extras); } } @Override - public void onPrepareFromSearch(String query, Bundle extras) { + public void onPrepareFromSearch(String query, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH)) { playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ false, extras); } } @Override - public void onPrepareFromUri(Uri uri, Bundle extras) { + public void onPrepareFromUri(Uri uri, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_URI)) { playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ false, extras); } } @Override - public void onPlayFromMediaId(String mediaId, Bundle extras) { + public void onPlayFromMediaId(String mediaId, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID)) { playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ true, extras); } } @Override - public void onPlayFromSearch(String query, Bundle extras) { + public void onPlayFromSearch(String query, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH)) { playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ true, extras); } } @Override - public void onPlayFromUri(Uri uri, Bundle extras) { + public void onPlayFromUri(Uri uri, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_URI)) { playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ true, extras); } @@ -1368,7 +1370,7 @@ public final class MediaSessionConnector { } @Override - public void onSetRating(RatingCompat rating, Bundle extras) { + public void onSetRating(RatingCompat rating, @Nullable Bundle extras) { if (canDispatchSetRating()) { ratingCallback.onSetRating(player, rating, extras); } diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index 41eac7c661..e0d12b5c1c 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -41,7 +41,7 @@ dependencies { // https://cashapp.github.io/2019-02-05/okhttp-3-13-requires-android-5 // Since OkHttp is distributed as a jar rather than an aar, Gradle won't // stop us from making this mistake! - api 'com.squareup.okhttp3:okhttp:3.12.8' + api 'com.squareup.okhttp3:okhttp:3.12.11' } ext { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/C.java b/library/core/src/main/java/com/google/android/exoplayer2/C.java index 43cedf985b..3eee0a1891 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/C.java @@ -1056,7 +1056,8 @@ public final class C { * #ROLE_FLAG_DUB}, {@link #ROLE_FLAG_EMERGENCY}, {@link #ROLE_FLAG_CAPTION}, {@link * #ROLE_FLAG_SUBTITLE}, {@link #ROLE_FLAG_SIGN}, {@link #ROLE_FLAG_DESCRIBES_VIDEO}, {@link * #ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND}, {@link #ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY}, - * {@link #ROLE_FLAG_TRANSCRIBES_DIALOG} and {@link #ROLE_FLAG_EASY_TO_READ}. + * {@link #ROLE_FLAG_TRANSCRIBES_DIALOG}, {@link #ROLE_FLAG_EASY_TO_READ} and {@link + * #ROLE_FLAG_TRICK_PLAY}. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -1076,7 +1077,8 @@ public final class C { ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND, ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY, ROLE_FLAG_TRANSCRIBES_DIALOG, - ROLE_FLAG_EASY_TO_READ + ROLE_FLAG_EASY_TO_READ, + ROLE_FLAG_TRICK_PLAY }) public @interface RoleFlags {} /** Indicates a main track. */ @@ -1122,6 +1124,8 @@ public final class C { public static final int ROLE_FLAG_TRANSCRIBES_DIALOG = 1 << 12; /** Indicates the track contains a text that has been edited for ease of reading. */ public static final int ROLE_FLAG_EASY_TO_READ = 1 << 13; + /** Indicates the track is intended for trick play. */ + public static final int ROLE_FLAG_TRICK_PLAY = 1 << 14; /** * Converts a time in microseconds to the corresponding time in milliseconds, preserving diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 6fd23a93c5..ddc54e9e6e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -120,7 +120,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private int pendingPrepareCount; private SeekPosition pendingInitialSeekPosition; private long rendererPositionUs; - private int nextPendingMessageIndex; + private int nextPendingMessageIndexHint; private boolean deliverPendingMessageAtStartPositionRequired; public ExoPlayerImplInternal( @@ -928,7 +928,6 @@ import java.util.concurrent.atomic.AtomicBoolean; pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); } pendingMessages.clear(); - nextPendingMessageIndex = 0; } MediaPeriodId mediaPeriodId = resetPosition @@ -954,7 +953,12 @@ import java.util.concurrent.atomic.AtomicBoolean; startPositionUs); if (releaseMediaSource) { if (mediaSource != null) { - mediaSource.releaseSource(/* caller= */ this); + try { + mediaSource.releaseSource(/* caller= */ this); + } catch (RuntimeException e) { + // There's nothing we can do. + Log.e(TAG, "Failed to release child source.", e); + } mediaSource = null; } } @@ -1077,6 +1081,7 @@ import java.util.concurrent.atomic.AtomicBoolean; // Correct next index if necessary (e.g. after seeking, timeline changes, or new messages) int currentPeriodIndex = playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); + int nextPendingMessageIndex = Math.min(nextPendingMessageIndexHint, pendingMessages.size()); PendingMessageInfo previousInfo = nextPendingMessageIndex > 0 ? pendingMessages.get(nextPendingMessageIndex - 1) : null; while (previousInfo != null @@ -1122,6 +1127,7 @@ import java.util.concurrent.atomic.AtomicBoolean; ? pendingMessages.get(nextPendingMessageIndex) : null; } + nextPendingMessageIndexHint = nextPendingMessageIndex; } private void ensureStopped(Renderer renderer) throws ExoPlaybackException { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 06743732e7..15d43c7b79 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -29,11 +29,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.4"; + public static final String VERSION = "2.11.5"; /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.4"; + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.5"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +43,7 @@ public final class ExoPlayerLibraryInfo { * integer version 123045006 (123-045-006). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 2011004; + public static final int VERSION_INT = 2011005; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index 7423320d8b..4dac71559a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -19,6 +19,7 @@ import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; /** * A flexible representation of the structure of media. A timeline is able to represent the @@ -278,6 +279,48 @@ public abstract class Timeline { return positionInFirstPeriodUs; } + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + Window that = (Window) obj; + return Util.areEqual(uid, that.uid) + && Util.areEqual(tag, that.tag) + && Util.areEqual(manifest, that.manifest) + && presentationStartTimeMs == that.presentationStartTimeMs + && windowStartTimeMs == that.windowStartTimeMs + && isSeekable == that.isSeekable + && isDynamic == that.isDynamic + && isLive == that.isLive + && defaultPositionUs == that.defaultPositionUs + && durationUs == that.durationUs + && firstPeriodIndex == that.firstPeriodIndex + && lastPeriodIndex == that.lastPeriodIndex + && positionInFirstPeriodUs == that.positionInFirstPeriodUs; + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + uid.hashCode(); + result = 31 * result + (tag == null ? 0 : tag.hashCode()); + result = 31 * result + (manifest == null ? 0 : manifest.hashCode()); + result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32)); + result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32)); + result = 31 * result + (isSeekable ? 1 : 0); + result = 31 * result + (isDynamic ? 1 : 0); + result = 31 * result + (isLive ? 1 : 0); + result = 31 * result + (int) (defaultPositionUs ^ (defaultPositionUs >>> 32)); + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + firstPeriodIndex; + result = 31 * result + lastPeriodIndex; + result = 31 * result + (int) (positionInFirstPeriodUs ^ (positionInFirstPeriodUs >>> 32)); + return result; + } } /** @@ -423,8 +466,8 @@ public abstract class Timeline { * microseconds. * * @param adGroupIndex The ad group index. - * @return The time of the ad group at the index, in microseconds, or {@link - * C#TIME_END_OF_SOURCE} for a post-roll ad group. + * @return The time of the ad group at the index relative to the start of the enclosing {@link + * Period}, in microseconds, or {@link C#TIME_END_OF_SOURCE} for a post-roll ad group. */ public long getAdGroupTimeUs(int adGroupIndex) { return adPlaybackState.adGroupTimesUs[adGroupIndex]; @@ -467,22 +510,23 @@ public abstract class Timeline { } /** - * Returns the index of the ad group at or before {@code positionUs}, if that ad group is - * unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code positionUs} has - * no ads remaining to be played, or if there is no such ad group. + * Returns the index of the ad group at or before {@code positionUs} in the period, if that ad + * group is unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code + * positionUs} has no ads remaining to be played, or if there is no such ad group. * - * @param positionUs The position at or before which to find an ad group, in microseconds. + * @param positionUs The period position at or before which to find an ad group, in + * microseconds. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexForPositionUs(long positionUs) { - return adPlaybackState.getAdGroupIndexForPositionUs(positionUs); + return adPlaybackState.getAdGroupIndexForPositionUs(positionUs, durationUs); } /** - * Returns the index of the next ad group after {@code positionUs} that has ads remaining to be - * played. Returns {@link C#INDEX_UNSET} if there is no such ad group. + * Returns the index of the next ad group after {@code positionUs} in the period that has ads + * remaining to be played. Returns {@link C#INDEX_UNSET} if there is no such ad group. * - * @param positionUs The position after which to find an ad group, in microseconds. + * @param positionUs The period position after which to find an ad group, in microseconds. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexAfterPositionUs(long positionUs) { @@ -534,6 +578,34 @@ public abstract class Timeline { return adPlaybackState.adResumePositionUs; } + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + Period that = (Period) obj; + return Util.areEqual(id, that.id) + && Util.areEqual(uid, that.uid) + && windowIndex == that.windowIndex + && durationUs == that.durationUs + && positionInWindowUs == that.positionInWindowUs + && Util.areEqual(adPlaybackState, that.adPlaybackState); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + (id == null ? 0 : id.hashCode()); + result = 31 * result + (uid == null ? 0 : uid.hashCode()); + result = 31 * result + windowIndex; + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + (int) (positionInWindowUs ^ (positionInWindowUs >>> 32)); + result = 31 * result + (adPlaybackState == null ? 0 : adPlaybackState.hashCode()); + return result; + } } /** An empty timeline. */ @@ -834,4 +906,50 @@ public abstract class Timeline { * @return The unique id of the period. */ public abstract Object getUidOfPeriod(int periodIndex); + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Timeline)) { + return false; + } + Timeline other = (Timeline) obj; + if (other.getWindowCount() != getWindowCount() || other.getPeriodCount() != getPeriodCount()) { + return false; + } + Timeline.Window window = new Timeline.Window(); + Timeline.Period period = new Timeline.Period(); + Timeline.Window otherWindow = new Timeline.Window(); + Timeline.Period otherPeriod = new Timeline.Period(); + for (int i = 0; i < getWindowCount(); i++) { + if (!getWindow(i, window).equals(other.getWindow(i, otherWindow))) { + return false; + } + } + for (int i = 0; i < getPeriodCount(); i++) { + if (!getPeriod(i, period, /* setIds= */ true) + .equals(other.getPeriod(i, otherPeriod, /* setIds= */ true))) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + Window window = new Window(); + Period period = new Period(); + int result = 7; + result = 31 * result + getWindowCount(); + for (int i = 0; i < getWindowCount(); i++) { + result = 31 * result + getWindow(i, window).hashCode(); + } + result = 31 * result + getPeriodCount(); + for (int i = 0; i < getPeriodCount(); i++) { + result = 31 * result + getPeriod(i, period, /* setIds= */ true).hashCode(); + } + return result; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java index 44f8c10afe..04f3ba154a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -29,7 +29,6 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Random; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Default {@link PlaybackSessionManager} which instantiates a new session for each window in the @@ -48,8 +47,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag private @MonotonicNonNull Listener listener; private Timeline currentTimeline; - @Nullable private MediaPeriodId currentMediaPeriodId; - @Nullable private String activeSessionId; + @Nullable private String currentSessionId; /** Creates session manager. */ public DefaultPlaybackSessionManager() { @@ -83,22 +81,34 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag @Override public synchronized void updateSessions(EventTime eventTime) { - boolean isObviouslyFinished = - eventTime.mediaPeriodId != null - && currentMediaPeriodId != null - && eventTime.mediaPeriodId.windowSequenceNumber - < currentMediaPeriodId.windowSequenceNumber; - if (!isObviouslyFinished) { - SessionDescriptor descriptor = - getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); - if (!descriptor.isCreated) { - descriptor.isCreated = true; - Assertions.checkNotNull(listener).onSessionCreated(eventTime, descriptor.sessionId); - if (activeSessionId == null) { - updateActiveSession(eventTime, descriptor); - } + Assertions.checkNotNull(listener); + @Nullable SessionDescriptor currentSession = sessions.get(currentSessionId); + if (eventTime.mediaPeriodId != null && currentSession != null) { + // If we receive an event associated with a media period, then it needs to be either part of + // the current window if it's the first created media period, or a window that will be played + // in the future. Otherwise, we know that it belongs to a session that was already finished + // and we can ignore the event. + boolean isAlreadyFinished = + currentSession.windowSequenceNumber == C.INDEX_UNSET + ? currentSession.windowIndex != eventTime.windowIndex + : eventTime.mediaPeriodId.windowSequenceNumber < currentSession.windowSequenceNumber; + if (isAlreadyFinished) { + return; } } + SessionDescriptor eventSession = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (currentSessionId == null) { + currentSessionId = eventSession.sessionId; + } + if (!eventSession.isCreated) { + eventSession.isCreated = true; + listener.onSessionCreated(eventTime, eventSession.sessionId); + } + if (eventSession.sessionId.equals(currentSessionId) && !eventSession.isActive) { + eventSession.isActive = true; + listener.onSessionActive(eventTime, eventSession.sessionId); + } } @Override @@ -112,8 +122,8 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag if (!session.tryResolvingToNewTimeline(previousTimeline, currentTimeline)) { iterator.remove(); if (session.isCreated) { - if (session.sessionId.equals(activeSessionId)) { - activeSessionId = null; + if (session.sessionId.equals(currentSessionId)) { + currentSessionId = null; } listener.onSessionFinished( eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); @@ -136,36 +146,55 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag if (session.isFinishedAtEventTime(eventTime)) { iterator.remove(); if (session.isCreated) { - boolean isRemovingActiveSession = session.sessionId.equals(activeSessionId); - boolean isAutomaticTransition = hasAutomaticTransition && isRemovingActiveSession; - if (isRemovingActiveSession) { - activeSessionId = null; + boolean isRemovingCurrentSession = session.sessionId.equals(currentSessionId); + boolean isAutomaticTransition = + hasAutomaticTransition && isRemovingCurrentSession && session.isActive; + if (isRemovingCurrentSession) { + currentSessionId = null; } listener.onSessionFinished(eventTime, session.sessionId, isAutomaticTransition); } } } - SessionDescriptor activeSessionDescriptor = + @Nullable SessionDescriptor previousSessionDescriptor = sessions.get(currentSessionId); + SessionDescriptor currentSessionDescriptor = getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + currentSessionId = currentSessionDescriptor.sessionId; if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd() - && (currentMediaPeriodId == null - || currentMediaPeriodId.windowSequenceNumber + && (previousSessionDescriptor == null + || previousSessionDescriptor.windowSequenceNumber != eventTime.mediaPeriodId.windowSequenceNumber - || currentMediaPeriodId.adGroupIndex != eventTime.mediaPeriodId.adGroupIndex - || currentMediaPeriodId.adIndexInAdGroup != eventTime.mediaPeriodId.adIndexInAdGroup)) { + || previousSessionDescriptor.adMediaPeriodId == null + || previousSessionDescriptor.adMediaPeriodId.adGroupIndex + != eventTime.mediaPeriodId.adGroupIndex + || previousSessionDescriptor.adMediaPeriodId.adIndexInAdGroup + != eventTime.mediaPeriodId.adIndexInAdGroup)) { // New ad playback started. Find corresponding content session and notify ad playback started. MediaPeriodId contentMediaPeriodId = new MediaPeriodId( eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); SessionDescriptor contentSession = getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); - if (contentSession.isCreated && activeSessionDescriptor.isCreated) { + if (contentSession.isCreated && currentSessionDescriptor.isCreated) { listener.onAdPlaybackStarted( - eventTime, contentSession.sessionId, activeSessionDescriptor.sessionId); + eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId); + } + } + } + + @Override + public void finishAllSessions(EventTime eventTime) { + currentSessionId = null; + Iterator iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + iterator.remove(); + if (session.isCreated && listener != null) { + listener.onSessionFinished( + eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); } } - updateActiveSession(eventTime, activeSessionDescriptor); } private SessionDescriptor getOrAddSession( @@ -199,18 +228,6 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag return bestMatch; } - @RequiresNonNull("listener") - private void updateActiveSession(EventTime eventTime, SessionDescriptor sessionDescriptor) { - currentMediaPeriodId = eventTime.mediaPeriodId; - if (sessionDescriptor.isCreated) { - activeSessionId = sessionDescriptor.sessionId; - if (!sessionDescriptor.isActive) { - sessionDescriptor.isActive = true; - listener.onSessionActive(eventTime, sessionDescriptor.sessionId); - } - } - } - private static String generateSessionId() { byte[] randomBytes = new byte[SESSION_ID_LENGTH]; RANDOM.nextBytes(randomBytes); @@ -284,8 +301,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { if (windowSequenceNumber == C.INDEX_UNSET && eventWindowIndex == windowIndex - && eventMediaPeriodId != null - && !eventMediaPeriodId.isAd()) { + && eventMediaPeriodId != null) { // Set window sequence number for this session as soon as we have one. windowSequenceNumber = eventMediaPeriodId.windowSequenceNumber; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java index 53d63e23fc..7045779125 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java @@ -117,4 +117,12 @@ public interface PlaybackSessionManager { * @param reason The {@link DiscontinuityReason}. */ void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); + + /** + * Finishes all existing sessions and calls their respective {@link + * Listener#onSessionFinished(EventTime, String, boolean)} callback. + * + * @param eventTime The event time at which sessions are finished. + */ + void finishAllSessions(EventTime eventTime); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java index a9fd9d8641..46c0a05342 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -148,7 +148,6 @@ public final class PlaybackStatsListener // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with // an actual EventTime. Should also simplify other cases where the listener needs to be released // separately from the player. - HashMap trackerCopy = new HashMap<>(playbackStatsTrackers); EventTime dummyEventTime = new EventTime( SystemClock.elapsedRealtime(), @@ -158,9 +157,7 @@ public final class PlaybackStatsListener /* eventPlaybackPositionMs= */ 0, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); - for (String session : trackerCopy.keySet()) { - onSessionFinished(dummyEventTime, session, /* automaticTransition= */ false); - } + sessionManager.finishAllSessions(dummyEventTime); } // PlaybackSessionManager.Listener implementation. @@ -189,11 +186,15 @@ public final class PlaybackStatsListener @Override public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) { Assertions.checkState(Assertions.checkNotNull(eventTime.mediaPeriodId).isAd()); - long contentPositionUs = + long contentPeriodPositionUs = eventTime .timeline .getPeriodByUid(eventTime.mediaPeriodId.periodUid, period) .getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex); + long contentWindowPositionUs = + contentPeriodPositionUs == C.TIME_END_OF_SOURCE + ? C.TIME_END_OF_SOURCE + : contentPeriodPositionUs + period.getPositionInWindowUs(); EventTime contentEventTime = new EventTime( eventTime.realtimeMs, @@ -203,7 +204,7 @@ public final class PlaybackStatsListener eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber, eventTime.mediaPeriodId.adGroupIndex), - /* eventPlaybackPositionMs= */ C.usToMs(contentPositionUs), + /* eventPlaybackPositionMs= */ C.usToMs(contentWindowPositionUs), eventTime.currentPlaybackPositionMs, eventTime.totalBufferedDurationMs); Assertions.checkNotNull(playbackStatsTrackers.get(contentSession)) @@ -239,7 +240,7 @@ public final class PlaybackStatsListener EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) { this.playWhenReady = playWhenReady; this.playbackState = playbackState; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers @@ -252,7 +253,7 @@ public final class PlaybackStatsListener public void onPlaybackSuppressionReasonChanged( EventTime eventTime, int playbackSuppressionReason) { isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers @@ -264,7 +265,7 @@ public final class PlaybackStatsListener @Override public void onTimelineChanged(EventTime eventTime, int reason) { sessionManager.handleTimelineUpdate(eventTime); - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); @@ -275,7 +276,7 @@ public final class PlaybackStatsListener @Override public void onPositionDiscontinuity(EventTime eventTime, int reason) { sessionManager.handlePositionDiscontinuity(eventTime, reason); - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); @@ -285,7 +286,7 @@ public final class PlaybackStatsListener @Override public void onSeekStarted(EventTime eventTime) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onSeekStarted(eventTime); @@ -295,7 +296,7 @@ public final class PlaybackStatsListener @Override public void onSeekProcessed(EventTime eventTime) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onSeekProcessed(eventTime); @@ -305,7 +306,7 @@ public final class PlaybackStatsListener @Override public void onPlayerError(EventTime eventTime, ExoPlaybackException error) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onFatalError(eventTime, error); @@ -317,7 +318,7 @@ public final class PlaybackStatsListener public void onPlaybackParametersChanged( EventTime eventTime, PlaybackParameters playbackParameters) { playbackSpeed = playbackParameters.speed; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); } @@ -326,7 +327,7 @@ public final class PlaybackStatsListener @Override public void onTracksChanged( EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections); @@ -337,7 +338,7 @@ public final class PlaybackStatsListener @Override public void onLoadStarted( EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onLoadStarted(eventTime); @@ -347,7 +348,7 @@ public final class PlaybackStatsListener @Override public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData); @@ -362,7 +363,7 @@ public final class PlaybackStatsListener int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height); @@ -373,7 +374,7 @@ public final class PlaybackStatsListener @Override public void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded); @@ -384,7 +385,7 @@ public final class PlaybackStatsListener @Override public void onAudioUnderrun( EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onAudioUnderrun(); @@ -394,7 +395,7 @@ public final class PlaybackStatsListener @Override public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames); @@ -409,7 +410,7 @@ public final class PlaybackStatsListener MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); @@ -419,7 +420,7 @@ public final class PlaybackStatsListener @Override public void onDrmSessionManagerError(EventTime eventTime, Exception error) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); @@ -427,6 +428,13 @@ public final class PlaybackStatsListener } } + private void maybeAddSession(EventTime eventTime) { + boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; + if (!isCompletelyIdle) { + sessionManager.updateSessions(eventTime); + } + } + /** Tracker for playback stats of a single playback. */ private static final class PlaybackStatsTracker { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java index 0564591f1f..200c917954 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java @@ -37,7 +37,7 @@ import java.lang.annotation.RetentionPolicy; * *

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

This is a fail safe that should not be required on correctly functioning devices. */ private static final long MAX_LATENCY_US = 5 * C.MICROS_PER_SECOND; + /** The duration of time used to smooth over an adjustment between position sampling modes. */ + private static final long MODE_SWITCH_SMOOTHING_DURATION_US = C.MICROS_PER_SECOND; private static final long FORCE_RESET_WORKAROUND_TIMEOUT_MS = 200; @@ -160,6 +162,15 @@ import java.lang.reflect.Method; private long stopPlaybackHeadPosition; private long endPlaybackHeadPosition; + // Results from the previous call to getCurrentPositionUs. + private long lastPositionUs; + private long lastSystemTimeUs; + private boolean lastSampleUsedGetTimestampMode; + + // Results from the last call to getCurrentPositionUs that used a different sample mode. + private long previousModePositionUs; + private long previousModeSystemTimeUs; + /** * Creates a new audio track position tracker. * @@ -206,6 +217,7 @@ import java.lang.reflect.Method; hasData = false; stopTimestampUs = C.TIME_UNSET; forceResetWorkaroundTimeMs = C.TIME_UNSET; + lastLatencySampleTimeUs = 0; latencyUs = 0; } @@ -217,18 +229,16 @@ import java.lang.reflect.Method; // If the device supports it, use the playback timestamp from AudioTrack.getTimestamp. // Otherwise, derive a smoothed position by sampling the track's frame position. long systemTimeUs = System.nanoTime() / 1000; + long positionUs; AudioTimestampPoller audioTimestampPoller = Assertions.checkNotNull(this.audioTimestampPoller); - if (audioTimestampPoller.hasTimestamp()) { + boolean useGetTimestampMode = audioTimestampPoller.hasAdvancingTimestamp(); + if (useGetTimestampMode) { // Calculate the speed-adjusted position using the timestamp (which may be in the future). long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); long timestampPositionUs = framesToDurationUs(timestampPositionFrames); - if (!audioTimestampPoller.isTimestampAdvancing()) { - return timestampPositionUs; - } long elapsedSinceTimestampUs = systemTimeUs - audioTimestampPoller.getTimestampSystemTimeUs(); - return timestampPositionUs + elapsedSinceTimestampUs; + positionUs = timestampPositionUs + elapsedSinceTimestampUs; } else { - long positionUs; if (playheadOffsetCount == 0) { // The AudioTrack has started, but we don't have any samples to compute a smoothed position. positionUs = getPlaybackHeadPositionUs(); @@ -239,10 +249,31 @@ import java.lang.reflect.Method; positionUs = systemTimeUs + smoothedPlayheadOffsetUs; } if (!sourceEnded) { - positionUs -= latencyUs; + positionUs = Math.max(0, positionUs - latencyUs); } - return positionUs; } + + if (lastSampleUsedGetTimestampMode != useGetTimestampMode) { + // We've switched sampling mode. + previousModeSystemTimeUs = lastSystemTimeUs; + previousModePositionUs = lastPositionUs; + } + long elapsedSincePreviousModeUs = systemTimeUs - previousModeSystemTimeUs; + if (elapsedSincePreviousModeUs < MODE_SWITCH_SMOOTHING_DURATION_US) { + // Use a ramp to smooth between the old mode and the new one to avoid introducing a sudden + // jump if the two modes disagree. + long previousModeProjectedPositionUs = previousModePositionUs + elapsedSincePreviousModeUs; + // A ramp consisting of 1000 points distributed over MODE_SWITCH_SMOOTHING_DURATION_US. + long rampPoint = (elapsedSincePreviousModeUs * 1000) / MODE_SWITCH_SMOOTHING_DURATION_US; + positionUs *= rampPoint; + positionUs += (1000 - rampPoint) * previousModeProjectedPositionUs; + positionUs /= 1000; + } + + lastSystemTimeUs = systemTimeUs; + lastPositionUs = positionUs; + lastSampleUsedGetTimestampMode = useGetTimestampMode; + return positionUs; } /** Starts position tracking. Must be called immediately before {@link AudioTrack#play()}. */ @@ -353,7 +384,7 @@ import java.lang.reflect.Method; } /** - * Resets the position tracker. Should be called when the audio track previous passed to {@link + * Resets the position tracker. Should be called when the audio track previously passed to {@link * #setAudioTrack(AudioTrack, int, int, int)} is no longer in use. */ public void reset() { @@ -457,6 +488,8 @@ import java.lang.reflect.Method; playheadOffsetCount = 0; nextPlayheadOffsetIndex = 0; lastPlayheadSampleTimeUs = 0; + lastSystemTimeUs = 0; + previousModeSystemTimeUs = 0; } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index ba31c118e7..32a819bf81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -120,9 +120,20 @@ public final class DefaultAudioSink implements AudioSink { /** * Creates a new default chain of audio processors, with the user-defined {@code - * audioProcessors} applied before silence skipping and playback parameters. + * audioProcessors} applied before silence skipping and speed adjustment processors. */ public DefaultAudioProcessorChain(AudioProcessor... audioProcessors) { + this(audioProcessors, new SilenceSkippingAudioProcessor(), new SonicAudioProcessor()); + } + + /** + * Creates a new default chain of audio processors, with the user-defined {@code + * audioProcessors} applied before silence skipping and speed adjustment processors. + */ + public DefaultAudioProcessorChain( + AudioProcessor[] audioProcessors, + SilenceSkippingAudioProcessor silenceSkippingAudioProcessor, + SonicAudioProcessor sonicAudioProcessor) { // The passed-in type may be more specialized than AudioProcessor[], so allocate a new array // rather than using Arrays.copyOf. this.audioProcessors = new AudioProcessor[audioProcessors.length + 2]; @@ -132,8 +143,8 @@ public final class DefaultAudioSink implements AudioSink { /* dest= */ this.audioProcessors, /* destPos= */ 0, /* length= */ audioProcessors.length); - silenceSkippingAudioProcessor = new SilenceSkippingAudioProcessor(); - sonicAudioProcessor = new SonicAudioProcessor(); + this.silenceSkippingAudioProcessor = silenceSkippingAudioProcessor; + this.sonicAudioProcessor = sonicAudioProcessor; this.audioProcessors[audioProcessors.length] = silenceSkippingAudioProcessor; this.audioProcessors[audioProcessors.length + 1] = sonicAudioProcessor; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java index 2a98d2fb25..7ddb491525 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java @@ -17,11 +17,13 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.IntDef; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; +import java.nio.ByteOrder; /** * An {@link AudioProcessor} that skips silence in the input stream. Input and output are 16-bit @@ -30,27 +32,20 @@ import java.nio.ByteBuffer; public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { /** - * The minimum duration of audio that must be below {@link #SILENCE_THRESHOLD_LEVEL} to classify - * that part of audio as silent, in microseconds. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * minimumSilenceDurationUs}. */ - private static final long MINIMUM_SILENCE_DURATION_US = 150_000; + public static final long DEFAULT_MINIMUM_SILENCE_DURATION_US = 150_000; /** - * The duration of silence by which to extend non-silent sections, in microseconds. The value must - * not exceed {@link #MINIMUM_SILENCE_DURATION_US}. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * paddingSilenceUs}. */ - private static final long PADDING_SILENCE_US = 20_000; + public static final long DEFAULT_PADDING_SILENCE_US = 20_000; /** - * The absolute level below which an individual PCM sample is classified as silent. Note: the - * specified value will be rounded so that the threshold check only depends on the more - * significant byte, for efficiency. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * silenceThresholdLevel}. */ - private static final short SILENCE_THRESHOLD_LEVEL = 1024; - - /** - * Threshold for classifying an individual PCM sample as silent based on its more significant - * byte. This is {@link #SILENCE_THRESHOLD_LEVEL} divided by 256 with rounding. - */ - private static final byte SILENCE_THRESHOLD_LEVEL_MSB = (SILENCE_THRESHOLD_LEVEL + 128) >> 8; + public static final short DEFAULT_SILENCE_THRESHOLD_LEVEL = 1024; /** Trimming states. */ @Documented @@ -68,8 +63,10 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { /** State when the input is silent. */ private static final int STATE_SILENT = 2; + private final long minimumSilenceDurationUs; + private final long paddingSilenceUs; + private final short silenceThresholdLevel; private int bytesPerFrame; - private boolean enabled; /** @@ -91,8 +88,31 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { private boolean hasOutputNoise; private long skippedFrames; - /** Creates a new silence trimming audio processor. */ + /** Creates a new silence skipping audio processor. */ public SilenceSkippingAudioProcessor() { + this( + DEFAULT_MINIMUM_SILENCE_DURATION_US, + DEFAULT_PADDING_SILENCE_US, + DEFAULT_SILENCE_THRESHOLD_LEVEL); + } + + /** + * Creates a new silence skipping audio processor. + * + * @param minimumSilenceDurationUs The minimum duration of audio that must be below {@code + * silenceThresholdLevel} to classify that part of audio as silent, in microseconds. + * @param paddingSilenceUs The duration of silence by which to extend non-silent sections, in + * microseconds. The value must not exceed {@code minimumSilenceDurationUs}. + * @param silenceThresholdLevel The absolute level below which an individual PCM sample is + * classified as silent. + */ + public SilenceSkippingAudioProcessor( + long minimumSilenceDurationUs, long paddingSilenceUs, short silenceThresholdLevel) { + Assertions.checkArgument(paddingSilenceUs <= minimumSilenceDurationUs); + this.minimumSilenceDurationUs = minimumSilenceDurationUs; + this.paddingSilenceUs = paddingSilenceUs; + this.silenceThresholdLevel = silenceThresholdLevel; + maybeSilenceBuffer = Util.EMPTY_BYTE_ARRAY; paddingBuffer = Util.EMPTY_BYTE_ARRAY; } @@ -166,11 +186,11 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { protected void onFlush() { if (enabled) { bytesPerFrame = inputAudioFormat.bytesPerFrame; - int maybeSilenceBufferSize = durationUsToFrames(MINIMUM_SILENCE_DURATION_US) * bytesPerFrame; + int maybeSilenceBufferSize = durationUsToFrames(minimumSilenceDurationUs) * bytesPerFrame; if (maybeSilenceBuffer.length != maybeSilenceBufferSize) { maybeSilenceBuffer = new byte[maybeSilenceBufferSize]; } - paddingSize = durationUsToFrames(PADDING_SILENCE_US) * bytesPerFrame; + paddingSize = durationUsToFrames(paddingSilenceUs) * bytesPerFrame; if (paddingBuffer.length != paddingSize) { paddingBuffer = new byte[paddingSize]; } @@ -325,9 +345,10 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { * classified as a noisy frame, or the limit of the buffer if no such frame exists. */ private int findNoisePosition(ByteBuffer buffer) { + Assertions.checkArgument(buffer.order() == ByteOrder.LITTLE_ENDIAN); // The input is in ByteOrder.nativeOrder(), which is little endian on Android. - for (int i = buffer.position() + 1; i < buffer.limit(); i += 2) { - if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + for (int i = buffer.position(); i < buffer.limit(); i += 2) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Round to the start of the frame. return bytesPerFrame * (i / bytesPerFrame); } @@ -340,9 +361,10 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { * from the byte position to the limit are classified as silent. */ private int findNoiseLimit(ByteBuffer buffer) { + Assertions.checkArgument(buffer.order() == ByteOrder.LITTLE_ENDIAN); // The input is in ByteOrder.nativeOrder(), which is little endian on Android. - for (int i = buffer.limit() - 1; i >= buffer.position(); i -= 2) { - if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + for (int i = buffer.limit() - 2; i >= buffer.position(); i -= 2) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Return the start of the next frame. return bytesPerFrame * (i / bytesPerFrame) + bytesPerFrame; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java index b6a063bd14..a9afa47198 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/TeeAudioProcessor.java @@ -79,6 +79,11 @@ public final class TeeAudioProcessor extends BaseAudioProcessor { replaceOutputBuffer(remaining).put(inputBuffer).flip(); } + @Override + protected void onFlush() { + flushSinkIfActive(); + } + @Override protected void onQueueEndOfStream() { flushSinkIfActive(); @@ -201,7 +206,7 @@ public final class TeeAudioProcessor extends BaseAudioProcessor { } private void reset() throws IOException { - RandomAccessFile randomAccessFile = this.randomAccessFile; + @Nullable RandomAccessFile randomAccessFile = this.randomAccessFile; if (randomAccessFile == null) { return; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java index 8d84325d93..f630c267e6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java @@ -155,18 +155,20 @@ import java.nio.ByteBuffer; @Override protected void onFlush() { if (reconfigurationPending) { - // This is the initial flush after reconfiguration. Prepare to trim bytes from the start/end. + // Flushing activates the new configuration, so prepare to trim bytes from the start/end. reconfigurationPending = false; endBuffer = new byte[trimEndFrames * inputAudioFormat.bytesPerFrame]; pendingTrimStartBytes = trimStartFrames * inputAudioFormat.bytesPerFrame; - } else { - // This is a flush during playback (after the initial flush). We assume this was caused by a - // seek to a non-zero position and clear pending start bytes. This assumption may be wrong (we - // may be seeking to zero), but playing data that should have been trimmed shouldn't be - // noticeable after a seek. Ideally we would check the timestamp of the first input buffer - // queued after flushing to decide whether to trim (see also [Internal: b/77292509]). - pendingTrimStartBytes = 0; } + + // TODO(internal b/77292509): Flushing occurs to activate a configuration (handled above) but + // also when seeking within a stream. This implementation currently doesn't handle seek to start + // (where we need to trim at the start again), nor seeks to non-zero positions before start + // trimming has occurred (where we should set pendingTrimStartBytes to zero). These cases can be + // fixed by trimming in queueInput based on timestamp, once that information is available. + + // Any data in the end buffer should no longer be output if we are playing from a different + // position, so discard it and refill the buffer using new input. endBufferSize = 0; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java index db1a0199ac..c51b68a7c6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java @@ -60,7 +60,7 @@ import com.google.android.exoplayer2.util.Util; return new XingSeeker(position, mpegAudioHeader.frameSize, durationUs); } - long dataSize = frame.readUnsignedIntToInt(); + long dataSize = frame.readUnsignedInt(); long[] tableOfContents = new long[100]; for (int i = 0; i < 100; i++) { tableOfContents[i] = frame.readUnsignedByte(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index 35f85d0a08..c0d1581c39 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -664,9 +664,9 @@ public class FragmentedMp4Extractor implements Extractor { private static Pair parseTrex(ParsableByteArray trex) { trex.setPosition(Atom.FULL_HEADER_SIZE); int trackId = trex.readInt(); - int defaultSampleDescriptionIndex = trex.readUnsignedIntToInt() - 1; - int defaultSampleDuration = trex.readUnsignedIntToInt(); - int defaultSampleSize = trex.readUnsignedIntToInt(); + int defaultSampleDescriptionIndex = trex.readInt() - 1; + int defaultSampleDuration = trex.readInt(); + int defaultSampleSize = trex.readInt(); int defaultSampleFlags = trex.readInt(); return Pair.create(trackId, new DefaultSampleValues(defaultSampleDescriptionIndex, @@ -751,8 +751,9 @@ public class FragmentedMp4Extractor implements Extractor { } } - private static void parseTruns(ContainerAtom traf, TrackBundle trackBundle, long decodeTime, - @Flags int flags) { + private static void parseTruns( + ContainerAtom traf, TrackBundle trackBundle, long decodeTime, @Flags int flags) + throws ParserException { int trunCount = 0; int totalSampleCount = 0; List leafChildren = traf.leafChildren; @@ -871,13 +872,20 @@ public class FragmentedMp4Extractor implements Extractor { DefaultSampleValues defaultSampleValues = trackBundle.defaultSampleValues; int defaultSampleDescriptionIndex = ((atomFlags & 0x02 /* default_sample_description_index_present */) != 0) - ? tfhd.readUnsignedIntToInt() - 1 : defaultSampleValues.sampleDescriptionIndex; - int defaultSampleDuration = ((atomFlags & 0x08 /* default_sample_duration_present */) != 0) - ? tfhd.readUnsignedIntToInt() : defaultSampleValues.duration; - int defaultSampleSize = ((atomFlags & 0x10 /* default_sample_size_present */) != 0) - ? tfhd.readUnsignedIntToInt() : defaultSampleValues.size; - int defaultSampleFlags = ((atomFlags & 0x20 /* default_sample_flags_present */) != 0) - ? tfhd.readUnsignedIntToInt() : defaultSampleValues.flags; + ? tfhd.readInt() - 1 + : defaultSampleValues.sampleDescriptionIndex; + int defaultSampleDuration = + ((atomFlags & 0x08 /* default_sample_duration_present */) != 0) + ? tfhd.readInt() + : defaultSampleValues.duration; + int defaultSampleSize = + ((atomFlags & 0x10 /* default_sample_size_present */) != 0) + ? tfhd.readInt() + : defaultSampleValues.size; + int defaultSampleFlags = + ((atomFlags & 0x20 /* default_sample_flags_present */) != 0) + ? tfhd.readInt() + : defaultSampleValues.flags; trackBundle.fragment.header = new DefaultSampleValues(defaultSampleDescriptionIndex, defaultSampleDuration, defaultSampleSize, defaultSampleFlags); return trackBundle; @@ -910,16 +918,22 @@ public class FragmentedMp4Extractor implements Extractor { /** * Parses a trun atom (defined in 14496-12). * - * @param trackBundle The {@link TrackBundle} that contains the {@link TrackFragment} into - * which parsed data should be placed. + * @param trackBundle The {@link TrackBundle} that contains the {@link TrackFragment} into which + * parsed data should be placed. * @param index Index of the track run in the fragment. * @param decodeTime The decode time of the first sample in the fragment run. * @param flags Flags to allow any required workaround to be executed. * @param trun The trun atom to decode. * @return The starting position of samples for the next run. */ - private static int parseTrun(TrackBundle trackBundle, int index, long decodeTime, - @Flags int flags, ParsableByteArray trun, int trackRunStart) { + private static int parseTrun( + TrackBundle trackBundle, + int index, + long decodeTime, + @Flags int flags, + ParsableByteArray trun, + int trackRunStart) + throws ParserException { trun.setPosition(Atom.HEADER_SIZE); int fullAtom = trun.readInt(); int atomFlags = Atom.parseFullAtomFlags(fullAtom); @@ -937,7 +951,7 @@ public class FragmentedMp4Extractor implements Extractor { boolean firstSampleFlagsPresent = (atomFlags & 0x04 /* first_sample_flags_present */) != 0; int firstSampleFlags = defaultSampleValues.flags; if (firstSampleFlagsPresent) { - firstSampleFlags = trun.readUnsignedIntToInt(); + firstSampleFlags = trun.readInt(); } boolean sampleDurationsPresent = (atomFlags & 0x100 /* sample_duration_present */) != 0; @@ -948,20 +962,20 @@ public class FragmentedMp4Extractor implements Extractor { // Offset to the entire video timeline. In the presence of B-frames this is usually used to // ensure that the first frame's presentation timestamp is zero. - long edtsOffset = 0; + long edtsOffsetUs = 0; // Currently we only support a single edit that moves the entire media timeline (indicated by // duration == 0). Other uses of edit lists are uncommon and unsupported. if (track.editListDurations != null && track.editListDurations.length == 1 && track.editListDurations[0] == 0) { - edtsOffset = + edtsOffsetUs = Util.scaleLargeTimestamp( - track.editListMediaTimes[0], C.MILLIS_PER_SECOND, track.timescale); + track.editListMediaTimes[0], C.MICROS_PER_SECOND, track.timescale); } int[] sampleSizeTable = fragment.sampleSizeTable; - int[] sampleCompositionTimeOffsetTable = fragment.sampleCompositionTimeOffsetTable; - long[] sampleDecodingTimeTable = fragment.sampleDecodingTimeTable; + int[] sampleCompositionTimeOffsetUsTable = fragment.sampleCompositionTimeOffsetUsTable; + long[] sampleDecodingTimeUsTable = fragment.sampleDecodingTimeUsTable; boolean[] sampleIsSyncFrameTable = fragment.sampleIsSyncFrameTable; boolean workaroundEveryVideoFrameIsSyncFrame = track.type == C.TRACK_TYPE_VIDEO @@ -972,9 +986,10 @@ public class FragmentedMp4Extractor implements Extractor { long cumulativeTime = index > 0 ? fragment.nextFragmentDecodeTime : decodeTime; for (int i = trackRunStart; i < trackRunEnd; i++) { // Use trun values if present, otherwise tfhd, otherwise trex. - int sampleDuration = sampleDurationsPresent ? trun.readUnsignedIntToInt() - : defaultSampleValues.duration; - int sampleSize = sampleSizesPresent ? trun.readUnsignedIntToInt() : defaultSampleValues.size; + int sampleDuration = + checkNonNegative(sampleDurationsPresent ? trun.readInt() : defaultSampleValues.duration); + int sampleSize = + checkNonNegative(sampleSizesPresent ? trun.readInt() : defaultSampleValues.size); int sampleFlags = (i == 0 && firstSampleFlagsPresent) ? firstSampleFlags : sampleFlagsPresent ? trun.readInt() : defaultSampleValues.flags; if (sampleCompositionTimeOffsetsPresent) { @@ -984,13 +999,13 @@ public class FragmentedMp4Extractor implements Extractor { // here, because unsigned integers will still be parsed correctly (unless their top bit is // set, which is never true in practice because sample offsets are always small). int sampleOffset = trun.readInt(); - sampleCompositionTimeOffsetTable[i] = - (int) ((sampleOffset * C.MILLIS_PER_SECOND) / timescale); + sampleCompositionTimeOffsetUsTable[i] = + (int) ((sampleOffset * C.MICROS_PER_SECOND) / timescale); } else { - sampleCompositionTimeOffsetTable[i] = 0; + sampleCompositionTimeOffsetUsTable[i] = 0; } - sampleDecodingTimeTable[i] = - Util.scaleLargeTimestamp(cumulativeTime, C.MILLIS_PER_SECOND, timescale) - edtsOffset; + sampleDecodingTimeUsTable[i] = + Util.scaleLargeTimestamp(cumulativeTime, C.MICROS_PER_SECOND, timescale) - edtsOffsetUs; sampleSizeTable[i] = sampleSize; sampleIsSyncFrameTable[i] = ((sampleFlags >> 16) & 0x1) == 0 && (!workaroundEveryVideoFrameIsSyncFrame || i == 0); @@ -1000,6 +1015,13 @@ public class FragmentedMp4Extractor implements Extractor { return trackRunEnd; } + private static int checkNonNegative(int value) throws ParserException { + if (value < 0) { + throw new ParserException("Unexpected negtive value: " + value); + } + return value; + } + private static void parseUuid(ParsableByteArray uuid, TrackFragment out, byte[] extendedTypeScratch) throws ParserException { uuid.setPosition(Atom.HEADER_SIZE); @@ -1269,7 +1291,7 @@ public class FragmentedMp4Extractor implements Extractor { Track track = currentTrackBundle.track; TrackOutput output = currentTrackBundle.output; int sampleIndex = currentTrackBundle.currentSampleIndex; - long sampleTimeUs = fragment.getSamplePresentationTime(sampleIndex) * 1000L; + long sampleTimeUs = fragment.getSamplePresentationTimeUs(sampleIndex); if (timestampAdjuster != null) { sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs); } @@ -1513,10 +1535,9 @@ public class FragmentedMp4Extractor implements Extractor { * @param timeUs The seek time, in microseconds. */ public void seek(long timeUs) { - long timeMs = C.usToMs(timeUs); int searchIndex = currentSampleIndex; while (searchIndex < fragment.sampleCount - && fragment.getSamplePresentationTime(searchIndex) < timeMs) { + && fragment.getSamplePresentationTimeUs(searchIndex) < timeUs) { if (fragment.sampleIsSyncFrameTable[searchIndex]) { firstSampleToOutputIndex = searchIndex; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java index 732a69cecd..4f65836b76 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -28,7 +28,6 @@ import com.google.android.exoplayer2.metadata.id3.InternalFrame; import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ParsableByteArray; -import java.nio.ByteBuffer; /** Utilities for handling metadata in MP4. */ /* package */ final class MetadataUtil { @@ -282,7 +281,6 @@ import java.nio.ByteBuffer; private static final int TYPE_TOP_BYTE_REPLACEMENT = 0xFD; // Truncated value of \uFFFD. private static final String MDTA_KEY_ANDROID_CAPTURE_FPS = "com.android.capture.fps"; - private static final int MDTA_TYPE_INDICATOR_FLOAT = 23; private MetadataUtil() {} @@ -312,15 +310,8 @@ import java.nio.ByteBuffer; Metadata.Entry entry = mdtaMetadata.get(i); if (entry instanceof MdtaMetadataEntry) { MdtaMetadataEntry mdtaMetadataEntry = (MdtaMetadataEntry) entry; - if (MDTA_KEY_ANDROID_CAPTURE_FPS.equals(mdtaMetadataEntry.key) - && mdtaMetadataEntry.typeIndicator == MDTA_TYPE_INDICATOR_FLOAT) { - try { - float fps = ByteBuffer.wrap(mdtaMetadataEntry.value).asFloatBuffer().get(); - format = format.copyWithFrameRate(fps); - format = format.copyWithMetadata(new Metadata(mdtaMetadataEntry)); - } catch (NumberFormatException e) { - Log.w(TAG, "Ignoring invalid framerate"); - } + if (MDTA_KEY_ANDROID_CAPTURE_FPS.equals(mdtaMetadataEntry.key)) { + format = format.copyWithMetadata(new Metadata(mdtaMetadataEntry)); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java index 51ec2bf282..0272e8e338 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java @@ -60,14 +60,10 @@ import java.io.IOException; * The size of each sample in the fragment. */ public int[] sampleSizeTable; - /** - * The composition time offset of each sample in the fragment. - */ - public int[] sampleCompositionTimeOffsetTable; - /** - * The decoding time of each sample in the fragment. - */ - public long[] sampleDecodingTimeTable; + /** The composition time offset of each sample in the fragment, in microseconds. */ + public int[] sampleCompositionTimeOffsetUsTable; + /** The decoding time of each sample in the fragment, in microseconds. */ + public long[] sampleDecodingTimeUsTable; /** * Indicates which samples are sync frames. */ @@ -139,8 +135,8 @@ import java.io.IOException; // likely. The choice of 25% is relatively arbitrary. int tableSize = (sampleCount * 125) / 100; sampleSizeTable = new int[tableSize]; - sampleCompositionTimeOffsetTable = new int[tableSize]; - sampleDecodingTimeTable = new long[tableSize]; + sampleCompositionTimeOffsetUsTable = new int[tableSize]; + sampleDecodingTimeUsTable = new long[tableSize]; sampleIsSyncFrameTable = new boolean[tableSize]; sampleHasSubsampleEncryptionTable = new boolean[tableSize]; } @@ -186,8 +182,14 @@ import java.io.IOException; sampleEncryptionDataNeedsFill = false; } - public long getSamplePresentationTime(int index) { - return sampleDecodingTimeTable[index] + sampleCompositionTimeOffsetTable[index]; + /** + * Returns the sample presentation timestamp in microseconds. + * + * @param index The sample index. + * @return The presentation timestamps of this sample in microseconds. + */ + public long getSamplePresentationTimeUs(int index) { + return sampleDecodingTimeUsTable[index] + sampleCompositionTimeOffsetUsTable[index]; } /** Returns whether the sample at the given index has a subsample encryption table. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index 88bde53746..b4007ea4a4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -41,6 +41,7 @@ public final class H265Reader implements ElementaryStreamReader { private static final int VPS_NUT = 32; private static final int SPS_NUT = 33; private static final int PPS_NUT = 34; + private static final int AUD_NUT = 35; private static final int PREFIX_SEI_NUT = 39; private static final int SUFFIX_SEI_NUT = 40; @@ -59,7 +60,7 @@ public final class H265Reader implements ElementaryStreamReader { private final NalUnitTargetBuffer sps; private final NalUnitTargetBuffer pps; private final NalUnitTargetBuffer prefixSei; - private final NalUnitTargetBuffer suffixSei; // TODO: Are both needed? + private final NalUnitTargetBuffer suffixSei; private long totalBytesWritten; // Per packet state that gets reset at the start of each packet. @@ -161,9 +162,8 @@ public final class H265Reader implements ElementaryStreamReader { } private void startNalUnit(long position, int offset, int nalUnitType, long pesTimeUs) { - if (hasOutputFormat) { - sampleReader.startNalUnit(position, offset, nalUnitType, pesTimeUs); - } else { + sampleReader.startNalUnit(position, offset, nalUnitType, pesTimeUs, hasOutputFormat); + if (!hasOutputFormat) { vps.startNalUnit(nalUnitType); sps.startNalUnit(nalUnitType); pps.startNalUnit(nalUnitType); @@ -173,9 +173,8 @@ public final class H265Reader implements ElementaryStreamReader { } private void nalUnitData(byte[] dataArray, int offset, int limit) { - if (hasOutputFormat) { - sampleReader.readNalUnitData(dataArray, offset, limit); - } else { + sampleReader.readNalUnitData(dataArray, offset, limit); + if (!hasOutputFormat) { vps.appendToNalUnit(dataArray, offset, limit); sps.appendToNalUnit(dataArray, offset, limit); pps.appendToNalUnit(dataArray, offset, limit); @@ -185,9 +184,8 @@ public final class H265Reader implements ElementaryStreamReader { } private void endNalUnit(long position, int offset, int discardPadding, long pesTimeUs) { - if (hasOutputFormat) { - sampleReader.endNalUnit(position, offset); - } else { + sampleReader.endNalUnit(position, offset, hasOutputFormat); + if (!hasOutputFormat) { vps.endNalUnit(discardPadding); sps.endNalUnit(discardPadding); pps.endNalUnit(discardPadding); @@ -400,17 +398,17 @@ public final class H265Reader implements ElementaryStreamReader { private final TrackOutput output; // Per NAL unit state. A sample consists of one or more NAL units. - private long nalUnitStartPosition; + private long nalUnitPosition; private boolean nalUnitHasKeyframeData; private int nalUnitBytesRead; private long nalUnitTimeUs; private boolean lookingForFirstSliceFlag; private boolean isFirstSlice; - private boolean isFirstParameterSet; + private boolean isFirstPrefixNalUnit; // Per sample state that gets reset at the start of each sample. private boolean readingSample; - private boolean writingParameterSets; + private boolean readingPrefix; private long samplePosition; private long sampleTimeUs; private boolean sampleIsKeyframe; @@ -422,32 +420,33 @@ public final class H265Reader implements ElementaryStreamReader { public void reset() { lookingForFirstSliceFlag = false; isFirstSlice = false; - isFirstParameterSet = false; + isFirstPrefixNalUnit = false; readingSample = false; - writingParameterSets = false; + readingPrefix = false; } - public void startNalUnit(long position, int offset, int nalUnitType, long pesTimeUs) { + public void startNalUnit( + long position, int offset, int nalUnitType, long pesTimeUs, boolean hasOutputFormat) { isFirstSlice = false; - isFirstParameterSet = false; + isFirstPrefixNalUnit = false; nalUnitTimeUs = pesTimeUs; nalUnitBytesRead = 0; - nalUnitStartPosition = position; + nalUnitPosition = position; - if (nalUnitType >= VPS_NUT) { - if (!writingParameterSets && readingSample) { - // This is a non-VCL NAL unit, so flush the previous sample. - outputSample(offset); + if (!isVclBodyNalUnit(nalUnitType)) { + if (readingSample && !readingPrefix) { + if (hasOutputFormat) { + outputSample(offset); + } readingSample = false; } - if (nalUnitType <= PPS_NUT) { - // This sample will have parameter sets at the start. - isFirstParameterSet = !writingParameterSets; - writingParameterSets = true; + if (isPrefixNalUnit(nalUnitType)) { + isFirstPrefixNalUnit = !readingPrefix; + readingPrefix = true; } } - // Look for the flag if this NAL unit contains a slice_segment_layer_rbsp. + // Look for the first slice flag if this NAL unit contains a slice_segment_layer_rbsp. nalUnitHasKeyframeData = (nalUnitType >= BLA_W_LP && nalUnitType <= CRA_NUT); lookingForFirstSliceFlag = nalUnitHasKeyframeData || nalUnitType <= RASL_R; } @@ -464,31 +463,39 @@ public final class H265Reader implements ElementaryStreamReader { } } - public void endNalUnit(long position, int offset) { - if (writingParameterSets && isFirstSlice) { + public void endNalUnit(long position, int offset, boolean hasOutputFormat) { + if (readingPrefix && isFirstSlice) { // This sample has parameter sets. Reset the key-frame flag based on the first slice. sampleIsKeyframe = nalUnitHasKeyframeData; - writingParameterSets = false; - } else if (isFirstParameterSet || isFirstSlice) { + readingPrefix = false; + } else if (isFirstPrefixNalUnit || isFirstSlice) { // This NAL unit is at the start of a new sample (access unit). - if (readingSample) { + if (hasOutputFormat && readingSample) { // Output the sample ending before this NAL unit. - int nalUnitLength = (int) (position - nalUnitStartPosition); + int nalUnitLength = (int) (position - nalUnitPosition); outputSample(offset + nalUnitLength); } - samplePosition = nalUnitStartPosition; + samplePosition = nalUnitPosition; sampleTimeUs = nalUnitTimeUs; - readingSample = true; sampleIsKeyframe = nalUnitHasKeyframeData; + readingSample = true; } } private void outputSample(int offset) { @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; - int size = (int) (nalUnitStartPosition - samplePosition); + int size = (int) (nalUnitPosition - samplePosition); output.sampleMetadata(sampleTimeUs, flags, size, offset, null); } - } + /** Returns whether a NAL unit type is one that occurs before any VCL NAL units in a sample. */ + private static boolean isPrefixNalUnit(int nalUnitType) { + return (VPS_NUT <= nalUnitType && nalUnitType <= AUD_NUT) || nalUnitType == PREFIX_SEI_NUT; + } + /** Returns whether a NAL unit type is one that occurs in the VLC body of a sample. */ + private static boolean isVclBodyNalUnit(int nalUnitType) { + return nalUnitType < VPS_NUT || nalUnitType == SUFFIX_SEI_NUT; + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index 2cd7398d7c..2bd5b12551 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -460,10 +460,15 @@ public final class TsExtractor implements Extractor { // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. return; } - // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12), - // transport_stream_id (16), reserved (2), version_number (5), current_next_indicator (1), - // section_number (8), last_section_number (8) - sectionData.skipBytes(7); + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.5. + return; + } + // section_length(8), transport_stream_id (16), reserved (2), version_number (5), + // current_next_indicator (1), section_number (8), last_section_number (8) + sectionData.skipBytes(6); int programCount = sectionData.bytesLeft() / 4; for (int i = 0; i < programCount; i++) { @@ -535,8 +540,14 @@ public final class TsExtractor implements Extractor { timestampAdjusters.add(timestampAdjuster); } - // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12) - sectionData.skipBytes(2); + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(4) + int secondHeaderByte = sectionData.readUnsignedByte(); + if ((secondHeaderByte & 0x80) == 0) { + // section_syntax_indicator must be 1. See ISO/IEC 13818-1, section 2.4.4.9. + return; + } + // section_length(8) + sectionData.skipBytes(1); int programNumber = sectionData.readUnsignedShort(); // Skip 3 bytes (24 bits), including: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java index 64517feec9..60c29f6183 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java @@ -573,7 +573,9 @@ public final class MediaCodecInfo { width = alignedSize.x; height = alignedSize.y; - if (frameRate == Format.NO_VALUE || frameRate <= 0) { + // VideoCapabilities.areSizeAndRateSupported incorrectly returns false if frameRate < 1 on some + // versions of Android, so we only check the size in this case [Internal ref: b/153940404]. + if (frameRate == Format.NO_VALUE || frameRate < 1) { return capabilities.isSizeSupported(width, height); } else { // The signaled frame rate may be slightly higher than the actual frame rate, so we take the diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index 819478b80e..f78e9bb545 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -1022,7 +1022,7 @@ public abstract class DownloadService extends Service { try { Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); context.startService(intent); - } catch (IllegalArgumentException e) { + } catch (IllegalStateException e) { // The process is classed as idle by the platform. Starting a background service is not // allowed in this state. Log.w(TAG, "Failed to restart DownloadService (process is idle)."); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java index 8919a26720..4e2c83d5d6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -129,8 +129,9 @@ public final class Requirements implements Parcelable { } ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo networkInfo = Assertions.checkNotNull(connectivityManager).getActiveNetworkInfo(); + (ConnectivityManager) + Assertions.checkNotNull(context.getSystemService(Context.CONNECTIVITY_SERVICE)); + @Nullable NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); if (networkInfo == null || !networkInfo.isConnected() || !isInternetConnectivityValidated(connectivityManager)) { @@ -156,23 +157,27 @@ public final class Requirements implements Parcelable { } private boolean isDeviceIdle(Context context) { - PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + PowerManager powerManager = + (PowerManager) Assertions.checkNotNull(context.getSystemService(Context.POWER_SERVICE)); return Util.SDK_INT >= 23 ? powerManager.isDeviceIdleMode() : Util.SDK_INT >= 20 ? !powerManager.isInteractive() : !powerManager.isScreenOn(); } private static boolean isInternetConnectivityValidated(ConnectivityManager connectivityManager) { - // It's possible to query NetworkCapabilities from API level 23, but RequirementsWatcher only - // fires an event to update its Requirements when NetworkCapabilities change from API level 24. - // Since Requirements won't be updated, we assume connectivity is validated on API level 23. + // It's possible to check NetworkCapabilities.NET_CAPABILITY_VALIDATED from API level 23, but + // RequirementsWatcher only fires an event to re-check the requirements when NetworkCapabilities + // change from API level 24. We assume that network capability is validated for API level 23 to + // keep in sync. if (Util.SDK_INT < 24) { return true; } - Network activeNetwork = connectivityManager.getActiveNetwork(); + + @Nullable Network activeNetwork = connectivityManager.getActiveNetwork(); if (activeNetwork == null) { return false; } + @Nullable NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork); return networkCapabilities != null diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java index f55978c28a..80015cf3a7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -150,6 +150,23 @@ public final class RequirementsWatcher { } } + /** + * Re-checks the requirements if there are network requirements that are currently not met. + * + *

When we receive an event that implies newly established network connectivity, we re-check + * the requirements by calling {@link #checkRequirements()}. This check sometimes sees that there + * is still no active network, meaning that any network requirements will remain not met. By + * calling this method when we receive other events that imply continued network connectivity, we + * can detect that the requirements are met once an active network does exist. + */ + private void recheckNotMetNetworkRequirements() { + if ((notMetRequirements & (Requirements.NETWORK | Requirements.NETWORK_UNMETERED)) == 0) { + // No unmet network requirements to recheck. + return; + } + checkRequirements(); + } + private class DeviceStatusChangeReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { @@ -161,17 +178,25 @@ public final class RequirementsWatcher { @RequiresApi(24) private final class NetworkCallback extends ConnectivityManager.NetworkCallback { - boolean receivedCapabilitiesChange; - boolean networkValidated; + + private boolean receivedCapabilitiesChange; + private boolean networkValidated; @Override public void onAvailable(Network network) { - onNetworkCallback(); + postCheckRequirements(); } @Override public void onLost(Network network) { - onNetworkCallback(); + postCheckRequirements(); + } + + @Override + public void onBlockedStatusChanged(Network network, boolean blocked) { + if (!blocked) { + postRecheckNotMetNetworkRequirements(); + } } @Override @@ -181,11 +206,13 @@ public final class RequirementsWatcher { if (!receivedCapabilitiesChange || this.networkValidated != networkValidated) { receivedCapabilitiesChange = true; this.networkValidated = networkValidated; - onNetworkCallback(); + postCheckRequirements(); + } else if (networkValidated) { + postRecheckNotMetNetworkRequirements(); } } - private void onNetworkCallback() { + private void postCheckRequirements() { handler.post( () -> { if (networkCallback != null) { @@ -193,5 +220,14 @@ public final class RequirementsWatcher { } }); } + + private void postRecheckNotMetNetworkRequirements() { + handler.post( + () -> { + if (networkCallback != null) { + recheckNotMetNetworkRequirements(); + } + }); + } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java index 891cb351c1..47279f2358 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -293,7 +293,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { } /** Dummy placeholder timeline with one dynamic window with a period of indeterminate duration. */ - private static final class DummyTimeline extends Timeline { + public static final class DummyTimeline extends Timeline { @Nullable private final Object tag; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 966a58bf5f..277e17410d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -679,7 +679,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return sampleQueues[i]; } } - SampleQueue trackOutput = new SampleQueue(allocator, drmSessionManager); + SampleQueue trackOutput = new SampleQueue( + allocator, /* playbackLooper= */ handler.getLooper(), drmSessionManager); trackOutput.setUpstreamFormatChangeListener(this); @NullableType TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1); @@ -729,6 +730,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackFormat = trackFormat.copyWithBitrate(icyHeaders.bitrate); } } + if (trackFormat.drmInitData != null) { + trackFormat = + trackFormat.copyWithExoMediaCryptoType( + drmSessionManager.getExoMediaCryptoType(trackFormat.drmInitData)); + } trackArray[i] = new TrackGroup(trackFormat); } isLive = length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java index 512fbce4a2..b48e7835ab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -174,7 +174,10 @@ public final class ProgressiveMediaSource extends BaseMediaSource @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index b5cfe6ed72..c63b755f4a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -55,6 +55,7 @@ public class SampleQueue implements TrackOutput { private final SampleExtrasHolder extrasHolder; private final DrmSessionManager drmSessionManager; private UpstreamFormatChangedListener upstreamFormatChangeListener; + private final Looper playbackLooper; @Nullable private Format downstreamFormat; @Nullable private DrmSession currentDrmSession; @@ -91,11 +92,13 @@ public class SampleQueue implements TrackOutput { * Creates a sample queue. * * @param allocator An {@link Allocator} from which allocations for sample data can be obtained. + * @param playbackLooper The looper associated with the media playback thread. * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} * from. The created instance does not take ownership of this {@link DrmSessionManager}. */ - public SampleQueue(Allocator allocator, DrmSessionManager drmSessionManager) { + public SampleQueue(Allocator allocator, Looper playbackLooper, DrmSessionManager drmSessionManager) { sampleDataQueue = new SampleDataQueue(allocator); + this.playbackLooper = playbackLooper; this.drmSessionManager = drmSessionManager; extrasHolder = new SampleExtrasHolder(); capacity = SAMPLE_CAPACITY_INCREMENT; @@ -789,8 +792,7 @@ public class SampleQueue implements TrackOutput { } // Ensure we acquire the new session before releasing the previous one in case the same session // is being used for both DrmInitData. - DrmSession previousSession = currentDrmSession; - Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); + @Nullable DrmSession previousSession = currentDrmSession; currentDrmSession = newDrmInitData != null ? drmSessionManager.acquireSession(playbackLooper, newDrmInitData) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java index abaf33633e..773eba732b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -33,6 +33,42 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Media source with a single period consisting of silent raw audio of a given duration. */ public final class SilenceMediaSource extends BaseMediaSource { + /** Factory for {@link SilenceMediaSource SilenceMediaSources}. */ + public static final class Factory { + + private long durationUs; + @Nullable private Object tag; + + /** + * Sets the duration of the silent audio. + * + * @param durationUs The duration of silent audio to output, in microseconds. + * @return This factory, for convenience. + */ + public Factory setDurationUs(long durationUs) { + this.durationUs = durationUs; + return this; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * com.google.android.exoplayer2.Timeline} of the source as {@link + * com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + */ + public Factory setTag(@Nullable Object tag) { + this.tag = tag; + return this; + } + + /** Creates a new {@link SilenceMediaSource}. */ + public SilenceMediaSource createMediaSource() { + return new SilenceMediaSource(durationUs, tag); + } + } + private static final int SAMPLE_RATE_HZ = 44100; @C.PcmEncoding private static final int ENCODING = C.ENCODING_PCM_16BIT; private static final int CHANNEL_COUNT = 2; @@ -54,6 +90,7 @@ public final class SilenceMediaSource extends BaseMediaSource { new byte[Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT) * 1024]; private final long durationUs; + @Nullable private final Object tag; /** * Creates a new media source providing silent audio of the given duration. @@ -61,15 +98,25 @@ public final class SilenceMediaSource extends BaseMediaSource { * @param durationUs The duration of silent audio to output, in microseconds. */ public SilenceMediaSource(long durationUs) { + this(durationUs, /* tag= */ null); + } + + private SilenceMediaSource(long durationUs, @Nullable Object tag) { Assertions.checkArgument(durationUs >= 0); this.durationUs = durationUs; + this.tag = tag; } @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { refreshSourceInfo( new SinglePeriodTimeline( - durationUs, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false)); + durationUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* manifest= */ null, + tag)); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index 0a1628b3f9..783a452b1a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -29,8 +29,7 @@ import java.util.Arrays; import org.checkerframework.checker.nullness.compatqual.NullableType; /** - * Represents ad group times relative to the start of the media and information on the state and - * URIs of ads within each ad group. + * Represents ad group times and information on the state and URIs of ads within each ad group. * *

Instances are immutable. Call the {@code with*} methods to get new instances that have the * required changes. @@ -272,8 +271,9 @@ public final class AdPlaybackState { /** The number of ad groups. */ public final int adGroupCount; /** - * The times of ad groups, in microseconds. A final element with the value {@link - * C#TIME_END_OF_SOURCE} indicates a postroll ad. + * The times of ad groups, in microseconds, relative to the start of the {@link + * com.google.android.exoplayer2.Timeline.Period} they belong to. A final element with the value + * {@link C#TIME_END_OF_SOURCE} indicates a postroll ad. */ public final long[] adGroupTimesUs; /** The ad groups. */ @@ -286,8 +286,9 @@ public final class AdPlaybackState { /** * Creates a new ad playback state with the specified ad group times. * - * @param adGroupTimesUs The times of ad groups in microseconds. A final element with the value - * {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad. + * @param adGroupTimesUs The times of ad groups in microseconds, relative to the start of the + * {@link com.google.android.exoplayer2.Timeline.Period} they belong to. A final element with + * the value {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad. */ public AdPlaybackState(long... adGroupTimesUs) { int count = adGroupTimesUs.length; @@ -315,16 +316,18 @@ public final class AdPlaybackState { * unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code positionUs} has no * ads remaining to be played, or if there is no such ad group. * - * @param positionUs The position at or before which to find an ad group, in microseconds, or - * {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case the index of any + * @param positionUs The period position at or before which to find an ad group, in microseconds, + * or {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case the index of any * unplayed postroll ad group will be returned). + * @param periodDurationUs The duration of the containing timeline period, in microseconds, or + * {@link C#TIME_UNSET} if not known. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ - public int getAdGroupIndexForPositionUs(long positionUs) { + public int getAdGroupIndexForPositionUs(long positionUs, long periodDurationUs) { // Use a linear search as the array elements may not be increasing due to TIME_END_OF_SOURCE. // In practice we expect there to be few ad groups so the search shouldn't be expensive. int index = adGroupTimesUs.length - 1; - while (index >= 0 && isPositionBeforeAdGroup(positionUs, index)) { + while (index >= 0 && isPositionBeforeAdGroup(positionUs, periodDurationUs, index)) { index--; } return index >= 0 && adGroups[index].hasUnplayedAds() ? index : C.INDEX_UNSET; @@ -334,11 +337,11 @@ public final class AdPlaybackState { * Returns the index of the next ad group after {@code positionUs} that has ads remaining to be * played. Returns {@link C#INDEX_UNSET} if there is no such ad group. * - * @param positionUs The position after which to find an ad group, in microseconds, or {@link - * C#TIME_END_OF_SOURCE} for the end of the stream (in which case there can be no ad group - * after the position). - * @param periodDurationUs The duration of the containing period in microseconds, or {@link - * C#TIME_UNSET} if not known. + * @param positionUs The period position after which to find an ad group, in microseconds, or + * {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case there can be no ad + * group after the position). + * @param periodDurationUs The duration of the containing timeline period, in microseconds, or + * {@link C#TIME_UNSET} if not known. * @return The index of the ad group, or {@link C#INDEX_UNSET}. */ public int getAdGroupIndexAfterPositionUs(long positionUs, long periodDurationUs) { @@ -357,6 +360,18 @@ public final class AdPlaybackState { return index < adGroupTimesUs.length ? index : C.INDEX_UNSET; } + /** Returns whether the specified ad has been marked as in {@link #AD_STATE_ERROR}. */ + public boolean isAdInErrorState(int adGroupIndex, int adIndexInAdGroup) { + if (adGroupIndex >= adGroups.length) { + return false; + } + AdGroup adGroup = adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET || adIndexInAdGroup >= adGroup.count) { + return false; + } + return adGroup.states[adIndexInAdGroup] == AdPlaybackState.AD_STATE_ERROR; + } + /** * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. * The ad count must be greater than zero. @@ -425,7 +440,10 @@ public final class AdPlaybackState { return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); } - /** Returns an instance with the specified ad resume position, in microseconds. */ + /** + * Returns an instance with the specified ad resume position, in microseconds, relative to the + * start of the current ad. + */ @CheckResult public AdPlaybackState withAdResumePositionUs(long adResumePositionUs) { if (this.adResumePositionUs == adResumePositionUs) { @@ -471,14 +489,15 @@ public final class AdPlaybackState { return result; } - private boolean isPositionBeforeAdGroup(long positionUs, int adGroupIndex) { + private boolean isPositionBeforeAdGroup( + long positionUs, long periodDurationUs, int adGroupIndex) { if (positionUs == C.TIME_END_OF_SOURCE) { // The end of the content is at (but not before) any postroll ad, and after any other ads. return false; } long adGroupPositionUs = adGroupTimesUs[adGroupIndex]; if (adGroupPositionUs == C.TIME_END_OF_SOURCE) { - return contentDurationUs == C.TIME_UNSET || positionUs < contentDurationUs; + return periodDurationUs == C.TIME_UNSET || positionUs < periodDurationUs; } else { return positionUs < adGroupPositionUs; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 5e22de4320..3481042c98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source.ads; import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -44,10 +45,9 @@ import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * A {@link MediaSource} that inserts ads linearly with a provided content media source. This source @@ -128,15 +128,13 @@ public final class AdsMediaSource extends CompositeMediaSource { private final AdsLoader adsLoader; private final AdsLoader.AdViewProvider adViewProvider; private final Handler mainHandler; - private final Map> maskingMediaPeriodByAdMediaSource; private final Timeline.Period period; // Accessed on the player thread. @Nullable private ComponentListener componentListener; @Nullable private Timeline contentTimeline; @Nullable private AdPlaybackState adPlaybackState; - private @NullableType MediaSource[][] adGroupMediaSources; - private @NullableType Timeline[][] adGroupTimelines; + private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders; /** * Constructs a new source that inserts ads linearly with the content specified by {@code @@ -178,10 +176,8 @@ public final class AdsMediaSource extends CompositeMediaSource { this.adsLoader = adsLoader; this.adViewProvider = adViewProvider; mainHandler = new Handler(Looper.getMainLooper()); - maskingMediaPeriodByAdMediaSource = new HashMap<>(); period = new Timeline.Period(); - adGroupMediaSources = new MediaSource[0][]; - adGroupTimelines = new Timeline[0][]; + adMediaSourceHolders = new AdMediaSourceHolder[0][]; adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes()); } @@ -208,36 +204,21 @@ public final class AdsMediaSource extends CompositeMediaSource { int adIndexInAdGroup = id.adIndexInAdGroup; Uri adUri = Assertions.checkNotNull(adPlaybackState.adGroups[adGroupIndex].uris[adIndexInAdGroup]); - if (adGroupMediaSources[adGroupIndex].length <= adIndexInAdGroup) { + if (adMediaSourceHolders[adGroupIndex].length <= adIndexInAdGroup) { int adCount = adIndexInAdGroup + 1; - adGroupMediaSources[adGroupIndex] = - Arrays.copyOf(adGroupMediaSources[adGroupIndex], adCount); - adGroupTimelines[adGroupIndex] = Arrays.copyOf(adGroupTimelines[adGroupIndex], adCount); + adMediaSourceHolders[adGroupIndex] = + Arrays.copyOf(adMediaSourceHolders[adGroupIndex], adCount); } - MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup]; - if (mediaSource == null) { - mediaSource = adMediaSourceFactory.createMediaSource(adUri); - adGroupMediaSources[adGroupIndex][adIndexInAdGroup] = mediaSource; - maskingMediaPeriodByAdMediaSource.put(mediaSource, new ArrayList<>()); - prepareChildSource(id, mediaSource); + @Nullable + AdMediaSourceHolder adMediaSourceHolder = + adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]; + if (adMediaSourceHolder == null) { + MediaSource adMediaSource = adMediaSourceFactory.createMediaSource(adUri); + adMediaSourceHolder = new AdMediaSourceHolder(adMediaSource); + adMediaSourceHolders[adGroupIndex][adIndexInAdGroup] = adMediaSourceHolder; + prepareChildSource(id, adMediaSource); } - MaskingMediaPeriod maskingMediaPeriod = - new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs); - maskingMediaPeriod.setPrepareErrorListener( - new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup)); - List mediaPeriods = maskingMediaPeriodByAdMediaSource.get(mediaSource); - if (mediaPeriods == null) { - Object periodUid = - Assertions.checkNotNull(adGroupTimelines[adGroupIndex][adIndexInAdGroup]) - .getUidOfPeriod(/* periodIndex= */ 0); - MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); - maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); - } else { - // Keep track of the masking media period so it can be populated with the real media period - // when the source's info becomes available. - mediaPeriods.add(maskingMediaPeriod); - } - return maskingMediaPeriod; + return adMediaSourceHolder.createMediaPeriod(adUri, id, allocator, startPositionUs); } else { MaskingMediaPeriod mediaPeriod = new MaskingMediaPeriod(contentMediaSource, id, allocator, startPositionUs); @@ -249,12 +230,18 @@ public final class AdsMediaSource extends CompositeMediaSource { @Override public void releasePeriod(MediaPeriod mediaPeriod) { MaskingMediaPeriod maskingMediaPeriod = (MaskingMediaPeriod) mediaPeriod; - List mediaPeriods = - maskingMediaPeriodByAdMediaSource.get(maskingMediaPeriod.mediaSource); - if (mediaPeriods != null) { - mediaPeriods.remove(maskingMediaPeriod); + MediaPeriodId id = maskingMediaPeriod.id; + if (id.isAd()) { + AdMediaSourceHolder adMediaSourceHolder = + Assertions.checkNotNull(adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup]); + adMediaSourceHolder.releaseMediaPeriod(maskingMediaPeriod); + if (adMediaSourceHolder.isInactive()) { + releaseChildSource(id); + adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup] = null; + } + } else { + maskingMediaPeriod.releasePeriod(); } - maskingMediaPeriod.releasePeriod(); } @Override @@ -262,11 +249,9 @@ public final class AdsMediaSource extends CompositeMediaSource { super.releaseSourceInternal(); Assertions.checkNotNull(componentListener).release(); componentListener = null; - maskingMediaPeriodByAdMediaSource.clear(); contentTimeline = null; adPlaybackState = null; - adGroupMediaSources = new MediaSource[0][]; - adGroupTimelines = new Timeline[0][]; + adMediaSourceHolders = new AdMediaSourceHolder[0][]; mainHandler.post(adsLoader::stop); } @@ -276,14 +261,17 @@ public final class AdsMediaSource extends CompositeMediaSource { if (mediaPeriodId.isAd()) { int adGroupIndex = mediaPeriodId.adGroupIndex; int adIndexInAdGroup = mediaPeriodId.adIndexInAdGroup; - onAdSourceInfoRefreshed(mediaSource, adGroupIndex, adIndexInAdGroup, timeline); + Assertions.checkNotNull(adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]) + .handleSourceInfoRefresh(timeline); } else { - onContentSourceInfoRefreshed(timeline); + Assertions.checkArgument(timeline.getPeriodCount() == 1); + contentTimeline = timeline; } + maybeUpdateSourceInfo(); } @Override - protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( MediaPeriodId childId, MediaPeriodId mediaPeriodId) { // The child id for the content period is just DUMMY_CONTENT_MEDIA_PERIOD_ID. That's why we need // to forward the reported mediaPeriodId in this case. @@ -294,42 +282,17 @@ public final class AdsMediaSource extends CompositeMediaSource { private void onAdPlaybackState(AdPlaybackState adPlaybackState) { if (this.adPlaybackState == null) { - adGroupMediaSources = new MediaSource[adPlaybackState.adGroupCount][]; - Arrays.fill(adGroupMediaSources, new MediaSource[0]); - adGroupTimelines = new Timeline[adPlaybackState.adGroupCount][]; - Arrays.fill(adGroupTimelines, new Timeline[0]); + adMediaSourceHolders = new AdMediaSourceHolder[adPlaybackState.adGroupCount][]; + Arrays.fill(adMediaSourceHolders, new AdMediaSourceHolder[0]); } this.adPlaybackState = adPlaybackState; maybeUpdateSourceInfo(); } - private void onContentSourceInfoRefreshed(Timeline timeline) { - Assertions.checkArgument(timeline.getPeriodCount() == 1); - contentTimeline = timeline; - maybeUpdateSourceInfo(); - } - - private void onAdSourceInfoRefreshed(MediaSource mediaSource, int adGroupIndex, - int adIndexInAdGroup, Timeline timeline) { - Assertions.checkArgument(timeline.getPeriodCount() == 1); - adGroupTimelines[adGroupIndex][adIndexInAdGroup] = timeline; - List mediaPeriods = maskingMediaPeriodByAdMediaSource.remove(mediaSource); - if (mediaPeriods != null) { - Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); - for (int i = 0; i < mediaPeriods.size(); i++) { - MaskingMediaPeriod mediaPeriod = mediaPeriods.get(i); - MediaPeriodId adSourceMediaPeriodId = - new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); - mediaPeriod.createPeriod(adSourceMediaPeriodId); - } - } - maybeUpdateSourceInfo(); - } - private void maybeUpdateSourceInfo() { - Timeline contentTimeline = this.contentTimeline; + @Nullable Timeline contentTimeline = this.contentTimeline; if (adPlaybackState != null && contentTimeline != null) { - adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurations(adGroupTimelines, period)); + adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurationsUs()); Timeline timeline = adPlaybackState.adGroupCount == 0 ? contentTimeline @@ -338,19 +301,16 @@ public final class AdsMediaSource extends CompositeMediaSource { } } - private static long[][] getAdDurations( - @NullableType Timeline[][] adTimelines, Timeline.Period period) { - long[][] adDurations = new long[adTimelines.length][]; - for (int i = 0; i < adTimelines.length; i++) { - adDurations[i] = new long[adTimelines[i].length]; - for (int j = 0; j < adTimelines[i].length; j++) { - adDurations[i][j] = - adTimelines[i][j] == null - ? C.TIME_UNSET - : adTimelines[i][j].getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + private long[][] getAdDurationsUs() { + long[][] adDurationsUs = new long[adMediaSourceHolders.length][]; + for (int i = 0; i < adMediaSourceHolders.length; i++) { + adDurationsUs[i] = new long[adMediaSourceHolders[i].length]; + for (int j = 0; j < adMediaSourceHolders[i].length; j++) { + @Nullable AdMediaSourceHolder holder = adMediaSourceHolders[i][j]; + adDurationsUs[i][j] = holder == null ? C.TIME_UNSET : holder.getDurationUs(); } } - return adDurations; + return adDurationsUs; } /** Listener for component events. All methods are called on the main thread. */ @@ -399,7 +359,7 @@ public final class AdsMediaSource extends CompositeMediaSource { dataSpec.uri, /* responseHeaders= */ Collections.emptyMap(), C.DATA_TYPE_AD, - C.TRACK_TYPE_UNKNOWN, + /* elapsedRealtimeMs= */ SystemClock.elapsedRealtime(), /* loadDurationMs= */ 0, /* bytesLoaded= */ 0, error, @@ -436,4 +396,61 @@ public final class AdsMediaSource extends CompositeMediaSource { () -> adsLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception)); } } + + private final class AdMediaSourceHolder { + + private final MediaSource adMediaSource; + private final List activeMediaPeriods; + + @MonotonicNonNull private Timeline timeline; + + public AdMediaSourceHolder(MediaSource adMediaSource) { + this.adMediaSource = adMediaSource; + activeMediaPeriods = new ArrayList<>(); + } + + public MediaPeriod createMediaPeriod( + Uri adUri, MediaPeriodId id, Allocator allocator, long startPositionUs) { + MaskingMediaPeriod maskingMediaPeriod = + new MaskingMediaPeriod(adMediaSource, id, allocator, startPositionUs); + maskingMediaPeriod.setPrepareErrorListener( + new AdPrepareErrorListener(adUri, id.adGroupIndex, id.adIndexInAdGroup)); + activeMediaPeriods.add(maskingMediaPeriod); + if (timeline != null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); + maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); + } + return maskingMediaPeriod; + } + + public void handleSourceInfoRefresh(Timeline timeline) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + if (this.timeline == null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + for (int i = 0; i < activeMediaPeriods.size(); i++) { + MaskingMediaPeriod mediaPeriod = activeMediaPeriods.get(i); + MediaPeriodId adSourceMediaPeriodId = + new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); + mediaPeriod.createPeriod(adSourceMediaPeriodId); + } + } + this.timeline = timeline; + } + + public long getDurationUs() { + return timeline == null + ? C.TIME_UNSET + : timeline.getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + } + + public void releaseMediaPeriod(MaskingMediaPeriod maskingMediaPeriod) { + activeMediaPeriods.remove(maskingMediaPeriod); + maskingMediaPeriod.releasePeriod(); + } + + public boolean isInactive() { + return activeMediaPeriods.isEmpty(); + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index db555b136f..e2278d7f95 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.chunk; +import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -130,13 +131,19 @@ public class ChunkSampleStream implements SampleStream, S int[] trackTypes = new int[1 + embeddedTrackCount]; SampleQueue[] sampleQueues = new SampleQueue[1 + embeddedTrackCount]; - primarySampleQueue = new SampleQueue(allocator, drmSessionManager); + primarySampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + drmSessionManager); trackTypes[0] = primaryTrackType; sampleQueues[0] = primarySampleQueue; for (int i = 0; i < embeddedTrackCount; i++) { SampleQueue sampleQueue = - new SampleQueue(allocator, DrmSessionManager.getDummyDrmSessionManager()); + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + DrmSessionManager.getDummyDrmSessionManager()); embeddedSampleQueues[i] = sampleQueue; sampleQueues[i + 1] = sampleQueue; trackTypes[i + 1] = embeddedTrackTypes[i]; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index 822fd03fdf..5330894dab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -1990,6 +1990,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maxVideoHeight, int maxVideoFrameRate, int maxVideoBitrate) { + if ((format.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { + // Ignore trick-play tracks for now. + return false; + } return isSupported(formatSupport, false) && ((formatSupport & requiredAdaptiveSupport) != 0) && (mimeType == null || Util.areEqual(format.sampleMimeType, mimeType)) @@ -2013,9 +2017,13 @@ public class DefaultTrackSelector extends MappingTrackSelector { params.viewportWidth, params.viewportHeight, params.viewportOrientationMayChange); @Capabilities int[] trackFormatSupport = formatSupports[groupIndex]; for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + Format format = trackGroup.getFormat(trackIndex); + if ((format.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { + // Ignore trick-play tracks for now. + continue; + } if (isSupported(trackFormatSupport[trackIndex], params.exceedRendererCapabilitiesIfNecessary)) { - Format format = trackGroup.getFormat(trackIndex); boolean isWithinConstraints = selectedTrackIndices.contains(trackIndex) && (format.width == Format.NO_VALUE || format.width <= params.maxVideoWidth) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index 2fbed96a85..6cbc17d3e0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -56,19 +56,19 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList /** Default initial Wifi bitrate estimate in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI = - new long[] {5_700_000, 3_500_000, 2_000_000, 1_100_000, 470_000}; + new long[] {5_800_000, 3_500_000, 1_900_000, 1_000_000, 520_000}; /** Default initial 2G bitrate estimates in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_2G = - new long[] {200_000, 148_000, 132_000, 115_000, 95_000}; + new long[] {204_000, 154_000, 139_000, 122_000, 102_000}; /** Default initial 3G bitrate estimates in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_3G = - new long[] {2_200_000, 1_300_000, 970_000, 810_000, 490_000}; + new long[] {2_200_000, 1_150_000, 810_000, 640_000, 450_000}; /** Default initial 4G bitrate estimates in bits per second. */ public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_4G = - new long[] {5_300_000, 3_200_000, 2_000_000, 1_400_000, 690_000}; + new long[] {4_900_000, 2_300_000, 1_500_000, 970_000, 540_000}; /** * Default initial bitrate estimate used when the device is offline or the network type cannot be @@ -203,10 +203,11 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList result.append(C.NETWORK_TYPE_2G, DEFAULT_INITIAL_BITRATE_ESTIMATES_2G[groupIndices[1]]); result.append(C.NETWORK_TYPE_3G, DEFAULT_INITIAL_BITRATE_ESTIMATES_3G[groupIndices[2]]); result.append(C.NETWORK_TYPE_4G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); - // Assume default Wifi bitrate for Ethernet and 5G to prevent using the slower fallback. + // Assume default Wifi and 4G bitrate for Ethernet and 5G, respectively, to prevent using the + // slower fallback. result.append( C.NETWORK_TYPE_ETHERNET, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); - result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); + result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); return result; } @@ -488,244 +489,245 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList private static Map createInitialBitrateCountryGroupAssignment() { HashMap countryGroupAssignment = new HashMap<>(); - countryGroupAssignment.put("AD", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("AE", new int[] {1, 4, 4, 4}); + countryGroupAssignment.put("AD", new int[] {0, 2, 0, 0}); + countryGroupAssignment.put("AE", new int[] {2, 4, 4, 4}); countryGroupAssignment.put("AF", new int[] {4, 4, 3, 3}); - countryGroupAssignment.put("AG", new int[] {3, 1, 0, 1}); - countryGroupAssignment.put("AI", new int[] {1, 0, 0, 3}); + countryGroupAssignment.put("AG", new int[] {4, 2, 2, 3}); + countryGroupAssignment.put("AI", new int[] {0, 3, 2, 4}); countryGroupAssignment.put("AL", new int[] {1, 2, 0, 1}); - countryGroupAssignment.put("AM", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("AO", new int[] {3, 4, 2, 0}); - countryGroupAssignment.put("AR", new int[] {2, 3, 2, 2}); - countryGroupAssignment.put("AS", new int[] {3, 0, 4, 2}); + countryGroupAssignment.put("AM", new int[] {2, 2, 1, 2}); + countryGroupAssignment.put("AO", new int[] {3, 4, 3, 1}); + countryGroupAssignment.put("AQ", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("AR", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("AS", new int[] {2, 2, 4, 2}); countryGroupAssignment.put("AT", new int[] {0, 3, 0, 0}); - countryGroupAssignment.put("AU", new int[] {0, 3, 0, 1}); - countryGroupAssignment.put("AW", new int[] {1, 1, 0, 3}); - countryGroupAssignment.put("AX", new int[] {0, 3, 0, 2}); + countryGroupAssignment.put("AU", new int[] {0, 2, 0, 1}); + countryGroupAssignment.put("AW", new int[] {1, 1, 2, 4}); + countryGroupAssignment.put("AX", new int[] {0, 1, 0, 0}); countryGroupAssignment.put("AZ", new int[] {3, 3, 3, 3}); countryGroupAssignment.put("BA", new int[] {1, 1, 0, 1}); - countryGroupAssignment.put("BB", new int[] {0, 2, 0, 0}); - countryGroupAssignment.put("BD", new int[] {2, 1, 3, 3}); - countryGroupAssignment.put("BE", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("BB", new int[] {0, 3, 0, 0}); + countryGroupAssignment.put("BD", new int[] {2, 0, 4, 3}); + countryGroupAssignment.put("BE", new int[] {0, 1, 2, 3}); countryGroupAssignment.put("BF", new int[] {4, 4, 4, 1}); countryGroupAssignment.put("BG", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("BH", new int[] {2, 1, 3, 4}); + countryGroupAssignment.put("BH", new int[] {1, 0, 3, 4}); countryGroupAssignment.put("BI", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("BJ", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("BL", new int[] {1, 0, 2, 2}); - countryGroupAssignment.put("BM", new int[] {1, 2, 0, 0}); - countryGroupAssignment.put("BN", new int[] {4, 1, 3, 2}); - countryGroupAssignment.put("BO", new int[] {1, 2, 3, 2}); - countryGroupAssignment.put("BQ", new int[] {1, 1, 2, 4}); - countryGroupAssignment.put("BR", new int[] {2, 3, 3, 2}); - countryGroupAssignment.put("BS", new int[] {2, 1, 1, 4}); + countryGroupAssignment.put("BJ", new int[] {4, 4, 3, 4}); + countryGroupAssignment.put("BL", new int[] {1, 0, 4, 3}); + countryGroupAssignment.put("BM", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("BN", new int[] {4, 0, 2, 4}); + countryGroupAssignment.put("BO", new int[] {1, 3, 3, 3}); + countryGroupAssignment.put("BQ", new int[] {1, 0, 1, 0}); + countryGroupAssignment.put("BR", new int[] {2, 4, 3, 1}); + countryGroupAssignment.put("BS", new int[] {3, 1, 1, 3}); countryGroupAssignment.put("BT", new int[] {3, 0, 3, 1}); - countryGroupAssignment.put("BW", new int[] {4, 4, 1, 2}); - countryGroupAssignment.put("BY", new int[] {0, 1, 1, 2}); - countryGroupAssignment.put("BZ", new int[] {2, 2, 2, 1}); - countryGroupAssignment.put("CA", new int[] {0, 3, 1, 3}); - countryGroupAssignment.put("CD", new int[] {4, 4, 2, 2}); - countryGroupAssignment.put("CF", new int[] {4, 4, 3, 0}); - countryGroupAssignment.put("CG", new int[] {3, 4, 2, 4}); - countryGroupAssignment.put("CH", new int[] {0, 0, 1, 0}); + countryGroupAssignment.put("BW", new int[] {3, 4, 3, 3}); + countryGroupAssignment.put("BY", new int[] {0, 1, 1, 1}); + countryGroupAssignment.put("BZ", new int[] {1, 3, 2, 1}); + countryGroupAssignment.put("CA", new int[] {0, 3, 2, 2}); + countryGroupAssignment.put("CD", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("CF", new int[] {4, 3, 2, 2}); + countryGroupAssignment.put("CG", new int[] {3, 4, 1, 1}); + countryGroupAssignment.put("CH", new int[] {0, 0, 0, 0}); countryGroupAssignment.put("CI", new int[] {3, 4, 3, 3}); - countryGroupAssignment.put("CK", new int[] {2, 4, 1, 0}); + countryGroupAssignment.put("CK", new int[] {2, 0, 1, 0}); countryGroupAssignment.put("CL", new int[] {1, 2, 2, 3}); - countryGroupAssignment.put("CM", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("CN", new int[] {2, 0, 2, 3}); - countryGroupAssignment.put("CO", new int[] {2, 3, 2, 2}); - countryGroupAssignment.put("CR", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("CU", new int[] {4, 4, 3, 1}); - countryGroupAssignment.put("CV", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("CM", new int[] {3, 4, 3, 2}); + countryGroupAssignment.put("CN", new int[] {1, 0, 1, 1}); + countryGroupAssignment.put("CO", new int[] {2, 3, 3, 2}); + countryGroupAssignment.put("CR", new int[] {2, 2, 4, 4}); + countryGroupAssignment.put("CU", new int[] {4, 4, 2, 1}); + countryGroupAssignment.put("CV", new int[] {2, 3, 3, 2}); countryGroupAssignment.put("CW", new int[] {1, 1, 0, 0}); countryGroupAssignment.put("CY", new int[] {1, 1, 0, 0}); countryGroupAssignment.put("CZ", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("DE", new int[] {0, 1, 1, 3}); - countryGroupAssignment.put("DJ", new int[] {4, 3, 4, 1}); - countryGroupAssignment.put("DK", new int[] {0, 0, 1, 1}); - countryGroupAssignment.put("DM", new int[] {1, 0, 1, 3}); + countryGroupAssignment.put("DE", new int[] {0, 1, 2, 3}); + countryGroupAssignment.put("DJ", new int[] {4, 2, 4, 4}); + countryGroupAssignment.put("DK", new int[] {0, 0, 1, 0}); + countryGroupAssignment.put("DM", new int[] {1, 1, 0, 2}); countryGroupAssignment.put("DO", new int[] {3, 3, 4, 4}); countryGroupAssignment.put("DZ", new int[] {3, 3, 4, 4}); - countryGroupAssignment.put("EC", new int[] {2, 3, 4, 3}); - countryGroupAssignment.put("EE", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("EG", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("EH", new int[] {2, 0, 3, 3}); - countryGroupAssignment.put("ER", new int[] {4, 2, 2, 0}); + countryGroupAssignment.put("EC", new int[] {2, 3, 4, 2}); + countryGroupAssignment.put("EE", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("EG", new int[] {3, 4, 2, 1}); + countryGroupAssignment.put("EH", new int[] {2, 0, 3, 1}); + countryGroupAssignment.put("ER", new int[] {4, 2, 4, 4}); countryGroupAssignment.put("ES", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("ET", new int[] {4, 4, 4, 0}); + countryGroupAssignment.put("ET", new int[] {4, 4, 4, 1}); countryGroupAssignment.put("FI", new int[] {0, 0, 1, 0}); - countryGroupAssignment.put("FJ", new int[] {3, 0, 3, 3}); - countryGroupAssignment.put("FK", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("FM", new int[] {4, 0, 4, 0}); - countryGroupAssignment.put("FO", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("FR", new int[] {1, 0, 3, 1}); - countryGroupAssignment.put("GA", new int[] {3, 3, 2, 2}); - countryGroupAssignment.put("GB", new int[] {0, 1, 3, 3}); - countryGroupAssignment.put("GD", new int[] {2, 0, 4, 4}); - countryGroupAssignment.put("GE", new int[] {1, 1, 1, 4}); - countryGroupAssignment.put("GF", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("GG", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("GH", new int[] {3, 3, 2, 2}); - countryGroupAssignment.put("GI", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("GL", new int[] {2, 2, 0, 2}); - countryGroupAssignment.put("GM", new int[] {4, 4, 3, 4}); + countryGroupAssignment.put("FJ", new int[] {3, 0, 4, 4}); + countryGroupAssignment.put("FK", new int[] {2, 2, 2, 1}); + countryGroupAssignment.put("FM", new int[] {3, 2, 4, 1}); + countryGroupAssignment.put("FO", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("FR", new int[] {1, 1, 1, 1}); + countryGroupAssignment.put("GA", new int[] {3, 2, 2, 2}); + countryGroupAssignment.put("GB", new int[] {0, 1, 1, 1}); + countryGroupAssignment.put("GD", new int[] {1, 1, 3, 1}); + countryGroupAssignment.put("GE", new int[] {1, 0, 1, 4}); + countryGroupAssignment.put("GF", new int[] {2, 0, 1, 3}); + countryGroupAssignment.put("GG", new int[] {1, 0, 0, 0}); + countryGroupAssignment.put("GH", new int[] {3, 3, 3, 3}); + countryGroupAssignment.put("GI", new int[] {4, 4, 0, 0}); + countryGroupAssignment.put("GL", new int[] {2, 1, 1, 2}); + countryGroupAssignment.put("GM", new int[] {4, 3, 2, 4}); countryGroupAssignment.put("GN", new int[] {3, 4, 4, 2}); - countryGroupAssignment.put("GP", new int[] {2, 1, 1, 4}); - countryGroupAssignment.put("GQ", new int[] {4, 4, 3, 0}); - countryGroupAssignment.put("GR", new int[] {1, 1, 0, 2}); - countryGroupAssignment.put("GT", new int[] {3, 3, 3, 3}); - countryGroupAssignment.put("GU", new int[] {1, 2, 4, 4}); - countryGroupAssignment.put("GW", new int[] {4, 4, 4, 1}); + countryGroupAssignment.put("GP", new int[] {2, 1, 3, 4}); + countryGroupAssignment.put("GQ", new int[] {4, 4, 4, 0}); + countryGroupAssignment.put("GR", new int[] {1, 1, 0, 1}); + countryGroupAssignment.put("GT", new int[] {3, 2, 2, 2}); + countryGroupAssignment.put("GU", new int[] {1, 0, 2, 2}); + countryGroupAssignment.put("GW", new int[] {3, 4, 4, 3}); countryGroupAssignment.put("GY", new int[] {3, 2, 1, 1}); countryGroupAssignment.put("HK", new int[] {0, 2, 3, 4}); - countryGroupAssignment.put("HN", new int[] {3, 2, 3, 2}); + countryGroupAssignment.put("HN", new int[] {3, 1, 3, 3}); countryGroupAssignment.put("HR", new int[] {1, 1, 0, 1}); countryGroupAssignment.put("HT", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("HU", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("ID", new int[] {3, 2, 3, 4}); + countryGroupAssignment.put("ID", new int[] {2, 2, 2, 3}); countryGroupAssignment.put("IE", new int[] {1, 0, 1, 1}); - countryGroupAssignment.put("IL", new int[] {0, 0, 2, 3}); + countryGroupAssignment.put("IL", new int[] {1, 0, 2, 3}); countryGroupAssignment.put("IM", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("IN", new int[] {2, 2, 4, 4}); - countryGroupAssignment.put("IO", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("IN", new int[] {2, 2, 4, 3}); + countryGroupAssignment.put("IO", new int[] {4, 4, 2, 3}); countryGroupAssignment.put("IQ", new int[] {3, 3, 4, 2}); - countryGroupAssignment.put("IR", new int[] {3, 0, 2, 2}); + countryGroupAssignment.put("IR", new int[] {3, 0, 2, 1}); countryGroupAssignment.put("IS", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("IT", new int[] {1, 0, 1, 2}); + countryGroupAssignment.put("IT", new int[] {1, 1, 1, 2}); countryGroupAssignment.put("JE", new int[] {1, 0, 0, 1}); - countryGroupAssignment.put("JM", new int[] {2, 3, 3, 1}); - countryGroupAssignment.put("JO", new int[] {1, 2, 1, 2}); - countryGroupAssignment.put("JP", new int[] {0, 2, 1, 1}); - countryGroupAssignment.put("KE", new int[] {3, 4, 4, 3}); - countryGroupAssignment.put("KG", new int[] {1, 1, 2, 2}); - countryGroupAssignment.put("KH", new int[] {1, 0, 4, 4}); - countryGroupAssignment.put("KI", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("KM", new int[] {4, 3, 2, 3}); - countryGroupAssignment.put("KN", new int[] {1, 0, 1, 3}); - countryGroupAssignment.put("KP", new int[] {4, 2, 4, 2}); - countryGroupAssignment.put("KR", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("KW", new int[] {2, 3, 1, 1}); - countryGroupAssignment.put("KY", new int[] {1, 1, 0, 1}); - countryGroupAssignment.put("KZ", new int[] {1, 2, 2, 3}); + countryGroupAssignment.put("JM", new int[] {3, 3, 3, 4}); + countryGroupAssignment.put("JO", new int[] {1, 2, 1, 1}); + countryGroupAssignment.put("JP", new int[] {0, 2, 0, 0}); + countryGroupAssignment.put("KE", new int[] {3, 4, 3, 3}); + countryGroupAssignment.put("KG", new int[] {2, 0, 2, 2}); + countryGroupAssignment.put("KH", new int[] {1, 0, 4, 3}); + countryGroupAssignment.put("KI", new int[] {4, 4, 4, 0}); + countryGroupAssignment.put("KM", new int[] {4, 3, 2, 4}); + countryGroupAssignment.put("KN", new int[] {1, 0, 2, 4}); + countryGroupAssignment.put("KP", new int[] {4, 2, 0, 2}); + countryGroupAssignment.put("KR", new int[] {0, 1, 0, 1}); + countryGroupAssignment.put("KW", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("KY", new int[] {3, 1, 2, 3}); + countryGroupAssignment.put("KZ", new int[] {1, 2, 2, 2}); countryGroupAssignment.put("LA", new int[] {2, 2, 1, 1}); countryGroupAssignment.put("LB", new int[] {3, 2, 0, 0}); countryGroupAssignment.put("LC", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("LI", new int[] {0, 0, 2, 4}); - countryGroupAssignment.put("LK", new int[] {2, 1, 2, 3}); - countryGroupAssignment.put("LR", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("LS", new int[] {3, 3, 2, 0}); + countryGroupAssignment.put("LI", new int[] {0, 0, 1, 1}); + countryGroupAssignment.put("LK", new int[] {2, 0, 2, 3}); + countryGroupAssignment.put("LR", new int[] {3, 4, 4, 2}); + countryGroupAssignment.put("LS", new int[] {3, 3, 2, 2}); countryGroupAssignment.put("LT", new int[] {0, 0, 0, 0}); countryGroupAssignment.put("LU", new int[] {0, 0, 0, 0}); countryGroupAssignment.put("LV", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("LY", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("MA", new int[] {2, 1, 2, 1}); - countryGroupAssignment.put("MC", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("LY", new int[] {3, 3, 4, 3}); + countryGroupAssignment.put("MA", new int[] {3, 2, 3, 2}); + countryGroupAssignment.put("MC", new int[] {0, 4, 0, 0}); countryGroupAssignment.put("MD", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("ME", new int[] {1, 2, 1, 2}); - countryGroupAssignment.put("MF", new int[] {1, 1, 1, 1}); - countryGroupAssignment.put("MG", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("ME", new int[] {1, 3, 1, 2}); + countryGroupAssignment.put("MF", new int[] {2, 3, 1, 1}); + countryGroupAssignment.put("MG", new int[] {3, 4, 2, 3}); countryGroupAssignment.put("MH", new int[] {4, 0, 2, 4}); countryGroupAssignment.put("MK", new int[] {1, 0, 0, 0}); countryGroupAssignment.put("ML", new int[] {4, 4, 2, 0}); - countryGroupAssignment.put("MM", new int[] {3, 3, 1, 2}); - countryGroupAssignment.put("MN", new int[] {2, 3, 2, 3}); + countryGroupAssignment.put("MM", new int[] {3, 3, 2, 2}); + countryGroupAssignment.put("MN", new int[] {2, 3, 1, 1}); countryGroupAssignment.put("MO", new int[] {0, 0, 4, 4}); - countryGroupAssignment.put("MP", new int[] {0, 2, 4, 4}); - countryGroupAssignment.put("MQ", new int[] {2, 1, 1, 4}); - countryGroupAssignment.put("MR", new int[] {4, 2, 4, 2}); - countryGroupAssignment.put("MS", new int[] {1, 2, 3, 3}); - countryGroupAssignment.put("MT", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("MU", new int[] {2, 2, 3, 4}); - countryGroupAssignment.put("MV", new int[] {4, 3, 0, 2}); - countryGroupAssignment.put("MW", new int[] {3, 2, 1, 0}); - countryGroupAssignment.put("MX", new int[] {2, 4, 4, 3}); - countryGroupAssignment.put("MY", new int[] {2, 2, 3, 3}); - countryGroupAssignment.put("MZ", new int[] {3, 3, 2, 1}); - countryGroupAssignment.put("NA", new int[] {3, 3, 2, 1}); - countryGroupAssignment.put("NC", new int[] {2, 0, 3, 3}); - countryGroupAssignment.put("NE", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("NF", new int[] {1, 2, 2, 2}); - countryGroupAssignment.put("NG", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("NI", new int[] {3, 3, 4, 4}); - countryGroupAssignment.put("NL", new int[] {0, 2, 3, 3}); - countryGroupAssignment.put("NO", new int[] {0, 1, 1, 0}); + countryGroupAssignment.put("MP", new int[] {0, 2, 1, 2}); + countryGroupAssignment.put("MQ", new int[] {2, 1, 1, 3}); + countryGroupAssignment.put("MR", new int[] {4, 2, 4, 4}); + countryGroupAssignment.put("MS", new int[] {1, 4, 3, 4}); + countryGroupAssignment.put("MT", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("MU", new int[] {2, 2, 4, 4}); + countryGroupAssignment.put("MV", new int[] {4, 3, 2, 4}); + countryGroupAssignment.put("MW", new int[] {3, 1, 1, 1}); + countryGroupAssignment.put("MX", new int[] {2, 4, 3, 3}); + countryGroupAssignment.put("MY", new int[] {2, 1, 3, 3}); + countryGroupAssignment.put("MZ", new int[] {3, 3, 3, 3}); + countryGroupAssignment.put("NA", new int[] {4, 3, 3, 3}); + countryGroupAssignment.put("NC", new int[] {2, 0, 4, 4}); + countryGroupAssignment.put("NE", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("NF", new int[] {1, 2, 2, 0}); + countryGroupAssignment.put("NG", new int[] {3, 3, 2, 2}); + countryGroupAssignment.put("NI", new int[] {3, 2, 4, 3}); + countryGroupAssignment.put("NL", new int[] {0, 2, 3, 2}); + countryGroupAssignment.put("NO", new int[] {0, 2, 1, 0}); countryGroupAssignment.put("NP", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("NR", new int[] {4, 0, 3, 1}); + countryGroupAssignment.put("NR", new int[] {4, 0, 3, 2}); countryGroupAssignment.put("NZ", new int[] {0, 0, 1, 2}); - countryGroupAssignment.put("OM", new int[] {3, 2, 1, 3}); - countryGroupAssignment.put("PA", new int[] {1, 3, 3, 4}); - countryGroupAssignment.put("PE", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("PF", new int[] {2, 2, 0, 1}); - countryGroupAssignment.put("PG", new int[] {4, 3, 3, 1}); + countryGroupAssignment.put("OM", new int[] {2, 3, 0, 2}); + countryGroupAssignment.put("PA", new int[] {1, 3, 3, 3}); + countryGroupAssignment.put("PE", new int[] {2, 4, 4, 4}); + countryGroupAssignment.put("PF", new int[] {2, 1, 1, 1}); + countryGroupAssignment.put("PG", new int[] {4, 3, 3, 2}); countryGroupAssignment.put("PH", new int[] {3, 0, 3, 4}); - countryGroupAssignment.put("PK", new int[] {3, 3, 3, 3}); - countryGroupAssignment.put("PL", new int[] {1, 0, 1, 3}); + countryGroupAssignment.put("PK", new int[] {3, 2, 3, 2}); + countryGroupAssignment.put("PL", new int[] {1, 0, 1, 2}); countryGroupAssignment.put("PM", new int[] {0, 2, 2, 0}); - countryGroupAssignment.put("PR", new int[] {1, 2, 3, 3}); - countryGroupAssignment.put("PS", new int[] {3, 3, 2, 4}); + countryGroupAssignment.put("PR", new int[] {2, 2, 2, 2}); + countryGroupAssignment.put("PS", new int[] {3, 3, 1, 4}); countryGroupAssignment.put("PT", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("PW", new int[] {2, 1, 2, 0}); - countryGroupAssignment.put("PY", new int[] {2, 0, 2, 3}); - countryGroupAssignment.put("QA", new int[] {2, 2, 1, 2}); + countryGroupAssignment.put("PW", new int[] {1, 1, 3, 0}); + countryGroupAssignment.put("PY", new int[] {2, 0, 3, 3}); + countryGroupAssignment.put("QA", new int[] {2, 3, 1, 1}); countryGroupAssignment.put("RE", new int[] {1, 0, 2, 2}); countryGroupAssignment.put("RO", new int[] {0, 1, 1, 2}); countryGroupAssignment.put("RS", new int[] {1, 2, 0, 0}); - countryGroupAssignment.put("RU", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("RW", new int[] {4, 4, 2, 4}); + countryGroupAssignment.put("RU", new int[] {0, 1, 0, 1}); + countryGroupAssignment.put("RW", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("SA", new int[] {2, 2, 2, 1}); - countryGroupAssignment.put("SB", new int[] {4, 4, 3, 0}); + countryGroupAssignment.put("SB", new int[] {4, 4, 4, 1}); countryGroupAssignment.put("SC", new int[] {4, 2, 0, 1}); - countryGroupAssignment.put("SD", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("SD", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("SE", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("SG", new int[] {0, 2, 3, 3}); - countryGroupAssignment.put("SH", new int[] {4, 4, 2, 3}); - countryGroupAssignment.put("SI", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("SJ", new int[] {2, 0, 2, 4}); + countryGroupAssignment.put("SG", new int[] {1, 0, 3, 3}); + countryGroupAssignment.put("SH", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("SI", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("SJ", new int[] {2, 2, 2, 4}); countryGroupAssignment.put("SK", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("SL", new int[] {4, 3, 3, 3}); - countryGroupAssignment.put("SM", new int[] {0, 0, 2, 4}); - countryGroupAssignment.put("SN", new int[] {3, 4, 4, 2}); - countryGroupAssignment.put("SO", new int[] {3, 4, 4, 3}); - countryGroupAssignment.put("SR", new int[] {2, 2, 1, 0}); - countryGroupAssignment.put("SS", new int[] {4, 3, 4, 3}); - countryGroupAssignment.put("ST", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("SV", new int[] {2, 3, 3, 4}); + countryGroupAssignment.put("SL", new int[] {4, 3, 3, 1}); + countryGroupAssignment.put("SM", new int[] {0, 0, 1, 2}); + countryGroupAssignment.put("SN", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("SO", new int[] {3, 4, 3, 4}); + countryGroupAssignment.put("SR", new int[] {2, 2, 2, 1}); + countryGroupAssignment.put("SS", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("ST", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("SV", new int[] {2, 2, 4, 4}); countryGroupAssignment.put("SX", new int[] {2, 4, 1, 0}); - countryGroupAssignment.put("SY", new int[] {4, 3, 2, 1}); + countryGroupAssignment.put("SY", new int[] {4, 3, 1, 1}); countryGroupAssignment.put("SZ", new int[] {4, 4, 3, 4}); - countryGroupAssignment.put("TC", new int[] {1, 2, 1, 1}); - countryGroupAssignment.put("TD", new int[] {4, 4, 4, 2}); - countryGroupAssignment.put("TG", new int[] {3, 3, 1, 0}); - countryGroupAssignment.put("TH", new int[] {1, 3, 4, 4}); + countryGroupAssignment.put("TC", new int[] {1, 2, 1, 0}); + countryGroupAssignment.put("TD", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("TG", new int[] {3, 2, 1, 0}); + countryGroupAssignment.put("TH", new int[] {1, 3, 3, 3}); countryGroupAssignment.put("TJ", new int[] {4, 4, 4, 4}); countryGroupAssignment.put("TL", new int[] {4, 2, 4, 4}); - countryGroupAssignment.put("TM", new int[] {4, 1, 2, 2}); - countryGroupAssignment.put("TN", new int[] {2, 2, 1, 2}); - countryGroupAssignment.put("TO", new int[] {3, 3, 3, 1}); - countryGroupAssignment.put("TR", new int[] {2, 2, 1, 2}); - countryGroupAssignment.put("TT", new int[] {1, 3, 1, 2}); - countryGroupAssignment.put("TV", new int[] {4, 2, 2, 4}); + countryGroupAssignment.put("TM", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("TN", new int[] {2, 1, 1, 1}); + countryGroupAssignment.put("TO", new int[] {4, 3, 4, 4}); + countryGroupAssignment.put("TR", new int[] {1, 2, 1, 1}); + countryGroupAssignment.put("TT", new int[] {1, 3, 2, 4}); + countryGroupAssignment.put("TV", new int[] {4, 2, 3, 4}); countryGroupAssignment.put("TW", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("TZ", new int[] {3, 3, 4, 3}); - countryGroupAssignment.put("UA", new int[] {0, 2, 1, 2}); - countryGroupAssignment.put("UG", new int[] {4, 3, 3, 2}); - countryGroupAssignment.put("US", new int[] {1, 1, 3, 3}); - countryGroupAssignment.put("UY", new int[] {2, 2, 1, 1}); - countryGroupAssignment.put("UZ", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("VA", new int[] {1, 2, 4, 2}); - countryGroupAssignment.put("VC", new int[] {2, 0, 2, 4}); - countryGroupAssignment.put("VE", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("VG", new int[] {3, 0, 1, 3}); - countryGroupAssignment.put("VI", new int[] {1, 1, 4, 4}); - countryGroupAssignment.put("VN", new int[] {0, 2, 4, 4}); - countryGroupAssignment.put("VU", new int[] {4, 1, 3, 1}); - countryGroupAssignment.put("WS", new int[] {3, 3, 3, 2}); + countryGroupAssignment.put("TZ", new int[] {3, 4, 3, 3}); + countryGroupAssignment.put("UA", new int[] {0, 3, 1, 1}); + countryGroupAssignment.put("UG", new int[] {3, 2, 2, 3}); + countryGroupAssignment.put("US", new int[] {0, 1, 2, 2}); + countryGroupAssignment.put("UY", new int[] {2, 1, 2, 2}); + countryGroupAssignment.put("UZ", new int[] {2, 2, 3, 2}); + countryGroupAssignment.put("VA", new int[] {0, 2, 2, 2}); + countryGroupAssignment.put("VC", new int[] {2, 3, 0, 2}); + countryGroupAssignment.put("VE", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("VG", new int[] {3, 1, 2, 4}); + countryGroupAssignment.put("VI", new int[] {1, 4, 4, 3}); + countryGroupAssignment.put("VN", new int[] {0, 1, 3, 4}); + countryGroupAssignment.put("VU", new int[] {4, 0, 3, 3}); + countryGroupAssignment.put("WS", new int[] {3, 2, 4, 3}); countryGroupAssignment.put("XK", new int[] {1, 2, 1, 0}); countryGroupAssignment.put("YE", new int[] {4, 4, 4, 3}); countryGroupAssignment.put("YT", new int[] {2, 2, 2, 3}); - countryGroupAssignment.put("ZA", new int[] {2, 4, 2, 2}); - countryGroupAssignment.put("ZM", new int[] {3, 2, 2, 1}); - countryGroupAssignment.put("ZW", new int[] {3, 3, 2, 1}); + countryGroupAssignment.put("ZA", new int[] {2, 3, 2, 2}); + countryGroupAssignment.put("ZM", new int[] {3, 2, 3, 3}); + countryGroupAssignment.put("ZW", new int[] {3, 3, 2, 3}); return Collections.unmodifiableMap(countryGroupAssignment); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java index a498f510dd..5b7846f5ce 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/Loader.java @@ -32,6 +32,7 @@ import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; /** * Manages the background loading of {@link Loadable}s. @@ -56,6 +57,21 @@ public final class Loader implements LoaderErrorThrower { /** * Cancels the load. + * + *

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

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

Although the loading thread will be interrupted, Loadable implementations should not use + * the interrupted status of the loading thread in {@link #load()} to determine whether the load + * has been canceled. This approach is not robust [Internal ref: b/79223737]. Instead, + * implementations should use their own flag to signal cancelation (for example, using {@link + * AtomicBoolean}). */ void cancelLoad(); @@ -309,10 +325,9 @@ public final class Loader implements LoaderErrorThrower { private static final String TAG = "LoadTask"; private static final int MSG_START = 0; - private static final int MSG_CANCEL = 1; - private static final int MSG_END_OF_SOURCE = 2; - private static final int MSG_IO_EXCEPTION = 3; - private static final int MSG_FATAL_ERROR = 4; + private static final int MSG_FINISH = 1; + private static final int MSG_IO_EXCEPTION = 2; + private static final int MSG_FATAL_ERROR = 3; public final int defaultMinRetryCount; @@ -323,8 +338,8 @@ public final class Loader implements LoaderErrorThrower { @Nullable private IOException currentError; private int errorCount; - @Nullable private volatile Thread executorThread; - private volatile boolean canceled; + @Nullable private Thread executorThread; + private boolean canceled; private volatile boolean released; public LoadTask(Looper looper, T loadable, Loader.Callback callback, @@ -356,16 +371,21 @@ public final class Loader implements LoaderErrorThrower { this.released = released; currentError = null; if (hasMessages(MSG_START)) { + // The task has not been given to the executor yet. + canceled = true; removeMessages(MSG_START); if (!released) { - sendEmptyMessage(MSG_CANCEL); + sendEmptyMessage(MSG_FINISH); } } else { - canceled = true; - loadable.cancelLoad(); - Thread executorThread = this.executorThread; - if (executorThread != null) { - executorThread.interrupt(); + // The task has been given to the executor. + synchronized (this) { + canceled = true; + loadable.cancelLoad(); + @Nullable Thread executorThread = this.executorThread; + if (executorThread != null) { + executorThread.interrupt(); + } } } if (released) { @@ -384,8 +404,12 @@ public final class Loader implements LoaderErrorThrower { @Override public void run() { try { - executorThread = Thread.currentThread(); - if (!canceled) { + boolean shouldLoad; + synchronized (this) { + shouldLoad = !canceled; + executorThread = Thread.currentThread(); + } + if (shouldLoad) { TraceUtil.beginSection("load:" + loadable.getClass().getSimpleName()); try { loadable.load(); @@ -393,8 +417,13 @@ public final class Loader implements LoaderErrorThrower { TraceUtil.endSection(); } } + synchronized (this) { + executorThread = null; + // Clear the interrupted flag if set, to avoid it leaking into a subsequent task. + Thread.interrupted(); + } if (!released) { - sendEmptyMessage(MSG_END_OF_SOURCE); + sendEmptyMessage(MSG_FINISH); } } catch (IOException e) { if (!released) { @@ -404,7 +433,7 @@ public final class Loader implements LoaderErrorThrower { // The load was canceled. Assertions.checkState(canceled); if (!released) { - sendEmptyMessage(MSG_END_OF_SOURCE); + sendEmptyMessage(MSG_FINISH); } } catch (Exception e) { // This should never happen, but handle it anyway. @@ -453,10 +482,7 @@ public final class Loader implements LoaderErrorThrower { return; } switch (msg.what) { - case MSG_CANCEL: - callback.onLoadCanceled(loadable, nowMs, durationMs, false); - break; - case MSG_END_OF_SOURCE: + case MSG_FINISH: try { callback.onLoadCompleted(loadable, nowMs, durationMs); } catch (RuntimeException e) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java index ce16ea2439..9f1fc54462 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -109,7 +109,6 @@ public final class CacheUtil { * * @param dataSpec Defines the data to be cached. * @param cache A {@link Cache} to store the data. - * @param cacheKeyFactory An optional factory for cache keys. * @param upstream A {@link DataSource} for reading data not in the cache. * @param progressListener A listener to receive progress updates, or {@code null}. * @param isCanceled An optional flag that will interrupt caching if set to true. @@ -120,7 +119,6 @@ public final class CacheUtil { public static void cache( DataSpec dataSpec, Cache cache, - @Nullable CacheKeyFactory cacheKeyFactory, DataSource upstream, @Nullable ProgressListener progressListener, @Nullable AtomicBoolean isCanceled) @@ -128,7 +126,7 @@ public final class CacheUtil { cache( dataSpec, cache, - cacheKeyFactory, + /* cacheKeyFactory= */ null, new CacheDataSource(cache, upstream), new byte[DEFAULT_BUFFER_SIZE_BYTES], /* priorityTaskManager= */ null, @@ -139,14 +137,14 @@ public final class CacheUtil { } /** - * Caches the data defined by {@code dataSpec} while skipping already cached data. Caching stops - * early if end of input is reached and {@code enableEOFException} is false. + * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early + * if end of input is reached and {@code enableEOFException} is false. * - *

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

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

This method may be slow and shouldn't normally be called on the main thread. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java index 7abb9b3896..b6a55c8da4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -15,8 +15,10 @@ */ package com.google.android.exoplayer2.upstream.cache; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkState; + import androidx.annotation.Nullable; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import java.io.File; import java.util.TreeSet; @@ -115,12 +117,18 @@ import java.util.TreeSet; * @return the length of the cached or not cached data block length. */ public long getCachedBytesLength(long position, long length) { + checkArgument(position >= 0); + checkArgument(length >= 0); SimpleCacheSpan span = getSpan(position); if (span.isHoleSpan()) { // We don't have a span covering the start of the queried region. return -Math.min(span.isOpenEnded() ? Long.MAX_VALUE : span.length, length); } long queryEndPosition = position + length; + if (queryEndPosition < 0) { + // The calculation rolled over (length is probably Long.MAX_VALUE). + queryEndPosition = Long.MAX_VALUE; + } long currentEndPosition = span.position + span.length; if (currentEndPosition < queryEndPosition) { for (SimpleCacheSpan next : cachedSpans.tailSet(span, false)) { @@ -151,7 +159,7 @@ import java.util.TreeSet; */ public SimpleCacheSpan setLastTouchTimestamp( SimpleCacheSpan cacheSpan, long lastTouchTimestamp, boolean updateFile) { - Assertions.checkState(cachedSpans.remove(cacheSpan)); + checkState(cachedSpans.remove(cacheSpan)); File file = cacheSpan.file; if (updateFile) { File directory = file.getParentFile(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java index 7a87d7d9a3..ffb8236bd1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Clock.java @@ -30,6 +30,13 @@ public interface Clock { */ Clock DEFAULT = new SystemClock(); + /** + * Returns the current time in milliseconds since the Unix Epoch. + * + * @see System#currentTimeMillis() + */ + long currentTimeMillis(); + /** @see android.os.SystemClock#elapsedRealtime() */ long elapsedRealtime(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java index c035c62a7e..69782ab1e8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java @@ -16,13 +16,39 @@ package com.google.android.exoplayer2.util; /** - * An interruptible condition variable whose {@link #open()} and {@link #close()} methods return - * whether they resulted in a change of state. + * An interruptible condition variable. This class provides a number of benefits over {@link + * android.os.ConditionVariable}: + * + *

    + *
  • Consistent use of ({@link Clock#elapsedRealtime()} for timing {@link #block(long)} timeout + * intervals. {@link android.os.ConditionVariable} used {@link System#currentTimeMillis()} + * prior to Android 10, which is not a correct clock to use for interval timing because it's + * not guaranteed to be monotonic. + *
  • Support for injecting a custom {@link Clock}. + *
  • The ability to query the variable's current state, by calling {@link #isOpen()}. + *
  • {@link #open()} and {@link #close()} return whether they changed the variable's state. + *
*/ public final class ConditionVariable { + private final Clock clock; private boolean isOpen; + /** Creates an instance using {@link Clock#DEFAULT}. */ + public ConditionVariable() { + this(Clock.DEFAULT); + } + + /** + * Creates an instance. + * + * @param clock The {@link Clock} whose {@link Clock#elapsedRealtime()} method is used to + * determine when {@link #block(long)} should time out. + */ + public ConditionVariable(Clock clock) { + this.clock = clock; + } + /** * Opens the condition and releases all threads that are blocked. * @@ -60,18 +86,27 @@ public final class ConditionVariable { } /** - * Blocks until the condition is opened or until {@code timeout} milliseconds have passed. + * Blocks until the condition is opened or until {@code timeoutMs} have passed. * - * @param timeout The maximum time to wait in milliseconds. + * @param timeoutMs The maximum time to wait in milliseconds. If {@code timeoutMs <= 0} then the + * call will return immediately without blocking. * @return True if the condition was opened, false if the call returns because of the timeout. * @throws InterruptedException If the thread is interrupted. */ - public synchronized boolean block(long timeout) throws InterruptedException { - long now = android.os.SystemClock.elapsedRealtime(); - long end = now + timeout; - while (!isOpen && now < end) { - wait(end - now); - now = android.os.SystemClock.elapsedRealtime(); + public synchronized boolean block(long timeoutMs) throws InterruptedException { + if (timeoutMs <= 0) { + return isOpen; + } + long nowMs = clock.elapsedRealtime(); + long endMs = nowMs + timeoutMs; + if (endMs < nowMs) { + // timeoutMs is large enough for (nowMs + timeoutMs) to rollover. Block indefinitely. + block(); + } else { + while (!isOpen && nowMs < endMs) { + wait(endMs - nowMs); + nowMs = clock.elapsedRealtime(); + } } return isOpen; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java index be526595c6..89e1c60d7a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java @@ -21,9 +21,17 @@ import android.os.Looper; import androidx.annotation.Nullable; /** - * The standard implementation of {@link Clock}. + * The standard implementation of {@link Clock}, an instance of which is available via {@link + * SystemClock#DEFAULT}. */ -/* package */ final class SystemClock implements Clock { +public class SystemClock implements Clock { + + protected SystemClock() {} + + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } @Override public long elapsedRealtime() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index ea43ee7bb3..a7a46b163d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -726,6 +726,24 @@ public final class Util { return C.INDEX_UNSET; } + /** + * Returns the index of the first occurrence of {@code value} in {@code array}, or {@link + * C#INDEX_UNSET} if {@code value} is not contained in {@code array}. + * + * @param array The array to search. + * @param value The value to search for. + * @return The index of the first occurrence of value in {@code array}, or {@link C#INDEX_UNSET} + * if {@code value} is not contained in {@code array}. + */ + public static int linearSearch(long[] array, long value) { + for (int i = 0; i < array.length; i++) { + if (array[i] == value) { + return i; + } + } + return C.INDEX_UNSET; + } + /** * Returns the index of the largest element in {@code array} that is less than (or optionally * equal to) a specified {@code value}. diff --git a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump index 505c85e51f..b2412d09ff 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.0.dump @@ -81,7 +81,7 @@ track 0: flags = 1 data = length 520, hash FEE56928 sample 13: - time = 520000 + time = 519999 flags = 1 data = length 599, hash 41F496C5 sample 14: diff --git a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump index 8bee343bd9..41844c32a3 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.1.dump @@ -57,7 +57,7 @@ track 0: flags = 1 data = length 520, hash FEE56928 sample 7: - time = 520000 + time = 519999 flags = 1 data = length 599, hash 41F496C5 sample 8: diff --git a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump index ee1cf91a57..0f00ba9c5b 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_fragmented.mp4.2.dump @@ -33,7 +33,7 @@ track 0: flags = 1 data = length 520, hash FEE56928 sample 1: - time = 520000 + time = 519999 flags = 1 data = length 599, hash 41F496C5 sample 2: diff --git a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump index 02db599cd7..c0e7d2a38d 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.0.dump @@ -107,7 +107,7 @@ track 0: crypto mode = 1 encryption key = length 16, hash 9FDDEA52 sample 13: - time = 520000 + time = 519999 flags = 1073741825 data = length 616, hash 3F657E23 crypto mode = 1 diff --git a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump index 8b45dd0a50..7886fc21ac 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.1.dump @@ -71,7 +71,7 @@ track 0: crypto mode = 1 encryption key = length 16, hash 9FDDEA52 sample 7: - time = 520000 + time = 519999 flags = 1073741825 data = length 616, hash 3F657E23 crypto mode = 1 diff --git a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump index a6be34dec7..e726932cb0 100644 --- a/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_ac4_protected.mp4.2.dump @@ -35,7 +35,7 @@ track 0: crypto mode = 1 encryption key = length 16, hash 9FDDEA52 sample 1: - time = 520000 + time = 519999 flags = 1073741825 data = length 616, hash 3F657E23 crypto mode = 1 diff --git a/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4 b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4 new file mode 100644 index 0000000000..e5594c83e1 Binary files /dev/null and b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4 differ diff --git a/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.0.dump b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.0.dump new file mode 100644 index 0000000000..2880b9493f --- /dev/null +++ b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.0.dump @@ -0,0 +1,61 @@ +seekMap: + isSeekable = true + duration = 526000 + getPosition(0) = [[timeUs=0, position=1161]] +numberOfTracks = 1 +track 0: + format: + bitrate = -1 + id = 1 + containerMimeType = null + sampleMimeType = video/avc + maxInputSize = 34686 + width = 1280 + height = 720 + frameRate = 13.307984 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + metadata = entries=[mdta: key=com.android.capture.fps] + initializationData: + data = length 22, hash 4CF81805 + data = length 9, hash FBAFBA1C + total output bytes = 42320 + sample count = 7 + sample 0: + time = 0 + flags = 1 + data = length 34656, hash D92B66FF + sample 1: + time = 325344 + flags = 0 + data = length 768, hash D0C3B229 + sample 2: + time = 358677 + flags = 0 + data = length 1184, hash C598EFC0 + sample 3: + time = 392011 + flags = 0 + data = length 576, hash 667AEC2C + sample 4: + time = 425344 + flags = 0 + data = length 1456, hash 430D1498 + sample 5: + time = 458677 + flags = 0 + data = length 1280, hash 12267E0E + sample 6: + time = 492011 + flags = 536870912 + data = length 2400, hash FBCB42C +tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.1.dump b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.1.dump new file mode 100644 index 0000000000..2880b9493f --- /dev/null +++ b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.1.dump @@ -0,0 +1,61 @@ +seekMap: + isSeekable = true + duration = 526000 + getPosition(0) = [[timeUs=0, position=1161]] +numberOfTracks = 1 +track 0: + format: + bitrate = -1 + id = 1 + containerMimeType = null + sampleMimeType = video/avc + maxInputSize = 34686 + width = 1280 + height = 720 + frameRate = 13.307984 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + metadata = entries=[mdta: key=com.android.capture.fps] + initializationData: + data = length 22, hash 4CF81805 + data = length 9, hash FBAFBA1C + total output bytes = 42320 + sample count = 7 + sample 0: + time = 0 + flags = 1 + data = length 34656, hash D92B66FF + sample 1: + time = 325344 + flags = 0 + data = length 768, hash D0C3B229 + sample 2: + time = 358677 + flags = 0 + data = length 1184, hash C598EFC0 + sample 3: + time = 392011 + flags = 0 + data = length 576, hash 667AEC2C + sample 4: + time = 425344 + flags = 0 + data = length 1456, hash 430D1498 + sample 5: + time = 458677 + flags = 0 + data = length 1280, hash 12267E0E + sample 6: + time = 492011 + flags = 536870912 + data = length 2400, hash FBCB42C +tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.2.dump b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.2.dump new file mode 100644 index 0000000000..2880b9493f --- /dev/null +++ b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.2.dump @@ -0,0 +1,61 @@ +seekMap: + isSeekable = true + duration = 526000 + getPosition(0) = [[timeUs=0, position=1161]] +numberOfTracks = 1 +track 0: + format: + bitrate = -1 + id = 1 + containerMimeType = null + sampleMimeType = video/avc + maxInputSize = 34686 + width = 1280 + height = 720 + frameRate = 13.307984 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + metadata = entries=[mdta: key=com.android.capture.fps] + initializationData: + data = length 22, hash 4CF81805 + data = length 9, hash FBAFBA1C + total output bytes = 42320 + sample count = 7 + sample 0: + time = 0 + flags = 1 + data = length 34656, hash D92B66FF + sample 1: + time = 325344 + flags = 0 + data = length 768, hash D0C3B229 + sample 2: + time = 358677 + flags = 0 + data = length 1184, hash C598EFC0 + sample 3: + time = 392011 + flags = 0 + data = length 576, hash 667AEC2C + sample 4: + time = 425344 + flags = 0 + data = length 1456, hash 430D1498 + sample 5: + time = 458677 + flags = 0 + data = length 1280, hash 12267E0E + sample 6: + time = 492011 + flags = 536870912 + data = length 2400, hash FBCB42C +tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.3.dump b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.3.dump new file mode 100644 index 0000000000..2880b9493f --- /dev/null +++ b/library/core/src/test/assets/mp4/sample_android_slow_motion.mp4.3.dump @@ -0,0 +1,61 @@ +seekMap: + isSeekable = true + duration = 526000 + getPosition(0) = [[timeUs=0, position=1161]] +numberOfTracks = 1 +track 0: + format: + bitrate = -1 + id = 1 + containerMimeType = null + sampleMimeType = video/avc + maxInputSize = 34686 + width = 1280 + height = 720 + frameRate = 13.307984 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + metadata = entries=[mdta: key=com.android.capture.fps] + initializationData: + data = length 22, hash 4CF81805 + data = length 9, hash FBAFBA1C + total output bytes = 42320 + sample count = 7 + sample 0: + time = 0 + flags = 1 + data = length 34656, hash D92B66FF + sample 1: + time = 325344 + flags = 0 + data = length 768, hash D0C3B229 + sample 2: + time = 358677 + flags = 0 + data = length 1184, hash C598EFC0 + sample 3: + time = 392011 + flags = 0 + data = length 576, hash 667AEC2C + sample 4: + time = 425344 + flags = 0 + data = length 1456, hash 430D1498 + sample 5: + time = 458677 + flags = 0 + data = length 1280, hash 12267E0E + sample 6: + time = 492011 + flags = 536870912 + data = length 2400, hash FBCB42C +tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump index 65f59d78b5..d2b197286b 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented.mp4.0.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -181,183 +181,183 @@ track 1: flags = 1 data = length 18, hash 96519432 sample 1: - time = 23000 + time = 23219 flags = 1 data = length 4, hash EE9DF sample 2: - time = 46000 + time = 46439 flags = 1 data = length 4, hash EEDBF sample 3: - time = 69000 + time = 69659 flags = 1 data = length 157, hash E2F078F4 sample 4: - time = 92000 + time = 92879 flags = 1 data = length 371, hash B9471F94 sample 5: - time = 116000 + time = 116099 flags = 1 data = length 373, hash 2AB265CB sample 6: - time = 139000 + time = 139319 flags = 1 data = length 402, hash 1295477C sample 7: - time = 162000 + time = 162539 flags = 1 data = length 455, hash 2D8146C8 sample 8: - time = 185000 + time = 185759 flags = 1 data = length 434, hash F2C5D287 sample 9: - time = 208000 + time = 208979 flags = 1 data = length 450, hash 84143FCD sample 10: - time = 232000 + time = 232199 flags = 1 data = length 429, hash EF769D50 sample 11: - time = 255000 + time = 255419 flags = 1 data = length 450, hash EC3DE692 sample 12: - time = 278000 + time = 278639 flags = 1 data = length 447, hash 3E519E13 sample 13: - time = 301000 + time = 301859 flags = 1 data = length 457, hash 1E4F23A0 sample 14: - time = 325000 + time = 325079 flags = 1 data = length 447, hash A439EA97 sample 15: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 16: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 17: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 18: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 19: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 20: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 21: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 22: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 23: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 24: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 25: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 26: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 27: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 28: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 29: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 30: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 31: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 32: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 33: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 34: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 35: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 36: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 37: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 38: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 39: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 40: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 41: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 42: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 43: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 44: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 45: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump index 27838bd2a8..8df0f881aa 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.0.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -181,183 +181,183 @@ track 1: flags = 1 data = length 18, hash 96519432 sample 1: - time = 23000 + time = 23219 flags = 1 data = length 4, hash EE9DF sample 2: - time = 46000 + time = 46439 flags = 1 data = length 4, hash EEDBF sample 3: - time = 69000 + time = 69659 flags = 1 data = length 157, hash E2F078F4 sample 4: - time = 92000 + time = 92879 flags = 1 data = length 371, hash B9471F94 sample 5: - time = 116000 + time = 116099 flags = 1 data = length 373, hash 2AB265CB sample 6: - time = 139000 + time = 139319 flags = 1 data = length 402, hash 1295477C sample 7: - time = 162000 + time = 162539 flags = 1 data = length 455, hash 2D8146C8 sample 8: - time = 185000 + time = 185759 flags = 1 data = length 434, hash F2C5D287 sample 9: - time = 208000 + time = 208979 flags = 1 data = length 450, hash 84143FCD sample 10: - time = 232000 + time = 232199 flags = 1 data = length 429, hash EF769D50 sample 11: - time = 255000 + time = 255419 flags = 1 data = length 450, hash EC3DE692 sample 12: - time = 278000 + time = 278639 flags = 1 data = length 447, hash 3E519E13 sample 13: - time = 301000 + time = 301859 flags = 1 data = length 457, hash 1E4F23A0 sample 14: - time = 325000 + time = 325079 flags = 1 data = length 447, hash A439EA97 sample 15: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 16: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 17: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 18: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 19: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 20: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 21: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 22: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 23: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 24: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 25: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 26: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 27: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 28: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 29: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 30: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 31: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 32: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 33: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 34: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 35: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 36: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 37: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 38: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 39: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 40: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 41: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 42: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 43: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 44: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 45: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump index ea6deafcad..2e80647199 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.1.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -177,127 +177,127 @@ track 1: total output bytes = 13359 sample count = 31 sample 0: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 1: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 2: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 3: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 4: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 5: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 6: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 7: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 8: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 9: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 10: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 11: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 12: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 13: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 14: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 15: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 16: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 17: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 18: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 19: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 20: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 21: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 22: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 23: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 24: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 25: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 26: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 27: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 28: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 29: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 30: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump index d14025e0b1..1715795320 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.2.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -177,67 +177,67 @@ track 1: total output bytes = 6804 sample count = 16 sample 0: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 1: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 2: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 3: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 4: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 5: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 6: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 7: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 8: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 9: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 10: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 11: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 12: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 13: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 14: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 15: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump index d08a1e93ad..fcd968440f 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_seekable.mp4.3.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -177,7 +177,7 @@ track 1: total output bytes = 10 sample count = 1 sample 0: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE tracksEnded = true diff --git a/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump b/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump index d596a77f78..3967f39251 100644 --- a/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump +++ b/library/core/src/test/assets/mp4/sample_fragmented_sei.mp4.0.dump @@ -31,123 +31,123 @@ track 0: total output bytes = 85933 sample count = 30 sample 0: - time = 66000 + time = 66733 flags = 1 data = length 38070, hash B58E1AEE sample 1: - time = 199000 + time = 200199 flags = 0 data = length 8340, hash 8AC449FF sample 2: - time = 132000 + time = 133466 flags = 0 data = length 1295, hash C0DA5090 sample 3: - time = 100000 + time = 100100 flags = 0 data = length 469, hash D6E0A200 sample 4: - time = 166000 + time = 166832 flags = 0 data = length 564, hash E5F56C5B sample 5: - time = 332000 + time = 333666 flags = 0 data = length 6075, hash 8756E49E sample 6: - time = 266000 + time = 266933 flags = 0 data = length 847, hash DCC2B618 sample 7: - time = 233000 + time = 233566 flags = 0 data = length 455, hash B9CCE047 sample 8: - time = 299000 + time = 300299 flags = 0 data = length 467, hash 69806D94 sample 9: - time = 466000 + time = 467133 flags = 0 data = length 4549, hash 3944F501 sample 10: - time = 399000 + time = 400399 flags = 0 data = length 1087, hash 491BF106 sample 11: - time = 367000 + time = 367033 flags = 0 data = length 380, hash 5FED016A sample 12: - time = 433000 + time = 433766 flags = 0 data = length 455, hash 8A0610 sample 13: - time = 599000 + time = 600599 flags = 0 data = length 5190, hash B9031D8 sample 14: - time = 533000 + time = 533866 flags = 0 data = length 1071, hash 684E7DC8 sample 15: - time = 500000 + time = 500500 flags = 0 data = length 653, hash 8494F326 sample 16: - time = 566000 + time = 567232 flags = 0 data = length 485, hash 2CCC85F4 sample 17: - time = 733000 + time = 734066 flags = 0 data = length 4884, hash D16B6A96 sample 18: - time = 666000 + time = 667333 flags = 0 data = length 997, hash 164FF210 sample 19: - time = 633000 + time = 633966 flags = 0 data = length 640, hash F664125B sample 20: - time = 700000 + time = 700699 flags = 0 data = length 491, hash B5930C7C sample 21: - time = 866000 + time = 867533 flags = 0 data = length 2989, hash 92CF4FCF sample 22: - time = 800000 + time = 800799 flags = 0 data = length 838, hash 294A3451 sample 23: - time = 767000 + time = 767433 flags = 0 data = length 544, hash FCCE2DE6 sample 24: - time = 833000 + time = 834166 flags = 0 data = length 329, hash A654FFA1 sample 25: - time = 1000000 + time = 1000999 flags = 0 data = length 1517, hash 5F7EBF8B sample 26: - time = 933000 + time = 934266 flags = 0 data = length 803, hash 7A5C4C1D sample 27: - time = 900000 + time = 900900 flags = 0 data = length 415, hash B31BBC3B sample 28: - time = 967000 + time = 967632 flags = 0 data = length 415, hash 850DFEA3 sample 29: - time = 1033000 + time = 1034366 flags = 0 data = length 619, hash AB5E56CA track 1: @@ -181,183 +181,183 @@ track 1: flags = 1 data = length 18, hash 96519432 sample 1: - time = 23000 + time = 23219 flags = 1 data = length 4, hash EE9DF sample 2: - time = 46000 + time = 46439 flags = 1 data = length 4, hash EEDBF sample 3: - time = 69000 + time = 69659 flags = 1 data = length 157, hash E2F078F4 sample 4: - time = 92000 + time = 92879 flags = 1 data = length 371, hash B9471F94 sample 5: - time = 116000 + time = 116099 flags = 1 data = length 373, hash 2AB265CB sample 6: - time = 139000 + time = 139319 flags = 1 data = length 402, hash 1295477C sample 7: - time = 162000 + time = 162539 flags = 1 data = length 455, hash 2D8146C8 sample 8: - time = 185000 + time = 185759 flags = 1 data = length 434, hash F2C5D287 sample 9: - time = 208000 + time = 208979 flags = 1 data = length 450, hash 84143FCD sample 10: - time = 232000 + time = 232199 flags = 1 data = length 429, hash EF769D50 sample 11: - time = 255000 + time = 255419 flags = 1 data = length 450, hash EC3DE692 sample 12: - time = 278000 + time = 278639 flags = 1 data = length 447, hash 3E519E13 sample 13: - time = 301000 + time = 301859 flags = 1 data = length 457, hash 1E4F23A0 sample 14: - time = 325000 + time = 325079 flags = 1 data = length 447, hash A439EA97 sample 15: - time = 348000 + time = 348299 flags = 1 data = length 456, hash 1E9034C6 sample 16: - time = 371000 + time = 371519 flags = 1 data = length 398, hash 99DB7345 sample 17: - time = 394000 + time = 394739 flags = 1 data = length 474, hash 3F05F10A sample 18: - time = 417000 + time = 417959 flags = 1 data = length 416, hash C105EE09 sample 19: - time = 441000 + time = 441179 flags = 1 data = length 454, hash 5FDBE458 sample 20: - time = 464000 + time = 464399 flags = 1 data = length 438, hash 41A93AC3 sample 21: - time = 487000 + time = 487619 flags = 1 data = length 443, hash 10FDA652 sample 22: - time = 510000 + time = 510839 flags = 1 data = length 412, hash 1F791E25 sample 23: - time = 534000 + time = 534058 flags = 1 data = length 482, hash A6D983D sample 24: - time = 557000 + time = 557278 flags = 1 data = length 386, hash BED7392F sample 25: - time = 580000 + time = 580498 flags = 1 data = length 463, hash 5309F8C9 sample 26: - time = 603000 + time = 603718 flags = 1 data = length 394, hash 21C7321F sample 27: - time = 626000 + time = 626938 flags = 1 data = length 489, hash 71B4730D sample 28: - time = 650000 + time = 650158 flags = 1 data = length 403, hash D9C6DE89 sample 29: - time = 673000 + time = 673378 flags = 1 data = length 447, hash 9B14B73B sample 30: - time = 696000 + time = 696598 flags = 1 data = length 439, hash 4760D35B sample 31: - time = 719000 + time = 719818 flags = 1 data = length 463, hash 1601F88D sample 32: - time = 743000 + time = 743038 flags = 1 data = length 423, hash D4AE6773 sample 33: - time = 766000 + time = 766258 flags = 1 data = length 497, hash A3C674D3 sample 34: - time = 789000 + time = 789478 flags = 1 data = length 419, hash D3734A1F sample 35: - time = 812000 + time = 812698 flags = 1 data = length 474, hash DFB41F9 sample 36: - time = 835000 + time = 835918 flags = 1 data = length 413, hash 53E7CB9F sample 37: - time = 859000 + time = 859138 flags = 1 data = length 445, hash D15B0E39 sample 38: - time = 882000 + time = 882358 flags = 1 data = length 453, hash 77ED81E4 sample 39: - time = 905000 + time = 905578 flags = 1 data = length 545, hash 3321AEB9 sample 40: - time = 928000 + time = 928798 flags = 1 data = length 317, hash F557D0E sample 41: - time = 952000 + time = 952018 flags = 1 data = length 537, hash ED58CF7B sample 42: - time = 975000 + time = 975238 flags = 1 data = length 458, hash 51CDAA10 sample 43: - time = 998000 + time = 998458 flags = 1 data = length 465, hash CBA1EFD7 sample 44: - time = 1021000 + time = 1021678 flags = 1 data = length 446, hash D6735B8A sample 45: - time = 1044000 + time = 1044897 flags = 1 data = length 10, hash A453EEBE track 3: diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java index f0b18b4a20..1de3a8d1e5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java @@ -21,6 +21,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -501,6 +502,7 @@ public final class DefaultPlaybackSessionManagerTest { createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); sessionManager.handleTimelineUpdate(newTimelineEventTime); + sessionManager.updateSessions(newTimelineEventTime); ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); @@ -657,6 +659,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eq(eventTime1), anyString()); verify(mockListener).onSessionActive(eq(eventTime1), anyString()); @@ -688,6 +691,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -722,6 +726,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -748,6 +753,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.updateSessions(eventTime2); sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(eventTime2); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -790,6 +796,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); sessionManager.handlePositionDiscontinuity(eventTime3, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(eventTime3); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -851,6 +858,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( contentEventTime, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(contentEventTime); verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); verify(mockListener).onSessionActive(adEventTime1, adSessionId1); @@ -858,6 +866,8 @@ public final class DefaultPlaybackSessionManagerTest { verify(mockListener) .onSessionFinished( contentEventTime, adSessionId1, /* automaticTransitionToNextPlayback= */ true); + verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); + verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); verifyNoMoreInteractions(mockListener); } @@ -908,6 +918,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(adEventTime1); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -964,7 +975,9 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.handlePositionDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(adEventTime1); sessionManager.handlePositionDiscontinuity(adEventTime2, Player.DISCONTINUITY_REASON_SEEK); + sessionManager.updateSessions(adEventTime2); verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); @@ -1034,8 +1047,10 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.updateSessions(adEventTime1); sessionManager.handlePositionDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(adEventTime1); sessionManager.handlePositionDiscontinuity( contentEventTime2, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessions(contentEventTime2); String adSessionId2 = sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); @@ -1044,6 +1059,31 @@ public final class DefaultPlaybackSessionManagerTest { verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2)); } + @Test + public void finishAllSessions_callsOnSessionFinishedForAllCreatedSessions() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 4); + EventTime eventTimeWindow0 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTimeWindow2 = + createEventTime(timeline, /* windowIndex= */ 2, /* mediaPeriodId= */ null); + // Actually create sessions for window 0 and 2. + sessionManager.updateSessions(eventTimeWindow0); + sessionManager.updateSessions(eventTimeWindow2); + // Query information about session for window 1, but don't create it. + sessionManager.getSessionForMediaPeriodId( + timeline, + new MediaPeriodId( + timeline.getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true).uid, + /* windowSequenceNumber= */ 123)); + verify(mockListener, times(2)).onSessionCreated(any(), anyString()); + + EventTime finishEventTime = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + sessionManager.finishAllSessions(finishEventTime); + + verify(mockListener, times(2)).onSessionFinished(eq(finishEventTime), anyString(), eq(false)); + } + private static EventTime createEventTime( Timeline timeline, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { return new EventTime( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java index 10122d36ec..41db4dc570 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java @@ -16,11 +16,20 @@ package com.google.android.exoplayer2.analytics; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.testutil.FakeTimeline; import org.junit.Test; import org.junit.runner.RunWith; @@ -28,7 +37,7 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class PlaybackStatsListenerTest { - private static final AnalyticsListener.EventTime TEST_EVENT_TIME = + private static final AnalyticsListener.EventTime EMPTY_TIMELINE_EVENT_TIME = new AnalyticsListener.EventTime( /* realtimeMs= */ 500, Timeline.EMPTY, @@ -37,6 +46,41 @@ public final class PlaybackStatsListenerTest { /* eventPlaybackPositionMs= */ 0, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); + private static final Timeline TEST_TIMELINE = new FakeTimeline(/* windowCount= */ 1); + private static final AnalyticsListener.EventTime TEST_EVENT_TIME = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 500, + TEST_TIMELINE, + /* windowIndex= */ 0, + new MediaSource.MediaPeriodId( + TEST_TIMELINE.getPeriod( + /* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true) + .uid, + /* windowSequenceNumber= */ 42), + /* eventPlaybackPositionMs= */ 123, + /* currentPlaybackPositionMs= */ 123, + /* totalBufferedDurationMs= */ 456); + + @Test + public void stateChangeEvent_toNonIdle_createsInitialPlaybackStats() { + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); + + playbackStatsListener.onPlayerStateChanged( + EMPTY_TIMELINE_EVENT_TIME, /* playWhenReady= */ false, Player.STATE_BUFFERING); + + assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); + } + + @Test + public void timelineChangeEvent_toNonEmpty_createsInitialPlaybackStats() { + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); + + playbackStatsListener.onTimelineChanged(TEST_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + + assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); + } @Test public void playback_withKeepHistory_updatesStats() { @@ -71,4 +115,72 @@ public final class PlaybackStatsListenerTest { assertThat(playbackStats).isNotNull(); assertThat(playbackStats.endedCount).isEqualTo(1); } + + @Test + public void finishedSession_callsCallback() { + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + + // Create session with an event and finish it by simulating removal from playlist. + playbackStatsListener.onPlayerStateChanged( + TEST_EVENT_TIME, /* playWhenReady= */ false, Player.STATE_BUFFERING); + verify(callback, never()).onPlaybackStatsReady(any(), any()); + playbackStatsListener.onTimelineChanged( + EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + + verify(callback).onPlaybackStatsReady(eq(TEST_EVENT_TIME), any()); + } + + @Test + public void finishAllSessions_callsAllPendingCallbacks() { + AnalyticsListener.EventTime eventTimeWindow0 = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 0, + Timeline.EMPTY, + /* windowIndex= */ 0, + /* mediaPeriodId= */ null, + /* eventPlaybackPositionMs= */ 0, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + AnalyticsListener.EventTime eventTimeWindow1 = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 0, + Timeline.EMPTY, + /* windowIndex= */ 1, + /* mediaPeriodId= */ null, + /* eventPlaybackPositionMs= */ 0, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + playbackStatsListener.onPlayerStateChanged( + eventTimeWindow0, /* playWhenReady= */ false, Player.STATE_BUFFERING); + playbackStatsListener.onPlayerStateChanged( + eventTimeWindow1, /* playWhenReady= */ false, Player.STATE_BUFFERING); + + playbackStatsListener.finishAllSessions(); + + verify(callback, times(2)).onPlaybackStatsReady(any(), any()); + verify(callback).onPlaybackStatsReady(eq(eventTimeWindow0), any()); + verify(callback).onPlaybackStatsReady(eq(eventTimeWindow1), any()); + } + + @Test + public void finishAllSessions_doesNotCallCallbackAgainWhenSessionWouldBeAutomaticallyFinished() { + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + playbackStatsListener.onPlayerStateChanged( + TEST_EVENT_TIME, /* playWhenReady= */ false, Player.STATE_BUFFERING); + SystemClock.setCurrentTimeMillis(TEST_EVENT_TIME.realtimeMs + 100); + + playbackStatsListener.finishAllSessions(); + // Simulate removing the playback item to ensure the session would finish if it hadn't already. + playbackStatsListener.onTimelineChanged( + EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + + verify(callback).onPlaybackStatsReady(any(), any()); + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java index 6783c96055..4933460e01 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessorTest.java @@ -204,7 +204,34 @@ public final class SilenceSkippingAudioProcessorTest { } @Test - public void testSkipThenFlush_resetsSkippedFrameCount() throws Exception { + public void customPaddingValue_hasCorrectOutputAndSkippedFrameCounts() throws Exception { + // Given a signal that alternates between silence and noise. + InputBufferProvider inputBufferProvider = + getInputBufferProviderForAlternatingSilenceAndNoise( + TEST_SIGNAL_SILENCE_DURATION_MS, + TEST_SIGNAL_NOISE_DURATION_MS, + TEST_SIGNAL_FRAME_COUNT); + + // When processing the entire signal with a larger than normal padding silence. + SilenceSkippingAudioProcessor silenceSkippingAudioProcessor = + new SilenceSkippingAudioProcessor( + SilenceSkippingAudioProcessor.DEFAULT_MINIMUM_SILENCE_DURATION_US, + /* paddingSilenceUs= */ 21_000, + SilenceSkippingAudioProcessor.DEFAULT_SILENCE_THRESHOLD_LEVEL); + silenceSkippingAudioProcessor.setEnabled(true); + silenceSkippingAudioProcessor.configure(AUDIO_FORMAT); + silenceSkippingAudioProcessor.flush(); + assertThat(silenceSkippingAudioProcessor.isActive()).isTrue(); + long totalOutputFrames = + process(silenceSkippingAudioProcessor, inputBufferProvider, /* inputBufferSize= */ 120); + + // The right number of frames are skipped/output. + assertThat(totalOutputFrames).isEqualTo(58379); + assertThat(silenceSkippingAudioProcessor.getSkippedFrames()).isEqualTo(41621); + } + + @Test + public void skipThenFlush_resetsSkippedFrameCount() throws Exception { // Given a signal that alternates between silence and noise. InputBufferProvider inputBufferProvider = getInputBufferProviderForAlternatingSilenceAndNoise( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/TeeAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/TeeAudioProcessorTest.java new file mode 100644 index 0000000000..6f0a87e97b --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/TeeAudioProcessorTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static org.mockito.Mockito.verify; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.audio.AudioProcessor.AudioFormat; +import com.google.android.exoplayer2.audio.TeeAudioProcessor.AudioBufferSink; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link TeeAudioProcessorTest}. */ +@RunWith(AndroidJUnit4.class) +public final class TeeAudioProcessorTest { + + private static final AudioFormat AUDIO_FORMAT = + new AudioFormat(/* sampleRate= */ 44100, /* channelCount= */ 2, C.ENCODING_PCM_16BIT); + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private TeeAudioProcessor teeAudioProcessor; + + @Mock private AudioBufferSink mockAudioBufferSink; + + @Before + public void setUp() { + teeAudioProcessor = new TeeAudioProcessor(mockAudioBufferSink); + } + + @Test + public void initialFlush_flushesSink() throws Exception { + teeAudioProcessor.configure(AUDIO_FORMAT); + teeAudioProcessor.flush(); + + verify(mockAudioBufferSink) + .flush(AUDIO_FORMAT.sampleRate, AUDIO_FORMAT.channelCount, AUDIO_FORMAT.encoding); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java new file mode 100644 index 0000000000..19a1ad19c3 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.audio.AudioProcessor.AudioFormat; +import java.nio.ByteBuffer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link TrimmingAudioProcessor}. */ +@RunWith(AndroidJUnit4.class) +public final class TrimmingAudioProcessorTest { + + private static final AudioFormat AUDIO_FORMAT = + new AudioFormat(/* sampleRate= */ 44100, /* channelCount= */ 2, C.ENCODING_PCM_16BIT); + private static final int TRACK_ONE_UNTRIMMED_FRAME_COUNT = 1024; + private static final int TRACK_ONE_TRIM_START_FRAME_COUNT = 64; + private static final int TRACK_ONE_TRIM_END_FRAME_COUNT = 32; + private static final int TRACK_TWO_TRIM_START_FRAME_COUNT = 128; + private static final int TRACK_TWO_TRIM_END_FRAME_COUNT = 16; + + private static final int TRACK_ONE_BUFFER_SIZE_BYTES = + AUDIO_FORMAT.bytesPerFrame * TRACK_ONE_UNTRIMMED_FRAME_COUNT; + private static final int TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES = + TRACK_ONE_BUFFER_SIZE_BYTES + - AUDIO_FORMAT.bytesPerFrame + * (TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT); + + private TrimmingAudioProcessor trimmingAudioProcessor; + + @Before + public void setUp() { + trimmingAudioProcessor = new TrimmingAudioProcessor(); + } + + @After + public void tearDown() { + trimmingAudioProcessor.reset(); + } + + @Test + public void flushTwice_trimsStartAndEnd() throws Exception { + trimmingAudioProcessor.setTrimFrameCount( + TRACK_ONE_TRIM_START_FRAME_COUNT, TRACK_ONE_TRIM_END_FRAME_COUNT); + trimmingAudioProcessor.configure(AUDIO_FORMAT); + trimmingAudioProcessor.flush(); + trimmingAudioProcessor.flush(); + + int outputSizeBytes = feedAndDrainAudioProcessorToEndOfTrackOne(); + + assertThat(trimmingAudioProcessor.getTrimmedFrameCount()) + .isEqualTo(TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT); + assertThat(outputSizeBytes).isEqualTo(TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES); + } + + /** + * Feeds and drains the audio processor up to the end of track one, returning the total output + * size in bytes. + */ + private int feedAndDrainAudioProcessorToEndOfTrackOne() throws Exception { + // Feed and drain the processor, simulating a gapless transition to another track. + ByteBuffer inputBuffer = ByteBuffer.allocate(TRACK_ONE_BUFFER_SIZE_BYTES); + int outputSize = 0; + while (!trimmingAudioProcessor.isEnded()) { + if (inputBuffer.hasRemaining()) { + trimmingAudioProcessor.queueInput(inputBuffer); + if (!inputBuffer.hasRemaining()) { + // Reconfigure for a next track then begin draining. + trimmingAudioProcessor.setTrimFrameCount( + TRACK_TWO_TRIM_START_FRAME_COUNT, TRACK_TWO_TRIM_END_FRAME_COUNT); + trimmingAudioProcessor.configure(AUDIO_FORMAT); + trimmingAudioProcessor.queueEndOfStream(); + } + } + ByteBuffer outputBuffer = trimmingAudioProcessor.getOutput(); + outputSize += outputBuffer.remaining(); + outputBuffer.clear(); + } + trimmingAudioProcessor.reset(); + return outputSize; + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java index 1a5f90bc39..40df1cc689 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java @@ -42,4 +42,9 @@ public final class Mp4ExtractorTest { public void testMp4SampleWithAc4Track() throws Exception { ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac4.mp4"); } + + @Test + public void testMp4SampleWithSlowMotionMetadata() throws Exception { + ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_android_slow_motion.mp4"); + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index a34488d2e7..a35e8c4d52 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -26,6 +26,7 @@ import static java.util.Arrays.copyOfRange; import static org.junit.Assert.assertArrayEquals; import static org.mockito.Mockito.when; +import android.os.Looper; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -40,6 +41,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.IOException; import java.util.Arrays; @@ -143,7 +145,10 @@ public final class SampleQueueTest { mockDrmSession = (DrmSession) Mockito.mock(DrmSession.class); when(mockDrmSessionManager.acquireSession(ArgumentMatchers.any(), ArgumentMatchers.any())) .thenReturn(mockDrmSession); - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager); + sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager); formatHolder = new FormatHolder(); inputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); } @@ -360,7 +365,10 @@ public final class SampleQueueTest { public void testIsReadyReturnsTrueForClearSampleAndPlayClearSamplesWithoutKeysIsTrue() { when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true); // We recreate the queue to ensure the mock DRM session manager flags are taken into account. - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager); + sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager); writeTestDataWithEncryptedSections(); assertThat(sampleQueue.isReady(/* loadingFinished= */ false)).isTrue(); } @@ -542,7 +550,10 @@ public final class SampleQueueTest { public void testAllowPlayClearSamplesWithoutKeysReadsClearSamples() { when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true); // We recreate the queue to ensure the mock DRM session manager flags are taken into account. - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager); + sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager); when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED); writeTestDataWithEncryptedSections(); @@ -931,7 +942,10 @@ public final class SampleQueueTest { public void testAdjustUpstreamFormat() { String label = "label"; sampleQueue = - new SampleQueue(allocator, mockDrmSessionManager) { + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager) { @Override public Format getAdjustedUpstreamFormat(Format format) { return super.getAdjustedUpstreamFormat(format.copyWithLabel(label)); @@ -947,7 +961,10 @@ public final class SampleQueueTest { public void testInvalidateUpstreamFormatAdjustment() { AtomicReference label = new AtomicReference<>("label1"); sampleQueue = - new SampleQueue(allocator, mockDrmSessionManager) { + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager) { @Override public Format getAdjustedUpstreamFormat(Format format) { return super.getAdjustedUpstreamFormat(format.copyWithLabel(label.get())); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java index 0cd27a90c0..bd4dd8876f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -64,7 +64,9 @@ public final class AdPlaybackStateTest { assertThat(state.adGroups[0].uris[0]).isNull(); assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_ERROR); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)).isTrue(); assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)).isFalse(); } @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java new file mode 100644 index 0000000000..77eb628f28 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java @@ -0,0 +1,216 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.ads; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; +import static org.robolectric.annotation.LooperMode.Mode.PAUSED; + +import android.net.Uri; +import android.os.Looper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; +import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider; +import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener; +import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.upstream.Allocator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.annotation.LooperMode; + +/** Unit tests for {@link AdsMediaSource}. */ +@RunWith(AndroidJUnit4.class) +@LooperMode(PAUSED) +public final class AdsMediaSourceTest { + + private static final long PREROLL_AD_DURATION_US = 10 * C.MICROS_PER_SECOND; + private static final Timeline PREROLL_AD_TIMELINE = + new SinglePeriodTimeline( + PREROLL_AD_DURATION_US, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false); + private static final Object PREROLL_AD_PERIOD_UID = + PREROLL_AD_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); + + private static final long CONTENT_DURATION_US = 30 * C.MICROS_PER_SECOND; + private static final Timeline CONTENT_TIMELINE = + new SinglePeriodTimeline( + CONTENT_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false); + private static final Object CONTENT_PERIOD_UID = + CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); + + private static final AdPlaybackState AD_PLAYBACK_STATE = + new AdPlaybackState(/* adGroupTimesUs...= */ 0) + .withContentDurationUs(CONTENT_DURATION_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .withAdResumePositionUs(/* adResumePositionUs= */ 0); + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private FakeMediaSource contentMediaSource; + private FakeMediaSource prerollAdMediaSource; + @Mock private MediaSourceCaller mockMediaSourceCaller; + private AdsMediaSource adsMediaSource; + + @Before + public void setUp() { + // Set up content and ad media sources, passing a null timeline so tests can simulate setting it + // later. + contentMediaSource = new FakeMediaSource(/* timeline= */ null); + prerollAdMediaSource = new FakeMediaSource(/* timeline= */ null); + MediaSourceFactory adMediaSourceFactory = mock(MediaSourceFactory.class); + when(adMediaSourceFactory.createMediaSource(any(Uri.class))).thenReturn(prerollAdMediaSource); + + // Prepare the AdsMediaSource and capture its ads loader listener. + AdsLoader mockAdsLoader = mock(AdsLoader.class); + AdViewProvider mockAdViewProvider = mock(AdViewProvider.class); + ArgumentCaptor eventListenerArgumentCaptor = + ArgumentCaptor.forClass(AdsLoader.EventListener.class); + adsMediaSource = + new AdsMediaSource( + contentMediaSource, adMediaSourceFactory, mockAdsLoader, mockAdViewProvider); + adsMediaSource.prepareSource(mockMediaSourceCaller, /* mediaTransferListener= */ null); + shadowOf(Looper.getMainLooper()).idle(); + verify(mockAdsLoader).start(eventListenerArgumentCaptor.capture(), eq(mockAdViewProvider)); + + // Simulate loading a preroll ad. + AdsLoader.EventListener adsLoaderEventListener = eventListenerArgumentCaptor.getValue(); + adsLoaderEventListener.onAdPlaybackState(AD_PLAYBACK_STATE); + shadowOf(Looper.getMainLooper()).idle(); + } + + @Test + public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfo() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(prerollAdMediaSource.isPrepared()).isTrue(); + verify(mockMediaSourceCaller) + .onSourceInfoRefreshed( + adsMediaSource, new SinglePeriodAdTimeline(CONTENT_TIMELINE, AD_PLAYBACK_STATE)); + } + + @Test + public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfoWithAdMediaSourceInfo() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mockMediaSourceCaller) + .onSourceInfoRefreshed( + adsMediaSource, + new SinglePeriodAdTimeline( + CONTENT_TIMELINE, + AD_PLAYBACK_STATE.withAdDurationsUs(new long[][] {{PREROLL_AD_DURATION_US}}))); + } + + @Test + public void createPeriod_createsChildPrerollAdMediaPeriod() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + + prerollAdMediaSource.assertMediaPeriodCreated( + new MediaPeriodId(PREROLL_AD_PERIOD_UID, /* windowSequenceNumber= */ 0)); + } + + @Test + public void createPeriod_createsChildContentMediaPeriod() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + adsMediaSource.createPeriod( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + + contentMediaSource.assertMediaPeriodCreated( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0)); + } + + @Test + public void releasePeriod_releasesChildMediaPeriodsAndSources() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE, null); + MediaPeriod prerollAdMediaPeriod = + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE, null); + shadowOf(Looper.getMainLooper()).idle(); + MediaPeriod contentMediaPeriod = + adsMediaSource.createPeriod( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + adsMediaSource.releasePeriod(prerollAdMediaPeriod); + + prerollAdMediaSource.assertReleased(); + + adsMediaSource.releasePeriod(contentMediaPeriod); + adsMediaSource.releaseSource(mockMediaSourceCaller); + shadowOf(Looper.getMainLooper()).idle(); + prerollAdMediaSource.assertReleased(); + contentMediaSource.assertReleased(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java index 27438fcac3..8862a65db2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest.java @@ -365,7 +365,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( unboundedDataSpec, cache, - /* cacheKeyFactory= */ null, upstream2, /* progressListener= */ null, /* isCanceled= */ null); @@ -414,7 +413,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( unboundedDataSpec, cache, - /* cacheKeyFactory= */ null, upstream2, /* progressListener= */ null, /* isCanceled= */ null); @@ -438,7 +436,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( dataSpec, cache, - /* cacheKeyFactory= */ null, upstream, /* progressListener= */ null, /* isCanceled= */ null); @@ -474,7 +471,6 @@ public final class CacheDataSourceTest { CacheUtil.cache( dataSpec, cache, - /* cacheKeyFactory= */ null, upstream, /* progressListener= */ null, /* isCanceled= */ null); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java index 9a449b2ebd..69463bff54 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java @@ -207,7 +207,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(Uri.parse("test_data")), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); @@ -224,8 +223,7 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, 10, 20, null); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 20, 20); counters.reset(); @@ -233,7 +231,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(testUri), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); @@ -251,8 +248,7 @@ public final class CacheUtilTest { DataSpec dataSpec = new DataSpec(Uri.parse("test_data")); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); @@ -268,8 +264,7 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, 10, 20, null); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 20, 20); counters.reset(); @@ -277,7 +272,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(testUri), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); @@ -294,8 +288,7 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, 0, 1000, null); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); + CacheUtil.cache(dataSpec, cache, dataSource, counters, /* isCanceled= */ null); counters.assertValues(0, 100, 1000); assertCachedData(cache, fakeDataSet); @@ -344,7 +337,6 @@ public final class CacheUtilTest { CacheUtil.cache( new DataSpec(Uri.parse("test_data")), cache, - /* cacheKeyFactory= */ null, dataSource, counters, /* isCanceled= */ null); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 4d9a936c4e..8294dee383 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -284,38 +284,93 @@ public class SimpleCacheTest { } @Test - public void testGetCachedLength() throws Exception { + public void getCachedLength_noCachedContent_returnsNegativeMaxHoleLength() { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); - // No cached bytes, returns -'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(-100); - - // Position value doesn't affect the return value - assertThat(simpleCache.getCachedLength(KEY_1, 20, 100)).isEqualTo(-100); - - addCache(simpleCache, KEY_1, 0, 15); - - // Returns the length of a single span - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(15); - - // Value is capped by the 'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 10)).isEqualTo(10); - - addCache(simpleCache, KEY_1, 15, 35); - - // Returns the length of two adjacent spans - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); - - addCache(simpleCache, KEY_1, 60, 10); - - // Not adjacent span doesn't affect return value - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); - - // Returns length of hole up to the next cached span - assertThat(simpleCache.getCachedLength(KEY_1, 55, 100)).isEqualTo(-5); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(-100); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-Long.MAX_VALUE); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-100); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-Long.MAX_VALUE); + } + @Test + public void getCachedLength_returnsNegativeHoleLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 50, /* length= */ 50); simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-30); + } + + @Test + public void getCachedLength_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 50); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); + addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleNonAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); + addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); + simpleCache.releaseHoleSpan(cacheSpan); + + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); } /* Tests https://github.com/google/ExoPlayer/issues/3260 case. */ diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java new file mode 100644 index 0000000000..79eac5d1ad --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ConditionVariableTest}. */ +@RunWith(AndroidJUnit4.class) +public class ConditionVariableTest { + + @Test + public void initialState_isClosed() { + ConditionVariable conditionVariable = buildTestConditionVariable(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void blockWithTimeout_timesOut() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + assertThat(conditionVariable.block(1)).isFalse(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void blockWithTimeout_blocksForAtLeastTimeout() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + long startTimeMs = System.currentTimeMillis(); + assertThat(conditionVariable.block(/* timeoutMs= */ 500)).isFalse(); + long endTimeMs = System.currentTimeMillis(); + assertThat(endTimeMs - startTimeMs).isAtLeast(500L); + } + + @Test + public void blockWithoutTimeout_blocks() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + blockingThread.join(); + assertThat(blockWasInterrupted.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void blockWithMaxTimeout_blocks() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(/* timeoutMs= */ Long.MAX_VALUE); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + blockingThread.join(); + assertThat(blockWasInterrupted.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void open_unblocksBlock() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + conditionVariable.open(); + blockingThread.join(); + assertThat(blockReturned.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isTrue(); + } + + private static ConditionVariable buildTestConditionVariable() { + return new ConditionVariable( + new SystemClock() { + @Override + public long elapsedRealtime() { + // elapsedRealtime() does not advance during Robolectric test execution, so use + // currentTimeMillis() instead. This is technically unsafe because this clock is not + // guaranteed to be monotonic, but in practice it will work provided the clock of the + // host machine does not change during test execution. + return Clock.DEFAULT.currentTimeMillis(); + } + }); + } +} diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index 88de84603e..fa8e5338fc 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source.dash; import android.util.Pair; +import android.util.SparseArray; import android.util.SparseIntArray; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -516,50 +517,94 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return Pair.create(new TrackGroupArray(trackGroups), trackGroupInfos); } + /** + * Groups adaptation sets. Two adaptations sets belong to the same group if either: + * + *
    + *
  • One is a trick-play adaptation set and uses a {@code + * http://dashif.org/guidelines/trickmode} essential or supplemental property to indicate + * that the other is the main adaptation set to which it corresponds. + *
  • The two adaptation sets are marked as safe for switching using {@code + * urn:mpeg:dash:adaptation-set-switching:2016} supplemental properties. + *
+ * + * @param adaptationSets The adaptation sets to merge. + * @return An array of groups, where each group is an array of adaptation set indices. + */ private static int[][] getGroupedAdaptationSetIndices(List adaptationSets) { int adaptationSetCount = adaptationSets.size(); - SparseIntArray idToIndexMap = new SparseIntArray(adaptationSetCount); + SparseIntArray adaptationSetIdToIndex = new SparseIntArray(adaptationSetCount); + List> adaptationSetGroupedIndices = new ArrayList<>(adaptationSetCount); + SparseArray> adaptationSetIndexToGroupedIndices = + new SparseArray<>(adaptationSetCount); + + // Initially make each adaptation set belong to its own group. Also build the + // adaptationSetIdToIndex map. for (int i = 0; i < adaptationSetCount; i++) { - idToIndexMap.put(adaptationSets.get(i).id, i); + adaptationSetIdToIndex.put(adaptationSets.get(i).id, i); + List initialGroup = new ArrayList<>(); + initialGroup.add(i); + adaptationSetGroupedIndices.add(initialGroup); + adaptationSetIndexToGroupedIndices.put(i, initialGroup); } - int[][] groupedAdaptationSetIndices = new int[adaptationSetCount][]; - boolean[] adaptationSetUsedFlags = new boolean[adaptationSetCount]; - - int groupCount = 0; + // Merge adaptation set groups. for (int i = 0; i < adaptationSetCount; i++) { - if (adaptationSetUsedFlags[i]) { - // This adaptation set has already been included in a group. - continue; + int mergedGroupIndex = i; + AdaptationSet adaptationSet = adaptationSets.get(i); + + // Trick-play adaptation sets are merged with their corresponding main adaptation sets. + @Nullable + Descriptor trickPlayProperty = findTrickPlayProperty(adaptationSet.essentialProperties); + if (trickPlayProperty == null) { + // Trick-play can also be specified using a supplemental property. + trickPlayProperty = findTrickPlayProperty(adaptationSet.supplementalProperties); } - adaptationSetUsedFlags[i] = true; - Descriptor adaptationSetSwitchingProperty = findAdaptationSetSwitchingProperty( - adaptationSets.get(i).supplementalProperties); - if (adaptationSetSwitchingProperty == null) { - groupedAdaptationSetIndices[groupCount++] = new int[] {i}; - } else { - String[] extraAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ","); - int[] adaptationSetIndices = new int[1 + extraAdaptationSetIds.length]; - adaptationSetIndices[0] = i; - int outputIndex = 1; - for (String adaptationSetId : extraAdaptationSetIds) { - int extraIndex = - idToIndexMap.get(Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1); - if (extraIndex != -1) { - adaptationSetUsedFlags[extraIndex] = true; - adaptationSetIndices[outputIndex] = extraIndex; - outputIndex++; + if (trickPlayProperty != null) { + int mainAdaptationSetId = Integer.parseInt(trickPlayProperty.value); + int mainAdaptationSetIndex = + adaptationSetIdToIndex.get(mainAdaptationSetId, /* valueIfKeyNotFound= */ -1); + if (mainAdaptationSetIndex != -1) { + mergedGroupIndex = mainAdaptationSetIndex; + } + } + + // Adaptation sets that are safe for switching are merged, using the smallest index for the + // merged group. + if (mergedGroupIndex == i) { + @Nullable + Descriptor adaptationSetSwitchingProperty = + findAdaptationSetSwitchingProperty(adaptationSet.supplementalProperties); + if (adaptationSetSwitchingProperty != null) { + String[] otherAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ","); + for (String adaptationSetId : otherAdaptationSetIds) { + int otherAdaptationSetId = + adaptationSetIdToIndex.get( + Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1); + if (otherAdaptationSetId != -1) { + mergedGroupIndex = Math.min(mergedGroupIndex, otherAdaptationSetId); + } } } - if (outputIndex < adaptationSetIndices.length) { - adaptationSetIndices = Arrays.copyOf(adaptationSetIndices, outputIndex); - } - groupedAdaptationSetIndices[groupCount++] = adaptationSetIndices; + } + + // Merge the groups if necessary. + if (mergedGroupIndex != i) { + List thisGroup = adaptationSetIndexToGroupedIndices.get(i); + List mergedGroup = adaptationSetIndexToGroupedIndices.get(mergedGroupIndex); + mergedGroup.addAll(thisGroup); + adaptationSetIndexToGroupedIndices.put(i, mergedGroup); + adaptationSetGroupedIndices.remove(thisGroup); } } - return groupCount < adaptationSetCount - ? Arrays.copyOf(groupedAdaptationSetIndices, groupCount) : groupedAdaptationSetIndices; + int[][] groupedAdaptationSetIndices = new int[adaptationSetGroupedIndices.size()][]; + for (int i = 0; i < groupedAdaptationSetIndices.length; i++) { + groupedAdaptationSetIndices[i] = Util.toArray(adaptationSetGroupedIndices.get(i)); + // Restore the original adaptation set order within each group. + Arrays.sort(groupedAdaptationSetIndices[i]); + } + return groupedAdaptationSetIndices; } /** @@ -739,9 +784,19 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } private static Descriptor findAdaptationSetSwitchingProperty(List descriptors) { + return findDescriptor(descriptors, "urn:mpeg:dash:adaptation-set-switching:2016"); + } + + @Nullable + private static Descriptor findTrickPlayProperty(List descriptors) { + return findDescriptor(descriptors, "http://dashif.org/guidelines/trickmode"); + } + + @Nullable + private static Descriptor findDescriptor(List descriptors, String schemeIdUri) { for (int i = 0; i < descriptors.size(); i++) { Descriptor descriptor = descriptors.get(i); - if ("urn:mpeg:dash:adaptation-set-switching:2016".equals(descriptor.schemeIdUri)) { + if (schemeIdUri.equals(descriptor.schemeIdUri)) { return descriptor; } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index dcd4b15cae..39cc03dd12 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -307,7 +307,10 @@ public final class DashMediaSource extends BaseMediaSource { @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index 3b52e070a6..187baad76b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java @@ -284,7 +284,10 @@ public final class PlayerEmsgHandler implements Handler.Callback { private final MetadataInputBuffer buffer; /* package */ PlayerTrackEmsgHandler(Allocator allocator) { - this.sampleQueue = new SampleQueue(allocator, DrmSessionManager.getDummyDrmSessionManager()); + this.sampleQueue = new SampleQueue( + allocator, + /* playbackLooper= */ handler.getLooper(), + DrmSessionManager.getDummyDrmSessionManager()); formatHolder = new FormatHolder(); buffer = new MetadataInputBuffer(); } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java index d962374745..b0689eeb11 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/AdaptationSet.java @@ -50,9 +50,10 @@ public class AdaptationSet { */ public final List accessibilityDescriptors; - /** - * Supplemental properties in the adaptation set. - */ + /** Essential properties in the adaptation set. */ + public final List essentialProperties; + + /** Supplemental properties in the adaptation set. */ public final List supplementalProperties; /** @@ -62,21 +63,21 @@ public class AdaptationSet { * {@code TRACK_TYPE_*} constants. * @param representations {@link Representation}s in the adaptation set. * @param accessibilityDescriptors Accessibility descriptors in the adaptation set. + * @param essentialProperties Essential properties in the adaptation set. * @param supplementalProperties Supplemental properties in the adaptation set. */ - public AdaptationSet(int id, int type, List representations, - List accessibilityDescriptors, List supplementalProperties) { + public AdaptationSet( + int id, + int type, + List representations, + List accessibilityDescriptors, + List essentialProperties, + List supplementalProperties) { this.id = id; this.type = type; this.representations = Collections.unmodifiableList(representations); - this.accessibilityDescriptors = - accessibilityDescriptors == null - ? Collections.emptyList() - : Collections.unmodifiableList(accessibilityDescriptors); - this.supplementalProperties = - supplementalProperties == null - ? Collections.emptyList() - : Collections.unmodifiableList(supplementalProperties); + this.accessibilityDescriptors = Collections.unmodifiableList(accessibilityDescriptors); + this.essentialProperties = Collections.unmodifiableList(essentialProperties); + this.supplementalProperties = Collections.unmodifiableList(supplementalProperties); } - } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java index 2d8909f8b4..c21af45d15 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifest.java @@ -224,9 +224,14 @@ public class DashManifest implements FilterableManifest { key = keys.poll(); } while (key.periodIndex == periodIndex && key.groupIndex == adaptationSetIndex); - copyAdaptationSets.add(new AdaptationSet(adaptationSet.id, adaptationSet.type, - copyRepresentations, adaptationSet.accessibilityDescriptors, - adaptationSet.supplementalProperties)); + copyAdaptationSets.add( + new AdaptationSet( + adaptationSet.id, + adaptationSet.type, + copyRepresentations, + adaptationSet.accessibilityDescriptors, + adaptationSet.essentialProperties, + adaptationSet.supplementalProperties)); } while(key.periodIndex == periodIndex); // Add back the last key which doesn't belong to the period being processed keys.addFirst(key); diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index 95129d68c4..6d25c50cf6 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -289,6 +289,7 @@ public class DashManifestParser extends DefaultHandler ArrayList inbandEventStreams = new ArrayList<>(); ArrayList accessibilityDescriptors = new ArrayList<>(); ArrayList roleDescriptors = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(); ArrayList supplementalProperties = new ArrayList<>(); List representationInfos = new ArrayList<>(); @@ -317,6 +318,8 @@ public class DashManifestParser extends DefaultHandler audioChannels = parseAudioChannelConfiguration(xpp); } else if (XmlPullParserUtil.isStartTag(xpp, "Accessibility")) { accessibilityDescriptors.add(parseDescriptor(xpp, "Accessibility")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); } else if (XmlPullParserUtil.isStartTag(xpp, "Representation")) { @@ -334,6 +337,7 @@ public class DashManifestParser extends DefaultHandler language, roleDescriptors, accessibilityDescriptors, + essentialProperties, supplementalProperties, segmentBase, periodDurationMs); @@ -369,14 +373,28 @@ public class DashManifestParser extends DefaultHandler inbandEventStreams)); } - return buildAdaptationSet(id, contentType, representations, accessibilityDescriptors, + return buildAdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, supplementalProperties); } - protected AdaptationSet buildAdaptationSet(int id, int contentType, - List representations, List accessibilityDescriptors, + protected AdaptationSet buildAdaptationSet( + int id, + int contentType, + List representations, + List accessibilityDescriptors, + List essentialProperties, List supplementalProperties) { - return new AdaptationSet(id, contentType, representations, accessibilityDescriptors, + return new AdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, supplementalProperties); } @@ -505,6 +523,7 @@ public class DashManifestParser extends DefaultHandler @Nullable String adaptationSetLanguage, List adaptationSetRoleDescriptors, List adaptationSetAccessibilityDescriptors, + List adaptationSetEssentialProperties, List adaptationSetSupplementalProperties, @Nullable SegmentBase segmentBase, long periodDurationMs) @@ -522,7 +541,9 @@ public class DashManifestParser extends DefaultHandler String drmSchemeType = null; ArrayList drmSchemeDatas = new ArrayList<>(); ArrayList inbandEventStreams = new ArrayList<>(); - ArrayList supplementalProperties = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(adaptationSetEssentialProperties); + ArrayList supplementalProperties = + new ArrayList<>(adaptationSetSupplementalProperties); boolean seenFirstBaseUrl = false; do { @@ -555,6 +576,8 @@ public class DashManifestParser extends DefaultHandler } } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); } else { @@ -576,6 +599,7 @@ public class DashManifestParser extends DefaultHandler adaptationSetRoleDescriptors, adaptationSetAccessibilityDescriptors, codecs, + essentialProperties, supplementalProperties); segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase(); @@ -596,11 +620,14 @@ public class DashManifestParser extends DefaultHandler List roleDescriptors, List accessibilityDescriptors, @Nullable String codecs, + List essentialProperties, List supplementalProperties) { String sampleMimeType = getSampleMimeType(containerMimeType, codecs); @C.SelectionFlags int selectionFlags = parseSelectionFlagsFromRoleDescriptors(roleDescriptors); @C.RoleFlags int roleFlags = parseRoleFlagsFromRoleDescriptors(roleDescriptors); roleFlags |= parseRoleFlagsFromAccessibilityDescriptors(accessibilityDescriptors); + roleFlags |= parseRoleFlagsFromProperties(essentialProperties); + roleFlags |= parseRoleFlagsFromProperties(supplementalProperties); if (sampleMimeType != null) { if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType)) { sampleMimeType = parseEac3SupplementalProperties(supplementalProperties); @@ -1233,6 +1260,18 @@ public class DashManifestParser extends DefaultHandler return result; } + @C.RoleFlags + protected int parseRoleFlagsFromProperties(List accessibilityDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if ("http://dashif.org/guidelines/trickmode".equalsIgnoreCase(descriptor.schemeIdUri)) { + result |= C.ROLE_FLAG_TRICK_PLAY; + } + } + return result; + } + @C.RoleFlags protected int parseDashRoleSchemeValue(@Nullable String value) { if (value == null) { diff --git a/library/dash/src/test/assets/sample_mpd_trick_play b/library/dash/src/test/assets/sample_mpd_trick_play new file mode 100644 index 0000000000..b35c906b5f --- /dev/null +++ b/library/dash/src/test/assets/sample_mpd_trick_play @@ -0,0 +1,32 @@ + + + + + + + + + + + https://test.com/0 + + + + + https://test.com/0 + + + + + + https://test.com/0 + + + + + + https://test.com/0 + + + + diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java index f39a493e9f..9e74ddde45 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java @@ -26,6 +26,8 @@ import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.dash.PlayerEmsgHandler.PlayerEmsgCallback; import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; @@ -35,7 +37,6 @@ import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import com.google.android.exoplayer2.testutil.MediaPeriodAsserts; -import com.google.android.exoplayer2.testutil.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoaderErrorThrower; @@ -43,6 +44,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.LooperMode; @@ -53,7 +55,7 @@ import org.robolectric.annotation.LooperMode; public final class DashMediaPeriodTest { @Test - public void getSteamKeys_isCompatibleWithDashManifestFilter() { + public void getStreamKeys_isCompatibleWithDashManifestFilter() { // Test manifest which covers various edge cases: // - Multiple periods. // - Single and multiple representations per adaptation set. @@ -61,83 +63,220 @@ public final class DashMediaPeriodTest { // - Embedded track groups. // All cases are deliberately combined in one test to catch potential indexing problems which // only occur in combination. - DashManifest testManifest = + DashManifest manifest = createDashManifest( createPeriod( createAdaptationSet( /* id= */ 0, - /* trackType= */ C.TRACK_TYPE_VIDEO, + C.TRACK_TYPE_VIDEO, /* descriptor= */ null, createVideoRepresentation(/* bitrate= */ 1000000))), createPeriod( createAdaptationSet( /* id= */ 100, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 103, 104), + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 103, 104), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 200000), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 400000), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 600000)), createAdaptationSet( /* id= */ 101, - /* trackType= */ C.TRACK_TYPE_AUDIO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 102), + C.TRACK_TYPE_AUDIO, + createSwitchDescriptor(/* ids...= */ 102), createAudioRepresentation(/* bitrate= */ 48000), createAudioRepresentation(/* bitrate= */ 96000)), createAdaptationSet( /* id= */ 102, - /* trackType= */ C.TRACK_TYPE_AUDIO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 101), + C.TRACK_TYPE_AUDIO, + createSwitchDescriptor(/* ids...= */ 101), createAudioRepresentation(/* bitrate= */ 256000)), createAdaptationSet( /* id= */ 103, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 100, 104), + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 100, 104), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 800000), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 1000000)), createAdaptationSet( /* id= */ 104, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids= */ 100, 103), + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 100, 103), createVideoRepresentationWithInbandEventStream(/* bitrate= */ 2000000)), createAdaptationSet( /* id= */ 105, - /* trackType= */ C.TRACK_TYPE_TEXT, + C.TRACK_TYPE_TEXT, /* descriptor= */ null, createTextRepresentation(/* language= */ "eng")), createAdaptationSet( /* id= */ 105, - /* trackType= */ C.TRACK_TYPE_TEXT, + C.TRACK_TYPE_TEXT, /* descriptor= */ null, createTextRepresentation(/* language= */ "ger")))); - FilterableManifestMediaPeriodFactory mediaPeriodFactory = - (manifest, periodIndex) -> - new DashMediaPeriod( - /* id= */ periodIndex, - manifest, - periodIndex, - mock(DashChunkSource.Factory.class), - mock(TransferListener.class), - DrmSessionManager.getDummyDrmSessionManager(), - mock(LoadErrorHandlingPolicy.class), - new EventDispatcher() - .withParameters( - /* windowIndex= */ 0, - /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), - /* mediaTimeOffsetMs= */ 0), - /* elapsedRealtimeOffsetMs= */ 0, - mock(LoaderErrorThrower.class), - mock(Allocator.class), - mock(CompositeSequenceableLoaderFactory.class), - mock(PlayerEmsgCallback.class)); // Ignore embedded metadata as we don't want to select primary group just to get embedded track. MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration( - mediaPeriodFactory, - testManifest, + DashMediaPeriodTest::createDashMediaPeriod, + manifest, /* periodIndex= */ 1, /* ignoredMimeType= */ "application/x-emsg"); } + @Test + public void adaptationSetSwitchingProperty_mergesTrackGroups() { + DashManifest manifest = + createDashManifest( + createPeriod( + createAdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 1, 2), + createVideoRepresentation(/* bitrate= */ 0), + createVideoRepresentation(/* bitrate= */ 1)), + createAdaptationSet( + /* id= */ 3, + C.TRACK_TYPE_VIDEO, + /* descriptor= */ null, + createVideoRepresentation(/* bitrate= */ 300)), + createAdaptationSet( + /* id= */ 2, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 0, 1), + createVideoRepresentation(/* bitrate= */ 200), + createVideoRepresentation(/* bitrate= */ 201)), + createAdaptationSet( + /* id= */ 1, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 0, 2), + createVideoRepresentation(/* bitrate= */ 100)))); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect the three adaptation sets with the switch descriptor to be merged, retaining the + // representations in their original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format), + new TrackGroup(adaptationSets.get(1).representations.get(0).format)); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + + @Test + public void trickPlayProperty_mergesTrackGroups() { + DashManifest manifest = + createDashManifest( + createPeriod( + createAdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 1), + createVideoRepresentation(/* bitrate= */ 0), + createVideoRepresentation(/* bitrate= */ 1)), + createAdaptationSet( + /* id= */ 1, + C.TRACK_TYPE_VIDEO, + /* descriptor= */ null, + createVideoRepresentation(/* bitrate= */ 100)), + createAdaptationSet( + /* id= */ 2, + C.TRACK_TYPE_VIDEO, + /* descriptor= */ null, + createVideoRepresentation(/* bitrate= */ 200), + createVideoRepresentation(/* bitrate= */ 201)), + createAdaptationSet( + /* id= */ 3, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 2), + createVideoRepresentation(/* bitrate= */ 300)))); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect the trick play adaptation sets to be merged with the ones to which they refer, + // retaining representations in their original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(1).representations.get(0).format), + new TrackGroup( + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format)); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + + @Test + public void adaptationSetSwitchingProperty_andTrickPlayProperty_mergesTrackGroups() { + DashManifest manifest = + createDashManifest( + createPeriod( + createAdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 1), + createVideoRepresentation(/* bitrate= */ 0), + createVideoRepresentation(/* bitrate= */ 1)), + createAdaptationSet( + /* id= */ 1, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 2), + createVideoRepresentation(/* bitrate= */ 100)), + createAdaptationSet( + /* id= */ 2, + C.TRACK_TYPE_VIDEO, + createSwitchDescriptor(/* ids...= */ 1), + createVideoRepresentation(/* bitrate= */ 200), + createVideoRepresentation(/* bitrate= */ 201)), + createAdaptationSet( + /* id= */ 3, + C.TRACK_TYPE_VIDEO, + createTrickPlayDescriptor(/* mainAdaptationSetId= */ 2), + createVideoRepresentation(/* bitrate= */ 300)))); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect all adaptation sets to be merged into one group, retaining representations in their + // original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(1).representations.get(0).format, + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format)); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + + private static DashMediaPeriod createDashMediaPeriod(DashManifest manifest, int periodIndex) { + return new DashMediaPeriod( + /* id= */ periodIndex, + manifest, + periodIndex, + mock(DashChunkSource.Factory.class), + mock(TransferListener.class), + DrmSessionManager.getDummyDrmSessionManager(), + mock(LoadErrorHandlingPolicy.class), + new EventDispatcher() + .withParameters( + /* windowIndex= */ 0, + /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), + /* mediaTimeOffsetMs= */ 0), + /* elapsedRealtimeOffsetMs= */ 0, + mock(LoaderErrorThrower.class), + mock(Allocator.class), + mock(CompositeSequenceableLoaderFactory.class), + mock(PlayerEmsgCallback.class)); + } + private static DashManifest createDashManifest(Period... periods) { return new DashManifest( /* availabilityStartTimeMs= */ 0, @@ -165,6 +304,7 @@ public final class DashMediaPeriodTest { trackType, Arrays.asList(representations), /* accessibilityDescriptors= */ Collections.emptyList(), + /* essentialProperties= */ Collections.emptyList(), descriptor == null ? Collections.emptyList() : Collections.singletonList(descriptor)); } @@ -244,6 +384,13 @@ public final class DashMediaPeriodTest { /* id= */ null); } + private static Descriptor createTrickPlayDescriptor(int mainAdaptationSetId) { + return new Descriptor( + /* schemeIdUri= */ "http://dashif.org/guidelines/trickmode", + /* value= */ Integer.toString(mainAdaptationSetId), + /* id= */ null); + } + private static Descriptor getInbandEventDescriptor() { return new Descriptor( /* schemeIdUri= */ "inBandSchemeIdUri", /* value= */ "inBandValue", /* id= */ "inBandId"); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java index 6e769b72e1..6b8bc8ad25 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashUtilTest.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegm import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; +import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -38,21 +39,21 @@ public final class DashUtilTest { @Test public void testLoadDrmInitDataFromManifest() throws Exception { - Period period = newPeriod(newAdaptationSets(newRepresentations(newDrmInitData()))); + Period period = newPeriod(newAdaptationSet(newRepresentations(newDrmInitData()))); DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); assertThat(drmInitData).isEqualTo(newDrmInitData()); } @Test public void testLoadDrmInitDataMissing() throws Exception { - Period period = newPeriod(newAdaptationSets(newRepresentations(null /* no init data */))); + Period period = newPeriod(newAdaptationSet(newRepresentations(null /* no init data */))); DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); assertThat(drmInitData).isNull(); } @Test public void testLoadDrmInitDataNoRepresentations() throws Exception { - Period period = newPeriod(newAdaptationSets(/* no representation */ )); + Period period = newPeriod(newAdaptationSet(/* no representation */ )); DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); assertThat(drmInitData).isNull(); } @@ -68,8 +69,14 @@ public final class DashUtilTest { return new Period("", 0, Arrays.asList(adaptationSets)); } - private static AdaptationSet newAdaptationSets(Representation... representations) { - return new AdaptationSet(0, C.TRACK_TYPE_VIDEO, Arrays.asList(representations), null, null); + private static AdaptationSet newAdaptationSet(Representation... representations) { + return new AdaptationSet( + /* id= */ 0, + C.TRACK_TYPE_VIDEO, + Arrays.asList(representations), + /* accessibilityDescriptors= */ Collections.emptyList(), + /* essentialProperties= */ Collections.emptyList(), + /* supplementalProperties= */ Collections.emptyList()); } private static Representation newRepresentations(DrmInitData drmInitData) { diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java index 390a18d2cc..ea03770c89 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java @@ -45,6 +45,7 @@ public class DashManifestParserTest { private static final String SAMPLE_MPD_SEGMENT_TEMPLATE = "sample_mpd_segment_template"; private static final String SAMPLE_MPD_EVENT_STREAM = "sample_mpd_event_stream"; private static final String SAMPLE_MPD_LABELS = "sample_mpd_labels"; + private static final String SAMPLE_MPD_TRICK_PLAY = "sample_mpd_trick_play"; private static final String NEXT_TAG_NAME = "Next"; private static final String NEXT_TAG = "<" + NEXT_TAG_NAME + "/>"; @@ -169,7 +170,7 @@ public class DashManifestParserTest { DashManifestParser parser = new DashManifestParser(); DashManifest mpd = parser.parse( - Uri.parse("Https://example.com/test.mpd"), + Uri.parse("https://example.com/test.mpd"), TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), SAMPLE_MPD)); ProgramInformation expectedProgramInformation = new ProgramInformation( @@ -192,6 +193,46 @@ public class DashManifestParserTest { assertThat(adaptationSets.get(1).representations.get(0).format.label).isEqualTo("video label"); } + @Test + public void parseMediaPresentationDescription_trickPlay() throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), SAMPLE_MPD_TRICK_PLAY)); + + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + AdaptationSet adaptationSet = adaptationSets.get(0); + assertThat(adaptationSet.essentialProperties).isEmpty(); + assertThat(adaptationSet.supplementalProperties).isEmpty(); + assertThat(adaptationSet.representations.get(0).format.roleFlags).isEqualTo(0); + + adaptationSet = adaptationSets.get(1); + assertThat(adaptationSet.essentialProperties).isEmpty(); + assertThat(adaptationSet.supplementalProperties).isEmpty(); + assertThat(adaptationSet.representations.get(0).format.roleFlags).isEqualTo(0); + + adaptationSet = adaptationSets.get(2); + assertThat(adaptationSet.essentialProperties).hasSize(1); + assertThat(adaptationSet.essentialProperties.get(0).schemeIdUri) + .isEqualTo("http://dashif.org/guidelines/trickmode"); + assertThat(adaptationSet.essentialProperties.get(0).value).isEqualTo("0"); + assertThat(adaptationSet.supplementalProperties).isEmpty(); + assertThat(adaptationSet.representations.get(0).format.roleFlags) + .isEqualTo(C.ROLE_FLAG_TRICK_PLAY); + + adaptationSet = adaptationSets.get(3); + assertThat(adaptationSet.essentialProperties).isEmpty(); + assertThat(adaptationSet.supplementalProperties).hasSize(1); + assertThat(adaptationSet.supplementalProperties.get(0).schemeIdUri) + .isEqualTo("http://dashif.org/guidelines/trickmode"); + assertThat(adaptationSet.supplementalProperties.get(0).value).isEqualTo("1"); + assertThat(adaptationSet.representations.get(0).format.roleFlags) + .isEqualTo(C.ROLE_FLAG_TRICK_PLAY); + } + @Test public void parseSegmentTimeline_repeatCount() throws Exception { DashManifestParser parser = new DashManifestParser(); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java index a336602965..3f3b35b5b9 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java @@ -239,6 +239,12 @@ public class DashManifestTest { } private static AdaptationSet newAdaptationSet(int seed, Representation... representations) { - return new AdaptationSet(++seed, ++seed, Arrays.asList(representations), null, null); + return new AdaptationSet( + ++seed, + ++seed, + Arrays.asList(representations), + /* accessibilityDescriptors= */ Collections.emptyList(), + /* essentialProperties= */ Collections.emptyList(), + /* supplementalProperties= */ Collections.emptyList()); } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index 16dedb6c21..cc2fe618fe 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -323,7 +323,10 @@ public final class HlsMediaSource extends BaseMediaSource @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 03a67a1407..c7116ba878 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.hls; import android.net.Uri; import android.os.Handler; +import android.os.Looper; import android.util.SparseIntArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -907,7 +908,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO; FormatAdjustingSampleQueue trackOutput = - new FormatAdjustingSampleQueue(allocator, drmSessionManager, overridingDrmInitData); + new FormatAdjustingSampleQueue( + allocator, + /* playbackLooper= */ handler.getLooper(), + drmSessionManager, + overridingDrmInitData); if (isAudioVideo) { trackOutput.setDrmInitData(drmInitData); } @@ -1331,9 +1336,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; public FormatAdjustingSampleQueue( Allocator allocator, + Looper playbackLooper, DrmSessionManager drmSessionManager, Map overridingDrmInitData) { - super(allocator, drmSessionManager); + super(allocator, playbackLooper, drmSessionManager); this.overridingDrmInitData = overridingDrmInitData; } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 8cc848dfa4..89dd8039ef 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -273,7 +273,10 @@ public final class SsMediaSource extends BaseMediaSource @Override public Factory setDrmSessionManager(DrmSessionManager drmSessionManager) { Assertions.checkState(!isCreateCalled); - this.drmSessionManager = drmSessionManager; + this.drmSessionManager = + drmSessionManager != null + ? drmSessionManager + : DrmSessionManager.getDummyDrmSessionManager(); return this; } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 89bcaf84bc..0d1f21b167 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ui; import android.annotation.TargetApi; +import android.animation.ValueAnimator; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; @@ -166,6 +167,9 @@ public class DefaultTimeBar extends View implements TimeBar { private static final int DEFAULT_INCREMENT_COUNT = 20; + private static final float SHOWN_SCRUBBER_SCALE = 1.0f; + private static final float HIDDEN_SCRUBBER_SCALE = 0.0f; + /** * The name of the Android SDK view that most closely resembles this custom view. Used as the * class name for accessibility. @@ -195,7 +199,6 @@ public class DefaultTimeBar extends View implements TimeBar { private final Formatter formatter; private final Runnable stopScrubbingRunnable; private final CopyOnWriteArraySet listeners; - private final int[] locationOnScreen; private final Point touchPosition; private final float density; @@ -204,6 +207,8 @@ public class DefaultTimeBar extends View implements TimeBar { private int lastCoarseScrubXPosition; @MonotonicNonNull private Rect lastExclusionRectangle; + private ValueAnimator scrubberScalingAnimator; + private float scrubberScale; private boolean scrubbing; private long scrubPosition; private long duration; @@ -249,7 +254,6 @@ public class DefaultTimeBar extends View implements TimeBar { scrubberPaint = new Paint(); scrubberPaint.setAntiAlias(true); listeners = new CopyOnWriteArraySet<>(); - locationOnScreen = new int[2]; touchPosition = new Point(); // Calculate the dimensions and paints for drawn elements. @@ -331,6 +335,13 @@ public class DefaultTimeBar extends View implements TimeBar { (Math.max(scrubberDisabledSize, Math.max(scrubberEnabledSize, scrubberDraggedSize)) + 1) / 2; } + scrubberScale = 1.0f; + scrubberScalingAnimator = new ValueAnimator(); + scrubberScalingAnimator.addUpdateListener( + animation -> { + scrubberScale = (float) animation.getAnimatedValue(); + invalidate(seekBounds); + }); duration = C.TIME_UNSET; keyTimeIncrement = C.TIME_UNSET; keyCountIncrement = DEFAULT_INCREMENT_COUNT; @@ -340,6 +351,44 @@ public class DefaultTimeBar extends View implements TimeBar { } } + /** Shows the scrubber handle. */ + public void showScrubber() { + showScrubber(/* showAnimationDurationMs= */ 0); + } + + /** + * Shows the scrubber handle with animation. + * + * @param showAnimationDurationMs The duration for scrubber showing animation. + */ + public void showScrubber(long showAnimationDurationMs) { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberScalingAnimator.setFloatValues(scrubberScale, SHOWN_SCRUBBER_SCALE); + scrubberScalingAnimator.setDuration(showAnimationDurationMs); + scrubberScalingAnimator.start(); + } + + /** Hides the scrubber handle. */ + public void hideScrubber() { + hideScrubber(/* hideAnimationDurationMs= */ 0); + } + + /** + * Hides the scrubber handle with animation. + * + * @param hideAnimationDurationMs The duration for scrubber hiding animation. + */ + public void hideScrubber(long hideAnimationDurationMs) { + if (scrubberScalingAnimator.isStarted()) { + scrubberScalingAnimator.cancel(); + } + scrubberScalingAnimator.setFloatValues(scrubberScale, HIDDEN_SCRUBBER_SCALE); + scrubberScalingAnimator.setDuration(hideAnimationDurationMs); + scrubberScalingAnimator.start(); + } + /** * Sets the color for the portion of the time bar representing media before the playback position. * @@ -755,10 +804,7 @@ public class DefaultTimeBar extends View implements TimeBar { } private Point resolveRelativeTouchPosition(MotionEvent motionEvent) { - getLocationOnScreen(locationOnScreen); - touchPosition.set( - ((int) motionEvent.getRawX()) - locationOnScreen[0], - ((int) motionEvent.getRawY()) - locationOnScreen[1]); + touchPosition.set((int) motionEvent.getX(), (int) motionEvent.getY()); return touchPosition; } @@ -820,11 +866,11 @@ public class DefaultTimeBar extends View implements TimeBar { if (scrubberDrawable == null) { int scrubberSize = (scrubbing || isFocused()) ? scrubberDraggedSize : (isEnabled() ? scrubberEnabledSize : scrubberDisabledSize); - int playheadRadius = scrubberSize / 2; + int playheadRadius = (int) ((scrubberSize * scrubberScale) / 2); canvas.drawCircle(playheadX, playheadY, playheadRadius, scrubberPaint); } else { - int scrubberDrawableWidth = scrubberDrawable.getIntrinsicWidth(); - int scrubberDrawableHeight = scrubberDrawable.getIntrinsicHeight(); + int scrubberDrawableWidth = (int) (scrubberDrawable.getIntrinsicWidth() * scrubberScale); + int scrubberDrawableHeight = (int) (scrubberDrawable.getIntrinsicHeight() * scrubberScale); scrubberDrawable.setBounds( playheadX - scrubberDrawableWidth / 2, playheadY - scrubberDrawableHeight / 2, diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java index 76768804df..2258f528d4 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java @@ -33,6 +33,7 @@ import android.text.TextPaint; import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; import android.util.DisplayMetrics; import androidx.annotation.Nullable; @@ -64,7 +65,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final float spacingAdd; private final TextPaint textPaint; - private final Paint paint; + private final Paint windowPaint; + private final Paint bitmapPaint; // Previous input variables. @Nullable private CharSequence cueText; @@ -98,6 +100,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // Derived drawing variables. private @MonotonicNonNull StaticLayout textLayout; + private @MonotonicNonNull StaticLayout edgeLayout; private int textLeft; private int textTop; private int textPaddingX; @@ -122,9 +125,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; textPaint.setAntiAlias(true); textPaint.setSubpixelText(true); - paint = new Paint(); - paint.setAntiAlias(true); - paint.setStyle(Style.FILL); + windowPaint = new Paint(); + windowPaint.setAntiAlias(true); + windowPaint.setStyle(Style.FILL); + + bitmapPaint = new Paint(); + bitmapPaint.setAntiAlias(true); + bitmapPaint.setFilterBitmap(true); } /** @@ -286,11 +293,38 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } } + // Remove embedded font color to not destroy edges, otherwise it overrides edge color. + SpannableStringBuilder cueTextEdge = new SpannableStringBuilder(cueText); + if (edgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) { + int cueLength = cueTextEdge.length(); + ForegroundColorSpan[] foregroundColorSpans = + cueTextEdge.getSpans(0, cueLength, ForegroundColorSpan.class); + for (ForegroundColorSpan foregroundColorSpan : foregroundColorSpans) { + cueTextEdge.removeSpan(foregroundColorSpan); + } + } + + // EDGE_TYPE_NONE & EDGE_TYPE_DROP_SHADOW both paint in one pass, they ignore cueTextEdge. + // In other cases we use two painters and we need to apply the background in the first one only, + // otherwise the background color gets drawn in front of the edge color + // (https://github.com/google/ExoPlayer/pull/6724#issuecomment-564650572). if (Color.alpha(backgroundColor) > 0) { - SpannableStringBuilder newCueText = new SpannableStringBuilder(cueText); - newCueText.setSpan( - new BackgroundColorSpan(backgroundColor), 0, newCueText.length(), Spanned.SPAN_PRIORITY); - cueText = newCueText; + if (edgeType == CaptionStyleCompat.EDGE_TYPE_NONE + || edgeType == CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW) { + SpannableStringBuilder newCueText = new SpannableStringBuilder(cueText); + newCueText.setSpan( + new BackgroundColorSpan(backgroundColor), + 0, + newCueText.length(), + Spanned.SPAN_PRIORITY); + cueText = newCueText; + } else { + cueTextEdge.setSpan( + new BackgroundColorSpan(backgroundColor), + 0, + cueTextEdge.length(), + Spanned.SPAN_PRIORITY); + } } Alignment textAlignment = cueTextAlignment == null ? Alignment.ALIGN_CENTER : cueTextAlignment; @@ -366,6 +400,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // Update the derived drawing variables. this.textLayout = new StaticLayout(cueText, textPaint, textWidth, textAlignment, spacingMult, spacingAdd, true); + this.edgeLayout = + new StaticLayout( + cueTextEdge, textPaint, textWidth, textAlignment, spacingMult, spacingAdd, true); this.textLeft = textLeft; this.textTop = textTop; this.textPaddingX = textPaddingX; @@ -405,8 +442,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } private void drawTextLayout(Canvas canvas) { - StaticLayout layout = textLayout; - if (layout == null) { + StaticLayout textLayout = this.textLayout; + StaticLayout edgeLayout = this.edgeLayout; + if (textLayout == null || edgeLayout == null) { // Nothing to draw. return; } @@ -415,9 +453,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; canvas.translate(textLeft, textTop); if (Color.alpha(windowColor) > 0) { - paint.setColor(windowColor); - canvas.drawRect(-textPaddingX, 0, layout.getWidth() + textPaddingX, layout.getHeight(), - paint); + windowPaint.setColor(windowColor); + canvas.drawRect( + -textPaddingX, + 0, + textLayout.getWidth() + textPaddingX, + textLayout.getHeight(), + windowPaint); } if (edgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) { @@ -425,7 +467,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; textPaint.setStrokeWidth(outlineWidth); textPaint.setColor(edgeColor); textPaint.setStyle(Style.FILL_AND_STROKE); - layout.draw(canvas); + edgeLayout.draw(canvas); } else if (edgeType == CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW) { textPaint.setShadowLayer(shadowRadius, shadowOffset, shadowOffset, edgeColor); } else if (edgeType == CaptionStyleCompat.EDGE_TYPE_RAISED @@ -437,13 +479,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; textPaint.setColor(foregroundColor); textPaint.setStyle(Style.FILL); textPaint.setShadowLayer(shadowRadius, -offset, -offset, colorUp); - layout.draw(canvas); + edgeLayout.draw(canvas); textPaint.setShadowLayer(shadowRadius, offset, offset, colorDown); } textPaint.setColor(foregroundColor); textPaint.setStyle(Style.FILL); - layout.draw(canvas); + textLayout.draw(canvas); textPaint.setShadowLayer(0, 0, 0, 0); canvas.restoreToCount(saveCount); @@ -451,7 +493,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @RequiresNonNull({"cueBitmap", "bitmapRect"}) private void drawBitmapLayout(Canvas canvas) { - canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, /* paint= */ null); + canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, bitmapPaint); } /** diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index b65accdf3f..2d6beff416 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java @@ -685,13 +685,13 @@ public abstract class Action { */ public static final class WaitForPlaybackState extends Action { - private final int targetPlaybackState; + @Player.State private final int targetPlaybackState; /** * @param tag A tag to use for logging. * @param targetPlaybackState The playback state to wait for. */ - public WaitForPlaybackState(String tag, int targetPlaybackState) { + public WaitForPlaybackState(String tag, @Player.State int targetPlaybackState) { super(tag, "WaitForPlaybackState"); this.targetPlaybackState = targetPlaybackState; } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java index f6ab4b9924..4800df662c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java @@ -433,7 +433,7 @@ public final class ActionSchedule { * @param targetPlaybackState The target playback state. * @return The builder, for convenience. */ - public Builder waitForPlaybackState(int targetPlaybackState) { + public Builder waitForPlaybackState(@Player.State int targetPlaybackState) { return apply(new WaitForPlaybackState(tag, targetPlaybackState)); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java index a591546613..dcf454449c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeClock.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.testutil; import android.os.Handler.Callback; import android.os.Looper; import android.os.Message; +import androidx.annotation.GuardedBy; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.HandlerWrapper; import java.util.ArrayList; @@ -28,16 +29,31 @@ public class FakeClock implements Clock { private final List wakeUpTimes; private final List handlerMessages; + private final long bootTimeMs; - private long currentTimeMs; + @GuardedBy("this") + private long timeSinceBootMs; /** - * Create {@link FakeClock} with an arbitrary initial timestamp. + * Creates a fake clock assuming the system was booted exactly at time {@code 0} (the Unix Epoch) + * and {@code initialTimeMs} milliseconds have passed since system boot. * - * @param initialTimeMs Initial timestamp in milliseconds. + * @param initialTimeMs The initial elapsed time since the boot time, in milliseconds. */ public FakeClock(long initialTimeMs) { - this.currentTimeMs = initialTimeMs; + this(/* bootTimeMs= */ 0, initialTimeMs); + } + + /** + * Creates a fake clock specifying when the system was booted and how much time has passed since + * then. + * + * @param bootTimeMs The time the system was booted since the Unix Epoch, in milliseconds. + * @param initialTimeMs The initial elapsed time since the boot time, in milliseconds. + */ + public FakeClock(long bootTimeMs, long initialTimeMs) { + this.bootTimeMs = bootTimeMs; + this.timeSinceBootMs = initialTimeMs; this.wakeUpTimes = new ArrayList<>(); this.handlerMessages = new ArrayList<>(); } @@ -48,23 +64,28 @@ public class FakeClock implements Clock { * @param timeDiffMs The amount of time to add to the timestamp in milliseconds. */ public synchronized void advanceTime(long timeDiffMs) { - currentTimeMs += timeDiffMs; + timeSinceBootMs += timeDiffMs; for (Long wakeUpTime : wakeUpTimes) { - if (wakeUpTime <= currentTimeMs) { + if (wakeUpTime <= timeSinceBootMs) { notifyAll(); break; } } for (int i = handlerMessages.size() - 1; i >= 0; i--) { - if (handlerMessages.get(i).maybeSendToTarget(currentTimeMs)) { + if (handlerMessages.get(i).maybeSendToTarget(timeSinceBootMs)) { handlerMessages.remove(i); } } } + @Override + public synchronized long currentTimeMillis() { + return bootTimeMs + timeSinceBootMs; + } + @Override public synchronized long elapsedRealtime() { - return currentTimeMs; + return timeSinceBootMs; } @Override @@ -77,9 +98,9 @@ public class FakeClock implements Clock { if (sleepTimeMs <= 0) { return; } - Long wakeUpTimeMs = currentTimeMs + sleepTimeMs; + Long wakeUpTimeMs = timeSinceBootMs + sleepTimeMs; wakeUpTimes.add(wakeUpTimeMs); - while (currentTimeMs < wakeUpTimeMs) { + while (timeSinceBootMs < wakeUpTimeMs) { try { wait(); } catch (InterruptedException e) { @@ -97,7 +118,7 @@ public class FakeClock implements Clock { /** Adds a handler post to list of pending messages. */ protected synchronized boolean addHandlerMessageAtTime( HandlerWrapper handler, Runnable runnable, long timeMs) { - if (timeMs <= currentTimeMs) { + if (timeMs <= timeSinceBootMs) { return handler.post(runnable); } handlerMessages.add(new HandlerMessageData(timeMs, handler, runnable)); @@ -107,7 +128,7 @@ public class FakeClock implements Clock { /** Adds an empty handler message to list of pending messages. */ protected synchronized boolean addHandlerMessageAtTime( HandlerWrapper handler, int message, long timeMs) { - if (timeMs <= currentTimeMs) { + if (timeMs <= timeSinceBootMs) { return handler.sendEmptyMessage(message); } handlerMessages.add(new HandlerMessageData(timeMs, handler, message)); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java index 42fc40e72d..82f56b262e 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/MediaPeriodAsserts.java @@ -54,6 +54,17 @@ public final class MediaPeriodAsserts { private MediaPeriodAsserts() {} + /** + * Prepares the {@link MediaPeriod} and asserts that it provides the specified track groups. + * + * @param mediaPeriod The {@link MediaPeriod} to test. + * @param expectedGroups The expected track groups. + */ + public static void assertTrackGroups(MediaPeriod mediaPeriod, TrackGroupArray expectedGroups) { + TrackGroupArray actualGroups = prepareAndGetTrackGroups(mediaPeriod); + assertThat(actualGroups).isEqualTo(expectedGroups); + } + /** * Asserts that the values returns by {@link MediaPeriod#getStreamKeys(List)} are compatible with * a {@link FilterableManifest} using these stream keys. @@ -85,7 +96,7 @@ public final class MediaPeriodAsserts { int periodIndex, @Nullable String ignoredMimeType) { MediaPeriod mediaPeriod = mediaPeriodFactory.createMediaPeriod(manifest, periodIndex); - TrackGroupArray trackGroupArray = getTrackGroups(mediaPeriod); + TrackGroupArray trackGroupArray = prepareAndGetTrackGroups(mediaPeriod); // Create test vector of query test selections: // - One selection with one track per group, two tracks or all tracks. @@ -146,7 +157,7 @@ public final class MediaPeriodAsserts { // The filtered manifest should only have one period left. MediaPeriod filteredMediaPeriod = mediaPeriodFactory.createMediaPeriod(filteredManifest, /* periodIndex= */ 0); - TrackGroupArray filteredTrackGroupArray = getTrackGroups(filteredMediaPeriod); + TrackGroupArray filteredTrackGroupArray = prepareAndGetTrackGroups(filteredMediaPeriod); for (TrackSelection trackSelection : testSelection) { if (ignoredMimeType != null && ignoredMimeType.equals(trackSelection.getFormat(0).sampleMimeType)) { @@ -186,8 +197,8 @@ public final class MediaPeriodAsserts { return true; } - private static TrackGroupArray getTrackGroups(MediaPeriod mediaPeriod) { - AtomicReference trackGroupArray = new AtomicReference<>(null); + private static TrackGroupArray prepareAndGetTrackGroups(MediaPeriod mediaPeriod) { + AtomicReference trackGroupArray = new AtomicReference<>(); DummyMainThread dummyMainThread = new DummyMainThread(); ConditionVariable preparedCondition = new ConditionVariable(); dummyMainThread.runOnMainThread( diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index c47b438100..b0beb1ba13 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -36,6 +36,9 @@ import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.SystemClock; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.io.InputStream; @@ -441,4 +444,22 @@ public class TestUtil { } return new DefaultExtractorInput(dataSource, position, length); } + + /** + * Creates a {@link ConditionVariable} whose {@link ConditionVariable#block(long)} method times + * out according to wallclock time when used in Robolectric tests. + */ + public static ConditionVariable createRobolectricConditionVariable() { + return new ConditionVariable( + new SystemClock() { + @Override + public long elapsedRealtime() { + // elapsedRealtime() does not advance during Robolectric test execution, so use + // currentTimeMillis() instead. This is technically unsafe because this clock is not + // guaranteed to be monotonic, but in practice it will work provided the clock of the + // host machine does not change during test execution. + return Clock.DEFAULT.currentTimeMillis(); + } + }); + } } diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java index c82980d7a4..55e0d29f01 100644 --- a/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/FakeClockTest.java @@ -35,6 +35,25 @@ public final class FakeClockTest { private static final long TIMEOUT_MS = 10000; + @Test + public void currentTimeMillis_withoutBootTime() { + FakeClock fakeClock = new FakeClock(/* initialTimeMs= */ 10); + assertThat(fakeClock.currentTimeMillis()).isEqualTo(10); + } + + @Test + public void currentTimeMillis_withBootTime() { + FakeClock fakeClock = new FakeClock(/* bootTimeMs= */ 150, /* initialTimeMs= */ 200); + assertThat(fakeClock.currentTimeMillis()).isEqualTo(350); + } + + @Test + public void currentTimeMillis_advanceTime_currentTimeHasAdvanced() { + FakeClock fakeClock = new FakeClock(/* bootTimeMs= */ 100, /* initialTimeMs= */ 50); + fakeClock.advanceTime(/* timeDiffMs */ 250); + assertThat(fakeClock.currentTimeMillis()).isEqualTo(400); + } + @Test public void testAdvanceTime() { FakeClock fakeClock = new FakeClock(2000); diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java new file mode 100644 index 0000000000..0a999c4161 --- /dev/null +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/TestUtilTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.testutil; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.util.ConditionVariable; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link TestUtil}. */ +@RunWith(AndroidJUnit4.class) +public class TestUtilTest { + + @Test + public void createRobolectricConditionVariable_blockWithTimeout_timesOut() + throws InterruptedException { + ConditionVariable conditionVariable = TestUtil.createRobolectricConditionVariable(); + assertThat(conditionVariable.block(/* timeoutMs= */ 1)).isFalse(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void createRobolectricConditionVariable_blockWithTimeout_blocksForAtLeastTimeout() + throws InterruptedException { + ConditionVariable conditionVariable = TestUtil.createRobolectricConditionVariable(); + long startTimeMs = System.currentTimeMillis(); + assertThat(conditionVariable.block(/* timeoutMs= */ 500)).isFalse(); + long endTimeMs = System.currentTimeMillis(); + assertThat(endTimeMs - startTimeMs).isAtLeast(500); + } +}