diff --git a/.hgignore b/.hgignore index a32dfee85d..e2c54bcf61 100644 --- a/.hgignore +++ b/.hgignore @@ -65,6 +65,7 @@ extensions/vp9/src/main/jni/libyuv # AV1 extension extensions/av1/src/main/jni/libgav1 +extensions/av1/src/main/jni/cpu_features # Opus extension extensions/opus/src/main/jni/libopus diff --git a/README.md b/README.md index d488f4113e..ac4c41b0fe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ExoPlayer # +# ExoPlayer # ExoPlayer is an application level media player for Android. It provides an alternative to Android’s MediaPlayer API for playing audio and video both diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c38cd08da9..8ef1c7e5eb 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,142 +2,428 @@ ### dev-v2 (not yet released) +* Track selection: + * Add option to specify multiple preferred audio or text languages. +* Data sources: + * Add support for `android.resource` URI scheme in `RawResourceDataSource` + ([#7866](https://github.com/google/ExoPlayer/issues/7866)). * Core library: - * Added `TextComponent.getCurrentCues` because the current cues are no - longer forwarded to a new `TextOutput` in `SimpleExoPlayer` - automatically. - * Add opt-in to verify correct thread usage with - `SimpleExoPlayer.setThrowsWhenUsingWrongThread(true)` - ([#4463](https://github.com/google/ExoPlayer/issues/4463)). - * Fix bug where `PlayerMessages` throw an exception after `MediaSources` - are removed from the playlist - ([#7278](https://github.com/google/ExoPlayer/issues/7278)). - * Add playbackPositionUs parameter to 'LoadControl.shouldContinueLoading'. - * The `DefaultLoadControl` default minimum buffer is set to 50 seconds, - equal to the default maximum buffer. `DefaultLoadControl` applies the - same behavior for audio and video. - * Add API in `AnalyticsListener` to report video frame processing offset. - `MediaCodecVideoRenderer` reports the event. - * Add fields `videoFrameProcessingOffsetUsSum` and - `videoFrameProcessingOffsetUsCount` in `DecoderCounters` to compute the - average video frame processing offset. - * Add playlist API - ([#6161](https://github.com/google/ExoPlayer/issues/6161)). - * Add `play` and `pause` methods to `Player`. - * Add `Player.getCurrentLiveOffset` to conveniently return the live - offset. - * Add `Player.onPlayWhenReadyChanged` with reasons. - * Add `Player.onPlaybackStateChanged` and deprecate - `Player.onPlayerStateChanged`. - * Add `Player.setAudioSessionId` to set the session ID attached to the - `AudioTrack`. - * Deprecate and rename `getPlaybackError` to `getPlayerError` for - consistency. - * Deprecate and rename `onLoadingChanged` to `onIsLoadingChanged` for - consistency. - * Deprecate `onSeekProcessed` because all seek changes happen instantly - now and listening to `onPositionDiscontinuity` is sufficient. - * Add `ExoPlayer.setPauseAtEndOfMediaItems` to let the player pause at the - end of each media item - ([#5660](https://github.com/google/ExoPlayer/issues/5660)). - * Split `setPlaybackParameter` into `setPlaybackSpeed` and - `AudioComponent.setSkipSilenceEnabled` with callbacks - `onPlaybackSpeedChanged` and - `AudioListener.onSkipSilenceEnabledChanged`. - * Make `MediaSourceEventListener.LoadEventInfo` and - `MediaSourceEventListener.MediaLoadData` top-level classes. - * Rename `MediaCodecRenderer.onOutputFormatChanged` to - `MediaCodecRenderer.onOutputMediaFormatChanged`, further clarifying the - distinction between `Format` and `MediaFormat`. - * Improve `Format` propagation within the media codec renderer - ([#6646](https://github.com/google/ExoPlayer/issues/6646)). - * Move player message-related constants from `C` to `Renderer`, to avoid - having the constants class depend on player/renderer classes. - * Split out `common` and `extractor` submodules. - * Allow to explicitly send `PlayerMessage`s at the end of a stream. - * Add `DataSpec.Builder` and deprecate most `DataSpec` constructors. - * Add `DataSpec.customData` to allow applications to pass custom data - through `DataSource` chains. - * Add a `Format.Builder` and deprecate all `Format.create*` methods and - most `Format.copyWith*` methods. - * Split `Format.bitrate` into `Format.averageBitrate` and - `Format.peakBitrate` - ([#2863](https://github.com/google/ExoPlayer/issues/2863)). - * Add option to `MergingMediaSource` to adjust the time offsets between - the merged sources + * Suppress Guava-related ProGuard/R8 warnings + ([#7904](https://github.com/google/ExoPlayer/issues/7904)). + +### 2.12.0 (2020-09-11) ### + +* Core library: + * `Player`: + * Add a top level playlist API based on a new `MediaItem` class + ([#6161](https://github.com/google/ExoPlayer/issues/6161)). The + new methods for playlist manipulation are `setMediaItem(s)`, + `addMediaItem(s)`, `moveMediaItem(s)`, `removeMediaItem(s)` and + `clearMediaItems`. The playlist can be queried using + `getMediaItemCount` and `getMediaItemAt`. This API should be used + instead of `ConcatenatingMediaSource` in most cases. + * Add `getCurrentMediaItem` for getting the currently playing item + in the playlist. + * Add `EventListener.onMediaItemTransition` to report when + playback transitions from one item to another in the playlist. + * Add `play` and `pause` convenience methods. They are equivalent to + `setPlayWhenReady(true)` and `setPlayWhenReady(false)` respectively. + * Add `getCurrentLiveOffset` for getting the offset of the current + playback position from the live edge of a live stream. + * Add `getTrackSelector` for getting the `TrackSelector` used by the + player. + * Add `AudioComponent.setAudioSessionId` to set the audio session ID. + This method is also available on `SimpleExoPlayer`. + * Add `TextComponent.getCurrentCues` to get the current cues. This + method is also available on `SimpleExoPlayer`. The current cues are + no longer automatically forwarded to a `TextOutput` when it's added + to a `SimpleExoPlayer`. + * Add `Player.DeviceComponent` to query and control the device volume. + `SimpleExoPlayer` implements this interface. + * Deprecate and rename `getPlaybackError` to `getPlayerError` for + consistency. + * Deprecate and rename `onLoadingChanged` to `onIsLoadingChanged` for + consistency. + * Deprecate `EventListener.onPlayerStateChanged`, replacing it with + `EventListener.onPlayWhenReadyChanged` and + `EventListener.onPlaybackStateChanged`. + * Deprecate `EventListener.onSeekProcessed` because seek changes now + happen instantly and listening to `onPositionDiscontinuity` is + sufficient. + * `ExoPlayer`: + * Add `setMediaSource(s)` and `addMediaSource(s)` to `ExoPlayer`, for + adding `MediaSource` instances directly to the playlist. + * Add `ExoPlayer.setPauseAtEndOfMediaItems` to let the player pause at + the end of each media item + ([#5660](https://github.com/google/ExoPlayer/issues/5660)). + * Allow passing `C.TIME_END_OF_SOURCE` to `PlayerMessage.setPosition` + to send a `PlayerMessage` at the end of a stream. + * `SimpleExoPlayer`: + * `SimpleExoPlayer` implements the new `MediaItem` based playlist API, + using a `MediaSourceFactory` to convert `MediaItem` instances to + playable `MediaSource` instances. A `DefaultMediaSourceFactory` is + used by default. `Builder.setMediaSourceFactory` allows setting a + custom factory. + * Add additional options to `Builder` that were previously only + accessible via setters. + * Add opt-in to verify correct thread usage with + `setThrowsWhenUsingWrongThread(true)` + ([#4463](https://github.com/google/ExoPlayer/issues/4463)). + * `Format`: + * Add a `Builder` and deprecate all `create` methods and most + `Format.copyWith` methods. + * Split `bitrate` into `averageBitrate` and `peakBitrate` + ([#2863](https://github.com/google/ExoPlayer/issues/2863)). + * `LoadControl`: + * Add a `playbackPositionUs` parameter to `shouldContinueLoading`. + * Set the default minimum buffer duration in `DefaultLoadControl` to + 50 seconds (equal to the default maximum buffer), and treat audio + and video the same. + * Add a `MetadataRetriever` API for retrieving track information and + static metadata for a media item + ([#3609](https://github.com/google/ExoPlayer/issues/3609)). + * Attach an identifier and extra information to load error events passed + to `LoadErrorHandlingPolicy` + ([#7309](https://github.com/google/ExoPlayer/issues/7309)). + `LoadErrorHandlingPolicy` implementations should migrate to implementing + the non-deprecated methods of the interface. + * Add an option to `MergingMediaSource` to adjust the time offsets + between the merged sources ([#6103](https://github.com/google/ExoPlayer/issues/6103)). - * `SimpleDecoderVideoRenderer` and `SimpleDecoderAudioRenderer` renamed to + * Move `MediaSourceEventListener.LoadEventInfo` and + `MediaSourceEventListener.MediaLoadData` to be top-level classes in + `com.google.android.exoplayer2.source`. + * Move `SimpleDecoderVideoRenderer` and `SimpleDecoderAudioRenderer` to `DecoderVideoRenderer` and `DecoderAudioRenderer` respectively, and - generalized to work with `Decoder` rather than `SimpleDecoder`. - * Add media item based playlist API to Player. - * Remove deprecated members in `DefaultTrackSelector`. - * Add `Player.DeviceComponent` and implement it for `SimpleExoPlayer` so - that the device volume can be controlled by player. - * Avoid throwing an exception while parsing fragmented MP4 default sample - values where the most-significant bit is set - ([#7207](https://github.com/google/ExoPlayer/issues/7207)). - * Add `SilenceMediaSource.Factory` to support tags - ([PR #7245](https://github.com/google/ExoPlayer/pull/7245)). - * Fix `AdsMediaSource` child `MediaSource`s not being released. - * Parse track titles from Matroska files - ([#7247](https://github.com/google/ExoPlayer/pull/7247)). - * Replace `CacheDataSinkFactory` and `CacheDataSourceFactory` with - `CacheDataSink.Factory` and `CacheDataSource.Factory` respectively. -* Video: Pass frame rate hint to `Surface.setFrameRate` on Android R devices. + generalize them to work with `Decoder` rather than `SimpleDecoder`. + * Deprecate `C.MSG_*` constants, replacing them with constants in + `Renderer`. + * Split the `library-core` module into `library-core`, + `library-common` and `library-extractor`. The `library-core` module + has an API dependency on both of the new modules, so this change + should be transparent to developers including ExoPlayer using Gradle + dependencies. + * Add a dependency on Guava. +* Video: + * Pass frame rate hint to `Surface.setFrameRate` on Android 11. + * Fix incorrect aspect ratio when transitioning from one video to another + with the same resolution, but a different pixel aspect ratio + ([#6646](https://github.com/google/ExoPlayer/issues/6646)). +* Audio: + * Add experimental support for power efficient playback using audio + offload. + * Add support for using framework audio speed adjustment instead of + ExoPlayer's implementation + ([#7502](https://github.com/google/ExoPlayer/issues/7502)). This option + can be set using + `DefaultRenderersFactory.setEnableAudioTrackPlaybackParams`. + * Add an event for the audio position starting to advance, to make it + easier for apps to determine when audio playout started + ([#7577](https://github.com/google/ExoPlayer/issues/7577)). + * Generalize support for floating point audio. + * Add an option to `DefaultAudioSink` for enabling floating point + output. This option can also be set using + `DefaultRenderersFactory.setEnableAudioFloatOutput`. + * Add floating point output capability to `MediaCodecAudioRenderer` + and `LibopusAudioRenderer`, which is enabled automatically if the + audio sink supports floating point output and if it makes sense for + the content being played. + * Enable the floating point output capability of `FfmpegAudioRenderer` + automatically if the audio sink supports floating point output and + if it makes sense for the content being played. The option to + manually enable floating point output has been removed, since this + now done with the generalized option on `DefaultAudioSink`. + * In `MediaCodecAudioRenderer`, stop passing audio samples through + `MediaCodec` when playing PCM audio or encoded audio using passthrough + mode. + * Reuse audio decoders when transitioning through playlists of gapless + audio, rather than reinstantiating them. + * Check `DefaultAudioSink` supports passthrough, in addition to checking + the `AudioCapabilities` + ([#7404](https://github.com/google/ExoPlayer/issues/7404)). * Text: - * Parse `` and `` tags in WebVTT subtitles (rendering is coming - later). - * Parse `text-combine-upright` CSS property (i.e. tate-chu-yoko) in WebVTT - subtitles (rendering is coming later). - * Parse `tts:combineText` property (i.e. tate-chu-yoko) in TTML subtitles - (rendering is coming later). - * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct - color ([#6724](https://github.com/google/ExoPlayer/pull/6724)). - * Add support for WebVTT default - [text](https://www.w3.org/TR/webvtt1/#default-text-color) and - [background](https://www.w3.org/TR/webvtt1/#default-text-background) - colors ([PR #4178](https://github.com/google/ExoPlayer/pull/4178), - [issue #6581](https://github.com/google/ExoPlayer/issues/6581)). - * Parse `tts:ruby` and `tts:rubyPosition` properties in TTML subtitles - (rendering is coming later). - * Update WebVTT position alignment parsing to recognise `line-left`, - `center` and `line-right` as per the - [released spec](https://www.w3.org/TR/webvtt1/#webvtt-position-cue-setting) - (a - [previous draft](https://www.w3.org/TR/2014/WD-webvtt1-20141111/#dfn-webvtt-text-position-cue-setting) - used `start`, `middle` and `end`). - * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles - ([#6950](https://github.com/google/ExoPlayer/pull/6950)). - * Implement timing-out of stuck CEA-608 captions (as permitted by - ANSI/CTA-608-E R-2014 Annex C.9) and set the default timeout to 16 - seconds ([#7181](https://github.com/google/ExoPlayer/issues/7181)). - * Add special-case positioning behaviour for vertical cues being rendered - horizontally. - * Implement steps 4-10 of the - [WebVTT line computation algorithm](https://www.w3.org/TR/webvtt1/#cue-computed-line). -* DRM: - * Add support for attaching DRM sessions to clear content in the demo app. - * Remove `DrmSessionManager` references from all renderers. - `DrmSessionManager` must be injected into the MediaSources using the - MediaSources factories. - * Add option to inject a custom `DefaultDrmSessionManager` into - `OfflineLicenseHelper` - ([#7078](https://github.com/google/ExoPlayer/issues/7078)). - * Remove generics from DRM components. + * Add a WebView-based output option to `SubtitleView`. This can display + some features not supported by the existing Canvas-based output such as + vertical text and rubies. It can be enabled by calling + `SubtitleView#setViewType(VIEW_TYPE_WEB)`. + * Recreate the decoder when handling and swallowing decode errors in + `TextRenderer`. This fixes a case where playback would never end when + playing content with malformed subtitles + ([#7590](https://github.com/google/ExoPlayer/issues/7590)). + * Only apply `CaptionManager` font scaling in + `SubtitleView.setUserDefaultTextSize` if the `CaptionManager` is + enabled. + * Improve positioning of vertical cues when rendered horizontally. + * Redefine `Cue.lineType=LINE_TYPE_NUMBER` in terms of aligning the cue + text lines to grid of viewport lines. Only consider `Cue.lineAnchor` + when `Cue.lineType=LINE_TYPE_FRACTION`. + * WebVTT + * Add support for default + [text](https://www.w3.org/TR/webvtt1/#default-text-color) and + [background](https://www.w3.org/TR/webvtt1/#default-text-background) + colors ([#6581](https://github.com/google/ExoPlayer/issues/6581)). + * Update position alignment parsing to recognise `line-left`, `center` + and `line-right`. + * Implement steps 4-10 of the + [WebVTT line computation algorithm](https://www.w3.org/TR/webvtt1/#cue-computed-line). + * Stop parsing unsupported CSS properties. The spec provides an + [exhaustive list](https://www.w3.org/TR/webvtt1/#the-cue-pseudo-element) + of which properties are supported. + * Parse the `ruby-position` CSS property. + * Parse the `text-combine-upright` CSS property (i.e., tate-chu-yoko). + * Parse the `` and `` tags. + * TTML + * Parse the `tts:combineText` property (i.e., tate-chu-yoko). + * Parse t`tts:ruby` and `tts:rubyPosition` properties. + * CEA-608 + * Implement timing-out of stuck captions, as permitted by + ANSI/CTA-608-E R-2014 Annex C.9. The default timeout is set to 16 + seconds ([#7181](https://github.com/google/ExoPlayer/issues/7181)). + * Trim lines that exceed the maximum length of 32 characters + ([#7341](https://github.com/google/ExoPlayer/issues/7341)). + * Fix positioning of roll-up captions in the top half of the screen + ([#7475](https://github.com/google/ExoPlayer/issues/7475)). + * Stop automatically generating a CEA-608 track when playing + standalone MPEG-TS files. The previous behavior can still be + obtained by manually injecting a customized + `DefaultTsPayloadReaderFactory` into `TsExtractor`. +* Metadata: Add minimal DVB Application Information Table (AIT) support. +* DASH: + * Add support for canceling in-progress segment fetches + ([#2848](https://github.com/google/ExoPlayer/issues/2848)). + * Add support for CEA-708 embedded in FMP4. +* SmoothStreaming: + * Add support for canceling in-progress segment fetches + ([#2848](https://github.com/google/ExoPlayer/issues/2848)). +* HLS: + * Add support for discarding buffered media (e.g., to allow faster + adaptation to a higher quality variant) + ([#6322](https://github.com/google/ExoPlayer/issues/6322)). + * Add support for canceling in-progress segment fetches + ([#2848](https://github.com/google/ExoPlayer/issues/2848)). + * Respect 33-bit PTS wrapping when applying `X-TIMESTAMP-MAP` to WebVTT + timestamps ([#7464](https://github.com/google/ExoPlayer/issues/7464)). +* Extractors: + * Optimize the `Extractor` sniffing order to reduce start-up latency in + `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory` + ([#6410](https://github.com/google/ExoPlayer/issues/6410)). + * Use filename extensions and response header MIME types to further + optimize `Extractor` sniffing order on a per-media basis. + * MP3: Add `IndexSeeker` for accurate seeks in VBR MP3 streams + ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker + can be enabled by passing `FLAG_ENABLE_INDEX_SEEKING` to the + `Mp3Extractor`. A significant portion of the file may need to be scanned + when a seek is performed, which may be costly for large files. + * MP4: Fix playback of MP4 streams that contain Opus audio. + * FMP4: Add support for partially fragmented MP4s + ([#7308](https://github.com/google/ExoPlayer/issues/7308)). + * Matroska: + * Support Dolby Vision + ([#7267](https://github.com/google/ExoPlayer/issues/7267)). + * Populate `Format.label` with track titles. + * Remove support for the `Invisible` block header flag. + * MPEG-TS: Add support for MPEG-4 Part 2 and H.263 + ([#1603](https://github.com/google/ExoPlayer/issues/1603), + [#5107](https://github.com/google/ExoPlayer/issues/5107)). + * Ogg: Fix handling of non-contiguous pages + ([#7230](https://github.com/google/ExoPlayer/issues/7230)). +* UI: + * Add `StyledPlayerView` and `StyledPlayerControlView`, which provide a + more polished user experience than `PlayerView` and `PlayerControlView` + at the cost of decreased customizability. + * Remove the previously deprecated `SimpleExoPlayerView` and + `PlaybackControlView` classes, along with the corresponding + `exo_simple_player_view.xml` and `exo_playback_control_view.xml` layout + resources. Use the equivalent `PlayerView`, `PlayerControlView`, + `exo_player_view.xml` and `exo_player_control_view.xml` instead. + * Add setter methods to `PlayerView` and `PlayerControlView` to set + whether the rewind, fast forward, previous and next buttons are shown + ([#7410](https://github.com/google/ExoPlayer/issues/7410)). + * Update `TrackSelectionDialogBuilder` to use the AndroidX app compat + `AlertDialog` rather than the platform version, if available + ([#7357](https://github.com/google/ExoPlayer/issues/7357)). + * Make UI components dispatch previous, next, fast forward and rewind + actions via their `ControlDispatcher` + ([#6926](https://github.com/google/ExoPlayer/issues/6926)). * Downloads and caching: - * Merge downloads in `SegmentDownloader` to improve overall download speed + * Add `DownloadRequest.Builder`. + * Add `DownloadRequest.keySetId` to make it easier to store an offline + license keyset identifier alongside the other information that's + persisted in `DownloadIndex`. + * Support passing an `Executor` to `DefaultDownloaderFactory` on which + data downloads are performed. + * Parallelize and merge downloads in `SegmentDownloader` to improve + download speeds ([#5978](https://github.com/google/ExoPlayer/issues/5978)). * Replace `CacheDataSinkFactory` and `CacheDataSourceFactory` with `CacheDataSink.Factory` and `CacheDataSource.Factory` respectively. - * Remove `DownloadConstructorHelper` and use `CacheDataSource.Factory` - directly instead. - * Update `CachedContentIndex` to use `SecureRandom` for generating the - initialization vector used to encrypt the cache contents. -* Audio: - * Add a sample count parameter to `MediaCodecRenderer.processOutputBuffer` - and `AudioSink.handleBuffer` to allow batching multiple encoded frames - in one buffer. - * No longer use a `MediaCodec` in audio passthrough mode. + * Remove `DownloadConstructorHelper` and instead use + `CacheDataSource.Factory` directly. + * Add `Requirements.DEVICE_STORAGE_NOT_LOW`, which can be specified as a + requirement to a `DownloadManager` for it to proceed with downloading. + * For failed downloads, propagate the `Exception` that caused the failure + to `DownloadManager.Listener.onDownloadChanged`. + * Support multiple non-overlapping write locks for the same key in + `SimpleCache`. + * Remove `CacheUtil`. Equivalent functionality is provided by a new + `CacheWriter` class, `Cache.getCachedBytes`, `Cache.removeResource` and + `CacheKeyFactory.DEFAULT`. +* DRM: + * Remove previously deprecated APIs to inject `DrmSessionManager` into + `Renderer` instances. `DrmSessionManager` must now be injected into + `MediaSource` instances via the `MediaSource` factories. + * Add the ability to inject a custom `DefaultDrmSessionManager` into + `OfflineLicenseHelper` + ([#7078](https://github.com/google/ExoPlayer/issues/7078)). + * Keep DRM sessions alive for a short time before fully releasing them + ([#7011](https://github.com/google/ExoPlayer/issues/7011), + [#6725](https://github.com/google/ExoPlayer/issues/6725), + [#7066](https://github.com/google/ExoPlayer/issues/7066)). + * Remove support for `cbc1` and `cens` encrytion schemes. Support for + these schemes was removed from the Android platform from API level 30, + and the range of API levels for which they are supported is too small to + be useful. + * Remove generic types from DRM components. +* Track selection: + * Add `TrackSelection.shouldCancelMediaChunkLoad` to check whether an + ongoing load should be canceled + ([#2848](https://github.com/google/ExoPlayer/issues/2848)). + * Add `DefaultTrackSelector` constraints for minimum video resolution, + bitrate and frame rate + ([#4511](https://github.com/google/ExoPlayer/issues/4511)). + * Remove previously deprecated `DefaultTrackSelector` members. +* Data sources: + * Add `HttpDataSource.InvalidResponseCodeException#responseBody` field + ([#6853](https://github.com/google/ExoPlayer/issues/6853)). + * Add `DataSpec.Builder` and deprecate most `DataSpec` constructors. + * Add `DataSpec.customData` to allow applications to pass custom data + through `DataSource` chains. + * Deprecate `CacheDataSinkFactory` and `CacheDataSourceFactory`, which are + replaced by `CacheDataSink.Factory` and `CacheDataSource.Factory` + respectively. +* Analytics: + * Extend `EventTime` with more details about the current player state + ([#7332](https://github.com/google/ExoPlayer/issues/7332)). + * Add `AnalyticsListener.onVideoFrameProcessingOffset` to report how + early or late video frames are processed relative to them needing to be + presented. Video frame processing offset fields are also added to + `DecoderCounters`. + * Fix incorrect `MediaPeriodId` for some renderer errors reported by + `AnalyticsListener.onPlayerError`. + * Remove `onMediaPeriodCreated`, `onMediaPeriodReleased` and + `onReadingStarted` from `AnalyticsListener`. +* Test utils: Add `TestExoPlayer`, a utility class with APIs to create + `SimpleExoPlayer` instances with fake components for testing. +* Media2 extension: This is a new extension that makes it easy to use + ExoPlayer together with AndroidX Media2. +* Cast extension: Implement playlist API and deprecate the old queue + manipulation API. +* IMA extension: + * Migrate to new 'friendly obstruction' IMA SDK APIs, and allow apps to + register a purpose and detail reason for overlay views via + `AdsLoader.AdViewProvider`. + * Add support for audio-only ads display containers by returning `null` + from `AdsLoader.AdViewProvider.getAdViewGroup`, and allow skipping + audio-only ads via `ImaAdsLoader.skipAd` + ([#7689](https://github.com/google/ExoPlayer/issues/7689)). + * Add `ImaAdsLoader.Builder.setCompanionAdSlots` so it's possible to set + companion ad slots without accessing the `AdDisplayContainer`. + * Add missing notification of `VideoAdPlayerCallback.onLoaded`. + * Fix handling of incompatible VPAID ads + ([#7832](https://github.com/google/ExoPlayer/issues/7832)). + * Fix handling of empty ads at non-integer cue points + ([#7889](https://github.com/google/ExoPlayer/issues/7889)). +* Demo app: + * Replace the `extensions` variant with `decoderExtensions` and update the + demo app use the Cronet and IMA extensions by default. + * Expand the `exolist.json` schema, as well the structure of intents that + can be used to launch `PlayerActivity`. See the + [Demo application page](https://exoplayer.dev/demo-application.html#playing-your-own-content) + for the latest versions. Changes include: + * Add `drm_session_for_clear_content` to allow attaching DRM sessions + to clear audio and video tracks. + * Add `clip_start_position_ms` and `clip_end_position_ms` to allow + clipped samples. + * Use `StyledPlayerControlView` rather than `PlayerView`. + * Remove support for media tunneling, random ABR and playback of + spherical video. Developers wishing to experiment with these features + can enable them by modifying the demo app source code. + * Add support for downloading DRM-protected content using offline + Widevine licenses. + +### 2.11.8 (2020-08-25) ### + +* Fix distorted playback of floating point audio when samples exceed the + `[-1, 1]` nominal range. +* MP4: + * Add support for `piff` and `isml` brands + ([#7584](https://github.com/google/ExoPlayer/issues/7584)). + * Fix playback of very short MP4 files. +* FMP4: + * Fix `saiz` and `senc` sample count checks, resolving a "length + mismatch" `ParserException` when playing certain protected FMP4 streams + ([#7592](https://github.com/google/ExoPlayer/issues/7592)). + * Fix handling of `traf` boxes containing multiple `sbgp` or `sgpd` + boxes. +* FLV: Ignore `SCRIPTDATA` segments with invalid name types, rather than + failing playback ([#7675](https://github.com/google/ExoPlayer/issues/7675)). +* Better infer the content type of `.ism` and `.isml` streaming URLs. +* Workaround an issue on Broadcom based devices where playbacks would not + transition to `STATE_ENDED` when using video tunneling mode + ([#7647](https://github.com/google/ExoPlayer/issues/7647)). +* IMA extension: Upgrade to IMA SDK 3.19.4, bringing in a fix for setting the + media load timeout + ([#7170](https://github.com/google/ExoPlayer/issues/7170)). +* Demo app: Fix playback of ClearKey protected content on API level 26 and + earlier ([#7735](https://github.com/google/ExoPlayer/issues/7735)). + +### 2.11.7 (2020-06-29) ### + +* IMA extension: Fix the way postroll "content complete" notifications are + handled to avoid repeatedly refreshing the timeline after playback ends. + +### 2.11.6 (2020-06-19) ### + +* UI: Prevent `PlayerView` from temporarily hiding the video surface when + seeking to an unprepared period within the current window. For example when + seeking over an ad group, or to the next period in a multi-period DASH + stream ([#5507](https://github.com/google/ExoPlayer/issues/5507)). +* IMA extension: + * Add option to skip ads before the start position. + * Catch unexpected errors in `stopAd` to avoid a crash + ([#7492](https://github.com/google/ExoPlayer/issues/7492)). + * Fix a bug that caused playback to be stuck buffering on resuming from + the background after all ads had played to the end + ([#7508](https://github.com/google/ExoPlayer/issues/7508)). + * Fix a bug where the number of ads in an ad group couldn't change + ([#7477](https://github.com/google/ExoPlayer/issues/7477)). + * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded + on seeking to another position + ([#7492](https://github.com/google/ExoPlayer/issues/7492)). + * Fix incorrect rounding of ad cue points. + * Fix handling of postrolls preloading + ([#7518](https://github.com/google/ExoPlayer/issues/7518)). + +### 2.11.5 (2020-06-05) ### + +* 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 @@ -145,45 +431,57 @@ marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. * Fix assertion failure in `SampleQueue` when playing DASH streams with EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). -* MP3: Add `IndexSeeker` for accurate seeks in VBR streams - ([#6787](https://github.com/google/ExoPlayer/issues/6787)). This seeker is - enabled by passing `FLAG_ENABLE_INDEX_SEEKING` to the `Mp3Extractor`. It may - require to scan a significant portion of the file for seeking, which may be - costly on large files. * 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)). -* Testing - * Add `TestExoPlayer`, a utility class with APIs to create - `SimpleExoPlayer` instances with fake components for testing. - * Upgrade Truth dependency from 0.44 to 1.0. - * Upgrade to JUnit 4.13-rc-2. -* UI - * Remove deperecated `exo_simple_player_view.xml` and - `exo_playback_control_view.xml` from resource. - * Add `showScrubber` and `hideScrubber` methods to DefaultTimeBar. - * Move logic of prev, next, fast forward and rewind to ControlDispatcher - ([#6926](https://github.com/google/ExoPlayer/issues/6926)). -* Metadata: Add minimal DVB Application Information Table (AIT) support - ([#6922](https://github.com/google/ExoPlayer/pull/6922)). -* Cast extension: Implement playlist API and deprecate the old queue - manipulation API. -* Demo app: Retain previous position in list of samples. -* Change the order of extractors for sniffing to reduce start-up latency in - `DefaultExtractorsFactory` and `DefaultHlsExtractorsFactory` - ([#6410](https://github.com/google/ExoPlayer/issues/6410)). -* Add missing `@Nullable` annotations to `MediaSessionConnector` - ([#7234](https://github.com/google/ExoPlayer/issues/7234)). -* AV1 extension: Add a heuristic to determine the default number of threads - used for AV1 playback using the extension. -* IMA extension: Upgrade to IMA SDK version 3.18.1, and migrate to new - preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). -* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* 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) @@ -206,11 +504,12 @@ to the `DefaultAudioSink` constructor ([#7134](https://github.com/google/ExoPlayer/issues/7134)). * Workaround issue that could cause slower than realtime playback of AAC - on Android 10 ([#6671](https://github.com/google/ExoPlayer/issues/6671). + on Android 10 + ([#6671](https://github.com/google/ExoPlayer/issues/6671)). * Fix case where another app spuriously holding transient audio focus could prevent ExoPlayer from acquiring audio focus for an indefinite period of time - ([#7182](https://github.com/google/ExoPlayer/issues/7182). + ([#7182](https://github.com/google/ExoPlayer/issues/7182)). * Fix case where the player volume could be permanently ducked if audio focus was released whilst ducking. * Fix playback of WAV files with trailing non-media bytes @@ -1159,7 +1458,7 @@ ([#4492](https://github.com/google/ExoPlayer/issues/4492) and [#4634](https://github.com/google/ExoPlayer/issues/4634)). * Fix issue where removing looping media from a playlist throws an exception - ([#4871](https://github.com/google/ExoPlayer/issues/4871). + ([#4871](https://github.com/google/ExoPlayer/issues/4871)). * Fix issue where the preferred audio or text track would not be selected if mapped onto a secondary renderer of the corresponding type ([#4711](http://github.com/google/ExoPlayer/issues/4711)). @@ -1590,7 +1889,7 @@ resources when the playback thread has quit by the time the loading task has completed. * ID3: Better handle malformed ID3 data - ([#3792](https://github.com/google/ExoPlayer/issues/3792). + ([#3792](https://github.com/google/ExoPlayer/issues/3792)). * Support 14-bit mode and little endianness in DTS PES packets ([#3340](https://github.com/google/ExoPlayer/issues/3340)). * Demo app: Add ability to download not DRM protected content. diff --git a/build.gradle b/build.gradle index d520925fb0..00277b8cee 100644 --- a/build.gradle +++ b/build.gradle @@ -17,7 +17,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.6.3' + classpath 'com.android.tools.build:gradle:4.0.0' classpath 'com.novoda:bintray-release:0.9.1' classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.1' } @@ -26,6 +26,7 @@ allprojects { repositories { google() jcenter() + maven { url "https://oss.sonatype.org/content/repositories/snapshots" } } project.ext { exoplayerPublishEnabled = false diff --git a/common_library_config.gradle b/common_library_config.gradle new file mode 100644 index 0000000000..431a7ab14d --- /dev/null +++ b/common_library_config.gradle @@ -0,0 +1,34 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +apply from: "$gradle.ext.exoplayerSettingsDir/constants.gradle" +apply plugin: 'com.android.library' + +android { + compileSdkVersion project.ext.compileSdkVersion + + defaultConfig { + minSdkVersion project.ext.minSdkVersion + targetSdkVersion project.ext.targetSdkVersion + consumerProguardFiles 'proguard-rules.txt' + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions.unitTests.includeAndroidResources = true +} diff --git a/constants.gradle b/constants.gradle index 1a7840588f..c2b0000368 100644 --- a/constants.gradle +++ b/constants.gradle @@ -13,24 +13,28 @@ // limitations under the License. project.ext { // ExoPlayer version and version code. - releaseVersion = '2.11.4' - releaseVersionCode = 2011004 + releaseVersion = '2.12.0' + releaseVersionCode = 2012000 minSdkVersion = 16 appTargetSdkVersion = 29 targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved. Also fix TODOs in UtilTest. compileSdkVersion = 29 dexmakerVersion = '2.21.0' junitVersion = '4.13-rc-2' - guavaVersion = '28.2-android' - mockitoVersion = '2.25.0' - robolectricVersion = '4.3.1' - checkerframeworkVersion = '2.5.0' + guavaVersion = '27.1-android' + mockitoVersion = '2.28.2' + mockWebServerVersion = '3.12.0' + robolectricVersion = '4.4' + checkerframeworkVersion = '3.3.0' + checkerframeworkCompatVersion = '2.5.0' jsr305Version = '3.0.2' kotlinAnnotationsVersion = '1.3.70' androidxAnnotationVersion = '1.1.0' androidxAppCompatVersion = '1.1.0' androidxCollectionVersion = '1.1.0' androidxMediaVersion = '1.0.1' + androidxMultidexVersion = '2.0.0' + androidxRecyclerViewVersion = '1.1.0' androidxTestCoreVersion = '1.2.0' androidxTestJUnitVersion = '1.1.1' androidxTestRunnerVersion = '1.2.0' diff --git a/core_settings.gradle b/core_settings.gradle index ac56933155..b508243371 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. def rootDir = gradle.ext.exoplayerRoot +if (!gradle.ext.has('exoplayerSettingsDir')) { + gradle.ext.exoplayerSettingsDir = + new File(rootDir.toString()).getCanonicalPath() +} def modulePrefix = ':' if (gradle.ext.has('exoplayerModulePrefix')) { modulePrefix += gradle.ext.exoplayerModulePrefix @@ -35,6 +39,7 @@ include modulePrefix + 'extension-ima' include modulePrefix + 'extension-cast' include modulePrefix + 'extension-cronet' include modulePrefix + 'extension-mediasession' +include modulePrefix + 'extension-media2' include modulePrefix + 'extension-okhttp' include modulePrefix + 'extension-opus' include modulePrefix + 'extension-vp9' @@ -61,6 +66,7 @@ project(modulePrefix + 'extension-ima').projectDir = new File(rootDir, 'extensio project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast') project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet') project(modulePrefix + 'extension-mediasession').projectDir = new File(rootDir, 'extensions/mediasession') +project(modulePrefix + 'extension-media2').projectDir = new File(rootDir, 'extensions/media2') project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'extensions/okhttp') project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensions/opus') project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9') 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/cast/build.gradle b/demos/cast/build.gradle index c929f09c87..868e3c7b43 100644 --- a/demos/cast/build.gradle +++ b/demos/cast/build.gradle @@ -27,6 +27,7 @@ android { versionCode project.ext.releaseVersionCode minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.appTargetSdkVersion + multiDexEnabled true } buildTypes { @@ -57,8 +58,9 @@ dependencies { implementation project(modulePrefix + 'library-ui') implementation project(modulePrefix + 'extension-cast') implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion + implementation 'androidx.multidex:multidex:' + androidxMultidexVersion implementation 'androidx.recyclerview:recyclerview:1.1.0' - implementation 'com.google.android.material:material:1.1.0' + implementation 'com.google.android.material:material:1.2.1' } apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Function.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoApplication.java similarity index 57% rename from library/common/src/main/java/com/google/android/exoplayer2/util/Function.java rename to demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoApplication.java index 900f32db45..f2d2288b6a 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Function.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoApplication.java @@ -13,21 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.util; +package com.google.android.exoplayer2.castdemo; -/** - * A functional interface representing a function taking one argument and returning a result. - * - * @param The input type of the function. - * @param The output type of the function. - */ -public interface Function { +import androidx.multidex.MultiDexApplication; - /** - * Applies this function to the given argument. - * - * @param t The function argument. - * @return The function result, which may be {@code null}. - */ - R apply(T t); -} +// Note: Multidex is enabled in code not AndroidManifest.xml because the internal build system +// doesn't dejetify MultiDexApplication in AndroidManifest.xml. +/** Application for multidex support. */ +public final class DemoApplication extends MultiDexApplication {} diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java index c5dfe70d93..9dc82e0b23 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java @@ -29,7 +29,6 @@ import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.ext.cast.CastPlayer; import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener; -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; @@ -61,7 +60,6 @@ import java.util.ArrayList; private static final DefaultHttpDataSourceFactory DATA_SOURCE_FACTORY = new DefaultHttpDataSourceFactory(USER_AGENT); - private final DefaultMediaSourceFactory defaultMediaSourceFactory; private final PlayerView localPlayerView; private final PlayerControlView castControlView; private final DefaultTrackSelector trackSelector; @@ -97,7 +95,6 @@ import java.util.ArrayList; trackSelector = new DefaultTrackSelector(context); exoPlayer = new SimpleExoPlayer.Builder(context).setTrackSelector(trackSelector).build(); - defaultMediaSourceFactory = DefaultMediaSourceFactory.newInstance(context, DATA_SOURCE_FACTORY); exoPlayer.addListener(this); localPlayerView.setPlayer(exoPlayer); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Supplier.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java similarity index 71% rename from library/common/src/main/java/com/google/android/exoplayer2/util/Supplier.java rename to demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java index 723047b1ed..70e2af79df 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Supplier.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/package-info.java @@ -13,16 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@NonNullApi +package com.google.android.exoplayer2.castdemo; -package com.google.android.exoplayer2.util; - -/** - * A functional interface representing a supplier of results. - * - * @param The type of results supplied by this supplier. - */ -public interface Supplier { - - /** Gets a result. */ - T get(); -} +import com.google.android.exoplayer2.util.NonNullApi; 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/gl/build.gradle b/demos/gl/build.gradle index 8fe3e04045..e065f9b8f2 100644 --- a/demos/gl/build.gradle +++ b/demos/gl/build.gradle @@ -49,5 +49,5 @@ dependencies { implementation project(modulePrefix + 'library-dash') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion } diff --git a/demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl b/demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl index e54d0c256d..17fec0601d 100644 --- a/demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl +++ b/demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl @@ -32,4 +32,3 @@ void main() { gl_FragColor = videoColor * (1.0 - overlayColor.a) + overlayColor * overlayColor.a; } - diff --git a/demos/gl/src/main/assets/bitmap_overlay_video_processor_vertex.glsl b/demos/gl/src/main/assets/bitmap_overlay_video_processor_vertex.glsl index e333d977b2..0c07c12a70 100644 --- a/demos/gl/src/main/assets/bitmap_overlay_video_processor_vertex.glsl +++ b/demos/gl/src/main/assets/bitmap_overlay_video_processor_vertex.glsl @@ -11,11 +11,10 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -attribute vec4 a_position; -attribute vec3 a_texcoord; +attribute vec2 a_position; +attribute vec2 a_texcoord; varying vec2 v_texcoord; void main() { - gl_Position = a_position; - v_texcoord = a_texcoord.xy; + gl_Position = vec4(a_position.x, a_position.y, 0, 1); + v_texcoord = a_texcoord; } - diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java index 063b660751..89bea32581 100644 --- a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java @@ -88,18 +88,9 @@ import javax.microedition.khronos.opengles.GL10; GlUtil.Uniform[] uniforms = GlUtil.getUniforms(program); for (GlUtil.Attribute attribute : attributes) { if (attribute.name.equals("a_position")) { - attribute.setBuffer( - new float[] { - -1.0f, -1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, - 1.0f, 0.0f, 1.0f, - }, - 4); + attribute.setBuffer(new float[] {-1, -1, 1, -1, -1, 1, 1, 1}, 2); } else if (attribute.name.equals("a_texcoord")) { - attribute.setBuffer( - new float[] { - 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, - }, - 3); + attribute.setBuffer(new float[] {0, 1, 1, 1, 0, 0, 1, 0}, 2); } } this.attributes = attributes; diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java index c788f752f7..dc0a8b990a 100644 --- a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java @@ -24,6 +24,7 @@ import android.widget.FrameLayout; import android.widget.Toast; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; @@ -138,13 +139,12 @@ public final class MainActivity extends Activity { ACTION_VIEW.equals(action) ? Assertions.checkNotNull(intent.getData()) : Uri.parse(DEFAULT_MEDIA_URI); - String userAgent = Util.getUserAgent(this, getString(R.string.application_name)); DrmSessionManager drmSessionManager; if (Util.SDK_INT >= 18 && intent.hasExtra(DRM_SCHEME_EXTRA)) { String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA)); String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA)); UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme)); - HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); + HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(); HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory); drmSessionManager = @@ -155,21 +155,19 @@ public final class MainActivity extends Activity { drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); } - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - this, Util.getUserAgent(this, getString(R.string.application_name))); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this); MediaSource mediaSource; @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA)); if (type == C.TYPE_DASH) { mediaSource = new DashMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else if (type == C.TYPE_OTHER) { mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else { throw new IllegalStateException(); } diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java new file mode 100644 index 0000000000..59ad052449 --- /dev/null +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.gldemo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/demos/gl/src/main/res/layout/main_activity.xml b/demos/gl/src/main/res/layout/main_activity.xml index ec3868d6a8..4728dc2d49 100644 --- a/demos/gl/src/main/res/layout/main_activity.xml +++ b/demos/gl/src/main/res/layout/main_activity.xml @@ -27,4 +27,3 @@ app:surface_type="none"/> - 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/build.gradle b/demos/main/build.gradle index b7a8666fe3..716b3c1f99 100644 --- a/demos/main/build.gradle +++ b/demos/main/build.gradle @@ -27,6 +27,7 @@ android { versionCode project.ext.releaseVersionCode minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.appTargetSdkVersion + multiDexEnabled true } buildTypes { @@ -49,34 +50,46 @@ android { disable 'GoogleAppIndexingWarning','MissingTranslation','IconDensities' } - flavorDimensions "extensions" + flavorDimensions "decoderExtensions" productFlavors { - noExtensions { - dimension "extensions" + noDecoderExtensions { + dimension "decoderExtensions" + buildConfigField "boolean", "USE_DECODER_EXTENSIONS", "false" } - withExtensions { - dimension "extensions" + withDecoderExtensions { + dimension "decoderExtensions" + buildConfigField "boolean", "USE_DECODER_EXTENSIONS", "true" } } } dependencies { + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion - implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.multidex:multidex:' + androidxMultidexVersion + implementation 'com.google.android.material:material:1.2.1' + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-dash') implementation project(modulePrefix + 'library-hls') implementation project(modulePrefix + 'library-smoothstreaming') implementation project(modulePrefix + 'library-ui') - withExtensionsImplementation project(path: modulePrefix + 'extension-av1') - withExtensionsImplementation project(path: modulePrefix + 'extension-ffmpeg') - withExtensionsImplementation project(path: modulePrefix + 'extension-flac') - withExtensionsImplementation project(path: modulePrefix + 'extension-ima') - withExtensionsImplementation project(path: modulePrefix + 'extension-opus') - withExtensionsImplementation project(path: modulePrefix + 'extension-vp9') - withExtensionsImplementation project(path: modulePrefix + 'extension-rtmp') + implementation project(modulePrefix + 'extension-cronet') + implementation project(modulePrefix + 'extension-ima') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-av1') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-ffmpeg') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-flac') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-opus') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-vp9') + withDecoderExtensionsImplementation project(modulePrefix + 'extension-rtmp') } apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' diff --git a/demos/main/proguard-rules.txt b/demos/main/proguard-rules.txt index cd201892ab..5358f3cec7 100644 --- a/demos/main/proguard-rules.txt +++ b/demos/main/proguard-rules.txt @@ -1,7 +1,2 @@ # Proguard rules specific to the main demo app. -# Constructor accessed via reflection in PlayerActivity --dontnote com.google.android.exoplayer2.ext.ima.ImaAdsLoader --keepclassmembers class com.google.android.exoplayer2.ext.ima.ImaAdsLoader { - (android.content.Context, android.net.Uri); -} diff --git a/demos/main/src/main/AndroidManifest.xml b/demos/main/src/main/AndroidManifest.xml index 0240a377ac..053665502b 100644 --- a/demos/main/src/main/AndroidManifest.xml +++ b/demos/main/src/main/AndroidManifest.xml @@ -35,8 +35,8 @@ android:largeHeap="true" android:allowBackup="false" android:requestLegacyExternalStorage="true" - android:name="com.google.android.exoplayer2.demo.DemoApplication" - tools:ignore="UnusedAttribute"> + android:name="androidx.multidex.MultiDexApplication" + tools:targetApi="29"> Clear -> Secure (cenc)", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/widevine/tears_enc_clear_enc.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test", - "drm_session_for_clear_types": ["audio", "video"] + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test", + "drm_session_for_clear_content": true } ] }, { - "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" + "drm_license_uri": "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" + "drm_license_uri": "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" + "drm_license_uri": "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" + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" } ] }, { - "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" + "drm_license_uri": "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" + "drm_license_uri": "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_uri": "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_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_HW_SECURE_ALL&provider=widevine_test" } ] }, @@ -362,7 +258,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" }, { @@ -429,6 +325,10 @@ { "name": "Big Buck Bunny 480p video (MP4,AV1)", "uri": "https://storage.googleapis.com/downloads.webmproject.org/av1/exoplayer/bbb-av1-480p.mp4" + }, + { + "name": "One hour frame counter (MP4)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4" } ] }, @@ -469,7 +369,7 @@ { "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" + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { "uri": "https://html5demos.com/assets/dizzy.mp4" @@ -477,12 +377,29 @@ { "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" + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { "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" + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + } + ] + }, + { + "name": "Manual ad insertion", + "playlist": [ + { + "uri": "https://html5demos.com/assets/dizzy.mp4", + "clip_end_position_ms": 10000 + }, + { + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4", + "clip_end_position_ms": 5000 + }, + { + "uri": "https://html5demos.com/assets/dizzy.mp4", + "clip_start_position_ms": 10000 } ] } @@ -575,26 +492,11 @@ "name": "VMAP full, empty, full midrolls", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv", "ad_tag_uri": "https://vastsynthesizer.appspot.com/empty-midroll-2" - } - ] - }, - { - "name": "360", - "samples": [ - { - "name": "Congo (360 top-bottom stereo)", - "uri": "https://storage.googleapis.com/exoplayer-test-media-1/360/congo.mp4", - "spherical_stereo_mode": "top_bottom" }, { - "name": "Sphericalv2 (180 top-bottom stereo)", - "uri": "https://storage.googleapis.com/exoplayer-test-media-1/360/sphericalv2.mp4", - "spherical_stereo_mode": "top_bottom" - }, - { - "name": "Iceland (360 top-bottom stereo ts)", - "uri": "https://storage.googleapis.com/exoplayer-test-media-1/360/iceland0.ts", - "spherical_stereo_mode": "top_bottom" + "name": "VMAP midroll at 1765 s", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/frame-counter-one-hour.mp4", + "ad_tag_uri": "https://vastsynthesizer.appspot.com/midroll-large" } ] }, @@ -608,12 +510,23 @@ "subtitle_mime_type": "application/ttml+xml", "subtitle_language": "en" }, + { + "name": "WebVTT line positioning", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/webvtt/numeric-lines.vtt", + "subtitle_mime_type": "text/vtt", + "subtitle_language": "en" + }, { "name": "SSA/ASS position & alignment", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4", "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ssa/test-subs-position.ass", "subtitle_mime_type": "text/x-ssa", "subtitle_language": "en" + }, + { + "name": "MPEG-4 Timed Text (tx3g, mov_text)", + "uri": "https://storage.googleapis.com/exoplayer-test-media-1/mp4/dizzy-with-tx3g.mp4" } ] }, @@ -632,13 +545,13 @@ "name": "Big Buck Bunny (DASH,H264,1080p,Widevine)", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-wv-1080/manifest.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" }, { "name": "Big Buck Bunny (DASH,H264,4K,Widevine)", "uri": "https://storage.googleapis.com/exoplayer-test-media-1/60fps/bbb-wv-2160/manifest.mpd", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" } ] } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java deleted file mode 100644 index c36d370992..0000000000 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.demo; - -import android.app.Application; -import com.google.android.exoplayer2.DefaultRenderersFactory; -import com.google.android.exoplayer2.RenderersFactory; -import com.google.android.exoplayer2.database.DatabaseProvider; -import com.google.android.exoplayer2.database.ExoDatabaseProvider; -import com.google.android.exoplayer2.offline.ActionFileUpgradeUtil; -import com.google.android.exoplayer2.offline.DefaultDownloadIndex; -import com.google.android.exoplayer2.offline.DownloadManager; -import com.google.android.exoplayer2.ui.DownloadNotificationHelper; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.cache.Cache; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.Util; -import java.io.File; -import java.io.IOException; - -/** - * Placeholder application to facilitate overriding Application methods for debugging and testing. - */ -public class DemoApplication extends Application { - - public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; - - private static final String TAG = "DemoApplication"; - private static final String DOWNLOAD_ACTION_FILE = "actions"; - private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions"; - private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; - - protected String userAgent; - - private DatabaseProvider databaseProvider; - private File downloadDirectory; - private Cache downloadCache; - private DownloadManager downloadManager; - private DownloadTracker downloadTracker; - private DownloadNotificationHelper downloadNotificationHelper; - - @Override - public void onCreate() { - super.onCreate(); - userAgent = Util.getUserAgent(this, "ExoPlayerDemo"); - } - - /** Returns a {@link DataSource.Factory}. */ - public DataSource.Factory buildDataSourceFactory() { - DefaultDataSourceFactory upstreamFactory = - new DefaultDataSourceFactory(this, buildHttpDataSourceFactory()); - return buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache()); - } - - /** Returns a {@link HttpDataSource.Factory}. */ - public HttpDataSource.Factory buildHttpDataSourceFactory() { - return new DefaultHttpDataSourceFactory(userAgent); - } - - /** Returns whether extension renderers should be used. */ - public boolean useExtensionRenderers() { - return "withExtensions".equals(BuildConfig.FLAVOR); - } - - public RenderersFactory buildRenderersFactory(boolean preferExtensionRenderer) { - @DefaultRenderersFactory.ExtensionRendererMode - int extensionRendererMode = - useExtensionRenderers() - ? (preferExtensionRenderer - ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) - : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; - return new DefaultRenderersFactory(/* context= */ this) - .setExtensionRendererMode(extensionRendererMode); - } - - public DownloadNotificationHelper getDownloadNotificationHelper() { - if (downloadNotificationHelper == null) { - downloadNotificationHelper = - new DownloadNotificationHelper(this, DOWNLOAD_NOTIFICATION_CHANNEL_ID); - } - return downloadNotificationHelper; - } - - public DownloadManager getDownloadManager() { - initDownloadManager(); - return downloadManager; - } - - public DownloadTracker getDownloadTracker() { - initDownloadManager(); - return downloadTracker; - } - - protected synchronized Cache getDownloadCache() { - if (downloadCache == null) { - File downloadContentDirectory = new File(getDownloadDirectory(), DOWNLOAD_CONTENT_DIRECTORY); - downloadCache = - new SimpleCache(downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider()); - } - return downloadCache; - } - - private synchronized void initDownloadManager() { - if (downloadManager == null) { - DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(getDatabaseProvider()); - upgradeActionFile( - DOWNLOAD_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ false); - upgradeActionFile( - DOWNLOAD_TRACKER_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ true); - downloadManager = - new DownloadManager( - this, getDatabaseProvider(), getDownloadCache(), buildHttpDataSourceFactory()); - downloadTracker = - new DownloadTracker(/* context= */ this, buildDataSourceFactory(), downloadManager); - } - } - - private void upgradeActionFile( - String fileName, DefaultDownloadIndex downloadIndex, boolean addNewDownloadsAsCompleted) { - try { - ActionFileUpgradeUtil.upgradeAndDelete( - new File(getDownloadDirectory(), fileName), - /* downloadIdProvider= */ null, - downloadIndex, - /* deleteOnFailure= */ true, - addNewDownloadsAsCompleted); - } catch (IOException e) { - Log.e(TAG, "Failed to upgrade action file: " + fileName, e); - } - } - - private DatabaseProvider getDatabaseProvider() { - if (databaseProvider == null) { - databaseProvider = new ExoDatabaseProvider(this); - } - return databaseProvider; - } - - private File getDownloadDirectory() { - if (downloadDirectory == null) { - downloadDirectory = getExternalFilesDir(null); - if (downloadDirectory == null) { - downloadDirectory = getFilesDir(); - } - } - return downloadDirectory; - } - - protected static CacheDataSource.Factory buildReadOnlyCacheDataSource( - DataSource.Factory upstreamFactory, Cache cache) { - return new CacheDataSource.Factory() - .setCache(cache) - .setUpstreamDataSourceFactory(upstreamFactory) - .setCacheWriteDataSinkFactory(null) - .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR); - } -} diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java index 71b1eda7bf..c462c14c75 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoDownloadService.java @@ -15,11 +15,12 @@ */ package com.google.android.exoplayer2.demo; -import static com.google.android.exoplayer2.demo.DemoApplication.DOWNLOAD_NOTIFICATION_CHANNEL_ID; +import static com.google.android.exoplayer2.demo.DemoUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID; import android.app.Notification; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.offline.Download; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadService; @@ -49,10 +50,9 @@ public class DemoDownloadService extends DownloadService { protected DownloadManager getDownloadManager() { // This will only happen once, because getDownloadManager is guaranteed to be called only once // in the life cycle of the process. - DemoApplication application = (DemoApplication) getApplication(); - DownloadManager downloadManager = application.getDownloadManager(); + DownloadManager downloadManager = DemoUtil.getDownloadManager(/* context= */ this); DownloadNotificationHelper downloadNotificationHelper = - application.getDownloadNotificationHelper(); + DemoUtil.getDownloadNotificationHelper(/* context= */ this); downloadManager.addListener( new TerminalStateNotificationHelper( this, downloadNotificationHelper, FOREGROUND_NOTIFICATION_ID + 1)); @@ -67,10 +67,13 @@ public class DemoDownloadService extends DownloadService { @Override @NonNull protected Notification getForegroundNotification(@NonNull List downloads) { - return ((DemoApplication) getApplication()) - .getDownloadNotificationHelper() + return DemoUtil.getDownloadNotificationHelper(/* context= */ this) .buildProgressNotification( - R.drawable.ic_download, /* contentIntent= */ null, /* message= */ null, downloads); + /* context= */ this, + R.drawable.ic_download, + /* contentIntent= */ null, + /* message= */ null, + downloads); } /** @@ -94,17 +97,20 @@ public class DemoDownloadService extends DownloadService { } @Override - public void onDownloadChanged(@NonNull DownloadManager manager, @NonNull Download download) { + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Exception finalException) { Notification notification; if (download.state == Download.STATE_COMPLETED) { notification = notificationHelper.buildDownloadCompletedNotification( + context, R.drawable.ic_download_done, /* contentIntent= */ null, Util.fromUtf8Bytes(download.request.data)); } else if (download.state == Download.STATE_FAILED) { notification = notificationHelper.buildDownloadFailedNotification( + context, R.drawable.ic_download_done, /* contentIntent= */ null, Util.fromUtf8Bytes(download.request.data)); diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java new file mode 100644 index 0000000000..2d15dfcbb4 --- /dev/null +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoUtil.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.demo; + +import android.content.Context; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.database.DatabaseProvider; +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.ext.cronet.CronetDataSourceFactory; +import com.google.android.exoplayer2.ext.cronet.CronetEngineWrapper; +import com.google.android.exoplayer2.offline.ActionFileUpgradeUtil; +import com.google.android.exoplayer2.offline.DefaultDownloadIndex; +import com.google.android.exoplayer2.offline.DownloadManager; +import com.google.android.exoplayer2.ui.DownloadNotificationHelper; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.Log; +import java.io.File; +import java.io.IOException; +import java.util.concurrent.Executors; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Utility methods for the demo app. */ +public final class DemoUtil { + + public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; + + private static final String TAG = "DemoUtil"; + private static final String DOWNLOAD_ACTION_FILE = "actions"; + private static final String DOWNLOAD_TRACKER_ACTION_FILE = "tracked_actions"; + private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; + + private static DataSource.@MonotonicNonNull Factory dataSourceFactory; + private static HttpDataSource.@MonotonicNonNull Factory httpDataSourceFactory; + private static @MonotonicNonNull DatabaseProvider databaseProvider; + private static @MonotonicNonNull File downloadDirectory; + private static @MonotonicNonNull Cache downloadCache; + private static @MonotonicNonNull DownloadManager downloadManager; + private static @MonotonicNonNull DownloadTracker downloadTracker; + private static @MonotonicNonNull DownloadNotificationHelper downloadNotificationHelper; + + /** Returns whether extension renderers should be used. */ + public static boolean useExtensionRenderers() { + return BuildConfig.USE_DECODER_EXTENSIONS; + } + + public static RenderersFactory buildRenderersFactory( + Context context, boolean preferExtensionRenderer) { + @DefaultRenderersFactory.ExtensionRendererMode + int extensionRendererMode = + useExtensionRenderers() + ? (preferExtensionRenderer + ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; + return new DefaultRenderersFactory(context.getApplicationContext()) + .setExtensionRendererMode(extensionRendererMode); + } + + public static synchronized HttpDataSource.Factory getHttpDataSourceFactory(Context context) { + if (httpDataSourceFactory == null) { + context = context.getApplicationContext(); + CronetEngineWrapper cronetEngineWrapper = new CronetEngineWrapper(context); + httpDataSourceFactory = + new CronetDataSourceFactory(cronetEngineWrapper, Executors.newSingleThreadExecutor()); + } + return httpDataSourceFactory; + } + + /** Returns a {@link DataSource.Factory}. */ + public static synchronized DataSource.Factory getDataSourceFactory(Context context) { + if (dataSourceFactory == null) { + context = context.getApplicationContext(); + DefaultDataSourceFactory upstreamFactory = + new DefaultDataSourceFactory(context, getHttpDataSourceFactory(context)); + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + } + return dataSourceFactory; + } + + public static synchronized DownloadNotificationHelper getDownloadNotificationHelper( + Context context) { + if (downloadNotificationHelper == null) { + downloadNotificationHelper = + new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID); + } + return downloadNotificationHelper; + } + + public static synchronized DownloadManager getDownloadManager(Context context) { + ensureDownloadManagerInitialized(context); + return downloadManager; + } + + public static synchronized DownloadTracker getDownloadTracker(Context context) { + ensureDownloadManagerInitialized(context); + return downloadTracker; + } + + private static synchronized Cache getDownloadCache(Context context) { + if (downloadCache == null) { + File downloadContentDirectory = + new File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY); + downloadCache = + new SimpleCache( + downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider(context)); + } + return downloadCache; + } + + private static synchronized void ensureDownloadManagerInitialized(Context context) { + if (downloadManager == null) { + DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(getDatabaseProvider(context)); + upgradeActionFile( + context, DOWNLOAD_ACTION_FILE, downloadIndex, /* addNewDownloadsAsCompleted= */ false); + upgradeActionFile( + context, + DOWNLOAD_TRACKER_ACTION_FILE, + downloadIndex, + /* addNewDownloadsAsCompleted= */ true); + downloadManager = + new DownloadManager( + context, + getDatabaseProvider(context), + getDownloadCache(context), + getHttpDataSourceFactory(context), + Executors.newFixedThreadPool(/* nThreads= */ 6)); + downloadTracker = + new DownloadTracker(context, getHttpDataSourceFactory(context), downloadManager); + } + } + + private static synchronized void upgradeActionFile( + Context context, + String fileName, + DefaultDownloadIndex downloadIndex, + boolean addNewDownloadsAsCompleted) { + try { + ActionFileUpgradeUtil.upgradeAndDelete( + new File(getDownloadDirectory(context), fileName), + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + addNewDownloadsAsCompleted); + } catch (IOException e) { + Log.e(TAG, "Failed to upgrade action file: " + fileName, e); + } + } + + private static synchronized DatabaseProvider getDatabaseProvider(Context context) { + if (databaseProvider == null) { + databaseProvider = new ExoDatabaseProvider(context); + } + return databaseProvider; + } + + private static synchronized File getDownloadDirectory(Context context) { + if (downloadDirectory == null) { + downloadDirectory = context.getExternalFilesDir(/* type= */ null); + if (downloadDirectory == null) { + downloadDirectory = context.getFilesDir(); + } + } + return downloadDirectory; + } + + private static CacheDataSource.Factory buildReadOnlyCacheDataSource( + DataSource.Factory upstreamFactory, Cache cache) { + return new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(upstreamFactory) + .setCacheWriteDataSinkFactory(null) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR); + } + + private DemoUtil() {} +} diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java index 3127ed95e9..07f4dd2f6e 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -16,27 +16,37 @@ package com.google.android.exoplayer2.demo; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import android.content.Context; import android.content.DialogInterface; import android.net.Uri; +import android.os.AsyncTask; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.fragment.app.FragmentManager; -import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.OfflineLicenseHelper; import com.google.android.exoplayer2.offline.Download; import com.google.android.exoplayer2.offline.DownloadCursor; import com.google.android.exoplayer2.offline.DownloadHelper; +import com.google.android.exoplayer2.offline.DownloadHelper.LiveContentUnsupportedException; import com.google.android.exoplayer2.offline.DownloadIndex; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.offline.DownloadService; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; -import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -56,7 +66,7 @@ public class DownloadTracker { private static final String TAG = "DownloadTracker"; private final Context context; - private final DataSource.Factory dataSourceFactory; + private final HttpDataSource.Factory httpDataSourceFactory; private final CopyOnWriteArraySet listeners; private final HashMap downloads; private final DownloadIndex downloadIndex; @@ -65,9 +75,11 @@ public class DownloadTracker { @Nullable private StartDownloadDialogHelper startDownloadDialogHelper; public DownloadTracker( - Context context, DataSource.Factory dataSourceFactory, DownloadManager downloadManager) { + Context context, + HttpDataSource.Factory httpDataSourceFactory, + DownloadManager downloadManager) { this.context = context.getApplicationContext(); - this.dataSourceFactory = dataSourceFactory; + this.httpDataSourceFactory = httpDataSourceFactory; listeners = new CopyOnWriteArraySet<>(); downloads = new HashMap<>(); downloadIndex = downloadManager.getDownloadIndex(); @@ -77,6 +89,7 @@ public class DownloadTracker { } public void addListener(Listener listener) { + checkNotNull(listener); listeners.add(listener); } @@ -89,6 +102,7 @@ public class DownloadTracker { return download != null && download.state != Download.STATE_FAILED; } + @Nullable public DownloadRequest getDownloadRequest(Uri uri) { Download download = downloads.get(uri); return download != null && download.state != Download.STATE_FAILED ? download.request : null; @@ -106,7 +120,10 @@ public class DownloadTracker { } startDownloadDialogHelper = new StartDownloadDialogHelper( - fragmentManager, getDownloadHelper(mediaItem, renderersFactory), mediaItem); + fragmentManager, + DownloadHelper.forMediaItem( + context, mediaItem, renderersFactory, httpDataSourceFactory), + mediaItem); } } @@ -121,33 +138,13 @@ public class DownloadTracker { } } - private DownloadHelper getDownloadHelper(MediaItem mediaItem, RenderersFactory renderersFactory) { - MediaItem.PlaybackProperties playbackProperties = checkNotNull(mediaItem.playbackProperties); - @C.ContentType - int type = - Util.inferContentTypeWithMimeType(playbackProperties.uri, playbackProperties.mimeType); - switch (type) { - case C.TYPE_DASH: - return DownloadHelper.forDash( - context, playbackProperties.uri, dataSourceFactory, renderersFactory); - case C.TYPE_SS: - return DownloadHelper.forSmoothStreaming( - context, playbackProperties.uri, dataSourceFactory, renderersFactory); - case C.TYPE_HLS: - return DownloadHelper.forHls( - context, playbackProperties.uri, dataSourceFactory, renderersFactory); - case C.TYPE_OTHER: - return DownloadHelper.forProgressive(context, playbackProperties.uri); - default: - throw new IllegalStateException("Unsupported type: " + type); - } - } - private class DownloadManagerListener implements DownloadManager.Listener { @Override public void onDownloadChanged( - @NonNull DownloadManager downloadManager, @NonNull Download download) { + @NonNull DownloadManager downloadManager, + @NonNull Download download, + @Nullable Exception finalException) { downloads.put(download.request.uri, download); for (Listener listener : listeners) { listener.onDownloadsChanged(); @@ -175,6 +172,8 @@ public class DownloadTracker { private TrackSelectionDialog trackSelectionDialog; private MappedTrackInfo mappedTrackInfo; + private WidevineOfflineLicenseFetchTask widevineOfflineLicenseFetchTask; + @Nullable private byte[] keySetId; public StartDownloadDialogHelper( FragmentManager fragmentManager, DownloadHelper downloadHelper, MediaItem mediaItem) { @@ -189,46 +188,57 @@ public class DownloadTracker { if (trackSelectionDialog != null) { trackSelectionDialog.dismiss(); } + if (widevineOfflineLicenseFetchTask != null) { + widevineOfflineLicenseFetchTask.cancel(false); + } } // DownloadHelper.Callback implementation. @Override public void onPrepared(@NonNull DownloadHelper helper) { - if (helper.getPeriodCount() == 0) { - Log.d(TAG, "No periods found. Downloading entire stream."); - startDownload(); - downloadHelper.release(); + @Nullable Format format = getFirstFormatWithDrmInitData(helper); + if (format == null) { + onDownloadPrepared(helper); return; } - mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); - if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) { - Log.d(TAG, "No dialog content. Downloading entire stream."); - startDownload(); - downloadHelper.release(); + + // The content is DRM protected. We need to acquire an offline license. + if (Util.SDK_INT < 18) { + Toast.makeText(context, R.string.error_drm_unsupported_before_api_18, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Downloading DRM protected content is not supported on API versions below 18"); return; } - trackSelectionDialog = - TrackSelectionDialog.createForMappedTrackInfoAndParameters( - /* titleId= */ R.string.exo_download_description, - mappedTrackInfo, - trackSelectorParameters, - /* allowAdaptiveSelections =*/ false, - /* allowMultipleOverrides= */ true, - /* onClickListener= */ this, - /* onDismissListener= */ this); - trackSelectionDialog.show(fragmentManager, /* tag= */ null); + // TODO(internal b/163107948): Support cases where DrmInitData are not in the manifest. + if (!hasSchemaData(format.drmInitData)) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e( + TAG, + "Downloading content where DRM scheme data is not located in the manifest is not" + + " supported"); + return; + } + widevineOfflineLicenseFetchTask = + new WidevineOfflineLicenseFetchTask( + format, + mediaItem.playbackProperties.drmConfiguration.licenseUri, + httpDataSourceFactory, + /* dialogHelper= */ this, + helper); + widevineOfflineLicenseFetchTask.execute(); } @Override public void onPrepareError(@NonNull DownloadHelper helper, @NonNull IOException e) { - Toast.makeText(context, R.string.download_start_error, Toast.LENGTH_LONG).show(); - Log.e( - TAG, - e instanceof DownloadHelper.LiveContentUnsupportedException - ? "Downloading live content unsupported" - : "Failed to start download", - e); + boolean isLiveContent = e instanceof LiveContentUnsupportedException; + int toastStringId = + isLiveContent ? R.string.download_live_unsupported : R.string.download_start_error; + String logMessage = + isLiveContent ? "Downloading live content unsupported" : "Failed to start download"; + Toast.makeText(context, toastStringId, Toast.LENGTH_LONG).show(); + Log.e(TAG, logMessage, e); } // DialogInterface.OnClickListener implementation. @@ -265,6 +275,83 @@ public class DownloadTracker { // Internal methods. + /** + * Returns the first {@link Format} with a non-null {@link Format#drmInitData} found in the + * content's tracks, or null if none is found. + */ + @Nullable + private Format getFirstFormatWithDrmInitData(DownloadHelper helper) { + for (int periodIndex = 0; periodIndex < helper.getPeriodCount(); periodIndex++) { + MappedTrackInfo mappedTrackInfo = helper.getMappedTrackInfo(periodIndex); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + for (int trackGroupIndex = 0; trackGroupIndex < trackGroups.length; trackGroupIndex++) { + TrackGroup trackGroup = trackGroups.get(trackGroupIndex); + for (int formatIndex = 0; formatIndex < trackGroup.length; formatIndex++) { + Format format = trackGroup.getFormat(formatIndex); + if (format.drmInitData != null) { + return format; + } + } + } + } + } + return null; + } + + private void onOfflineLicenseFetched(DownloadHelper helper, byte[] keySetId) { + this.keySetId = keySetId; + onDownloadPrepared(helper); + } + + private void onOfflineLicenseFetchedError(DrmSession.DrmSessionException e) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Failed to fetch offline DRM license", e); + } + + private void onDownloadPrepared(DownloadHelper helper) { + if (helper.getPeriodCount() == 0) { + Log.d(TAG, "No periods found. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + + mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); + if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) { + Log.d(TAG, "No dialog content. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + trackSelectionDialog = + TrackSelectionDialog.createForMappedTrackInfoAndParameters( + /* titleId= */ R.string.exo_download_description, + mappedTrackInfo, + trackSelectorParameters, + /* allowAdaptiveSelections =*/ false, + /* allowMultipleOverrides= */ true, + /* onClickListener= */ this, + /* onDismissListener= */ this); + trackSelectionDialog.show(fragmentManager, /* tag= */ null); + } + + /** + * Returns whether any the {@link DrmInitData.SchemeData} contained in {@code drmInitData} has + * non-null {@link DrmInitData.SchemeData#data}. + */ + private boolean hasSchemaData(DrmInitData drmInitData) { + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + if (drmInitData.get(i).hasData()) { + return true; + } + } + return false; + } + private void startDownload() { startDownload(buildDownloadRequest()); } @@ -275,8 +362,62 @@ public class DownloadTracker { } private DownloadRequest buildDownloadRequest() { - return downloadHelper.getDownloadRequest( - Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title))); + return downloadHelper + .getDownloadRequest(Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title))) + .copyWithKeySetId(keySetId); + } + } + + /** Downloads a Widevine offline license in a background thread. */ + @RequiresApi(18) + private static final class WidevineOfflineLicenseFetchTask extends AsyncTask { + + private final Format format; + private final Uri licenseUri; + private final HttpDataSource.Factory httpDataSourceFactory; + private final StartDownloadDialogHelper dialogHelper; + private final DownloadHelper downloadHelper; + + @Nullable private byte[] keySetId; + @Nullable private DrmSession.DrmSessionException drmSessionException; + + public WidevineOfflineLicenseFetchTask( + Format format, + Uri licenseUri, + HttpDataSource.Factory httpDataSourceFactory, + StartDownloadDialogHelper dialogHelper, + DownloadHelper downloadHelper) { + this.format = format; + this.licenseUri = licenseUri; + this.httpDataSourceFactory = httpDataSourceFactory; + this.dialogHelper = dialogHelper; + this.downloadHelper = downloadHelper; + } + + @Override + protected Void doInBackground(Void... voids) { + OfflineLicenseHelper offlineLicenseHelper = + OfflineLicenseHelper.newWidevineInstance( + licenseUri.toString(), + httpDataSourceFactory, + new DrmSessionEventListener.EventDispatcher()); + try { + keySetId = offlineLicenseHelper.downloadLicense(format); + } catch (DrmSession.DrmSessionException e) { + drmSessionException = e; + } finally { + offlineLicenseHelper.release(); + } + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + if (drmSessionException != null) { + dialogHelper.onOfflineLicenseFetchedError(drmSessionException); + } else { + dialogHelper.onOfflineLicenseFetched(downloadHelper, checkStateNotNull(keySetId)); + } } } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java index c043fa9f5d..d2d962c568 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java @@ -23,35 +23,18 @@ import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; /** Util to read from and populate an intent. */ public class IntentUtil { - /** A tag to hold custom playback configuration attributes. */ - public static class Tag { - - /** Whether the stream is a live stream. */ - public final boolean isLive; - /** The spherical stereo mode or null. */ - @Nullable public final String sphericalStereoMode; - - /** Creates an instance. */ - public Tag(boolean isLive, @Nullable String sphericalStereoMode) { - this.isLive = isLive; - this.sphericalStereoMode = sphericalStereoMode; - } - } - // Actions. public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW"; @@ -60,61 +43,41 @@ public class IntentUtil { // Activity extras. - public static final String SPHERICAL_STEREO_MODE_EXTRA = "spherical_stereo_mode"; - public static final String SPHERICAL_STEREO_MODE_MONO = "mono"; - public static final String SPHERICAL_STEREO_MODE_TOP_BOTTOM = "top_bottom"; - public static final String SPHERICAL_STEREO_MODE_LEFT_RIGHT = "left_right"; - - // Player configuration extras. - - public static final String ABR_ALGORITHM_EXTRA = "abr_algorithm"; - public static final String ABR_ALGORITHM_DEFAULT = "default"; - public static final String ABR_ALGORITHM_RANDOM = "random"; + public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders"; // Media item configuration extras. public static final String URI_EXTRA = "uri"; - public static final String IS_LIVE_EXTRA = "is_live"; public static final String MIME_TYPE_EXTRA = "mime_type"; - // For backwards compatibility only. - public static final String EXTENSION_EXTRA = "extension"; + public static final String CLIP_START_POSITION_MS_EXTRA = "clip_start_position_ms"; + public static final String CLIP_END_POSITION_MS_EXTRA = "clip_end_position_ms"; + + public static final String AD_TAG_URI_EXTRA = "ad_tag_uri"; public static final String DRM_SCHEME_EXTRA = "drm_scheme"; - public static final String DRM_LICENSE_URL_EXTRA = "drm_license_url"; + public static final String DRM_LICENSE_URI_EXTRA = "drm_license_uri"; public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties"; - public static final String DRM_SESSION_FOR_CLEAR_TYPES_EXTRA = "drm_session_for_clear_types"; + public static final String DRM_SESSION_FOR_CLEAR_CONTENT = "drm_session_for_clear_content"; public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session"; - public static final String AD_TAG_URI_EXTRA = "ad_tag_uri"; + public static final String DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA = "drm_force_default_license_uri"; + public static final String SUBTITLE_URI_EXTRA = "subtitle_uri"; public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type"; public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language"; - // For backwards compatibility only. - public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid"; - - public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders"; - public static final String TUNNELING_EXTRA = "tunneling"; /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */ - public static List createMediaItemsFromIntent( - Intent intent, DownloadTracker downloadTracker) { + public static List createMediaItemsFromIntent(Intent intent) { List mediaItems = new ArrayList<>(); if (ACTION_VIEW_LIST.equals(intent.getAction())) { int index = 0; while (intent.hasExtra(URI_EXTRA + "_" + index)) { Uri uri = Uri.parse(intent.getStringExtra(URI_EXTRA + "_" + index)); - mediaItems.add( - createMediaItemFromIntent( - uri, - intent, - /* extrasKeySuffix= */ "_" + index, - downloadTracker.getDownloadRequest(uri))); + mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "_" + index)); index++; } } else { Uri uri = intent.getData(); - mediaItems.add( - createMediaItemFromIntent( - uri, intent, /* extrasKeySuffix= */ "", downloadTracker.getDownloadRequest(uri))); + mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "")); } return mediaItems; } @@ -123,54 +86,41 @@ public class IntentUtil { public static void addToIntent(List mediaItems, Intent intent) { Assertions.checkArgument(!mediaItems.isEmpty()); if (mediaItems.size() == 1) { - MediaItem.PlaybackProperties playbackProperties = - checkNotNull(mediaItems.get(0).playbackProperties); - intent.setAction(IntentUtil.ACTION_VIEW).setData(playbackProperties.uri); + MediaItem mediaItem = mediaItems.get(0); + MediaItem.PlaybackProperties playbackProperties = checkNotNull(mediaItem.playbackProperties); + intent.setAction(ACTION_VIEW).setData(mediaItem.playbackProperties.uri); addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ ""); + addClippingPropertiesToIntent( + mediaItem.clippingProperties, intent, /* extrasKeySuffix= */ ""); } else { - intent.setAction(IntentUtil.ACTION_VIEW_LIST); + intent.setAction(ACTION_VIEW_LIST); for (int i = 0; i < mediaItems.size(); i++) { + MediaItem mediaItem = mediaItems.get(i); MediaItem.PlaybackProperties playbackProperties = - checkNotNull(mediaItems.get(i).playbackProperties); - intent.putExtra(IntentUtil.URI_EXTRA + ("_" + i), playbackProperties.uri.toString()); + checkNotNull(mediaItem.playbackProperties); + intent.putExtra(URI_EXTRA + ("_" + i), playbackProperties.uri.toString()); addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ "_" + i); + addClippingPropertiesToIntent( + mediaItem.clippingProperties, intent, /* extrasKeySuffix= */ "_" + i); } } } - /** Makes a best guess to infer the MIME type from a {@link Uri} and an optional extension. */ - @Nullable - public static String inferAdaptiveStreamMimeType(Uri uri, @Nullable String extension) { - @C.ContentType int contentType = Util.inferContentType(uri, extension); - switch (contentType) { - case C.TYPE_DASH: - return MimeTypes.APPLICATION_MPD; - case C.TYPE_HLS: - return MimeTypes.APPLICATION_M3U8; - case C.TYPE_SS: - return MimeTypes.APPLICATION_SS; - case C.TYPE_OTHER: - default: - return null; - } - } - private static MediaItem createMediaItemFromIntent( - Uri uri, Intent intent, String extrasKeySuffix, @Nullable DownloadRequest downloadRequest) { - String mimeType = intent.getStringExtra(MIME_TYPE_EXTRA + extrasKeySuffix); - if (mimeType == null) { - // Try to use extension for backwards compatibility. - String extension = intent.getStringExtra(EXTENSION_EXTRA + extrasKeySuffix); - mimeType = inferAdaptiveStreamMimeType(uri, extension); - } + Uri uri, Intent intent, String extrasKeySuffix) { + @Nullable String mimeType = intent.getStringExtra(MIME_TYPE_EXTRA + extrasKeySuffix); MediaItem.Builder builder = new MediaItem.Builder() .setUri(uri) - .setStreamKeys(downloadRequest != null ? downloadRequest.streamKeys : null) - .setCustomCacheKey(downloadRequest != null ? downloadRequest.customCacheKey : null) .setMimeType(mimeType) .setAdTagUri(intent.getStringExtra(AD_TAG_URI_EXTRA + extrasKeySuffix)) - .setSubtitles(createSubtitlesFromIntent(intent, extrasKeySuffix)); + .setSubtitles(createSubtitlesFromIntent(intent, extrasKeySuffix)) + .setClipStartPositionMs( + intent.getLongExtra(CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, 0)) + .setClipEndPositionMs( + intent.getLongExtra( + CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, C.TIME_END_OF_SOURCE)); + return populateDrmPropertiesFromIntent(builder, intent, extrasKeySuffix).build(); } @@ -190,17 +140,12 @@ public class IntentUtil { private static MediaItem.Builder populateDrmPropertiesFromIntent( MediaItem.Builder builder, Intent intent, String extrasKeySuffix) { String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix; - String schemeUuidKey = DRM_SCHEME_UUID_EXTRA + extrasKeySuffix; - if (!intent.hasExtra(schemeKey) && !intent.hasExtra(schemeUuidKey)) { + @Nullable String drmSchemeExtra = intent.getStringExtra(schemeKey); + if (drmSchemeExtra == null) { return builder; } - String drmSchemeExtra = - intent.hasExtra(schemeKey) - ? intent.getStringExtra(schemeKey) - : intent.getStringExtra(schemeUuidKey); - String[] drmSessionForClearTypesExtra = - intent.getStringArrayExtra(DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix); Map headers = new HashMap<>(); + @Nullable String[] keyRequestPropertiesArray = intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix); if (keyRequestPropertiesArray != null) { @@ -210,50 +155,25 @@ public class IntentUtil { } builder .setDrmUuid(Util.getDrmUuid(Util.castNonNull(drmSchemeExtra))) - .setDrmLicenseUri(intent.getStringExtra(DRM_LICENSE_URL_EXTRA + extrasKeySuffix)) - .setDrmSessionForClearTypes(toTrackTypeList(drmSessionForClearTypesExtra)) + .setDrmLicenseUri(intent.getStringExtra(DRM_LICENSE_URI_EXTRA + extrasKeySuffix)) .setDrmMultiSession( intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false)) + .setDrmForceDefaultLicenseUri( + intent.getBooleanExtra(DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, false)) .setDrmLicenseRequestHeaders(headers); + if (intent.getBooleanExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, false)) { + builder.setDrmSessionForClearTypes(ImmutableList.of(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO)); + } return builder; } - private static List toTrackTypeList(@Nullable String[] trackTypeStringsArray) { - if (trackTypeStringsArray == null) { - return Collections.emptyList(); - } - HashSet trackTypes = new HashSet<>(); - for (String trackTypeString : trackTypeStringsArray) { - switch (Util.toLowerInvariant(trackTypeString)) { - case "audio": - trackTypes.add(C.TRACK_TYPE_AUDIO); - break; - case "video": - trackTypes.add(C.TRACK_TYPE_VIDEO); - break; - default: - throw new IllegalArgumentException("Invalid track type: " + trackTypeString); - } - } - return new ArrayList<>(trackTypes); - } - private static void addPlaybackPropertiesToIntent( MediaItem.PlaybackProperties playbackProperties, Intent intent, String extrasKeySuffix) { - boolean isLive = false; - String sphericalStereoMode = null; - if (playbackProperties.tag instanceof Tag) { - Tag tag = (Tag) playbackProperties.tag; - isLive = tag.isLive; - sphericalStereoMode = tag.sphericalStereoMode; - } intent .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, playbackProperties.mimeType) .putExtra( AD_TAG_URI_EXTRA + extrasKeySuffix, - playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null) - .putExtra(IS_LIVE_EXTRA + extrasKeySuffix, isLive) - .putExtra(SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode); + playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null); if (playbackProperties.drmConfiguration != null) { addDrmConfigurationToIntent(playbackProperties.drmConfiguration, intent, extrasKeySuffix); } @@ -270,9 +190,12 @@ public class IntentUtil { MediaItem.DrmConfiguration drmConfiguration, Intent intent, String extrasKeySuffix) { intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmConfiguration.uuid.toString()); intent.putExtra( - DRM_LICENSE_URL_EXTRA + extrasKeySuffix, - checkNotNull(drmConfiguration.licenseUri).toString()); + DRM_LICENSE_URI_EXTRA + extrasKeySuffix, + drmConfiguration.licenseUri != null ? drmConfiguration.licenseUri.toString() : null); intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmConfiguration.multiSession); + intent.putExtra( + DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, + drmConfiguration.forceDefaultLicenseUri); String[] drmKeyRequestProperties = new String[drmConfiguration.requestHeaders.size() * 2]; int index = 0; @@ -282,13 +205,26 @@ public class IntentUtil { } intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties); - ArrayList typeStrings = new ArrayList<>(); - for (int type : drmConfiguration.sessionForClearTypes) { - // Only audio and video are supported. - Assertions.checkState(type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO); - typeStrings.add(type == C.TRACK_TYPE_AUDIO ? "audio" : "video"); + List drmSessionForClearTypes = drmConfiguration.sessionForClearTypes; + if (!drmSessionForClearTypes.isEmpty()) { + // Only video and audio together are supported. + Assertions.checkState( + drmSessionForClearTypes.size() == 2 + && drmSessionForClearTypes.contains(C.TRACK_TYPE_VIDEO) + && drmSessionForClearTypes.contains(C.TRACK_TYPE_AUDIO)); + intent.putExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, true); + } + } + + private static void addClippingPropertiesToIntent( + MediaItem.ClippingProperties clippingProperties, Intent intent, String extrasKeySuffix) { + if (clippingProperties.startPositionMs != 0) { + intent.putExtra( + CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, clippingProperties.startPositionMs); + } + if (clippingProperties.endPositionMs != C.TIME_END_OF_SOURCE) { + intent.putExtra( + CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, clippingProperties.endPositionMs); } - intent.putExtra( - DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix, typeStrings.toArray(new String[0])); } } 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 47d7966b18..eae302887e 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 @@ -15,9 +15,10 @@ */ package com.google.android.exoplayer2.demo; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.content.Intent; import android.content.pm.PackageManager; -import android.media.MediaDrm; import android.net.Uri; import android.os.Bundle; import android.util.Pair; @@ -39,37 +40,36 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.drm.FrameworkMediaDrm; +import com.google.android.exoplayer2.ext.ima.ImaAdsLoader; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; +import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdsLoader; -import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; -import com.google.android.exoplayer2.trackselection.RandomTrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.DebugTextViewHelper; -import com.google.android.exoplayer2.ui.PlayerControlView; -import com.google.android.exoplayer2.ui.PlayerView; -import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView; +import com.google.android.exoplayer2.ui.StyledPlayerControlView; +import com.google.android.exoplayer2.ui.StyledPlayerView; import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ErrorMessageProvider; import com.google.android.exoplayer2.util.EventLogger; import com.google.android.exoplayer2.util.Util; -import java.lang.reflect.Constructor; import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; +import java.util.ArrayList; import java.util.Collections; import java.util.List; /** An activity that plays media using {@link SimpleExoPlayer}. */ public class PlayerActivity extends AppCompatActivity - implements OnClickListener, PlaybackPreparer, PlayerControlView.VisibilityListener { + implements OnClickListener, PlaybackPreparer, StyledPlayerControlView.VisibilityListener { // Saved instance state keys. @@ -85,14 +85,14 @@ public class PlayerActivity extends AppCompatActivity DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); } - private PlayerView playerView; - private LinearLayout debugRootView; - private Button selectTracksButton; - private TextView debugTextView; - private boolean isShowingTrackSelectionDialog; + protected StyledPlayerView playerView; + protected LinearLayout debugRootView; + protected TextView debugTextView; + protected SimpleExoPlayer player; + private boolean isShowingTrackSelectionDialog; + private Button selectTracksButton; private DataSource.Factory dataSourceFactory; - private SimpleExoPlayer player; private List mediaItems; private DefaultTrackSelector trackSelector; private DefaultTrackSelector.Parameters trackSelectorParameters; @@ -111,18 +111,13 @@ public class PlayerActivity extends AppCompatActivity @Override public void onCreate(Bundle savedInstanceState) { - Intent intent = getIntent(); - String sphericalStereoMode = intent.getStringExtra(IntentUtil.SPHERICAL_STEREO_MODE_EXTRA); - if (sphericalStereoMode != null) { - setTheme(R.style.PlayerTheme_Spherical); - } super.onCreate(savedInstanceState); - dataSourceFactory = buildDataSourceFactory(); + dataSourceFactory = DemoUtil.getDataSourceFactory(/* context= */ this); if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); } - setContentView(R.layout.player_activity); + setContentView(); debugRootView = findViewById(R.id.controls_root); debugTextView = findViewById(R.id.debug_text_view); selectTracksButton = findViewById(R.id.select_tracks_button); @@ -132,21 +127,6 @@ public class PlayerActivity extends AppCompatActivity playerView.setControllerVisibilityListener(this); playerView.setErrorMessageProvider(new PlayerErrorMessageProvider()); playerView.requestFocus(); - if (sphericalStereoMode != null) { - int stereoMode; - if (IntentUtil.SPHERICAL_STEREO_MODE_MONO.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_MONO; - } else if (IntentUtil.SPHERICAL_STEREO_MODE_TOP_BOTTOM.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_TOP_BOTTOM; - } else if (IntentUtil.SPHERICAL_STEREO_MODE_LEFT_RIGHT.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_LEFT_RIGHT; - } else { - showToast(R.string.error_unrecognized_stereo_mode); - finish(); - return; - } - ((SphericalGLSurfaceView) playerView.getVideoSurfaceView()).setDefaultStereoMode(stereoMode); - } if (savedInstanceState != null) { trackSelectorParameters = savedInstanceState.getParcelable(KEY_TRACK_SELECTOR_PARAMETERS); @@ -156,10 +136,6 @@ public class PlayerActivity extends AppCompatActivity } else { DefaultTrackSelector.ParametersBuilder builder = new DefaultTrackSelector.ParametersBuilder(/* context= */ this); - boolean tunneling = intent.getBooleanExtra(IntentUtil.TUNNELING_EXTRA, false); - if (Util.SDK_INT >= 21 && tunneling) { - builder.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(/* context= */ this)); - } trackSelectorParameters = builder.build(); clearStartPosition(); } @@ -276,14 +252,14 @@ public class PlayerActivity extends AppCompatActivity } } - // PlaybackControlView.PlaybackPreparer implementation + // PlaybackPreparer implementation @Override public void preparePlayback() { player.prepare(); } - // PlaybackControlView.VisibilityListener implementation + // PlayerControlView.VisibilityListener implementation @Override public void onVisibilityChange(int visibility) { @@ -292,47 +268,41 @@ public class PlayerActivity extends AppCompatActivity // Internal methods - private void initializePlayer() { + protected void setContentView() { + setContentView(R.layout.player_activity); + } + + /** @return Whether initialization was successful. */ + protected boolean initializePlayer() { if (player == null) { Intent intent = getIntent(); mediaItems = createMediaItems(intent); if (mediaItems.isEmpty()) { - return; - } - - TrackSelection.Factory trackSelectionFactory; - String abrAlgorithm = intent.getStringExtra(IntentUtil.ABR_ALGORITHM_EXTRA); - if (abrAlgorithm == null || IntentUtil.ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) { - trackSelectionFactory = new AdaptiveTrackSelection.Factory(); - } else if (IntentUtil.ABR_ALGORITHM_RANDOM.equals(abrAlgorithm)) { - trackSelectionFactory = new RandomTrackSelection.Factory(); - } else { - showToast(R.string.error_unrecognized_abr_algorithm); - finish(); - return; + return false; } boolean preferExtensionDecoders = intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false); RenderersFactory renderersFactory = - ((DemoApplication) getApplication()).buildRenderersFactory(preferExtensionDecoders); + DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders); + MediaSourceFactory mediaSourceFactory = + new DefaultMediaSourceFactory(dataSourceFactory) + .setAdsLoaderProvider(this::getAdsLoader) + .setAdViewProvider(playerView); - trackSelector = new DefaultTrackSelector(/* context= */ this, trackSelectionFactory); + trackSelector = new DefaultTrackSelector(/* context= */ this); trackSelector.setParameters(trackSelectorParameters); lastSeenTrackGroupArray = null; - player = new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory) - .setMediaSourceFactory( - new DefaultMediaSourceFactory( - /* context= */ this, dataSourceFactory, new AdSupportProvider())) + .setMediaSourceFactory(mediaSourceFactory) .setTrackSelector(trackSelector) .build(); player.addListener(new PlayerEventListener()); + player.addAnalyticsListener(new EventLogger(trackSelector)); player.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true); player.setPlayWhenReady(startAutoPlay); - player.addAnalyticsListener(new EventLogger(trackSelector)); playerView.setPlayer(player); playerView.setPlaybackPreparer(this); debugViewHelper = new DebugTextViewHelper(player, debugTextView); @@ -345,6 +315,7 @@ public class PlayerActivity extends AppCompatActivity player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition); player.prepare(); updateButtonVisibility(); + return true; } private List createMediaItems(Intent intent) { @@ -357,8 +328,7 @@ public class PlayerActivity extends AppCompatActivity } List mediaItems = - IntentUtil.createMediaItemsFromIntent( - intent, ((DemoApplication) getApplication()).getDownloadTracker()); + createMediaItems(intent, DemoUtil.getDownloadTracker(/* context= */ this)); boolean hasAds = false; for (int i = 0; i < mediaItems.size(); i++) { MediaItem mediaItem = mediaItems.get(i); @@ -373,13 +343,13 @@ public class PlayerActivity extends AppCompatActivity } MediaItem.DrmConfiguration drmConfiguration = - Assertions.checkNotNull(mediaItem.playbackProperties).drmConfiguration; + checkNotNull(mediaItem.playbackProperties).drmConfiguration; if (drmConfiguration != null) { if (Util.SDK_INT < 18) { showToast(R.string.error_drm_unsupported_before_api_18); finish(); return Collections.emptyList(); - } else if (!MediaDrm.isCryptoSchemeSupported(drmConfiguration.uuid)) { + } else if (!FrameworkMediaDrm.isCryptoSchemeSupported(drmConfiguration.uuid)) { showToast(R.string.error_drm_unsupported_scheme); finish(); return Collections.emptyList(); @@ -393,7 +363,25 @@ public class PlayerActivity extends AppCompatActivity return mediaItems; } - private void releasePlayer() { + private AdsLoader getAdsLoader(Uri adTagUri) { + if (mediaItems.size() > 1) { + showToast(R.string.unsupported_ads_in_playlist); + releaseAdsLoader(); + return null; + } + if (!adTagUri.equals(loadedAdTagUri)) { + releaseAdsLoader(); + loadedAdTagUri = adTagUri; + } + // The ads loader is reused for multiple playbacks, so that ad playback can resume. + if (adsLoader == null) { + adsLoader = new ImaAdsLoader(/* context= */ PlayerActivity.this, adTagUri); + } + adsLoader.setPlayer(player); + return adsLoader; + } + + protected void releasePlayer() { if (player != null) { updateTrackSelectorParameters(); updateStartPosition(); @@ -432,42 +420,12 @@ public class PlayerActivity extends AppCompatActivity } } - private void clearStartPosition() { + protected void clearStartPosition() { startAutoPlay = true; startWindow = C.INDEX_UNSET; startPosition = C.TIME_UNSET; } - /** Returns a new DataSource factory. */ - private DataSource.Factory buildDataSourceFactory() { - return ((DemoApplication) getApplication()).buildDataSourceFactory(); - } - - /** - * Returns an ads loader for the Interactive Media Ads SDK if found in the classpath, or null - * otherwise. - */ - @Nullable - private AdsLoader maybeCreateAdsLoader(Uri adTagUri) { - // Load the extension source using reflection so the demo app doesn't have to depend on it. - try { - Class loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader"); - // Full class names used so the lint rule triggers should any of the classes move. - // LINT.IfChange - Constructor loaderConstructor = - loaderClass - .asSubclass(AdsLoader.class) - .getConstructor(android.content.Context.class, android.net.Uri.class); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - return loaderConstructor.newInstance(this, adTagUri); - } catch (ClassNotFoundException e) { - // IMA extension not loaded. - return null; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - // User controls private void updateButtonVisibility() { @@ -579,35 +537,26 @@ public class PlayerActivity extends AppCompatActivity } } - private class AdSupportProvider implements DefaultMediaSourceFactory.AdSupportProvider { - - @Nullable - @Override - public AdsLoader getAdsLoader(Uri adTagUri) { - if (mediaItems.size() > 1) { - showToast(R.string.unsupported_ads_in_concatenation); - releaseAdsLoader(); - return null; - } - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; - } - // The ads loader is reused for multiple playbacks, so that ad playback can resume. - if (adsLoader == null) { - adsLoader = maybeCreateAdsLoader(adTagUri); - } - if (adsLoader != null) { - adsLoader.setPlayer(player); + private static List createMediaItems(Intent intent, DownloadTracker downloadTracker) { + List mediaItems = new ArrayList<>(); + for (MediaItem item : IntentUtil.createMediaItemsFromIntent(intent)) { + @Nullable + DownloadRequest downloadRequest = + downloadTracker.getDownloadRequest(checkNotNull(item.playbackProperties).uri); + if (downloadRequest != null) { + MediaItem.Builder builder = item.buildUpon(); + builder + .setMediaId(downloadRequest.id) + .setUri(downloadRequest.uri) + .setCustomCacheKey(downloadRequest.customCacheKey) + .setMimeType(downloadRequest.mimeType) + .setStreamKeys(downloadRequest.streamKeys) + .setDrmKeySetId(downloadRequest.keySetId); + mediaItems.add(builder.build()); } else { - showToast(R.string.ima_not_loaded); + mediaItems.add(item); } - return adsLoader; - } - - @Override - public AdsLoader.AdViewProvider getAdViewProvider() { - return Assertions.checkNotNull(playerView); } + return mediaItems; } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 6f598b95a0..ea5b38ce8e 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -15,7 +15,9 @@ */ package com.google.android.exoplayer2.demo; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; import android.content.Context; import android.content.Intent; @@ -51,10 +53,9 @@ import com.google.android.exoplayer2.offline.DownloadService; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSourceInputStream; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -62,7 +63,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; @@ -71,16 +71,14 @@ public class SampleChooserActivity extends AppCompatActivity implements DownloadTracker.Listener, OnChildClickListener { private static final String TAG = "SampleChooserActivity"; - private static final String GROUP_POSITION_PREFERENCE_KEY = "SAMPLE_CHOOSER_GROUP_POSITION"; - private static final String CHILD_POSITION_PREFERENCE_KEY = "SAMPLE_CHOOSER_CHILD_POSITION"; + private static final String GROUP_POSITION_PREFERENCE_KEY = "sample_chooser_group_position"; + private static final String CHILD_POSITION_PREFERENCE_KEY = "sample_chooser_child_position"; private String[] uris; private boolean useExtensionRenderers; private DownloadTracker downloadTracker; private SampleAdapter sampleAdapter; private MenuItem preferExtensionDecodersMenuItem; - private MenuItem randomAbrMenuItem; - private MenuItem tunnelingMenuItem; private ExpandableListView sampleListView; @Override @@ -115,9 +113,8 @@ public class SampleChooserActivity extends AppCompatActivity Arrays.sort(uris); } - DemoApplication application = (DemoApplication) getApplication(); - useExtensionRenderers = application.useExtensionRenderers(); - downloadTracker = application.getDownloadTracker(); + useExtensionRenderers = DemoUtil.useExtensionRenderers(); + downloadTracker = DemoUtil.getDownloadTracker(/* context= */ this); loadSample(); // Start the download service if it should be running but it's not currently. @@ -137,11 +134,6 @@ public class SampleChooserActivity extends AppCompatActivity inflater.inflate(R.menu.sample_chooser_menu, menu); preferExtensionDecodersMenuItem = menu.findItem(R.id.prefer_extension_decoders); preferExtensionDecodersMenuItem.setVisible(useExtensionRenderers); - randomAbrMenuItem = menu.findItem(R.id.random_abr); - tunnelingMenuItem = menu.findItem(R.id.tunneling); - if (Util.SDK_INT < 21) { - tunnelingMenuItem.setEnabled(false); - } return true; } @@ -209,16 +201,13 @@ public class SampleChooserActivity extends AppCompatActivity sampleAdapter.setPlaylistGroups(groups); SharedPreferences preferences = getPreferences(MODE_PRIVATE); - - int groupPosition = -1; - int childPosition = -1; - try { - groupPosition = preferences.getInt(GROUP_POSITION_PREFERENCE_KEY, /* defValue= */ -1); - childPosition = preferences.getInt(CHILD_POSITION_PREFERENCE_KEY, /* defValue= */ -1); - } catch (ClassCastException e) { - Log.w(TAG, "Saved position is not an int. Will not restore position.", e); - } - if (groupPosition != -1 && childPosition != -1) { + int groupPosition = preferences.getInt(GROUP_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + int childPosition = preferences.getInt(CHILD_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + // Clear the group and child position if either are unset or if either are out of bounds. + if (groupPosition != -1 + && childPosition != -1 + && groupPosition < groups.size() + && childPosition < groups.get(groupPosition).playlists.size()) { sampleListView.expandGroup(groupPosition); // shouldExpandGroup does not work without this. sampleListView.setSelectedChild(groupPosition, childPosition, /* shouldExpandGroup= */ true); } @@ -238,12 +227,6 @@ public class SampleChooserActivity extends AppCompatActivity intent.putExtra( IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, isNonNullAndChecked(preferExtensionDecodersMenuItem)); - String abrAlgorithm = - isNonNullAndChecked(randomAbrMenuItem) - ? IntentUtil.ABR_ALGORITHM_RANDOM - : IntentUtil.ABR_ALGORITHM_DEFAULT; - intent.putExtra(IntentUtil.ABR_ALGORITHM_EXTRA, abrAlgorithm); - intent.putExtra(IntentUtil.TUNNELING_EXTRA, isNonNullAndChecked(tunnelingMenuItem)); IntentUtil.addToIntent(playlistHolder.mediaItems, intent); startActivity(intent); return true; @@ -256,8 +239,8 @@ public class SampleChooserActivity extends AppCompatActivity .show(); } else { RenderersFactory renderersFactory = - ((DemoApplication) getApplication()) - .buildRenderersFactory(isNonNullAndChecked(preferExtensionDecodersMenuItem)); + DemoUtil.buildRenderersFactory( + /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem)); downloadTracker.toggleDownload( getSupportFragmentManager(), playlistHolder.mediaItems.get(0), renderersFactory); } @@ -269,12 +252,6 @@ public class SampleChooserActivity extends AppCompatActivity } MediaItem.PlaybackProperties playbackProperties = checkNotNull(playlistHolder.mediaItems.get(0).playbackProperties); - if (playbackProperties.drmConfiguration != null) { - return R.string.download_drm_unsupported; - } - if (((IntentUtil.Tag) checkNotNull(playbackProperties.tag)).isLive) { - return R.string.download_live_unsupported; - } if (playbackProperties.adTagUri != null) { return R.string.download_ads_unsupported; } @@ -298,9 +275,7 @@ public class SampleChooserActivity extends AppCompatActivity protected List doInBackground(String... uris) { List result = new ArrayList<>(); Context context = getApplicationContext(); - String userAgent = Util.getUserAgent(context, "ExoPlayerDemo"); - DataSource dataSource = - new DefaultDataSource(context, userAgent, /* allowCrossProtocolRedirects= */ false); + DataSource dataSource = DemoUtil.getDataSourceFactory(context).createDataSource(); for (String uri : uris) { DataSpec dataSpec = new DataSpec(Uri.parse(uri)); InputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); @@ -366,8 +341,6 @@ public class SampleChooserActivity extends AppCompatActivity Uri uri = null; String extension = null; String title = null; - boolean isLive = false; - String sphericalStereoMode = null; ArrayList children = null; Uri subtitleUri = null; String subtitleMimeType = null; @@ -387,13 +360,20 @@ public class SampleChooserActivity extends AppCompatActivity case "extension": extension = reader.nextString(); break; + case "clip_start_position_ms": + mediaItem.setClipStartPositionMs(reader.nextLong()); + break; + case "clip_end_position_ms": + mediaItem.setClipEndPositionMs(reader.nextLong()); + break; + case "ad_tag_uri": + mediaItem.setAdTagUri(reader.nextString()); + break; case "drm_scheme": mediaItem.setDrmUuid(Util.getDrmUuid(reader.nextString())); break; - case "is_live": - isLive = reader.nextBoolean(); - break; - case "drm_license_url": + case "drm_license_uri": + case "drm_license_url": // For backward compatibility only. mediaItem.setDrmLicenseUri(reader.nextString()); break; case "drm_key_request_properties": @@ -405,34 +385,17 @@ public class SampleChooserActivity extends AppCompatActivity reader.endObject(); mediaItem.setDrmLicenseRequestHeaders(requestHeaders); break; - case "drm_session_for_clear_types": - HashSet drmSessionForClearTypes = new HashSet<>(); - reader.beginArray(); - while (reader.hasNext()) { - drmSessionForClearTypes.add(toTrackType(reader.nextString())); + case "drm_session_for_clear_content": + if (reader.nextBoolean()) { + mediaItem.setDrmSessionForClearTypes( + ImmutableList.of(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO)); } - reader.endArray(); - mediaItem.setDrmSessionForClearTypes(new ArrayList<>(drmSessionForClearTypes)); break; case "drm_multi_session": mediaItem.setDrmMultiSession(reader.nextBoolean()); break; - case "playlist": - Assertions.checkState(!insidePlaylist, "Invalid nesting of playlists"); - children = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - children.add(readEntry(reader, /* insidePlaylist= */ true)); - } - reader.endArray(); - break; - case "ad_tag_uri": - mediaItem.setAdTagUri(reader.nextString()); - break; - case "spherical_stereo_mode": - Assertions.checkState( - !insidePlaylist, "Invalid attribute on nested item: spherical_stereo_mode"); - sphericalStereoMode = reader.nextString(); + case "drm_force_default_license_uri": + mediaItem.setDrmForceDefaultLicenseUri(reader.nextBoolean()); break; case "subtitle_uri": subtitleUri = Uri.parse(reader.nextString()); @@ -443,6 +406,15 @@ public class SampleChooserActivity extends AppCompatActivity case "subtitle_language": subtitleLanguage = reader.nextString(); break; + case "playlist": + checkState(!insidePlaylist, "Invalid nesting of playlists"); + children = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + children.add(readEntry(reader, /* insidePlaylist= */ true)); + } + reader.endArray(); + break; default: throw new ParserException("Unsupported attribute name: " + name); } @@ -456,11 +428,13 @@ public class SampleChooserActivity extends AppCompatActivity } return new PlaylistHolder(title, mediaItems); } else { + @Nullable + String adaptiveMimeType = + Util.getAdaptiveMimeTypeForContentType(Util.inferContentType(uri, extension)); mediaItem .setUri(uri) .setMediaMetadata(new MediaMetadata.Builder().setTitle(title).build()) - .setMimeType(IntentUtil.inferAdaptiveStreamMimeType(uri, extension)) - .setTag(new IntentUtil.Tag(isLive, sphericalStereoMode)); + .setMimeType(adaptiveMimeType); if (subtitleUri != null) { MediaItem.Subtitle subtitle = new MediaItem.Subtitle( @@ -484,17 +458,6 @@ public class SampleChooserActivity extends AppCompatActivity groups.add(group); return group; } - - private int toTrackType(String trackTypeString) { - switch (Util.toLowerInvariant(trackTypeString)) { - case "audio": - return C.TRACK_TYPE_AUDIO; - case "video": - return C.TRACK_TYPE_VIDEO; - default: - throw new IllegalArgumentException("Invalid track type: " + trackTypeString); - } - } } private final class SampleAdapter extends BaseExpandableListAdapter implements OnClickListener { @@ -609,7 +572,7 @@ public class SampleChooserActivity extends AppCompatActivity public final List mediaItems; private PlaylistHolder(String title, List mediaItems) { - Assertions.checkArgument(!mediaItems.isEmpty()); + checkArgument(!mediaItems.isEmpty()); this.title = title; this.mediaItems = Collections.unmodifiableList(new ArrayList<>(mediaItems)); } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java index b1db44110d..5cf2353f21 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java @@ -286,7 +286,7 @@ public final class TrackSelectionDialog extends DialogFragment { private final class FragmentAdapter extends FragmentPagerAdapter { public FragmentAdapter(FragmentManager fragmentManager) { - super(fragmentManager); + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); } @Override diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java new file mode 100644 index 0000000000..cc22be27e0 --- /dev/null +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.demo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/demos/main/src/main/res/layout/player_activity.xml b/demos/main/src/main/res/layout/player_activity.xml index ea3de257e2..5b897fa7ea 100644 --- a/demos/main/src/main/res/layout/player_activity.xml +++ b/demos/main/src/main/res/layout/player_activity.xml @@ -15,14 +15,17 @@ --> - + android:layout_height="match_parent" + app:show_shuffle_button="true" + app:show_subtitle_button="true"/> - - diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index 671303a522..bd5cd63467 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -25,16 +25,10 @@ Playback failed - Unrecognized ABR algorithm - - Unrecognized stereo mode - - Protected content not supported on API levels below 18 + DRM content not supported on API levels below 18 This device does not support the required DRM scheme - An unknown DRM error occurred - This device does not provide a decoder for %1$s This device does not provide a secure decoder for %1$s @@ -51,15 +45,13 @@ One or more sample lists failed to load - Playing sample without ads, as the IMA extension was not loaded - - Playing sample without ads, as ads are not supported in concatenations + Playing without ads, as ads are not supported in playlists Failed to start download - This demo app does not support downloading playlists + Failed to obtain offline license - This demo app does not support downloading protected content + This demo app does not support downloading playlists This demo app only supports downloading http streams @@ -69,8 +61,4 @@ Prefer extension decoders - Enable random ABR - - Request multimedia tunneling - diff --git a/demos/main/src/main/res/values/styles.xml b/demos/main/src/main/res/values/styles.xml index a2ebde37bd..3a8740d80a 100644 --- a/demos/main/src/main/res/values/styles.xml +++ b/demos/main/src/main/res/values/styles.xml @@ -23,8 +23,4 @@ @android:color/black - - 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/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java index 67419edf3b..eb669ecf94 100644 --- a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java +++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java @@ -28,6 +28,7 @@ import android.widget.Button; import android.widget.GridLayout; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; @@ -183,13 +184,12 @@ public final class MainActivity extends Activity { ACTION_VIEW.equals(action) ? Assertions.checkNotNull(intent.getData()) : Uri.parse(DEFAULT_MEDIA_URI); - String userAgent = Util.getUserAgent(this, getString(R.string.application_name)); DrmSessionManager drmSessionManager; if (intent.hasExtra(DRM_SCHEME_EXTRA)) { String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA)); String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA)); UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme)); - HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); + HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(); HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory); drmSessionManager = @@ -200,21 +200,19 @@ public final class MainActivity extends Activity { drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); } - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - this, Util.getUserAgent(this, getString(R.string.application_name))); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this); MediaSource mediaSource; @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA)); if (type == C.TYPE_DASH) { mediaSource = new DashMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else if (type == C.TYPE_OTHER) { mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory) .setDrmSessionManager(drmSessionManager) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); } else { throw new IllegalStateException(); } diff --git a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java new file mode 100644 index 0000000000..0f632a6e3c --- /dev/null +++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.surfacedemo; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/extensions/av1/README.md b/extensions/av1/README.md index 54e27a3b87..8e11a5e2e7 100644 --- a/extensions/av1/README.md +++ b/extensions/av1/README.md @@ -39,7 +39,7 @@ git clone https://github.com/google/cpu_features ``` cd "${AV1_EXT_PATH}/jni" && \ -git clone https://chromium.googlesource.com/codecs/libgav1 libgav1 +git clone https://chromium.googlesource.com/codecs/libgav1 ``` * Fetch Abseil: @@ -109,19 +109,22 @@ To try out playback using the extension in the [demo application][], see There are two possibilities for rendering the output `Libgav1VideoRenderer` gets from the libgav1 decoder: -* GL rendering using GL shader for color space conversion - * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option by - setting `surface_type` of `PlayerView` to be - `video_decoder_gl_surface_view`. - * Otherwise, enable this option by sending `Libgav1VideoRenderer` a message - of type `C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an instance of - `VideoDecoderOutputBufferRenderer` as its object. +* GL rendering using GL shader for color space conversion -* Native rendering using `ANativeWindow` - * If you are using `SimpleExoPlayer` with `PlayerView`, this option is enabled - by default. - * Otherwise, enable this option by sending `Libgav1VideoRenderer` a message of - type `C.MSG_SET_SURFACE` with an instance of `SurfaceView` as its object. + * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option + by setting `surface_type` of `PlayerView` to be + `video_decoder_gl_surface_view`. + * Otherwise, enable this option by sending `Libgav1VideoRenderer` a + message of type `Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` + with an instance of `VideoDecoderOutputBufferRenderer` as its object. + +* Native rendering using `ANativeWindow` + + * If you are using `SimpleExoPlayer` with `PlayerView`, this option is + enabled by default. + * Otherwise, enable this option by sending `Libgav1VideoRenderer` a + message of type `Renderer.MSG_SET_SURFACE` with an instance of + `SurfaceView` as its object. Note: Although the default option uses `ANativeWindow`, based on our testing the GL rendering mode has better performance, so should be preferred diff --git a/extensions/av1/build.gradle b/extensions/av1/build.gradle index d61a3a97f8..95a953d145 100644 --- a/extensions/av1/build.gradle +++ b/extensions/av1/build.gradle @@ -11,22 +11,10 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - externalNativeBuild { cmake { // Debug CMake build type causes video frames to drop, @@ -36,30 +24,22 @@ android { } } } - - // This option resolves the problem of finding libgav1JNI.so - // on multiple paths. The first one found is picked. - packagingOptions { - pickFirst 'lib/arm64-v8a/libgav1JNI.so' - pickFirst 'lib/armeabi-v7a/libgav1JNI.so' - pickFirst 'lib/x86/libgav1JNI.so' - pickFirst 'lib/x86_64/libgav1JNI.so' - } - - sourceSets.main { - // As native JNI library build is invoked from gradle, this is - // not needed. However, it exposes the built library and keeps - // consistency with the other extensions. - jniLibs.srcDir 'src/main/libs' - } } -// Configure the native build only if libgav1 is present, to avoid gradle sync -// failures if libgav1 hasn't been checked out according to the README and CMake -// isn't installed. +// Configure the native build only if libgav1 is present to avoid gradle sync +// failures if libgav1 hasn't been built according to the README instructions. if (project.file('src/main/jni/libgav1').exists()) { - android.externalNativeBuild.cmake.path = 'src/main/jni/CMakeLists.txt' - android.externalNativeBuild.cmake.version = '3.7.1+' + android.externalNativeBuild.cmake { + path = 'src/main/jni/CMakeLists.txt' + version = '3.7.1+' + if (project.hasProperty('externalNativeBuildDir')) { + if (!new File(externalNativeBuildDir).isAbsolute()) { + ext.externalNativeBuildDir = + new File(rootDir, it.externalNativeBuildDir) + } + buildStagingDirectory = "${externalNativeBuildDir}/${project.name}" + } + } } dependencies { 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 840cd158f9..ad8c8a682c 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java @@ -20,6 +20,7 @@ import static java.lang.Runtime.getRuntime; import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoDecoderInputBuffer; @@ -83,18 +84,9 @@ import java.nio.ByteBuffer; return "libgav1"; } - /** - * Sets the output mode for frames rendered by the decoder. - * - * @param outputMode The output mode. - */ - public void setOutputMode(@C.VideoOutputMode int outputMode) { - this.outputMode = outputMode; - } - @Override protected VideoDecoderInputBuffer createInputBuffer() { - return new VideoDecoderInputBuffer(); + return new VideoDecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); } @Override @@ -128,7 +120,7 @@ import java.nio.ByteBuffer; outputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); } if (!decodeOnly) { - outputBuffer.colorInfo = inputBuffer.colorInfo; + outputBuffer.format = inputBuffer.format; } return null; @@ -155,6 +147,15 @@ import java.nio.ByteBuffer; super.releaseOutputBuffer(buffer); } + /** + * Sets the output mode for frames rendered by the decoder. + * + * @param outputMode The output mode. + */ + public void setOutputMode(@C.VideoOutputMode int outputMode) { + this.outputMode = outputMode; + } + /** * Renders output buffer to the given surface. Must only be called when in {@link * C#VIDEO_OUTPUT_MODE_SURFACE_YUV} mode. diff --git a/extensions/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 c07a590c68..7c558d24b2 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 @@ -126,7 +126,7 @@ public class Libgav1VideoRenderer extends DecoderVideoRenderer { || !Gav1Library.isAvailable()) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); } - if (format.drmInitData != null && format.exoMediaCryptoType == null) { + if (format.exoMediaCryptoType != null) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM); } return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_SEAMLESS, TUNNELING_NOT_SUPPORTED); diff --git a/extensions/av1/src/main/jni/CMakeLists.txt b/extensions/av1/src/main/jni/CMakeLists.txt index 075773a70e..fe0e8edaeb 100644 --- a/extensions/av1/src/main/jni/CMakeLists.txt +++ b/extensions/av1/src/main/jni/CMakeLists.txt @@ -1,7 +1,4 @@ -# libgav1JNI requires modern CMake. cmake_minimum_required(VERSION 3.7.1 FATAL_ERROR) - -# libgav1JNI requires C++11. set(CMAKE_CXX_STANDARD 11) project(libgav1JNI C CXX) @@ -21,24 +18,13 @@ if(build_type MATCHES "^rel") endif() set(libgav1_jni_root "${CMAKE_CURRENT_SOURCE_DIR}") -set(libgav1_jni_build "${CMAKE_BINARY_DIR}") -set(libgav1_jni_output_directory - ${libgav1_jni_root}/../libs/${ANDROID_ABI}/) - -set(libgav1_root "${libgav1_jni_root}/libgav1") -set(libgav1_build "${libgav1_jni_build}/libgav1") - -set(cpu_features_root "${libgav1_jni_root}/cpu_features") -set(cpu_features_build "${libgav1_jni_build}/cpu_features") # Build cpu_features library. -add_subdirectory("${cpu_features_root}" - "${cpu_features_build}" +add_subdirectory("${libgav1_jni_root}/cpu_features" EXCLUDE_FROM_ALL) # Build libgav1. -add_subdirectory("${libgav1_root}" - "${libgav1_build}" +add_subdirectory("${libgav1_jni_root}/libgav1" EXCLUDE_FROM_ALL) # Build libgav1JNI. @@ -58,7 +44,3 @@ target_link_libraries(gav1JNI PRIVATE libgav1_static PRIVATE ${android_log_lib}) -# Specify output directory for libgav1JNI. -set_target_properties(gav1JNI PROPERTIES - LIBRARY_OUTPUT_DIRECTORY - ${libgav1_jni_output_directory}) diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle index 853861e4ad..4c8f648e34 100644 --- a/extensions/cast/build.gradle +++ b/extensions/cast/build.gradle @@ -11,24 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { api 'com.google.android.gms:play-services-cast-framework:18.1.0' @@ -36,7 +19,7 @@ dependencies { implementation project(modulePrefix + 'library-core') implementation project(modulePrefix + 'library-ui') compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 835d6a33fc..80d9817a46 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.cast; +import static java.lang.Math.min; + import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.BasePlayer; @@ -30,6 +32,7 @@ import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.FixedTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; @@ -290,6 +293,7 @@ public final class CastPlayer extends BasePlayer { @Override public void addListener(EventListener listener) { + Assertions.checkNotNull(listener); listeners.addIfAbsent(new ListenerHolder(listener)); } @@ -333,7 +337,7 @@ public final class CastPlayer extends BasePlayer { && toIndex <= currentTimeline.getWindowCount() && newIndex >= 0 && newIndex < currentTimeline.getWindowCount()); - newIndex = Math.min(newIndex, currentTimeline.getWindowCount() - (toIndex - fromIndex)); + newIndex = min(newIndex, currentTimeline.getWindowCount() - (toIndex - fromIndex)); if (fromIndex == toIndex || fromIndex == newIndex) { // Do nothing. return; @@ -426,6 +430,9 @@ public final class CastPlayer extends BasePlayer { return playWhenReady.value; } + // We still call EventListener#onSeekProcessed() for backwards compatibility with listeners that + // don't implement onPositionDiscontinuity(). + @SuppressWarnings("deprecation") @Override public void seekTo(int windowIndex, long positionMs) { MediaStatus mediaStatus = getMediaStatus(); @@ -451,32 +458,16 @@ public final class CastPlayer extends BasePlayer { flushNotifications(); } - /** @deprecated Use {@link #setPlaybackSpeed(float)} instead. */ - @SuppressWarnings("deprecation") - @Deprecated @Override public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { // Unsupported by the RemoteMediaClient API. Do nothing. } - /** @deprecated Use {@link #getPlaybackSpeed()} instead. */ - @SuppressWarnings("deprecation") - @Deprecated @Override public PlaybackParameters getPlaybackParameters() { return PlaybackParameters.DEFAULT; } - @Override - public void setPlaybackSpeed(float playbackSpeed) { - // Unsupported by the RemoteMediaClient API. Do nothing. - } - - @Override - public float getPlaybackSpeed() { - return Player.DEFAULT_PLAYBACK_SPEED; - } - @Override public void stop(boolean reset) { playbackState = STATE_IDLE; @@ -513,6 +504,12 @@ public final class CastPlayer extends BasePlayer { } } + @Override + @Nullable + public TrackSelector getTrackSelector() { + return null; + } + @Override public void setRepeatMode(@RepeatMode int repeatMode) { if (remoteMediaClient == null) { @@ -800,7 +797,7 @@ public final class CastPlayer extends BasePlayer { } return remoteMediaClient.queueLoad( mediaQueueItems, - Math.min(startWindowIndex, mediaQueueItems.length - 1), + min(startWindowIndex, mediaQueueItems.length - 1), getCastRepeatMode(repeatMode), startPositionMs, /* customData= */ null); @@ -874,7 +871,7 @@ public final class CastPlayer extends BasePlayer { return; } if (this.remoteMediaClient != null) { - this.remoteMediaClient.removeListener(statusListener); + this.remoteMediaClient.unregisterCallback(statusListener); this.remoteMediaClient.removeProgressListener(statusListener); } this.remoteMediaClient = remoteMediaClient; @@ -882,7 +879,7 @@ public final class CastPlayer extends BasePlayer { if (sessionAvailabilityListener != null) { sessionAvailabilityListener.onCastSessionAvailable(); } - remoteMediaClient.addListener(statusListener); + remoteMediaClient.registerCallback(statusListener); remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS); updateInternalStateAndNotifyIfChanged(); } else { @@ -996,10 +993,8 @@ public final class CastPlayer extends BasePlayer { // Internal classes. - private final class StatusListener - implements RemoteMediaClient.Listener, - SessionManagerListener, - RemoteMediaClient.ProgressListener { + private final class StatusListener extends RemoteMediaClient.Callback + implements SessionManagerListener, RemoteMediaClient.ProgressListener { // RemoteMediaClient.ProgressListener implementation. @@ -1008,7 +1003,7 @@ public final class CastPlayer extends BasePlayer { lastReportedPositionMs = progressMs; } - // RemoteMediaClient.Listener implementation. + // RemoteMediaClient.Callback implementation. @Override public void onStatusUpdated() { @@ -1085,6 +1080,9 @@ public final class CastPlayer extends BasePlayer { private final class SeekResultCallback implements ResultCallback { + // We still call EventListener#onSeekProcessed() for backwards compatibility with listeners that + // don't implement onPositionDiscontinuity(). + @SuppressWarnings("deprecation") @Override public void onResult(MediaChannelResult result) { int statusCode = result.getStatus().getStatusCode(); diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java index 38a7a692b2..edd2a060d2 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java @@ -15,10 +15,12 @@ */ package com.google.android.exoplayer2.ext.cast; +import android.net.Uri; import android.util.SparseArray; import android.util.SparseIntArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import java.util.Arrays; @@ -126,7 +128,7 @@ import java.util.Arrays; boolean isDynamic = durationUs == C.TIME_UNSET; return window.set( /* uid= */ ids[windowIndex], - /* tag= */ ids[windowIndex], + /* mediaItem= */ new MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build(), /* manifest= */ null, /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java index d1ab67b5ad..049bc89b72 100644 --- a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java @@ -59,8 +59,7 @@ public class CastPlayerTest { private CastPlayer castPlayer; - @SuppressWarnings("deprecation") - private RemoteMediaClient.Listener remoteMediaClientListener; + private RemoteMediaClient.Callback remoteMediaClientCallback; @Mock private RemoteMediaClient mockRemoteMediaClient; @Mock private MediaStatus mockMediaStatus; @@ -76,7 +75,7 @@ public class CastPlayerTest { private ArgumentCaptor> setResultCallbackArgumentCaptor; - @Captor private ArgumentCaptor listenerArgumentCaptor; + @Captor private ArgumentCaptor callbackArgumentCaptor; @Captor private ArgumentCaptor queueItemsArgumentCaptor; @@ -95,8 +94,8 @@ public class CastPlayerTest { when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_OFF); castPlayer = new CastPlayer(mockCastContext); castPlayer.addListener(mockListener); - verify(mockRemoteMediaClient).addListener(listenerArgumentCaptor.capture()); - remoteMediaClientListener = listenerArgumentCaptor.getValue(); + verify(mockRemoteMediaClient).registerCallback(callbackArgumentCaptor.capture()); + remoteMediaClientCallback = callbackArgumentCaptor.getValue(); } @SuppressWarnings("deprecation") @@ -113,7 +112,7 @@ public class CastPlayerTest { .onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST); // There is a status update in the middle, which should be hidden by masking. - remoteMediaClientListener.onStatusUpdated(); + remoteMediaClientCallback.onStatusUpdated(); verifyNoMoreInteractions(mockListener); // Upon result, the remoteMediaClient has updated its state according to the play() call. @@ -169,7 +168,7 @@ public class CastPlayerTest { public void playWhenReady_changesOnStatusUpdates() { assertThat(castPlayer.getPlayWhenReady()).isFalse(); when(mockRemoteMediaClient.isPaused()).thenReturn(false); - remoteMediaClientListener.onStatusUpdated(); + remoteMediaClientCallback.onStatusUpdated(); verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE); verify(mockListener).onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE); assertThat(castPlayer.getPlayWhenReady()).isTrue(); @@ -187,7 +186,7 @@ public class CastPlayerTest { // There is a status update in the middle, which should be hidden by masking. when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_ALL); - remoteMediaClientListener.onStatusUpdated(); + remoteMediaClientCallback.onStatusUpdated(); verifyNoMoreInteractions(mockListener); // Upon result, the mediaStatus now exposes the new repeat mode. @@ -209,7 +208,7 @@ public class CastPlayerTest { // There is a status update in the middle, which should be hidden by masking. when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_ALL); - remoteMediaClientListener.onStatusUpdated(); + remoteMediaClientCallback.onStatusUpdated(); verifyNoMoreInteractions(mockListener); // Upon result, the repeat mode is ALL. The state should reflect that. @@ -224,7 +223,7 @@ public class CastPlayerTest { public void repeatMode_changesOnStatusUpdates() { assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_OFF); when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_SINGLE); - remoteMediaClientListener.onStatusUpdated(); + remoteMediaClientCallback.onStatusUpdated(); verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE); assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE); } @@ -494,6 +493,6 @@ public class CastPlayerTest { castPlayer.addMediaItems(mediaItems); // Call listener to update the timeline of the player. - remoteMediaClientListener.onQueueStatusUpdated(); + remoteMediaClientCallback.onQueueStatusUpdated(); } } diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java index cb852eb1d6..cae117ea00 100644 --- a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.cast; +import static org.mockito.Mockito.when; + import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.TimelineAsserts; @@ -105,18 +107,18 @@ public class CastTimelineTrackerTest { int[] itemIds, int currentItemId, long currentDurationMs) { RemoteMediaClient remoteMediaClient = Mockito.mock(RemoteMediaClient.class); MediaStatus status = Mockito.mock(MediaStatus.class); - Mockito.when(status.getQueueItems()).thenReturn(Collections.emptyList()); - Mockito.when(remoteMediaClient.getMediaStatus()).thenReturn(status); - Mockito.when(status.getMediaInfo()).thenReturn(getMediaInfo(currentDurationMs)); - Mockito.when(status.getCurrentItemId()).thenReturn(currentItemId); + when(status.getQueueItems()).thenReturn(Collections.emptyList()); + when(remoteMediaClient.getMediaStatus()).thenReturn(status); + when(status.getMediaInfo()).thenReturn(getMediaInfo(currentDurationMs)); + when(status.getCurrentItemId()).thenReturn(currentItemId); MediaQueue mediaQueue = mockMediaQueue(itemIds); - Mockito.when(remoteMediaClient.getMediaQueue()).thenReturn(mediaQueue); + when(remoteMediaClient.getMediaQueue()).thenReturn(mediaQueue); return remoteMediaClient; } private static MediaQueue mockMediaQueue(int[] itemIds) { MediaQueue mediaQueue = Mockito.mock(MediaQueue.class); - Mockito.when(mediaQueue.getItemIds()).thenReturn(itemIds); + when(mediaQueue.getItemIds()).thenReturn(itemIds); return mediaQueue; } diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index c27bc37ff0..0dd1d42d72 100644 --- a/extensions/cronet/build.gradle +++ b/extensions/cronet/build.gradle @@ -11,29 +11,19 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { api "com.google.android.gms:play-services-cronet:17.0.0" implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'library') diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java index 314e06900e..e70538d7be 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProvider.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.cronet; +import static java.lang.Math.min; + import java.io.IOException; import java.nio.ByteBuffer; import org.chromium.net.UploadDataProvider; @@ -40,7 +42,7 @@ import org.chromium.net.UploadDataSink; @Override public void read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) throws IOException { - int readLength = Math.min(byteBuffer.remaining(), data.length - position); + int readLength = min(byteBuffer.remaining(), data.length - position); byteBuffer.put(data, position, readLength); position += readLength; uploadDataSink.onReadSucceeded(false); 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 457401f5df..26a60d3332 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 @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.ext.cronet; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; +import static java.lang.Math.min; import android.net.Uri; import android.text.TextUtils; @@ -30,12 +32,14 @@ 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.Log; -import com.google.android.exoplayer2.util.Predicate; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; import java.io.IOException; import java.io.InterruptedIOException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.ByteBuffer; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -146,6 +150,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { private volatile long currentConnectTimeoutMs; /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -164,6 +170,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -195,6 +203,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -229,6 +239,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -241,6 +253,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public CronetDataSource( CronetEngine cronetEngine, @@ -257,6 +270,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -274,6 +289,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean, * RequestProperties)} and {@link #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public CronetDataSource( CronetEngine cronetEngine, @@ -295,6 +311,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -440,12 +458,28 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { UrlResponseInfo responseInfo = Assertions.checkNotNull(this.responseInfo); int responseCode = responseInfo.getHttpStatusCode(); if (responseCode < 200 || responseCode > 299) { + byte[] responseBody = Util.EMPTY_BYTE_ARRAY; + ByteBuffer readBuffer = getOrCreateReadBuffer(); + while (!readBuffer.hasRemaining()) { + operation.close(); + readBuffer.clear(); + readInternal(readBuffer); + if (finished) { + break; + } + readBuffer.flip(); + int existingResponseBodyEnd = responseBody.length; + responseBody = Arrays.copyOf(responseBody, responseBody.length + readBuffer.remaining()); + readBuffer.get(responseBody, existingResponseBodyEnd, readBuffer.remaining()); + } + InvalidResponseCodeException exception = new InvalidResponseCodeException( responseCode, responseInfo.getHttpStatusText(), responseInfo.getAllHeaders(), - dataSpec); + dataSpec, + responseBody); if (responseCode == 416) { exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE)); } @@ -457,7 +491,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { if (contentTypePredicate != null) { List contentTypeHeaders = responseInfo.getAllHeaders().get(CONTENT_TYPE); String contentType = isEmpty(contentTypeHeaders) ? null : contentTypeHeaders.get(0); - if (contentType != null && !contentTypePredicate.evaluate(contentType)) { + if (contentType != null && !contentTypePredicate.apply(contentType)) { throw new InvalidContentTypeException(contentType, dataSpec); } } @@ -496,17 +530,12 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { return C.RESULT_END_OF_INPUT; } - ByteBuffer readBuffer = this.readBuffer; - if (readBuffer == null) { - readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); - readBuffer.limit(0); - this.readBuffer = readBuffer; - } + ByteBuffer readBuffer = getOrCreateReadBuffer(); while (!readBuffer.hasRemaining()) { // Fill readBuffer with more data from Cronet. operation.close(); readBuffer.clear(); - readInternal(castNonNull(readBuffer)); + readInternal(readBuffer); if (finished) { bytesRemaining = 0; @@ -516,14 +545,14 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { readBuffer.flip(); Assertions.checkState(readBuffer.hasRemaining()); if (bytesToSkip > 0) { - int bytesSkipped = (int) Math.min(readBuffer.remaining(), bytesToSkip); + int bytesSkipped = (int) min(readBuffer.remaining(), bytesToSkip); readBuffer.position(readBuffer.position() + bytesSkipped); bytesToSkip -= bytesSkipped; } } } - int bytesRead = Math.min(readBuffer.remaining(), readLength); + int bytesRead = min(readBuffer.remaining(), readLength); readBuffer.get(buffer, offset, bytesRead); if (bytesRemaining != C.LENGTH_UNSET) { @@ -603,11 +632,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { operation.close(); if (!useCallerBuffer) { - if (readBuffer == null) { - readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); - } else { - readBuffer.clear(); - } + ByteBuffer readBuffer = getOrCreateReadBuffer(); + readBuffer.clear(); if (bytesToSkip < READ_BUFFER_SIZE_BYTES) { readBuffer.limit((int) bytesToSkip); } @@ -781,6 +807,14 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { } } + private ByteBuffer getOrCreateReadBuffer() { + if (readBuffer == null) { + readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES); + readBuffer.limit(0); + } + return readBuffer; + } + private static boolean isCompressed(UrlResponseInfo info) { for (Map.Entry entry : info.getAllHeadersAsList()) { if (entry.getKey().equalsIgnoreCase("Content-Encoding")) { @@ -826,7 +860,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { // would increase it. Log.w(TAG, "Inconsistent headers [" + contentLengthHeader + "] [" + contentRangeHeader + "]"); - contentLength = Math.max(contentLength, contentLengthFromRange); + contentLength = max(contentLength, contentLengthFromRange); } } catch (NumberFormatException e) { Log.e(TAG, "Unexpected Content-Range [" + contentRangeHeader + "]"); @@ -869,7 +903,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { // Copy as much as possible from the src buffer into dst buffer. // Returns the number of bytes copied. private static int copyByteBuffer(ByteBuffer src, ByteBuffer dst) { - int remaining = Math.min(src.remaining(), dst.remaining()); + int remaining = min(src.remaining(), dst.remaining()); int limit = src.limit(); src.limit(src.position() + remaining); dst.put(src); @@ -893,7 +927,11 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource { if (responseCode == 307 || responseCode == 308) { exception = new InvalidResponseCodeException( - responseCode, info.getHttpStatusText(), info.getAllHeaders(), dataSpec); + responseCode, + info.getHttpStatusText(), + info.getAllHeaders(), + dataSpec, + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); operation.open(); return; } diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java index 4086011b4f..85c9d09a79 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.cronet; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.HttpDataSource; @@ -50,14 +52,13 @@ public final class CronetDataSourceFactory extends BaseFactory { private final HttpDataSource.Factory fallbackFactory; /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. * *

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -79,23 +80,36 @@ public final class CronetDataSourceFactory extends BaseFactory { } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

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

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

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. * @param userAgent A user agent used to create a fallback HttpDataSource if needed. */ public CronetDataSourceFactory( - CronetEngineWrapper cronetEngineWrapper, - Executor executor, - String userAgent) { + CronetEngineWrapper cronetEngineWrapper, Executor executor, String userAgent) { this( cronetEngineWrapper, executor, @@ -112,7 +126,7 @@ public final class CronetDataSourceFactory extends BaseFactory { } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

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

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. @@ -178,14 +192,13 @@ public final class CronetDataSourceFactory extends BaseFactory { } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. * *

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, - * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -209,14 +222,33 @@ public final class CronetDataSourceFactory extends BaseFactory { } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

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

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

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. * * @param cronetEngineWrapper A {@link CronetEngineWrapper}. * @param executor The {@link java.util.concurrent.Executor} that will perform the requests. @@ -244,7 +276,7 @@ public final class CronetDataSourceFactory extends BaseFactory { } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

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

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java index a05dda1983..9f709b14d0 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.cronet; +import static java.lang.Math.min; + import android.content.Context; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -230,7 +232,7 @@ public final class CronetEngineWrapper { } String[] versionStringsLeft = Util.split(versionLeft, "\\."); String[] versionStringsRight = Util.split(versionRight, "\\."); - int minLength = Math.min(versionStringsLeft.length, versionStringsRight.length); + int minLength = min(versionStringsLeft.length, versionStringsRight.length); for (int i = 0; i < minLength; i++) { if (!versionStringsLeft[i].equals(versionStringsRight[i])) { try { 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 49c34ae53b..ac19c8548d 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ext.cronet; import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.min; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -64,13 +65,10 @@ import org.junit.runner.RunWith; import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; import org.robolectric.shadows.ShadowLooper; /** Tests for {@link CronetDataSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public final class CronetDataSourceTest { private static final int TEST_CONNECT_TIMEOUT_MS = 100; @@ -378,15 +376,18 @@ public final class CronetDataSourceTest { } @Test - public void requestOpenValidatesStatusCode() { + public void requestOpenPropagatesFailureResponseBody() throws Exception { mockResponseStartSuccess(); - testUrlResponseInfo = createUrlResponseInfo(500); // statusCode + // Use a size larger than CronetDataSource.READ_BUFFER_SIZE_BYTES + int responseLength = 40 * 1024; + mockReadSuccess(/* position= */ 0, /* length= */ responseLength); + testUrlResponseInfo = createUrlResponseInfo(/* statusCode= */ 500); try { dataSourceUnderTest.open(testDataSpec); - fail("HttpDataSource.HttpDataSourceException expected"); - } catch (HttpDataSourceException e) { - assertThat(e).isInstanceOf(HttpDataSource.InvalidResponseCodeException.class); + fail("HttpDataSource.InvalidResponseCodeException expected"); + } catch (HttpDataSource.InvalidResponseCodeException e) { + assertThat(e.responseBody).isEqualTo(buildTestDataArray(0, responseLength)); // Check for connection not automatically closed. verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) @@ -1423,7 +1424,7 @@ public final class CronetDataSourceTest { mockUrlRequest, testUrlResponseInfo); } else { ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0]; - int readLength = Math.min(positionAndRemaining[1], inputBuffer.remaining()); + int readLength = min(positionAndRemaining[1], inputBuffer.remaining()); inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength)); positionAndRemaining[0] += readLength; positionAndRemaining[1] -= readLength; diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md index f6e3944572..639d1f6d6c 100644 --- a/extensions/ffmpeg/README.md +++ b/extensions/ffmpeg/README.md @@ -18,14 +18,15 @@ its modules locally. Instructions for doing this can be found in ExoPlayer's [top level README][]. The extension is not provided via JCenter (see [#2781][] for more information). -In addition, it's necessary to build the extension's native components as -follows: +In addition, it's necessary to manually build the FFmpeg library, so that gradle +can bundle the FFmpeg binaries in the APK: * Set the following shell variable: ``` cd "" -FFMPEG_EXT_PATH="$(pwd)/extensions/ffmpeg/src/main/jni" +EXOPLAYER_ROOT="$(pwd)" +FFMPEG_EXT_PATH="${EXOPLAYER_ROOT}/extensions/ffmpeg/src/main" ``` * Download the [Android NDK][] and set its location in a shell variable. @@ -41,6 +42,17 @@ NDK_PATH="" HOST_PLATFORM="linux-x86_64" ``` +* Fetch FFmpeg and checkout an appropriate branch. We cannot guarantee + compatibility with all versions of FFmpeg. We currently recommend version 4.2: + +``` +cd "" && \ +git clone git://source.ffmpeg.org/ffmpeg && \ +cd ffmpeg && \ +git checkout release/4.2 && \ +FFMPEG_PATH="$(pwd)" +``` + * Configure the decoders to include. See the [Supported formats][] page for details of the available decoders, and which formats they support. @@ -48,24 +60,23 @@ HOST_PLATFORM="linux-x86_64" ENABLED_DECODERS=(vorbis opus flac) ``` -* Fetch and build FFmpeg. Executing `build_ffmpeg.sh` will fetch and build - FFmpeg 4.2 for `armeabi-v7a`, `arm64-v8a`, `x86` and `x86_64`. The script can - be edited if you need to build for different architectures. +* Add a link to the FFmpeg source code in the FFmpeg extension `jni` directory. ``` -cd "${FFMPEG_EXT_PATH}" && \ +cd "${FFMPEG_EXT_PATH}/jni" && \ +ln -s "$FFMPEG_PATH" ffmpeg +``` + +* Execute `build_ffmpeg.sh` to build FFmpeg for `armeabi-v7a`, `arm64-v8a`, + `x86` and `x86_64`. The script can be edited if you need to build for + different architectures: + +``` +cd "${FFMPEG_EXT_PATH}/jni" && \ ./build_ffmpeg.sh \ "${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}" ``` -* Build the JNI native libraries, setting `APP_ABI` to include the architectures - built in the previous step. For example: - -``` -cd "${FFMPEG_EXT_PATH}" && \ -${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86 x86_64" -j4 -``` - ## Build instructions (Windows) ## We do not provide support for building this extension on Windows, however it diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle index 26a72ae335..a9edeaff6b 100644 --- a/extensions/ffmpeg/build.gradle +++ b/extensions/ffmpeg/build.gradle @@ -11,29 +11,13 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - } - - sourceSets.main { - jniLibs.srcDir 'src/main/libs' - jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio. - } - - testOptions.unitTests.includeAndroidResources = true +// Configure the native build only if ffmpeg is present to avoid gradle sync +// failures if ffmpeg hasn't been built according to the README instructions. +if (project.file('src/main/jni/ffmpeg').exists()) { + android.externalNativeBuild.cmake.path = 'src/main/jni/CMakeLists.txt' + android.externalNativeBuild.cmake.version = '3.7.1+' } dependencies { diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java index c5072a3398..d6980f2801 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java @@ -52,10 +52,10 @@ import java.util.List; private volatile int sampleRate; public FfmpegAudioDecoder( + Format format, int numInputBuffers, int numOutputBuffers, int initialInputBufferSize, - Format format, boolean outputFloat) throws FfmpegDecoderException { super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]); @@ -82,7 +82,9 @@ import java.util.List; @Override protected DecoderInputBuffer createInputBuffer() { - return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); + return new DecoderInputBuffer( + DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT, + FfmpegLibrary.getInputBufferPaddingSize()); } @Override diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java index efa1d3965f..0718dc2c5c 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.ext.ffmpeg; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_UNSUPPORTED; + import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -22,16 +26,17 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.audio.AudioSink.SinkFormatSupport; import com.google.android.exoplayer2.audio.DecoderAudioRenderer; import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TraceUtil; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import com.google.android.exoplayer2.util.Util; /** Decodes and renders audio using FFmpeg. */ -public final class FfmpegAudioRenderer extends DecoderAudioRenderer { +public final class FfmpegAudioRenderer extends DecoderAudioRenderer { private static final String TAG = "FfmpegAudioRenderer"; @@ -40,10 +45,6 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { /** The default input buffer size. */ private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6; - private final boolean enableFloatOutput; - - private @MonotonicNonNull FfmpegAudioDecoder decoder; - public FfmpegAudioRenderer() { this(/* eventHandler= */ null, /* eventListener= */ null); } @@ -63,8 +64,7 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { this( eventHandler, eventListener, - new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors), - /* enableFloatOutput= */ false); + new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors)); } /** @@ -74,21 +74,15 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. * @param audioSink The sink to which audio will be output. - * @param enableFloatOutput Whether to enable 32-bit float audio format, if supported on the - * device/build and if the input format may have bit depth higher than 16-bit. When using - * 32-bit float output, any audio processing will be disabled, including playback speed/pitch - * adjustment. */ public FfmpegAudioRenderer( @Nullable Handler eventHandler, @Nullable AudioRendererEventListener eventListener, - AudioSink audioSink, - boolean enableFloatOutput) { + AudioSink audioSink) { super( eventHandler, eventListener, audioSink); - this.enableFloatOutput = enableFloatOutput; } @Override @@ -102,9 +96,11 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { String mimeType = Assertions.checkNotNull(format.sampleMimeType); if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) { return FORMAT_UNSUPPORTED_TYPE; - } else if (!FfmpegLibrary.supportsFormat(mimeType) || !isOutputSupported(format)) { + } else if (!FfmpegLibrary.supportsFormat(mimeType) + || (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT) + && !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) { return FORMAT_UNSUPPORTED_SUBTYPE; - } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { + } else if (format.exoMediaCryptoType != null) { return FORMAT_UNSUPPORTED_DRM; } else { return FORMAT_HANDLED; @@ -123,15 +119,15 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { TraceUtil.beginSection("createFfmpegAudioDecoder"); int initialInputBufferSize = format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE; - decoder = + FfmpegAudioDecoder decoder = new FfmpegAudioDecoder( - NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, format, shouldUseFloatOutput(format)); + format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format)); TraceUtil.endSection(); return decoder; } @Override - public Format getOutputFormat() { + public Format getOutputFormat(FfmpegAudioDecoder decoder) { Assertions.checkNotNull(decoder); return new Format.Builder() .setSampleMimeType(MimeTypes.AUDIO_RAW) @@ -141,31 +137,36 @@ public final class FfmpegAudioRenderer extends DecoderAudioRenderer { .build(); } - private boolean isOutputSupported(Format inputFormat) { - return shouldUseFloatOutput(inputFormat) - || supportsOutput(inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_16BIT); + /** + * Returns whether the renderer's {@link AudioSink} supports the PCM format that will be output + * from the decoder for the given input format and requested output encoding. + */ + private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) { + return sinkSupportsFormat( + Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate)); } - private boolean shouldUseFloatOutput(Format inputFormat) { - Assertions.checkNotNull(inputFormat.sampleMimeType); - if (!enableFloatOutput - || !supportsOutput( - inputFormat.channelCount, inputFormat.sampleRate, C.ENCODING_PCM_FLOAT)) { - return false; + private boolean shouldOutputFloat(Format inputFormat) { + if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) { + // We have no choice because the sink doesn't support 16-bit integer PCM. + return true; } - switch (inputFormat.sampleMimeType) { - case MimeTypes.AUDIO_RAW: - // For raw audio, output in 32-bit float encoding if the bit depth is > 16-bit. - return inputFormat.pcmEncoding == C.ENCODING_PCM_24BIT - || inputFormat.pcmEncoding == C.ENCODING_PCM_32BIT - || inputFormat.pcmEncoding == C.ENCODING_PCM_FLOAT; - case MimeTypes.AUDIO_AC3: - // AC-3 is always 16-bit, so there is no point outputting in 32-bit float encoding. - return false; + + @SinkFormatSupport + int formatSupport = + getSinkFormatSupport( + Util.getPcmFormat( + C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate)); + switch (formatSupport) { + case SINK_FORMAT_SUPPORTED_DIRECTLY: + // AC-3 is always 16-bit, so there's no point using floating point. Assume that it's worth + // using for all other formats. + return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType); + case SINK_FORMAT_UNSUPPORTED: + case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING: default: - // For all other formats, assume that it's worth using 32-bit float encoding. - return true; + // Always prefer 16-bit PCM if the sink does not provide direct support for floating point. + return false; } } - } diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java index cc2a78ae86..71912aea2f 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java @@ -16,10 +16,12 @@ package com.google.android.exoplayer2.ext.ffmpeg; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.util.LibraryLoader; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * Configures and queries the underlying native library. @@ -33,7 +35,10 @@ public final class FfmpegLibrary { private static final String TAG = "FfmpegLibrary"; private static final LibraryLoader LOADER = - new LibraryLoader("avutil", "swresample", "avcodec", "ffmpeg"); + new LibraryLoader("avutil", "swresample", "avcodec", "ffmpeg_jni"); + + private static @MonotonicNonNull String version; + private static int inputBufferPaddingSize = C.LENGTH_UNSET; private FfmpegLibrary() {} @@ -58,7 +63,27 @@ public final class FfmpegLibrary { /** Returns the version of the underlying library if available, or null otherwise. */ @Nullable public static String getVersion() { - return isAvailable() ? ffmpegGetVersion() : null; + if (!isAvailable()) { + return null; + } + if (version == null) { + version = ffmpegGetVersion(); + } + return version; + } + + /** + * Returns the required amount of padding for input buffers in bytes, or {@link C#LENGTH_UNSET} if + * the underlying library is not available. + */ + public static int getInputBufferPaddingSize() { + if (!isAvailable()) { + return C.LENGTH_UNSET; + } + if (inputBufferPaddingSize == C.LENGTH_UNSET) { + inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize(); + } + return inputBufferPaddingSize; } /** @@ -130,6 +155,8 @@ public final class FfmpegLibrary { } private static native String ffmpegGetVersion(); - private static native boolean ffmpegHasDecoder(String codecName); + private static native int ffmpegGetInputBufferPaddingSize(); + + private static native boolean ffmpegHasDecoder(String codecName); } diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java index 6f3b8b1fc7..d2f2fce639 100644 --- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java +++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegVideoRenderer.java @@ -38,7 +38,7 @@ import com.google.android.exoplayer2.video.VideoRendererEventListener; */ public final class FfmpegVideoRenderer extends DecoderVideoRenderer { - private static final String TAG = "FfmpegAudioRenderer"; + private static final String TAG = "FfmpegVideoRenderer"; /** * Creates a new instance. @@ -76,7 +76,7 @@ public final class FfmpegVideoRenderer extends DecoderVideoRenderer { return FORMAT_UNSUPPORTED_TYPE; } else if (!FfmpegLibrary.supportsFormat(format.sampleMimeType)) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); - } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { + } else if (format.exoMediaCryptoType != null) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM); } else { return RendererCapabilities.create( diff --git a/extensions/ffmpeg/src/main/jni/Android.mk b/extensions/ffmpeg/src/main/jni/Android.mk deleted file mode 100644 index bcaf12cd11..0000000000 --- a/extensions/ffmpeg/src/main/jni/Android.mk +++ /dev/null @@ -1,40 +0,0 @@ -# -# Copyright (C) 2016 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -LOCAL_PATH := $(call my-dir) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavcodec -LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libswresample -LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := libavutil -LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so -include $(PREBUILT_SHARED_LIBRARY) - -include $(CLEAR_VARS) -LOCAL_MODULE := ffmpeg -LOCAL_SRC_FILES := ffmpeg_jni.cc -LOCAL_C_INCLUDES := ffmpeg -LOCAL_SHARED_LIBRARIES := libavcodec libswresample libavutil -LOCAL_LDLIBS := -Lffmpeg/android-libs/$(TARGET_ARCH_ABI) -llog -include $(BUILD_SHARED_LIBRARY) diff --git a/extensions/ffmpeg/src/main/jni/Application.mk b/extensions/ffmpeg/src/main/jni/Application.mk deleted file mode 100644 index 7d6f732548..0000000000 --- a/extensions/ffmpeg/src/main/jni/Application.mk +++ /dev/null @@ -1,20 +0,0 @@ -# -# Copyright (C) 2016 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -APP_OPTIM := release -APP_STL := c++_static -APP_CPPFLAGS := -frtti -APP_PLATFORM := android-9 diff --git a/extensions/ffmpeg/src/main/jni/CMakeLists.txt b/extensions/ffmpeg/src/main/jni/CMakeLists.txt new file mode 100644 index 0000000000..b60af4fa18 --- /dev/null +++ b/extensions/ffmpeg/src/main/jni/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.7.1 FATAL_ERROR) + +# Enable C++11 features. +set(CMAKE_CXX_STANDARD 11) + +project(libffmpeg_jni C CXX) + +set(ffmpeg_location "${CMAKE_CURRENT_SOURCE_DIR}/ffmpeg") +set(ffmpeg_binaries "${ffmpeg_location}/android-libs/${ANDROID_ABI}") + +foreach(ffmpeg_lib avutil swresample avcodec) + set(ffmpeg_lib_filename lib${ffmpeg_lib}.so) + set(ffmpeg_lib_file_path ${ffmpeg_binaries}/${ffmpeg_lib_filename}) + add_library( + ${ffmpeg_lib} + SHARED + IMPORTED) + set_target_properties( + ${ffmpeg_lib} PROPERTIES + IMPORTED_LOCATION + ${ffmpeg_lib_file_path}) +endforeach() + +include_directories(${ffmpeg_location}) +find_library(android_log_lib log) + +add_library(ffmpeg_jni + SHARED + ffmpeg_jni.cc) + +target_link_libraries(ffmpeg_jni + PRIVATE android + PRIVATE avutil + PRIVATE swresample + PRIVATE avcodec + PRIVATE ${android_log_lib}) diff --git a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh index 833ea189b2..4660669a33 100755 --- a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh +++ b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh @@ -41,10 +41,7 @@ for decoder in "${ENABLED_DECODERS[@]}" do COMMON_OPTIONS="${COMMON_OPTIONS} --enable-decoder=${decoder}" done -cd "${FFMPEG_EXT_PATH}" -(git -C ffmpeg pull || git clone git://source.ffmpeg.org/ffmpeg ffmpeg) -cd ffmpeg -git checkout release/4.2 +cd "${FFMPEG_EXT_PATH}/jni/ffmpeg" ./configure \ --libdir=android-libs/armeabi-v7a \ --arch=arm \ diff --git a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc index adbf515f9b..7738e5c2d5 100644 --- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc +++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc @@ -113,6 +113,10 @@ LIBRARY_FUNC(jstring, ffmpegGetVersion) { return env->NewStringUTF(LIBAVCODEC_IDENT); } +LIBRARY_FUNC(jint, ffmpegGetInputBufferPaddingSize) { + return (jint)AV_INPUT_BUFFER_PADDING_SIZE; +} + LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) { return getCodecByName(env, codecName) != NULL; } diff --git a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java index a52d1b1d7a..cc8ca5487e 100644 --- a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java +++ b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java @@ -21,13 +21,22 @@ import com.google.android.exoplayer2.testutil.DefaultRenderersFactoryAsserts; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer}. */ +/** + * Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer} and {@link + * FfmpegVideoRenderer}. + */ @RunWith(AndroidJUnit4.class) public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesFfmpegAudioRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( FfmpegAudioRenderer.class, C.TRACK_TYPE_AUDIO); } + + @Test + public void createRenderers_instantiatesFfmpegVideoRenderer() { + DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( + FfmpegVideoRenderer.class, C.TRACK_TYPE_VIDEO); + } } diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle index f220d21106..9aeeb83eb3 100644 --- a/extensions/flac/build.gradle +++ b/extensions/flac/build.gradle @@ -11,24 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - sourceSets { main { jniLibs.srcDir 'src/main/libs' @@ -36,8 +21,6 @@ android { } androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java index 1c0c450a30..e6e66fbe29 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java @@ -37,16 +37,16 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class FlacExtractorSeekTest { - private static final String TEST_FILE_SEEK_TABLE = "flac/bear.flac"; - private static final String TEST_FILE_BINARY_SEARCH = "flac/bear_one_metadata_block.flac"; - private static final String TEST_FILE_UNSEEKABLE = "flac/bear_no_seek_table_no_num_samples.flac"; + private static final String TEST_FILE_SEEK_TABLE = "media/flac/bear.flac"; + private static final String TEST_FILE_BINARY_SEARCH = "media/flac/bear_one_metadata_block.flac"; + private static final String TEST_FILE_UNSEEKABLE = + "media/flac/bear_no_seek_table_no_num_samples.flac"; private static final int DURATION_US = 2_741_000; private FlacExtractor extractor = new FlacExtractor(); private FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); private DefaultDataSource dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") - .createDataSource(); + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()).createDataSource(); @Test public void flacExtractorReads_seekTable_returnSeekableSeekMap() throws IOException { diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java index ed28a2286a..d260a58e5d 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java @@ -39,78 +39,80 @@ public class FlacExtractorTest { @Test public void sample() throws Exception { ExtractorAsserts.assertAllBehaviors( - FlacExtractor::new, /* file= */ "flac/bear.flac", /* dumpFilesPrefix= */ "flac/bear_raw"); + FlacExtractor::new, + /* file= */ "media/flac/bear.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_raw"); } @Test public void sampleWithId3HeaderAndId3Enabled() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_with_id3.flac", - /* dumpFilesPrefix= */ "flac/bear_with_id3_enabled_raw"); + /* file= */ "media/flac/bear_with_id3.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_id3_enabled_raw"); } @Test public void sampleWithId3HeaderAndId3Disabled() throws Exception { ExtractorAsserts.assertAllBehaviors( () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA), - /* file= */ "flac/bear_with_id3.flac", - /* dumpFilesPrefix= */ "flac/bear_with_id3_disabled_raw"); + /* file= */ "media/flac/bear_with_id3.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_id3_disabled_raw"); } @Test public void sampleUnseekable() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_no_seek_table_no_num_samples.flac", - /* dumpFilesPrefix= */ "flac/bear_no_seek_table_no_num_samples_raw"); + /* file= */ "media/flac/bear_no_seek_table_no_num_samples.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_no_seek_table_no_num_samples_raw"); } @Test public void sampleWithVorbisComments() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_with_vorbis_comments.flac", - /* dumpFilesPrefix= */ "flac/bear_with_vorbis_comments_raw"); + /* file= */ "media/flac/bear_with_vorbis_comments.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_vorbis_comments_raw"); } @Test public void sampleWithPicture() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_with_picture.flac", - /* dumpFilesPrefix= */ "flac/bear_with_picture_raw"); + /* file= */ "media/flac/bear_with_picture.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_with_picture_raw"); } @Test public void oneMetadataBlock() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_one_metadata_block.flac", - /* dumpFilesPrefix= */ "flac/bear_one_metadata_block_raw"); + /* file= */ "media/flac/bear_one_metadata_block.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_one_metadata_block_raw"); } @Test public void noMinMaxFrameSize() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_no_min_max_frame_size.flac", - /* dumpFilesPrefix= */ "flac/bear_no_min_max_frame_size_raw"); + /* file= */ "media/flac/bear_no_min_max_frame_size.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_no_min_max_frame_size_raw"); } @Test public void noNumSamples() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_no_num_samples.flac", - /* dumpFilesPrefix= */ "flac/bear_no_num_samples_raw"); + /* file= */ "media/flac/bear_no_num_samples.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_no_num_samples_raw"); } @Test public void uncommonSampleRate() throws Exception { ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_uncommon_sample_rate.flac", - /* dumpFilesPrefix= */ "flac/bear_uncommon_sample_rate_raw"); + /* file= */ "media/flac/bear_uncommon_sample_rate.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_uncommon_sample_rate_raw"); } } diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java index e9b1fd1019..bbcc26fb64 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java @@ -25,6 +25,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioSink; @@ -33,6 +34,7 @@ import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.testutil.CapturingAudioSink; +import com.google.android.exoplayer2.testutil.DumpFileAsserts; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import org.junit.Before; import org.junit.Test; @@ -69,7 +71,7 @@ public class FlacPlaybackTest { TestPlaybackRunnable testPlaybackRunnable = new TestPlaybackRunnable( - Uri.parse("asset:///" + fileName), + Uri.parse("asset:///media/" + fileName), ApplicationProvider.getApplicationContext(), audioSink); Thread thread = new Thread(testPlaybackRunnable); @@ -79,8 +81,10 @@ public class FlacPlaybackTest { throw testPlaybackRunnable.playbackException; } - audioSink.assertOutput( - ApplicationProvider.getApplicationContext(), fileName + ".audiosink.dump"); + DumpFileAsserts.assertOutput( + ApplicationProvider.getApplicationContext(), + audioSink, + "audiosinkdumps/" + fileName + ".audiosink.dump"); } private static class TestPlaybackRunnable implements Player.EventListener, Runnable { @@ -107,9 +111,8 @@ public class FlacPlaybackTest { player.addListener(this); MediaSource mediaSource = new ProgressiveMediaSource.Factory( - new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest"), - MatroskaExtractor.FACTORY) - .createMediaSource(uri); + new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY) + .createMediaSource(MediaItem.fromUri(uri)); player.setMediaSource(mediaSource); player.prepare(); player.play(); diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java index 742ade214d..b736c4d743 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.flac; +import static java.lang.Math.max; + import com.google.android.exoplayer2.extractor.BinarySearchSeeker; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.FlacStreamMetadata; @@ -74,7 +76,7 @@ import java.nio.ByteBuffer; /* floorBytePosition= */ firstFramePosition, /* ceilingBytePosition= */ inputLength, /* approxBytesPerFrame= */ streamMetadata.getApproxBytesPerFrame(), - /* minimumSearchRange= */ Math.max( + /* minimumSearchRange= */ max( FlacConstants.MIN_FRAME_HEADER_SIZE, streamMetadata.minFrameSize)); this.decoderJni = Assertions.checkNotNull(decoderJni); } diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java index daf4584948..af4e571024 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.flac; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; @@ -118,7 +120,7 @@ import java.nio.ByteBuffer; public int read(ByteBuffer target) throws IOException { int byteCount = target.remaining(); if (byteBufferData != null) { - byteCount = Math.min(byteCount, byteBufferData.remaining()); + byteCount = min(byteCount, byteBufferData.remaining()); int originalLimit = byteBufferData.limit(); byteBufferData.limit(byteBufferData.position() + byteCount); target.put(byteBufferData); @@ -126,7 +128,7 @@ import java.nio.ByteBuffer; } else if (extractorInput != null) { ExtractorInput extractorInput = this.extractorInput; byte[] tempBuffer = Util.castNonNull(this.tempBuffer); - byteCount = Math.min(byteCount, TEMP_BUFFER_SIZE); + byteCount = min(byteCount, TEMP_BUFFER_SIZE); int read = readFromExtractorInput(extractorInput, tempBuffer, /* offset= */ 0, byteCount); if (read < 4) { // Reading less than 4 bytes, most of the time, happens because of getting the bytes left in diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java index 364cf80ef8..0ac4dbeffa 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java @@ -53,6 +53,11 @@ public final class FlacExtractor implements Extractor { /** Factory that returns one extractor which is a {@link FlacExtractor}. */ public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + // LINT.IfChange + /* + * Flags in the two FLAC extractors should be kept in sync. If we ever change this then + * DefaultExtractorsFactory will need modifying, because it currently assumes this is the case. + */ /** * Flags controlling the behavior of the extractor. Possible flag value is {@link * #FLAG_DISABLE_ID3_METADATA}. @@ -68,7 +73,9 @@ public final class FlacExtractor implements Extractor { * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not * required. */ - public static final int FLAG_DISABLE_ID3_METADATA = 1; + public static final int FLAG_DISABLE_ID3_METADATA = + com.google.android.exoplayer2.extractor.flac.FlacExtractor.FLAG_DISABLE_ID3_METADATA; + // LINT.ThenChange(../../../../../../../../../../../library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java) private final ParsableByteArray outputBuffer; private final boolean id3MetadataDisabled; @@ -203,7 +210,7 @@ public final class FlacExtractor implements Extractor { if (this.streamMetadata == null) { this.streamMetadata = streamMetadata; outputBuffer.reset(streamMetadata.getMaxDecodedFrameSize()); - outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.data)); + outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.getData())); binarySearchSeeker = outputSeekMap( flacDecoderJni, diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java index 9315c302cc..df511866a3 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java @@ -25,26 +25,24 @@ import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.DecoderAudioRenderer; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.extractor.FlacStreamMetadata; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.FlacConstants; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TraceUtil; import com.google.android.exoplayer2.util.Util; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** Decodes and renders audio using the native Flac decoder. */ -public final class LibflacAudioRenderer extends DecoderAudioRenderer { +public final class LibflacAudioRenderer extends DecoderAudioRenderer { private static final String TAG = "LibflacAudioRenderer"; private static final int NUM_BUFFERS = 16; - private @MonotonicNonNull FlacStreamMetadata streamMetadata; - public LibflacAudioRenderer() { this(/* eventHandler= */ null, /* eventListener= */ null); } /** + * Creates an instance. + * * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. @@ -58,6 +56,8 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { } /** + * Creates an instance. + * * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. @@ -85,24 +85,25 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { || !MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType)) { return FORMAT_UNSUPPORTED_TYPE; } - // Compute the PCM encoding that the FLAC decoder will output. - @C.PcmEncoding int pcmEncoding; + // Compute the format that the FLAC decoder will output. + Format outputFormat; if (format.initializationData.isEmpty()) { // The initialization data might not be set if the format was obtained from a manifest (e.g. // for DASH playbacks) rather than directly from the media. In this case we assume // ENCODING_PCM_16BIT. If the actual encoding is different then playback will still succeed as // long as the AudioSink supports it, which will always be true when using DefaultAudioSink. - pcmEncoding = C.ENCODING_PCM_16BIT; + outputFormat = + Util.getPcmFormat(C.ENCODING_PCM_16BIT, format.channelCount, format.sampleRate); } else { int streamMetadataOffset = FlacConstants.STREAM_MARKER_SIZE + FlacConstants.METADATA_BLOCK_HEADER_SIZE; FlacStreamMetadata streamMetadata = new FlacStreamMetadata(format.initializationData.get(0), streamMetadataOffset); - pcmEncoding = Util.getPcmEncoding(streamMetadata.bitsPerSample); + outputFormat = getOutputFormat(streamMetadata); } - if (!supportsOutput(format.channelCount, format.sampleRate, pcmEncoding)) { + if (!sinkSupportsFormat(outputFormat)) { return FORMAT_UNSUPPORTED_SUBTYPE; - } else if (format.drmInitData != null && format.exoMediaCryptoType == null) { + } else if (format.exoMediaCryptoType != null) { return FORMAT_UNSUPPORTED_DRM; } else { return FORMAT_HANDLED; @@ -115,19 +116,19 @@ public final class LibflacAudioRenderer extends DecoderAudioRenderer { TraceUtil.beginSection("createFlacDecoder"); FlacDecoder decoder = new FlacDecoder(NUM_BUFFERS, NUM_BUFFERS, format.maxInputSize, format.initializationData); - streamMetadata = decoder.getStreamMetadata(); TraceUtil.endSection(); return decoder; } @Override - protected Format getOutputFormat() { - Assertions.checkNotNull(streamMetadata); - return new Format.Builder() - .setSampleMimeType(MimeTypes.AUDIO_RAW) - .setChannelCount(streamMetadata.channels) - .setSampleRate(streamMetadata.sampleRate) - .setPcmEncoding(Util.getPcmEncoding(streamMetadata.bitsPerSample)) - .build(); + protected Format getOutputFormat(FlacDecoder decoder) { + return getOutputFormat(decoder.getStreamMetadata()); + } + + private static Format getOutputFormat(FlacStreamMetadata streamMetadata) { + return Util.getPcmFormat( + Util.getPcmEncoding(streamMetadata.bitsPerSample), + streamMetadata.channels, + streamMetadata.sampleRate); } } diff --git a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java index fb20ff1114..3fb8f2cece 100644 --- a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java +++ b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java @@ -26,7 +26,7 @@ import org.junit.runner.RunWith; public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesFlacRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( LibflacAudioRenderer.class, C.TRACK_TYPE_AUDIO); } diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle index 4e6bd76cb4..891888a0d2 100644 --- a/extensions/gvr/build.gradle +++ b/extensions/gvr/build.gradle @@ -11,24 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion 19 - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +android.defaultConfig.minSdkVersion 19 dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/ima/README.md b/extensions/ima/README.md index f28ba2977e..c67dfdbb5d 100644 --- a/extensions/ima/README.md +++ b/extensions/ima/README.md @@ -26,35 +26,29 @@ locally. Instructions for doing this can be found in ExoPlayer's ## Using the extension ## -To play ads alongside a single-window content `MediaSource`, prepare the player -with an `AdsMediaSource` constructed using an `ImaAdsLoader`, the content -`MediaSource` and an overlay `ViewGroup` on top of the player. Pass an ad tag -URI from your ad campaign when creating the `ImaAdsLoader`. The IMA -documentation includes some [sample ad tags][] for testing. Note that the IMA +To use the extension, follow the instructions on the +[Ad insertion page](https://exoplayer.dev/ad-insertion.html#declarative-ad-support) +of the developer guide. The `AdsLoaderProvider` passed to the player's +`DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA extension only supports players which are accessed on the application's main thread. Resuming the player after entering the background requires some special handling when playing ads. The player and its media source are released on entering the -background, and are recreated when the player returns to the foreground. When -playing ads it is necessary to persist ad playback state while in the background -by keeping a reference to the `ImaAdsLoader`. Reuse it when resuming playback of -the same content/ads by passing it in when constructing the new -`AdsMediaSource`. It is also important to persist the player position when +background, and are recreated when returning to the foreground. When playing ads +it is necessary to persist ad playback state while in the background by keeping +a reference to the `ImaAdsLoader`. When re-entering the foreground, pass the +same instance back when `AdsLoaderProvider.getAdsLoader(Uri adTagUri)` is called +to restore the state. It is also important to persist the player position when entering the background by storing the value of `player.getContentPosition()`. On returning to the foreground, seek to that position before preparing the new player instance. Finally, it is important to call `ImaAdsLoader.release()` when -playback of the content/ads has finished and will not be resumed. +playback has finished and will not be resumed. -You can try the IMA extension in the ExoPlayer demo app. To do this you must -select and build one of the `withExtensions` build variants of the demo app in -Android Studio. You can find IMA test content in the "IMA sample ad tags" -section of the app. The demo app's `PlayerActivity` also shows how to persist -the `ImaAdsLoader` instance and the player position when backgrounded during ad -playback. - -[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md -[sample ad tags]: https://developers.google.com/interactive-media-ads/docs/sdks/android/tags +You can try the IMA extension in the ExoPlayer demo app, which has test content +in the "IMA sample ad tags" section of the sample chooser. The demo app's +`PlayerActivity` also shows how to persist the `ImaAdsLoader` instance and the +player position when backgrounded during ad playback. ## Links ## diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index f5d29efb97..f7b2b3f77c 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -11,22 +11,10 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' // Enable multidex for androidTests. multiDexEnabled true } @@ -34,22 +22,42 @@ android { sourceSets { androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { - api 'com.google.ads.interactivemedia.v3:interactivemedia:3.18.1' + api 'com.google.ads.interactivemedia.v3:interactivemedia:3.19.4' implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0' + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion androidTestImplementation project(modulePrefix + 'testutils') + androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion - androidTestImplementation 'com.android.support:multidex:1.0.3' + androidTestImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } androidTestCompileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion testImplementation project(modulePrefix + 'testutils') + testImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java b/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java index 0e685e55ea..88bc4e14c5 100644 --- a/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java +++ b/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java @@ -20,7 +20,6 @@ import static com.google.common.truth.Truth.assertThat; import android.content.Context; import android.net.Uri; import android.view.Surface; -import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import androidx.annotation.Nullable; @@ -39,6 +38,7 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider; import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.testutil.ActionSchedule; @@ -49,7 +49,7 @@ import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -234,29 +234,26 @@ public final class ImaPlaybackTest { @Override protected MediaSource buildSource( HostActivity host, - String userAgent, DrmSessionManager drmSessionManager, FrameLayout overlayFrameLayout) { Context context = host.getApplicationContext(); - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ImaPlaybackTest.class.getSimpleName())); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context); MediaSource contentMediaSource = - DefaultMediaSourceFactory.newInstance(context) - .createMediaSource(MediaItem.fromUri(contentUri)); + new DefaultMediaSourceFactory(context).createMediaSource(MediaItem.fromUri(contentUri)); return new AdsMediaSource( contentMediaSource, dataSourceFactory, Assertions.checkNotNull(imaAdsLoader), new AdViewProvider() { + @Override public ViewGroup getAdViewGroup() { return overlayFrameLayout; } @Override - public View[] getAdOverlayViews() { - return new View[0]; + public ImmutableList getAdOverlayInfos() { + return ImmutableList.of(); } }); } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java new file mode 100644 index 0000000000..a97307a419 --- /dev/null +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/AdPlaybackStateFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.ima; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import java.util.Arrays; +import java.util.List; + +/** + * Static utility class for constructing {@link AdPlaybackState} instances from IMA-specific data. + */ +/* package */ final class AdPlaybackStateFactory { + private AdPlaybackStateFactory() {} + + /** + * Construct an {@link AdPlaybackState} from the provided {@code cuePoints}. + * + * @param cuePoints The cue points of the ads in seconds. + * @return The {@link AdPlaybackState}. + */ + public static AdPlaybackState fromCuePoints(List cuePoints) { + if (cuePoints.isEmpty()) { + // If no cue points are specified, there is a preroll ad. + return new AdPlaybackState(/* adGroupTimesUs...= */ 0); + } + + int count = cuePoints.size(); + long[] adGroupTimesUs = new long[count]; + int adGroupIndex = 0; + for (int i = 0; i < count; i++) { + double cuePoint = cuePoints.get(i); + if (cuePoint == -1.0) { + adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; + } else { + adGroupTimesUs[adGroupIndex++] = Math.round(C.MICROS_PER_SECOND * cuePoint); + } + } + // Cue points may be out of order, so sort them. + Arrays.sort(adGroupTimesUs, 0, adGroupIndex); + return new AdPlaybackState(adGroupTimesUs); + } +} diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 77e0f0f7e8..88b0daac49 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,7 +15,11 @@ */ package com.google.android.exoplayer2.ext.ima; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; import android.content.Context; import android.net.Uri; @@ -36,12 +40,15 @@ import com.google.ads.interactivemedia.v3.api.AdEvent; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType; import com.google.ads.interactivemedia.v3.api.AdPodInfo; +import com.google.ads.interactivemedia.v3.api.AdsLoader; import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener; import com.google.ads.interactivemedia.v3.api.AdsManager; import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; import com.google.ads.interactivemedia.v3.api.AdsRequest; import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; +import com.google.ads.interactivemedia.v3.api.FriendlyObstruction; +import com.google.ads.interactivemedia.v3.api.FriendlyObstructionPurpose; import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.UiElement; @@ -55,14 +62,16 @@ import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; -import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -71,31 +80,28 @@ 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; import java.util.Set; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** - * {@link AdsLoader} using the IMA SDK. All methods must be called on the main thread. + * {@link com.google.android.exoplayer2.source.ads.AdsLoader} using the IMA SDK. All methods must be + * called on the main thread. * *

The player instance that will play the loaded ads must be set before playback using {@link * #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling * {@link #release()}. * - *

The IMA SDK can take into account video control overlay views when calculating ad viewability. - * For more details see {@link AdDisplayContainer#registerVideoControlsOverlay(View)} and {@link - * AdViewProvider#getAdOverlayViews()}. + *

The IMA SDK can report obstructions to the ad view for accurate viewability measurement. This + * means that any overlay views that obstruct the ad overlay but are essential for playback need to + * be registered via the {@link AdViewProvider} passed to the {@link + * com.google.android.exoplayer2.source.ads.AdsMediaSource}. See the + * IMA SDK Open Measurement documentation for more information. */ public final class ImaAdsLoader - implements Player.EventListener, - AdsLoader, - VideoAdPlayer, - ContentProgressProvider, - AdErrorListener, - AdsLoadedListener, - AdEventListener { + implements Player.EventListener, com.google.android.exoplayer2.source.ads.AdsLoader { static { ExoPlayerLibraryInfo.registerModule("goog.exo.ima"); @@ -104,15 +110,30 @@ 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 AdErrorListener adErrorListener; @Nullable private AdEventListener adEventListener; @Nullable private Set adUiElements; + @Nullable private Collection companionAdSlots; + private long adPreloadTimeoutMs; private int vastLoadTimeoutMs; private int mediaLoadTimeoutMs; private int mediaBitrate; private boolean focusSkipButtonWhenAvailable; + private boolean playAdBeforeStartPosition; private ImaFactory imaFactory; /** @@ -121,11 +142,13 @@ public final class ImaAdsLoader * @param context The context; */ public Builder(Context context) { - this.context = Assertions.checkNotNull(context); + this.context = checkNotNull(context); + adPreloadTimeoutMs = DEFAULT_AD_PRELOAD_TIMEOUT_MS; vastLoadTimeoutMs = TIMEOUT_UNSET; mediaLoadTimeoutMs = TIMEOUT_UNSET; mediaBitrate = BITRATE_UNSET; focusSkipButtonWhenAvailable = true; + playAdBeforeStartPosition = true; imaFactory = new DefaultImaFactory(); } @@ -139,7 +162,20 @@ public final class ImaAdsLoader * @return This builder, for convenience. */ public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) { - this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings); + this.imaSdkSettings = checkNotNull(imaSdkSettings); + return this; + } + + /** + * Sets a listener for ad errors that will be passed to {@link + * AdsLoader#addAdErrorListener(AdErrorListener)} and {@link + * AdsManager#addAdErrorListener(AdErrorListener)}. + * + * @param adErrorListener The ad error listener. + * @return This builder, for convenience. + */ + public Builder setAdErrorListener(AdErrorListener adErrorListener) { + this.adErrorListener = checkNotNull(adErrorListener); return this; } @@ -151,7 +187,7 @@ public final class ImaAdsLoader * @return This builder, for convenience. */ public Builder setAdEventListener(AdEventListener adEventListener) { - this.adEventListener = Assertions.checkNotNull(adEventListener); + this.adEventListener = checkNotNull(adEventListener); return this; } @@ -163,7 +199,38 @@ public final class ImaAdsLoader * @see AdsRenderingSettings#setUiElements(Set) */ public Builder setAdUiElements(Set adUiElements) { - this.adUiElements = new HashSet<>(Assertions.checkNotNull(adUiElements)); + this.adUiElements = ImmutableSet.copyOf(checkNotNull(adUiElements)); + return this; + } + + /** + * Sets the slots to use for companion ads, if they are present in the loaded ad. + * + * @param companionAdSlots The slots to use for companion ads. + * @return This builder, for convenience. + * @see AdDisplayContainer#setCompanionSlots(Collection) + */ + public Builder setCompanionAdSlots(Collection companionAdSlots) { + this.companionAdSlots = ImmutableList.copyOf(checkNotNull(companionAdSlots)); + 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) { + checkArgument(adPreloadTimeoutMs == C.TIME_UNSET || adPreloadTimeoutMs > 0); + this.adPreloadTimeoutMs = adPreloadTimeoutMs; return this; } @@ -175,7 +242,7 @@ public final class ImaAdsLoader * @see AdsRequest#setVastLoadTimeout(float) */ public Builder setVastLoadTimeoutMs(int vastLoadTimeoutMs) { - Assertions.checkArgument(vastLoadTimeoutMs > 0); + checkArgument(vastLoadTimeoutMs > 0); this.vastLoadTimeoutMs = vastLoadTimeoutMs; return this; } @@ -188,7 +255,7 @@ public final class ImaAdsLoader * @see AdsRenderingSettings#setLoadVideoTimeout(int) */ public Builder setMediaLoadTimeoutMs(int mediaLoadTimeoutMs) { - Assertions.checkArgument(mediaLoadTimeoutMs > 0); + checkArgument(mediaLoadTimeoutMs > 0); this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; return this; } @@ -201,7 +268,7 @@ public final class ImaAdsLoader * @see AdsRenderingSettings#setBitrateKbps(int) */ public Builder setMaxMediaBitrate(int bitrate) { - Assertions.checkArgument(bitrate > 0); + checkArgument(bitrate > 0); this.mediaBitrate = bitrate; return this; } @@ -220,9 +287,24 @@ public final class ImaAdsLoader return this; } + /** + * Sets whether to play an ad before the start position when beginning playback. If {@code + * true}, an ad will be played if there is one at or before the start position. If {@code + * false}, an ad will be played only if there is one exactly at the start position. The default + * setting is {@code true}. + * + * @param playAdBeforeStartPosition Whether to play an ad before the start position when + * beginning playback. + * @return This builder, for convenience. + */ + public Builder setPlayAdBeforeStartPosition(boolean playAdBeforeStartPosition) { + this.playAdBeforeStartPosition = playAdBeforeStartPosition; + return this; + } + @VisibleForTesting /* package */ Builder setImaFactory(ImaFactory imaFactory) { - this.imaFactory = Assertions.checkNotNull(imaFactory); + this.imaFactory = checkNotNull(imaFactory); return this; } @@ -239,12 +321,16 @@ public final class ImaAdsLoader context, adTagUri, imaSdkSettings, - null, + /* adsResponse= */ null, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, + companionAdSlots, + adErrorListener, adEventListener, imaFactory); } @@ -259,14 +345,18 @@ public final class ImaAdsLoader public ImaAdsLoader buildForAdsResponse(String adsResponse) { return new ImaAdsLoader( context, - null, + /* adTagUri= */ null, imaSdkSettings, adsResponse, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, + companionAdSlots, + adErrorListener, adEventListener, imaFactory); } @@ -282,7 +372,7 @@ public final class ImaAdsLoader * 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 + * @see VideoAdPlayer.VideoAdPlayerCallback */ private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100; @@ -293,7 +383,14 @@ public final class ImaAdsLoader * Threshold before the end of content at which IMA is notified that content is complete if the * player buffers, in milliseconds. */ - private static final long END_OF_CONTENT_THRESHOLD_MS = 5000; + private static final long THRESHOLD_END_OF_CONTENT_MS = 5000; + /** + * Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in + * milliseconds. + */ + private static final long THRESHOLD_AD_PRELOAD_MS = 4000; + /** The threshold below which ad cue points are treated as matching, in microseconds. */ + private static final long THRESHOLD_AD_MATCH_US = 1000; private static final int TIMEOUT_UNSET = -1; private static final int BITRATE_UNSET = -1; @@ -303,37 +400,43 @@ public final class ImaAdsLoader @Retention(RetentionPolicy.SOURCE) @IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED}) private @interface ImaAdState {} - /** - * The ad playback state when IMA is not playing an ad. - */ + /** The ad playback state when IMA is not playing an ad. */ private static final int IMA_AD_STATE_NONE = 0; /** - * The ad playback state when IMA has called {@link #playAd(AdMediaInfo)} and not {@link - * #pauseAd(AdMediaInfo)}. + * The ad playback state when IMA has called {@link ComponentListener#playAd(AdMediaInfo)} and not + * {@link ComponentListener##pauseAd(AdMediaInfo)}. */ private static final int IMA_AD_STATE_PLAYING = 1; /** - * The ad playback state when IMA has called {@link #pauseAd(AdMediaInfo)} while playing an ad. + * The ad playback state when IMA has called {@link ComponentListener#pauseAd(AdMediaInfo)} while + * playing an ad. */ private static final int IMA_AD_STATE_PAUSED = 2; + private final Context context; @Nullable private final Uri adTagUri; @Nullable private final String adsResponse; + private final long adPreloadTimeoutMs; private final int vastLoadTimeoutMs; private final int mediaLoadTimeoutMs; private final boolean focusSkipButtonWhenAvailable; + private final boolean playAdBeforeStartPosition; private final int mediaBitrate; @Nullable private final Set adUiElements; + @Nullable private final Collection companionAdSlots; + @Nullable private final AdErrorListener adErrorListener; @Nullable private final AdEventListener adEventListener; private final ImaFactory imaFactory; + private final ImaSdkSettings imaSdkSettings; private final Timeline.Period period; private final Handler handler; - private final List adCallbacks; - private final AdDisplayContainer adDisplayContainer; - private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + private final ComponentListener componentListener; + private final List adCallbacks; private final Runnable updateAdProgressRunnable; - private final Map adInfoByAdMediaInfo; + private final BiMap adInfoByAdMediaInfo; + private @MonotonicNonNull AdDisplayContainer adDisplayContainer; + private @MonotonicNonNull AdsLoader adsLoader; private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; @Nullable private Object pendingAdRequestContext; @@ -342,10 +445,10 @@ public final class ImaAdsLoader @Nullable private Player player; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; - private int lastVolumePercentage; + private int lastVolumePercent; @Nullable private AdsManager adsManager; - private boolean initializedAdsManager; + private boolean isAdsManagerInitialized; private boolean hasAdPlaybackState; @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; @@ -362,10 +465,7 @@ public final class ImaAdsLoader @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. - */ + /** Whether IMA has been notified that playback of content has finished. */ private boolean sentContentComplete; // Fields tracking the player/loader state. @@ -385,10 +485,10 @@ public final class ImaAdsLoader */ @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd(AdMediaInfo)}, - * stores the value of {@link SystemClock#elapsedRealtime()} when the content stopped playing. - * This can be used to determine a fake, increasing content position. {@link C#TIME_UNSET} - * otherwise. + * If a content period has finished but IMA has not yet called {@link + * ComponentListener#playAd(AdMediaInfo)}, stores the value of {@link + * SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to determine + * a fake, increasing content position. {@link C#TIME_UNSET} otherwise. */ private long fakeContentProgressElapsedRealtimeMs; /** @@ -398,8 +498,16 @@ public final class ImaAdsLoader private long fakeContentProgressOffsetMs; /** Stores the pending content position when a seek operation was intercepted to play an ad. */ private long pendingContentPositionMs; - /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */ + /** + * Whether {@link ComponentListener#getContentProgress()} has sent {@link + * #pendingContentPositionMs} to IMA. + */ private boolean sentPendingContentPositionMs; + /** + * Stores the real time in milliseconds at which the player started buffering, possibly due to not + * having preloaded an ad, or {@link C#TIME_UNSET} if not applicable. + */ + private long waitingForPreloadElapsedRealtimeMs; /** * Creates a new IMA ads loader. @@ -417,38 +525,15 @@ 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, /* focusSkipButtonWhenAvailable= */ true, + /* playAdBeforeStartPosition= */ true, /* adUiElements= */ null, - /* adEventListener= */ null, - /* 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, + /* companionAdSlots= */ null, + /* adErrorListener= */ null, /* adEventListener= */ null, /* imaFactory= */ new DefaultImaFactory()); } @@ -459,21 +544,30 @@ public final class ImaAdsLoader @Nullable Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings, @Nullable String adsResponse, + long adPreloadTimeoutMs, int vastLoadTimeoutMs, int mediaLoadTimeoutMs, int mediaBitrate, boolean focusSkipButtonWhenAvailable, + boolean playAdBeforeStartPosition, @Nullable Set adUiElements, + @Nullable Collection companionAdSlots, + @Nullable AdErrorListener adErrorListener, @Nullable AdEventListener adEventListener, ImaFactory imaFactory) { - Assertions.checkArgument(adTagUri != null || adsResponse != null); + checkArgument(adTagUri != null || adsResponse != null); + this.context = context.getApplicationContext(); this.adTagUri = adTagUri; this.adsResponse = adsResponse; + this.adPreloadTimeoutMs = adPreloadTimeoutMs; this.vastLoadTimeoutMs = vastLoadTimeoutMs; this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; this.mediaBitrate = mediaBitrate; this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable; + this.playAdBeforeStartPosition = playAdBeforeStartPosition; this.adUiElements = adUiElements; + this.companionAdSlots = companionAdSlots; + this.adErrorListener = adErrorListener; this.adEventListener = adEventListener; this.imaFactory = imaFactory; if (imaSdkSettings == null) { @@ -484,64 +578,50 @@ public final class ImaAdsLoader } imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE); imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION); + this.imaSdkSettings = imaSdkSettings; period = new Timeline.Period(); handler = Util.createHandler(getImaLooper(), /* callback= */ null); + componentListener = new ComponentListener(); adCallbacks = new ArrayList<>(/* initialCapacity= */ 1); - adDisplayContainer = imaFactory.createAdDisplayContainer(); - adDisplayContainer.setPlayer(/* videoAdPlayer= */ this); - adsLoader = - imaFactory.createAdsLoader( - context.getApplicationContext(), imaSdkSettings, adDisplayContainer); - adsLoader.addAdErrorListener(/* adErrorListener= */ this); - adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); updateAdProgressRunnable = this::updateAdProgress; - adInfoByAdMediaInfo = new HashMap<>(); + adInfoByAdMediaInfo = HashBiMap.create(); 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; + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; contentDurationMs = C.TIME_UNSET; timeline = Timeline.EMPTY; adPlaybackState = AdPlaybackState.NONE; } /** - * Returns the underlying {@code com.google.ads.interactivemedia.v3.api.AdsLoader} wrapped by - * this instance. + * Returns the underlying {@link AdsLoader} wrapped by this instance, or {@code null} if ads have + * not been requested yet. */ - public com.google.ads.interactivemedia.v3.api.AdsLoader getAdsLoader() { + @Nullable + public AdsLoader getAdsLoader() { return adsLoader; } /** - * Returns the {@link AdDisplayContainer} used by this loader. + * Returns the {@link AdDisplayContainer} used by this loader, or {@code null} if ads have not + * been requested yet. * *

Note: any video controls overlays registered via {@link - * AdDisplayContainer#registerVideoControlsOverlay(View)} will be unregistered automatically when - * the media source detaches from this instance. It is therefore necessary to re-register views - * each time the ads loader is reused. Alternatively, provide overlay views via the {@link - * AdsLoader.AdViewProvider} when creating the media source to benefit from automatic - * registration. + * AdDisplayContainer#registerFriendlyObstruction(FriendlyObstruction)} will be unregistered + * automatically when the media source detaches from this instance. It is therefore necessary to + * re-register views each time the ads loader is reused. Alternatively, provide overlay views via + * the {@link com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider} when creating the + * media source to benefit from automatic registration. */ + @Nullable public AdDisplayContainer getAdDisplayContainer() { 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. * @@ -549,14 +629,30 @@ public final class ImaAdsLoader * called, so it is only necessary to call this method if you want to request ads before preparing * the player. * - * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI. + * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI, or {@code + * null} if playing audio-only ads. */ - public void requestAds(ViewGroup adViewGroup) { + public void requestAds(@Nullable ViewGroup adViewGroup) { if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) { // Ads have already been requested. return; } - adDisplayContainer.setAdContainer(adViewGroup); + if (adViewGroup != null) { + adDisplayContainer = + imaFactory.createAdDisplayContainer(adViewGroup, /* player= */ componentListener); + } else { + adDisplayContainer = + imaFactory.createAudioAdDisplayContainer(context, /* player= */ componentListener); + } + if (companionAdSlots != null) { + adDisplayContainer.setCompanionSlots(companionAdSlots); + } + adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer); + adsLoader.addAdErrorListener(componentListener); + if (adErrorListener != null) { + adsLoader.addAdErrorListener(adErrorListener); + } + adsLoader.addAdsLoadedListener(componentListener); AdsRequest request = imaFactory.createAdsRequest(); if (adTagUri != null) { request.setAdTagUrl(adTagUri.toString()); @@ -566,18 +662,31 @@ public final class ImaAdsLoader if (vastLoadTimeoutMs != TIMEOUT_UNSET) { request.setVastLoadTimeout(vastLoadTimeoutMs); } - request.setContentProgressProvider(this); + request.setContentProgressProvider(componentListener); pendingAdRequestContext = new Object(); request.setUserRequestContext(pendingAdRequestContext); adsLoader.requestAds(request); } - // AdsLoader implementation. + /** + * Skips the current ad. + * + *

This method is intended for apps that play audio-only ads and so need to provide their own + * UI for users to skip skippable ads. Apps showing video ads should not call this method, as the + * IMA SDK provides the UI to skip ads in the ad view group passed via {@link AdViewProvider}. + */ + public void skipAd() { + if (adsManager != null) { + adsManager.skip(); + } + } + + // com.google.android.exoplayer2.source.ads.AdsLoader implementation. @Override public void setPlayer(@Nullable Player player) { - Assertions.checkState(Looper.myLooper() == getImaLooper()); - Assertions.checkState(player == null || player.getApplicationLooper() == getImaLooper()); + checkState(Looper.myLooper() == getImaLooper()); + checkState(player == null || player.getApplicationLooper() == getImaLooper()); nextPlayer = player; wasSetPlayerCalled = true; } @@ -606,7 +715,7 @@ public final class ImaAdsLoader @Override public void start(EventListener eventListener, AdViewProvider adViewProvider) { - Assertions.checkState( + checkState( wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player."); player = nextPlayer; if (player == null) { @@ -615,15 +724,9 @@ public final class ImaAdsLoader player.addListener(this); boolean playWhenReady = player.getPlayWhenReady(); this.eventListener = eventListener; - lastVolumePercentage = 0; + lastVolumePercent = 0; lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; - ViewGroup adViewGroup = adViewProvider.getAdViewGroup(); - adDisplayContainer.setAdContainer(adViewGroup); - View[] adOverlayViews = adViewProvider.getAdOverlayViews(); - for (View view : adOverlayViews) { - adDisplayContainer.registerVideoControlsOverlay(view); - } maybeNotifyPendingAdLoadError(); if (hasAdPlaybackState) { // Pass the ad playback state to the player, and resume ads if necessary. @@ -632,11 +735,20 @@ public final class ImaAdsLoader adsManager.resume(); } } else if (adsManager != null) { - adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); updateAdPlaybackState(); } else { // Ads haven't loaded yet, so request them. - requestAds(adViewGroup); + requestAds(adViewProvider.getAdViewGroup()); + } + if (adDisplayContainer != null) { + for (OverlayInfo overlayInfo : adViewProvider.getAdOverlayInfos()) { + adDisplayContainer.registerFriendlyObstruction( + imaFactory.createFriendlyObstruction( + overlayInfo.view, + getFriendlyObstructionPurpose(overlayInfo.purpose), + overlayInfo.reasonDetail)); + } } } @@ -652,10 +764,12 @@ public final class ImaAdsLoader adPlaybackState.withAdResumePositionUs( playingAd ? C.msToUs(player.getCurrentPosition()) : 0); } - lastVolumePercentage = getVolume(); + lastVolumePercent = getPlayerVolumePercent(); lastAdProgress = getAdVideoProgressUpdate(); - lastContentProgress = getContentProgress(); - adDisplayContainer.unregisterAllVideoControlsOverlays(); + lastContentProgress = getContentVideoProgressUpdate(); + if (adDisplayContainer != null) { + adDisplayContainer.unregisterAllFriendlyObstructions(); + } player.removeListener(this); this.player = null; eventListener = null; @@ -664,27 +778,41 @@ public final class ImaAdsLoader @Override public void release() { pendingAdRequestContext = null; - if (adsManager != null) { - adsManager.removeAdErrorListener(this); - adsManager.removeAdEventListener(this); - if (adEventListener != null) { - adsManager.removeAdEventListener(adEventListener); + destroyAdsManager(); + if (adsLoader != null) { + adsLoader.removeAdsLoadedListener(componentListener); + adsLoader.removeAdErrorListener(componentListener); + if (adErrorListener != null) { + adsLoader.removeAdErrorListener(adErrorListener); } - adsManager.destroy(); - adsManager = null; } - adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this); - adsLoader.removeAdErrorListener(/* adErrorListener= */ this); imaPausedContent = false; imaAdState = IMA_AD_STATE_NONE; imaAdMediaInfo = null; + stopUpdatingAdProgress(); imaAdInfo = null; pendingAdLoadError = null; adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = false; + hasAdPlaybackState = true; updateAdPlaybackState(); } + @Override + public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) { + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + if (DEBUG) { + Log.d(TAG, "Prepared ad " + adInfo); + } + @Nullable AdMediaInfo adMediaInfo = adInfoByAdMediaInfo.inverse().get(adInfo); + if (adMediaInfo != null) { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onLoaded(adMediaInfo); + } + } else { + Log.w(TAG, "Unexpected prepared ad " + adInfo); + } + } + @Override public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) { if (player == null) { @@ -692,266 +820,11 @@ public final class ImaAdsLoader } try { handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("handlePrepareError", e); } } - // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. - - @Override - public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { - AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); - if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { - adsManager.destroy(); - return; - } - pendingAdRequestContext = null; - this.adsManager = adsManager; - adsManager.addAdErrorListener(this); - adsManager.addAdEventListener(this); - if (adEventListener != null) { - adsManager.addAdEventListener(adEventListener); - } - if (player != null) { - // If a player is attached already, start playback immediately. - try { - adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints())); - hasAdPlaybackState = true; - updateAdPlaybackState(); - } catch (Exception e) { - maybeNotifyInternalError("onAdsManagerLoaded", e); - } - } - } - - // AdEvent.AdEventListener implementation. - - @Override - public void onAdEvent(AdEvent adEvent) { - AdEventType adEventType = adEvent.getType(); - if (DEBUG) { - Log.d(TAG, "onAdEvent: " + adEventType); - } - if (adsManager == null) { - // Drop events after release. - return; - } - try { - handleAdEvent(adEvent); - } catch (Exception e) { - maybeNotifyInternalError("onAdEvent", e); - } - } - - // AdErrorEvent.AdErrorListener implementation. - - @Override - public void onAdError(AdErrorEvent adErrorEvent) { - AdError error = adErrorEvent.getError(); - if (DEBUG) { - Log.d(TAG, "onAdError", error); - } - if (adsManager == null) { - // No ads were loaded, so allow playback to start without any ads. - pendingAdRequestContext = null; - adPlaybackState = AdPlaybackState.NONE; - hasAdPlaybackState = true; - updateAdPlaybackState(); - } else if (isAdGroupLoadError(error)) { - try { - handleAdGroupLoadError(error); - } catch (Exception e) { - maybeNotifyInternalError("onAdError", e); - } - } - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAllAds(error); - } - maybeNotifyPendingAdLoadError(); - } - - // ContentProgressProvider implementation. - - @Override - public VideoProgressUpdate getContentProgress() { - 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); - } - - // VideoAdPlayer implementation. - - @Override - public VideoProgressUpdate getAdProgress() { - throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); - } - - @Override - public int getVolume() { - @Nullable Player player = this.player; - if (player == null) { - return lastVolumePercentage; - } - - @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); - if (audioComponent != null) { - return (int) (audioComponent.getVolume() * 100); - } - - // Check for a selected track using an audio renderer. - TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); - for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { - if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { - return 100; - } - } - return 0; - } - - @Override - public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { - try { - if (DEBUG) { - Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); - } - if (adsManager == null) { - // Drop events after release. - return; - } - int adGroupIndex = getAdGroupIndex(adPodInfo); - int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; - AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); - adInfoByAdMediaInfo.put(adMediaInfo, adInfo); - AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount( - adInfo.adGroupIndex, Math.max(adPodInfo.getTotalAds(), adGroup.states.length)); - adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; - } - for (int i = 0; i < adIndexInAdGroup; i++) { - // Any preceding ads that haven't loaded are not going to load. - if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { - adPlaybackState = - adPlaybackState.withAdLoadError( - /* adGroupIndex= */ adGroupIndex, /* adIndexInAdGroup= */ i); - } - } - Uri adUri = Uri.parse(adMediaInfo.getUrl()); - adPlaybackState = - adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); - updateAdPlaybackState(); - } catch (Exception e) { - maybeNotifyInternalError("loadAd", e); - } - } - - @Override - public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.add(videoAdPlayerCallback); - } - - @Override - public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { - adCallbacks.remove(videoAdPlayerCallback); - } - - @Override - public void playAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop events after release. - return; - } - - if (imaAdState == IMA_AD_STATE_PLAYING) { - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028]. - Log.w(TAG, "Unexpected playAd without stopAd"); - } - - 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(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); - } - if (adsManager == null) { - // Drop event after release. - return; - } - - Assertions.checkNotNull(player); - Assertions.checkState(imaAdState != IMA_AD_STATE_NONE); - try { - stopAdInternal(); - } catch (Exception e) { - maybeNotifyInternalError("stopAd", e); - } - } - - @Override - public void pauseAd(AdMediaInfo adMediaInfo) { - if (DEBUG) { - Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); - } - if (imaAdState == IMA_AD_STATE_NONE) { - // This method is called after content is resumed. - return; - } - Assertions.checkState(adMediaInfo.equals(imaAdMediaInfo)); - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(adMediaInfo); - } - } - // Player.EventListener implementation. @Override @@ -960,16 +833,28 @@ public final class ImaAdsLoader // The player is being reset or contains no media. return; } - Assertions.checkArgument(timeline.getPeriodCount() == 1); + checkArgument(timeline.getPeriodCount() == 1); this.timeline = timeline; long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs; contentDurationMs = C.usToMs(contentDurationUs); if (contentDurationUs != C.TIME_UNSET) { adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); } - if (!initializedAdsManager && adsManager != null) { - initializedAdsManager = true; - initializeAdsManager(adsManager); + @Nullable AdsManager adsManager = this.adsManager; + if (!isAdsManagerInitialized && adsManager != null) { + isAdsManagerInitialized = true; + @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (DEBUG) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } + } + updateAdPlaybackState(); } handleTimelineOrPositionChanged(); } @@ -981,9 +866,34 @@ public final class ImaAdsLoader @Override public void onPlaybackStateChanged(@Player.State int playbackState) { + @Nullable Player player = this.player; if (adsManager == null || player == null) { return; } + + if (playbackState == Player.STATE_BUFFERING && !player.isPlayingAd()) { + // Check whether we are waiting for an ad to preload. + int adGroupIndex = getLoadingAdGroupIndex(); + if (adGroupIndex == C.INDEX_UNSET) { + return; + } + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count != C.LENGTH_UNSET + && adGroup.count != 0 + && adGroup.states[0] != AdPlaybackState.AD_STATE_UNAVAILABLE) { + // An ad is available already so we must be buffering for some other reason. + return; + } + long adGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + long contentPositionMs = getContentPeriodPositionMs(player, timeline, period); + long timeUntilAdMs = adGroupTimeMs - contentPositionMs; + if (timeUntilAdMs < adPreloadTimeoutMs) { + waitingForPreloadElapsedRealtimeMs = SystemClock.elapsedRealtime(); + } + } else if (playbackState == Player.STATE_READY) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + } + handlePlayerStateChanged(player.getPlayWhenReady(), playbackState); } @@ -1009,7 +919,7 @@ public final class ImaAdsLoader @Override public void onPlayerError(ExoPlaybackException error) { if (imaAdState != IMA_AD_STATE_NONE) { - AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); for (int i = 0; i < adCallbacks.size(); i++) { adCallbacks.get(i).onError(adMediaInfo); } @@ -1018,7 +928,12 @@ public final class ImaAdsLoader // Internal methods. - private void initializeAdsManager(AdsManager adsManager) { + /** + * Configures ads rendering for starting playback, returning the settings for the IMA SDK or + * {@code null} if no ads should play. + */ + @Nullable + private AdsRenderingSettings setupAdsRendering() { AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); adsRenderingSettings.setEnablePreloading(true); adsRenderingSettings.setMimeTypes(supportedMimeTypes); @@ -1034,65 +949,135 @@ public final class ImaAdsLoader } // Skip ads based on the start position as required. - long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints()); - long contentPositionMs = - getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); - int adGroupIndexForPosition = + long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; + long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period); + int adGroupForPositionIndex = adPlaybackState.getAdGroupIndexForPositionUs( C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) { - // Skip any ad groups before the one at or immediately before the playback position. - for (int i = 0; i < adGroupIndexForPosition; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + if (adGroupForPositionIndex != C.INDEX_UNSET) { + boolean playAdWhenStartingPlayback = + playAdBeforeStartPosition + || adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs); + if (!playAdWhenStartingPlayback) { + adGroupForPositionIndex++; + } else if (hasMidrollAdGroups(adGroupTimesUs)) { + // Provide the player's initial position to trigger loading and playing the ad. If there are + // no midrolls, we are playing a preroll and any pending content position wouldn't be + // cleared. + pendingContentPositionMs = contentPositionMs; + } + if (adGroupForPositionIndex > 0) { + for (int i = 0; i < adGroupForPositionIndex; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + if (adGroupForPositionIndex == adGroupTimesUs.length) { + // We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP + // ads, we signal that no ads will render so the caller can destroy the ads manager. + return null; + } + long adGroupForPositionTimeUs = adGroupTimesUs[adGroupForPositionIndex]; + long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1]; + if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) { + // Play the postroll by offsetting the start position just past the last non-postroll ad. + adsRenderingSettings.setPlayAdsAfterTime( + (double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d); + } else { + // Play ads after the midpoint between the ad to play and the one before it, to avoid + // issues with rounding one of the two ad times. + double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d; + adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + } } - // Play ads after the midpoint between the ad to play and the one before it, to avoid issues - // with rounding one of the two ad times. - long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition]; - long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1]; - double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d; - adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); } + return adsRenderingSettings; + } - if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) { - // Provide the player's initial position to trigger loading and playing the ad. - pendingContentPositionMs = contentPositionMs; + 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); + } - adsManager.init(adsRenderingSettings); - adsManager.start(); - updateAdPlaybackState(); - if (DEBUG) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + 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 = checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onAdProgress(adMediaInfo, videoProgressUpdate); + } + handler.removeCallbacks(updateAdProgressRunnable); + handler.postDelayed(updateAdProgressRunnable, AD_PROGRESS_UPDATE_INTERVAL_MS); + } + + private void stopUpdatingAdProgress() { + handler.removeCallbacks(updateAdProgressRunnable); + } + + private int getPlayerVolumePercent() { + @Nullable Player player = this.player; + if (player == null) { + return lastVolumePercent; + } + + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); + if (audioComponent != null) { + return (int) (audioComponent.getVolume() * 100); + } + + // Check for a selected track using an audio renderer. + TrackSelectionArray trackSelections = player.getCurrentTrackSelections(); + for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) { + return 100; + } + } + return 0; + } + private void handleAdEvent(AdEvent adEvent) { + if (adsManager == null) { + // Drop events after release. + return; + } switch (adEvent.getType()) { case AD_BREAK_FETCH_ERROR: - String adGroupTimeSecondsString = - Assertions.checkNotNull(adEvent.getAdData().get("adBreakTime")); + String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime")); if (DEBUG) { Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); } - int adGroupTimeSeconds = Integer.parseInt(adGroupTimeSecondsString); + double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString); int adGroupIndex = - Arrays.binarySearch( - adPlaybackState.adGroupTimesUs, C.MICROS_PER_SECOND * adGroupTimeSeconds); - AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; - if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount(adGroupIndex, Math.max(1, adGroup.states.length)); - adGroup = adPlaybackState.adGroups[adGroupIndex]; - } - for (int i = 0; i < adGroup.count; i++) { - if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { - if (DEBUG) { - Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); - } - adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); - } - } - updateAdPlaybackState(); + adGroupTimeSeconds == -1.0 + ? adPlaybackState.adGroupCount - 1 + : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds); + handleAdGroupFetchError(adGroupIndex); break; case CONTENT_PAUSE_REQUESTED: // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads @@ -1124,37 +1109,30 @@ public final class ImaAdsLoader } } - 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 pauseContentInternal() { + imaAdState = IMA_AD_STATE_NONE; + if (sentPendingContentPositionMs) { + pendingContentPositionMs = C.TIME_UNSET; + sentPendingContentPositionMs = false; } } - 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); + private void resumeContentInternal() { + if (imaAdInfo != null) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); + updateAdPlaybackState(); + } else if (adPlaybackState.adGroupCount == 1 && adPlaybackState.adGroupTimesUs[0] == 0) { + // For incompatible VPAID ads with one preroll, content is resumed immediately. In this case + // we haven't received ad info (the ad never loaded), but there is only one ad group to skip. + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ 0); + updateAdPlaybackState(); } - 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); + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); for (int i = 0; i < adCallbacks.size(); i++) { adCallbacks.get(i).onBuffering(adMediaInfo); } @@ -1168,9 +1146,9 @@ public final class ImaAdsLoader if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING && playWhenReady) { - checkForContentComplete(); + ensureSentContentCompleteIfAtEndOfStream(); } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { - AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); if (adMediaInfo == null) { Log.w(TAG, "onEnded without ad media info"); } else { @@ -1190,15 +1168,8 @@ public final class ImaAdsLoader return; } if (!playingAd && !player.isPlayingAd()) { - checkForContentComplete(); - if (sentContentComplete) { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); - } - } - updateAdPlaybackState(); - } else if (!timeline.isEmpty()) { + ensureSentContentCompleteIfAtEndOfStream(); + if (!sentContentComplete && !timeline.isEmpty()) { long positionMs = getContentPeriodPositionMs(player, timeline, period); timeline.getPeriod(/* periodIndex= */ 0, period); int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); @@ -1231,37 +1202,159 @@ public final class ImaAdsLoader } if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) { int adGroupIndex = player.getCurrentAdGroupIndex(); - // IMA hasn't called playAd yet, so fake the content position. - fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); - fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); - if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { - fakeContentProgressOffsetMs = contentDurationMs; + if (adPlaybackState.adGroupTimesUs[adGroupIndex] == C.TIME_END_OF_SOURCE) { + sendContentComplete(); + } else { + // IMA hasn't called playAd yet, so fake the content position. + fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime(); + fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]); + if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { + fakeContentProgressOffsetMs = contentDurationMs; + } } } } - private void resumeContentInternal() { - if (imaAdInfo != null) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); - updateAdPlaybackState(); + private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + if (adsManager == null) { + // Drop events after release. + if (DEBUG) { + Log.d( + TAG, + "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); + } + return; + } + + int adGroupIndex = getAdGroupIndexForAdPod(adPodInfo); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + AdInfo adInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); + adInfoByAdMediaInfo.put(adMediaInfo, adInfo); + if (DEBUG) { + Log.d(TAG, "loadAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. IMA will + // timeout after its media load timeout. + return; + } + + // The ad count may increase on successive loads of ads in the same ad pod, for example, due to + // separate requests for ad tags with multiple ads within the ad pod completing after an earlier + // ad has loaded. See also https://github.com/google/ExoPlayer/issues/7477. + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + adPlaybackState = + adPlaybackState.withAdCount( + adInfo.adGroupIndex, max(adPodInfo.getTotalAds(), adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adInfo.adGroupIndex]; + for (int i = 0; i < adIndexInAdGroup; i++) { + // Any preceding ads that haven't loaded are not going to load. + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, /* adIndexInAdGroup= */ i); + } + } + + Uri adUri = Uri.parse(adMediaInfo.getUrl()); + adPlaybackState = + adPlaybackState.withAdUri(adInfo.adGroupIndex, adInfo.adIndexInAdGroup, adUri); + updateAdPlaybackState(); + } + + private void playAdInternal(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "playAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop events after release. + return; + } + + if (imaAdState == IMA_AD_STATE_PLAYING) { + // IMA does not always call stopAd before resuming content. + // See [Internal: b/38354028]. + Log.w(TAG, "Unexpected playAd without stopAd"); + } + + if (imaAdState == IMA_AD_STATE_NONE) { + // IMA is requesting to play the ad, so stop faking the content position. + fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; + fakeContentProgressOffsetMs = C.TIME_UNSET; + imaAdState = IMA_AD_STATE_PLAYING; + imaAdMediaInfo = adMediaInfo; + imaAdInfo = checkNotNull(adInfoByAdMediaInfo.get(adMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPlay(adMediaInfo); + } + if (pendingAdPrepareErrorAdInfo != null && pendingAdPrepareErrorAdInfo.equals(imaAdInfo)) { + pendingAdPrepareErrorAdInfo = null; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + updateAdProgress(); + } else { + imaAdState = IMA_AD_STATE_PLAYING; + checkState(adMediaInfo.equals(imaAdMediaInfo)); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onResume(adMediaInfo); + } + } + if (!checkNotNull(player).getPlayWhenReady()) { + checkNotNull(adsManager).pause(); } } - private void pauseContentInternal() { - imaAdState = IMA_AD_STATE_NONE; - if (sentPendingContentPositionMs) { - pendingContentPositionMs = C.TIME_UNSET; - sentPendingContentPositionMs = false; + private void pauseAdInternal(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "pauseAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called if loadAd has been called but the loaded ad won't play due to a seek + // to a different position, so drop the event. See also [Internal: b/159111848]. + return; + } + checkState(adMediaInfo.equals(imaAdMediaInfo)); + imaAdState = IMA_AD_STATE_PAUSED; + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onPause(adMediaInfo); } } - private void stopAdInternal() { + private void stopAdInternal(AdMediaInfo adMediaInfo) { + if (DEBUG) { + Log.d(TAG, "stopAd " + getAdMediaInfoString(adMediaInfo)); + } + if (adsManager == null) { + // Drop event after release. + return; + } + if (imaAdState == IMA_AD_STATE_NONE) { + // This method is called if loadAd has been called but the preloaded ad won't play due to a + // seek to a different position, so drop the event and discard the ad. See also [Internal: + // b/159111848]. + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + if (adInfo != null) { + adPlaybackState = + adPlaybackState.withSkippedAd(adInfo.adGroupIndex, adInfo.adIndexInAdGroup); + updateAdPlaybackState(); + } + return; + } + checkNotNull(player); imaAdState = IMA_AD_STATE_NONE; stopUpdatingAdProgress(); // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. - Assertions.checkNotNull(imaAdInfo); + 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(); @@ -1271,30 +1364,38 @@ public final class ImaAdsLoader } } + private void handleAdGroupFetchError(int adGroupIndex) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + if (adGroup.count == C.LENGTH_UNSET) { + adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length)); + adGroup = adPlaybackState.adGroups[adGroupIndex]; + } + for (int i = 0; i < adGroup.count; i++) { + if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) { + if (DEBUG) { + Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex); + } + adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i); + } + } + updateAdPlaybackState(); + } + private void handleAdGroupLoadError(Exception error) { if (player == null) { return; } - // TODO: Once IMA signals which ad group failed to load, clean up this code. - long playerPositionMs = player.getContentPosition(); - int adGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); + // TODO: Once IMA signals which ad group failed to load, remove this call. + int adGroupIndex = getLoadingAdGroupIndex(); if (adGroupIndex == C.INDEX_UNSET) { - adGroupIndex = - adPlaybackState.getAdGroupIndexAfterPositionUs( - C.msToUs(playerPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndex == C.INDEX_UNSET) { - // The error doesn't seem to relate to any ad group so give up handling it. - return; - } + Log.w(TAG, "Unable to determine ad group index for ad group load error", error); + return; } AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; if (adGroup.count == C.LENGTH_UNSET) { - adPlaybackState = - adPlaybackState.withAdCount(adGroupIndex, Math.max(1, adGroup.states.length)); + adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, max(1, adGroup.states.length)); adGroup = adPlaybackState.adGroups[adGroupIndex]; } for (int i = 0; i < adGroup.count; i++) { @@ -1332,7 +1433,7 @@ public final class ImaAdsLoader } pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); } else { - AdMediaInfo adMediaInfo = Assertions.checkNotNull(imaAdMediaInfo); + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); // We're already playing an ad. if (adIndexInAdGroup > playingAdIndexInAdGroup) { // Mark the playing ad as ended so we can notify the error on the next ad and remove it, @@ -1343,27 +1444,40 @@ public final class ImaAdsLoader } playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(Assertions.checkNotNull(adMediaInfo)); + adCallbacks.get(i).onError(checkNotNull(adMediaInfo)); } } adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); updateAdPlaybackState(); } - private void checkForContentComplete() { - long positionMs = getContentPeriodPositionMs(Assertions.checkNotNull(player), timeline, period); + private void ensureSentContentCompleteIfAtEndOfStream() { if (!sentContentComplete && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && positionMs + END_OF_CONTENT_THRESHOLD_MS >= contentDurationMs) { - adsLoader.contentComplete(); - if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete"); - } - sentContentComplete = true; + && getContentPeriodPositionMs(checkNotNull(player), timeline, period) + + THRESHOLD_END_OF_CONTENT_MS + >= contentDurationMs) { + sendContentComplete(); } } + private void sendContentComplete() { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onContentComplete(); + } + sentContentComplete = true; + if (DEBUG) { + Log.d(TAG, "adsLoader.contentComplete"); + } + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); + } + } + updateAdPlaybackState(); + } + private void updateAdPlaybackState() { // Ignore updates while detached. When a player is attached it will receive the latest state. if (eventListener != null) { @@ -1393,6 +1507,68 @@ public final class ImaAdsLoader } } + private int getAdGroupIndexForAdPod(AdPodInfo adPodInfo) { + if (adPodInfo.getPodIndex() == -1) { + // This is a postroll ad. + return adPlaybackState.adGroupCount - 1; + } + + // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. + return getAdGroupIndexForCuePointTimeSeconds(adPodInfo.getTimeOffset()); + } + + /** + * Returns the index of the ad group that will preload next, or {@link C#INDEX_UNSET} if there is + * no such ad group. + */ + private int getLoadingAdGroupIndex() { + long playerPositionUs = + C.msToUs(getContentPeriodPositionMs(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 int getAdGroupIndexForCuePointTimeSeconds(double cuePointTimeSeconds) { + // We receive initial cue points from IMA SDK as floats. This code replicates the same + // calculation used to populate adGroupTimesUs (having truncated input back to float, to avoid + // failures if the behavior of the IMA SDK changes to provide greater precision). + long adPodTimeUs = Math.round((float) cuePointTimeSeconds * C.MICROS_PER_SECOND); + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + long adGroupTimeUs = adPlaybackState.adGroupTimesUs[adGroupIndex]; + if (adGroupTimeUs != C.TIME_END_OF_SOURCE + && Math.abs(adGroupTimeUs - adPodTimeUs) < THRESHOLD_AD_MATCH_US) { + return adGroupIndex; + } + } + throw new IllegalStateException("Failed to find cue point"); + } + + private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { + @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); + return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; + } + + private static FriendlyObstructionPurpose getFriendlyObstructionPurpose( + @OverlayInfo.Purpose int purpose) { + switch (purpose) { + case OverlayInfo.PURPOSE_CONTROLS: + return FriendlyObstructionPurpose.VIDEO_CONTROLS; + case OverlayInfo.PURPOSE_CLOSE_AD: + return FriendlyObstructionPurpose.CLOSE_AD; + case OverlayInfo.PURPOSE_NOT_VISIBLE: + return FriendlyObstructionPurpose.NOT_VISIBLE; + case OverlayInfo.PURPOSE_OTHER: + default: + return FriendlyObstructionPurpose.OTHER; + } + } + private static DataSpec getAdsDataSpec(@Nullable Uri adTagUri) { return new DataSpec(adTagUri != null ? adTagUri : Uri.EMPTY); } @@ -1401,45 +1577,9 @@ public final class ImaAdsLoader Player player, Timeline timeline, Timeline.Period period) { long contentWindowPositionMs = player.getContentPosition(); return contentWindowPositionMs - - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); - } - - private int getAdGroupIndex(AdPodInfo adPodInfo) { - if (adPodInfo.getPodIndex() == -1) { - // This is a postroll ad. - return adPlaybackState.adGroupCount - 1; - } - - // adPodInfo.podIndex may be 0-based or 1-based, so for now look up the cue point instead. - long adGroupTimeUs = (long) (((float) adPodInfo.getTimeOffset()) * C.MICROS_PER_SECOND); - for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { - if (adPlaybackState.adGroupTimesUs[adGroupIndex] == adGroupTimeUs) { - return adGroupIndex; - } - } - throw new IllegalStateException("Failed to find cue point"); - } - - private static long[] getAdGroupTimesUs(List cuePoints) { - if (cuePoints.isEmpty()) { - // If no cue points are specified, there is a preroll ad. - return new long[] {0}; - } - - int count = cuePoints.size(); - long[] adGroupTimesUs = new long[count]; - int adGroupIndex = 0; - for (int i = 0; i < count; i++) { - double cuePoint = cuePoints.get(i); - if (cuePoint == -1.0) { - adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE; - } else { - adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint); - } - } - // Cue points may be out of order, so sort them. - Arrays.sort(adGroupTimesUs, 0, adGroupIndex); - return adGroupTimesUs; + - (timeline.isEmpty() + ? 0 + : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } private static boolean isAdGroupLoadError(AdError adError) { @@ -1467,25 +1607,227 @@ public final class ImaAdsLoader } } + private void destroyAdsManager() { + if (adsManager != null) { + adsManager.removeAdErrorListener(componentListener); + if (adErrorListener != null) { + adsManager.removeAdErrorListener(adErrorListener); + } + adsManager.removeAdEventListener(componentListener); + if (adEventListener != null) { + adsManager.removeAdEventListener(adEventListener); + } + adsManager.destroy(); + adsManager = null; + } + } + /** Factory for objects provided by the IMA SDK. */ @VisibleForTesting /* package */ interface ImaFactory { - /** @see ImaSdkSettings */ + /** Creates {@link ImaSdkSettings} for configuring the IMA SDK. */ ImaSdkSettings createImaSdkSettings(); - /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdsRenderingSettings() */ + /** + * Creates {@link AdsRenderingSettings} for giving the {@link AdsManager} parameters that + * control rendering of ads. + */ AdsRenderingSettings createAdsRenderingSettings(); - /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdDisplayContainer() */ - AdDisplayContainer createAdDisplayContainer(); - /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdsRequest() */ + /** + * Creates an {@link AdDisplayContainer} to hold the player for video ads, a container for + * non-linear ads, and slots for companion ads. + */ + AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player); + /** Creates an {@link AdDisplayContainer} to hold the player for audio ads. */ + AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player); + /** + * Creates a {@link FriendlyObstruction} to describe an obstruction considered "friendly" for + * viewability measurement purposes. + */ + FriendlyObstruction createFriendlyObstruction( + View view, + FriendlyObstructionPurpose friendlyObstructionPurpose, + @Nullable String reasonDetail); + /** Creates an {@link AdsRequest} to contain the data used to request ads. */ AdsRequest createAdsRequest(); - /** @see ImaSdkFactory#createAdsLoader(Context, ImaSdkSettings, AdDisplayContainer) */ - com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( + /** Creates an {@link AdsLoader} for requesting ads using the specified settings. */ + AdsLoader createAdsLoader( Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer); } - private String getAdMediaInfoString(AdMediaInfo adMediaInfo) { - @Nullable AdInfo adInfo = adInfoByAdMediaInfo.get(adMediaInfo); - return "AdMediaInfo[" + adMediaInfo.getUrl() + (adInfo != null ? ", " + adInfo : "") + "]"; + private final class ComponentListener + implements AdsLoadedListener, + ContentProgressProvider, + AdEventListener, + AdErrorListener, + VideoAdPlayer { + + // AdsLoader.AdsLoadedListener implementation. + + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { + AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); + if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { + adsManager.destroy(); + return; + } + pendingAdRequestContext = null; + ImaAdsLoader.this.adsManager = adsManager; + adsManager.addAdErrorListener(this); + if (adErrorListener != null) { + adsManager.addAdErrorListener(adErrorListener); + } + adsManager.addAdEventListener(this); + if (adEventListener != null) { + adsManager.addAdEventListener(adEventListener); + } + if (player != null) { + // If a player is attached already, start playback immediately. + try { + adPlaybackState = AdPlaybackStateFactory.fromCuePoints(adsManager.getAdCuePoints()); + hasAdPlaybackState = true; + updateAdPlaybackState(); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdsManagerLoaded", e); + } + } + } + + // ContentProgressProvider implementation. + + @Override + public VideoProgressUpdate getContentProgress() { + VideoProgressUpdate videoProgressUpdate = getContentVideoProgressUpdate(); + if (DEBUG) { + if (VideoProgressUpdate.VIDEO_TIME_NOT_READY.equals(videoProgressUpdate)) { + Log.d(TAG, "Content progress: not ready"); + } else { + Log.d( + TAG, + Util.formatInvariant( + "Content progress: %.1f of %.1f s", + videoProgressUpdate.getCurrentTime(), videoProgressUpdate.getDuration())); + } + } + + if (waitingForPreloadElapsedRealtimeMs != C.TIME_UNSET) { + // IMA is polling the player position but we are buffering for an ad to preload, so playback + // may be stuck. Detect this case and signal an error if applicable. + long stuckElapsedRealtimeMs = + SystemClock.elapsedRealtime() - waitingForPreloadElapsedRealtimeMs; + if (stuckElapsedRealtimeMs >= THRESHOLD_AD_PRELOAD_MS) { + waitingForPreloadElapsedRealtimeMs = C.TIME_UNSET; + handleAdGroupLoadError(new IOException("Ad preloading timed out")); + maybeNotifyPendingAdLoadError(); + } + } + + return videoProgressUpdate; + } + + // AdEvent.AdEventListener implementation. + + @Override + public void onAdEvent(AdEvent adEvent) { + AdEventType adEventType = adEvent.getType(); + if (DEBUG && adEventType != AdEventType.AD_PROGRESS) { + Log.d(TAG, "onAdEvent: " + adEventType); + } + try { + handleAdEvent(adEvent); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdEvent", e); + } + } + + // AdErrorEvent.AdErrorListener implementation. + + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + AdError error = adErrorEvent.getError(); + if (DEBUG) { + Log.d(TAG, "onAdError", error); + } + if (adsManager == null) { + // No ads were loaded, so allow playback to start without any ads. + pendingAdRequestContext = null; + adPlaybackState = AdPlaybackState.NONE; + hasAdPlaybackState = true; + updateAdPlaybackState(); + } else if (isAdGroupLoadError(error)) { + try { + handleAdGroupLoadError(error); + } catch (RuntimeException e) { + maybeNotifyInternalError("onAdError", e); + } + } + if (pendingAdLoadError == null) { + pendingAdLoadError = AdLoadException.createForAllAds(error); + } + maybeNotifyPendingAdLoadError(); + } + + // VideoAdPlayer implementation. + + @Override + public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.add(videoAdPlayerCallback); + } + + @Override + public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) { + adCallbacks.remove(videoAdPlayerCallback); + } + + @Override + public VideoProgressUpdate getAdProgress() { + throw new IllegalStateException("Unexpected call to getAdProgress when using preloading"); + } + + @Override + public int getVolume() { + return getPlayerVolumePercent(); + } + + @Override + public void loadAd(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + try { + loadAdInternal(adMediaInfo, adPodInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("loadAd", e); + } + } + + @Override + public void playAd(AdMediaInfo adMediaInfo) { + try { + playAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("playAd", e); + } + } + + @Override + public void pauseAd(AdMediaInfo adMediaInfo) { + try { + pauseAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("pauseAd", e); + } + } + + @Override + public void stopAd(AdMediaInfo adMediaInfo) { + try { + stopAdInternal(adMediaInfo); + } catch (RuntimeException e) { + maybeNotifyInternalError("stopAd", e); + } + } + + @Override + public void release() { + // Do nothing. + } } // TODO: Consider moving this into AdPlaybackState. @@ -1539,8 +1881,25 @@ public final class ImaAdsLoader } @Override - public AdDisplayContainer createAdDisplayContainer() { - return ImaSdkFactory.getInstance().createAdDisplayContainer(); + public AdDisplayContainer createAdDisplayContainer(ViewGroup container, VideoAdPlayer player) { + return ImaSdkFactory.createAdDisplayContainer(container, player); + } + + @Override + public AdDisplayContainer createAudioAdDisplayContainer(Context context, VideoAdPlayer player) { + return ImaSdkFactory.createAudioAdDisplayContainer(context, player); + } + + // The reasonDetail parameter to createFriendlyObstruction is annotated @Nullable but the + // annotation is not kept in the obfuscated dependency. + @SuppressWarnings("nullness:argument.type.incompatible") + @Override + public FriendlyObstruction createFriendlyObstruction( + View view, + FriendlyObstructionPurpose friendlyObstructionPurpose, + @Nullable String reasonDetail) { + return ImaSdkFactory.getInstance() + .createFriendlyObstruction(view, friendlyObstructionPurpose, reasonDetail); } @Override @@ -1549,7 +1908,7 @@ public final class ImaAdsLoader } @Override - public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader( + public AdsLoader createAdsLoader( Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) { return ImaSdkFactory.getInstance() .createAdsLoader(context, imaSdkSettings, adDisplayContainer); diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 6405583bf1..e32a199200 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 @@ -15,12 +15,16 @@ */ package com.google.android.exoplayer2.ext.ima; +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,7 +33,6 @@ import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import androidx.annotation.Nullable; -import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.ads.interactivemedia.v3.api.Ad; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; @@ -40,15 +43,19 @@ 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.FriendlyObstruction; import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; +import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; +import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.ext.ima.ImaAdsLoader.ImaFactory; -import com.google.android.exoplayer2.source.MaskingMediaSource.DummyTimeline; +import com.google.android.exoplayer2.source.MaskingMediaSource.PlaceholderTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; @@ -56,7 +63,10 @@ import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.io.IOException; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -73,6 +83,7 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.mockito.stubbing.Answer; +import org.robolectric.shadows.ShadowSystemClock; /** Tests for {@link ImaAdsLoader}. */ @RunWith(AndroidJUnit4.class) @@ -88,8 +99,7 @@ public final class ImaAdsLoaderTest { private static final Uri TEST_URI = Uri.EMPTY; private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; - private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; + private static final ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f); @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -100,13 +110,17 @@ public final class ImaAdsLoaderTest { @Mock private AdsRequest mockAdsRequest; @Mock private AdsManagerLoadedEvent mockAdsManagerLoadedEvent; @Mock private com.google.ads.interactivemedia.v3.api.AdsLoader mockAdsLoader; + @Mock private FriendlyObstruction mockFriendlyObstruction; @Mock private ImaFactory mockImaFactory; @Mock private AdPodInfo mockAdPodInfo; @Mock private Ad mockPrerollSingleAd; private ViewGroup adViewGroup; - private View adOverlayView; private AdsLoader.AdViewProvider adViewProvider; + private AdsLoader.AdViewProvider audioAdsAdViewProvider; + private AdEvent.AdEventListener adEventListener; + private ContentProgressProvider contentProgressProvider; + private VideoAdPlayer videoAdPlayer; private TestAdsLoaderListener adsLoaderListener; private FakePlayer fakeExoPlayer; private ImaAdsLoader imaAdsLoader; @@ -114,8 +128,8 @@ public final class ImaAdsLoaderTest { @Before public void setUp() { setupMocks(); - adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext()); - adOverlayView = new View(ApplicationProvider.getApplicationContext()); + adViewGroup = new FrameLayout(getApplicationContext()); + View adOverlayView = new View(getApplicationContext()); adViewProvider = new AdsLoader.AdViewProvider() { @Override @@ -124,8 +138,21 @@ public final class ImaAdsLoaderTest { } @Override - public View[] getAdOverlayViews() { - return new View[] {adOverlayView}; + public ImmutableList getAdOverlayInfos() { + return ImmutableList.of( + new AdsLoader.OverlayInfo(adOverlayView, AdsLoader.OverlayInfo.PURPOSE_CLOSE_AD)); + } + }; + audioAdsAdViewProvider = + new AdsLoader.AdViewProvider() { + @Override + public ViewGroup getAdViewGroup() { + return null; + } + + @Override + public ImmutableList getAdOverlayInfos() { + return ImmutableList.of(); } }; } @@ -140,24 +167,36 @@ public final class ImaAdsLoaderTest { @Test public void builder_overridesPlayerType() { when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); verify(mockImaSdkSettings).setPlayerType("google/exo.ext.ima"); } @Test public void start_setsAdUiViewGroup() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); - verify(mockAdDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); - verify(mockAdDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView); + verify(mockImaFactory, atLeastOnce()).createAdDisplayContainer(adViewGroup, videoAdPlayer); + verify(mockImaFactory, never()).createAudioAdDisplayContainer(any(), any()); + verify(mockAdDisplayContainer).registerFriendlyObstruction(mockFriendlyObstruction); + } + + @Test + public void startForAudioOnlyAds_createsAudioOnlyAdDisplayContainer() { + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); + imaAdsLoader.start(adsLoaderListener, audioAdsAdViewProvider); + + verify(mockImaFactory, atLeastOnce()) + .createAudioAdDisplayContainer(getApplicationContext(), videoAdPlayer); + verify(mockImaFactory, never()).createAdDisplayContainer(any(), any()); + verify(mockAdDisplayContainer, never()).registerFriendlyObstruction(any()); } @Test public void start_withPlaceholderContent_initializedAdsLoader() { - Timeline placeholderTimeline = new DummyTimeline(/* tag= */ null); - setupPlayback(placeholderTimeline, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + Timeline placeholderTimeline = new PlaceholderTimeline(MediaItem.fromUri(Uri.EMPTY)); + setupPlayback(placeholderTimeline, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); // We'll only create the rendering settings when initializing the ads loader. @@ -166,26 +205,27 @@ public final class ImaAdsLoaderTest { @Test public void start_updatesAdPlaybackState() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); assertThat(adsLoaderListener.adPlaybackState) .isEqualTo( new AdPlaybackState(/* adGroupTimesUs...= */ 0) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); } @Test public void startAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); } @Test public void startAndCallbacksAfterRelease() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); + // Request ads in order to get a reference to the ad event listener. + imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.release(); imaAdsLoader.start(adsLoaderListener, adViewProvider); fakeExoPlayer.setPlayingContentPosition(/* position= */ 0); @@ -196,47 +236,47 @@ public final class ImaAdsLoaderTest { // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.pauseAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + videoAdPlayer.pauseAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException())); imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); imaAdsLoader.handlePrepareError( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException()); } @Test public void playback_withPrerollAd_marksAdAsPlayed() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); // Load the preroll ad. imaAdsLoader.start(adsLoaderListener, adViewProvider); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); - imaAdsLoader.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.LOADED, mockPrerollSingleAd)); + videoAdPlayer.loadAd(TEST_AD_MEDIA_INFO, mockAdPodInfo); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, mockPrerollSingleAd)); // Play the preroll ad. - imaAdsLoader.playAd(TEST_AD_MEDIA_INFO); + videoAdPlayer.playAd(TEST_AD_MEDIA_INFO); fakeExoPlayer.setPlayingAdPosition( /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* position= */ 0, /* contentPosition= */ 0); fakeExoPlayer.setState(Player.STATE_READY, true); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.STARTED, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.MIDPOINT, mockPrerollSingleAd)); + adEventListener.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, mockPrerollSingleAd)); // Play the content. fakeExoPlayer.setPlayingContentPosition(0); - imaAdsLoader.stopAd(TEST_AD_MEDIA_INFO); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); + videoAdPlayer.stopAd(TEST_AD_MEDIA_INFO); + adEventListener.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null)); // Verify that the preroll ad has been marked as played. assertThat(adsLoaderListener.adPlaybackState) @@ -245,32 +285,479 @@ public final class ImaAdsLoaderTest { .withContentDurationUs(CONTENT_PERIOD_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI) - .withAdDurationsUs(PREROLL_ADS_DURATIONS_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) .withAdResumePositionUs(/* adResumePositionUs= */ 0)); } + @Test + public void playback_withMidrollFetchError_marksAdAsInErrorState() { + AdEvent mockMidrollFetchErrorAdEvent = mock(AdEvent.class); + when(mockMidrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockMidrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "20.5")); + setupPlayback(CONTENT_TIMELINE, ImmutableList.of(20.5f)); + + // Simulate loading an empty midroll ad. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + adEventListener.onAdEvent(mockMidrollFetchErrorAdEvent); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ 20_500_000) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + + @Test + public void playback_withPostrollFetchError_marksAdAsInErrorState() { + AdEvent mockPostrollFetchErrorAdEvent = mock(AdEvent.class); + when(mockPostrollFetchErrorAdEvent.getType()).thenReturn(AdEventType.AD_BREAK_FETCH_ERROR); + when(mockPostrollFetchErrorAdEvent.getAdData()) + .thenReturn(ImmutableMap.of("adBreakTime", "-1")); + setupPlayback(CONTENT_TIMELINE, ImmutableList.of(-1f)); + + // Simulate loading an empty postroll ad. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + adEventListener.onAdEvent(mockPostrollFetchErrorAdEvent); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + new AdPlaybackState(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + + @Test + public void playback_withAdNotPreloadingBeforeTimeout_hasNoError() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + long adGroupTimeUs = + adGroupPositionInWindowUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + 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)); + contentProgressProvider.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void playback_withAdNotPreloadingAfterTimeout_hasErrorAdGroup() { + // Simulate an ad at 2 seconds. + long adGroupPositionInWindowUs = 2 * C.MICROS_PER_SECOND; + long adGroupTimeUs = + adGroupPositionInWindowUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = ImmutableList.of((float) adGroupTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); + + // Advance playback to just before the midroll and simulate buffering. + imaAdsLoader.start(adsLoaderListener, adViewProvider); + 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)); + contentProgressProvider.getContentProgress(); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}}) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdLoadError(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)); + } + + @Test + public void resumePlaybackBeforeMidroll_playsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtMidroll_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackAfterMidroll_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackBeforeSecondMidroll_playsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsRenderingSettings, never()).setPlayAdsAfterTime(anyDouble()); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtSecondMidroll_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback(CONTENT_TIMELINE, cuePoints); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackBeforeMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback( + CONTENT_TIMELINE, + cuePoints, + new ImaAdsLoader.Builder(getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtMidroll_withoutPlayAdBeforeStartPosition_skipsPreroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback( + CONTENT_TIMELINE, + cuePoints, + new ImaAdsLoader.Builder(getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = midrollPeriodTimeUs / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + + @Test + public void resumePlaybackAfterMidroll_withoutPlayAdBeforeStartPosition_skipsMidroll() { + long midrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long midrollPeriodTimeUs = + midrollWindowTimeUs + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of(0f, (float) midrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback( + CONTENT_TIMELINE, + cuePoints, + new ImaAdsLoader.Builder(getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(midrollWindowTimeUs) + 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + verify(mockAdsManager).destroy(); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withSkippedAdGroup(/* adGroupIndex= */ 1)); + } + + @Test + public void + resumePlaybackBeforeSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback( + CONTENT_TIMELINE, + cuePoints, + new ImaAdsLoader.Builder(getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs) - 1_000); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withSkippedAdGroup(/* adGroupIndex= */ 0) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US)); + } + + @Test + public void resumePlaybackAtSecondMidroll_withoutPlayAdBeforeStartPosition_skipsFirstMidroll() { + long firstMidrollWindowTimeUs = 2 * C.MICROS_PER_SECOND; + long firstMidrollPeriodTimeUs = + firstMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + long secondMidrollWindowTimeUs = 4 * C.MICROS_PER_SECOND; + long secondMidrollPeriodTimeUs = + secondMidrollWindowTimeUs + + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; + ImmutableList cuePoints = + ImmutableList.of( + (float) firstMidrollPeriodTimeUs / C.MICROS_PER_SECOND, + (float) secondMidrollPeriodTimeUs / C.MICROS_PER_SECOND); + setupPlayback( + CONTENT_TIMELINE, + cuePoints, + new ImaAdsLoader.Builder(getApplicationContext()) + .setPlayAdBeforeStartPosition(false) + .setImaFactory(mockImaFactory) + .setImaSdkSettings(mockImaSdkSettings) + .buildForAdTag(TEST_URI)); + + fakeExoPlayer.setPlayingContentPosition(C.usToMs(secondMidrollWindowTimeUs)); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + + ArgumentCaptor playAdsAfterTimeCaptor = ArgumentCaptor.forClass(Double.class); + verify(mockAdsRenderingSettings).setPlayAdsAfterTime(playAdsAfterTimeCaptor.capture()); + double expectedPlayAdsAfterTimeUs = (firstMidrollPeriodTimeUs + secondMidrollPeriodTimeUs) / 2d; + assertThat(playAdsAfterTimeCaptor.getValue()) + .isWithin(0.1d) + .of(expectedPlayAdsAfterTimeUs / C.MICROS_PER_SECOND); + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withSkippedAdGroup(/* adGroupIndex= */ 0)); + } + @Test public void stop_unregistersAllVideoControlOverlays() { - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); imaAdsLoader.start(adsLoaderListener, adViewProvider); imaAdsLoader.requestAds(adViewGroup); imaAdsLoader.stop(); InOrder inOrder = inOrder(mockAdDisplayContainer); - inOrder.verify(mockAdDisplayContainer).registerVideoControlsOverlay(adOverlayView); - inOrder.verify(mockAdDisplayContainer).unregisterAllVideoControlsOverlays(); + inOrder.verify(mockAdDisplayContainer).registerFriendlyObstruction(mockFriendlyObstruction); + inOrder.verify(mockAdDisplayContainer).unregisterAllFriendlyObstructions(); } - private void setupPlayback(Timeline contentTimeline, long[][] adDurationsUs, Float[] cuePoints) { - fakeExoPlayer = new FakePlayer(); - adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline, adDurationsUs); - when(mockAdsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints)); - imaAdsLoader = - new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext()) + @Test + public void loadAd_withLargeAdCuePoint_updatesAdPlaybackStateWithLoadedAd() { + float midrollTimeSecs = 1_765f; + ImmutableList cuePoints = ImmutableList.of(midrollTimeSecs); + setupPlayback(CONTENT_TIMELINE, cuePoints); + imaAdsLoader.start(adsLoaderListener, adViewProvider); + videoAdPlayer.loadAd( + TEST_AD_MEDIA_INFO, + new AdPodInfo() { + @Override + public int getTotalAds() { + return 1; + } + + @Override + public int getAdPosition() { + return 1; + } + + @Override + public boolean isBumper() { + return false; + } + + @Override + public double getMaxDuration() { + return 0; + } + + @Override + public int getPodIndex() { + return 0; + } + + @Override + public double getTimeOffset() { + return midrollTimeSecs; + } + }); + + assertThat(adsLoaderListener.adPlaybackState) + .isEqualTo( + AdPlaybackStateFactory.fromCuePoints(cuePoints) + .withContentDurationUs(CONTENT_PERIOD_DURATION_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, TEST_URI) + .withAdDurationsUs(new long[][] {{TEST_AD_DURATION_US}})); + } + + private void setupPlayback(Timeline contentTimeline, List cuePoints) { + setupPlayback( + contentTimeline, + cuePoints, + new ImaAdsLoader.Builder(getApplicationContext()) .setImaFactory(mockImaFactory) .setImaSdkSettings(mockImaSdkSettings) - .buildForAdTag(TEST_URI); + .buildForAdTag(TEST_URI)); + } + + private void setupPlayback( + Timeline contentTimeline, List cuePoints, ImaAdsLoader imaAdsLoader) { + fakeExoPlayer = new FakePlayer(); + adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline); + when(mockAdsManager.getAdCuePoints()).thenReturn(cuePoints); + this.imaAdsLoader = imaAdsLoader; imaAdsLoader.setPlayer(fakeExoPlayer); } @@ -278,9 +765,11 @@ public final class ImaAdsLoaderTest { ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class); doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture()); when(mockAdsRequest.getUserRequestContext()) - .thenAnswer((Answer) invocation -> userRequestContextCaptor.getValue()); + .thenAnswer(invocation -> userRequestContextCaptor.getValue()); List adsLoadedListeners = new ArrayList<>(); + // Deliberately don't handle removeAdsLoadedListener to allow testing behavior if the IMA SDK + // invokes callbacks after release. doAnswer( invocation -> { adsLoadedListeners.add(invocation.getArgument(0)); @@ -288,13 +777,6 @@ public final class ImaAdsLoaderTest { }) .when(mockAdsLoader) .addAdsLoadedListener(any()); - doAnswer( - invocation -> { - adsLoadedListeners.remove(invocation.getArgument(0)); - return null; - }) - .when(mockAdsLoader) - .removeAdsLoadedListener(any()); when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); when(mockAdsManagerLoadedEvent.getUserRequestContext()) .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); @@ -310,10 +792,41 @@ public final class ImaAdsLoaderTest { .when(mockAdsLoader) .requestAds(mockAdsRequest); - when(mockImaFactory.createAdDisplayContainer()).thenReturn(mockAdDisplayContainer); + doAnswer( + invocation -> { + adEventListener = invocation.getArgument(0); + return null; + }) + .when(mockAdsManager) + .addAdEventListener(any()); + + doAnswer( + invocation -> { + contentProgressProvider = invocation.getArgument(0); + return null; + }) + .when(mockAdsRequest) + .setContentProgressProvider(any()); + + doAnswer( + invocation -> { + videoAdPlayer = invocation.getArgument(1); + return mockAdDisplayContainer; + }) + .when(mockImaFactory) + .createAdDisplayContainer(any(), any()); + doAnswer( + invocation -> { + videoAdPlayer = invocation.getArgument(1); + return mockAdDisplayContainer; + }) + .when(mockImaFactory) + .createAudioAdDisplayContainer(any(), any()); when(mockImaFactory.createAdsRenderingSettings()).thenReturn(mockAdsRenderingSettings); when(mockImaFactory.createAdsRequest()).thenReturn(mockAdsRequest); when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(mockAdsLoader); + when(mockImaFactory.createFriendlyObstruction(any(), any(), any())) + .thenReturn(mockFriendlyObstruction); when(mockAdPodInfo.getPodIndex()).thenReturn(0); when(mockAdPodInfo.getTotalAds()).thenReturn(1); @@ -347,19 +860,21 @@ public final class ImaAdsLoaderTest { private final FakePlayer fakeExoPlayer; private final Timeline contentTimeline; - private final long[][] adDurationsUs; public AdPlaybackState adPlaybackState; - public TestAdsLoaderListener( - FakePlayer fakeExoPlayer, Timeline contentTimeline, long[][] adDurationsUs) { + public TestAdsLoaderListener(FakePlayer fakeExoPlayer, Timeline contentTimeline) { this.fakeExoPlayer = fakeExoPlayer; this.contentTimeline = contentTimeline; - this.adDurationsUs = adDurationsUs; } @Override public void onAdPlaybackState(AdPlaybackState adPlaybackState) { + long[][] adDurationsUs = new long[adPlaybackState.adGroupCount][]; + for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) { + adDurationsUs[adGroupIndex] = new long[adPlaybackState.adGroups[adGroupIndex].uris.length]; + Arrays.fill(adDurationsUs[adGroupIndex], TEST_AD_DURATION_US); + } adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); this.adPlaybackState = adPlaybackState; fakeExoPlayer.updateTimeline( diff --git a/extensions/jobdispatcher/README.md b/extensions/jobdispatcher/README.md index 613277bad2..9e26c07c5d 100644 --- a/extensions/jobdispatcher/README.md +++ b/extensions/jobdispatcher/README.md @@ -1,12 +1,10 @@ # ExoPlayer Firebase JobDispatcher extension # -**DEPRECATED - Please use [WorkManager extension][] or [PlatformScheduler][] -instead.** +**This extension is deprecated. Use the [WorkManager extension][] instead.** This extension provides a Scheduler implementation which uses [Firebase JobDispatcher][]. [WorkManager extension]: https://github.com/google/ExoPlayer/blob/release-v2/extensions/workmanager/README.md -[PlatformScheduler]: https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java [Firebase JobDispatcher]: https://github.com/firebase/firebase-jobdispatcher-android ## Getting the extension ## diff --git a/extensions/jobdispatcher/build.gradle b/extensions/jobdispatcher/build.gradle index 05ac82ba08..df50cde8f9 100644 --- a/extensions/jobdispatcher/build.gradle +++ b/extensions/jobdispatcher/build.gradle @@ -13,24 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java index 8841f8355f..b65988a5e2 100644 --- a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java +++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java @@ -60,11 +60,15 @@ import com.google.android.exoplayer2.util.Util; @Deprecated public final class JobDispatcherScheduler implements Scheduler { - private static final boolean DEBUG = false; private static final String TAG = "JobDispatcherScheduler"; private static final String KEY_SERVICE_ACTION = "service_action"; private static final String KEY_SERVICE_PACKAGE = "service_package"; private static final String KEY_REQUIREMENTS = "requirements"; + private static final int SUPPORTED_REQUIREMENTS = + Requirements.NETWORK + | Requirements.NETWORK_UNMETERED + | Requirements.DEVICE_IDLE + | Requirements.DEVICE_CHARGING; private final String jobTag; private final FirebaseJobDispatcher jobDispatcher; @@ -85,35 +89,44 @@ public final class JobDispatcherScheduler implements Scheduler { public boolean schedule(Requirements requirements, String servicePackage, String serviceAction) { Job job = buildJob(jobDispatcher, requirements, jobTag, servicePackage, serviceAction); int result = jobDispatcher.schedule(job); - logd("Scheduling job: " + jobTag + " result: " + result); return result == FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS; } @Override public boolean cancel() { int result = jobDispatcher.cancel(jobTag); - logd("Canceling job: " + jobTag + " result: " + result); return result == FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS; } + @Override + public Requirements getSupportedRequirements(Requirements requirements) { + return requirements.filterRequirements(SUPPORTED_REQUIREMENTS); + } + private static Job buildJob( FirebaseJobDispatcher dispatcher, Requirements requirements, String tag, String servicePackage, String serviceAction) { + Requirements filteredRequirements = requirements.filterRequirements(SUPPORTED_REQUIREMENTS); + if (!filteredRequirements.equals(requirements)) { + Log.w( + TAG, + "Ignoring unsupported requirements: " + + (filteredRequirements.getRequirements() ^ requirements.getRequirements())); + } + Job.Builder builder = dispatcher .newJobBuilder() .setService(JobDispatcherSchedulerService.class) // the JobService that will be called .setTag(tag); - if (requirements.isUnmeteredNetworkRequired()) { builder.addConstraint(Constraint.ON_UNMETERED_NETWORK); } else if (requirements.isNetworkRequired()) { builder.addConstraint(Constraint.ON_ANY_NETWORK); } - if (requirements.isIdleRequired()) { builder.addConstraint(Constraint.DEVICE_IDLE); } @@ -131,31 +144,20 @@ public final class JobDispatcherScheduler implements Scheduler { return builder.build(); } - private static void logd(String message) { - if (DEBUG) { - Log.d(TAG, message); - } - } - /** A {@link JobService} that starts the target service if the requirements are met. */ public static final class JobDispatcherSchedulerService extends JobService { @Override public boolean onStartJob(JobParameters params) { - logd("JobDispatcherSchedulerService is started"); - Bundle extras = params.getExtras(); - Assertions.checkNotNull(extras, "Service started without extras."); + Bundle extras = Assertions.checkNotNull(params.getExtras()); Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS)); - if (requirements.checkRequirements(this)) { - logd("Requirements are met"); - String serviceAction = extras.getString(KEY_SERVICE_ACTION); - String servicePackage = extras.getString(KEY_SERVICE_PACKAGE); - Assertions.checkNotNull(serviceAction, "Service action missing."); - Assertions.checkNotNull(servicePackage, "Service package missing."); + int notMetRequirements = requirements.getNotMetRequirements(this); + if (notMetRequirements == 0) { + String serviceAction = Assertions.checkNotNull(extras.getString(KEY_SERVICE_ACTION)); + String servicePackage = Assertions.checkNotNull(extras.getString(KEY_SERVICE_PACKAGE)); Intent intent = new Intent(serviceAction).setPackage(servicePackage); - logd("Starting service action: " + serviceAction + " package: " + servicePackage); Util.startForegroundService(this, intent); } else { - logd("Requirements are not met"); + Log.w(TAG, "Requirements not met: " + notMetRequirements); jobFinished(params, /* needsReschedule */ true); } return false; diff --git a/extensions/leanback/build.gradle b/extensions/leanback/build.gradle index 19b4cde3bf..14ced09f12 100644 --- a/extensions/leanback/build.gradle +++ b/extensions/leanback/build.gradle @@ -11,24 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion 17 - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +android.defaultConfig.minSdkVersion 17 dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java index e385cd52e9..6538160b8b 100644 --- a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java +++ b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java @@ -72,7 +72,7 @@ public final class LeanbackPlayerAdapter extends PlayerAdapter implements Runnab this.context = context; this.player = player; this.updatePeriodMs = updatePeriodMs; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentOrMainLooper(); componentListener = new ComponentListener(); controlDispatcher = new DefaultControlDispatcher(); } diff --git a/extensions/media2/README.md b/extensions/media2/README.md new file mode 100644 index 0000000000..32ea864940 --- /dev/null +++ b/extensions/media2/README.md @@ -0,0 +1,53 @@ +# ExoPlayer Media2 extension # + +The Media2 extension provides builders for [SessionPlayer][] and [MediaSession.SessionCallback][] in +the [Media2 library][]. + +Compared to [MediaSessionConnector][] that uses [MediaSessionCompat][], this provides finer grained +control for incoming calls, so you can selectively allow/reject commands per controller. + +## Getting the extension ## + +The easiest way to use the extension is to add it as a gradle dependency: + +```gradle +implementation 'com.google.android.exoplayer:extension-media2:2.X.X' +``` + +where `2.X.X` is the version, which must match the version of the ExoPlayer +library being used. + +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][]. + +[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md + +## Using the extension ## + +### Using `SessionPlayerConnector` ### + +`SessionPlayerConnector` is a [SessionPlayer][] implementation wrapping a given `Player`. +You can use a [SessionPlayer][] instance to build a [MediaSession][], or to set the player +associated with a [VideoView][] or [MediaControlView][] + +### Using `SessionCallbackBuilder` ### + +`SessionCallbackBuilder` lets you build a [MediaSession.SessionCallback][] instance given its +collaborators. You can use a [MediaSession.SessionCallback][] to build a [MediaSession][]. + +## Links ## + +* [Javadoc][]: Classes matching + `com.google.android.exoplayer2.ext.media2.*` belong to this module. + +[Javadoc]: https://exoplayer.dev/doc/reference/index.html + +[SessionPlayer]: https://developer.android.com/reference/androidx/media2/common/SessionPlayer +[MediaSession]: https://developer.android.com/reference/androidx/media2/session/MediaSession +[MediaSession.SessionCallback]: https://developer.android.com/reference/androidx/media2/session/MediaSession.SessionCallback +[Media2 library]: https://developer.android.com/jetpack/androidx/releases/media2 +[MediaSessionCompat]: https://developer.android.com/reference/android/support/v4/media/session/MediaSessionCompat +[MediaSessionConnector]: https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.html +[VideoView]: https://developer.android.com/reference/androidx/media2/widget/VideoView +[MediaControlView]: https://developer.android.com/reference/androidx/media2/widget/MediaControlView diff --git a/extensions/media2/build.gradle b/extensions/media2/build.gradle new file mode 100644 index 0000000000..744d79980b --- /dev/null +++ b/extensions/media2/build.gradle @@ -0,0 +1,49 @@ +// Copyright 2019 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" + +android.defaultConfig.minSdkVersion 19 + +dependencies { + implementation project(modulePrefix + 'library-core') + implementation 'androidx.collection:collection:' + androidxCollectionVersion + implementation 'androidx.concurrent:concurrent-futures:1.1.0' + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } + api 'androidx.media2:media2-session:1.0.3' + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion + androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion + androidTestImplementation 'androidx.test:core:' + androidxTestCoreVersion + androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion + androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion + androidTestImplementation 'com.google.truth:truth:' + truthVersion +} + +ext { + javadocTitle = 'Media2 extension' +} +apply from: '../../javadoc_library.gradle' + +ext { + releaseArtifact = 'extension-media2' + releaseDescription = 'Media2 extension for ExoPlayer.' +} +apply from: '../../publish.gradle' diff --git a/extensions/media2/src/androidTest/AndroidManifest.xml b/extensions/media2/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..b699de67b1 --- /dev/null +++ b/extensions/media2/src/androidTest/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java new file mode 100644 index 0000000000..8cf586b846 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.content.Context; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; +import androidx.annotation.NonNull; +import androidx.media2.common.SessionPlayer; +import androidx.media2.common.SessionPlayer.PlayerResult; +import androidx.media2.session.MediaSession; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import com.google.android.exoplayer2.ext.media2.test.R; +import com.google.android.exoplayer2.util.Assertions; +import com.google.common.util.concurrent.ListenableFuture; +import java.util.concurrent.CountDownLatch; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link MediaSessionUtil} */ +@RunWith(AndroidJUnit4.class) +public class MediaSessionUtilTest { + private static final int PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000; + + @Rule public final PlayerTestRule playerTestRule = new PlayerTestRule(); + + @Test + public void getSessionCompatToken_withMediaControllerCompat_returnsValidToken() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + + SessionPlayerConnector sessionPlayerConnector = playerTestRule.getSessionPlayerConnector(); + MediaSession.SessionCallback sessionCallback = + new SessionCallbackBuilder(context, sessionPlayerConnector).build(); + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + ListenableFuture prepareResult = sessionPlayerConnector.prepare(); + CountDownLatch latch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + playerTestRule.getExecutor(), + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + if (playerState == SessionPlayer.PLAYER_STATE_PLAYING) { + latch.countDown(); + } + } + }); + + MediaSession session2 = + new MediaSession.Builder(context, sessionPlayerConnector) + .setSessionCallback(playerTestRule.getExecutor(), sessionCallback) + .build(); + + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + try { + MediaSessionCompat.Token token = + Assertions.checkNotNull(MediaSessionUtil.getSessionCompatToken(session2)); + MediaControllerCompat controllerCompat = new MediaControllerCompat(context, token); + controllerCompat.getTransportControls().play(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + assertThat(prepareResult.get(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS).getResultCode()) + .isEqualTo(PlayerResult.RESULT_SUCCESS); + assertThat(latch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java new file mode 100644 index 0000000000..23a4491389 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java @@ -0,0 +1,83 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.WindowManager; +import com.google.android.exoplayer2.ext.media2.test.R; +import com.google.android.exoplayer2.util.Util; + +/** Stub activity to play media contents on. */ +public final class MediaStubActivity extends Activity { + + private static final String TAG = "MediaStubActivity"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.mediaplayer); + + // disable enter animation. + overridePendingTransition(0, 0); + + if (Util.SDK_INT >= 27) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + setTurnScreenOn(true); + setShowWhenLocked(true); + KeyguardManager keyguardManager = + (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + keyguardManager.requestDismissKeyguard(this, null); + } else { + getWindow() + .addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD); + } + } + + @Override + public void finish() { + super.finish(); + + // disable exit animation. + overridePendingTransition(0, 0); + } + + @Override + protected void onResume() { + Log.i(TAG, "onResume"); + super.onResume(); + } + + @Override + protected void onPause() { + Log.i(TAG, "onPause"); + super.onPause(); + } + + public SurfaceHolder getSurfaceHolder() { + SurfaceView surface = findViewById(R.id.surface); + return surface.getHolder(); + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java new file mode 100644 index 0000000000..df6963c2fc --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java @@ -0,0 +1,185 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import android.content.Context; +import android.net.Uri; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.platform.app.InstrumentationRegistry; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.TransferListener; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.rules.ExternalResource; + +/** Rule for tests that use {@link SessionPlayerConnector}. */ +/* package */ final class PlayerTestRule extends ExternalResource { + + /** Instrumentation to attach to {@link DataSource} instances used by the player. */ + public interface DataSourceInstrumentation { + + /** Called at the start of {@link DataSource#open}. */ + void onPreOpen(DataSpec dataSpec); + } + + private Context context; + private ExecutorService executor; + + private SessionPlayerConnector sessionPlayerConnector; + private SimpleExoPlayer exoPlayer; + @Nullable private DataSourceInstrumentation dataSourceInstrumentation; + + @Override + protected void before() { + // Workaround limitation in androidx.media2.session:1.0.3 which session can only be instantiated + // on thread with prepared Looper. + // TODO: Remove when androidx.media2.session:1.1.0 is released without the limitation + // [Internal: b/146536708] + if (Looper.myLooper() == null) { + Looper.prepare(); + } + + context = ApplicationProvider.getApplicationContext(); + executor = Executors.newFixedThreadPool(1); + + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + // Initialize AudioManager on the main thread to workaround that + // audio focus listener is called on the thread where the AudioManager was + // originally initialized. [Internal: b/78617702] + // Without posting this, audio focus listeners wouldn't be called because the + // listeners would be posted to the test thread (here) where it waits until the + // tests are finished. + context.getSystemService(Context.AUDIO_SERVICE); + + DataSource.Factory dataSourceFactory = new InstrumentingDataSourceFactory(context); + exoPlayer = + new SimpleExoPlayer.Builder(context) + .setLooper(Looper.myLooper()) + .setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory)) + .build(); + sessionPlayerConnector = new SessionPlayerConnector(exoPlayer); + }); + } + + @Override + protected void after() { + if (sessionPlayerConnector != null) { + sessionPlayerConnector.close(); + sessionPlayerConnector = null; + } + if (exoPlayer != null) { + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + exoPlayer.release(); + exoPlayer = null; + }); + } + if (executor != null) { + executor.shutdown(); + executor = null; + } + } + + public void setDataSourceInstrumentation( + @Nullable DataSourceInstrumentation dataSourceInstrumentation) { + this.dataSourceInstrumentation = dataSourceInstrumentation; + } + + public ExecutorService getExecutor() { + return executor; + } + + public SessionPlayerConnector getSessionPlayerConnector() { + return sessionPlayerConnector; + } + + public SimpleExoPlayer getSimpleExoPlayer() { + return exoPlayer; + } + + private final class InstrumentingDataSourceFactory implements DataSource.Factory { + + private final DefaultDataSourceFactory defaultDataSourceFactory; + + public InstrumentingDataSourceFactory(Context context) { + defaultDataSourceFactory = new DefaultDataSourceFactory(context); + } + + @Override + public DataSource createDataSource() { + DataSource dataSource = defaultDataSourceFactory.createDataSource(); + return dataSourceInstrumentation == null + ? dataSource + : new InstrumentedDataSource(dataSource, dataSourceInstrumentation); + } + } + + private static final class InstrumentedDataSource implements DataSource { + + private final DataSource wrappedDataSource; + private final DataSourceInstrumentation instrumentation; + + public InstrumentedDataSource( + DataSource wrappedDataSource, DataSourceInstrumentation instrumentation) { + this.wrappedDataSource = wrappedDataSource; + this.instrumentation = instrumentation; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + wrappedDataSource.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + instrumentation.onPreOpen(dataSpec); + return wrappedDataSource.open(dataSpec); + } + + @Nullable + @Override + public Uri getUri() { + return wrappedDataSource.getUri(); + } + + @Override + public Map> getResponseHeaders() { + return wrappedDataSource.getResponseHeaders(); + } + + @Override + public int read(byte[] target, int offset, int length) throws IOException { + return wrappedDataSource.read(target, offset, length); + } + + @Override + public void close() throws IOException { + wrappedDataSource.close(); + } + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java new file mode 100644 index 0000000000..c578b0ba8c --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java @@ -0,0 +1,680 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResultSuccess; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.media2.common.MediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.Rating; +import androidx.media2.common.SessionPlayer; +import androidx.media2.common.UriMediaItem; +import androidx.media2.session.HeartRating; +import androidx.media2.session.MediaController; +import androidx.media2.session.MediaSession; +import androidx.media2.session.SessionCommand; +import androidx.media2.session.SessionCommandGroup; +import androidx.media2.session.SessionResult; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.media2.test.R; +import com.google.android.exoplayer2.upstream.RawResourceDataSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests {@link SessionCallbackBuilder}. */ +@RunWith(AndroidJUnit4.class) +public class SessionCallbackBuilderTest { + @Rule + public final ActivityTestRule activityRule = + new ActivityTestRule<>(MediaStubActivity.class); + + @Rule public final PlayerTestRule playerTestRule = new PlayerTestRule(); + + private static final String MEDIA_SESSION_ID = SessionCallbackBuilderTest.class.getSimpleName(); + private static final long CONTROLLER_COMMAND_WAIT_TIME_MS = 3_000; + private static final long PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS = 10_000; + private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000; + + private Context context; + private Executor executor; + private SessionPlayerConnector sessionPlayerConnector; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + executor = playerTestRule.getExecutor(); + sessionPlayerConnector = playerTestRule.getSessionPlayerConnector(); + + // Sets the surface to the player for manual check. + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + SimpleExoPlayer exoPlayer = playerTestRule.getSimpleExoPlayer(); + exoPlayer + .getVideoComponent() + .setVideoSurfaceHolder(activityRule.getActivity().getSurfaceHolder()); + }); + } + + @Test + public void constructor() throws Exception { + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector).build())) { + assertPlayerResultSuccess(sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem())); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + OnConnectedListener listener = + (controller, allowedCommands) -> { + List disallowedCommandCodes = + Arrays.asList( + SessionCommand.COMMAND_CODE_SESSION_SET_RATING, // no rating callback + SessionCommand.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM, // no media item provider + SessionCommand + .COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM, // no media item provider + SessionCommand.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, // no media item provider + SessionCommand.COMMAND_CODE_PLAYER_SET_PLAYLIST, // no media item provider + SessionCommand.COMMAND_CODE_SESSION_REWIND, // no current media item + SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD // no current media item + ); + assertDisallowedCommands(disallowedCommandCodes, allowedCommands); + }; + try (MediaController controller = createConnectedController(session, listener, null)) { + assertThat(controller.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + } + } + } + + @Test + public void allowedCommand_withoutPlaylist_disallowsSkipTo() throws Exception { + int testRewindIncrementMs = 100; + int testFastForwardIncrementMs = 100; + + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setRatingCallback( + (mediaSession, controller, mediaId, rating) -> + SessionResult.RESULT_ERROR_BAD_VALUE) + .setRewindIncrementMs(testRewindIncrementMs) + .setFastForwardIncrementMs(testFastForwardIncrementMs) + .setMediaItemProvider(new SessionCallbackBuilder.MediaIdMediaItemProvider()) + .build())) { + assertPlayerResultSuccess(sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem())); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch latch = new CountDownLatch(1); + OnConnectedListener listener = + (controller, allowedCommands) -> { + List disallowedCommandCodes = + Arrays.asList( + SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM, + SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM, + SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM); + assertDisallowedCommands(disallowedCommandCodes, allowedCommands); + latch.countDown(); + }; + try (MediaController controller = createConnectedController(session, listener, null)) { + assertThat(latch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + + assertSessionResultFailure(controller.skipToNextPlaylistItem()); + assertSessionResultFailure(controller.skipToPreviousPlaylistItem()); + assertSessionResultFailure(controller.skipToPlaylistItem(0)); + } + } + } + + @Test + public void allowedCommand_whenPlaylistSet_allowsSkipTo() throws Exception { + List testPlaylist = new ArrayList<>(); + testPlaylist.add(TestUtils.createMediaItem(R.raw.video_desks)); + testPlaylist.add(TestUtils.createMediaItem(R.raw.video_not_seekable)); + int testRewindIncrementMs = 100; + int testFastForwardIncrementMs = 100; + + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setRatingCallback( + (mediaSession, controller, mediaId, rating) -> + SessionResult.RESULT_ERROR_BAD_VALUE) + .setRewindIncrementMs(testRewindIncrementMs) + .setFastForwardIncrementMs(testFastForwardIncrementMs) + .setMediaItemProvider(new SessionCallbackBuilder.MediaIdMediaItemProvider()) + .build())) { + + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(testPlaylist, null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + OnConnectedListener connectedListener = + (controller, allowedCommands) -> { + List allowedCommandCodes = + Arrays.asList( + SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM, + SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO, + SessionCommand.COMMAND_CODE_SESSION_REWIND, + SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD); + assertAllowedCommands(allowedCommandCodes, allowedCommands); + + List disallowedCommandCodes = + Arrays.asList(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM); + assertDisallowedCommands(disallowedCommandCodes, allowedCommands); + }; + + CountDownLatch allowedCommandChangedLatch = new CountDownLatch(1); + OnAllowedCommandsChangedListener allowedCommandChangedListener = + (controller, allowedCommands) -> { + List allowedCommandCodes = + Arrays.asList(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM); + assertAllowedCommands(allowedCommandCodes, allowedCommands); + + List disallowedCommandCodes = + Arrays.asList( + SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM, + SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO, + SessionCommand.COMMAND_CODE_SESSION_REWIND, + SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD); + assertDisallowedCommands(disallowedCommandCodes, allowedCommands); + allowedCommandChangedLatch.countDown(); + }; + try (MediaController controller = + createConnectedController(session, connectedListener, allowedCommandChangedListener)) { + assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem()); + + assertThat(allowedCommandChangedLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + + // Also test whether the rewind fails as expected. + assertSessionResultFailure(controller.rewind()); + assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0); + assertThat(controller.getCurrentPosition()).isEqualTo(0); + } + } + } + + @Test + public void allowedCommand_afterCurrentMediaItemPrepared_notifiesSeekToAvailable() + throws Exception { + List testPlaylist = new ArrayList<>(); + testPlaylist.add(TestUtils.createMediaItem(R.raw.video_desks)); + UriMediaItem secondPlaylistItem = TestUtils.createMediaItem(R.raw.video_big_buck_bunny); + testPlaylist.add(secondPlaylistItem); + + CountDownLatch readAllowedLatch = new CountDownLatch(1); + playerTestRule.setDataSourceInstrumentation( + dataSpec -> { + if (dataSpec.uri.equals(secondPlaylistItem.getUri())) { + try { + assertThat(readAllowedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } catch (Exception e) { + assertWithMessage("Unexpected exception %s", e).fail(); + } + } + }); + + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector).build())) { + + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(testPlaylist, null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch seekToAllowedForSecondMediaItem = new CountDownLatch(1); + OnAllowedCommandsChangedListener allowedCommandsChangedListener = + (controller, allowedCommands) -> { + if (allowedCommands.hasCommand(SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO) + && controller.getCurrentMediaItemIndex() == 1) { + seekToAllowedForSecondMediaItem.countDown(); + } + }; + try (MediaController controller = + createConnectedController( + session, /* onConnectedListener= */ null, allowedCommandsChangedListener)) { + assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem()); + + readAllowedLatch.countDown(); + assertThat( + seekToAllowedForSecondMediaItem.await( + CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } + } + } + + @Test + public void setRatingCallback_withRatingCallback_receivesRatingCallback() throws Exception { + String testMediaId = "testRating"; + Rating testRating = new HeartRating(true); + CountDownLatch latch = new CountDownLatch(1); + + SessionCallbackBuilder.RatingCallback ratingCallback = + (session, controller, mediaId, rating) -> { + assertThat(mediaId).isEqualTo(testMediaId); + assertThat(rating).isEqualTo(testRating); + latch.countDown(); + return SessionResult.RESULT_SUCCESS; + }; + + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setRatingCallback(ratingCallback) + .build())) { + try (MediaController controller = createConnectedController(session)) { + assertSessionResultSuccess( + controller.setRating(testMediaId, testRating), CONTROLLER_COMMAND_WAIT_TIME_MS); + assertThat(latch.await(0, MILLISECONDS)).isTrue(); + } + } + } + + @Test + public void setCustomCommandProvider_withCustomCommandProvider_receivesCustomCommand() + throws Exception { + SessionCommand testCommand = new SessionCommand("exo.ext.media2.COMMAND", null); + CountDownLatch latch = new CountDownLatch(1); + + SessionCallbackBuilder.CustomCommandProvider provider = + new SessionCallbackBuilder.CustomCommandProvider() { + @Override + public SessionResult onCustomCommand( + MediaSession session, + MediaSession.ControllerInfo controllerInfo, + SessionCommand customCommand, + @Nullable Bundle args) { + assertThat(customCommand.getCustomAction()).isEqualTo(testCommand.getCustomAction()); + assertThat(args).isNull(); + latch.countDown(); + return new SessionResult(SessionResult.RESULT_SUCCESS, null); + } + + @Override + public SessionCommandGroup getCustomCommands( + MediaSession session, MediaSession.ControllerInfo controllerInfo) { + return new SessionCommandGroup.Builder().addCommand(testCommand).build(); + } + }; + + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setCustomCommandProvider(provider) + .build())) { + OnAllowedCommandsChangedListener listener = + (controller, allowedCommands) -> { + boolean foundCustomCommand = false; + for (SessionCommand command : allowedCommands.getCommands()) { + if (TextUtils.equals(testCommand.getCustomAction(), command.getCustomAction())) { + foundCustomCommand = true; + break; + } + } + assertThat(foundCustomCommand).isTrue(); + }; + try (MediaController controller = createConnectedController(session, null, listener)) { + assertSessionResultSuccess( + controller.sendCustomCommand(testCommand, null), CONTROLLER_COMMAND_WAIT_TIME_MS); + assertThat(latch.await(0, MILLISECONDS)).isTrue(); + } + } + } + + @LargeTest + @Test + public void setRewindIncrementMs_withPositiveRewindIncrement_rewinds() throws Exception { + int testResId = R.raw.video_big_buck_bunny; + int testDuration = 10_000; + int tolerance = 100; + int testSeekPosition = 2_000; + int testRewindIncrementMs = 500; + + TestUtils.loadResource(testResId, sessionPlayerConnector); + + // seekTo() sometimes takes couple of seconds. Disable default timeout behavior. + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setRewindIncrementMs(testRewindIncrementMs) + .setSeekTimeoutMs(0) + .build())) { + try (MediaController controller = createConnectedController(session)) { + // Prepare first to ensure that seek() works. + assertSessionResultSuccess( + controller.prepare(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS); + + assertThat((float) sessionPlayerConnector.getDuration()) + .isWithin(tolerance) + .of(testDuration); + assertSessionResultSuccess( + controller.seekTo(testSeekPosition), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS); + assertThat((float) sessionPlayerConnector.getCurrentPosition()) + .isWithin(tolerance) + .of(testSeekPosition); + + // Test rewind + assertSessionResultSuccess( + controller.rewind(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS); + assertThat((float) sessionPlayerConnector.getCurrentPosition()) + .isWithin(tolerance) + .of(testSeekPosition - testRewindIncrementMs); + } + } + } + + @LargeTest + @Test + public void setFastForwardIncrementMs_withPositiveFastForwardIncrement_fastsForward() + throws Exception { + int testResId = R.raw.video_big_buck_bunny; + int testDuration = 10_000; + int tolerance = 100; + int testSeekPosition = 2_000; + int testFastForwardIncrementMs = 300; + + TestUtils.loadResource(testResId, sessionPlayerConnector); + + // seekTo() sometimes takes couple of seconds. Disable default timeout behavior. + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setFastForwardIncrementMs(testFastForwardIncrementMs) + .setSeekTimeoutMs(0) + .build())) { + try (MediaController controller = createConnectedController(session)) { + // Prepare first to ensure that seek() works. + assertSessionResultSuccess( + controller.prepare(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS); + + assertThat((float) sessionPlayerConnector.getDuration()) + .isWithin(tolerance) + .of(testDuration); + assertSessionResultSuccess( + controller.seekTo(testSeekPosition), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS); + assertThat((float) sessionPlayerConnector.getCurrentPosition()) + .isWithin(tolerance) + .of(testSeekPosition); + + // Test fast-forward + assertSessionResultSuccess( + controller.fastForward(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS); + assertThat((float) sessionPlayerConnector.getCurrentPosition()) + .isWithin(tolerance) + .of(testSeekPosition + testFastForwardIncrementMs); + } + } + } + + @Test + public void setMediaItemProvider_withMediaItemProvider_receivesOnCreateMediaItem() + throws Exception { + Uri testMediaUri = RawResourceDataSource.buildRawResourceUri(R.raw.audio); + + CountDownLatch providerLatch = new CountDownLatch(1); + SessionCallbackBuilder.MediaIdMediaItemProvider mediaIdMediaItemProvider = + new SessionCallbackBuilder.MediaIdMediaItemProvider(); + SessionCallbackBuilder.MediaItemProvider provider = + (session, controllerInfo, mediaId) -> { + assertThat(mediaId).isEqualTo(testMediaUri.toString()); + providerLatch.countDown(); + return mediaIdMediaItemProvider.onCreateMediaItem(session, controllerInfo, mediaId); + }; + + CountDownLatch currentMediaItemChangedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + MediaMetadata metadata = item.getMetadata(); + assertThat(metadata.getString(MediaMetadata.METADATA_KEY_MEDIA_ID)) + .isEqualTo(testMediaUri.toString()); + currentMediaItemChangedLatch.countDown(); + } + }); + + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setMediaItemProvider(provider) + .build())) { + try (MediaController controller = createConnectedController(session)) { + assertSessionResultSuccess( + controller.setMediaItem(testMediaUri.toString()), + PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS); + assertThat(providerLatch.await(0, MILLISECONDS)).isTrue(); + assertThat( + currentMediaItemChangedLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } + } + } + + @Test + public void setSkipCallback_withSkipBackward_receivesOnSkipBackward() throws Exception { + CountDownLatch skipBackwardCalledLatch = new CountDownLatch(1); + SessionCallbackBuilder.SkipCallback skipCallback = + new SessionCallbackBuilder.SkipCallback() { + @Override + public int onSkipBackward( + MediaSession session, MediaSession.ControllerInfo controllerInfo) { + skipBackwardCalledLatch.countDown(); + return SessionResult.RESULT_SUCCESS; + } + + @Override + public int onSkipForward( + MediaSession session, MediaSession.ControllerInfo controllerInfo) { + return SessionResult.RESULT_ERROR_NOT_SUPPORTED; + } + }; + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setSkipCallback(skipCallback) + .build())) { + try (MediaController controller = createConnectedController(session)) { + assertSessionResultSuccess(controller.skipBackward(), CONTROLLER_COMMAND_WAIT_TIME_MS); + assertThat(skipBackwardCalledLatch.await(0, MILLISECONDS)).isTrue(); + } + } + } + + @Test + public void setSkipCallback_withSkipForward_receivesOnSkipForward() throws Exception { + CountDownLatch skipForwardCalledLatch = new CountDownLatch(1); + SessionCallbackBuilder.SkipCallback skipCallback = + new SessionCallbackBuilder.SkipCallback() { + @Override + public int onSkipBackward( + MediaSession session, MediaSession.ControllerInfo controllerInfo) { + return SessionResult.RESULT_ERROR_NOT_SUPPORTED; + } + + @Override + public int onSkipForward( + MediaSession session, MediaSession.ControllerInfo controllerInfo) { + skipForwardCalledLatch.countDown(); + return SessionResult.RESULT_SUCCESS; + } + }; + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setSkipCallback(skipCallback) + .build())) { + try (MediaController controller = createConnectedController(session)) { + assertSessionResultSuccess(controller.skipForward(), CONTROLLER_COMMAND_WAIT_TIME_MS); + assertThat(skipForwardCalledLatch.await(0, MILLISECONDS)).isTrue(); + } + } + } + + @Test + public void setPostConnectCallback_afterConnect_receivesOnPostConnect() throws Exception { + CountDownLatch postConnectLatch = new CountDownLatch(1); + SessionCallbackBuilder.PostConnectCallback postConnectCallback = + (session, controllerInfo) -> postConnectLatch.countDown(); + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setPostConnectCallback(postConnectCallback) + .build())) { + try (MediaController controller = createConnectedController(session)) { + assertThat(postConnectLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + } + } + + @Test + public void setDisconnectedCallback_afterDisconnect_receivesOnDisconnected() throws Exception { + CountDownLatch disconnectedLatch = new CountDownLatch(1); + SessionCallbackBuilder.DisconnectedCallback disconnectCallback = + (session, controllerInfo) -> disconnectedLatch.countDown(); + try (MediaSession session = + createMediaSession( + sessionPlayerConnector, + new SessionCallbackBuilder(context, sessionPlayerConnector) + .setDisconnectedCallback(disconnectCallback) + .build())) { + try (MediaController controller = createConnectedController(session)) {} + assertThat(disconnectedLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + } + + private MediaSession createMediaSession( + SessionPlayer sessionPlayer, MediaSession.SessionCallback callback) { + return new MediaSession.Builder(context, sessionPlayer) + .setSessionCallback(executor, callback) + .setId(MEDIA_SESSION_ID) + .build(); + } + + private MediaController createConnectedController(MediaSession session) throws Exception { + return createConnectedController(session, null, null); + } + + private MediaController createConnectedController( + MediaSession session, + OnConnectedListener onConnectedListener, + OnAllowedCommandsChangedListener onAllowedCommandsChangedListener) + throws Exception { + CountDownLatch latch = new CountDownLatch(1); + MediaController.ControllerCallback callback = + new MediaController.ControllerCallback() { + @Override + public void onAllowedCommandsChanged( + @NonNull MediaController controller, @NonNull SessionCommandGroup commands) { + if (onAllowedCommandsChangedListener != null) { + onAllowedCommandsChangedListener.onAllowedCommandsChanged(controller, commands); + } + } + + @Override + public void onConnected( + @NonNull MediaController controller, @NonNull SessionCommandGroup allowedCommands) { + if (onConnectedListener != null) { + onConnectedListener.onConnected(controller, allowedCommands); + } + latch.countDown(); + } + }; + MediaController controller = + new MediaController.Builder(context) + .setSessionToken(session.getToken()) + .setControllerCallback(ContextCompat.getMainExecutor(context), callback) + .build(); + latch.await(); + return controller; + } + + private static void assertSessionResultSuccess(Future future) throws Exception { + assertSessionResultSuccess(future, CONTROLLER_COMMAND_WAIT_TIME_MS); + } + + private static void assertSessionResultSuccess(Future future, long timeoutMs) + throws Exception { + SessionResult result = future.get(timeoutMs, MILLISECONDS); + assertThat(result.getResultCode()).isEqualTo(SessionResult.RESULT_SUCCESS); + } + + private static void assertSessionResultFailure(Future future) throws Exception { + SessionResult result = future.get(PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS, MILLISECONDS); + assertThat(result.getResultCode()).isNotEqualTo(SessionResult.RESULT_SUCCESS); + } + + private static void assertAllowedCommands( + List expectedAllowedCommandsCode, SessionCommandGroup allowedCommands) { + for (int commandCode : expectedAllowedCommandsCode) { + assertWithMessage("Command should be allowed, code=" + commandCode) + .that(allowedCommands.hasCommand(commandCode)) + .isTrue(); + } + } + + private static void assertDisallowedCommands( + List expectedDisallowedCommandsCode, SessionCommandGroup allowedCommands) { + for (int commandCode : expectedDisallowedCommandsCode) { + assertWithMessage("Command shouldn't be allowed, code=" + commandCode) + .that(allowedCommands.hasCommand(commandCode)) + .isFalse(); + } + } + + private interface OnAllowedCommandsChangedListener { + void onAllowedCommandsChanged(MediaController controller, SessionCommandGroup allowedCommands); + } + + private interface OnConnectedListener { + void onConnected(MediaController controller, SessionCommandGroup allowedCommands); + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java new file mode 100644 index 0000000000..b80cbe5a5f --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java @@ -0,0 +1,1301 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static androidx.media2.common.SessionPlayer.PLAYER_STATE_PAUSED; +import static androidx.media2.common.SessionPlayer.PLAYER_STATE_PLAYING; +import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_INFO_SKIPPED; +import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_SUCCESS; +import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResult; +import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResultSuccess; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.content.Context; +import android.media.AudioManager; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; +import androidx.media.AudioAttributesCompat; +import androidx.media2.common.MediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.SessionPlayer; +import androidx.media2.common.SessionPlayer.PlayerResult; +import androidx.media2.common.UriMediaItem; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.filters.MediumTest; +import androidx.test.filters.SdkSuppress; +import androidx.test.filters.SmallTest; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.media2.test.R; +import com.google.android.exoplayer2.upstream.RawResourceDataSource; +import com.google.common.util.concurrent.ListenableFuture; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests {@link SessionPlayerConnector}. */ +@SuppressWarnings("FutureReturnValueIgnored") +@RunWith(AndroidJUnit4.class) +public class SessionPlayerConnectorTest { + @Rule + public final ActivityTestRule activityRule = + new ActivityTestRule<>(MediaStubActivity.class); + + @Rule public final PlayerTestRule playerTestRule = new PlayerTestRule(); + + private static final long PLAYLIST_CHANGE_WAIT_TIME_MS = 1_000; + private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000; + private static final long PLAYBACK_COMPLETED_WAIT_TIME_MS = 20_000; + private static final float FLOAT_TOLERANCE = .0001f; + + private Context context; + private Executor executor; + private SessionPlayerConnector sessionPlayerConnector; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + executor = playerTestRule.getExecutor(); + sessionPlayerConnector = playerTestRule.getSessionPlayerConnector(); + + // Sets the surface to the player for manual check. + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + SimpleExoPlayer exoPlayer = playerTestRule.getSimpleExoPlayer(); + exoPlayer + .getVideoComponent() + .setVideoSurfaceHolder(activityRule.getActivity().getSurfaceHolder()); + }); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_onceWithAudioResource_changesPlayerStateToPlaying() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build(); + sessionPlayerConnector.setAudioAttributes(attributes); + + CountDownLatch onPlayingLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PLAYING) { + onPlayingLatch.countDown(); + } + } + }); + + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + assertThat(onPlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + + @Test + @MediumTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_onceWithAudioResourceOnMainThread_notifiesOnPlayerStateChanged() + throws Exception { + CountDownLatch onPlayerStatePlayingLatch = new CountDownLatch(1); + + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + try { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + } catch (Exception e) { + assertWithMessage(e.getMessage()).fail(); + } + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder() + .setLegacyStreamType(AudioManager.STREAM_MUSIC) + .build(); + sessionPlayerConnector.setAudioAttributes(attributes); + + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged( + @NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PLAYING) { + onPlayerStatePlayingLatch.countDown(); + } + } + }); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + }); + assertThat(onPlayerStatePlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_withCustomControlDispatcher_isSkipped() throws Exception { + if (Looper.myLooper() == null) { + Looper.prepare(); + } + + ControlDispatcher controlDispatcher = + new DefaultControlDispatcher() { + @Override + public boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady) { + return false; + } + }; + SimpleExoPlayer simpleExoPlayer = null; + SessionPlayerConnector playerConnector = null; + try { + simpleExoPlayer = + new SimpleExoPlayer.Builder(context) + .setLooper(Looper.myLooper()) + .build(); + playerConnector = + new SessionPlayerConnector(simpleExoPlayer, new DefaultMediaItemConverter()); + playerConnector.setControlDispatcher(controlDispatcher); + assertPlayerResult(playerConnector.play(), RESULT_INFO_SKIPPED); + } finally { + if (playerConnector != null) { + playerConnector.close(); + } + if (simpleExoPlayer != null) { + simpleExoPlayer.release(); + } + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setMediaItem_withAudioResource_notifiesOnPlaybackCompleted() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + + // waiting to complete + assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setMediaItem_withVideoResource_notifiesOnPlaybackCompleted() throws Exception { + TestUtils.loadResource(R.raw.video_desks, sessionPlayerConnector); + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + + // waiting to complete + assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getDuration_whenIdleState_returnsUnknownTime() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertThat(sessionPlayerConnector.getDuration()).isEqualTo(SessionPlayer.UNKNOWN_TIME); + } + + @Test + @MediumTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getDuration_afterPrepared_returnsDuration() throws Exception { + TestUtils.loadResource(R.raw.video_desks, sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + assertThat((float) sessionPlayerConnector.getDuration()).isWithin(50).of(5130); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getCurrentPosition_whenIdleState_returnsDefaultPosition() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getBufferedPosition_whenIdleState_returnsDefaultPosition() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertThat(sessionPlayerConnector.getBufferedPosition()).isEqualTo(0); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getPlaybackSpeed_whenIdleState_throwsNoException() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + try { + sessionPlayerConnector.getPlaybackSpeed(); + } catch (Exception e) { + assertWithMessage(e.getMessage()).fail(); + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_withDataSourceCallback_changesPlayerState() throws Exception { + sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(R.raw.video_big_buck_bunny)); + sessionPlayerConnector.prepare(); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + + // Test pause and restart. + assertPlayerResultSuccess(sessionPlayerConnector.pause()); + assertThat(sessionPlayerConnector.getPlayerState()).isNotEqualTo(PLAYER_STATE_PLAYING); + + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setMediaItem_withNullMediaItem_throwsException() { + try { + sessionPlayerConnector.setMediaItem(null); + assertWithMessage("Null media item should be rejected").fail(); + } catch (NullPointerException e) { + // Expected exception + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_afterPlayback_remainsSame() throws Exception { + int resId1 = R.raw.video_big_buck_bunny; + MediaItem mediaItem1 = + new UriMediaItem.Builder(RawResourceDataSource.buildRawResourceUri(resId1)) + .setStartPosition(6_000) + .setEndPosition(7_000) + .build(); + + MediaItem mediaItem2 = + new UriMediaItem.Builder(RawResourceDataSource.buildRawResourceUri(resId1)) + .setStartPosition(3_000) + .setEndPosition(4_000) + .build(); + + List items = new ArrayList<>(); + items.add(mediaItem1); + items.add(mediaItem2); + sessionPlayerConnector.setPlaylist(items, null); + + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + sessionPlayerConnector.prepare().get(); + + sessionPlayerConnector.setPlaybackSpeed(2.0f); + sessionPlayerConnector.play(); + + assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + assertThat(sessionPlayerConnector.getCurrentMediaItem()).isEqualTo(mediaItem2); + assertThat(sessionPlayerConnector.getPlaybackSpeed()).isWithin(0.001f).of(2.0f); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_withSeriesOfSeek_succeeds() throws Exception { + TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + List testSeekPositions = Arrays.asList(3000L, 2000L, 1000L); + for (long testSeekPosition : testSeekPositions) { + assertPlayerResultSuccess(sessionPlayerConnector.seekTo(testSeekPosition)); + assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(testSeekPosition); + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_skipsUnnecessarySeek() throws Exception { + CountDownLatch readAllowedLatch = new CountDownLatch(1); + playerTestRule.setDataSourceInstrumentation( + dataSpec -> { + try { + assertThat(readAllowedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } catch (Exception e) { + assertWithMessage("Unexpected exception %s", e).fail(); + } + }); + + sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(R.raw.video_big_buck_bunny)); + + // prepare() will be pending until readAllowed is countDowned. + sessionPlayerConnector.prepare(); + + CopyOnWriteArrayList positionChanges = new CopyOnWriteArrayList<>(); + long testIntermediateSeekToPosition1 = 3000; + long testIntermediateSeekToPosition2 = 2000; + long testFinalSeekToPosition = 1000; + CountDownLatch onSeekCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onSeekCompleted(@NonNull SessionPlayer player, long position) { + // Do not assert here, because onSeekCompleted() can be called after the player is + // closed. + positionChanges.add(position); + if (position == testFinalSeekToPosition) { + onSeekCompletedLatch.countDown(); + } + } + }); + + ListenableFuture seekFuture1 = + sessionPlayerConnector.seekTo(testIntermediateSeekToPosition1); + ListenableFuture seekFuture2 = + sessionPlayerConnector.seekTo(testIntermediateSeekToPosition2); + ListenableFuture seekFuture3 = + sessionPlayerConnector.seekTo(testFinalSeekToPosition); + + readAllowedLatch.countDown(); + + assertThat(seekFuture1.get().getResultCode()).isEqualTo(RESULT_INFO_SKIPPED); + assertThat(seekFuture2.get().getResultCode()).isEqualTo(RESULT_INFO_SKIPPED); + assertThat(seekFuture3.get().getResultCode()).isEqualTo(RESULT_SUCCESS); + assertThat(onSeekCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + assertThat(positionChanges) + .containsNoneOf(testIntermediateSeekToPosition1, testIntermediateSeekToPosition2); + assertThat(positionChanges).contains(testFinalSeekToPosition); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_whenUnderlyingPlayerAlsoSeeks_throwsNoException() throws Exception { + TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + List> futures = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + futures.add(sessionPlayerConnector.seekTo(4123)); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.seekTo(1243)); + } + + for (ListenableFuture future : futures) { + assertThat(future.get().getResultCode()) + .isAnyOf(PlayerResult.RESULT_INFO_SKIPPED, PlayerResult.RESULT_SUCCESS); + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_byUnderlyingPlayer_notifiesOnSeekCompleted() throws Exception { + TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + long testSeekPosition = 1023; + AtomicLong seekPosition = new AtomicLong(); + CountDownLatch onSeekCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onSeekCompleted(@NonNull SessionPlayer player, long position) { + // Do not assert here, because onSeekCompleted() can be called after the player is + // closed. + seekPosition.set(position); + onSeekCompletedLatch.countDown(); + } + }); + + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.seekTo(testSeekPosition)); + assertThat(onSeekCompletedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + assertThat(seekPosition.get()).isEqualTo(testSeekPosition); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getPlayerState_withCallingPrepareAndPlayAndPause_reflectsPlayerState() + throws Throwable { + TestUtils.loadResource(R.raw.video_desks, sessionPlayerConnector); + assertThat(sessionPlayerConnector.getBufferingState()) + .isEqualTo(SessionPlayer.BUFFERING_STATE_UNKNOWN); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + assertThat(sessionPlayerConnector.getBufferingState()) + .isAnyOf( + SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE, + SessionPlayer.BUFFERING_STATE_COMPLETE); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + + assertPlayerResultSuccess(sessionPlayerConnector.play()); + + assertThat(sessionPlayerConnector.getBufferingState()) + .isAnyOf( + SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE, + SessionPlayer.BUFFERING_STATE_COMPLETE); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + + assertPlayerResultSuccess(sessionPlayerConnector.pause()); + + assertThat(sessionPlayerConnector.getBufferingState()) + .isAnyOf( + SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE, + SessionPlayer.BUFFERING_STATE_COMPLETE); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = VERSION_CODES.KITKAT) + public void prepare_twice_finishes() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertPlayerResult(sessionPlayerConnector.prepare(), RESULT_INFO_SKIPPED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void prepare_notifiesOnPlayerStateChanged() throws Throwable { + TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector); + + CountDownLatch onPlayerStatePaused = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int state) { + if (state == SessionPlayer.PLAYER_STATE_PAUSED) { + onPlayerStatePaused.countDown(); + } + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertThat(onPlayerStatePaused.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void prepare_notifiesBufferingCompletedOnce() throws Throwable { + TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector); + + CountDownLatch onBufferingCompletedLatch = new CountDownLatch(2); + CopyOnWriteArrayList bufferingStateChanges = new CopyOnWriteArrayList<>(); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onBufferingStateChanged( + @NonNull SessionPlayer player, MediaItem item, int buffState) { + bufferingStateChanges.add(buffState); + if (buffState == SessionPlayer.BUFFERING_STATE_COMPLETE) { + onBufferingCompletedLatch.countDown(); + } + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertWithMessage( + "Expected BUFFERING_STATE_COMPLETE only once. Full changes are %s", + bufferingStateChanges) + .that(onBufferingCompletedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isFalse(); + assertThat(bufferingStateChanges).isNotEmpty(); + int lastIndex = bufferingStateChanges.size() - 1; + assertWithMessage( + "Didn't end with BUFFERING_STATE_COMPLETE. Full changes are %s", bufferingStateChanges) + .that(bufferingStateChanges.get(lastIndex)) + .isEqualTo(SessionPlayer.BUFFERING_STATE_COMPLETE); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_whenPrepared_notifiesOnSeekCompleted() throws Throwable { + long mp4DurationMs = 8_484L; + TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onSeekCompletedLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onSeekCompleted(@NonNull SessionPlayer player, long position) { + onSeekCompletedLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + sessionPlayerConnector.seekTo(mp4DurationMs >> 1); + + assertThat(onSeekCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_whenPrepared_notifiesOnPlaybackSpeedChanged() throws Throwable { + TestUtils.loadResource(R.raw.video_big_buck_bunny, sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaybackSpeedChangedLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackSpeedChanged(@NonNull SessionPlayer player, float speed) { + assertThat(speed).isWithin(FLOAT_TOLERANCE).of(0.5f); + onPlaybackSpeedChangedLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + sessionPlayerConnector.setPlaybackSpeed(0.5f); + + assertThat(onPlaybackSpeedChangedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_withZeroSpeed_throwsException() { + try { + sessionPlayerConnector.setPlaybackSpeed(0.0f); + assertWithMessage("zero playback speed shouldn't be allowed").fail(); + } catch (IllegalArgumentException e) { + // expected. pass-through. + } + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_withNegativeSpeed_throwsException() { + try { + sessionPlayerConnector.setPlaybackSpeed(-1.0f); + assertWithMessage("negative playback speed isn't supported").fail(); + } catch (IllegalArgumentException e) { + // expected. pass-through. + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void close_throwsNoExceptionAndDoesNotCrash() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build(); + sessionPlayerConnector.setAudioAttributes(attributes); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + sessionPlayerConnector.close(); + + // Set the player to null so we don't try to close it again in tearDown(). + sessionPlayerConnector = null; + + // Tests whether the notification from the player after the close() doesn't crash. + Thread.sleep(PLAYER_STATE_CHANGE_WAIT_TIME_MS); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void cancelReturnedFuture_withSeekTo_cancelsPendingCommand() throws Exception { + CountDownLatch readRequestedLatch = new CountDownLatch(1); + CountDownLatch readAllowedLatch = new CountDownLatch(1); + // Need to wait from prepare() to counting down readAllowedLatch. + playerTestRule.setDataSourceInstrumentation( + dataSpec -> { + readRequestedLatch.countDown(); + try { + assertThat(readAllowedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } catch (Exception e) { + assertWithMessage("Unexpected exception %s", e).fail(); + } + }); + assertPlayerResultSuccess( + sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(R.raw.audio))); + + // prepare() will be pending until readAllowed is countDowned. + ListenableFuture prepareFuture = sessionPlayerConnector.prepare(); + ListenableFuture seekFuture = sessionPlayerConnector.seekTo(1000); + + assertThat(readRequestedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + + // Cancel the pending commands while preparation is on hold. + seekFuture.cancel(false); + + // Make the on-going prepare operation resumed and finished. + readAllowedLatch.countDown(); + assertPlayerResultSuccess(prepareFuture); + + // Check whether the canceled seek() didn't happened. + // Checking seekFuture.get() will be useless because it always throws CancellationException due + // to the CallbackToFuture implementation. + Thread.sleep(PLAYER_STATE_CHANGE_WAIT_TIME_MS); + assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_withNullPlaylist_throwsException() throws Exception { + List playlist = TestUtils.createPlaylist(10); + try { + sessionPlayerConnector.setPlaylist(null, null); + assertWithMessage("null playlist shouldn't be allowed").fail(); + } catch (Exception e) { + // pass-through + } + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_withPlaylistContainingNullItem_throwsException() { + try { + List list = new ArrayList<>(); + list.add(null); + sessionPlayerConnector.setPlaylist(list, null); + assertWithMessage("playlist with null item shouldn't be allowed").fail(); + } catch (Exception e) { + // pass-through + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_setsPlaylistAndCurrentMediaItem() throws Exception { + List playlist = TestUtils.createPlaylist(10); + CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch)); + + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, null)); + assertThat(onCurrentMediaItemChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + + assertThat(sessionPlayerConnector.getPlaylist()).isEqualTo(playlist); + assertThat(sessionPlayerConnector.getCurrentMediaItem()).isEqualTo(playlist.get(0)); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(10); + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + + sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null); + sessionPlayerConnector.prepare(); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_byUnderlyingPlayerBeforePrepare_notifiesOnPlaylistChanged() + throws Exception { + List playlistToSessionPlayer = TestUtils.createPlaylist(2); + List playlistToExoPlayer = TestUtils.createPlaylist(4); + DefaultMediaItemConverter converter = new DefaultMediaItemConverter(); + List exoMediaItems = new ArrayList<>(); + for (MediaItem mediaItem : playlistToExoPlayer) { + exoMediaItems.add(converter.convertToExoPlayerMediaItem(mediaItem)); + } + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + if (ObjectsCompat.equals(list, playlistToExoPlayer)) { + onPlaylistChangedLatch.countDown(); + } + } + }); + sessionPlayerConnector.setPlaylist(playlistToSessionPlayer, /* metadata= */ null); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> playerTestRule.getSimpleExoPlayer().setMediaItems(exoMediaItems)); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_byUnderlyingPlayerAfterPrepare_notifiesOnPlaylistChanged() + throws Exception { + List playlistToSessionPlayer = TestUtils.createPlaylist(2); + List playlistToExoPlayer = TestUtils.createPlaylist(4); + DefaultMediaItemConverter converter = new DefaultMediaItemConverter(); + List exoMediaItems = new ArrayList<>(); + for (MediaItem mediaItem : playlistToExoPlayer) { + exoMediaItems.add(converter.convertToExoPlayerMediaItem(mediaItem)); + } + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + if (ObjectsCompat.equals(list, playlistToExoPlayer)) { + onPlaylistChangedLatch.countDown(); + } + } + }); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.setPlaylist(playlistToSessionPlayer, /* metadata= */ null); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> playerTestRule.getSimpleExoPlayer().setMediaItems(exoMediaItems)); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void addPlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(10); + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + int addIndex = 2; + MediaItem newMediaItem = TestUtils.createMediaItem(); + playlist.add(addIndex, newMediaItem); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + sessionPlayerConnector.addPlaylistItem(addIndex, newMediaItem); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void removePlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(10); + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + int removeIndex = 3; + playlist.remove(removeIndex); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + sessionPlayerConnector.removePlaylistItem(removeIndex); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void replacePlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(10); + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + int replaceIndex = 2; + MediaItem newMediaItem = TestUtils.createMediaItem(R.raw.video_big_buck_bunny); + playlist.set(replaceIndex, newMediaItem); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + sessionPlayerConnector.replacePlaylistItem(replaceIndex, newMediaItem); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_withPlaylist_notifiesOnCurrentMediaItemChanged() throws Exception { + int listSize = 2; + List playlist = TestUtils.createPlaylist(listSize); + + CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch)); + + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, null)); + assertThat(sessionPlayerConnector.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(onCurrentMediaItemChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_twice_finishes() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertPlayerResult(sessionPlayerConnector.play(), RESULT_INFO_SKIPPED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_withPlaylist_notifiesOnCurrentMediaItemChangedAndOnPlaybackCompleted() + throws Exception { + List playlist = new ArrayList<>(); + playlist.add(TestUtils.createMediaItem(R.raw.video_1)); + playlist.add(TestUtils.createMediaItem(R.raw.video_2)); + playlist.add(TestUtils.createMediaItem(R.raw.video_3)); + + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + int currentMediaItemChangedCount = 0; + + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + assertThat(item).isEqualTo(player.getCurrentMediaItem()); + + int expectedCurrentIndex = currentMediaItemChangedCount++; + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(expectedCurrentIndex); + assertThat(item).isEqualTo(playlist.get(expectedCurrentIndex)); + } + + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }); + + assertThat(sessionPlayerConnector.setPlaylist(playlist, null)).isNotNull(); + assertThat(sessionPlayerConnector.prepare()).isNotNull(); + assertThat(sessionPlayerConnector.play()).isNotNull(); + + assertThat(onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_byUnderlyingPlayer_notifiesOnPlayerStateChanges() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + CountDownLatch onPlayingLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PLAYING) { + onPlayingLatch.countDown(); + } + } + }); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.setPlayWhenReady(true)); + + assertThat(onPlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void pause_twice_finishes() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertPlayerResultSuccess(sessionPlayerConnector.pause()); + assertPlayerResult(sessionPlayerConnector.pause(), RESULT_INFO_SKIPPED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void pause_byUnderlyingPlayer_notifiesOnPlayerStateChanges() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPausedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PAUSED) { + onPausedLatch.countDown(); + } + } + }); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.setPlayWhenReady(false)); + + assertThat(onPausedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void pause_byUnderlyingPlayerInListener_changesToPlayerStatePaused() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + CountDownLatch playerStateChangesLatch = new CountDownLatch(3); + CopyOnWriteArrayList playerStateChanges = new CopyOnWriteArrayList<>(); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + playerStateChanges.add(playerState); + playerStateChangesLatch.countDown(); + } + }); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> + simpleExoPlayer.addListener( + new Player.EventListener() { + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + if (playWhenReady) { + simpleExoPlayer.setPlayWhenReady(false); + } + } + })); + + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat(playerStateChangesLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + assertThat(playerStateChanges) + .containsExactly( + PLAYER_STATE_PAUSED, // After prepare() + PLAYER_STATE_PLAYING, // After play() + PLAYER_STATE_PAUSED) // After setPlayWhenREady(false) + .inOrder(); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PAUSED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void skipToNextAndPrevious_calledInARow_notifiesOnCurrentMediaItemChanged() + throws Exception { + List playlist = new ArrayList<>(); + playlist.add(TestUtils.createMediaItem(R.raw.video_1)); + playlist.add(TestUtils.createMediaItem(R.raw.video_2)); + playlist.add(TestUtils.createMediaItem(R.raw.video_3)); + assertThat(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)).isNotNull(); + + // STEP 1: prepare() + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + // STEP 2: skipToNextPlaylistItem() + CountDownLatch onNextMediaItemLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback skipToNextTestCallback = + new SessionPlayer.PlayerCallback() { + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + super.onCurrentMediaItemChanged(player, item); + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(item).isEqualTo(player.getCurrentMediaItem()); + assertThat(item).isEqualTo(playlist.get(1)); + onNextMediaItemLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, skipToNextTestCallback); + assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem()); + assertThat(onNextMediaItemLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)).isTrue(); + sessionPlayerConnector.unregisterPlayerCallback(skipToNextTestCallback); + + // STEP 3: skipToPreviousPlaylistItem() + CountDownLatch onPreviousMediaItemLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback skipToPreviousTestCallback = + new SessionPlayer.PlayerCallback() { + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + super.onCurrentMediaItemChanged(player, item); + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(item).isEqualTo(player.getCurrentMediaItem()); + assertThat(item).isEqualTo(playlist.get(0)); + onPreviousMediaItemLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, skipToPreviousTestCallback); + assertPlayerResultSuccess(sessionPlayerConnector.skipToPreviousPlaylistItem()); + assertThat(onPreviousMediaItemLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + sessionPlayerConnector.unregisterPlayerCallback(skipToPreviousTestCallback); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setRepeatMode_withRepeatAll_continuesToPlayPlaylistWithoutBeingCompleted() + throws Exception { + List playlist = new ArrayList<>(); + playlist.add(TestUtils.createMediaItem(R.raw.video_1)); + playlist.add(TestUtils.createMediaItem(R.raw.video_2)); + playlist.add(TestUtils.createMediaItem(R.raw.video_3)); + int listSize = playlist.size(); + + // Any value more than list size + 1, to see repeat mode with the recorded video. + CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(listSize + 2); + CopyOnWriteArrayList currentMediaItemChanges = new CopyOnWriteArrayList<>(); + PlayerCallbackForPlaylist callback = + new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch) { + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + super.onCurrentMediaItemChanged(player, item); + currentMediaItemChanges.add(item); + onCurrentMediaItemChangedLatch.countDown(); + } + + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + assertWithMessage( + "Playback shouldn't be completed, Actual changes were %s", + currentMediaItemChanges) + .fail(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + assertThat(sessionPlayerConnector.setPlaylist(playlist, null)).isNotNull(); + assertThat(sessionPlayerConnector.prepare()).isNotNull(); + assertThat(sessionPlayerConnector.setRepeatMode(SessionPlayer.REPEAT_MODE_ALL)).isNotNull(); + assertThat(sessionPlayerConnector.play()).isNotNull(); + + assertWithMessage( + "Current media item didn't change as expected. Actual changes were %s", + currentMediaItemChanges) + .that(onCurrentMediaItemChangedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, MILLISECONDS)) + .isTrue(); + + int expectedMediaItemIndex = 0; + for (MediaItem mediaItemInPlaybackOrder : currentMediaItemChanges) { + assertWithMessage( + "Unexpected media item for %sth playback. Actual changes were %s", + expectedMediaItemIndex, currentMediaItemChanges) + .that(mediaItemInPlaybackOrder) + .isEqualTo(playlist.get(expectedMediaItemIndex)); + expectedMediaItemIndex = (expectedMediaItemIndex + 1) % listSize; + } + } + + @Test + @LargeTest + public void getPlayerState_withPrepareAndPlayAndPause_changesAsExpected() throws Exception { + TestUtils.loadResource(R.raw.audio, sessionPlayerConnector); + + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build(); + sessionPlayerConnector.setAudioAttributes(attributes); + sessionPlayerConnector.setRepeatMode(SessionPlayer.REPEAT_MODE_ALL); + + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + } + + @Test + @LargeTest + public void getPlaylist_returnsPlaylistInUnderlyingPlayer() { + List playlistToExoPlayer = TestUtils.createPlaylist(4); + DefaultMediaItemConverter converter = new DefaultMediaItemConverter(); + List exoMediaItems = new ArrayList<>(); + for (MediaItem mediaItem : playlistToExoPlayer) { + exoMediaItems.add(converter.convertToExoPlayerMediaItem(mediaItem)); + } + + AtomicReference> playlistFromSessionPlayer = new AtomicReference<>(); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + simpleExoPlayer.setMediaItems(exoMediaItems); + + try (SessionPlayerConnector sessionPlayer = + new SessionPlayerConnector(simpleExoPlayer)) { + List playlist = sessionPlayer.getPlaylist(); + playlistFromSessionPlayer.set(playlist); + } + }); + assertThat(playlistFromSessionPlayer.get()).isEqualTo(playlistToExoPlayer); + } + + private class PlayerCallbackForPlaylist extends SessionPlayer.PlayerCallback { + private List playlist; + private CountDownLatch onCurrentMediaItemChangedLatch; + + PlayerCallbackForPlaylist(List playlist, CountDownLatch latch) { + this.playlist = playlist; + onCurrentMediaItemChangedLatch = latch; + } + + @Override + public void onCurrentMediaItemChanged(@NonNull SessionPlayer player, @NonNull MediaItem item) { + int currentIndex = playlist.indexOf(item); + assertThat(sessionPlayerConnector.getCurrentMediaItemIndex()).isEqualTo(currentIndex); + onCurrentMediaItemChangedLatch.countDown(); + } + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java new file mode 100644 index 0000000000..a7eb058ee6 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java @@ -0,0 +1,80 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_SUCCESS; +import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import androidx.media2.common.MediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.SessionPlayer; +import androidx.media2.common.SessionPlayer.PlayerResult; +import androidx.media2.common.UriMediaItem; +import com.google.android.exoplayer2.ext.media2.test.R; +import com.google.android.exoplayer2.upstream.RawResourceDataSource; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; + +/** Utilities for tests. */ +/* package */ final class TestUtils { + private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000; + + public static UriMediaItem createMediaItem() { + return createMediaItem(R.raw.video_desks); + } + + public static UriMediaItem createMediaItem(int resId) { + MediaMetadata metadata = + new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, Integer.toString(resId)) + .build(); + return new UriMediaItem.Builder(RawResourceDataSource.buildRawResourceUri(resId)) + .setMetadata(metadata) + .build(); + } + + public static List createPlaylist(int size) { + List items = new ArrayList<>(); + for (int i = 0; i < size; ++i) { + items.add(createMediaItem()); + } + return items; + } + + public static void loadResource(int resId, SessionPlayer sessionPlayer) throws Exception { + MediaItem mediaItem = createMediaItem(resId); + assertPlayerResultSuccess(sessionPlayer.setMediaItem(mediaItem)); + } + + public static void assertPlayerResultSuccess(Future future) throws Exception { + assertPlayerResult(future, RESULT_SUCCESS); + } + + public static void assertPlayerResult( + Future future, /* @PlayerResult.ResultCode */ int playerResult) + throws Exception { + assertThat(future).isNotNull(); + PlayerResult result = future.get(PLAYER_STATE_CHANGE_WAIT_TIME_MS, MILLISECONDS); + assertThat(result).isNotNull(); + assertThat(result.getResultCode()).isEqualTo(playerResult); + } + + private TestUtils() { + // Prevent from instantiation. + } +} diff --git a/extensions/media2/src/androidTest/res/layout/mediaplayer.xml b/extensions/media2/src/androidTest/res/layout/mediaplayer.xml new file mode 100644 index 0000000000..1861e5e44e --- /dev/null +++ b/extensions/media2/src/androidTest/res/layout/mediaplayer.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/extensions/media2/src/androidTest/res/raw/audio.mp3 b/extensions/media2/src/androidTest/res/raw/audio.mp3 new file mode 100755 index 0000000000..657faf7718 Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/audio.mp3 differ diff --git a/extensions/media2/src/androidTest/res/raw/video_1.mp4 b/extensions/media2/src/androidTest/res/raw/video_1.mp4 new file mode 100644 index 0000000000..b8d9236def Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_1.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/video_2.mp4 b/extensions/media2/src/androidTest/res/raw/video_2.mp4 new file mode 100644 index 0000000000..c29d88c21f Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_2.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/video_3.mp4 b/extensions/media2/src/androidTest/res/raw/video_3.mp4 new file mode 100644 index 0000000000..767bd5c647 Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_3.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/video_big_buck_bunny.mp4 b/extensions/media2/src/androidTest/res/raw/video_big_buck_bunny.mp4 new file mode 100644 index 0000000000..571ff4459d Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_big_buck_bunny.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/video_desks.3gp b/extensions/media2/src/androidTest/res/raw/video_desks.3gp new file mode 100644 index 0000000000..c51f109f97 Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_desks.3gp differ diff --git a/testdata/src/test/assets/ts/sample_h262_mpeg_audio.ts b/extensions/media2/src/androidTest/res/raw/video_not_seekable.ts similarity index 100% rename from testdata/src/test/assets/ts/sample_h262_mpeg_audio.ts rename to extensions/media2/src/androidTest/res/raw/video_not_seekable.ts diff --git a/extensions/media2/src/main/AndroidManifest.xml b/extensions/media2/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3b87ee9dfa --- /dev/null +++ b/extensions/media2/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java new file mode 100644 index 0000000000..c23bdd5669 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DefaultMediaItemConverter.java @@ -0,0 +1,137 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static androidx.media2.common.MediaMetadata.METADATA_KEY_DISPLAY_TITLE; +import static androidx.media2.common.MediaMetadata.METADATA_KEY_MEDIA_ID; +import static androidx.media2.common.MediaMetadata.METADATA_KEY_MEDIA_URI; +import static androidx.media2.common.MediaMetadata.METADATA_KEY_TITLE; + +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.media2.common.CallbackMediaItem; +import androidx.media2.common.FileMediaItem; +import androidx.media2.common.UriMediaItem; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.util.Assertions; + +/** + * Default implementation of {@link MediaItemConverter}. + * + *

Note that {@link #getMetadata} can be overridden to fill in additional metadata when + * converting {@link MediaItem ExoPlayer MediaItems} to their AndroidX equivalents. + */ +public class DefaultMediaItemConverter implements MediaItemConverter { + + @Override + public MediaItem convertToExoPlayerMediaItem(androidx.media2.common.MediaItem media2MediaItem) { + if (media2MediaItem instanceof FileMediaItem) { + throw new IllegalStateException("FileMediaItem isn't supported"); + } + if (media2MediaItem instanceof CallbackMediaItem) { + throw new IllegalStateException("CallbackMediaItem isn't supported"); + } + + @Nullable Uri uri = null; + @Nullable String mediaId = null; + @Nullable String title = null; + if (media2MediaItem instanceof UriMediaItem) { + UriMediaItem uriMediaItem = (UriMediaItem) media2MediaItem; + uri = uriMediaItem.getUri(); + } + @Nullable androidx.media2.common.MediaMetadata metadata = media2MediaItem.getMetadata(); + if (metadata != null) { + @Nullable String uriString = metadata.getString(METADATA_KEY_MEDIA_URI); + mediaId = metadata.getString(METADATA_KEY_MEDIA_ID); + if (uri == null) { + if (uriString != null) { + uri = Uri.parse(uriString); + } else if (mediaId != null) { + uri = Uri.parse("media2:///" + mediaId); + } + } + title = metadata.getString(METADATA_KEY_DISPLAY_TITLE); + if (title == null) { + title = metadata.getString(METADATA_KEY_TITLE); + } + } + if (uri == null) { + // Generate a URI to make it non-null. If not, then the tag passed to setTag will be ignored. + uri = Uri.parse("media2:///"); + } + long startPositionMs = media2MediaItem.getStartPosition(); + if (startPositionMs == androidx.media2.common.MediaItem.POSITION_UNKNOWN) { + startPositionMs = 0; + } + long endPositionMs = media2MediaItem.getEndPosition(); + if (endPositionMs == androidx.media2.common.MediaItem.POSITION_UNKNOWN) { + endPositionMs = C.TIME_END_OF_SOURCE; + } + + return new MediaItem.Builder() + .setUri(uri) + .setMediaId(mediaId) + .setMediaMetadata( + new com.google.android.exoplayer2.MediaMetadata.Builder().setTitle(title).build()) + .setTag(media2MediaItem) + .setClipStartPositionMs(startPositionMs) + .setClipEndPositionMs(endPositionMs) + .build(); + } + + @Override + public androidx.media2.common.MediaItem convertToMedia2MediaItem(MediaItem exoPlayerMediaItem) { + Assertions.checkNotNull(exoPlayerMediaItem); + MediaItem.PlaybackProperties playbackProperties = + Assertions.checkNotNull(exoPlayerMediaItem.playbackProperties); + + @Nullable Object tag = playbackProperties.tag; + if (tag instanceof androidx.media2.common.MediaItem) { + return (androidx.media2.common.MediaItem) tag; + } + + androidx.media2.common.MediaMetadata metadata = getMetadata(exoPlayerMediaItem); + long startPositionMs = exoPlayerMediaItem.clippingProperties.startPositionMs; + long endPositionMs = exoPlayerMediaItem.clippingProperties.endPositionMs; + if (endPositionMs == C.TIME_END_OF_SOURCE) { + endPositionMs = androidx.media2.common.MediaItem.POSITION_UNKNOWN; + } + + return new androidx.media2.common.MediaItem.Builder() + .setMetadata(metadata) + .setStartPosition(startPositionMs) + .setEndPosition(endPositionMs) + .build(); + } + + /** + * Returns a {@link androidx.media2.common.MediaMetadata} corresponding to the given {@link + * MediaItem ExoPlayer MediaItem}. + */ + protected androidx.media2.common.MediaMetadata getMetadata(MediaItem exoPlayerMediaItem) { + @Nullable String title = exoPlayerMediaItem.mediaMetadata.title; + + androidx.media2.common.MediaMetadata.Builder metadataBuilder = + new androidx.media2.common.MediaMetadata.Builder() + .putString(METADATA_KEY_MEDIA_ID, exoPlayerMediaItem.mediaId); + if (title != null) { + metadataBuilder.putString(METADATA_KEY_TITLE, title); + metadataBuilder.putString(METADATA_KEY_DISPLAY_TITLE, title); + } + return metadataBuilder.build(); + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java new file mode 100644 index 0000000000..218c2a737e --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaItemConverter.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import com.google.android.exoplayer2.MediaItem; + +/** + * Converts between {@link androidx.media2.common.MediaItem Media2 MediaItem} and {@link MediaItem + * ExoPlayer MediaItem}. + */ +public interface MediaItemConverter { + /** + * Converts an {@link androidx.media2.common.MediaItem Media2 MediaItem} to an {@link MediaItem + * ExoPlayer MediaItem}. + */ + MediaItem convertToExoPlayerMediaItem(androidx.media2.common.MediaItem media2MediaItem); + + /** + * Converts an {@link MediaItem ExoPlayer MediaItem} to an {@link androidx.media2.common.MediaItem + * Media2 MediaItem}. + */ + androidx.media2.common.MediaItem convertToMedia2MediaItem(MediaItem exoPlayerMediaItem); +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java new file mode 100644 index 0000000000..e7cc9545b1 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import android.annotation.SuppressLint; +import android.support.v4.media.session.MediaSessionCompat; +import androidx.media2.session.MediaSession; + +/** Utility methods to use {@link MediaSession} with other ExoPlayer modules. */ +public final class MediaSessionUtil { + + /** Gets the {@link MediaSessionCompat.Token} from the {@link MediaSession}. */ + // TODO(b/152764014): Deprecate this API when MediaSession#getSessionCompatToken() is released. + public static MediaSessionCompat.Token getSessionCompatToken(MediaSession mediaSession) { + @SuppressLint("RestrictedApi") + @SuppressWarnings("RestrictTo") + MediaSessionCompat sessionCompat = mediaSession.getSessionCompat(); + return sessionCompat.getSessionToken(); + } + + private MediaSessionUtil() { + // Prevent from instantiation. + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java new file mode 100644 index 0000000000..fc80c85856 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java @@ -0,0 +1,458 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static com.google.android.exoplayer2.util.Util.postOrRun; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.os.Handler; +import androidx.annotation.GuardedBy; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.media2.common.MediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.SessionPlayer; +import androidx.media2.common.SessionPlayer.PlayerResult; +import com.google.android.exoplayer2.util.Log; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.Callable; + +/** Manages the queue of player actions and handles running them one by one. */ +/* package */ class PlayerCommandQueue implements AutoCloseable { + + private static final String TAG = "PlayerCommandQueue"; + private static final boolean DEBUG = false; + + // Redefine command codes rather than using constants from SessionCommand here, because command + // code for setAudioAttribute() is missing in SessionCommand. + /** Command code for {@link SessionPlayer#setAudioAttributes}. */ + public static final int COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES = 0; + + /** Command code for {@link SessionPlayer#play} */ + public static final int COMMAND_CODE_PLAYER_PLAY = 1; + + /** Command code for {@link SessionPlayer#replacePlaylistItem(int, MediaItem)} */ + public static final int COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM = 2; + + /** Command code for {@link SessionPlayer#skipToPreviousPlaylistItem()} */ + public static final int COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM = 3; + + /** Command code for {@link SessionPlayer#skipToNextPlaylistItem()} */ + public static final int COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM = 4; + + /** Command code for {@link SessionPlayer#skipToPlaylistItem(int)} */ + public static final int COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM = 5; + + /** Command code for {@link SessionPlayer#updatePlaylistMetadata(MediaMetadata)} */ + public static final int COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA = 6; + + /** Command code for {@link SessionPlayer#setRepeatMode(int)} */ + public static final int COMMAND_CODE_PLAYER_SET_REPEAT_MODE = 7; + + /** Command code for {@link SessionPlayer#setShuffleMode(int)} */ + public static final int COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE = 8; + + /** Command code for {@link SessionPlayer#setMediaItem(MediaItem)} */ + public static final int COMMAND_CODE_PLAYER_SET_MEDIA_ITEM = 9; + + /** Command code for {@link SessionPlayer#seekTo(long)} */ + public static final int COMMAND_CODE_PLAYER_SEEK_TO = 10; + + /** Command code for {@link SessionPlayer#prepare()} */ + public static final int COMMAND_CODE_PLAYER_PREPARE = 11; + + /** Command code for {@link SessionPlayer#setPlaybackSpeed(float)} */ + public static final int COMMAND_CODE_PLAYER_SET_SPEED = 12; + + /** Command code for {@link SessionPlayer#pause()} */ + public static final int COMMAND_CODE_PLAYER_PAUSE = 13; + + /** Command code for {@link SessionPlayer#setPlaylist(List, MediaMetadata)} */ + public static final int COMMAND_CODE_PLAYER_SET_PLAYLIST = 14; + + /** Command code for {@link SessionPlayer#addPlaylistItem(int, MediaItem)} */ + public static final int COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM = 15; + + /** Command code for {@link SessionPlayer#removePlaylistItem(int)} */ + public static final int COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM = 16; + + /** List of session commands whose result would be set after the command is finished. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES, + COMMAND_CODE_PLAYER_PLAY, + COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM, + COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM, + COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM, + COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM, + COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA, + COMMAND_CODE_PLAYER_SET_REPEAT_MODE, + COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE, + COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, + COMMAND_CODE_PLAYER_SEEK_TO, + COMMAND_CODE_PLAYER_PREPARE, + COMMAND_CODE_PLAYER_SET_SPEED, + COMMAND_CODE_PLAYER_PAUSE, + COMMAND_CODE_PLAYER_SET_PLAYLIST, + COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM, + COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM, + }) + public @interface CommandCode {} + + /** Command whose result would be set later via listener after the command is finished. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = {COMMAND_CODE_PLAYER_PREPARE, COMMAND_CODE_PLAYER_PLAY, COMMAND_CODE_PLAYER_PAUSE}) + public @interface AsyncCommandCode {} + + // Should be only used on the handler. + private final PlayerWrapper player; + private final Handler handler; + private final Object lock; + + @GuardedBy("lock") + private final Deque pendingPlayerCommandQueue; + + @GuardedBy("lock") + private boolean closed; + + // Should be only used on the handler. + @Nullable private AsyncPlayerCommandResult pendingAsyncPlayerCommandResult; + + public PlayerCommandQueue(PlayerWrapper player, Handler handler) { + this.player = player; + this.handler = handler; + lock = new Object(); + pendingPlayerCommandQueue = new ArrayDeque<>(); + } + + @Override + public void close() { + synchronized (lock) { + if (closed) { + return; + } + closed = true; + } + reset(); + } + + public void reset() { + handler.removeCallbacksAndMessages(/* token= */ null); + List queue; + synchronized (lock) { + queue = new ArrayList<>(pendingPlayerCommandQueue); + pendingPlayerCommandQueue.clear(); + } + for (PlayerCommand playerCommand : queue) { + playerCommand.result.set( + new PlayerResult(PlayerResult.RESULT_INFO_SKIPPED, /* item= */ null)); + } + } + + public ListenableFuture addCommand( + @CommandCode int commandCode, Callable command) { + return addCommand(commandCode, command, /* tag= */ null); + } + + public ListenableFuture addCommand( + @CommandCode int commandCode, Callable command, @Nullable Object tag) { + SettableFuture result = SettableFuture.create(); + synchronized (lock) { + if (closed) { + // OK to set result with lock hold because developers cannot add listener here. + result.set(new PlayerResult(PlayerResult.RESULT_ERROR_INVALID_STATE, /* item= */ null)); + return result; + } + PlayerCommand playerCommand = new PlayerCommand(commandCode, command, result, tag); + result.addListener( + () -> { + if (result.isCancelled()) { + boolean isCommandPending; + synchronized (lock) { + isCommandPending = pendingPlayerCommandQueue.remove(playerCommand); + } + if (isCommandPending) { + result.set( + new PlayerResult( + PlayerResult.RESULT_INFO_SKIPPED, player.getCurrentMediaItem())); + if (DEBUG) { + Log.d(TAG, "canceled " + playerCommand); + } + } + if (pendingAsyncPlayerCommandResult != null + && pendingAsyncPlayerCommandResult.result == result) { + pendingAsyncPlayerCommandResult = null; + } + } + processPendingCommandOnHandler(); + }, + (runnable) -> postOrRun(handler, runnable)); + if (DEBUG) { + Log.d(TAG, "adding " + playerCommand); + } + pendingPlayerCommandQueue.add(playerCommand); + } + processPendingCommand(); + return result; + } + + public void notifyCommandError() { + postOrRun( + handler, + () -> { + @Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult; + if (pendingResult == null) { + if (DEBUG) { + Log.d(TAG, "Ignoring notifyCommandError(). No pending async command."); + } + return; + } + pendingResult.result.set( + new PlayerResult(PlayerResult.RESULT_ERROR_UNKNOWN, player.getCurrentMediaItem())); + pendingAsyncPlayerCommandResult = null; + if (DEBUG) { + Log.d(TAG, "error on " + pendingResult); + } + processPendingCommandOnHandler(); + }); + } + + public void notifyCommandCompleted(@AsyncCommandCode int completedCommandCode) { + if (DEBUG) { + Log.d(TAG, "notifyCommandCompleted, completedCommandCode=" + completedCommandCode); + } + postOrRun( + handler, + () -> { + @Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult; + if (pendingResult == null || pendingResult.commandCode != completedCommandCode) { + if (DEBUG) { + Log.d( + TAG, + "Unexpected Listener is notified from the Player. Player may be used" + + " directly rather than " + + toLogFriendlyString(completedCommandCode)); + } + return; + } + pendingResult.result.set( + new PlayerResult(PlayerResult.RESULT_SUCCESS, player.getCurrentMediaItem())); + pendingAsyncPlayerCommandResult = null; + if (DEBUG) { + Log.d(TAG, "completed " + pendingResult); + } + processPendingCommandOnHandler(); + }); + } + + private void processPendingCommand() { + postOrRun(handler, this::processPendingCommandOnHandler); + } + + private void processPendingCommandOnHandler() { + while (pendingAsyncPlayerCommandResult == null) { + @Nullable PlayerCommand playerCommand; + synchronized (lock) { + playerCommand = pendingPlayerCommandQueue.poll(); + } + if (playerCommand == null) { + return; + } + + int commandCode = playerCommand.commandCode; + // Check if it's @AsyncCommandCode + boolean asyncCommand = isAsyncCommand(playerCommand.commandCode); + + // Continuous COMMAND_CODE_PLAYER_SEEK_TO can be skipped. + if (commandCode == COMMAND_CODE_PLAYER_SEEK_TO) { + @Nullable List skippingCommands = null; + while (true) { + synchronized (lock) { + @Nullable PlayerCommand pendingCommand = pendingPlayerCommandQueue.peek(); + if (pendingCommand == null || pendingCommand.commandCode != commandCode) { + break; + } + pendingPlayerCommandQueue.poll(); + if (skippingCommands == null) { + skippingCommands = new ArrayList<>(); + } + skippingCommands.add(playerCommand); + playerCommand = pendingCommand; + } + } + if (skippingCommands != null) { + for (PlayerCommand skippingCommand : skippingCommands) { + skippingCommand.result.set( + new PlayerResult(PlayerResult.RESULT_INFO_SKIPPED, player.getCurrentMediaItem())); + if (DEBUG) { + Log.d(TAG, "skipping pending command, " + skippingCommand); + } + } + } + } + + if (asyncCommand) { + // Result would come later, via #notifyCommandCompleted(). + // Set pending player result first because it may be notified while the command is running. + pendingAsyncPlayerCommandResult = + new AsyncPlayerCommandResult(commandCode, playerCommand.result); + } + + if (DEBUG) { + Log.d(TAG, "start processing command, " + playerCommand); + } + + int resultCode; + if (player.hasError()) { + resultCode = PlayerResult.RESULT_ERROR_INVALID_STATE; + } else { + try { + boolean handled = playerCommand.command.call(); + resultCode = handled ? PlayerResult.RESULT_SUCCESS : PlayerResult.RESULT_INFO_SKIPPED; + } catch (IllegalStateException e) { + resultCode = PlayerResult.RESULT_ERROR_INVALID_STATE; + } catch (IllegalArgumentException | IndexOutOfBoundsException e) { + resultCode = PlayerResult.RESULT_ERROR_BAD_VALUE; + } catch (SecurityException e) { + resultCode = PlayerResult.RESULT_ERROR_PERMISSION_DENIED; + } catch (Exception e) { + resultCode = PlayerResult.RESULT_ERROR_UNKNOWN; + } + } + if (DEBUG) { + Log.d(TAG, "command processed, " + playerCommand); + } + + if (asyncCommand) { + if (resultCode != PlayerResult.RESULT_SUCCESS + && pendingAsyncPlayerCommandResult != null + && playerCommand.result == pendingAsyncPlayerCommandResult.result) { + pendingAsyncPlayerCommandResult = null; + playerCommand.result.set(new PlayerResult(resultCode, player.getCurrentMediaItem())); + } + } else { + playerCommand.result.set(new PlayerResult(resultCode, player.getCurrentMediaItem())); + } + } + } + + private static String toLogFriendlyString(@AsyncCommandCode int commandCode) { + switch (commandCode) { + case COMMAND_CODE_PLAYER_PLAY: + return "SessionPlayerConnector#play()"; + case COMMAND_CODE_PLAYER_PAUSE: + return "SessionPlayerConnector#pause()"; + case COMMAND_CODE_PLAYER_PREPARE: + return "SessionPlayerConnector#prepare()"; + default: + // Never happens. + throw new IllegalStateException(); + } + } + + private static boolean isAsyncCommand(@CommandCode int commandCode) { + switch (commandCode) { + case COMMAND_CODE_PLAYER_PLAY: + case COMMAND_CODE_PLAYER_PAUSE: + case COMMAND_CODE_PLAYER_PREPARE: + return true; + } + return false; + } + + private static final class AsyncPlayerCommandResult { + @AsyncCommandCode public final int commandCode; + public final SettableFuture result; + + public AsyncPlayerCommandResult( + @AsyncCommandCode int commandCode, SettableFuture result) { + this.commandCode = commandCode; + this.result = result; + } + + @Override + public String toString() { + StringBuilder stringBuilder = + new StringBuilder("AsyncPlayerCommandResult {commandCode=") + .append(commandCode) + .append(", result=") + .append(result.hashCode()); + if (result.isDone()) { + try { + int resultCode = result.get(/* timeout= */ 0, MILLISECONDS).getResultCode(); + stringBuilder.append(", resultCode=").append(resultCode); + } catch (Exception e) { + // pass-through. + } + } + stringBuilder.append("}"); + return stringBuilder.toString(); + } + } + + private static final class PlayerCommand { + public final int commandCode; + public final Callable command; + // Result shouldn't be set with lock held, because it may trigger listener set by developers. + public final SettableFuture result; + @Nullable private final Object tag; + + public PlayerCommand( + int commandCode, + Callable command, + SettableFuture result, + @Nullable Object tag) { + this.commandCode = commandCode; + this.command = command; + this.result = result; + this.tag = tag; + } + + @Override + public String toString() { + StringBuilder stringBuilder = + new StringBuilder("PlayerCommand {commandCode=") + .append(commandCode) + .append(", result=") + .append(result.hashCode()); + if (result.isDone()) { + try { + int resultCode = result.get(/* timeout= */ 0, MILLISECONDS).getResultCode(); + stringBuilder.append(", resultCode=").append(resultCode); + } catch (Exception e) { + // pass-through. + } + } + if (tag != null) { + stringBuilder.append(", tag=").append(tag); + } + stringBuilder.append("}"); + return stringBuilder.toString(); + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java new file mode 100644 index 0000000000..09e0325e93 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java @@ -0,0 +1,657 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static com.google.android.exoplayer2.util.Util.postOrRun; + +import android.os.Handler; +import androidx.annotation.IntRange; +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; +import androidx.media.AudioAttributesCompat; +import androidx.media2.common.CallbackMediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.SessionPlayer; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.audio.AudioListener; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Wraps an ExoPlayer {@link Player} instance and provides methods and notifies events like those in + * the {@link SessionPlayer} API. + */ +/* package */ final class PlayerWrapper { + private static final String TAG = "PlayerWrapper"; + + /** Listener for player wrapper events. */ + public interface Listener { + /** + * Called when the player state is changed. + * + *

This method will be called at first if multiple events should be notified at once. + */ + void onPlayerStateChanged(/* @SessionPlayer.PlayerState */ int playerState); + + /** Called when the player is prepared. */ + void onPrepared(androidx.media2.common.MediaItem media2MediaItem, int bufferingPercentage); + + /** Called when a seek request has completed. */ + void onSeekCompleted(); + + /** Called when the player rebuffers. */ + void onBufferingStarted(androidx.media2.common.MediaItem media2MediaItem); + + /** Called when the player becomes ready again after rebuffering. */ + void onBufferingEnded( + androidx.media2.common.MediaItem media2MediaItem, int bufferingPercentage); + + /** Called periodically with the player's buffered position as a percentage. */ + void onBufferingUpdate( + androidx.media2.common.MediaItem media2MediaItem, int bufferingPercentage); + + /** Called when current media item is changed. */ + void onCurrentMediaItemChanged(androidx.media2.common.MediaItem media2MediaItem); + + /** Called when playback of the item list has ended. */ + void onPlaybackEnded(); + + /** Called when the player encounters an error. */ + void onError(@Nullable androidx.media2.common.MediaItem media2MediaItem); + + /** Called when the playlist is changed. */ + void onPlaylistChanged(); + + /** Called when the shuffle mode is changed. */ + void onShuffleModeChanged(int shuffleMode); + + /** Called when the repeat mode is changed. */ + void onRepeatModeChanged(int repeatMode); + + /** Called when the audio attributes is changed. */ + void onAudioAttributesChanged(AudioAttributesCompat audioAttributes); + + /** Called when the playback speed is changed. */ + void onPlaybackSpeedChanged(float playbackSpeed); + } + + private static final int POLL_BUFFER_INTERVAL_MS = 1000; + + private final Listener listener; + private final Handler handler; + private final Runnable pollBufferRunnable; + + private final Player player; + private final MediaItemConverter mediaItemConverter; + private final ComponentListener componentListener; + + @Nullable private MediaMetadata playlistMetadata; + + // These should be only updated in TimelineChanges. + private final List media2Playlist; + private final List exoPlayerPlaylist; + + private ControlDispatcher controlDispatcher; + private boolean prepared; + private boolean rebuffering; + private int currentWindowIndex; + private boolean ignoreTimelineUpdates; + + /** + * Creates a new ExoPlayer wrapper. + * + * @param listener A {@link Listener}. + * @param player The {@link Player}. + * @param mediaItemConverter The {@link MediaItemConverter}. + */ + public PlayerWrapper(Listener listener, Player player, MediaItemConverter mediaItemConverter) { + this.listener = listener; + this.player = player; + this.mediaItemConverter = mediaItemConverter; + + controlDispatcher = new DefaultControlDispatcher(); + componentListener = new ComponentListener(); + player.addListener(componentListener); + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); + if (audioComponent != null) { + audioComponent.addAudioListener(componentListener); + } + + handler = new Handler(player.getApplicationLooper()); + pollBufferRunnable = new PollBufferRunnable(); + + media2Playlist = new ArrayList<>(); + exoPlayerPlaylist = new ArrayList<>(); + currentWindowIndex = C.INDEX_UNSET; + + prepared = player.getPlaybackState() != Player.STATE_IDLE; + rebuffering = player.getPlaybackState() == Player.STATE_BUFFERING; + + updatePlaylist(player.getCurrentTimeline()); + } + + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + this.controlDispatcher = controlDispatcher; + } + + public boolean setMediaItem(androidx.media2.common.MediaItem media2MediaItem) { + return setPlaylist(Collections.singletonList(media2MediaItem), /* metadata= */ null); + } + + public boolean setPlaylist( + List playlist, @Nullable MediaMetadata metadata) { + // Check for duplication. + for (int i = 0; i < playlist.size(); i++) { + androidx.media2.common.MediaItem media2MediaItem = playlist.get(i); + Assertions.checkArgument(playlist.indexOf(media2MediaItem) == i); + } + + this.playlistMetadata = metadata; + List exoPlayerMediaItems = new ArrayList<>(); + for (int i = 0; i < playlist.size(); i++) { + androidx.media2.common.MediaItem media2MediaItem = playlist.get(i); + MediaItem exoPlayerMediaItem = + Assertions.checkNotNull(mediaItemConverter.convertToExoPlayerMediaItem(media2MediaItem)); + exoPlayerMediaItems.add(exoPlayerMediaItem); + } + + player.setMediaItems(exoPlayerMediaItems, /* resetPosition= */ true); + + currentWindowIndex = getCurrentMediaItemIndex(); + return true; + } + + public boolean addPlaylistItem(int index, androidx.media2.common.MediaItem media2MediaItem) { + Assertions.checkArgument(!media2Playlist.contains(media2MediaItem)); + index = Util.constrainValue(index, 0, media2Playlist.size()); + + MediaItem exoPlayerMediaItem = + Assertions.checkNotNull(mediaItemConverter.convertToExoPlayerMediaItem(media2MediaItem)); + player.addMediaItem(index, exoPlayerMediaItem); + return true; + } + + public boolean removePlaylistItem(@IntRange(from = 0) int index) { + player.removeMediaItem(index); + return true; + } + + public boolean replacePlaylistItem(int index, androidx.media2.common.MediaItem media2MediaItem) { + Assertions.checkArgument(!media2Playlist.contains(media2MediaItem)); + index = Util.constrainValue(index, 0, media2Playlist.size()); + + MediaItem exoPlayerMediaItemToAdd = + Assertions.checkNotNull(mediaItemConverter.convertToExoPlayerMediaItem(media2MediaItem)); + + ignoreTimelineUpdates = true; + player.removeMediaItem(index); + ignoreTimelineUpdates = false; + player.addMediaItem(index, exoPlayerMediaItemToAdd); + return true; + } + + public boolean skipToPreviousPlaylistItem() { + Timeline timeline = player.getCurrentTimeline(); + Assertions.checkState(!timeline.isEmpty()); + int previousWindowIndex = player.getPreviousWindowIndex(); + if (previousWindowIndex != C.INDEX_UNSET) { + return controlDispatcher.dispatchSeekTo(player, previousWindowIndex, C.TIME_UNSET); + } + return false; + } + + public boolean skipToNextPlaylistItem() { + Timeline timeline = player.getCurrentTimeline(); + Assertions.checkState(!timeline.isEmpty()); + int nextWindowIndex = player.getNextWindowIndex(); + if (nextWindowIndex != C.INDEX_UNSET) { + return controlDispatcher.dispatchSeekTo(player, nextWindowIndex, C.TIME_UNSET); + } + return false; + } + + public boolean skipToPlaylistItem(@IntRange(from = 0) int index) { + Timeline timeline = player.getCurrentTimeline(); + Assertions.checkState(!timeline.isEmpty()); + // Use checkState() instead of checkIndex() for throwing IllegalStateException. + // checkIndex() throws IndexOutOfBoundsException which maps the RESULT_ERROR_BAD_VALUE + // but RESULT_ERROR_INVALID_STATE with IllegalStateException is expected here. + Assertions.checkState(0 <= index && index < timeline.getWindowCount()); + int windowIndex = player.getCurrentWindowIndex(); + if (windowIndex != index) { + return controlDispatcher.dispatchSeekTo(player, index, C.TIME_UNSET); + } + return false; + } + + public boolean updatePlaylistMetadata(@Nullable MediaMetadata metadata) { + this.playlistMetadata = metadata; + return true; + } + + public boolean setRepeatMode(int repeatMode) { + return controlDispatcher.dispatchSetRepeatMode( + player, Utils.getExoPlayerRepeatMode(repeatMode)); + } + + public boolean setShuffleMode(int shuffleMode) { + return controlDispatcher.dispatchSetShuffleModeEnabled( + player, Utils.getExoPlayerShuffleMode(shuffleMode)); + } + + @Nullable + public List getPlaylist() { + return new ArrayList<>(media2Playlist); + } + + @Nullable + public MediaMetadata getPlaylistMetadata() { + return playlistMetadata; + } + + public int getRepeatMode() { + return Utils.getRepeatMode(player.getRepeatMode()); + } + + public int getShuffleMode() { + return Utils.getShuffleMode(player.getShuffleModeEnabled()); + } + + public int getCurrentMediaItemIndex() { + return media2Playlist.isEmpty() ? C.INDEX_UNSET : player.getCurrentWindowIndex(); + } + + public int getPreviousMediaItemIndex() { + return player.getPreviousWindowIndex(); + } + + public int getNextMediaItemIndex() { + return player.getNextWindowIndex(); + } + + @Nullable + public androidx.media2.common.MediaItem getCurrentMediaItem() { + int index = getCurrentMediaItemIndex(); + return index == C.INDEX_UNSET ? null : media2Playlist.get(index); + } + + public boolean prepare() { + if (prepared) { + return false; + } + player.prepare(); + return true; + } + + public boolean play() { + if (player.getPlaybackState() == Player.STATE_ENDED) { + boolean seekHandled = + controlDispatcher.dispatchSeekTo( + player, player.getCurrentWindowIndex(), /* positionMs= */ 0); + if (!seekHandled) { + return false; + } + } + boolean playWhenReady = player.getPlayWhenReady(); + int suppressReason = player.getPlaybackSuppressionReason(); + if (playWhenReady && suppressReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) { + return false; + } + return controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ true); + } + + public boolean pause() { + boolean playWhenReady = player.getPlayWhenReady(); + int suppressReason = player.getPlaybackSuppressionReason(); + if (!playWhenReady && suppressReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) { + return false; + } + return controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false); + } + + public boolean seekTo(long position) { + return controlDispatcher.dispatchSeekTo(player, player.getCurrentWindowIndex(), position); + } + + public long getCurrentPosition() { + return player.getCurrentPosition(); + } + + public long getDuration() { + long duration = player.getDuration(); + return duration == C.TIME_UNSET ? SessionPlayer.UNKNOWN_TIME : duration; + } + + public long getBufferedPosition() { + return player.getBufferedPosition(); + } + + /* @SessionPlayer.PlayerState */ + private int getState() { + if (hasError()) { + return SessionPlayer.PLAYER_STATE_ERROR; + } + int state = player.getPlaybackState(); + boolean playWhenReady = player.getPlayWhenReady(); + switch (state) { + case Player.STATE_IDLE: + return SessionPlayer.PLAYER_STATE_IDLE; + case Player.STATE_ENDED: + return SessionPlayer.PLAYER_STATE_PAUSED; + case Player.STATE_BUFFERING: + case Player.STATE_READY: + return playWhenReady + ? SessionPlayer.PLAYER_STATE_PLAYING + : SessionPlayer.PLAYER_STATE_PAUSED; + default: + throw new IllegalStateException(); + } + } + + public void setAudioAttributes(AudioAttributesCompat audioAttributes) { + Player.AudioComponent audioComponent = Assertions.checkStateNotNull(player.getAudioComponent()); + audioComponent.setAudioAttributes( + Utils.getAudioAttributes(audioAttributes), /* handleAudioFocus= */ true); + } + + public AudioAttributesCompat getAudioAttributes() { + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); + return Utils.getAudioAttributesCompat( + audioComponent != null ? audioComponent.getAudioAttributes() : AudioAttributes.DEFAULT); + } + + public void setPlaybackSpeed(float playbackSpeed) { + player.setPlaybackParameters(new PlaybackParameters(playbackSpeed)); + } + + public float getPlaybackSpeed() { + return player.getPlaybackParameters().speed; + } + + public void reset() { + controlDispatcher.dispatchStop(player, /* reset= */ true); + prepared = false; + rebuffering = false; + } + + public void close() { + handler.removeCallbacks(pollBufferRunnable); + player.removeListener(componentListener); + + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); + if (audioComponent != null) { + audioComponent.removeAudioListener(componentListener); + } + } + + public boolean isCurrentMediaItemSeekable() { + return getCurrentMediaItem() != null + && !player.isPlayingAd() + && player.isCurrentWindowSeekable(); + } + + public boolean canSkipToPlaylistItem() { + @Nullable List playlist = getPlaylist(); + return playlist != null && playlist.size() > 1; + } + + public boolean canSkipToPreviousPlaylistItem() { + return player.hasPrevious(); + } + + public boolean canSkipToNextPlaylistItem() { + return player.hasNext(); + } + + public boolean hasError() { + return player.getPlayerError() != null; + } + + private void handlePlayWhenReadyChanged() { + listener.onPlayerStateChanged(getState()); + } + + private void handlePlayerStateChanged(@Player.State int state) { + if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) { + postOrRun(handler, pollBufferRunnable); + } else { + handler.removeCallbacks(pollBufferRunnable); + } + + switch (state) { + case Player.STATE_BUFFERING: + maybeNotifyBufferingEvents(); + break; + case Player.STATE_READY: + maybeNotifyReadyEvents(); + break; + case Player.STATE_ENDED: + maybeNotifyEndedEvents(); + break; + case Player.STATE_IDLE: + // Do nothing. + break; + default: + throw new IllegalStateException(); + } + } + + private void handlePositionDiscontinuity(@Player.DiscontinuityReason int reason) { + int currentWindowIndex = getCurrentMediaItemIndex(); + if (this.currentWindowIndex != currentWindowIndex) { + this.currentWindowIndex = currentWindowIndex; + androidx.media2.common.MediaItem currentMediaItem = + Assertions.checkNotNull(getCurrentMediaItem()); + listener.onCurrentMediaItemChanged(currentMediaItem); + } else { + listener.onSeekCompleted(); + } + } + + private void handlePlayerError() { + listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_ERROR); + listener.onError(getCurrentMediaItem()); + } + + private void handleRepeatModeChanged(@Player.RepeatMode int repeatMode) { + listener.onRepeatModeChanged(Utils.getRepeatMode(repeatMode)); + } + + private void handleShuffleMode(boolean shuffleModeEnabled) { + listener.onShuffleModeChanged(Utils.getShuffleMode(shuffleModeEnabled)); + } + + private void handlePlaybackParametersChanged(PlaybackParameters playbackParameters) { + listener.onPlaybackSpeedChanged(playbackParameters.speed); + } + + private void handleTimelineChanged(Timeline timeline) { + if (ignoreTimelineUpdates) { + return; + } + if (!isExoPlayerMediaItemsChanged(timeline)) { + return; + } + updatePlaylist(timeline); + listener.onPlaylistChanged(); + } + + // Check whether Timeline is changed by media item changes or not + private boolean isExoPlayerMediaItemsChanged(Timeline timeline) { + if (exoPlayerPlaylist.size() != timeline.getWindowCount()) { + return true; + } + Timeline.Window window = new Timeline.Window(); + int windowCount = timeline.getWindowCount(); + for (int i = 0; i < windowCount; i++) { + timeline.getWindow(i, window); + if (!ObjectsCompat.equals(exoPlayerPlaylist.get(i), window.mediaItem)) { + return true; + } + } + return false; + } + + private void updatePlaylist(Timeline timeline) { + List media2MediaItemToBeRemoved = + new ArrayList<>(media2Playlist); + media2Playlist.clear(); + exoPlayerPlaylist.clear(); + + Timeline.Window window = new Timeline.Window(); + int windowCount = timeline.getWindowCount(); + for (int i = 0; i < windowCount; i++) { + timeline.getWindow(i, window); + MediaItem exoPlayerMediaItem = window.mediaItem; + androidx.media2.common.MediaItem media2MediaItem = + Assertions.checkNotNull(mediaItemConverter.convertToMedia2MediaItem(exoPlayerMediaItem)); + exoPlayerPlaylist.add(exoPlayerMediaItem); + media2Playlist.add(media2MediaItem); + media2MediaItemToBeRemoved.remove(media2MediaItem); + } + + for (androidx.media2.common.MediaItem item : media2MediaItemToBeRemoved) { + releaseMediaItem(item); + } + } + + private void handleAudioAttributesChanged(AudioAttributes audioAttributes) { + listener.onAudioAttributesChanged(Utils.getAudioAttributesCompat(audioAttributes)); + } + + private void updateBufferingAndScheduleNextPollBuffer() { + androidx.media2.common.MediaItem media2MediaItem = + Assertions.checkNotNull(getCurrentMediaItem()); + listener.onBufferingUpdate(media2MediaItem, player.getBufferedPercentage()); + handler.removeCallbacks(pollBufferRunnable); + handler.postDelayed(pollBufferRunnable, POLL_BUFFER_INTERVAL_MS); + } + + private void maybeNotifyBufferingEvents() { + androidx.media2.common.MediaItem media2MediaItem = + Assertions.checkNotNull(getCurrentMediaItem()); + if (prepared && !rebuffering) { + rebuffering = true; + listener.onBufferingStarted(media2MediaItem); + } + } + + private void maybeNotifyReadyEvents() { + androidx.media2.common.MediaItem media2MediaItem = + Assertions.checkNotNull(getCurrentMediaItem()); + boolean prepareComplete = !prepared; + if (prepareComplete) { + prepared = true; + handlePositionDiscontinuity(Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_PAUSED); + listener.onPrepared(media2MediaItem, player.getBufferedPercentage()); + } + if (rebuffering) { + rebuffering = false; + listener.onBufferingEnded(media2MediaItem, player.getBufferedPercentage()); + } + } + + private void maybeNotifyEndedEvents() { + if (player.getPlayWhenReady()) { + listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_PAUSED); + listener.onPlaybackEnded(); + player.setPlayWhenReady(false); + } + } + + private void releaseMediaItem(androidx.media2.common.MediaItem media2MediaItem) { + try { + if (media2MediaItem instanceof CallbackMediaItem) { + ((CallbackMediaItem) media2MediaItem).getDataSourceCallback().close(); + } + } catch (IOException e) { + Log.w(TAG, "Error releasing media item " + media2MediaItem, e); + } + } + + private final class ComponentListener implements Player.EventListener, AudioListener { + + // Player.EventListener implementation. + + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + handlePlayWhenReadyChanged(); + } + + @Override + public void onPlaybackStateChanged(@Player.State int state) { + handlePlayerStateChanged(state); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + handlePositionDiscontinuity(reason); + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + handlePlayerError(); + } + + @Override + public void onRepeatModeChanged(@Player.RepeatMode int repeatMode) { + handleRepeatModeChanged(repeatMode); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + handleShuffleMode(shuffleModeEnabled); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + handlePlaybackParametersChanged(playbackParameters); + } + + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + handleTimelineChanged(timeline); + } + + // AudioListener implementation. + + @Override + public void onAudioAttributesChanged(AudioAttributes audioAttributes) { + handleAudioAttributesChanged(audioAttributes); + } + } + + private final class PollBufferRunnable implements Runnable { + @Override + public void run() { + updateBufferingAndScheduleNextPollBuffer(); + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java new file mode 100644 index 0000000000..1f60db947e --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java @@ -0,0 +1,384 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media2.common.MediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.Rating; +import androidx.media2.common.SessionPlayer; +import androidx.media2.session.MediaSession; +import androidx.media2.session.SessionCommand; +import androidx.media2.session.SessionCommandGroup; +import androidx.media2.session.SessionResult; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.AllowedCommandProvider; +import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.CustomCommandProvider; +import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.DisconnectedCallback; +import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.MediaItemProvider; +import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.PostConnectCallback; +import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.RatingCallback; +import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.SkipCallback; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import com.google.common.util.concurrent.ListenableFuture; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +/* package */ class SessionCallback extends MediaSession.SessionCallback { + private static final String TAG = "SessionCallback"; + + private final SessionPlayer sessionPlayer; + private final int fastForwardMs; + private final int rewindMs; + private final int seekTimeoutMs; + private final Set sessions; + private final AllowedCommandProvider allowedCommandProvider; + @Nullable private final RatingCallback ratingCallback; + @Nullable private final CustomCommandProvider customCommandProvider; + @Nullable private final MediaItemProvider mediaItemProvider; + @Nullable private final SkipCallback skipCallback; + @Nullable private final PostConnectCallback postConnectCallback; + @Nullable private final DisconnectedCallback disconnectedCallback; + private boolean loggedUnexpectedSessionPlayerWarning; + + public SessionCallback( + SessionPlayerConnector sessionPlayerConnector, + int fastForwardMs, + int rewindMs, + int seekTimeoutMs, + AllowedCommandProvider allowedCommandProvider, + @Nullable RatingCallback ratingCallback, + @Nullable CustomCommandProvider customCommandProvider, + @Nullable MediaItemProvider mediaItemProvider, + @Nullable SkipCallback skipCallback, + @Nullable PostConnectCallback postConnectCallback, + @Nullable DisconnectedCallback disconnectedCallback) { + this.sessionPlayer = sessionPlayerConnector; + this.allowedCommandProvider = allowedCommandProvider; + this.ratingCallback = ratingCallback; + this.customCommandProvider = customCommandProvider; + this.mediaItemProvider = mediaItemProvider; + this.skipCallback = skipCallback; + this.postConnectCallback = postConnectCallback; + this.disconnectedCallback = disconnectedCallback; + this.fastForwardMs = fastForwardMs; + this.rewindMs = rewindMs; + this.seekTimeoutMs = seekTimeoutMs; + this.sessions = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + // Register PlayerCallback and make it to be called before the ListenableFuture set the result. + // It help the PlayerCallback to update allowed commands before pended Player APIs are executed. + sessionPlayerConnector.registerPlayerCallback(Runnable::run, new PlayerCallback()); + } + + @Override + @Nullable + public SessionCommandGroup onConnect( + MediaSession session, MediaSession.ControllerInfo controllerInfo) { + sessions.add(session); + if (!allowedCommandProvider.acceptConnection(session, controllerInfo)) { + return null; + } + SessionCommandGroup baseAllowedCommands = buildAllowedCommands(session, controllerInfo); + return allowedCommandProvider.getAllowedCommands(session, controllerInfo, baseAllowedCommands); + } + + @Override + public void onPostConnect( + @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) { + if (postConnectCallback != null) { + postConnectCallback.onPostConnect(session, controller); + } + } + + @Override + public void onDisconnected(MediaSession session, MediaSession.ControllerInfo controller) { + if (session.getConnectedControllers().isEmpty()) { + sessions.remove(session); + } + if (disconnectedCallback != null) { + disconnectedCallback.onDisconnected(session, controller); + } + } + + @Override + public int onCommandRequest( + MediaSession session, MediaSession.ControllerInfo controller, SessionCommand command) { + return allowedCommandProvider.onCommandRequest(session, controller, command); + } + + @Override + @Nullable + public MediaItem onCreateMediaItem( + MediaSession session, MediaSession.ControllerInfo controller, String mediaId) { + Assertions.checkNotNull(mediaItemProvider); + return mediaItemProvider.onCreateMediaItem(session, controller, mediaId); + } + + @Override + public int onSetRating( + MediaSession session, MediaSession.ControllerInfo controller, String mediaId, Rating rating) { + if (ratingCallback != null) { + return ratingCallback.onSetRating(session, controller, mediaId, rating); + } + return SessionResult.RESULT_ERROR_NOT_SUPPORTED; + } + + @Override + public SessionResult onCustomCommand( + MediaSession session, + MediaSession.ControllerInfo controller, + SessionCommand customCommand, + @Nullable Bundle args) { + if (customCommandProvider != null) { + return customCommandProvider.onCustomCommand(session, controller, customCommand, args); + } + return new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED, null); + } + + @Override + public int onFastForward(MediaSession session, MediaSession.ControllerInfo controller) { + if (fastForwardMs > 0) { + return seekToOffset(fastForwardMs); + } + return SessionResult.RESULT_ERROR_NOT_SUPPORTED; + } + + @Override + public int onRewind(MediaSession session, MediaSession.ControllerInfo controller) { + if (rewindMs > 0) { + return seekToOffset(-rewindMs); + } + return SessionResult.RESULT_ERROR_NOT_SUPPORTED; + } + + @Override + public int onSkipBackward( + @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) { + if (skipCallback != null) { + return skipCallback.onSkipBackward(session, controller); + } + return SessionResult.RESULT_ERROR_NOT_SUPPORTED; + } + + @Override + public int onSkipForward( + @NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) { + if (skipCallback != null) { + return skipCallback.onSkipForward(session, controller); + } + return SessionResult.RESULT_ERROR_NOT_SUPPORTED; + } + + private int seekToOffset(long offsetMs) { + long positionMs = sessionPlayer.getCurrentPosition() + offsetMs; + long durationMs = sessionPlayer.getDuration(); + if (durationMs != C.TIME_UNSET) { + positionMs = Math.min(positionMs, durationMs); + } + positionMs = Math.max(positionMs, 0); + + ListenableFuture result = sessionPlayer.seekTo(positionMs); + try { + if (seekTimeoutMs <= 0) { + return result.get().getResultCode(); + } + return result.get(seekTimeoutMs, MILLISECONDS).getResultCode(); + } catch (ExecutionException | InterruptedException | TimeoutException e) { + Log.w(TAG, "Failed to get the seeking result", e); + return SessionResult.RESULT_ERROR_UNKNOWN; + } + } + + private SessionCommandGroup buildAllowedCommands( + MediaSession session, MediaSession.ControllerInfo controllerInfo) { + SessionCommandGroup.Builder build; + @Nullable + SessionCommandGroup commands = + (customCommandProvider != null) + ? customCommandProvider.getCustomCommands(session, controllerInfo) + : null; + if (commands != null) { + build = new SessionCommandGroup.Builder(commands); + } else { + build = new SessionCommandGroup.Builder(); + } + + build.addAllPredefinedCommands(SessionCommand.COMMAND_VERSION_1); + // TODO: Use removeCommand(int) when it's added [Internal: b/142848015]. + if (mediaItemProvider == null) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM)); + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SET_PLAYLIST)); + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM)); + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM)); + } + if (ratingCallback == null) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING)); + } + if (skipCallback == null) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SKIP_BACKWARD)); + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SKIP_FORWARD)); + } + + // Apply player's capability. + // Check whether the session has unexpectedly changed the player. + if (session.getPlayer() instanceof SessionPlayerConnector) { + SessionPlayerConnector sessionPlayerConnector = (SessionPlayerConnector) session.getPlayer(); + + // Check whether skipTo* works. + if (!sessionPlayerConnector.canSkipToPlaylistItem()) { + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM)); + } + if (!sessionPlayerConnector.canSkipToPreviousPlaylistItem()) { + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM)); + } + if (!sessionPlayerConnector.canSkipToNextPlaylistItem()) { + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM)); + } + + // Check whether seekTo/rewind/fastForward works. + if (!sessionPlayerConnector.isCurrentMediaItemSeekable()) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO)); + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD)); + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND)); + } else { + if (fastForwardMs <= 0) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD)); + } + if (rewindMs <= 0) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND)); + } + } + } else { + if (!loggedUnexpectedSessionPlayerWarning) { + // This can happen if MediaSession#updatePlayer() is called. + Log.e(TAG, "SessionPlayer isn't a SessionPlayerConnector. Guess the allowed command."); + loggedUnexpectedSessionPlayerWarning = true; + } + + if (fastForwardMs <= 0) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD)); + } + if (rewindMs <= 0) { + build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND)); + } + @Nullable List playlist = sessionPlayer.getPlaylist(); + if (playlist == null) { + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM)); + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM)); + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM)); + } else { + if (playlist.isEmpty() + && (sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_NONE + || sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_ONE)) { + build.removeCommand( + new SessionCommand( + SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM)); + } + if (playlist.size() == sessionPlayer.getCurrentMediaItemIndex() + 1 + && (sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_NONE + || sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_ONE)) { + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM)); + } + if (playlist.size() <= 1) { + build.removeCommand( + new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM)); + } + } + } + return build.build(); + } + + private static boolean isBufferedState(/* @SessionPlayer.BuffState */ int buffState) { + return buffState == SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE + || buffState == SessionPlayer.BUFFERING_STATE_COMPLETE; + } + + private final class PlayerCallback extends SessionPlayer.PlayerCallback { + private boolean currentMediaItemBuffered; + + @Override + public void onPlaylistChanged( + SessionPlayer player, @Nullable List list, @Nullable MediaMetadata metadata) { + updateAllowedCommands(); + } + + @Override + public void onPlayerStateChanged(SessionPlayer player, int playerState) { + updateAllowedCommands(); + } + + @Override + public void onRepeatModeChanged(SessionPlayer player, int repeatMode) { + updateAllowedCommands(); + } + + @Override + public void onShuffleModeChanged(SessionPlayer player, int shuffleMode) { + updateAllowedCommands(); + } + + @Override + public void onCurrentMediaItemChanged(SessionPlayer player, MediaItem item) { + currentMediaItemBuffered = isBufferedState(player.getBufferingState()); + updateAllowedCommands(); + } + + @Override + public void onBufferingStateChanged( + SessionPlayer player, @Nullable MediaItem item, int buffState) { + if (currentMediaItemBuffered || player.getCurrentMediaItem() != item) { + return; + } + if (isBufferedState(buffState)) { + currentMediaItemBuffered = true; + updateAllowedCommands(); + } + } + + private void updateAllowedCommands() { + for (MediaSession session : sessions) { + List connectedControllers = session.getConnectedControllers(); + for (MediaSession.ControllerInfo controller : connectedControllers) { + SessionCommandGroup baseAllowedCommands = buildAllowedCommands(session, controller); + SessionCommandGroup allowedCommands = + allowedCommandProvider.getAllowedCommands(session, controller, baseAllowedCommands); + if (allowedCommands == null) { + allowedCommands = new SessionCommandGroup.Builder().build(); + } + session.setAllowedCommands(controller, allowedCommands); + } + } + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java new file mode 100644 index 0000000000..516ec20b3b --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java @@ -0,0 +1,550 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import android.Manifest; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.provider.Settings; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.media.MediaSessionManager; +import androidx.media.MediaSessionManager.RemoteUserInfo; +import androidx.media2.common.MediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.Rating; +import androidx.media2.common.SessionPlayer; +import androidx.media2.session.MediaController; +import androidx.media2.session.MediaSession; +import androidx.media2.session.MediaSession.ControllerInfo; +import androidx.media2.session.SessionCommand; +import androidx.media2.session.SessionCommandGroup; +import androidx.media2.session.SessionResult; +import com.google.android.exoplayer2.util.Assertions; +import java.util.ArrayList; +import java.util.List; + +/** + * Builds a {@link MediaSession.SessionCallback} with various collaborators. + * + * @see MediaSession.SessionCallback + */ +public final class SessionCallbackBuilder { + /** Default timeout value for {@link #setSeekTimeoutMs}. */ + public static final int DEFAULT_SEEK_TIMEOUT_MS = 1_000; + + private final Context context; + private final SessionPlayerConnector sessionPlayerConnector; + private int fastForwardMs; + private int rewindMs; + private int seekTimeoutMs; + @Nullable private RatingCallback ratingCallback; + @Nullable private CustomCommandProvider customCommandProvider; + @Nullable private MediaItemProvider mediaItemProvider; + @Nullable private AllowedCommandProvider allowedCommandProvider; + @Nullable private SkipCallback skipCallback; + @Nullable private PostConnectCallback postConnectCallback; + @Nullable private DisconnectedCallback disconnectedCallback; + + /** Provides allowed commands for {@link MediaController}. */ + public interface AllowedCommandProvider { + /** + * Called to query whether to allow connection from the controller. + * + *

If it returns {@code true} to accept connection, then {@link #getAllowedCommands} will be + * immediately followed to return initial allowed command. + * + *

Prefer use {@link PostConnectCallback} for any extra initialization about controller, + * where controller is connected and session can send commands to the controller. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller that is requesting + * connect. + * @return {@code true} to accept connection. {@code false} otherwise. + */ + boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo); + + /** + * Called to query allowed commands in following cases: + * + *

    + *
  • A {@link MediaController} requests to connect, and allowed commands is required to tell + * initial allowed commands. + *
  • Underlying {@link SessionPlayer} state changes, and allowed commands may be updated via + * {@link MediaSession#setAllowedCommands}. + *
+ * + *

The provided {@code baseAllowedSessionCommand} is built automatically based on the state + * of the {@link SessionPlayer}, {@link RatingCallback}, {@link MediaItemProvider}, {@link + * CustomCommandProvider}, and {@link SkipCallback} so may be a useful starting point for any + * required customizations. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller for which allowed + * commands are being queried. + * @param baseAllowedSessionCommand Base allowed session commands for customization. + * @return The allowed commands for the controller. + * @see MediaSession.SessionCallback#onConnect(MediaSession, ControllerInfo) + */ + SessionCommandGroup getAllowedCommands( + MediaSession session, + ControllerInfo controllerInfo, + SessionCommandGroup baseAllowedSessionCommand); + + /** + * Called when a {@link MediaController} has called an API that controls {@link SessionPlayer} + * set to the {@link MediaSession}. + * + * @param session The media session. + * @param controllerInfo A {@link ControllerInfo} that needs allowed command update. + * @param command A {@link SessionCommand} from the controller. + * @return A session result code defined in {@link SessionResult}. + * @see MediaSession.SessionCallback#onCommandRequest + */ + int onCommandRequest( + MediaSession session, ControllerInfo controllerInfo, SessionCommand command); + } + + /** Callback receiving a user rating for a specified media id. */ + public interface RatingCallback { + /** + * Called when the specified controller has set a rating for the specified media id. + * + * @see MediaSession.SessionCallback#onSetRating(MediaSession, MediaSession.ControllerInfo, + * String, Rating) + * @see androidx.media2.session.MediaController#setRating(String, Rating) + * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or + * failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the + * operation succeeded. + */ + int onSetRating(MediaSession session, ControllerInfo controller, String mediaId, Rating rating); + } + + /** + * Callbacks for querying what custom commands are supported, and for handling a custom command + * when a controller sends it. + */ + public interface CustomCommandProvider { + /** + * Called when a controller has sent a custom command. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller that sent the custom + * command. + * @param customCommand A {@link SessionCommand} from the controller. + * @param args A {@link Bundle} with the extra argument. + * @see MediaSession.SessionCallback#onCustomCommand(MediaSession, MediaSession.ControllerInfo, + * SessionCommand, Bundle) + * @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle) + */ + SessionResult onCustomCommand( + MediaSession session, + ControllerInfo controllerInfo, + SessionCommand customCommand, + @Nullable Bundle args); + + /** + * Returns a {@link SessionCommandGroup} with custom commands to publish to the controller, or + * {@code null} if no custom commands should be published. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller that is requesting custom + * commands. + * @return The custom commands to publish, or {@code null} if no custom commands should be + * published. + */ + @Nullable + SessionCommandGroup getCustomCommands(MediaSession session, ControllerInfo controllerInfo); + } + + /** Provides the {@link MediaItem}. */ + public interface MediaItemProvider { + /** + * Called when {@link MediaSession.SessionCallback#onCreateMediaItem(MediaSession, + * ControllerInfo, String)} is called. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to + * create the item. + * @return A new {@link MediaItem} that {@link SessionPlayerConnector} can play. + * @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String) + * @see androidx.media2.session.MediaController#addPlaylistItem(int, String) + * @see androidx.media2.session.MediaController#replacePlaylistItem(int, String) + * @see androidx.media2.session.MediaController#setMediaItem(String) + * @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata) + */ + @Nullable + MediaItem onCreateMediaItem( + MediaSession session, ControllerInfo controllerInfo, String mediaId); + } + + /** Callback receiving skip backward and skip forward. */ + public interface SkipCallback { + /** + * Called when the specified controller has sent skip backward. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to + * skip backward. + * @see MediaSession.SessionCallback#onSkipBackward(MediaSession, MediaSession.ControllerInfo) + * @see MediaController#skipBackward() + * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or + * failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the + * operation succeeded. + */ + int onSkipBackward(MediaSession session, ControllerInfo controllerInfo); + + /** + * Called when the specified controller has sent skip forward. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller that has requested to + * skip forward. + * @see MediaSession.SessionCallback#onSkipForward(MediaSession, MediaSession.ControllerInfo) + * @see MediaController#skipForward() + * @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or + * failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the + * operation succeeded. + */ + int onSkipForward(MediaSession session, ControllerInfo controllerInfo); + } + + /** Callback for handling extra initialization after the connection. */ + public interface PostConnectCallback { + /** + * Called after the specified controller is connected, and you need extra initialization. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the controller that just connected. + * @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo) + */ + void onPostConnect(MediaSession session, MediaSession.ControllerInfo controllerInfo); + } + + /** Callback for handling controller disconnection. */ + public interface DisconnectedCallback { + /** + * Called when the specified controller is disconnected. + * + * @param session The media session. + * @param controllerInfo The {@link ControllerInfo} for the disconnected controller. + * @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo) + */ + void onDisconnected(MediaSession session, MediaSession.ControllerInfo controllerInfo); + } + + /** + * Default implementation of {@link AllowedCommandProvider} that behaves as follows: + * + *

    + *
  • Accepts connection requests from controller if any of the following conditions are met: + *
      + *
    • Controller is in the same package as the session. + *
    • Controller is allowed via {@link #setTrustedPackageNames(List)}. + *
    • Controller has package name {@link RemoteUserInfo#LEGACY_CONTROLLER}. See {@link + * ControllerInfo#getPackageName() package name limitation} for details. + *
    • Controller is trusted (i.e. has MEDIA_CONTENT_CONTROL permission or has enabled + * notification manager). + *
    + *
  • Allows all commands that the current player can handle. + *
  • Accepts all command requests for allowed commands. + *
+ * + *

Note: this implementation matches the behavior of the ExoPlayer MediaSession extension and + * {@link android.support.v4.media.session.MediaSessionCompat}. + */ + public static final class DefaultAllowedCommandProvider implements AllowedCommandProvider { + private final Context context; + private final List trustedPackageNames; + + public DefaultAllowedCommandProvider(Context context) { + this.context = context; + trustedPackageNames = new ArrayList<>(); + } + + @Override + public boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo) { + return TextUtils.equals(controllerInfo.getPackageName(), context.getPackageName()) + || TextUtils.equals(controllerInfo.getPackageName(), RemoteUserInfo.LEGACY_CONTROLLER) + || trustedPackageNames.contains(controllerInfo.getPackageName()) + || isTrusted(controllerInfo); + } + + @Override + public SessionCommandGroup getAllowedCommands( + MediaSession session, + ControllerInfo controllerInfo, + SessionCommandGroup baseAllowedSessionCommands) { + return baseAllowedSessionCommands; + } + + @Override + public int onCommandRequest( + MediaSession session, ControllerInfo controllerInfo, SessionCommand command) { + return SessionResult.RESULT_SUCCESS; + } + + /** + * Sets the package names from which the session will accept incoming connections. + * + *

Apps that have {@code android.Manifest.permission.MEDIA_CONTENT_CONTROL}, packages listed + * in enabled_notification_listeners and the current package are always trusted, even if they + * are not specified here. + * + * @param packageNames Package names from which the session will accept incoming connections. + * @see MediaSession.SessionCallback#onConnect(MediaSession, MediaSession.ControllerInfo) + * @see MediaSessionManager#isTrustedForMediaControl(RemoteUserInfo) + */ + public void setTrustedPackageNames(@Nullable List packageNames) { + trustedPackageNames.clear(); + if (packageNames != null && !packageNames.isEmpty()) { + trustedPackageNames.addAll(packageNames); + } + } + + // TODO: Replace with ControllerInfo#isTrusted() when it's unhidden [Internal: b/142835448]. + private boolean isTrusted(MediaSession.ControllerInfo controllerInfo) { + // Check whether the controller has granted MEDIA_CONTENT_CONTROL. + if (context + .getPackageManager() + .checkPermission( + Manifest.permission.MEDIA_CONTENT_CONTROL, controllerInfo.getPackageName()) + == PackageManager.PERMISSION_GRANTED) { + return true; + } + + // Check whether the app has an enabled notification listener. + String enabledNotificationListeners = + Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners"); + if (!TextUtils.isEmpty(enabledNotificationListeners)) { + String[] components = enabledNotificationListeners.split(":"); + for (String componentString : components) { + @Nullable ComponentName component = ComponentName.unflattenFromString(componentString); + if (component != null) { + if (component.getPackageName().equals(controllerInfo.getPackageName())) { + return true; + } + } + } + } + return false; + } + } + + /** A {@link MediaItemProvider} that creates media items containing only a media ID. */ + public static final class MediaIdMediaItemProvider implements MediaItemProvider { + @Override + @Nullable + public MediaItem onCreateMediaItem( + MediaSession session, ControllerInfo controllerInfo, String mediaId) { + if (TextUtils.isEmpty(mediaId)) { + return null; + } + MediaMetadata metadata = + new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, mediaId) + .build(); + return new MediaItem.Builder().setMetadata(metadata).build(); + } + } + + /** + * Creates a new builder. + * + *

The builder uses the following default values: + * + *

    + *
  • {@link AllowedCommandProvider}: {@link DefaultAllowedCommandProvider} + *
  • Seek timeout: {@link #DEFAULT_SEEK_TIMEOUT_MS} + *
  • + *
+ * + * Unless stated above, {@code null} or {@code 0} would be used to disallow relevant features. + * + * @param context A context. + * @param sessionPlayerConnector A session player connector to handle incoming calls from the + * controller. + */ + public SessionCallbackBuilder(Context context, SessionPlayerConnector sessionPlayerConnector) { + this.context = Assertions.checkNotNull(context); + this.sessionPlayerConnector = Assertions.checkNotNull(sessionPlayerConnector); + this.seekTimeoutMs = DEFAULT_SEEK_TIMEOUT_MS; + } + + /** + * Sets the {@link RatingCallback} to handle user ratings. + * + * @param ratingCallback A rating callback. + * @return This builder. + * @see MediaSession.SessionCallback#onSetRating(MediaSession, ControllerInfo, String, Rating) + * @see androidx.media2.session.MediaController#setRating(String, Rating) + */ + public SessionCallbackBuilder setRatingCallback(@Nullable RatingCallback ratingCallback) { + this.ratingCallback = ratingCallback; + return this; + } + + /** + * Sets the {@link CustomCommandProvider} to handle incoming custom commands. + * + * @param customCommandProvider A custom command provider. + * @return This builder. + * @see MediaSession.SessionCallback#onCustomCommand(MediaSession, ControllerInfo, SessionCommand, + * Bundle) + * @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle) + */ + public SessionCallbackBuilder setCustomCommandProvider( + @Nullable CustomCommandProvider customCommandProvider) { + this.customCommandProvider = customCommandProvider; + return this; + } + + /** + * Sets the {@link MediaItemProvider} that will convert media ids to {@link MediaItem MediaItems}. + * + * @param mediaItemProvider The media item provider. + * @return This builder. + * @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String) + * @see androidx.media2.session.MediaController#addPlaylistItem(int, String) + * @see androidx.media2.session.MediaController#replacePlaylistItem(int, String) + * @see androidx.media2.session.MediaController#setMediaItem(String) + * @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata) + */ + public SessionCallbackBuilder setMediaItemProvider( + @Nullable MediaItemProvider mediaItemProvider) { + this.mediaItemProvider = mediaItemProvider; + return this; + } + + /** + * Sets the {@link AllowedCommandProvider} to provide allowed commands for controllers. + * + * @param allowedCommandProvider A allowed command provider. + * @return This builder. + */ + public SessionCallbackBuilder setAllowedCommandProvider( + @Nullable AllowedCommandProvider allowedCommandProvider) { + this.allowedCommandProvider = allowedCommandProvider; + return this; + } + + /** + * Sets the {@link SkipCallback} to handle skip backward and skip forward. + * + * @param skipCallback The skip callback. + * @return This builder. + * @see MediaSession.SessionCallback#onSkipBackward(MediaSession, ControllerInfo) + * @see MediaSession.SessionCallback#onSkipForward(MediaSession, ControllerInfo) + * @see MediaController#skipBackward() + * @see MediaController#skipForward() + */ + public SessionCallbackBuilder setSkipCallback(@Nullable SkipCallback skipCallback) { + this.skipCallback = skipCallback; + return this; + } + + /** + * Sets the {@link PostConnectCallback} to handle extra initialization after the connection. + * + * @param postConnectCallback The post connect callback. + * @return This builder. + * @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo) + */ + public SessionCallbackBuilder setPostConnectCallback( + @Nullable PostConnectCallback postConnectCallback) { + this.postConnectCallback = postConnectCallback; + return this; + } + + /** + * Sets the {@link DisconnectedCallback} to handle cleaning up controller. + * + * @param disconnectedCallback The disconnected callback. + * @return This builder. + * @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo) + */ + public SessionCallbackBuilder setDisconnectedCallback( + @Nullable DisconnectedCallback disconnectedCallback) { + this.disconnectedCallback = disconnectedCallback; + return this; + } + + /** + * Sets the rewind increment in milliseconds. + * + * @param rewindMs The rewind increment in milliseconds. A non-positive value will cause the + * rewind to be disabled. + * @return This builder. + * @see MediaSession.SessionCallback#onRewind(MediaSession, MediaSession.ControllerInfo) + * @see #setSeekTimeoutMs(int) + */ + public SessionCallbackBuilder setRewindIncrementMs(int rewindMs) { + this.rewindMs = rewindMs; + return this; + } + + /** + * Sets the fast forward increment in milliseconds. + * + * @param fastForwardMs The fast forward increment in milliseconds. A non-positive value will + * cause the fast forward to be disabled. + * @return This builder. + * @see MediaSession.SessionCallback#onFastForward(MediaSession, MediaSession.ControllerInfo) + * @see #setSeekTimeoutMs(int) + */ + public SessionCallbackBuilder setFastForwardIncrementMs(int fastForwardMs) { + this.fastForwardMs = fastForwardMs; + return this; + } + + /** + * Sets the timeout in milliseconds for fast forward and rewind operations, or {@code 0} for no + * timeout. If a timeout is set, controllers will receive an error if the session's call to {@link + * SessionPlayer#seekTo} takes longer than this amount of time. + * + * @param seekTimeoutMs A timeout for {@link SessionPlayer#seekTo}. A non-positive value will wait + * forever. + * @return This builder. + */ + public SessionCallbackBuilder setSeekTimeoutMs(int seekTimeoutMs) { + this.seekTimeoutMs = seekTimeoutMs; + return this; + } + + /** + * Builds {@link MediaSession.SessionCallback}. + * + * @return A new callback for a media session. + */ + public MediaSession.SessionCallback build() { + return new SessionCallback( + sessionPlayerConnector, + fastForwardMs, + rewindMs, + seekTimeoutMs, + allowedCommandProvider == null + ? new DefaultAllowedCommandProvider(context) + : allowedCommandProvider, + ratingCallback, + customCommandProvider, + mediaItemProvider, + skipCallback, + postConnectCallback, + disconnectedCallback); + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java new file mode 100644 index 0000000000..1c6cc151c9 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java @@ -0,0 +1,776 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import static com.google.android.exoplayer2.util.Util.postOrRun; + +import android.os.Handler; +import androidx.annotation.FloatRange; +import androidx.annotation.GuardedBy; +import androidx.annotation.IntRange; +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; +import androidx.core.util.Pair; +import androidx.media.AudioAttributesCompat; +import androidx.media2.common.CallbackMediaItem; +import androidx.media2.common.FileMediaItem; +import androidx.media2.common.MediaItem; +import androidx.media2.common.MediaMetadata; +import androidx.media2.common.SessionPlayer; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * An implementation of {@link SessionPlayer} that wraps a given ExoPlayer {@link Player} instance. + * + *

Internally this implementation posts operations to and receives callbacks on the thread + * associated with {@link Player#getApplicationLooper()}, so it is important not to block this + * thread. In particular, when awaiting the result of an asynchronous session player operation, apps + * should generally use {@link ListenableFuture#addListener(Runnable, Executor)} to be notified of + * completion, rather than calling the blocking {@link ListenableFuture#get()} method. + */ +public final class SessionPlayerConnector extends SessionPlayer { + + static { + ExoPlayerLibraryInfo.registerModule("goog.exo.media2"); + } + + private static final String TAG = "SessionPlayerConnector"; + private static final boolean DEBUG = false; + + private static final int END_OF_PLAYLIST = -1; + private final Object stateLock = new Object(); + + private final Handler taskHandler; + private final Executor taskHandlerExecutor; + private final PlayerWrapper player; + private final PlayerCommandQueue playerCommandQueue; + + @GuardedBy("stateLock") + private final Map mediaItemToBuffState = new HashMap<>(); + + @GuardedBy("stateLock") + /* @PlayerState */ + private int state; + + @GuardedBy("stateLock") + private boolean closed; + + // Should be only accessed on the executor, which is currently single-threaded. + @Nullable private MediaItem currentMediaItem; + + /** + * Creates an instance using {@link DefaultMediaItemConverter} to convert between ExoPlayer and + * media2 MediaItems and {@link DefaultControlDispatcher} to dispatch player commands. + * + * @param player The player to wrap. + */ + public SessionPlayerConnector(Player player) { + this(player, new DefaultMediaItemConverter()); + } + + /** + * Creates an instance using the provided {@link ControlDispatcher} to dispatch player commands. + * + * @param player The player to wrap. + * @param mediaItemConverter The {@link MediaItemConverter}. + */ + public SessionPlayerConnector(Player player, MediaItemConverter mediaItemConverter) { + Assertions.checkNotNull(player); + Assertions.checkNotNull(mediaItemConverter); + + state = PLAYER_STATE_IDLE; + taskHandler = new Handler(player.getApplicationLooper()); + taskHandlerExecutor = (runnable) -> postOrRun(taskHandler, runnable); + + this.player = new PlayerWrapper(new ExoPlayerWrapperListener(), player, mediaItemConverter); + playerCommandQueue = new PlayerCommandQueue(this.player, taskHandler); + } + + /** + * Sets the {@link ControlDispatcher}. + * + * @param controlDispatcher The {@link ControlDispatcher}. + */ + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + player.setControlDispatcher(controlDispatcher); + } + + @Override + public ListenableFuture play() { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY, /* command= */ player::play); + } + + @Override + public ListenableFuture pause() { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE, /* command= */ player::pause); + } + + @Override + public ListenableFuture prepare() { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE, /* command= */ player::prepare); + } + + @Override + public ListenableFuture seekTo(long position) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SEEK_TO, + /* command= */ () -> player.seekTo(position), + /* tag= */ position); + } + + @Override + public ListenableFuture setPlaybackSpeed( + @FloatRange(from = 0.0f, to = Float.MAX_VALUE, fromInclusive = false) float playbackSpeed) { + Assertions.checkArgument(playbackSpeed > 0f); + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SPEED, + /* command= */ () -> { + player.setPlaybackSpeed(playbackSpeed); + return true; + }); + } + + @Override + public ListenableFuture setAudioAttributes(AudioAttributesCompat attr) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES, + /* command= */ () -> { + player.setAudioAttributes(Assertions.checkNotNull(attr)); + return true; + }); + } + + @Override + /* @PlayerState */ + public int getPlayerState() { + synchronized (stateLock) { + return state; + } + } + + @Override + public long getCurrentPosition() { + long position = + runPlayerCallableBlocking( + /* callable= */ player::getCurrentPosition, + /* defaultValueWhenException= */ UNKNOWN_TIME); + return position >= 0 ? position : UNKNOWN_TIME; + } + + @Override + public long getDuration() { + long position = + runPlayerCallableBlocking( + /* callable= */ player::getDuration, /* defaultValueWhenException= */ UNKNOWN_TIME); + return position >= 0 ? position : UNKNOWN_TIME; + } + + @Override + public long getBufferedPosition() { + long position = + runPlayerCallableBlocking( + /* callable= */ player::getBufferedPosition, + /* defaultValueWhenException= */ UNKNOWN_TIME); + return position >= 0 ? position : UNKNOWN_TIME; + } + + @Override + /* @BuffState */ + public int getBufferingState() { + @Nullable + MediaItem mediaItem = + this.<@NullableType MediaItem>runPlayerCallableBlocking( + /* callable= */ player::getCurrentMediaItem, /* defaultValueWhenException= */ null); + if (mediaItem == null) { + return BUFFERING_STATE_UNKNOWN; + } + @Nullable Integer buffState; + synchronized (stateLock) { + buffState = mediaItemToBuffState.get(mediaItem); + } + return buffState == null ? BUFFERING_STATE_UNKNOWN : buffState; + } + + @Override + @FloatRange(from = 0.0f, to = Float.MAX_VALUE, fromInclusive = false) + public float getPlaybackSpeed() { + return runPlayerCallableBlocking( + /* callable= */ player::getPlaybackSpeed, /* defaultValueWhenException= */ 1.0f); + } + + @Override + @Nullable + public AudioAttributesCompat getAudioAttributes() { + return runPlayerCallableBlockingWithNullOnException(/* callable= */ player::getAudioAttributes); + } + + /** + * {@inheritDoc} + * + *

{@link FileMediaItem} and {@link CallbackMediaItem} are not supported. + */ + @Override + public ListenableFuture setMediaItem(MediaItem item) { + Assertions.checkNotNull(item); + Assertions.checkArgument(!(item instanceof FileMediaItem)); + Assertions.checkArgument(!(item instanceof CallbackMediaItem)); + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, () -> player.setMediaItem(item)); + return result; + } + + /** + * {@inheritDoc} + * + *

{@link FileMediaItem} and {@link CallbackMediaItem} are not supported. + */ + @Override + public ListenableFuture setPlaylist( + final List playlist, @Nullable MediaMetadata metadata) { + Assertions.checkNotNull(playlist); + Assertions.checkArgument(!playlist.isEmpty()); + for (int i = 0; i < playlist.size(); i++) { + MediaItem item = playlist.get(i); + Assertions.checkNotNull(item); + Assertions.checkArgument(!(item instanceof FileMediaItem)); + Assertions.checkArgument(!(item instanceof CallbackMediaItem)); + for (int j = 0; j < i; j++) { + Assertions.checkArgument( + item != playlist.get(j), + "playlist shouldn't contain duplicated item, index=" + i + " vs index=" + j); + } + } + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_PLAYLIST, + /* command= */ () -> player.setPlaylist(playlist, metadata)); + return result; + } + + /** + * {@inheritDoc} + * + *

{@link FileMediaItem} and {@link CallbackMediaItem} are not supported. + */ + @Override + public ListenableFuture addPlaylistItem(int index, MediaItem item) { + Assertions.checkArgument(index >= 0); + Assertions.checkNotNull(item); + Assertions.checkArgument(!(item instanceof FileMediaItem)); + Assertions.checkArgument(!(item instanceof CallbackMediaItem)); + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM, + /* command= */ () -> player.addPlaylistItem(index, item)); + return result; + } + + @Override + public ListenableFuture removePlaylistItem(@IntRange(from = 0) int index) { + Assertions.checkArgument(index >= 0); + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM, + /* command= */ () -> player.removePlaylistItem(index)); + return result; + } + + /** + * {@inheritDoc} + * + *

{@link FileMediaItem} and {@link CallbackMediaItem} are not supported. + */ + @Override + public ListenableFuture replacePlaylistItem(int index, MediaItem item) { + Assertions.checkArgument(index >= 0); + Assertions.checkNotNull(item); + Assertions.checkArgument(!(item instanceof FileMediaItem)); + Assertions.checkArgument(!(item instanceof CallbackMediaItem)); + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM, + /* command= */ () -> player.replacePlaylistItem(index, item)); + return result; + } + + @Override + public ListenableFuture skipToPreviousPlaylistItem() { + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM, + /* command= */ player::skipToPreviousPlaylistItem); + result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor); + return result; + } + + @Override + public ListenableFuture skipToNextPlaylistItem() { + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM, + /* command= */ player::skipToNextPlaylistItem); + result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor); + return result; + } + + @Override + public ListenableFuture skipToPlaylistItem(@IntRange(from = 0) int index) { + Assertions.checkArgument(index >= 0); + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM, + /* command= */ () -> player.skipToPlaylistItem(index)); + result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor); + return result; + } + + @Override + public ListenableFuture updatePlaylistMetadata(@Nullable MediaMetadata metadata) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA, + /* command= */ () -> { + boolean handled = player.updatePlaylistMetadata(metadata); + if (handled) { + notifySessionPlayerCallback( + callback -> + callback.onPlaylistMetadataChanged(SessionPlayerConnector.this, metadata)); + } + return handled; + }); + } + + @Override + public ListenableFuture setRepeatMode(int repeatMode) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_REPEAT_MODE, + /* command= */ () -> player.setRepeatMode(repeatMode)); + } + + @Override + public ListenableFuture setShuffleMode(int shuffleMode) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE, + /* command= */ () -> player.setShuffleMode(shuffleMode)); + } + + @Override + @Nullable + public List getPlaylist() { + return runPlayerCallableBlockingWithNullOnException(/* callable= */ player::getPlaylist); + } + + @Override + @Nullable + public MediaMetadata getPlaylistMetadata() { + return runPlayerCallableBlockingWithNullOnException( + /* callable= */ player::getPlaylistMetadata); + } + + @Override + public int getRepeatMode() { + return runPlayerCallableBlocking( + /* callable= */ player::getRepeatMode, /* defaultValueWhenException= */ REPEAT_MODE_NONE); + } + + @Override + public int getShuffleMode() { + return runPlayerCallableBlocking( + /* callable= */ player::getShuffleMode, /* defaultValueWhenException= */ SHUFFLE_MODE_NONE); + } + + @Override + @Nullable + public MediaItem getCurrentMediaItem() { + return runPlayerCallableBlockingWithNullOnException( + /* callable= */ player::getCurrentMediaItem); + } + + @Override + public int getCurrentMediaItemIndex() { + return runPlayerCallableBlocking( + /* callable= */ player::getCurrentMediaItemIndex, + /* defaultValueWhenException= */ END_OF_PLAYLIST); + } + + @Override + public int getPreviousMediaItemIndex() { + return runPlayerCallableBlocking( + /* callable= */ player::getPreviousMediaItemIndex, + /* defaultValueWhenException= */ END_OF_PLAYLIST); + } + + @Override + public int getNextMediaItemIndex() { + return runPlayerCallableBlocking( + /* callable= */ player::getNextMediaItemIndex, + /* defaultValueWhenException= */ END_OF_PLAYLIST); + } + + // TODO(b/147706139): Call super.close() after updating media2-common to 1.1.0 + @SuppressWarnings("MissingSuperCall") + @Override + public void close() { + synchronized (stateLock) { + if (closed) { + return; + } + closed = true; + } + reset(); + + this.runPlayerCallableBlocking( + /* callable= */ () -> { + player.close(); + return null; + }); + } + + // SessionPlayerConnector-specific functions. + + /** + * Returns whether the current media item is seekable. + * + * @return {@code true} if supported. {@code false} otherwise. + */ + /* package */ boolean isCurrentMediaItemSeekable() { + return runPlayerCallableBlocking( + /* callable= */ player::isCurrentMediaItemSeekable, /* defaultValueWhenException= */ false); + } + + /** + * Returns whether {@link #skipToPlaylistItem(int)} is supported. + * + * @return {@code true} if supported. {@code false} otherwise. + */ + /* package */ boolean canSkipToPlaylistItem() { + return runPlayerCallableBlocking( + /* callable= */ player::canSkipToPlaylistItem, /* defaultValueWhenException= */ false); + } + + /** + * Returns whether {@link #skipToPreviousPlaylistItem()} is supported. + * + * @return {@code true} if supported. {@code false} otherwise. + */ + /* package */ boolean canSkipToPreviousPlaylistItem() { + return runPlayerCallableBlocking( + /* callable= */ player::canSkipToPreviousPlaylistItem, + /* defaultValueWhenException= */ false); + } + + /** + * Returns whether {@link #skipToNextPlaylistItem()} is supported. + * + * @return {@code true} if supported. {@code false} otherwise. + */ + /* package */ boolean canSkipToNextPlaylistItem() { + return runPlayerCallableBlocking( + /* callable= */ player::canSkipToNextPlaylistItem, /* defaultValueWhenException= */ false); + } + + /** + * Resets {@link SessionPlayerConnector} to its uninitialized state if not closed. After calling + * this method, you will have to initialize it again by setting the media item and calling {@link + * #prepare()}. + * + *

Note that if the player is closed, there is no way to reuse the instance. + */ + private void reset() { + // Cancel the pending commands. + playerCommandQueue.reset(); + synchronized (stateLock) { + state = PLAYER_STATE_IDLE; + mediaItemToBuffState.clear(); + } + this.runPlayerCallableBlocking( + /* callable= */ () -> { + player.reset(); + return null; + }); + } + + private void setState(/* @PlayerState */ int state) { + boolean needToNotify = false; + synchronized (stateLock) { + if (this.state != state) { + this.state = state; + needToNotify = true; + } + } + if (needToNotify) { + notifySessionPlayerCallback( + callback -> callback.onPlayerStateChanged(SessionPlayerConnector.this, state)); + } + } + + private void setBufferingState(MediaItem item, /* @BuffState */ int state) { + @Nullable Integer previousState; + synchronized (stateLock) { + previousState = mediaItemToBuffState.put(item, state); + } + if (previousState == null || previousState != state) { + notifySessionPlayerCallback( + callback -> callback.onBufferingStateChanged(SessionPlayerConnector.this, item, state)); + } + } + + private void notifySessionPlayerCallback(SessionPlayerCallbackNotifier notifier) { + synchronized (stateLock) { + if (closed) { + return; + } + } + List> callbacks = getCallbacks(); + for (Pair pair : callbacks) { + SessionPlayer.PlayerCallback callback = Assertions.checkNotNull(pair.first); + Executor executor = Assertions.checkNotNull(pair.second); + executor.execute(() -> notifier.callCallback(callback)); + } + } + + private void handlePlaylistChangedOnHandler() { + List currentPlaylist = player.getPlaylist(); + MediaMetadata playlistMetadata = player.getPlaylistMetadata(); + + MediaItem currentMediaItem = player.getCurrentMediaItem(); + boolean notifyCurrentMediaItem = !ObjectsCompat.equals(this.currentMediaItem, currentMediaItem); + this.currentMediaItem = currentMediaItem; + + long currentPosition = getCurrentPosition(); + notifySessionPlayerCallback( + callback -> { + callback.onPlaylistChanged( + SessionPlayerConnector.this, currentPlaylist, playlistMetadata); + if (notifyCurrentMediaItem) { + Assertions.checkNotNull( + currentMediaItem, "PlaylistManager#currentMediaItem() cannot be changed to null"); + + callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, currentMediaItem); + + // Workaround for MediaSession's issue that current media item change isn't propagated + // to the legacy controllers. + // TODO(b/160846312): Remove this workaround with media2 1.1.0-stable. + callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition); + } + }); + } + + private void notifySkipToCompletedOnHandler() { + MediaItem currentMediaItem = Assertions.checkNotNull(player.getCurrentMediaItem()); + if (ObjectsCompat.equals(this.currentMediaItem, currentMediaItem)) { + return; + } + this.currentMediaItem = currentMediaItem; + long currentPosition = getCurrentPosition(); + notifySessionPlayerCallback( + callback -> { + callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, currentMediaItem); + + // Workaround for MediaSession's issue that current media item change isn't propagated + // to the legacy controllers. + // TODO(b/160846312): Remove this workaround with media2 1.1.0-stable. + callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition); + }); + } + + private T runPlayerCallableBlocking(Callable callable) { + SettableFuture future = SettableFuture.create(); + boolean success = + postOrRun( + taskHandler, + () -> { + try { + future.set(callable.call()); + } catch (Throwable e) { + future.setException(e); + } + }); + Assertions.checkState(success); + boolean wasInterrupted = false; + try { + while (true) { + try { + return future.get(); + } catch (InterruptedException e) { + // We always wait for player calls to return. + wasInterrupted = true; + } catch (ExecutionException e) { + if (DEBUG) { + Log.d(TAG, "Internal player error", e); + } + throw new IllegalStateException(e.getCause()); + } + } + } finally { + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } + } + + @Nullable + private T runPlayerCallableBlockingWithNullOnException(Callable<@NullableType T> callable) { + try { + return runPlayerCallableBlocking(callable); + } catch (Exception e) { + return null; + } + } + + private T runPlayerCallableBlocking(Callable callable, T defaultValueWhenException) { + try { + return runPlayerCallableBlocking(callable); + } catch (Exception e) { + return defaultValueWhenException; + } + } + + private interface SessionPlayerCallbackNotifier { + void callCallback(SessionPlayer.PlayerCallback callback); + } + + private final class ExoPlayerWrapperListener implements PlayerWrapper.Listener { + @Override + public void onPlayerStateChanged(int playerState) { + setState(playerState); + if (playerState == PLAYER_STATE_PLAYING) { + playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY); + } else if (playerState == PLAYER_STATE_PAUSED) { + playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE); + } + } + + @Override + public void onPrepared(MediaItem mediaItem, int bufferingPercentage) { + Assertions.checkNotNull(mediaItem); + + if (bufferingPercentage >= 100) { + setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE); + } else { + setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_PLAYABLE); + } + playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE); + } + + @Override + public void onSeekCompleted() { + long currentPosition = getCurrentPosition(); + notifySessionPlayerCallback( + callback -> callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition)); + } + + @Override + public void onBufferingStarted(MediaItem mediaItem) { + setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_STARVED); + } + + @Override + public void onBufferingUpdate(MediaItem mediaItem, int bufferingPercentage) { + if (bufferingPercentage >= 100) { + setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE); + } + } + + @Override + public void onBufferingEnded(MediaItem mediaItem, int bufferingPercentage) { + if (bufferingPercentage >= 100) { + setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE); + } else { + setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_PLAYABLE); + } + } + + @Override + public void onCurrentMediaItemChanged(MediaItem mediaItem) { + if (ObjectsCompat.equals(currentMediaItem, mediaItem)) { + return; + } + currentMediaItem = mediaItem; + long currentPosition = getCurrentPosition(); + notifySessionPlayerCallback( + callback -> { + callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, mediaItem); + + // Workaround for MediaSession's issue that current media item change isn't propagated + // to the legacy controllers. + // TODO(b/160846312): Remove this workaround with media2 1.1.0-stable. + callback.onSeekCompleted(SessionPlayerConnector.this, currentPosition); + }); + } + + @Override + public void onPlaybackEnded() { + notifySessionPlayerCallback( + callback -> callback.onPlaybackCompleted(SessionPlayerConnector.this)); + } + + @Override + public void onError(@Nullable MediaItem mediaItem) { + playerCommandQueue.notifyCommandError(); + if (mediaItem != null) { + setBufferingState(mediaItem, BUFFERING_STATE_UNKNOWN); + } + } + + @Override + public void onPlaylistChanged() { + handlePlaylistChangedOnHandler(); + } + + @Override + public void onShuffleModeChanged(int shuffleMode) { + notifySessionPlayerCallback( + callback -> callback.onShuffleModeChanged(SessionPlayerConnector.this, shuffleMode)); + } + + @Override + public void onRepeatModeChanged(int repeatMode) { + notifySessionPlayerCallback( + callback -> callback.onRepeatModeChanged(SessionPlayerConnector.this, repeatMode)); + } + + @Override + public void onPlaybackSpeedChanged(float playbackSpeed) { + notifySessionPlayerCallback( + callback -> callback.onPlaybackSpeedChanged(SessionPlayerConnector.this, playbackSpeed)); + } + + @Override + public void onAudioAttributesChanged(AudioAttributesCompat audioAttributes) { + notifySessionPlayerCallback( + callback -> + callback.onAudioAttributesChanged(SessionPlayerConnector.this, audioAttributes)); + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java new file mode 100644 index 0000000000..873e35cc25 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java @@ -0,0 +1,96 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.media2; + +import androidx.media.AudioAttributesCompat; +import androidx.media2.common.SessionPlayer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.audio.AudioAttributes; + +/** Utility methods for translating between the media2 and ExoPlayer APIs. */ +/* package */ final class Utils { + + /** Returns ExoPlayer audio attributes for the given audio attributes. */ + public static AudioAttributes getAudioAttributes(AudioAttributesCompat audioAttributesCompat) { + return new AudioAttributes.Builder() + .setContentType(audioAttributesCompat.getContentType()) + .setFlags(audioAttributesCompat.getFlags()) + .setUsage(audioAttributesCompat.getUsage()) + .build(); + } + + /** Returns audio attributes for the given ExoPlayer audio attributes. */ + public static AudioAttributesCompat getAudioAttributesCompat(AudioAttributes audioAttributes) { + return new AudioAttributesCompat.Builder() + .setContentType(audioAttributes.contentType) + .setFlags(audioAttributes.flags) + .setUsage(audioAttributes.usage) + .build(); + } + + /** Returns the SimpleExoPlayer's shuffle mode for the given shuffle mode. */ + public static boolean getExoPlayerShuffleMode(int shuffleMode) { + switch (shuffleMode) { + case SessionPlayer.SHUFFLE_MODE_ALL: + case SessionPlayer.SHUFFLE_MODE_GROUP: + return true; + case SessionPlayer.SHUFFLE_MODE_NONE: + return false; + default: + throw new IllegalArgumentException(); + } + } + + /** Returns the shuffle mode for the given ExoPlayer's shuffle mode */ + public static int getShuffleMode(boolean exoPlayerShuffleMode) { + return exoPlayerShuffleMode ? SessionPlayer.SHUFFLE_MODE_ALL : SessionPlayer.SHUFFLE_MODE_NONE; + } + + /** Returns the ExoPlayer's repeat mode for the given repeat mode. */ + @Player.RepeatMode + public static int getExoPlayerRepeatMode(int repeatMode) { + switch (repeatMode) { + case SessionPlayer.REPEAT_MODE_ALL: + case SessionPlayer.REPEAT_MODE_GROUP: + return Player.REPEAT_MODE_ALL; + case SessionPlayer.REPEAT_MODE_ONE: + return Player.REPEAT_MODE_ONE; + case SessionPlayer.REPEAT_MODE_NONE: + return Player.REPEAT_MODE_OFF; + default: + throw new IllegalArgumentException(); + } + } + + /** Returns the repeat mode for the given SimpleExoPlayer's repeat mode. */ + public static int getRepeatMode(@Player.RepeatMode int exoPlayerRepeatMode) { + switch (exoPlayerRepeatMode) { + case Player.REPEAT_MODE_ALL: + return SessionPlayer.REPEAT_MODE_ALL; + case Player.REPEAT_MODE_ONE: + return SessionPlayer.REPEAT_MODE_ONE; + case Player.REPEAT_MODE_OFF: + return SessionPlayer.REPEAT_MODE_NONE; + default: + throw new IllegalArgumentException(); + } + } + + + private Utils() { + // Prevent instantiation. + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java new file mode 100644 index 0000000000..4003847b3f --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.ext.media2; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/extensions/mediasession/build.gradle b/extensions/mediasession/build.gradle index f32ef263e0..5c827084da 100644 --- a/extensions/mediasession/build.gradle +++ b/extensions/mediasession/build.gradle @@ -11,24 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/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 fc75d4f549..85d0155bd7 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 @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.ControlDispatcher; import com.google.android.exoplayer2.DefaultControlDispatcher; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.util.Assertions; @@ -127,8 +128,8 @@ public final class MediaSessionConnector { @PlaybackActions public static final long DEFAULT_PLAYBACK_ACTIONS = ALL_PLAYBACK_ACTIONS; /** - * The name of the {@link PlaybackStateCompat} float extra with the value of {@link - * Player#getPlaybackSpeed()}. + * The name of the {@link PlaybackStateCompat} float extra with the value of {@code + * Player.getPlaybackParameters().speed}. */ public static final String EXTRAS_SPEED = "EXO_SPEED"; @@ -437,7 +438,7 @@ public final class MediaSessionConnector { */ public MediaSessionConnector(MediaSessionCompat mediaSession) { this.mediaSession = mediaSession; - looper = Util.getLooper(); + looper = Util.getCurrentOrMainLooper(); componentListener = new ComponentListener(); commandReceivers = new ArrayList<>(); customCommandReceivers = new ArrayList<>(); @@ -765,7 +766,7 @@ public final class MediaSessionConnector { queueNavigator != null ? queueNavigator.getActiveQueueItemId(player) : MediaSessionCompat.QueueItem.UNKNOWN_ID; - float playbackSpeed = player.getPlaybackSpeed(); + float playbackSpeed = player.getPlaybackParameters().speed; extras.putFloat(EXTRAS_SPEED, playbackSpeed); float sessionPlaybackSpeed = player.isPlaying() ? playbackSpeed : 0f; builder @@ -946,7 +947,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: @@ -1132,7 +1135,7 @@ public final class MediaSessionConnector { } @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { invalidateMediaSessionPlaybackState(); } diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java index 024faea209..203479a7ed 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.mediasession; +import static java.lang.Math.min; + import android.os.Bundle; import android.os.ResultReceiver; import android.support.v4.media.MediaDescriptionCompat; @@ -177,7 +179,7 @@ public abstract class TimelineQueueNavigator implements MediaSessionConnector.Qu return; } ArrayDeque queue = new ArrayDeque<>(); - int queueSize = Math.min(maxQueueSize, timeline.getWindowCount()); + int queueSize = min(maxQueueSize, timeline.getWindowCount()); // Add the active queue item. int currentWindowIndex = player.getCurrentWindowIndex(); diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle index 220522b9d9..f16e382aa1 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/build.gradle @@ -11,32 +11,22 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') + testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion // Do not update to 3.13.X or later until minSdkVersion is increased to 21: // https://cashapp.github.io/2019-02-05/okhttp-3-13-requires-android-5 diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java index fe2bdd672b..57fee20d04 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ext.okhttp; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; import android.net.Uri; import androidx.annotation.Nullable; @@ -26,8 +27,8 @@ import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.Predicate; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; @@ -80,6 +81,18 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { private long bytesRead; /** + * Creates an instance. + * + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the source. + */ + public OkHttpDataSource(Call.Factory callFactory) { + this(callFactory, ExoPlayerLibraryInfo.DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -89,6 +102,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -110,6 +125,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -119,6 +136,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { * @deprecated Use {@link #OkHttpDataSource(Call.Factory, String)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public OkHttpDataSource( Call.Factory callFactory, @@ -133,6 +151,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -230,10 +250,18 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { // Check for a valid response code. if (!response.isSuccessful()) { + byte[] errorResponseBody; + try { + errorResponseBody = Util.toByteArray(Assertions.checkNotNull(responseByteStream)); + } catch (IOException e) { + throw new HttpDataSourceException( + "Error reading non-2xx response body", e, dataSpec, HttpDataSourceException.TYPE_OPEN); + } Map> headers = response.headers().toMultimap(); closeConnectionQuietly(); InvalidResponseCodeException exception = - new InvalidResponseCodeException(responseCode, response.message(), headers, dataSpec); + new InvalidResponseCodeException( + responseCode, response.message(), headers, dataSpec, errorResponseBody); if (responseCode == 416) { exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE)); } @@ -243,7 +271,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { // Check for a valid content type. MediaType mediaType = responseBody.contentType(); String contentType = mediaType != null ? mediaType.toString() : ""; - if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) { + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { closeConnectionQuietly(); throw new InvalidContentTypeException(contentType, dataSpec); } @@ -386,7 +414,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { } while (bytesSkipped != bytesToSkip) { - int readLength = (int) Math.min(bytesToSkip - bytesSkipped, SKIP_BUFFER.length); + int readLength = (int) min(bytesToSkip - bytesSkipped, SKIP_BUFFER.length); int read = castNonNull(responseByteStream).read(SKIP_BUFFER, 0, readLength); if (Thread.currentThread().isInterrupted()) { throw new InterruptedIOException(); @@ -422,7 +450,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource { if (bytesRemaining == 0) { return C.RESULT_END_OF_INPUT; } - readLength = (int) Math.min(readLength, bytesRemaining); + readLength = (int) min(readLength, bytesRemaining); } int read = castNonNull(responseByteStream).read(buffer, offset, readLength); diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java index f3d74f9233..728428c811 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.okhttp; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; @@ -34,6 +36,18 @@ public final class OkHttpDataSourceFactory extends BaseFactory { @Nullable private final CacheControl cacheControl; /** + * Creates an instance. + * + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the sources created by the factory. + */ + public OkHttpDataSourceFactory(Call.Factory callFactory) { + this(callFactory, DEFAULT_USER_AGENT, /* listener= */ null, /* cacheControl= */ null); + } + + /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -43,6 +57,8 @@ public final class OkHttpDataSourceFactory extends BaseFactory { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -54,6 +70,8 @@ public final class OkHttpDataSourceFactory extends BaseFactory { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -65,6 +83,8 @@ public final class OkHttpDataSourceFactory extends BaseFactory { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. diff --git a/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java b/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java index 393c048eec..73e9909a8d 100644 --- a/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java +++ b/extensions/okhttp/src/test/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceTest.java @@ -17,107 +17,109 @@ package com.google.android.exoplayer2.ext.okhttp; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.common.base.Charsets; import java.util.HashMap; import java.util.Map; -import okhttp3.Call; -import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; +import okhttp3.Headers; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; /** Unit tests for {@link OkHttpDataSource}. */ @RunWith(AndroidJUnit4.class) public class OkHttpDataSourceTest { + /** + * This test will set HTTP default request parameters (1) in the OkHttpDataSource, (2) via + * OkHttpDataSource.setRequestProperty() and (3) in the DataSpec instance according to the table + * below. Values wrapped in '*' are the ones that should be set in the connection request. + * + *

{@code
+   * +---------------+-----+-----+-----+-----+-----+-----+-----+
+   * |               |               Header Key                |
+   * +---------------+-----+-----+-----+-----+-----+-----+-----+
+   * |   Location    |  0  |  1  |  2  |  3  |  4  |  5  |  6  |
+   * +---------------+-----+-----+-----+-----+-----+-----+-----+
+   * | Constructor   | *Y* |  Y  |  Y  |     |  Y  |     |     |
+   * | Setter        |     | *Y* |  Y  |  Y  |     | *Y* |     |
+   * | DataSpec      |     |     | *Y* | *Y* | *Y* |     | *Y* |
+   * +---------------+-----+-----+-----+-----+-----+-----+-----+
+   * }
+ */ @Test - public void open_setsCorrectHeaders() throws HttpDataSource.HttpDataSourceException { - /* - * This test will set HTTP default request parameters (1) in the OkHttpDataSource, (2) via - * OkHttpDataSource.setRequestProperty() and (3) in the DataSpec instance according to the table - * below. Values wrapped in '*' are the ones that should be set in the connection request. - * - * +-----------------------+---+-----+-----+-----+-----+-----+ - * | | Header Key | - * +-----------------------+---+-----+-----+-----+-----+-----+ - * | Location | 0 | 1 | 2 | 3 | 4 | 5 | - * +-----------------------+---+-----+-----+-----+-----+-----+ - * | Default |*Y*| Y | Y | | | | - * | OkHttpDataSource | | *Y* | Y | Y | *Y* | | - * | DataSpec | | | *Y* | *Y* | | *Y* | - * +-----------------------+---+-----+-----+-----+-----+-----+ - */ + public void open_setsCorrectHeaders() throws Exception { + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse()); - String defaultValue = "Default"; - String okHttpDataSourceValue = "OkHttpDataSource"; - String dataSpecValue = "DataSpec"; - - // 1. Default properties on OkHttpDataSource - HttpDataSource.RequestProperties defaultRequestProperties = - new HttpDataSource.RequestProperties(); - defaultRequestProperties.set("0", defaultValue); - defaultRequestProperties.set("1", defaultValue); - defaultRequestProperties.set("2", defaultValue); - - Call.Factory mockCallFactory = Mockito.mock(Call.Factory.class); - OkHttpDataSource okHttpDataSource = + String propertyFromConstructor = "fromConstructor"; + HttpDataSource.RequestProperties constructorProperties = new HttpDataSource.RequestProperties(); + constructorProperties.set("0", propertyFromConstructor); + constructorProperties.set("1", propertyFromConstructor); + constructorProperties.set("2", propertyFromConstructor); + constructorProperties.set("4", propertyFromConstructor); + OkHttpDataSource dataSource = new OkHttpDataSource( - mockCallFactory, "testAgent", /* cacheControl= */ null, defaultRequestProperties); + new OkHttpClient(), "testAgent", /* cacheControl= */ null, constructorProperties); - // 2. Additional properties set with setRequestProperty(). - okHttpDataSource.setRequestProperty("1", okHttpDataSourceValue); - okHttpDataSource.setRequestProperty("2", okHttpDataSourceValue); - okHttpDataSource.setRequestProperty("3", okHttpDataSourceValue); - okHttpDataSource.setRequestProperty("4", okHttpDataSourceValue); + String propertyFromSetter = "fromSetter"; + dataSource.setRequestProperty("1", propertyFromSetter); + dataSource.setRequestProperty("2", propertyFromSetter); + dataSource.setRequestProperty("3", propertyFromSetter); + dataSource.setRequestProperty("5", propertyFromSetter); - // 3. DataSpec properties + String propertyFromDataSpec = "fromDataSpec"; Map dataSpecRequestProperties = new HashMap<>(); - dataSpecRequestProperties.put("2", dataSpecValue); - dataSpecRequestProperties.put("3", dataSpecValue); - dataSpecRequestProperties.put("5", dataSpecValue); + dataSpecRequestProperties.put("2", propertyFromDataSpec); + dataSpecRequestProperties.put("3", propertyFromDataSpec); + dataSpecRequestProperties.put("4", propertyFromDataSpec); + dataSpecRequestProperties.put("6", propertyFromDataSpec); DataSpec dataSpec = new DataSpec.Builder() - .setUri("http://www.google.com") - .setPosition(1000) - .setLength(5000) + .setUri(mockWebServer.url("/test-path").toString()) .setHttpRequestHeaders(dataSpecRequestProperties) .build(); - Mockito.doAnswer( - invocation -> { - Request request = invocation.getArgument(0); - assertThat(request.header("0")).isEqualTo(defaultValue); - assertThat(request.header("1")).isEqualTo(okHttpDataSourceValue); - assertThat(request.header("2")).isEqualTo(dataSpecValue); - assertThat(request.header("3")).isEqualTo(dataSpecValue); - assertThat(request.header("4")).isEqualTo(okHttpDataSourceValue); - assertThat(request.header("5")).isEqualTo(dataSpecValue); + dataSource.open(dataSpec); - // return a Call whose .execute() will return a mock Response - Call returnValue = Mockito.mock(Call.class); - Mockito.doReturn( - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(200) - .message("OK") - .body(ResponseBody.create(MediaType.parse("text/plain"), "")) - .build()) - .when(returnValue) - .execute(); - return returnValue; - }) - .when(mockCallFactory) - .newCall(ArgumentMatchers.any()); - okHttpDataSource.open(dataSpec); + Headers headers = mockWebServer.takeRequest(10, SECONDS).getHeaders(); + assertThat(headers.get("0")).isEqualTo(propertyFromConstructor); + assertThat(headers.get("1")).isEqualTo(propertyFromSetter); + assertThat(headers.get("2")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("3")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("4")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("5")).isEqualTo(propertyFromSetter); + assertThat(headers.get("6")).isEqualTo(propertyFromDataSpec); + } + + @Test + public void open_invalidResponseCode() throws Exception { + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(404).setBody("failure msg")); + + OkHttpDataSource okHttpDataSource = + new OkHttpDataSource( + new OkHttpClient(), + "testAgent", + /* cacheControl= */ null, + /* defaultRequestProperties= */ null); + DataSpec dataSpec = + new DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build(); + + HttpDataSource.InvalidResponseCodeException exception = + assertThrows( + HttpDataSource.InvalidResponseCodeException.class, + () -> okHttpDataSource.open(dataSpec)); + + assertThat(exception.responseCode).isEqualTo(404); + assertThat(exception.responseBody).isEqualTo("failure msg".getBytes(Charsets.UTF_8)); } } diff --git a/extensions/opus/build.gradle b/extensions/opus/build.gradle index 545b5a7af8..ba670037f6 100644 --- a/extensions/opus/build.gradle +++ b/extensions/opus/build.gradle @@ -11,24 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - sourceSets { main { jniLibs.srcDir 'src/main/libs' @@ -36,8 +21,6 @@ android { } androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java index e4e392f2d3..c964b0cc1c 100644 --- a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java +++ b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java @@ -25,6 +25,7 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.MediaSource; @@ -38,9 +39,9 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class OpusPlaybackTest { - private static final String BEAR_OPUS_URI = "asset:///mka/bear-opus.mka"; + private static final String BEAR_OPUS_URI = "asset:///media/mka/bear-opus.mka"; private static final String BEAR_OPUS_NEGATIVE_GAIN_URI = - "asset:///mka/bear-opus-negative-gain.mka"; + "asset:///media/mka/bear-opus-negative-gain.mka"; @Before public void setUp() { @@ -91,10 +92,10 @@ public class OpusPlaybackTest { player.addListener(this); MediaSource mediaSource = new ProgressiveMediaSource.Factory( - new DefaultDataSourceFactory(context, "ExoPlayerExtOpusTest"), - MatroskaExtractor.FACTORY) - .createMediaSource(uri); - player.prepare(mediaSource); + new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY) + .createMediaSource(MediaItem.fromUri(uri)); + player.setMediaSource(mediaSource); + player.prepare(); player.play(); Looper.loop(); } diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java index 6fe1fa8895..603241486c 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/LibopusAudioRenderer.java @@ -21,13 +21,17 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.audio.AudioSink.SinkFormatSupport; import com.google.android.exoplayer2.audio.DecoderAudioRenderer; +import com.google.android.exoplayer2.audio.OpusUtil; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TraceUtil; +import com.google.android.exoplayer2.util.Util; /** Decodes and renders audio using the native Opus decoder. */ -public class LibopusAudioRenderer extends DecoderAudioRenderer { +public class LibopusAudioRenderer extends DecoderAudioRenderer { private static final String TAG = "LibopusAudioRenderer"; /** The number of input and output buffers. */ @@ -35,14 +39,13 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { /** The default input buffer size. */ private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6; - private int channelCount; - private int sampleRate; - public LibopusAudioRenderer() { this(/* eventHandler= */ null, /* eventListener= */ null); } /** + * Creates a new instance. + * * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be * null if delivery of events is not required. * @param eventListener A listener of events. May be null if delivery of events is not required. @@ -55,6 +58,21 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { super(eventHandler, eventListener, audioProcessors); } + /** + * Creates a new instance. + * + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioSink The sink to which audio will be output. + */ + public LibopusAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink) { + super(eventHandler, eventListener, audioSink); + } + @Override public String getName() { return TAG; @@ -64,12 +82,13 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { @FormatSupport protected int supportsFormatInternal(Format format) { boolean drmIsSupported = - format.drmInitData == null + format.exoMediaCryptoType == null || OpusLibrary.matchesExpectedExoMediaCryptoType(format.exoMediaCryptoType); if (!OpusLibrary.isAvailable() || !MimeTypes.AUDIO_OPUS.equalsIgnoreCase(format.sampleMimeType)) { return FORMAT_UNSUPPORTED_TYPE; - } else if (!supportsOutput(format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { + } else if (!sinkSupportsFormat( + Util.getPcmFormat(C.ENCODING_PCM_16BIT, format.channelCount, format.sampleRate))) { return FORMAT_UNSUPPORTED_SUBTYPE; } else if (!drmIsSupported) { return FORMAT_UNSUPPORTED_DRM; @@ -82,6 +101,12 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { protected OpusDecoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) throws OpusDecoderException { TraceUtil.beginSection("createOpusDecoder"); + @SinkFormatSupport + int formatSupport = + getSinkFormatSupport( + Util.getPcmFormat(C.ENCODING_PCM_FLOAT, format.channelCount, format.sampleRate)); + boolean outputFloat = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY; + int initialInputBufferSize = format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE; OpusDecoder decoder = @@ -90,20 +115,17 @@ public class LibopusAudioRenderer extends DecoderAudioRenderer { NUM_BUFFERS, initialInputBufferSize, format.initializationData, - mediaCrypto); - channelCount = decoder.getChannelCount(); - sampleRate = decoder.getSampleRate(); + mediaCrypto, + outputFloat); + TraceUtil.endSection(); return decoder; } @Override - protected Format getOutputFormat() { - return new Format.Builder() - .setSampleMimeType(MimeTypes.AUDIO_RAW) - .setChannelCount(channelCount) - .setSampleRate(sampleRate) - .setPcmEncoding(C.ENCODING_PCM_16BIT) - .build(); + protected Format getOutputFormat(OpusDecoder decoder) { + @C.PcmEncoding + int pcmEncoding = decoder.outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT; + return Util.getPcmFormat(pcmEncoding, decoder.channelCount, OpusUtil.SAMPLE_RATE); } } diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java index 8795950671..6b96cc5e49 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusDecoder.java @@ -17,39 +17,32 @@ package com.google.android.exoplayer2.ext.opus; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.audio.OpusUtil; import com.google.android.exoplayer2.decoder.CryptoInfo; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; import com.google.android.exoplayer2.drm.DecryptionException; import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; -import java.nio.ByteOrder; import java.util.List; -/** - * Opus decoder. - */ -/* package */ final class OpusDecoder extends - SimpleDecoder { - - private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; - - /** - * Opus streams are always decoded at 48000 Hz. - */ - private static final int SAMPLE_RATE = 48000; +/** Opus decoder. */ +/* package */ final class OpusDecoder + extends SimpleDecoder { private static final int NO_ERROR = 0; private static final int DECODE_ERROR = -1; private static final int DRM_ERROR = -2; - @Nullable private final ExoMediaCrypto exoMediaCrypto; + public final boolean outputFloat; + public final int channelCount; - private final int channelCount; - private final int headerSkipSamples; - private final int headerSeekPreRollSamples; + @Nullable private final ExoMediaCrypto exoMediaCrypto; + private final int preSkipSamples; + private final int seekPreRollSamples; private final long nativeDecoderContext; private int skipSamples; @@ -65,6 +58,7 @@ import java.util.List; * the encoder delay and seek pre roll values in nanoseconds, encoded as longs. * @param exoMediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted * content. Maybe null and can be ignored if decoder does not handle encrypted content. + * @param outputFloat Forces the decoder to output float PCM samples when set * @throws OpusDecoderException Thrown if an exception occurs when initializing the decoder. */ public OpusDecoder( @@ -72,25 +66,36 @@ import java.util.List; int numOutputBuffers, int initialInputBufferSize, List initializationData, - @Nullable ExoMediaCrypto exoMediaCrypto) + @Nullable ExoMediaCrypto exoMediaCrypto, + boolean outputFloat) throws OpusDecoderException { super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]); if (!OpusLibrary.isAvailable()) { - throw new OpusDecoderException("Failed to load decoder native libraries."); + throw new OpusDecoderException("Failed to load decoder native libraries"); } this.exoMediaCrypto = exoMediaCrypto; if (exoMediaCrypto != null && !OpusLibrary.opusIsSecureDecodeSupported()) { - throw new OpusDecoderException("Opus decoder does not support secure decode."); + throw new OpusDecoderException("Opus decoder does not support secure decode"); } + int initializationDataSize = initializationData.size(); + if (initializationDataSize != 1 && initializationDataSize != 3) { + throw new OpusDecoderException("Invalid initialization data size"); + } + if (initializationDataSize == 3 + && (initializationData.get(1).length != 8 || initializationData.get(2).length != 8)) { + throw new OpusDecoderException("Invalid pre-skip or seek pre-roll"); + } + preSkipSamples = OpusUtil.getPreSkipSamples(initializationData); + seekPreRollSamples = OpusUtil.getSeekPreRollSamples(initializationData); + byte[] headerBytes = initializationData.get(0); if (headerBytes.length < 19) { - throw new OpusDecoderException("Header size is too small."); + throw new OpusDecoderException("Invalid header length"); } - channelCount = headerBytes[9] & 0xFF; + channelCount = OpusUtil.getChannelCount(headerBytes); if (channelCount > 8) { throw new OpusDecoderException("Invalid channel count: " + channelCount); } - int preskip = readUnsignedLittleEndian16(headerBytes, 10); int gain = readSignedLittleEndian16(headerBytes, 16); byte[] streamMap = new byte[8]; @@ -99,7 +104,7 @@ import java.util.List; if (headerBytes[18] == 0) { // Channel mapping // If there is no channel mapping, use the defaults. if (channelCount > 2) { // Maximum channel count with default layout. - throw new OpusDecoderException("Invalid Header, missing stream map."); + throw new OpusDecoderException("Invalid header, missing stream map"); } numStreams = 1; numCoupled = (channelCount == 2) ? 1 : 0; @@ -107,33 +112,24 @@ import java.util.List; streamMap[1] = 1; } else { if (headerBytes.length < 21 + channelCount) { - throw new OpusDecoderException("Header size is too small."); + throw new OpusDecoderException("Invalid header length"); } // Read the channel mapping. numStreams = headerBytes[19] & 0xFF; numCoupled = headerBytes[20] & 0xFF; System.arraycopy(headerBytes, 21, streamMap, 0, channelCount); } - if (initializationData.size() == 3) { - if (initializationData.get(1).length != 8 || initializationData.get(2).length != 8) { - throw new OpusDecoderException("Invalid Codec Delay or Seek Preroll"); - } - long codecDelayNs = - ByteBuffer.wrap(initializationData.get(1)).order(ByteOrder.nativeOrder()).getLong(); - long seekPreRollNs = - ByteBuffer.wrap(initializationData.get(2)).order(ByteOrder.nativeOrder()).getLong(); - headerSkipSamples = nsToSamples(codecDelayNs); - headerSeekPreRollSamples = nsToSamples(seekPreRollNs); - } else { - headerSkipSamples = preskip; - headerSeekPreRollSamples = DEFAULT_SEEK_PRE_ROLL_SAMPLES; - } - nativeDecoderContext = opusInit(SAMPLE_RATE, channelCount, numStreams, numCoupled, gain, - streamMap); + nativeDecoderContext = + opusInit(OpusUtil.SAMPLE_RATE, channelCount, numStreams, numCoupled, gain, streamMap); if (nativeDecoderContext == 0) { throw new OpusDecoderException("Failed to initialize decoder"); } setInitialInputBufferSize(initialInputBufferSize); + + this.outputFloat = outputFloat; + if (outputFloat) { + opusSetFloatOutput(); + } } @Override @@ -164,22 +160,37 @@ import java.util.List; opusReset(nativeDecoderContext); // When seeking to 0, skip number of samples as specified in opus header. When seeking to // any other time, skip number of samples as specified by seek preroll. - skipSamples = (inputBuffer.timeUs == 0) ? headerSkipSamples : headerSeekPreRollSamples; + skipSamples = (inputBuffer.timeUs == 0) ? preSkipSamples : seekPreRollSamples; } ByteBuffer inputData = Util.castNonNull(inputBuffer.data); CryptoInfo cryptoInfo = inputBuffer.cryptoInfo; - int result = inputBuffer.isEncrypted() - ? opusSecureDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(), - outputBuffer, SAMPLE_RATE, exoMediaCrypto, cryptoInfo.mode, - cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples, - cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData) - : opusDecode(nativeDecoderContext, inputBuffer.timeUs, inputData, inputData.limit(), - outputBuffer); + int result = + inputBuffer.isEncrypted() + ? opusSecureDecode( + nativeDecoderContext, + inputBuffer.timeUs, + inputData, + inputData.limit(), + outputBuffer, + OpusUtil.SAMPLE_RATE, + exoMediaCrypto, + cryptoInfo.mode, + Assertions.checkNotNull(cryptoInfo.key), + Assertions.checkNotNull(cryptoInfo.iv), + cryptoInfo.numSubSamples, + cryptoInfo.numBytesOfClearData, + cryptoInfo.numBytesOfEncryptedData) + : opusDecode( + nativeDecoderContext, + inputBuffer.timeUs, + inputData, + inputData.limit(), + outputBuffer); if (result < 0) { if (result == DRM_ERROR) { String message = "Drm error: " + opusGetErrorMessage(nativeDecoderContext); - DecryptionException cause = new DecryptionException( - opusGetErrorCode(nativeDecoderContext), message); + DecryptionException cause = + new DecryptionException(opusGetErrorCode(nativeDecoderContext), message); return new OpusDecoderException(message, cause); } else { return new OpusDecoderException("Decode error: " + opusGetErrorMessage(result)); @@ -210,37 +221,20 @@ import java.util.List; opusClose(nativeDecoderContext); } - /** - * Returns the channel count of output audio. - */ - public int getChannelCount() { - return channelCount; - } - - /** - * Returns the sample rate of output audio. - */ - public int getSampleRate() { - return SAMPLE_RATE; - } - - private static int nsToSamples(long ns) { - return (int) (ns * SAMPLE_RATE / 1000000000); - } - - private static int readUnsignedLittleEndian16(byte[] input, int offset) { + private static int readSignedLittleEndian16(byte[] input, int offset) { int value = input[offset] & 0xFF; value |= (input[offset + 1] & 0xFF) << 8; - return value; + return (short) value; } - private static int readSignedLittleEndian16(byte[] input, int offset) { - return (short) readUnsignedLittleEndian16(input, offset); - } + private native long opusInit( + int sampleRate, int channelCount, int numStreams, int numCoupled, int gain, byte[] streamMap); - private native long opusInit(int sampleRate, int channelCount, int numStreams, int numCoupled, - int gain, byte[] streamMap); - private native int opusDecode(long decoder, long timeUs, ByteBuffer inputBuffer, int inputSize, + private native int opusDecode( + long decoder, + long timeUs, + ByteBuffer inputBuffer, + int inputSize, SimpleOutputBuffer outputBuffer); private native int opusSecureDecode( @@ -255,12 +249,16 @@ import java.util.List; byte[] key, byte[] iv, int numSubSamples, - int[] numBytesOfClearData, - int[] numBytesOfEncryptedData); + @Nullable int[] numBytesOfClearData, + @Nullable int[] numBytesOfEncryptedData); private native void opusClose(long decoder); + private native void opusReset(long decoder); + private native int opusGetErrorCode(long decoder); + private native String opusGetErrorMessage(long decoder); + private native void opusSetFloatOutput(); } diff --git a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java index d09d69bf03..5529701c06 100644 --- a/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java +++ b/extensions/opus/src/main/java/com/google/android/exoplayer2/ext/opus/OpusLibrary.java @@ -68,7 +68,7 @@ public final class OpusLibrary { * protected content. */ public static boolean matchesExpectedExoMediaCryptoType( - @Nullable Class exoMediaCryptoType) { + Class exoMediaCryptoType) { return Util.areEqual(OpusLibrary.exoMediaCryptoType, exoMediaCryptoType); } diff --git a/extensions/opus/src/main/jni/opus_jni.cc b/extensions/opus/src/main/jni/opus_jni.cc index 9042e4cb89..a2515be7f6 100644 --- a/extensions/opus/src/main/jni/opus_jni.cc +++ b/extensions/opus/src/main/jni/opus_jni.cc @@ -58,10 +58,12 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { return JNI_VERSION_1_6; } -static const int kBytesPerSample = 2; // opus fixed point uses 16 bit samples. +static const int kBytesPerIntPcmSample = 2; +static const int kBytesPerFloatSample = 4; static const int kMaxOpusOutputPacketSizeSamples = 960 * 6; static int channelCount; static int errorCode; +static bool outputFloat = false; DECODER_FUNC(jlong, opusInit, jint sampleRate, jint channelCount, jint numStreams, jint numCoupled, jint gain, jbyteArray jStreamMap) { @@ -99,8 +101,10 @@ DECODER_FUNC(jint, opusDecode, jlong jDecoder, jlong jTimeUs, reinterpret_cast( env->GetDirectBufferAddress(jInputBuffer)); + const int byteSizePerSample = outputFloat ? + kBytesPerFloatSample : kBytesPerIntPcmSample; const jint outputSize = - kMaxOpusOutputPacketSizeSamples * kBytesPerSample * channelCount; + kMaxOpusOutputPacketSizeSamples * byteSizePerSample * channelCount; env->CallObjectMethod(jOutputBuffer, outputBufferInit, jTimeUs, outputSize); if (env->ExceptionCheck()) { @@ -114,14 +118,23 @@ DECODER_FUNC(jint, opusDecode, jlong jDecoder, jlong jTimeUs, return -1; } - int16_t* outputBufferData = reinterpret_cast( - env->GetDirectBufferAddress(jOutputBufferData)); - int sampleCount = opus_multistream_decode(decoder, inputBuffer, inputSize, + int sampleCount; + if (outputFloat) { + float* outputBufferData = reinterpret_cast( + env->GetDirectBufferAddress(jOutputBufferData)); + sampleCount = opus_multistream_decode_float(decoder, inputBuffer, inputSize, outputBufferData, kMaxOpusOutputPacketSizeSamples, 0); + } else { + int16_t* outputBufferData = reinterpret_cast( + env->GetDirectBufferAddress(jOutputBufferData)); + sampleCount = opus_multistream_decode(decoder, inputBuffer, inputSize, + outputBufferData, kMaxOpusOutputPacketSizeSamples, 0); + } + // record error code errorCode = (sampleCount < 0) ? sampleCount : 0; return (sampleCount < 0) ? sampleCount - : sampleCount * kBytesPerSample * channelCount; + : sampleCount * byteSizePerSample * channelCount; } DECODER_FUNC(jint, opusSecureDecode, jlong jDecoder, jlong jTimeUs, @@ -154,6 +167,10 @@ DECODER_FUNC(jint, opusGetErrorCode, jlong jContext) { return errorCode; } +DECODER_FUNC(void, opusSetFloatOutput) { + outputFloat = true; +} + LIBRARY_FUNC(jstring, opusIsSecureDecodeSupported) { // Doesn't support return 0; diff --git a/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java b/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java index e57ad84a41..9931f2d05f 100644 --- a/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java +++ b/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java @@ -26,7 +26,7 @@ import org.junit.runner.RunWith; public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesOpusRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( LibopusAudioRenderer.class, C.TRACK_TYPE_AUDIO); } diff --git a/extensions/rtmp/build.gradle b/extensions/rtmp/build.gradle index 621f8b2998..3d912bebf6 100644 --- a/extensions/rtmp/build.gradle +++ b/extensions/rtmp/build.gradle @@ -11,24 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') diff --git a/extensions/vp9/README.md b/extensions/vp9/README.md index fd0836648a..765cdbca3b 100644 --- a/extensions/vp9/README.md +++ b/extensions/vp9/README.md @@ -42,9 +42,8 @@ cd "${VP9_EXT_PATH}/jni" && \ git clone https://chromium.googlesource.com/webm/libvpx libvpx ``` -* Checkout the appropriate branch of libvpx (the scripts and makefiles bundled - in this repo are known to work only at specific versions of the library - we - will update this periodically as newer versions of libvpx are released): +* Checkout an appropriate branch of libvpx. We cannot guarantee compatibility + with all versions of libvpx. We currently recommend version 1.8.0: ``` cd "${VP9_EXT_PATH}/jni/libvpx" && \ @@ -127,19 +126,22 @@ To try out playback using the extension in the [demo application][], see There are two possibilities for rendering the output `LibvpxVideoRenderer` gets from the libvpx decoder: -* GL rendering using GL shader for color space conversion - * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option by - setting `surface_type` of `PlayerView` to be - `video_decoder_gl_surface_view`. - * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message of - type `C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an instance of - `VideoDecoderOutputBufferRenderer` as its object. +* GL rendering using GL shader for color space conversion -* Native rendering using `ANativeWindow` - * If you are using `SimpleExoPlayer` with `PlayerView`, this option is enabled - by default. - * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message of - type `C.MSG_SET_SURFACE` with an instance of `SurfaceView` as its object. + * If you are using `SimpleExoPlayer` with `PlayerView`, enable this option + by setting `surface_type` of `PlayerView` to be + `video_decoder_gl_surface_view`. + * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message + of type `Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an + instance of `VideoDecoderOutputBufferRenderer` as its object. + +* Native rendering using `ANativeWindow` + + * If you are using `SimpleExoPlayer` with `PlayerView`, this option is + enabled by default. + * Otherwise, enable this option by sending `LibvpxVideoRenderer` a message + of type `Renderer.MSG_SET_SURFACE` with an instance of `SurfaceView` as + its object. Note: Although the default option uses `ANativeWindow`, based on our testing the GL rendering mode has better performance, so should be preferred. diff --git a/extensions/vp9/build.gradle b/extensions/vp9/build.gradle index ffd76d6e2f..79d85a6ac5 100644 --- a/extensions/vp9/build.gradle +++ b/extensions/vp9/build.gradle @@ -11,24 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - sourceSets { main { jniLibs.srcDir 'src/main/libs' @@ -36,8 +21,6 @@ android { } androidTest.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { diff --git a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java index 7b81c0b9b8..823ce02cfe 100644 --- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java +++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java @@ -24,10 +24,11 @@ import android.os.Looper; import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; @@ -42,10 +43,11 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class VpxPlaybackTest { - private static final String BEAR_URI = "asset:///vp9/bear-vp9.webm"; - private static final String BEAR_ODD_DIMENSIONS_URI = "asset:///vp9/bear-vp9-odd-dimensions.webm"; - private static final String ROADTRIP_10BIT_URI = "asset:///vp9/roadtrip-vp92-10bit.webm"; - private static final String INVALID_BITSTREAM_URI = "asset:///vp9/invalid-bitstream.webm"; + private static final String BEAR_URI = "asset:///media/vp9/bear-vp9.webm"; + private static final String BEAR_ODD_DIMENSIONS_URI = + "asset:///media/vp9/bear-vp9-odd-dimensions.webm"; + private static final String ROADTRIP_10BIT_URI = "asset:///media/vp9/roadtrip-vp92-10bit.webm"; + private static final String INVALID_BITSTREAM_URI = "asset:///media/vp9/invalid-bitstream.webm"; private static final String TAG = "VpxPlaybackTest"; @@ -119,15 +121,15 @@ public class VpxPlaybackTest { player.addListener(this); MediaSource mediaSource = new ProgressiveMediaSource.Factory( - new DefaultDataSourceFactory(context, "ExoPlayerExtVp9Test"), - MatroskaExtractor.FACTORY) - .createMediaSource(uri); + new DefaultDataSourceFactory(context), MatroskaExtractor.FACTORY) + .createMediaSource(MediaItem.fromUri(uri)); player .createMessage(videoRenderer) - .setType(C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) + .setType(Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) .setPayload(new VideoDecoderGLSurfaceView(context).getVideoDecoderOutputBufferRenderer()) .send(); - player.prepare(mediaSource); + player.setMediaSource(mediaSource); + player.prepare(); player.play(); Looper.loop(); } diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java index 8f95024423..61ebc8b0d9 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/LibvpxVideoRenderer.java @@ -102,7 +102,6 @@ public class LibvpxVideoRenderer extends DecoderVideoRenderer { * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. */ - @SuppressWarnings("deprecation") public LibvpxVideoRenderer( long allowedJoiningTimeMs, @Nullable Handler eventHandler, @@ -129,7 +128,7 @@ public class LibvpxVideoRenderer extends DecoderVideoRenderer { return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); } boolean drmIsSupported = - format.drmInitData == null + format.exoMediaCryptoType == null || VpxLibrary.matchesExpectedExoMediaCryptoType(format.exoMediaCryptoType); if (!drmIsSupported) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM); diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java index 98a26727ee..ce0873ad40 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxDecoder.java @@ -19,6 +19,7 @@ import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.decoder.CryptoInfo; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.drm.DecryptionException; import com.google.android.exoplayer2.drm.ExoMediaCrypto; @@ -86,18 +87,9 @@ import java.nio.ByteBuffer; return "libvpx" + VpxLibrary.getVersion(); } - /** - * Sets the output mode for frames rendered by the decoder. - * - * @param outputMode The output mode. - */ - public void setOutputMode(@C.VideoOutputMode int outputMode) { - this.outputMode = outputMode; - } - @Override protected VideoDecoderInputBuffer createInputBuffer() { - return new VideoDecoderInputBuffer(); + return new VideoDecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); } @Override @@ -132,11 +124,20 @@ import java.nio.ByteBuffer; ByteBuffer inputData = Util.castNonNull(inputBuffer.data); int inputSize = inputData.limit(); CryptoInfo cryptoInfo = inputBuffer.cryptoInfo; - final long result = inputBuffer.isEncrypted() - ? vpxSecureDecode(vpxDecContext, inputData, inputSize, exoMediaCrypto, - cryptoInfo.mode, cryptoInfo.key, cryptoInfo.iv, cryptoInfo.numSubSamples, - cryptoInfo.numBytesOfClearData, cryptoInfo.numBytesOfEncryptedData) - : vpxDecode(vpxDecContext, inputData, inputSize); + final long result = + inputBuffer.isEncrypted() + ? vpxSecureDecode( + vpxDecContext, + inputData, + inputSize, + exoMediaCrypto, + cryptoInfo.mode, + Assertions.checkNotNull(cryptoInfo.key), + Assertions.checkNotNull(cryptoInfo.iv), + cryptoInfo.numSubSamples, + cryptoInfo.numBytesOfClearData, + cryptoInfo.numBytesOfEncryptedData) + : vpxDecode(vpxDecContext, inputData, inputSize); if (result != NO_ERROR) { if (result == DRM_ERROR) { String message = "Drm error: " + vpxGetErrorMessage(vpxDecContext); @@ -170,7 +171,7 @@ import java.nio.ByteBuffer; } else if (getFrameResult == -1) { return new VpxDecoderException("Buffer initialization failed."); } - outputBuffer.colorInfo = inputBuffer.colorInfo; + outputBuffer.format = inputBuffer.format; } return null; } @@ -182,6 +183,15 @@ import java.nio.ByteBuffer; vpxClose(vpxDecContext); } + /** + * Sets the output mode for frames rendered by the decoder. + * + * @param outputMode The output mode. + */ + public void setOutputMode(@C.VideoOutputMode int outputMode) { + this.outputMode = outputMode; + } + /** Renders the outputBuffer to the surface. Used with OUTPUT_MODE_SURFACE_YUV only. */ public void renderToSurface(VideoDecoderOutputBuffer outputBuffer, Surface surface) throws VpxDecoderException { @@ -206,8 +216,8 @@ import java.nio.ByteBuffer; byte[] key, byte[] iv, int numSubSamples, - int[] numBytesOfClearData, - int[] numBytesOfEncryptedData); + @Nullable int[] numBytesOfClearData, + @Nullable int[] numBytesOfEncryptedData); private native int vpxGetFrame(long context, VideoDecoderOutputBuffer outputBuffer); diff --git a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java index e620332fc8..5106ab67ad 100644 --- a/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java +++ b/extensions/vp9/src/main/java/com/google/android/exoplayer2/ext/vp9/VpxLibrary.java @@ -87,7 +87,7 @@ public final class VpxLibrary { * protected content. */ public static boolean matchesExpectedExoMediaCryptoType( - @Nullable Class exoMediaCryptoType) { + Class exoMediaCryptoType) { return Util.areEqual(VpxLibrary.exoMediaCryptoType, exoMediaCryptoType); } diff --git a/extensions/vp9/src/main/jni/vpx_jni.cc b/extensions/vp9/src/main/jni/vpx_jni.cc index 9996848047..1fc0f9d56e 100644 --- a/extensions/vp9/src/main/jni/vpx_jni.cc +++ b/extensions/vp9/src/main/jni/vpx_jni.cc @@ -65,9 +65,11 @@ static jfieldID dataField; static jfieldID outputModeField; static jfieldID decoderPrivateField; -// android.graphics.ImageFormat.YV12. -static const int kHalPixelFormatYV12 = 0x32315659; +// Android YUV format. See: +// https://developer.android.com/reference/android/graphics/ImageFormat.html#YV12. +static const int kImageFormatYV12 = 0x32315659; static const int kDecoderPrivateBase = 0x100; + static int errorCode; jint JNI_OnLoad(JavaVM* vm, void* reserved) { @@ -635,7 +637,7 @@ DECODER_FUNC(jint, vpxRenderFrame, jlong jContext, jobject jSurface, } if (context->width != srcBuffer->d_w || context->height != srcBuffer->d_h) { ANativeWindow_setBuffersGeometry(context->native_window, srcBuffer->d_w, - srcBuffer->d_h, kHalPixelFormatYV12); + srcBuffer->d_h, kImageFormatYV12); context->width = srcBuffer->d_w; context->height = srcBuffer->d_h; } diff --git a/extensions/workmanager/build.gradle b/extensions/workmanager/build.gradle index 6025ecfcd0..1882ebac81 100644 --- a/extensions/workmanager/build.gradle +++ b/extensions/workmanager/build.gradle @@ -13,28 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - testOptions.unitTests.includeAndroidResources = true -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { implementation project(modulePrefix + 'library-core') - implementation 'androidx.work:work-runtime:2.3.4' + implementation 'androidx.work:work-runtime:2.4.0' + // Guava & Gradle interact badly, and this prevents + // "cannot access ListenableFuture" errors [internal b/157225611]. + // More info: https://blog.gradle.org/guava + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion } diff --git a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java index 97b132980d..ff9335ad84 100644 --- a/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java +++ b/extensions/workmanager/src/main/java/com/google/android/exoplayer2/ext/workmanager/WorkManagerScheduler.java @@ -35,22 +35,38 @@ import com.google.android.exoplayer2.util.Util; /** A {@link Scheduler} that uses {@link WorkManager}. */ public final class WorkManagerScheduler implements Scheduler { - private static final boolean DEBUG = false; private static final String TAG = "WorkManagerScheduler"; private static final String KEY_SERVICE_ACTION = "service_action"; private static final String KEY_SERVICE_PACKAGE = "service_package"; private static final String KEY_REQUIREMENTS = "requirements"; + private static final int SUPPORTED_REQUIREMENTS = + Requirements.NETWORK + | Requirements.NETWORK_UNMETERED + | (Util.SDK_INT >= 23 ? Requirements.DEVICE_IDLE : 0) + | Requirements.DEVICE_CHARGING + | Requirements.DEVICE_STORAGE_NOT_LOW; + private final WorkManager workManager; private final String workName; + /** @deprecated Call {@link #WorkManagerScheduler(Context, String)} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public WorkManagerScheduler(String workName) { + this.workName = workName; + workManager = WorkManager.getInstance(); + } + /** + * @param context A context. * @param workName A name for work scheduled by this instance. If the same name was used by a * previous instance, anything scheduled by the previous instance will be canceled by this * instance if {@link #schedule(Requirements, String, String)} or {@link #cancel()} are * called. */ - public WorkManagerScheduler(String workName) { + public WorkManagerScheduler(Context context, String workName) { this.workName = workName; + workManager = WorkManager.getInstance(context.getApplicationContext()); } @Override @@ -58,21 +74,31 @@ public final class WorkManagerScheduler implements Scheduler { Constraints constraints = buildConstraints(requirements); Data inputData = buildInputData(requirements, servicePackage, serviceAction); OneTimeWorkRequest workRequest = buildWorkRequest(constraints, inputData); - logd("Scheduling work: " + workName); - WorkManager.getInstance().enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest); + workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest); return true; } @Override public boolean cancel() { - logd("Canceling work: " + workName); - WorkManager.getInstance().cancelUniqueWork(workName); + workManager.cancelUniqueWork(workName); return true; } - private static Constraints buildConstraints(Requirements requirements) { - Constraints.Builder builder = new Constraints.Builder(); + @Override + public Requirements getSupportedRequirements(Requirements requirements) { + return requirements.filterRequirements(SUPPORTED_REQUIREMENTS); + } + private static Constraints buildConstraints(Requirements requirements) { + Requirements filteredRequirements = requirements.filterRequirements(SUPPORTED_REQUIREMENTS); + if (!filteredRequirements.equals(requirements)) { + Log.w( + TAG, + "Ignoring unsupported requirements: " + + (filteredRequirements.getRequirements() ^ requirements.getRequirements())); + } + + Constraints.Builder builder = new Constraints.Builder(); if (requirements.isUnmeteredNetworkRequired()) { builder.setRequiredNetworkType(NetworkType.UNMETERED); } else if (requirements.isNetworkRequired()) { @@ -80,13 +106,14 @@ public final class WorkManagerScheduler implements Scheduler { } else { builder.setRequiredNetworkType(NetworkType.NOT_REQUIRED); } - + if (Util.SDK_INT >= 23 && requirements.isIdleRequired()) { + setRequiresDeviceIdle(builder); + } if (requirements.isChargingRequired()) { builder.setRequiresCharging(true); } - - if (requirements.isIdleRequired() && Util.SDK_INT >= 23) { - setRequiresDeviceIdle(builder); + if (requirements.isStorageNotLowRequired()) { + builder.setRequiresStorageNotLow(true); } return builder.build(); @@ -117,12 +144,6 @@ public final class WorkManagerScheduler implements Scheduler { return builder.build(); } - private static void logd(String message) { - if (DEBUG) { - Log.d(TAG, message); - } - } - /** A {@link Worker} that starts the target service if the requirements are met. */ // This class needs to be public so that WorkManager can instantiate it. public static final class SchedulerWorker extends Worker { @@ -138,22 +159,17 @@ public final class WorkManagerScheduler implements Scheduler { @Override public Result doWork() { - logd("SchedulerWorker is started"); - Data inputData = workerParams.getInputData(); - Assertions.checkNotNull(inputData, "Work started without input data."); + Data inputData = Assertions.checkNotNull(workerParams.getInputData()); Requirements requirements = new Requirements(inputData.getInt(KEY_REQUIREMENTS, 0)); - if (requirements.checkRequirements(context)) { - logd("Requirements are met"); - String serviceAction = inputData.getString(KEY_SERVICE_ACTION); - String servicePackage = inputData.getString(KEY_SERVICE_PACKAGE); - Assertions.checkNotNull(serviceAction, "Service action missing."); - Assertions.checkNotNull(servicePackage, "Service package missing."); + int notMetRequirements = requirements.getNotMetRequirements(context); + if (notMetRequirements == 0) { + String serviceAction = Assertions.checkNotNull(inputData.getString(KEY_SERVICE_ACTION)); + String servicePackage = Assertions.checkNotNull(inputData.getString(KEY_SERVICE_PACKAGE)); Intent intent = new Intent(serviceAction).setPackage(servicePackage); - logd("Starting service action: " + serviceAction + " package: " + servicePackage); Util.startForegroundService(context, intent); return Result.success(); } else { - logd("Requirements are not met"); + Log.w(TAG, "Requirements not met: " + notMetRequirements); return Result.retry(); } } diff --git a/javadoc_combined.gradle b/javadoc_combined.gradle index 3b482910ae..1030d3e16a 100644 --- a/javadoc_combined.gradle +++ b/javadoc_combined.gradle @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +apply from: "${buildscript.sourceFile.parentFile}/constants.gradle" apply from: "${buildscript.sourceFile.parentFile}/javadoc_util.gradle" class CombinedJavadocPlugin implements Plugin { @@ -29,7 +30,8 @@ class CombinedJavadocPlugin implements Plugin { classpath = project.files([]) destinationDir = project.file("$project.buildDir/docs/javadoc") options { - links "https://developer.android.com/reference" + links "https://developer.android.com/reference", + "https://guava.dev/releases/$project.ext.guavaVersion/api/docs" encoding = "UTF-8" } exclude "**/BuildConfig.java" diff --git a/javadoc_library.gradle b/javadoc_library.gradle index dd508a1781..bb17dcb035 100644 --- a/javadoc_library.gradle +++ b/javadoc_library.gradle @@ -11,12 +11,13 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +apply from: "${buildscript.sourceFile.parentFile}/constants.gradle" apply from: "${buildscript.sourceFile.parentFile}/javadoc_util.gradle" android.libraryVariants.all { variant -> def name = variant.buildType.name - if (!name.equals("release")) { - return; // Skip non-release builds. + if (name != "release") { + return // Skip non-release builds. } def allSourceDirs = variant.sourceSets.inject ([]) { acc, val -> acc << val.javaDirectories @@ -26,7 +27,8 @@ android.libraryVariants.all { variant -> title = "ExoPlayer ${javadocTitle}" source = allSourceDirs options { - links "https://developer.android.com/reference" + links "https://developer.android.com/reference", + "https://guava.dev/releases/$project.ext.guavaVersion/api/docs" encoding = "UTF-8" } exclude "**/BuildConfig.java" diff --git a/library/all/build.gradle b/library/all/build.gradle index f78b8b2132..fa3491bb5d 100644 --- a/library/all/build.gradle +++ b/library/all/build.gradle @@ -11,17 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' - -android { - compileSdkVersion project.ext.compileSdkVersion - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } -} +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" dependencies { api project(modulePrefix + 'library-core') diff --git a/library/common/build.gradle b/library/common/build.gradle index 9dc3aabac3..2888b7e24c 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -11,38 +11,28 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - debug { - testCoverageEnabled = true - } - } - - testOptions.unitTests.includeAndroidResources = true -} +android.buildTypes.debug.testCoverageEnabled true dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion - testImplementation project(modulePrefix + 'testutils') + testImplementation 'org.mockito:mockito-core:' + mockitoVersion + testImplementation 'androidx.test:core:' + androidxTestCoreVersion + testImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion + testImplementation 'junit:junit:' + junitVersion + testImplementation 'com.google.truth:truth:' + truthVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/library/common/proguard-rules.txt b/library/common/proguard-rules.txt index c83dbaee2d..18e5264c20 100644 --- a/library/common/proguard-rules.txt +++ b/library/common/proguard-rules.txt @@ -4,3 +4,6 @@ -dontwarn org.checkerframework.** -dontwarn kotlin.annotations.jvm.** -dontwarn javax.annotation.** + +# From https://github.com/google/guava/wiki/UsingProGuardWithGuava +-dontwarn java.lang.ClassValue diff --git a/library/common/src/main/java/com/google/android/exoplayer2/C.java b/library/common/src/main/java/com/google/android/exoplayer2/C.java index 9f4e8beb1c..c4f4a2bbb5 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/C.java @@ -22,6 +22,7 @@ import android.media.AudioManager; import android.media.MediaCodec; import android.media.MediaFormat; import androidx.annotation.IntDef; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; @@ -85,23 +86,34 @@ public final class C { public static final int BYTES_PER_FLOAT = 4; /** - * The name of the ASCII charset. + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. */ - public static final String ASCII_NAME = "US-ASCII"; + @Deprecated public static final String ASCII_NAME = "US-ASCII"; /** - * The name of the UTF-8 charset. + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. */ - public static final String UTF8_NAME = "UTF-8"; + @Deprecated public static final String UTF8_NAME = "UTF-8"; - /** The name of the ISO-8859-1 charset. */ - public static final String ISO88591_NAME = "ISO-8859-1"; + /** + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. + */ + @Deprecated public static final String ISO88591_NAME = "ISO-8859-1"; - /** The name of the UTF-16 charset. */ - public static final String UTF16_NAME = "UTF-16"; + /** + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. + */ + @Deprecated public static final String UTF16_NAME = "UTF-16"; - /** The name of the UTF-16 little-endian charset. */ - public static final String UTF16LE_NAME = "UTF-16LE"; + /** + * @deprecated Use {@link java.nio.charset.StandardCharsets} or {@link + * com.google.common.base.Charsets} instead. + */ + @Deprecated public static final String UTF16LE_NAME = "UTF-16LE"; /** * The name of the serif font family. @@ -165,6 +177,7 @@ public final class C { ENCODING_AAC_HE_V2, ENCODING_AAC_XHE, ENCODING_AAC_ELD, + ENCODING_AAC_ER_BSAC, ENCODING_AC3, ENCODING_E_AC3, ENCODING_E_AC3_JOC, @@ -220,6 +233,8 @@ public final class C { public static final int ENCODING_AAC_XHE = AudioFormat.ENCODING_AAC_XHE; /** @see AudioFormat#ENCODING_AAC_ELD */ public static final int ENCODING_AAC_ELD = AudioFormat.ENCODING_AAC_ELD; + /** AAC Error Resilient Bit-Sliced Arithmetic Coding. */ + public static final int ENCODING_AAC_ER_BSAC = 0x40000000; /** @see AudioFormat#ENCODING_AC3 */ public static final int ENCODING_AC3 = AudioFormat.ENCODING_AC3; /** @see AudioFormat#ENCODING_E_AC3 */ @@ -549,6 +564,7 @@ public final class C { // ) /** @deprecated Use {@code Renderer.VideoScalingMode}. */ + @SuppressWarnings("deprecation") @Documented @Retention(RetentionPolicy.SOURCE) @IntDef(value = {VIDEO_SCALING_MODE_SCALE_TO_FIT, VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}) @@ -563,7 +579,9 @@ public final class C { public static final int VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING = MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING; /** @deprecated Use {@code Renderer.VIDEO_SCALING_MODE_DEFAULT}. */ - @Deprecated public static final int VIDEO_SCALING_MODE_DEFAULT = VIDEO_SCALING_MODE_SCALE_TO_FIT; + @SuppressWarnings("deprecation") + @Deprecated + public static final int VIDEO_SCALING_MODE_DEFAULT = VIDEO_SCALING_MODE_SCALE_TO_FIT; /** * Track selection flags. Possible flag values are {@link #SELECTION_FLAG_DEFAULT}, {@link @@ -675,7 +693,7 @@ public final class C { public static final int TRACK_TYPE_METADATA = 4; /** A type constant for camera motion tracks. */ public static final int TRACK_TYPE_CAMERA_MOTION = 5; - /** A type constant for a dummy or empty track. */ + /** A type constant for a fake or empty track. */ public static final int TRACK_TYPE_NONE = 6; /** * Applications or extensions may define custom {@code TRACK_TYPE_*} constants greater than or @@ -963,7 +981,7 @@ public final class C { /** * Mode specifying whether the player should hold a WakeLock and a WifiLock. One of {@link - * #WAKE_MODE_NONE}, {@link #WAKE_MODE_LOCAL} and {@link #WAKE_MODE_NETWORK}. + * #WAKE_MODE_NONE}, {@link #WAKE_MODE_LOCAL} or {@link #WAKE_MODE_NETWORK}. */ @Documented @Retention(RetentionPolicy.SOURCE) @@ -1099,8 +1117,9 @@ public final class C { */ @RequiresApi(21) public static int generateAudioSessionIdV21(Context context) { - return ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)) - .generateAudioSessionId(); + @Nullable + AudioManager audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)); + return audioManager == null ? AudioManager.ERROR : audioManager.generateAudioSessionId(); } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java index 06743732e7..15c4bf1c1d 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2; +import android.os.Build; import java.util.HashSet; /** @@ -29,11 +30,11 @@ public final class ExoPlayerLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "2.11.4"; + public static final String VERSION = "2.12.0"; /** 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.12.0"; /** * The version of the library expressed as an integer, for example 1002003. @@ -43,7 +44,11 @@ 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 = 2012000; + + /** The default user agent for requests made by the library. */ + public static final String DEFAULT_USER_AGENT = + VERSION_SLASHY + " (Linux;Android " + Build.VERSION.RELEASE + ") " + VERSION_SLASHY; /** * Whether the library was compiled with {@link com.google.android.exoplayer2.util.Assertions} diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Format.java b/library/common/src/main/java/com/google/android/exoplayer2/Format.java index e7db47d535..05062727c3 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/Format.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/Format.java @@ -20,7 +20,9 @@ import android.os.Parcelable; import androidx.annotation.Nullable; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.drm.UnsupportedMediaCrypto; import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.ColorInfo; @@ -163,7 +165,7 @@ public final class Format implements Parcelable { private int accessibilityChannel; - // Provided by source. + // Provided by the source. @Nullable private Class exoMediaCryptoType; @@ -228,7 +230,7 @@ public final class Format implements Parcelable { this.encoderPadding = format.encoderPadding; // Text specific. this.accessibilityChannel = format.accessibilityChannel; - // Provided by source. + // Provided by the source. this.exoMediaCryptoType = format.exoMediaCryptoType; } @@ -590,37 +592,7 @@ public final class Format implements Parcelable { // Build. public Format build() { - return new Format( - id, - label, - language, - selectionFlags, - roleFlags, - averageBitrate, - peakBitrate, - codecs, - metadata, - containerMimeType, - sampleMimeType, - maxInputSize, - initializationData, - drmInitData, - subsampleOffsetUs, - width, - height, - frameRate, - rotationDegrees, - pixelWidthHeightRatio, - projectionData, - stereoMode, - colorInfo, - channelCount, - sampleRate, - pcmEncoding, - encoderDelay, - encoderPadding, - accessibilityChannel, - exoMediaCryptoType); + return new Format(/* builder= */ this); } } @@ -785,9 +757,9 @@ public final class Format implements Parcelable { // Provided by source. /** - * The type of the {@link ExoMediaCrypto} provided by the media source, if the media source can - * acquire a DRM session for {@link #drmInitData}. Null if the media source cannot acquire a - * session for {@link #drmInitData}, or if not applicable. + * The type of {@link ExoMediaCrypto} that will be associated with the content this format + * describes, or {@code null} if the content is not encrypted. Cannot be null if {@link + * #drmInitData} is non-null. */ @Nullable public final Class exoMediaCryptoType; @@ -1209,84 +1181,55 @@ public final class Format implements Parcelable { return new Builder().setId(id).setSampleMimeType(sampleMimeType).build(); } - /* package */ Format( - @Nullable String id, - @Nullable String label, - @Nullable String language, - @C.SelectionFlags int selectionFlags, - @C.RoleFlags int roleFlags, - int averageBitrate, - int peakBitrate, - @Nullable String codecs, - @Nullable Metadata metadata, - // Container specific. - @Nullable String containerMimeType, - // Sample specific. - @Nullable String sampleMimeType, - int maxInputSize, - @Nullable List initializationData, - @Nullable DrmInitData drmInitData, - long subsampleOffsetUs, - // Video specific. - int width, - int height, - float frameRate, - int rotationDegrees, - float pixelWidthHeightRatio, - @Nullable byte[] projectionData, - @C.StereoMode int stereoMode, - @Nullable ColorInfo colorInfo, - // Audio specific. - int channelCount, - int sampleRate, - @C.PcmEncoding int pcmEncoding, - int encoderDelay, - int encoderPadding, - // Text specific. - int accessibilityChannel, - // Provided by source. - @Nullable Class exoMediaCryptoType) { - this.id = id; - this.label = label; - this.language = Util.normalizeLanguageCode(language); - this.selectionFlags = selectionFlags; - this.roleFlags = roleFlags; - this.averageBitrate = averageBitrate; - this.peakBitrate = peakBitrate; - this.bitrate = peakBitrate != NO_VALUE ? peakBitrate : averageBitrate; - this.codecs = codecs; - this.metadata = metadata; + private Format(Builder builder) { + id = builder.id; + label = builder.label; + language = Util.normalizeLanguageCode(builder.language); + selectionFlags = builder.selectionFlags; + roleFlags = builder.roleFlags; + averageBitrate = builder.averageBitrate; + peakBitrate = builder.peakBitrate; + bitrate = peakBitrate != NO_VALUE ? peakBitrate : averageBitrate; + codecs = builder.codecs; + metadata = builder.metadata; // Container specific. - this.containerMimeType = containerMimeType; + containerMimeType = builder.containerMimeType; // Sample specific. - this.sampleMimeType = sampleMimeType; - this.maxInputSize = maxInputSize; - this.initializationData = - initializationData == null ? Collections.emptyList() : initializationData; - this.drmInitData = drmInitData; - this.subsampleOffsetUs = subsampleOffsetUs; + sampleMimeType = builder.sampleMimeType; + maxInputSize = builder.maxInputSize; + initializationData = + builder.initializationData == null ? Collections.emptyList() : builder.initializationData; + drmInitData = builder.drmInitData; + subsampleOffsetUs = builder.subsampleOffsetUs; // Video specific. - this.width = width; - this.height = height; - this.frameRate = frameRate; - this.rotationDegrees = rotationDegrees == NO_VALUE ? 0 : rotationDegrees; - this.pixelWidthHeightRatio = pixelWidthHeightRatio == NO_VALUE ? 1 : pixelWidthHeightRatio; - this.projectionData = projectionData; - this.stereoMode = stereoMode; - this.colorInfo = colorInfo; + width = builder.width; + height = builder.height; + frameRate = builder.frameRate; + rotationDegrees = builder.rotationDegrees == NO_VALUE ? 0 : builder.rotationDegrees; + pixelWidthHeightRatio = + builder.pixelWidthHeightRatio == NO_VALUE ? 1 : builder.pixelWidthHeightRatio; + projectionData = builder.projectionData; + stereoMode = builder.stereoMode; + colorInfo = builder.colorInfo; // Audio specific. - this.channelCount = channelCount; - this.sampleRate = sampleRate; - this.pcmEncoding = pcmEncoding; - this.encoderDelay = encoderDelay == NO_VALUE ? 0 : encoderDelay; - this.encoderPadding = encoderPadding == NO_VALUE ? 0 : encoderPadding; + channelCount = builder.channelCount; + sampleRate = builder.sampleRate; + pcmEncoding = builder.pcmEncoding; + encoderDelay = builder.encoderDelay == NO_VALUE ? 0 : builder.encoderDelay; + encoderPadding = builder.encoderPadding == NO_VALUE ? 0 : builder.encoderPadding; // Text specific. - this.accessibilityChannel = accessibilityChannel; + accessibilityChannel = builder.accessibilityChannel; // Provided by source. - this.exoMediaCryptoType = exoMediaCryptoType; + if (builder.exoMediaCryptoType == null && drmInitData != null) { + // Encrypted content must always have a non-null exoMediaCryptoType. + exoMediaCryptoType = UnsupportedMediaCrypto.class; + } else { + exoMediaCryptoType = builder.exoMediaCryptoType; + } } - @SuppressWarnings("ResourceType") + // Some fields are deprecated but they're still assigned below. + @SuppressWarnings({"ResourceType"}) /* package */ Format(Parcel in) { id = in.readString(); label = in.readString(); @@ -1306,7 +1249,7 @@ public final class Format implements Parcelable { int initializationDataSize = in.readInt(); initializationData = new ArrayList<>(initializationDataSize); for (int i = 0; i < initializationDataSize; i++) { - initializationData.add(in.createByteArray()); + initializationData.add(Assertions.checkNotNull(in.createByteArray())); } drmInitData = in.readParcelable(DrmInitData.class.getClassLoader()); subsampleOffsetUs = in.readLong(); @@ -1329,7 +1272,8 @@ public final class Format implements Parcelable { // Text specific. accessibilityChannel = in.readInt(); // Provided by source. - exoMediaCryptoType = null; + // Encrypted content must always have a non-null exoMediaCryptoType. + exoMediaCryptoType = drmInitData != null ? UnsupportedMediaCrypto.class : null; } /** Returns a {@link Format.Builder} initialized with the values of this instance. */ @@ -1556,7 +1500,7 @@ public final class Format implements Parcelable { result = 31 * result + encoderPadding; // Text specific. result = 31 * result + accessibilityChannel; - // Provided by source. + // Provided by the source. result = 31 * result + (exoMediaCryptoType == null ? 0 : exoMediaCryptoType.hashCode()); hashCode = result; } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java index 15ec1bb227..dfff9a9e73 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -32,20 +32,20 @@ import java.util.UUID; public final class MediaItem { /** - * Creates a {@link MediaItem} for the given uri. + * Creates a {@link MediaItem} for the given URI. * - * @param uri The uri. - * @return An {@link MediaItem} for the given uri. + * @param uri The URI. + * @return An {@link MediaItem} for the given URI. */ public static MediaItem fromUri(String uri) { return new MediaItem.Builder().setUri(uri).build(); } /** - * Creates a {@link MediaItem} for the given {@link Uri uri}. + * Creates a {@link MediaItem} for the given {@link Uri URI}. * * @param uri The {@link Uri uri}. - * @return An {@link MediaItem} for the given uri. + * @return An {@link MediaItem} for the given URI. */ public static MediaItem fromUri(Uri uri) { return new MediaItem.Builder().setUri(uri).build(); @@ -67,6 +67,7 @@ public final class MediaItem { @Nullable private UUID drmUuid; private boolean drmMultiSession; private boolean drmPlayClearContentWithoutKey; + private boolean drmForceDefaultLicenseUri; private List drmSessionForClearTypes; @Nullable private byte[] drmKeySetId; private List streamKeys; @@ -108,6 +109,7 @@ public final class MediaItem { drmLicenseUri = drmConfiguration.licenseUri; drmLicenseRequestHeaders = drmConfiguration.requestHeaders; drmMultiSession = drmConfiguration.multiSession; + drmForceDefaultLicenseUri = drmConfiguration.forceDefaultLicenseUri; drmPlayClearContentWithoutKey = drmConfiguration.playClearContentWithoutKey; drmSessionForClearTypes = drmConfiguration.sessionForClearTypes; drmUuid = drmConfiguration.uuid; @@ -117,22 +119,32 @@ public final class MediaItem { } /** - * Sets the optional media id which identifies the media item. If not specified, {@link #setUri} + * Sets the optional media ID which identifies the media item. If not specified, {@link #setUri} * must be called and the string representation of {@link PlaybackProperties#uri} is used as the - * media id. + * media ID. */ public Builder setMediaId(@Nullable String mediaId) { this.mediaId = mediaId; return this; } - /** Sets the optional uri. If not specified, {@link #setMediaId(String)} must be called. */ + /** + * Sets the optional URI. If not specified, {@link #setMediaId(String)} must be called. + * + *

If {@code uri} is null or unset no {@link PlaybackProperties} object is created during + * {@link #build()} and any other {@code Builder} methods that would populate {@link + * MediaItem#playbackProperties} are ignored. + */ public Builder setUri(@Nullable String uri) { return setUri(uri == null ? null : Uri.parse(uri)); } /** - * Sets the optional {@link Uri}. If not specified, {@link #setMediaId(String)} must be called. + * Sets the optional URI. If not specified, {@link #setMediaId(String)} must be called. + * + *

If {@code uri} is null or unset no {@link PlaybackProperties} object is created during + * {@link #build()} and any other {@code Builder} methods that would populate {@link + * MediaItem#playbackProperties} are ignored. */ public Builder setUri(@Nullable Uri uri) { this.uri = uri; @@ -140,14 +152,14 @@ public final class MediaItem { } /** - * Sets the optional mime type. + * Sets the optional MIME type. * - *

The mime type may be used as a hint for inferring the type of the media item. + *

The MIME type may be used as a hint for inferring the type of the media item. * - *

If a {@link PlaybackProperties#uri} is set, the mime type is used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the MIME type is used to create a + * {@link PlaybackProperties} object. Otherwise it will be ignored. * - * @param mimeType The mime type. + * @param mimeType The MIME type. */ public Builder setMimeType(@Nullable String mimeType) { this.mimeType = mimeType; @@ -204,11 +216,11 @@ public final class MediaItem { } /** - * Sets the optional license server {@link Uri}. If a license uri is set, the {@link + * Sets the optional DRM license server URI. If this URI is set, the {@link * DrmConfiguration#uuid} needs to be specified as well. * - *

If a {@link PlaybackProperties#uri} is set, the drm license uri is used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the DRM license server URI is used to + * create a {@link PlaybackProperties} object. Otherwise it will be ignored. */ public Builder setDrmLicenseUri(@Nullable Uri licenseUri) { drmLicenseUri = licenseUri; @@ -216,11 +228,11 @@ public final class MediaItem { } /** - * Sets the optional license server uri as a {@link String}. If a license uri is set, the {@link + * Sets the optional DRM license server URI. If this URI is set, the {@link * DrmConfiguration#uuid} needs to be specified as well. * - *

If a {@link PlaybackProperties#uri} is set, the drm license uri is used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the DRM license server URI is used to + * create a {@link PlaybackProperties} object. Otherwise it will be ignored. */ public Builder setDrmLicenseUri(@Nullable String licenseUri) { drmLicenseUri = licenseUri == null ? null : Uri.parse(licenseUri); @@ -228,11 +240,11 @@ public final class MediaItem { } /** - * Sets the optional request headers attached to the drm license request. + * Sets the optional request headers attached to the DRM license request. * *

{@code null} or an empty {@link Map} can be used for a reset. * - *

If no valid drm configuration is specified, the drm license request headers are ignored. + *

If no valid DRM configuration is specified, the DRM license request headers are ignored. */ public Builder setDrmLicenseRequestHeaders( @Nullable Map licenseRequestHeaders) { @@ -244,11 +256,11 @@ public final class MediaItem { } /** - * Sets the {@link UUID} of the protection scheme. If a drm system uuid is set, the {@link + * Sets the {@link UUID} of the protection scheme. If a DRM system UUID is set, the {@link * DrmConfiguration#licenseUri} needs to be set as well. * - *

If a {@link PlaybackProperties#uri} is set, the drm system uuid is used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the DRM system UUID is used to create + * a {@link PlaybackProperties} object. Otherwise it will be ignored. */ public Builder setDrmUuid(@Nullable UUID uuid) { drmUuid = uuid; @@ -256,16 +268,28 @@ public final class MediaItem { } /** - * Sets whether the drm configuration is multi session enabled. + * Sets whether the DRM configuration is multi session enabled. * - *

If a {@link PlaybackProperties#uri} is set, the drm multi session flag is used to create a - * {@link PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the DRM multi session flag is used to + * create a {@link PlaybackProperties} object. Otherwise it will be ignored. */ public Builder setDrmMultiSession(boolean multiSession) { drmMultiSession = multiSession; return this; } + /** + * Sets whether to use the DRM license server URI of the media item for key requests that + * include their own DRM license server URI. + * + *

If {@link #setUri} is passed a non-null {@code uri}, the DRM force default license flag is + * used to create a {@link PlaybackProperties} object. Otherwise it will be ignored. + */ + public Builder setDrmForceDefaultLicenseUri(boolean forceDefaultLicenseUri) { + this.drmForceDefaultLicenseUri = forceDefaultLicenseUri; + return this; + } + /** * Sets whether clear samples within protected content should be played when keys for the * encrypted part of the content have yet to be loaded. @@ -276,7 +300,7 @@ public final class MediaItem { } /** - * Sets whether a drm session should be used for clear tracks of type {@link C#TRACK_TYPE_VIDEO} + * Sets whether a DRM session should be used for clear tracks of type {@link C#TRACK_TYPE_VIDEO} * and {@link C#TRACK_TYPE_AUDIO}. * *

This method overrides what has been set by previously calling {@link @@ -291,10 +315,10 @@ public final class MediaItem { } /** - * Sets a list of {@link C}{@code .TRACK_TYPE_*} constants for which to use a drm session even + * Sets a list of {@link C}{@code .TRACK_TYPE_*} constants for which to use a DRM session even * when the tracks are in the clear. * - *

For the common case of using a drm session for {@link C#TRACK_TYPE_VIDEO} and {@link + *

For the common case of using a DRM session for {@link C#TRACK_TYPE_VIDEO} and {@link * C#TRACK_TYPE_AUDIO} the {@link #setDrmSessionForClearPeriods(boolean)} can be used. * *

This method overrides what has been set by previously calling {@link @@ -330,8 +354,8 @@ public final class MediaItem { * *

{@code null} or an empty {@link List} can be used for a reset. * - *

If a {@link PlaybackProperties#uri} is set, the stream keys are used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the stream keys are used to create a + * {@link PlaybackProperties} object. Otherwise they will be ignored. */ public Builder setStreamKeys(@Nullable List streamKeys) { this.streamKeys = @@ -344,8 +368,8 @@ public final class MediaItem { /** * Sets the optional custom cache key (only used for progressive streams). * - *

If a {@link PlaybackProperties#uri} is set, the custom cache key is used to create a - * {@link PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the custom cache key is used to + * create a {@link PlaybackProperties} object. Otherwise it will be ignored. */ public Builder setCustomCacheKey(@Nullable String customCacheKey) { this.customCacheKey = customCacheKey; @@ -357,8 +381,8 @@ public final class MediaItem { * *

{@code null} or an empty {@link List} can be used for a reset. * - *

If a {@link PlaybackProperties#uri} is set, the subtitles are used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the subtitles are used to create a + * {@link PlaybackProperties} object. Otherwise they will be ignored. */ public Builder setSubtitles(@Nullable List subtitles) { this.subtitles = @@ -371,8 +395,8 @@ public final class MediaItem { /** * Sets the optional ad tag URI. * - *

If a {@link PlaybackProperties#uri} is set, the ad tag URI is used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

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

If a {@link PlaybackProperties#uri} is set, the ad tag URI is used to create a {@link - * PlaybackProperties} object. Otherwise it will be ignored. + *

If {@link #setUri} is passed a non-null {@code uri}, the ad tag URI is used to create a + * {@link PlaybackProperties} object. Otherwise it will be ignored. */ public Builder setAdTagUri(@Nullable Uri adTagUri) { this.adTagUri = adTagUri; @@ -395,7 +419,7 @@ public final class MediaItem { * published in the {@code com.google.android.exoplayer2.Timeline} of the source as {@code * com.google.android.exoplayer2.Timeline.Window#tag}. * - *

If a {@link PlaybackProperties#uri} is set, the tag is used to create a {@link + *

If {@link #setUri} is passed a non-null {@code uri}, the tag is used to create a {@link * PlaybackProperties} object. Otherwise it will be ignored. */ public Builder setTag(@Nullable Object tag) { @@ -426,6 +450,7 @@ public final class MediaItem { drmLicenseUri, drmLicenseRequestHeaders, drmMultiSession, + drmForceDefaultLicenseUri, drmPlayClearContentWithoutKey, drmSessionForClearTypes, drmKeySetId) @@ -457,15 +482,15 @@ public final class MediaItem { public final UUID uuid; /** - * Optional license server {@link Uri}. If {@code null} then the license server must be + * Optional DRM license server {@link Uri}. If {@code null} then the DRM license server must be * specified by the media. */ @Nullable public final Uri licenseUri; - /** The headers to attach to the request for the license uri. */ + /** The headers to attach to the request to the DRM license server. */ public final Map requestHeaders; - /** Whether the drm configuration is multi session enabled. */ + /** Whether the DRM configuration is multi session enabled. */ public final boolean multiSession; /** @@ -474,7 +499,13 @@ public final class MediaItem { */ public final boolean playClearContentWithoutKey; - /** The types of clear tracks for which to use a drm session. */ + /** + * Sets whether to use the DRM license server URI of the media item for key requests that + * include their own DRM license server URI. + */ + public final boolean forceDefaultLicenseUri; + + /** The types of clear tracks for which to use a DRM session. */ public final List sessionForClearTypes; @Nullable private final byte[] keySetId; @@ -484,6 +515,7 @@ public final class MediaItem { @Nullable Uri licenseUri, Map requestHeaders, boolean multiSession, + boolean forceDefaultLicenseUri, boolean playClearContentWithoutKey, List drmSessionForClearTypes, @Nullable byte[] keySetId) { @@ -491,6 +523,7 @@ public final class MediaItem { this.licenseUri = licenseUri; this.requestHeaders = requestHeaders; this.multiSession = multiSession; + this.forceDefaultLicenseUri = forceDefaultLicenseUri; this.playClearContentWithoutKey = playClearContentWithoutKey; this.sessionForClearTypes = drmSessionForClearTypes; this.keySetId = keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : null; @@ -516,6 +549,7 @@ public final class MediaItem { && Util.areEqual(licenseUri, other.licenseUri) && Util.areEqual(requestHeaders, other.requestHeaders) && multiSession == other.multiSession + && forceDefaultLicenseUri == other.forceDefaultLicenseUri && playClearContentWithoutKey == other.playClearContentWithoutKey && sessionForClearTypes.equals(other.sessionForClearTypes) && Arrays.equals(keySetId, other.keySetId); @@ -527,6 +561,7 @@ public final class MediaItem { result = 31 * result + (licenseUri != null ? licenseUri.hashCode() : 0); result = 31 * result + requestHeaders.hashCode(); result = 31 * result + (multiSession ? 1 : 0); + result = 31 * result + (forceDefaultLicenseUri ? 1 : 0); result = 31 * result + (playClearContentWithoutKey ? 1 : 0); result = 31 * result + sessionForClearTypes.hashCode(); result = 31 * result + Arrays.hashCode(keySetId); @@ -541,9 +576,9 @@ public final class MediaItem { public final Uri uri; /** - * The optional mime type of the item, or {@code null} if unspecified. + * The optional MIME type of the item, or {@code null} if unspecified. * - *

The mime type can be used to disambiguate media items that have a uri which does not allow + *

The MIME type can be used to disambiguate media items that have a URI which does not allow * to infer the actual media type. */ @Nullable public final String mimeType; @@ -638,8 +673,8 @@ public final class MediaItem { /** * Creates an instance. * - * @param uri The {@link Uri uri} to the subtitle file. - * @param mimeType The mime type. + * @param uri The {@link Uri URI} to the subtitle file. + * @param mimeType The MIME type. * @param language The optional language. */ public Subtitle(Uri uri, String mimeType, @Nullable String language) { @@ -649,8 +684,8 @@ public final class MediaItem { /** * Creates an instance with the given selection flags. * - * @param uri The {@link Uri uri} to the subtitle file. - * @param mimeType The mime type. + * @param uri The {@link Uri URI} to the subtitle file. + * @param mimeType The MIME type. * @param language The optional language. * @param selectionFlags The selection flags. */ diff --git a/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java index 8de423042f..4a03b79856 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/audio/AacUtil.java @@ -309,6 +309,8 @@ public final class AacUtil { return C.ENCODING_AAC_XHE; case AUDIO_OBJECT_TYPE_AAC_ELD: return C.ENCODING_AAC_ELD; + case AUDIO_OBJECT_TYPE_AAC_ER_BSAC: + return C.ENCODING_AAC_ER_BSAC; default: return C.ENCODING_INVALID; } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac4Util.java b/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac4Util.java index 2e4367f4e2..96712f04cb 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac4Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac4Util.java @@ -223,13 +223,14 @@ public final class Ac4Util { public static void getAc4SampleHeader(int size, ParsableByteArray buffer) { // See ETSI TS 103 190-1 V1.3.1, Annex G. buffer.reset(SAMPLE_HEADER_SIZE); - buffer.data[0] = (byte) 0xAC; - buffer.data[1] = 0x40; - buffer.data[2] = (byte) 0xFF; - buffer.data[3] = (byte) 0xFF; - buffer.data[4] = (byte) ((size >> 16) & 0xFF); - buffer.data[5] = (byte) ((size >> 8) & 0xFF); - buffer.data[6] = (byte) (size & 0xFF); + byte[] data = buffer.getData(); + data[0] = (byte) 0xAC; + data[1] = 0x40; + data[2] = (byte) 0xFF; + data[3] = (byte) 0xFF; + data[4] = (byte) ((size >> 16) & 0xFF); + data[5] = (byte) ((size >> 8) & 0xFF); + data[6] = (byte) (size & 0xFF); } private static int readVariableBits(ParsableBitArray data, int bitsPerRead) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java b/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java similarity index 90% rename from library/core/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java rename to library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java index 53eed6c551..71ffb00982 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/audio/AudioAttributes.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 The Android Open Source Project + * Copyright 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,14 +21,14 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; /** - * Attributes for audio playback, which configure the underlying platform - * {@link android.media.AudioTrack}. - *

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

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

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

This class is based on {@link android.media.AudioAttributes}, but can be used on all supported * API versions. */ public final class AudioAttributes { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/audio/OpusUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/audio/OpusUtil.java new file mode 100644 index 0000000000..3e434bb7e8 --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/audio/OpusUtil.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import com.google.android.exoplayer2.C; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +/** Utility methods for handling Opus audio streams. */ +public class OpusUtil { + + /** Opus streams are always 48000 Hz. */ + public static final int SAMPLE_RATE = 48_000; + + private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; + private static final int FULL_CODEC_INITIALIZATION_DATA_BUFFER_COUNT = 3; + + private OpusUtil() {} // Prevents instantiation. + + /** + * Parses the channel count from an Opus Identification Header. + * + * @param header An Opus Identification Header, as defined by RFC 7845. + * @return The parsed channel count. + */ + public static int getChannelCount(byte[] header) { + return header[9] & 0xFF; + } + + /** + * Builds codec initialization data from an Opus Identification Header. + * + * @param header An Opus Identification Header, as defined by RFC 7845. + * @return Codec initialization data suitable for an Opus MediaCodec. + */ + public static List buildInitializationData(byte[] header) { + int preSkipSamples = getPreSkipSamples(header); + long preSkipNanos = sampleCountToNanoseconds(preSkipSamples); + long seekPreRollNanos = sampleCountToNanoseconds(DEFAULT_SEEK_PRE_ROLL_SAMPLES); + + List initializationData = new ArrayList<>(FULL_CODEC_INITIALIZATION_DATA_BUFFER_COUNT); + initializationData.add(header); + initializationData.add(buildNativeOrderByteArray(preSkipNanos)); + initializationData.add(buildNativeOrderByteArray(seekPreRollNanos)); + return initializationData; + } + + /** + * Returns the number of pre-skip samples specified by the given Opus codec initialization data. + * + * @param initializationData The codec initialization data. + * @return The number of pre-skip samples. + */ + public static int getPreSkipSamples(List initializationData) { + if (initializationData.size() == FULL_CODEC_INITIALIZATION_DATA_BUFFER_COUNT) { + long codecDelayNs = + ByteBuffer.wrap(initializationData.get(1)).order(ByteOrder.nativeOrder()).getLong(); + return (int) nanosecondsToSampleCount(codecDelayNs); + } + // Fall back to parsing directly from the Opus Identification header. + return getPreSkipSamples(initializationData.get(0)); + } + + /** + * Returns the number of seek per-roll samples specified by the given Opus codec initialization + * data. + * + * @param initializationData The codec initialization data. + * @return The number of seek pre-roll samples. + */ + public static int getSeekPreRollSamples(List initializationData) { + if (initializationData.size() == FULL_CODEC_INITIALIZATION_DATA_BUFFER_COUNT) { + long seekPreRollNs = + ByteBuffer.wrap(initializationData.get(2)).order(ByteOrder.nativeOrder()).getLong(); + return (int) nanosecondsToSampleCount(seekPreRollNs); + } + // Fall back to returning the default seek pre-roll. + return DEFAULT_SEEK_PRE_ROLL_SAMPLES; + } + + private static int getPreSkipSamples(byte[] header) { + return ((header[11] & 0xFF) << 8) | (header[10] & 0xFF); + } + + private static byte[] buildNativeOrderByteArray(long value) { + return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).putLong(value).array(); + } + + private static long sampleCountToNanoseconds(long sampleCount) { + return (sampleCount * C.NANOS_PER_SECOND) / SAMPLE_RATE; + } + + private static long nanosecondsToSampleCount(long nanoseconds) { + return (nanoseconds * SAMPLE_RATE) / C.NANOS_PER_SECOND; + } +} diff --git a/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java index 1c52abc476..7eaab6ae1d 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/decoder/CryptoInfo.java @@ -15,8 +15,10 @@ */ package com.google.android.exoplayer2.decoder; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; /** @@ -30,13 +32,13 @@ public final class CryptoInfo { * * @see android.media.MediaCodec.CryptoInfo#iv */ - public byte[] iv; + @Nullable public byte[] iv; /** * The 16 byte key id. * * @see android.media.MediaCodec.CryptoInfo#key */ - public byte[] key; + @Nullable public byte[] key; /** * The type of encryption that has been applied. Must be one of the {@link C.CryptoMode} values. * @@ -49,14 +51,14 @@ public final class CryptoInfo { * * @see android.media.MediaCodec.CryptoInfo#numBytesOfClearData */ - public int[] numBytesOfClearData; + @Nullable public int[] numBytesOfClearData; /** * The number of trailing encrypted bytes in each sub-sample. If null, all bytes are treated as * clear and {@link #numBytesOfClearData} must be specified. * * @see android.media.MediaCodec.CryptoInfo#numBytesOfEncryptedData */ - public int[] numBytesOfEncryptedData; + @Nullable public int[] numBytesOfEncryptedData; /** * The number of subSamples that make up the buffer's contents. * @@ -73,7 +75,7 @@ public final class CryptoInfo { public int clearBlocks; private final android.media.MediaCodec.CryptoInfo frameworkCryptoInfo; - private final PatternHolderV24 patternHolder; + @Nullable private final PatternHolderV24 patternHolder; public CryptoInfo() { frameworkCryptoInfo = new android.media.MediaCodec.CryptoInfo(); @@ -102,7 +104,7 @@ public final class CryptoInfo { frameworkCryptoInfo.iv = iv; frameworkCryptoInfo.mode = mode; if (Util.SDK_INT >= 24) { - patternHolder.set(encryptedBlocks, clearBlocks); + Assertions.checkNotNull(patternHolder).set(encryptedBlocks, clearBlocks); } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/decoder/DecoderInputBuffer.java b/library/common/src/main/java/com/google/android/exoplayer2/decoder/DecoderInputBuffer.java index bd5df4c8b1..0ae8ce31f9 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/decoder/DecoderInputBuffer.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/decoder/DecoderInputBuffer.java @@ -30,7 +30,8 @@ import org.checkerframework.checker.nullness.qual.EnsuresNonNull; public class DecoderInputBuffer extends Buffer { /** - * The buffer replacement mode, which may disable replacement. One of {@link + * The buffer replacement mode. This controls how {@link #ensureSpaceForWrite} generates + * replacement buffers when the capacity of the existing buffer is insufficient. One of {@link * #BUFFER_REPLACEMENT_MODE_DISABLED}, {@link #BUFFER_REPLACEMENT_MODE_NORMAL} or {@link * #BUFFER_REPLACEMENT_MODE_DIRECT}. */ @@ -83,6 +84,7 @@ public class DecoderInputBuffer extends Buffer { @Nullable public ByteBuffer supplementalData; @BufferReplacementMode private final int bufferReplacementMode; + private final int paddingSize; /** * Creates a new instance for which {@link #isFlagsOnly()} will return true. @@ -94,13 +96,28 @@ public class DecoderInputBuffer extends Buffer { } /** - * @param bufferReplacementMode Determines the behavior of {@link #ensureSpaceForWrite(int)}. One - * of {@link #BUFFER_REPLACEMENT_MODE_DISABLED}, {@link #BUFFER_REPLACEMENT_MODE_NORMAL} and - * {@link #BUFFER_REPLACEMENT_MODE_DIRECT}. + * Creates a new instance. + * + * @param bufferReplacementMode The {@link BufferReplacementMode} replacement mode. */ public DecoderInputBuffer(@BufferReplacementMode int bufferReplacementMode) { + this(bufferReplacementMode, /* paddingSize= */ 0); + } + + /** + * Creates a new instance. + * + * @param bufferReplacementMode The {@link BufferReplacementMode} replacement mode. + * @param paddingSize If non-zero, {@link #ensureSpaceForWrite(int)} will ensure that the buffer + * is this number of bytes larger than the requested length. This can be useful for decoders + * that consume data in fixed size blocks, for efficiency. Setting the padding size to the + * decoder's fixed read size is necessary to prevent such a decoder from trying to read beyond + * the end of the buffer. + */ + public DecoderInputBuffer(@BufferReplacementMode int bufferReplacementMode, int paddingSize) { this.cryptoInfo = new CryptoInfo(); this.bufferReplacementMode = bufferReplacementMode; + this.paddingSize = paddingSize; } /** @@ -132,24 +149,27 @@ public class DecoderInputBuffer extends Buffer { */ @EnsuresNonNull("data") public void ensureSpaceForWrite(int length) { - if (data == null) { + length += paddingSize; + @Nullable ByteBuffer currentData = data; + if (currentData == null) { data = createReplacementByteBuffer(length); return; } // Check whether the current buffer is sufficient. - int capacity = data.capacity(); - int position = data.position(); + int capacity = currentData.capacity(); + int position = currentData.position(); int requiredCapacity = position + length; if (capacity >= requiredCapacity) { + data = currentData; return; } // Instantiate a new buffer if possible. ByteBuffer newData = createReplacementByteBuffer(requiredCapacity); - newData.order(data.order()); + newData.order(currentData.order()); // Copy data up to the current position from the old buffer to the new one. if (position > 0) { - data.flip(); - newData.put(data); + currentData.flip(); + newData.put(currentData); } // Set the new buffer. data = newData; @@ -176,7 +196,9 @@ public class DecoderInputBuffer extends Buffer { * @see java.nio.Buffer#flip() */ public final void flip() { - data.flip(); + if (data != null) { + data.flip(); + } if (supplementalData != null) { supplementalData.flip(); } @@ -205,5 +227,4 @@ public class DecoderInputBuffer extends Buffer { + requiredCapacity + ")"); } } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java b/library/common/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java similarity index 94% rename from library/core/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java rename to library/common/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java index 43c37028ea..8d662c318e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/device/DeviceInfo.java @@ -40,6 +40,10 @@ public final class DeviceInfo { /** Playback happens outside of the device (e.g. a cast device). */ public static final int PLAYBACK_TYPE_REMOTE = 1; + /** Unknown DeviceInfo. */ + public static final DeviceInfo UNKNOWN = + new DeviceInfo(PLAYBACK_TYPE_LOCAL, /* minVolume= */ 0, /* maxVolume= */ 0); + /** The type of playback. */ public final @PlaybackType int playbackType; /** The minimum volume that the device supports. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/device/package-info.java b/library/common/src/main/java/com/google/android/exoplayer2/device/package-info.java similarity index 100% rename from library/core/src/main/java/com/google/android/exoplayer2/device/package-info.java rename to library/common/src/main/java/com/google/android/exoplayer2/device/package-info.java diff --git a/library/common/src/main/java/com/google/android/exoplayer2/drm/UnsupportedMediaCrypto.java b/library/common/src/main/java/com/google/android/exoplayer2/drm/UnsupportedMediaCrypto.java new file mode 100644 index 0000000000..e8e6d6074b --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/drm/UnsupportedMediaCrypto.java @@ -0,0 +1,19 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.drm; + +/** {@link ExoMediaCrypto} type that cannot be used to handle any type of protected content. */ +public final class UnsupportedMediaCrypto implements ExoMediaCrypto {} diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java index 046c1fef55..21dacd4f9b 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/Metadata.java @@ -63,8 +63,7 @@ public final class Metadata implements Parcelable { * @param entries The metadata entries. */ public Metadata(List entries) { - this.entries = new Entry[entries.size()]; - entries.toArray(this.entries); + this.entries = entries.toArray(new Entry[0]); } /* package */ Metadata(Parcel in) { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java index dee0db5a8e..46501ce002 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/MetadataDecoder.java @@ -31,7 +31,8 @@ public interface MetadataDecoder { * ByteBuffer#hasArray()} is true. * * @param inputBuffer The input buffer to decode. - * @return The decoded metadata object, or null if the metadata could not be decoded. + * @return The decoded metadata object, or {@code null} if the metadata could not be decoded or if + * {@link MetadataInputBuffer#isDecodeOnly()} was set on the input buffer. */ @Nullable Metadata decode(MetadataInputBuffer inputBuffer); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/SimpleMetadataDecoder.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/SimpleMetadataDecoder.java new file mode 100644 index 0000000000..cf3954b7c5 --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/SimpleMetadataDecoder.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Assertions; +import java.nio.ByteBuffer; + +/** + * A {@link MetadataDecoder} base class that validates input buffers and discards any for which + * {@link MetadataInputBuffer#isDecodeOnly()} is {@code true}. + */ +public abstract class SimpleMetadataDecoder implements MetadataDecoder { + + @Override + @Nullable + public final Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); + Assertions.checkArgument( + buffer.position() == 0 && buffer.hasArray() && buffer.arrayOffset() == 0); + return inputBuffer.isDecodeOnly() ? null : decode(inputBuffer, buffer); + } + + /** + * Called by {@link #decode(MetadataInputBuffer)} after input buffer validation has been + * performed, except in the case that {@link MetadataInputBuffer#isDecodeOnly()} is {@code true}. + * + * @param inputBuffer The input buffer to decode. + * @param buffer The input buffer's {@link MetadataInputBuffer#data data buffer}, for convenience. + * Validation by {@link #decode} guarantees that {@link ByteBuffer#hasArray()}, {@link + * ByteBuffer#position()} and {@link ByteBuffer#arrayOffset()} are {@code true}, {@code 0} and + * {@code 0} respectively. + * @return The decoded metadata object, or {@code null} if the metadata could not be decoded. + */ + @Nullable + protected abstract Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer); +} diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java index c03a5cb038..8a7e1851c6 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java @@ -16,21 +16,19 @@ package com.google.android.exoplayer2.metadata.emsg; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import com.google.android.exoplayer2.metadata.SimpleMetadataDecoder; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import java.nio.ByteBuffer; import java.util.Arrays; /** Decodes data encoded by {@link EventMessageEncoder}. */ -public final class EventMessageDecoder implements MetadataDecoder { +public final class EventMessageDecoder extends SimpleMetadataDecoder { @Override - public Metadata decode(MetadataInputBuffer inputBuffer) { - ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); - Assertions.checkArgument( - buffer.position() == 0 && buffer.hasArray() && buffer.arrayOffset() == 0); + @SuppressWarnings("ByteBufferBackingArray") // Buffer validated by SimpleMetadataDecoder.decode + protected Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer) { return new Metadata(decode(new ParsableByteArray(buffer.array(), buffer.limit()))); } @@ -40,7 +38,7 @@ public final class EventMessageDecoder implements MetadataDecoder { long durationMs = emsgData.readUnsignedInt(); long id = emsgData.readUnsignedInt(); byte[] messageData = - Arrays.copyOfRange(emsgData.data, emsgData.getPosition(), emsgData.limit()); + Arrays.copyOfRange(emsgData.getData(), emsgData.getPosition(), emsgData.limit()); return new EventMessage(schemeIdUri, value, durationMs, id, messageData); } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/library/common/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java index 84a316e848..f660e21bfd 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -18,9 +18,8 @@ package com.google.android.exoplayer2.metadata.id3; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.metadata.SimpleMetadataDecoder; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -32,10 +31,8 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; -/** - * Decodes ID3 tags. - */ -public final class Id3Decoder implements MetadataDecoder { +/** Decodes ID3 tags. */ +public final class Id3Decoder extends SimpleMetadataDecoder { /** * A predicate for determining whether individual frames should be decoded. @@ -98,10 +95,8 @@ public final class Id3Decoder implements MetadataDecoder { @Override @Nullable - public Metadata decode(MetadataInputBuffer inputBuffer) { - ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); - Assertions.checkArgument( - buffer.position() == 0 && buffer.hasArray() && buffer.arrayOffset() == 0); + @SuppressWarnings("ByteBufferBackingArray") // Buffer validated by SimpleMetadataDecoder.decode + protected Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer) { return decode(buffer.array(), buffer.limit()); } @@ -118,7 +113,7 @@ public final class Id3Decoder implements MetadataDecoder { List id3Frames = new ArrayList<>(); ParsableByteArray id3Data = new ParsableByteArray(data, size); - Id3Header id3Header = decodeHeader(id3Data); + @Nullable Id3Header id3Header = decodeHeader(id3Data); if (id3Header == null) { return null; } @@ -142,8 +137,14 @@ public final class Id3Decoder implements MetadataDecoder { } while (id3Data.bytesLeft() >= frameHeaderSize) { - Id3Frame frame = decodeFrame(id3Header.majorVersion, id3Data, unsignedIntFrameSizeHack, - frameHeaderSize, framePredicate); + @Nullable + Id3Frame frame = + decodeFrame( + id3Header.majorVersion, + id3Data, + unsignedIntFrameSizeHack, + frameHeaderSize, + framePredicate); if (frame != null) { id3Frames.add(frame); } @@ -600,9 +601,10 @@ public final class Id3Decoder implements MetadataDecoder { @Nullable FramePredicate framePredicate) throws UnsupportedEncodingException { int framePosition = id3Data.getPosition(); - int chapterIdEndIndex = indexOfZeroByte(id3Data.data, framePosition); - String chapterId = new String(id3Data.data, framePosition, chapterIdEndIndex - framePosition, - "ISO-8859-1"); + int chapterIdEndIndex = indexOfZeroByte(id3Data.getData(), framePosition); + String chapterId = + new String( + id3Data.getData(), framePosition, chapterIdEndIndex - framePosition, "ISO-8859-1"); id3Data.setPosition(chapterIdEndIndex + 1); int startTime = id3Data.readInt(); @@ -626,8 +628,7 @@ public final class Id3Decoder implements MetadataDecoder { } } - Id3Frame[] subFrameArray = new Id3Frame[subFrames.size()]; - subFrames.toArray(subFrameArray); + Id3Frame[] subFrameArray = subFrames.toArray(new Id3Frame[0]); return new ChapterFrame(chapterId, startTime, endTime, startOffset, endOffset, subFrameArray); } @@ -640,9 +641,10 @@ public final class Id3Decoder implements MetadataDecoder { @Nullable FramePredicate framePredicate) throws UnsupportedEncodingException { int framePosition = id3Data.getPosition(); - int elementIdEndIndex = indexOfZeroByte(id3Data.data, framePosition); - String elementId = new String(id3Data.data, framePosition, elementIdEndIndex - framePosition, - "ISO-8859-1"); + int elementIdEndIndex = indexOfZeroByte(id3Data.getData(), framePosition); + String elementId = + new String( + id3Data.getData(), framePosition, elementIdEndIndex - framePosition, "ISO-8859-1"); id3Data.setPosition(elementIdEndIndex + 1); int ctocFlags = id3Data.readUnsignedByte(); @@ -653,23 +655,24 @@ public final class Id3Decoder implements MetadataDecoder { String[] children = new String[childCount]; for (int i = 0; i < childCount; i++) { int startIndex = id3Data.getPosition(); - int endIndex = indexOfZeroByte(id3Data.data, startIndex); - children[i] = new String(id3Data.data, startIndex, endIndex - startIndex, "ISO-8859-1"); + int endIndex = indexOfZeroByte(id3Data.getData(), startIndex); + children[i] = new String(id3Data.getData(), startIndex, endIndex - startIndex, "ISO-8859-1"); id3Data.setPosition(endIndex + 1); } ArrayList subFrames = new ArrayList<>(); int limit = framePosition + frameSize; while (id3Data.getPosition() < limit) { - Id3Frame frame = decodeFrame(majorVersion, id3Data, unsignedIntFrameSizeHack, - frameHeaderSize, framePredicate); + @Nullable + Id3Frame frame = + decodeFrame( + majorVersion, id3Data, unsignedIntFrameSizeHack, frameHeaderSize, framePredicate); if (frame != null) { subFrames.add(frame); } } - Id3Frame[] subFrameArray = new Id3Frame[subFrames.size()]; - subFrames.toArray(subFrameArray); + Id3Frame[] subFrameArray = subFrames.toArray(new Id3Frame[0]); return new ChapterTocFrame(elementId, isRoot, isOrdered, children, subFrameArray); } @@ -720,7 +723,7 @@ public final class Id3Decoder implements MetadataDecoder { * @return The length of the data after processing. */ private static int removeUnsynchronization(ParsableByteArray data, int length) { - byte[] bytes = data.data; + byte[] bytes = data.getData(); int startPosition = data.getPosition(); for (int i = startPosition; i + 1 < startPosition + length; i++) { if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) { diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java index 9a321fbdd8..bbc182d7af 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSource.java @@ -74,6 +74,8 @@ public interface DataSource extends DataReader { /** * When the source is open, returns the response headers associated with the last {@link #open} * call. Otherwise, returns an empty map. + * + *

Key look-up in the returned map is case-insensitive. */ default Map> getResponseHeaders() { return Collections.emptyMap(); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSourceException.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSourceException.java index e6b3ae2707..a45b7db2f2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSourceException.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSourceException.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream; +import androidx.annotation.Nullable; import java.io.IOException; /** @@ -22,6 +23,24 @@ import java.io.IOException; */ public final class DataSourceException extends IOException { + /** + * Returns whether the given {@link IOException} was caused by a {@link DataSourceException} whose + * {@link #reason} is {@link #POSITION_OUT_OF_RANGE} in its cause stack. + */ + public static boolean isCausedByPositionOutOfRange(IOException e) { + @Nullable Throwable cause = e; + while (cause != null) { + if (cause instanceof DataSourceException) { + int reason = ((DataSourceException) cause).reason; + if (reason == DataSourceException.POSITION_OUT_OF_RANGE) { + return true; + } + } + cause = cause.getCause(); + } + return false; + } + public static final int POSITION_OUT_OF_RANGE = 0; /** diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java index cdbf3fee7d..75e23ae6f2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/DataSpec.java @@ -27,9 +27,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; -/** - * Defines a region of data. - */ +/** Defines a region of data in a resource. */ public final class DataSpec { /** @@ -298,22 +296,21 @@ public final class DataSpec { } } - /** The {@link Uri} from which data should be read. */ + /** A {@link Uri} from which data belonging to the resource can be read. */ public final Uri uri; /** - * The offset of the data located at {@link #uri} within an original resource. + * The offset of the data located at {@link #uri} within the resource. * - *

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

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

This field can be ignored except for in specific circumstances where the absolute position - * in the original resource is required in a {@link DataSource} chain. One example is when a - * {@link DataSource} needs to decrypt the content as it's read. In this case the absolute - * position in the original resource is typically needed to correctly initialize the decryption - * algorithm. + * in the resource is required in a {@link DataSource} chain. One example is when a {@link + * DataSource} needs to decrypt the content as it's read. In this case the absolute position in + * the resource is typically needed to correctly initialize the decryption algorithm. */ public final long uriPositionOffset; @@ -353,11 +350,11 @@ public final class DataSpec { public final Map httpRequestHeaders; /** - * The absolute position of the data in the full stream. + * The absolute position of the data in the resource. * * @deprecated Use {@link #position} except for specific use cases where the absolute position - * within the original resource is required within a {@link DataSource} chain. Where the - * absolute position is required, use {@code uriPositionOffset + position}. + * within the resource is required within a {@link DataSource} chain. Where the absolute + * position is required, use {@code uriPositionOffset + position}. */ @Deprecated public final long absoluteStreamPosition; @@ -370,8 +367,8 @@ public final class DataSpec { public final long length; /** - * A key that uniquely identifies the original stream. Used for cache indexing. May be null if the - * data spec is not intended to be used in conjunction with a cache. + * A key that uniquely identifies the resource. Used for cache indexing. May be null if the data + * spec is not intended to be used in conjunction with a cache. */ @Nullable public final String key; @@ -521,7 +518,8 @@ public final class DataSpec { * set to {@link #HTTP_METHOD_POST}. If {@code postBody} is null then {@link #httpMethod} is set * to {@link #HTTP_METHOD_GET}. * - * @deprecated Use {@link Builder}. + * @deprecated Use {@link Builder}. Note that the httpMethod must be set explicitly for the + * Builder. * @param uri {@link #uri}. * @param postBody {@link #httpBody} The body of the HTTP request, which is also used to infer the * {@link #httpMethod}. diff --git a/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java b/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java index 9d4f9b6811..d2170b9eab 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/upstream/HttpDataSource.java @@ -18,8 +18,8 @@ package com.google.android.exoplayer2.upstream; import android.text.TextUtils; import androidx.annotation.IntDef; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.util.Predicate; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -306,22 +306,51 @@ public interface HttpDataSource extends DataSource { */ public final Map> headerFields; - /** @deprecated Use {@link #InvalidResponseCodeException(int, String, Map, DataSpec)}. */ + /** The response body. */ + public final byte[] responseBody; + + /** + * @deprecated Use {@link #InvalidResponseCodeException(int, String, Map, DataSpec, byte[])}. + */ @Deprecated public InvalidResponseCodeException( int responseCode, Map> headerFields, DataSpec dataSpec) { - this(responseCode, /* responseMessage= */ null, headerFields, dataSpec); + this( + responseCode, + /* responseMessage= */ null, + headerFields, + dataSpec, + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + } + + /** + * @deprecated Use {@link #InvalidResponseCodeException(int, String, Map, DataSpec, byte[])}. + */ + @Deprecated + public InvalidResponseCodeException( + int responseCode, + @Nullable String responseMessage, + Map> headerFields, + DataSpec dataSpec) { + this( + responseCode, + responseMessage, + headerFields, + dataSpec, + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); } public InvalidResponseCodeException( int responseCode, @Nullable String responseMessage, Map> headerFields, - DataSpec dataSpec) { + DataSpec dataSpec, + byte[] responseBody) { super("Response code: " + responseCode, dataSpec, TYPE_OPEN); this.responseCode = responseCode; this.responseMessage = responseMessage; this.headerFields = headerFields; + this.responseBody = responseBody; } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Predicate.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Consumer.java similarity index 64% rename from library/common/src/main/java/com/google/android/exoplayer2/util/Predicate.java rename to library/common/src/main/java/com/google/android/exoplayer2/util/Consumer.java index b582cf3f7c..8e982fc646 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Predicate.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Consumer.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 The Android Open Source Project + * 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. @@ -16,18 +16,11 @@ package com.google.android.exoplayer2.util; /** - * Determines a true or false value for a given input. - * - * @param The input type of the predicate. + * Represents an operation that accepts a single input argument and returns no result. Unlike most + * other functional interfaces, Consumer is expected to operate via side-effects. */ -public interface Predicate { - - /** - * Evaluates an input. - * - * @param input The input to evaluate. - * @return The evaluated result. - */ - boolean evaluate(T input); +public interface Consumer { + /** Performs this operation on the given argument. */ + void accept(T t); } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java b/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java index e8eb0d0df9..505ff55cbe 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/CopyOnWriteMultiset.java @@ -41,7 +41,9 @@ import java.util.Set; * * @param The type of element being stored. */ -public final class CopyOnWriteMultiset implements Iterable { +// Intentionally extending @NonNull-by-default Object to disallow @Nullable E types. +@SuppressWarnings("TypeParameterExplicitlyExtendsObject") +public final class CopyOnWriteMultiset implements Iterable { private final Object lock; diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java new file mode 100644 index 0000000000..d4b87abfdd --- /dev/null +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java @@ -0,0 +1,226 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.android.exoplayer2.util.MimeTypes.normalizeMimeType; + +import android.net.Uri; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Map; + +/** Defines common file type constants and helper methods. */ +public final class FileTypes { + + /** + * File types. One of {@link #UNKNOWN}, {@link #AC3}, {@link #AC4}, {@link #ADTS}, {@link #AMR}, + * {@link #FLAC}, {@link #FLV}, {@link #MATROSKA}, {@link #MP3}, {@link #MP4}, {@link #OGG}, + * {@link #PS}, {@link #TS}, {@link #WAV} and {@link #WEBVTT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({UNKNOWN, AC3, AC4, ADTS, AMR, FLAC, FLV, MATROSKA, MP3, MP4, OGG, PS, TS, WAV, WEBVTT}) + public @interface Type {} + /** Unknown file type. */ + public static final int UNKNOWN = -1; + /** File type for the AC-3 and E-AC-3 formats. */ + public static final int AC3 = 0; + /** File type for the AC-4 format. */ + public static final int AC4 = 1; + /** File type for the ADTS format. */ + public static final int ADTS = 2; + /** File type for the AMR format. */ + public static final int AMR = 3; + /** File type for the FLAC format. */ + public static final int FLAC = 4; + /** File type for the FLV format. */ + public static final int FLV = 5; + /** File type for the Matroska and WebM formats. */ + public static final int MATROSKA = 6; + /** File type for the MP3 format. */ + public static final int MP3 = 7; + /** File type for the MP4 format. */ + public static final int MP4 = 8; + /** File type for the Ogg format. */ + public static final int OGG = 9; + /** File type for the MPEG-PS format. */ + public static final int PS = 10; + /** File type for the MPEG-TS format. */ + public static final int TS = 11; + /** File type for the WAV format. */ + public static final int WAV = 12; + /** File type for the WebVTT format. */ + public static final int WEBVTT = 13; + + @VisibleForTesting /* package */ static final String HEADER_CONTENT_TYPE = "Content-Type"; + + private static final String EXTENSION_AC3 = ".ac3"; + private static final String EXTENSION_EC3 = ".ec3"; + private static final String EXTENSION_AC4 = ".ac4"; + private static final String EXTENSION_ADTS = ".adts"; + private static final String EXTENSION_AAC = ".aac"; + private static final String EXTENSION_AMR = ".amr"; + private static final String EXTENSION_FLAC = ".flac"; + private static final String EXTENSION_FLV = ".flv"; + private static final String EXTENSION_PREFIX_MK = ".mk"; + private static final String EXTENSION_WEBM = ".webm"; + private static final String EXTENSION_PREFIX_OG = ".og"; + private static final String EXTENSION_OPUS = ".opus"; + private static final String EXTENSION_MP3 = ".mp3"; + private static final String EXTENSION_MP4 = ".mp4"; + private static final String EXTENSION_PREFIX_M4 = ".m4"; + private static final String EXTENSION_PREFIX_MP4 = ".mp4"; + private static final String EXTENSION_PREFIX_CMF = ".cmf"; + private static final String EXTENSION_PS = ".ps"; + private static final String EXTENSION_MPEG = ".mpeg"; + private static final String EXTENSION_MPG = ".mpg"; + private static final String EXTENSION_M2P = ".m2p"; + private static final String EXTENSION_TS = ".ts"; + private static final String EXTENSION_PREFIX_TS = ".ts"; + private static final String EXTENSION_WAV = ".wav"; + private static final String EXTENSION_WAVE = ".wave"; + private static final String EXTENSION_VTT = ".vtt"; + private static final String EXTENSION_WEBVTT = ".webvtt"; + + private FileTypes() {} + + /** Returns the {@link Type} corresponding to the response headers provided. */ + @FileTypes.Type + public static int inferFileTypeFromResponseHeaders(Map> responseHeaders) { + @Nullable List contentTypes = responseHeaders.get(HEADER_CONTENT_TYPE); + @Nullable + String mimeType = contentTypes == null || contentTypes.isEmpty() ? null : contentTypes.get(0); + return inferFileTypeFromMimeType(mimeType); + } + + /** + * Returns the {@link Type} corresponding to the MIME type provided. + * + *

Returns {@link #UNKNOWN} if the mime type is {@code null}. + */ + @FileTypes.Type + public static int inferFileTypeFromMimeType(@Nullable String mimeType) { + if (mimeType == null) { + return FileTypes.UNKNOWN; + } + mimeType = normalizeMimeType(mimeType); + switch (mimeType) { + case MimeTypes.AUDIO_AC3: + case MimeTypes.AUDIO_E_AC3: + case MimeTypes.AUDIO_E_AC3_JOC: + return FileTypes.AC3; + case MimeTypes.AUDIO_AC4: + return FileTypes.AC4; + case MimeTypes.AUDIO_AMR: + case MimeTypes.AUDIO_AMR_NB: + case MimeTypes.AUDIO_AMR_WB: + return FileTypes.AMR; + case MimeTypes.AUDIO_FLAC: + return FileTypes.FLAC; + case MimeTypes.VIDEO_FLV: + return FileTypes.FLV; + case MimeTypes.VIDEO_MATROSKA: + case MimeTypes.AUDIO_MATROSKA: + case MimeTypes.VIDEO_WEBM: + case MimeTypes.AUDIO_WEBM: + case MimeTypes.APPLICATION_WEBM: + return FileTypes.MATROSKA; + case MimeTypes.AUDIO_MPEG: + return FileTypes.MP3; + case MimeTypes.VIDEO_MP4: + case MimeTypes.AUDIO_MP4: + case MimeTypes.APPLICATION_MP4: + return FileTypes.MP4; + case MimeTypes.AUDIO_OGG: + return FileTypes.OGG; + case MimeTypes.VIDEO_PS: + return FileTypes.PS; + case MimeTypes.VIDEO_MP2T: + return FileTypes.TS; + case MimeTypes.AUDIO_WAV: + return FileTypes.WAV; + case MimeTypes.TEXT_VTT: + return FileTypes.WEBVTT; + default: + return FileTypes.UNKNOWN; + } + } + + /** Returns the {@link Type} corresponding to the {@link Uri} provided. */ + @FileTypes.Type + public static int inferFileTypeFromUri(Uri uri) { + @Nullable String filename = uri.getLastPathSegment(); + if (filename == null) { + return FileTypes.UNKNOWN; + } else if (filename.endsWith(EXTENSION_AC3) || filename.endsWith(EXTENSION_EC3)) { + return FileTypes.AC3; + } else if (filename.endsWith(EXTENSION_AC4)) { + return FileTypes.AC4; + } else if (filename.endsWith(EXTENSION_ADTS) || filename.endsWith(EXTENSION_AAC)) { + return FileTypes.ADTS; + } else if (filename.endsWith(EXTENSION_AMR)) { + return FileTypes.AMR; + } else if (filename.endsWith(EXTENSION_FLAC)) { + return FileTypes.FLAC; + } else if (filename.endsWith(EXTENSION_FLV)) { + return FileTypes.FLV; + } else if (filename.startsWith( + EXTENSION_PREFIX_MK, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_MK.length() + 1)) + || filename.endsWith(EXTENSION_WEBM)) { + return FileTypes.MATROSKA; + } else if (filename.endsWith(EXTENSION_MP3)) { + return FileTypes.MP3; + } else if (filename.endsWith(EXTENSION_MP4) + || filename.startsWith( + EXTENSION_PREFIX_M4, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_M4.length() + 1)) + || filename.startsWith( + EXTENSION_PREFIX_MP4, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_MP4.length() + 1)) + || filename.startsWith( + EXTENSION_PREFIX_CMF, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_CMF.length() + 1))) { + return FileTypes.MP4; + } else if (filename.startsWith( + EXTENSION_PREFIX_OG, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_OG.length() + 1)) + || filename.endsWith(EXTENSION_OPUS)) { + return FileTypes.OGG; + } else if (filename.endsWith(EXTENSION_PS) + || filename.endsWith(EXTENSION_MPEG) + || filename.endsWith(EXTENSION_MPG) + || filename.endsWith(EXTENSION_M2P)) { + return FileTypes.PS; + } else if (filename.endsWith(EXTENSION_TS) + || filename.startsWith( + EXTENSION_PREFIX_TS, + /* toffset= */ filename.length() - (EXTENSION_PREFIX_TS.length() + 1))) { + return FileTypes.TS; + } else if (filename.endsWith(EXTENSION_WAV) || filename.endsWith(EXTENSION_WAVE)) { + return FileTypes.WAV; + } else if (filename.endsWith(EXTENSION_VTT) || filename.endsWith(EXTENSION_WEBVTT)) { + return FileTypes.WEBVTT; + } else { + return FileTypes.UNKNOWN; + } + } +} diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index e2055a24f0..6d5f167047 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.util; import android.text.TextUtils; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.audio.AacUtil; import java.util.ArrayList; @@ -28,25 +29,13 @@ import java.util.regex.Pattern; */ public final class MimeTypes { - /** An mp4a Object Type Indication (OTI) and its optional audio OTI is defined by RFC 6381. */ - public static final class Mp4aObjectType { - /** The Object Type Indication of the mp4a codec. */ - public final int objectTypeIndication; - /** The Audio Object Type Indication of the mp4a codec, or 0 if it is absent. */ - @AacUtil.AacAudioObjectType public final int audioObjectTypeIndication; - - private Mp4aObjectType(int objectTypeIndication, int audioObjectTypeIndication) { - this.objectTypeIndication = objectTypeIndication; - this.audioObjectTypeIndication = audioObjectTypeIndication; - } - } - public static final String BASE_TYPE_VIDEO = "video"; public static final String BASE_TYPE_AUDIO = "audio"; public static final String BASE_TYPE_TEXT = "text"; public static final String BASE_TYPE_APPLICATION = "application"; public static final String VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4"; + public static final String VIDEO_MATROSKA = BASE_TYPE_VIDEO + "/x-matroska"; public static final String VIDEO_WEBM = BASE_TYPE_VIDEO + "/webm"; public static final String VIDEO_H263 = BASE_TYPE_VIDEO + "/3gpp"; public static final String VIDEO_H264 = BASE_TYPE_VIDEO + "/avc"; @@ -63,10 +52,12 @@ public final class MimeTypes { public static final String VIDEO_DIVX = BASE_TYPE_VIDEO + "/divx"; public static final String VIDEO_FLV = BASE_TYPE_VIDEO + "/x-flv"; public static final String VIDEO_DOLBY_VISION = BASE_TYPE_VIDEO + "/dolby-vision"; + public static final String VIDEO_OGG = BASE_TYPE_VIDEO + "/ogg"; public static final String VIDEO_UNKNOWN = BASE_TYPE_VIDEO + "/x-unknown"; public static final String AUDIO_MP4 = BASE_TYPE_AUDIO + "/mp4"; public static final String AUDIO_AAC = BASE_TYPE_AUDIO + "/mp4a-latm"; + public static final String AUDIO_MATROSKA = BASE_TYPE_AUDIO + "/x-matroska"; public static final String AUDIO_WEBM = BASE_TYPE_AUDIO + "/webm"; public static final String AUDIO_MPEG = BASE_TYPE_AUDIO + "/mpeg"; public static final String AUDIO_MPEG_L1 = BASE_TYPE_AUDIO + "/mpeg-L1"; @@ -91,6 +82,7 @@ public final class MimeTypes { public static final String AUDIO_ALAC = BASE_TYPE_AUDIO + "/alac"; public static final String AUDIO_MSGSM = BASE_TYPE_AUDIO + "/gsm"; public static final String AUDIO_OGG = BASE_TYPE_AUDIO + "/ogg"; + public static final String AUDIO_WAV = BASE_TYPE_AUDIO + "/wav"; public static final String AUDIO_UNKNOWN = BASE_TYPE_AUDIO + "/x-unknown"; public static final String TEXT_VTT = BASE_TYPE_TEXT + "/vtt"; @@ -98,6 +90,7 @@ public final class MimeTypes { public static final String APPLICATION_MP4 = BASE_TYPE_APPLICATION + "/mp4"; public static final String APPLICATION_WEBM = BASE_TYPE_APPLICATION + "/webm"; + public static final String APPLICATION_MATROSKA = BASE_TYPE_APPLICATION + "/x-matroska"; public static final String APPLICATION_MPD = BASE_TYPE_APPLICATION + "/dash+xml"; public static final String APPLICATION_M3U8 = BASE_TYPE_APPLICATION + "/x-mpegURL"; public static final String APPLICATION_SS = BASE_TYPE_APPLICATION + "/vnd.ms-sstr+xml"; @@ -131,7 +124,7 @@ public final class MimeTypes { * via this method. If this method is used, it must be called before creating any player(s). * * @param mimeType The custom MIME type to register. - * @param codecPrefix The RFC 6381-style codec string prefix associated with the MIME type. + * @param codecPrefix The RFC 6381 codec string prefix associated with the MIME type. * @param trackType The {@link C}{@code .TRACK_TYPE_*} constant associated with the MIME type. * This value is ignored if the top-level type of {@code mimeType} is audio, video or text. */ @@ -177,35 +170,59 @@ public final class MimeTypes { } /** - * Returns true if it is known that all samples in a stream of the given sample MIME type are + * Returns true if it is known that all samples in a stream of the given MIME type and codec are * guaranteed to be sync samples (i.e., {@link C#BUFFER_FLAG_KEY_FRAME} is guaranteed to be set on * every sample). * - * @param mimeType The sample MIME type. - * @return True if it is known that all samples in a stream of the given sample MIME type are - * guaranteed to be sync samples. False otherwise, including if {@code null} is passed. + * @param mimeType The MIME type of the stream. + * @param codec The RFC 6381 codec string of the stream, or {@code null} if unknown. + * @return Whether it is known that all samples in the stream are guaranteed to be sync samples. */ - public static boolean allSamplesAreSyncSamples(@Nullable String mimeType) { + public static boolean allSamplesAreSyncSamples( + @Nullable String mimeType, @Nullable String codec) { if (mimeType == null) { return false; } - // TODO: Consider adding additional audio MIME types here. + // TODO: Add additional audio MIME types. Also consider evaluating based on Format rather than + // just MIME type, since in some cases the property is true for a subset of the profiles + // belonging to a single MIME type. If we do this, we should move the method to a different + // class. See [Internal ref: http://go/exo-audio-format-random-access]. switch (mimeType) { - case AUDIO_AAC: case AUDIO_MPEG: case AUDIO_MPEG_L1: case AUDIO_MPEG_L2: + case AUDIO_RAW: + case AUDIO_ALAW: + case AUDIO_MLAW: + case AUDIO_FLAC: + case AUDIO_AC3: + case AUDIO_E_AC3: + case AUDIO_E_AC3_JOC: return true; + case AUDIO_AAC: + if (codec == null) { + return false; + } + @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codec); + if (objectType == null) { + return false; + } + @C.Encoding + int encoding = AacUtil.getEncodingForAudioObjectType(objectType.audioObjectTypeIndication); + // xHE-AAC is an exception in which it's not true that all samples will be sync samples. + // Also return false for ENCODING_INVALID, which indicates we weren't able to parse the + // encoding from the codec string. + return encoding != C.ENCODING_INVALID && encoding != C.ENCODING_AAC_XHE; default: return false; } } /** - * Derives a video sample mimeType from a codecs attribute. + * Returns the first video MIME type derived from an RFC 6381 codecs string. * - * @param codecs The codecs attribute. - * @return The derived video mimeType, or null if it could not be derived. + * @param codecs An RFC 6381 codecs string. + * @return The first derived video MIME type, or {@code null}. */ @Nullable public static String getVideoMediaMimeType(@Nullable String codecs) { @@ -223,10 +240,10 @@ public final class MimeTypes { } /** - * Derives a audio sample mimeType from a codecs attribute. + * Returns the first audio MIME type derived from an RFC 6381 codecs string. * - * @param codecs The codecs attribute. - * @return The derived audio mimeType, or null if it could not be derived. + * @param codecs An RFC 6381 codecs string. + * @return The first derived audio MIME type, or {@code null}. */ @Nullable public static String getAudioMediaMimeType(@Nullable String codecs) { @@ -244,10 +261,10 @@ public final class MimeTypes { } /** - * Derives a text sample mimeType from a codecs attribute. + * Returns the first text MIME type derived from an RFC 6381 codecs string. * - * @param codecs The codecs attribute. - * @return The derived text mimeType, or null if it could not be derived. + * @param codecs An RFC 6381 codecs string. + * @return The first derived text MIME type, or {@code null}. */ @Nullable public static String getTextMediaMimeType(@Nullable String codecs) { @@ -265,10 +282,11 @@ public final class MimeTypes { } /** - * Derives a mimeType from a codec identifier, as defined in RFC 6381. + * Returns the MIME type corresponding to an RFC 6381 codec string, or {@code null} if it could + * not be determined. * - * @param codec The codec identifier to derive. - * @return The mimeType, or null if it could not be derived. + * @param codec An RFC 6381 codec string. + * @return The corresponding MIME type, or {@code null} if it could not be determined. */ @Nullable public static String getMediaMimeType(@Nullable String codec) { @@ -332,11 +350,11 @@ public final class MimeTypes { } /** - * Derives a mimeType from MP4 object type identifier, as defined in RFC 6381 and - * https://mp4ra.org/#/object_types. + * Returns the MIME type corresponding to an MP4 object type identifier, as defined in RFC 6381 + * and https://mp4ra.org/#/object_types. * - * @param objectType The objectType identifier to derive. - * @return The mimeType, or null if it could not be derived. + * @param objectType An MP4 object type identifier. + * @return The corresponding MIME type, or {@code null} if it could not be determined. */ @Nullable public static String getMimeTypeFromMp4ObjectType(int objectType) { @@ -388,12 +406,12 @@ public final class MimeTypes { } /** - * Returns the {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified MIME type. - * {@link C#TRACK_TYPE_UNKNOWN} if the MIME type is not known or the mapping cannot be - * established. + * Returns the {@link C}{@code .TRACK_TYPE_*} constant corresponding to a specified MIME type, or + * {@link C#TRACK_TYPE_UNKNOWN} if it could not be determined. * - * @param mimeType The MIME type. - * @return The {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified MIME type. + * @param mimeType A MIME type. + * @return The corresponding {@link C}{@code .TRACK_TYPE_*}, or {@link C#TRACK_TYPE_UNKNOWN} if it + * could not be determined. */ public static int getTrackType(@Nullable String mimeType) { if (TextUtils.isEmpty(mimeType)) { @@ -416,25 +434,24 @@ public final class MimeTypes { } /** - * Returns the {@link C}{@code .ENCODING_*} constant that corresponds to specified MIME type, if - * it is an encoded (non-PCM) audio format, or {@link C#ENCODING_INVALID} otherwise. + * Returns the {@link C.Encoding} constant corresponding to the specified audio MIME type and RFC + * 6381 codec string, or {@link C#ENCODING_INVALID} if the corresponding {@link C.Encoding} cannot + * be determined. * - * @param mimeType The MIME type. - * @param codecs Codecs of the format as described in RFC 6381, or null if unknown or not - * applicable. - * @return One of {@link C.Encoding} constants that corresponds to a specified MIME type, or - * {@link C#ENCODING_INVALID}. + * @param mimeType A MIME type. + * @param codec An RFC 6381 codec string, or {@code null} if unknown or not applicable. + * @return The corresponding {@link C.Encoding}, or {@link C#ENCODING_INVALID}. */ @C.Encoding - public static int getEncoding(String mimeType, @Nullable String codecs) { + public static int getEncoding(String mimeType, @Nullable String codec) { switch (mimeType) { case MimeTypes.AUDIO_MPEG: return C.ENCODING_MP3; case MimeTypes.AUDIO_AAC: - if (codecs == null) { + if (codec == null) { return C.ENCODING_INVALID; } - @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codecs); + @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codec); if (objectType == null) { return C.ENCODING_INVALID; } @@ -461,45 +478,44 @@ public final class MimeTypes { /** * Equivalent to {@code getTrackType(getMediaMimeType(codec))}. * - * @param codec The codec. - * @return The {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified codec. + * @param codec An RFC 6381 codec string. + * @return The corresponding {@link C}{@code .TRACK_TYPE_*}, or {@link C#TRACK_TYPE_UNKNOWN} if it + * could not be determined. */ public static int getTrackTypeOfCodec(String codec) { return getTrackType(getMediaMimeType(codec)); } /** - * Retrieves the object type of an mp4 audio codec from its string as defined in RFC 6381. + * Normalizes the MIME type provided so that equivalent MIME types are uniquely represented. * - *

Per https://mp4ra.org/#/object_types and https://tools.ietf.org/html/rfc6381#section-3.3, an - * mp4 codec string has the form: - * ~~~~~~~~~~~~~~ Object Type Indication (OTI) byte in hex - * mp4a.[a-zA-Z0-9]{2}(.[0-9]{1,2})? - * ~~~~~~~~~~ audio OTI, decimal. Only for certain OTI. - * For example: mp4a.40.2, has an OTI of 0x40 and an audio OTI of 2. - * - * @param codec The string as defined in RFC 6381 describing an mp4 audio codec. - * @return The {@link Mp4aObjectType} or {@code null} if the input is invalid. + * @param mimeType A MIME type to normalize. + * @return The normalized MIME type, or the argument MIME type if its normalized form is unknown. */ - @Nullable - public static Mp4aObjectType getObjectTypeFromMp4aRFC6381CodecString(String codec) { - Matcher matcher = MP4A_RFC_6381_CODEC_PATTERN.matcher(codec); - if (!matcher.matches()) { - return null; + public static String normalizeMimeType(String mimeType) { + switch (mimeType) { + case BASE_TYPE_AUDIO + "/x-flac": + return AUDIO_FLAC; + case BASE_TYPE_AUDIO + "/mp3": + return AUDIO_MPEG; + case BASE_TYPE_AUDIO + "/x-wav": + return AUDIO_WAV; + default: + return mimeType; } - String objectTypeIndicationHex = Assertions.checkNotNull(matcher.group(1)); - @Nullable String audioObjectTypeIndicationDec = matcher.group(2); - int objectTypeIndication; - int audioObjectTypeIndication = 0; - try { - objectTypeIndication = Integer.parseInt(objectTypeIndicationHex, 16); - if (audioObjectTypeIndicationDec != null) { - audioObjectTypeIndication = Integer.parseInt(audioObjectTypeIndicationDec); - } - } catch (NumberFormatException e) { - return null; + } + + /** Returns whether the given {@code mimeType} is a Matroska MIME type, including WebM. */ + public static boolean isMatroska(@Nullable String mimeType) { + if (mimeType == null) { + return false; } - return new Mp4aObjectType(objectTypeIndication, audioObjectTypeIndication); + return mimeType.startsWith(MimeTypes.VIDEO_WEBM) + || mimeType.startsWith(MimeTypes.AUDIO_WEBM) + || mimeType.startsWith(MimeTypes.APPLICATION_WEBM) + || mimeType.startsWith(MimeTypes.VIDEO_MATROSKA) + || mimeType.startsWith(MimeTypes.AUDIO_MATROSKA) + || mimeType.startsWith(MimeTypes.APPLICATION_MATROSKA); } /** @@ -545,6 +561,59 @@ public final class MimeTypes { // Prevent instantiation. } + /** + * Returns the {@link Mp4aObjectType} of an RFC 6381 MP4 audio codec string. + * + *

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

+   *         ~~~~~~~~~~~~~~ Object Type Indication (OTI) byte in hex
+   *    mp4a.[a-zA-Z0-9]{2}(.[0-9]{1,2})?
+   *                         ~~~~~~~~~~ audio OTI, decimal. Only for certain OTI.
+   * 
+ * + * For example, mp4a.40.2 has an OTI of 0x40 and an audio OTI of 2. + * + * @param codec An RFC 6381 MP4 audio codec string. + * @return The {@link Mp4aObjectType}, or {@code null} if the input was invalid. + */ + @VisibleForTesting + @Nullable + /* package */ static Mp4aObjectType getObjectTypeFromMp4aRFC6381CodecString(String codec) { + Matcher matcher = MP4A_RFC_6381_CODEC_PATTERN.matcher(codec); + if (!matcher.matches()) { + return null; + } + String objectTypeIndicationHex = Assertions.checkNotNull(matcher.group(1)); + @Nullable String audioObjectTypeIndicationDec = matcher.group(2); + int objectTypeIndication; + int audioObjectTypeIndication = 0; + try { + objectTypeIndication = Integer.parseInt(objectTypeIndicationHex, 16); + if (audioObjectTypeIndicationDec != null) { + audioObjectTypeIndication = Integer.parseInt(audioObjectTypeIndicationDec); + } + } catch (NumberFormatException e) { + return null; + } + return new Mp4aObjectType(objectTypeIndication, audioObjectTypeIndication); + } + + /** An MP4A Object Type Indication (OTI) and its optional audio OTI is defined by RFC 6381. */ + @VisibleForTesting + /* package */ static final class Mp4aObjectType { + /** The Object Type Indication of the MP4A codec. */ + public final int objectTypeIndication; + /** The Audio Object Type Indication of the MP4A codec, or 0 if it is absent. */ + @AacUtil.AacAudioObjectType public final int audioObjectTypeIndication; + + public Mp4aObjectType(int objectTypeIndication, int audioObjectTypeIndication) { + this.objectTypeIndication = objectTypeIndication; + this.audioObjectTypeIndication = audioObjectTypeIndication; + } + } + private static final class CustomMimeType { public final String mimeType; public final String codecPrefix; diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java index 05585d5301..4831ec59e2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.util; +import androidx.annotation.Nullable; import java.nio.ByteBuffer; import java.util.Arrays; @@ -219,11 +220,12 @@ public final class NalUnitUtil { * Returns whether the NAL unit with the specified header contains supplemental enhancement * information. * - * @param mimeType The sample MIME type. + * @param mimeType The sample MIME type, or {@code null} if unknown. * @param nalUnitHeaderFirstByte The first byte of nal_unit(). - * @return Whether the NAL unit with the specified header is an SEI NAL unit. + * @return Whether the NAL unit with the specified header is an SEI NAL unit. False is returned if + * the {@code MimeType} is {@code null}. */ - public static boolean isNalUnitSei(String mimeType, byte nalUnitHeaderFirstByte) { + public static boolean isNalUnitSei(@Nullable String mimeType, byte nalUnitHeaderFirstByte) { return (MimeTypes.VIDEO_H264.equals(mimeType) && (nalUnitHeaderFirstByte & 0x1F) == H264_NAL_UNIT_TYPE_SEI) || (MimeTypes.VIDEO_H265.equals(mimeType) @@ -431,18 +433,18 @@ public final class NalUnitUtil { return endOffset; } - if (prefixFlags != null) { - if (prefixFlags[0]) { - clearPrefixFlags(prefixFlags); - return startOffset - 3; - } else if (length > 1 && prefixFlags[1] && data[startOffset] == 1) { - clearPrefixFlags(prefixFlags); - return startOffset - 2; - } else if (length > 2 && prefixFlags[2] && data[startOffset] == 0 - && data[startOffset + 1] == 1) { - clearPrefixFlags(prefixFlags); - return startOffset - 1; - } + if (prefixFlags[0]) { + clearPrefixFlags(prefixFlags); + return startOffset - 3; + } else if (length > 1 && prefixFlags[1] && data[startOffset] == 1) { + clearPrefixFlags(prefixFlags); + return startOffset - 2; + } else if (length > 2 + && prefixFlags[2] + && data[startOffset] == 0 + && data[startOffset + 1] == 1) { + clearPrefixFlags(prefixFlags); + return startOffset - 1; } int limit = endOffset - 1; @@ -453,9 +455,7 @@ public final class NalUnitUtil { // There isn't a NAL prefix here, or at the next two positions. Do nothing and let the // loop advance the index by three. } else if (data[i - 2] == 0 && data[i - 1] == 0 && data[i] == 1) { - if (prefixFlags != null) { - clearPrefixFlags(prefixFlags); - } + clearPrefixFlags(prefixFlags); return i - 2; } else { // There isn't a NAL prefix here, but there might be at the next position. We should @@ -464,18 +464,20 @@ public final class NalUnitUtil { } } - if (prefixFlags != null) { - // True if the last three bytes in the data seen so far are {0,0,1}. - prefixFlags[0] = length > 2 - ? (data[endOffset - 3] == 0 && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) - : length == 2 ? (prefixFlags[2] && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) - : (prefixFlags[1] && data[endOffset - 1] == 1); - // True if the last two bytes in the data seen so far are {0,0}. - prefixFlags[1] = length > 1 ? data[endOffset - 2] == 0 && data[endOffset - 1] == 0 - : prefixFlags[2] && data[endOffset - 1] == 0; - // True if the last byte in the data seen so far is {0}. - prefixFlags[2] = data[endOffset - 1] == 0; - } + // True if the last three bytes in the data seen so far are {0,0,1}. + prefixFlags[0] = + length > 2 + ? (data[endOffset - 3] == 0 && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) + : length == 2 + ? (prefixFlags[2] && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) + : (prefixFlags[1] && data[endOffset - 1] == 1); + // True if the last two bytes in the data seen so far are {0,0}. + prefixFlags[1] = + length > 1 + ? data[endOffset - 2] == 0 && data[endOffset - 1] == 0 + : prefixFlags[2] && data[endOffset - 1] == 0; + // True if the last byte in the data seen so far is {0}. + prefixFlags[2] = data[endOffset - 1] == 0; return endOffset; } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java index 963e43fc7e..3ad5fd9703 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableBitArray.java @@ -15,7 +15,9 @@ */ package com.google.android.exoplayer2.util; -import com.google.android.exoplayer2.C; +import static java.lang.Math.min; + +import com.google.common.base.Charsets; import java.nio.charset.Charset; /** @@ -72,7 +74,7 @@ public final class ParsableBitArray { * @param parsableByteArray The {@link ParsableByteArray}. */ public void reset(ParsableByteArray parsableByteArray) { - reset(parsableByteArray.data, parsableByteArray.limit()); + reset(parsableByteArray.getData(), parsableByteArray.limit()); setPosition(parsableByteArray.getPosition() * 8); } @@ -288,7 +290,7 @@ public final class ParsableBitArray { * @return The string encoded by the bytes in UTF-8. */ public String readBytesAsString(int length) { - return readBytesAsString(length, Charset.forName(C.UTF8_NAME)); + return readBytesAsString(length, Charsets.UTF_8); } /** @@ -319,7 +321,7 @@ public final class ParsableBitArray { if (numBits < 32) { value &= (1 << numBits) - 1; } - int firstByteReadSize = Math.min(8 - bitOffset, numBits); + int firstByteReadSize = min(8 - bitOffset, numBits); int firstByteRightPaddingSize = 8 - bitOffset - firstByteReadSize; int firstByteBitmask = (0xFF00 >> bitOffset) | ((1 << firstByteRightPaddingSize) - 1); data[byteOffset] = (byte) (data[byteOffset] & firstByteBitmask); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java index 67686ad64f..a4e3c1dfbe 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2.util; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; import java.nio.charset.Charset; @@ -26,9 +26,9 @@ import java.nio.charset.Charset; */ public final class ParsableByteArray { - public byte[] data; - + private byte[] data; private int position; + // TODO(internal b/147657250): Enforce this limit on all read methods. private int limit; /** Creates a new instance that initially has no backing data. */ @@ -67,12 +67,6 @@ public final class ParsableByteArray { this.limit = limit; } - /** Sets the position and limit to zero. */ - public void reset() { - position = 0; - limit = 0; - } - /** * Resets the position to zero and the limit to the specified value. If the limit exceeds the * capacity, {@code data} is replaced with a new array of sufficient size. @@ -136,13 +130,6 @@ public final class ParsableByteArray { return position; } - /** - * Returns the capacity of the array, which may be larger than the limit. - */ - public int capacity() { - return data.length; - } - /** * Sets the reading offset in the array. * @@ -156,6 +143,23 @@ public final class ParsableByteArray { this.position = position; } + /** + * Returns the underlying array. + * + *

Changes to this array are reflected in the results of the {@code read...()} methods. + * + *

This reference must be assumed to become invalid when {@link #reset} is called (because the + * array might get reallocated). + */ + public byte[] getData() { + return data; + } + + /** Returns the capacity of the array, which may be larger than the limit. */ + public int capacity() { + return data.length; + } + /** * Moves the reading offset by {@code bytes}. * @@ -447,7 +451,7 @@ public final class ParsableByteArray { * @return The string encoded by the bytes. */ public String readString(int length) { - return readString(length, Charset.forName(C.UTF8_NAME)); + return readString(length, Charsets.UTF_8); } /** diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java b/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java index 439374a086..65f88b1983 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/TimestampAdjuster.java @@ -113,7 +113,7 @@ public final class TimestampAdjuster { if (lastSampleTimestampUs != C.TIME_UNSET) { // The wrap count for the current PTS may be closestWrapCount or (closestWrapCount - 1), // and we need to snap to the one closest to lastSampleTimestampUs. - long lastPts = usToPts(lastSampleTimestampUs); + long lastPts = usToNonWrappedPts(lastSampleTimestampUs); long closestWrapCount = (lastPts + (MAX_PTS_PLUS_ONE / 2)) / MAX_PTS_PLUS_ONE; long ptsWrapBelow = pts90Khz + (MAX_PTS_PLUS_ONE * (closestWrapCount - 1)); long ptsWrapAbove = pts90Khz + (MAX_PTS_PLUS_ONE * closestWrapCount); @@ -173,14 +173,27 @@ public final class TimestampAdjuster { return (pts * C.MICROS_PER_SECOND) / 90000; } + /** + * Converts a timestamp in microseconds to a 90 kHz clock timestamp, performing wraparound to keep + * the result within 33-bits. + * + * @param us A value in microseconds. + * @return The corresponding value as a 90 kHz clock timestamp, wrapped to 33 bits. + */ + public static long usToWrappedPts(long us) { + return usToNonWrappedPts(us) % MAX_PTS_PLUS_ONE; + } + /** * Converts a timestamp in microseconds to a 90 kHz clock timestamp. * + *

Does not perform any wraparound. To get a 90 kHz timestamp suitable for use with MPEG-TS, + * use {@link #usToWrappedPts(long)}. + * * @param us A value in microseconds. * @return The corresponding value as a 90 kHz clock timestamp. */ - public static long usToPts(long us) { + public static long usToNonWrappedPts(long us) { return (us * 90000) / C.MICROS_PER_SECOND; } - } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index b1c554cf88..cfc7b5a5f3 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -16,6 +16,9 @@ package com.google.android.exoplayer2.util; import static android.content.Context.UI_MODE_SERVICE; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.max; +import static java.lang.Math.min; import android.Manifest.permission; import android.annotation.SuppressLint; @@ -29,6 +32,8 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Configuration; import android.content.res.Resources; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; import android.graphics.Point; import android.media.AudioFormat; import android.net.ConnectivityManager; @@ -48,11 +53,14 @@ import android.view.WindowManager; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.C.ContentType; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.upstream.DataSource; +import com.google.common.base.Ascii; +import com.google.common.base.Charsets; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.File; @@ -62,8 +70,7 @@ import java.lang.reflect.Method; import java.math.BigDecimal; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.nio.charset.Charset; -import java.util.ArrayList; +import java.util.ArrayDeque; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; @@ -95,7 +102,10 @@ public final class Util { * Like {@link android.os.Build.VERSION#SDK_INT}, but in a place where it can be conveniently * overridden for local testing. */ - public static final int SDK_INT = "R".equals(Build.VERSION.CODENAME) ? 30 : Build.VERSION.SDK_INT; + public static final int SDK_INT = + "S".equals(Build.VERSION.CODENAME) + ? 31 + : "R".equals(Build.VERSION.CODENAME) ? 30 : Build.VERSION.SDK_INT; /** * Like {@link Build#DEVICE}, but in a place where it can be conveniently overridden for local @@ -134,6 +144,11 @@ public final class Util { + "(T(([0-9]*)H)?(([0-9]*)M)?(([0-9.]*)S)?)?$"); private static final Pattern ESCAPED_CHARACTER_PATTERN = Pattern.compile("%([A-Fa-f0-9]{2})"); + // https://docs.microsoft.com/en-us/azure/media-services/previous/media-services-deliver-content-overview#URLs. + private static final Pattern ISM_URL_PATTERN = Pattern.compile(".*\\.isml?(?:/(manifest(.*))?)?"); + private static final String ISM_HLS_FORMAT_EXTENSION = "format=m3u8-aapl"; + private static final String ISM_DASH_FORMAT_EXTENSION = "format=mpd-time-csf"; + // Replacement map of ISO language codes used for normalization. @Nullable private static HashMap languageTagReplacementMap; @@ -393,21 +408,63 @@ public final class Util { return concatenation; } + /** + * Copies the contents of {@code list} into {@code array}. + * + *

{@code list.size()} must be the same as {@code array.length} to ensure the contents can be + * copied into {@code array} without leaving any nulls at the end. + * + * @param list The list to copy items from. + * @param array The array to copy items to. + */ + @SuppressWarnings("nullness:toArray.nullable.elements.not.newarray") + public static void nullSafeListToArray(List list, T[] array) { + Assertions.checkState(list.size() == array.length); + list.toArray(array); + } + + /** + * Creates a {@link Handler} on the current {@link Looper} thread. + * + * @throws IllegalStateException If the current thread doesn't have a {@link Looper}. + */ + public static Handler createHandlerForCurrentLooper() { + return createHandlerForCurrentLooper(/* callback= */ null); + } + + /** + * Creates a {@link Handler} with the specified {@link Handler.Callback} on the current {@link + * Looper} thread. + * + *

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

If the current thread doesn't have a {@link Looper}, the application's main thread {@link * Looper} is used. */ - public static Handler createHandler() { - return createHandler(/* callback= */ null); + public static Handler createHandlerForCurrentOrMainLooper() { + return createHandlerForCurrentOrMainLooper(/* callback= */ null); } /** * Creates a {@link Handler} with the specified {@link Handler.Callback} on the current {@link - * Looper} thread. The method accepts partially initialized objects as callback under the - * assumption that the Handler won't be used to send messages until the callback is fully - * initialized. + * Looper} thread. + * + *

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

If the current thread doesn't have a {@link Looper}, the application's main thread {@link * Looper} is used. @@ -416,15 +473,17 @@ public final class Util { * callback is required. * @return A {@link Handler} with the specified callback on the current {@link Looper} thread. */ - public static Handler createHandler(@Nullable Handler.@UnknownInitialization Callback callback) { - return createHandler(getLooper(), callback); + public static Handler createHandlerForCurrentOrMainLooper( + @Nullable Handler.@UnknownInitialization Callback callback) { + return createHandler(getCurrentOrMainLooper(), callback); } /** * Creates a {@link Handler} with the specified {@link Handler.Callback} on the specified {@link - * Looper} thread. The method accepts partially initialized objects as callback under the - * assumption that the Handler won't be used to send messages until the callback is fully - * initialized. + * Looper} thread. + * + *

The method accepts partially initialized objects as callback under the assumption that the + * Handler won't be used to send messages until the callback is fully initialized. * * @param looper A {@link Looper} to run the callback on. * @param callback A {@link Handler.Callback}. May be a partially initialized class, or null if no @@ -437,12 +496,30 @@ public final class Util { return new Handler(looper, callback); } + /** + * Posts the {@link Runnable} if the calling thread differs with the {@link Looper} of the {@link + * Handler}. Otherwise, runs the {@link Runnable} directly. + * + * @param handler The handler to which the {@link Runnable} will be posted. + * @param runnable The runnable to either post or run. + * @return {@code true} if the {@link Runnable} was successfully posted to the {@link Handler} or + * run. {@code false} otherwise. + */ + public static boolean postOrRun(Handler handler, Runnable runnable) { + if (handler.getLooper() == Looper.myLooper()) { + runnable.run(); + return true; + } else { + return handler.post(runnable); + } + } + /** * Returns the {@link Looper} associated with the current thread, or the {@link Looper} of the * application's main thread if the current thread doesn't have a {@link Looper}. */ - public static Looper getLooper() { - Looper myLooper = Looper.myLooper(); + public static Looper getCurrentOrMainLooper() { + @Nullable Looper myLooper = Looper.myLooper(); return myLooper != null ? myLooper : Looper.getMainLooper(); } @@ -554,7 +631,7 @@ public final class Util { mainLanguage = replacedLanguage; } if ("no".equals(mainLanguage) || "i".equals(mainLanguage) || "zh".equals(mainLanguage)) { - normalizedTag = maybeReplaceGrandfatheredLanguageTags(normalizedTag); + normalizedTag = maybeReplaceLegacyLanguageTags(normalizedTag); } return normalizedTag; } @@ -566,7 +643,7 @@ public final class Util { * @return The string. */ public static String fromUtf8Bytes(byte[] bytes) { - return new String(bytes, Charset.forName(C.UTF8_NAME)); + return new String(bytes, Charsets.UTF_8); } /** @@ -578,7 +655,7 @@ public final class Util { * @return The string. */ public static String fromUtf8Bytes(byte[] bytes, int offset, int length) { - return new String(bytes, offset, length, Charset.forName(C.UTF8_NAME)); + return new String(bytes, offset, length, Charsets.UTF_8); } /** @@ -588,7 +665,7 @@ public final class Util { * @return The code points encoding using UTF-8. */ public static byte[] getUtf8Bytes(String value) { - return value.getBytes(Charset.forName(C.UTF8_NAME)); + return value.getBytes(Charsets.UTF_8); } /** @@ -688,7 +765,7 @@ public final class Util { * @return The constrained value {@code Math.max(min, Math.min(value, max))}. */ public static int constrainValue(int value, int min, int max) { - return Math.max(min, Math.min(value, max)); + return max(min, min(value, max)); } /** @@ -700,7 +777,7 @@ public final class Util { * @return The constrained value {@code Math.max(min, Math.min(value, max))}. */ public static long constrainValue(long value, long min, long max) { - return Math.max(min, Math.min(value, max)); + return max(min, min(value, max)); } /** @@ -712,7 +789,7 @@ public final class Util { * @return The constrained value {@code Math.max(min, Math.min(value, max))}. */ public static float constrainValue(float value, float min, float max) { - return Math.max(min, Math.min(value, max)); + return max(min, min(value, max)); } /** @@ -767,6 +844,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}. @@ -796,7 +891,7 @@ public final class Util { index++; } } - return stayInBounds ? Math.max(0, index) : index; + return stayInBounds ? max(0, index) : index; } /** @@ -828,7 +923,7 @@ public final class Util { index++; } } - return stayInBounds ? Math.max(0, index) : index; + return stayInBounds ? max(0, index) : index; } /** @@ -864,7 +959,7 @@ public final class Util { index++; } } - return stayInBounds ? Math.max(0, index) : index; + return stayInBounds ? max(0, index) : index; } /** @@ -938,7 +1033,7 @@ public final class Util { index--; } } - return stayInBounds ? Math.min(array.length - 1, index) : index; + return stayInBounds ? min(array.length - 1, index) : index; } /** @@ -971,7 +1066,7 @@ public final class Util { index--; } } - return stayInBounds ? Math.min(array.length - 1, index) : index; + return stayInBounds ? min(array.length - 1, index) : index; } /** @@ -1009,7 +1104,7 @@ public final class Util { index--; } } - return stayInBounds ? Math.min(list.size() - 1, index) : index; + return stayInBounds ? min(list.size() - 1, index) : index; } /** @@ -1216,41 +1311,6 @@ public final class Util { return Math.round((double) mediaDuration / speed); } - /** - * Converts a list of integers to a primitive array. - * - * @param list A list of integers. - * @return The list in array form, or null if the input list was null. - */ - public static int @PolyNull [] toArray(@PolyNull List list) { - if (list == null) { - return null; - } - int length = list.size(); - int[] intArray = new int[length]; - for (int i = 0; i < length; i++) { - intArray[i] = list.get(i); - } - return intArray; - } - - /** - * Converts an array of primitive ints to a list of integers. - * - * @param ints The ints. - * @return The input array in list form. - */ - public static List toList(int... ints) { - if (ints == null) { - return new ArrayList<>(); - } - List integers = new ArrayList<>(); - for (int anInt : ints) { - integers.add(anInt); - } - return integers; - } - /** * Returns the integer equal to the big-endian concatenation of the characters in {@code string} * as bytes. The string must be no more than four characters long. @@ -1291,6 +1351,24 @@ public final class Util { return (toUnsignedLong(mostSignificantBits) << 32) | toUnsignedLong(leastSignificantBits); } + /** + * Truncates a sequence of ASCII characters to a maximum length. + * + *

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

Note: This is not safe to use in general on Unicode text because it may separate + * characters from combining characters or split up surrogate pairs. + * + * @param sequence The character sequence to truncate. + * @param maxLength The max length to truncate to. + * @return {@code sequence} directly if {@code sequence.length() <= maxLength}, otherwise {@code + * sequence.subsequence(0, maxLength}. + */ + public static CharSequence truncateAscii(CharSequence sequence, int maxLength) { + return sequence.length() <= maxLength ? sequence : sequence.subSequence(0, maxLength); + } + /** * Returns a byte array containing values parsed from the hex string provided. * @@ -1399,14 +1477,30 @@ public final class Util { return split(codecs.trim(), "(\\s*,\\s*)"); } + /** + * Gets a PCM {@link Format} with the specified parameters. + * + * @param pcmEncoding The {@link C.PcmEncoding}. + * @param channels The number of channels, or {@link Format#NO_VALUE} if unknown. + * @param sampleRate The sample rate in Hz, or {@link Format#NO_VALUE} if unknown. + * @return The PCM format. + */ + public static Format getPcmFormat(@C.PcmEncoding int pcmEncoding, int channels, int sampleRate) { + return new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setChannelCount(channels) + .setSampleRate(sampleRate) + .setPcmEncoding(pcmEncoding) + .build(); + } + /** * Converts a sample bit depth to a corresponding PCM encoding constant. * * @param bitDepth The bit depth. Supported values are 8, 16, 24 and 32. - * @return The corresponding encoding. One of {@link C#ENCODING_PCM_8BIT}, - * {@link C#ENCODING_PCM_16BIT}, {@link C#ENCODING_PCM_24BIT} and - * {@link C#ENCODING_PCM_32BIT}. If the bit depth is unsupported then - * {@link C#ENCODING_INVALID} is returned. + * @return The corresponding encoding. One of {@link C#ENCODING_PCM_8BIT}, {@link + * C#ENCODING_PCM_16BIT}, {@link C#ENCODING_PCM_24BIT} and {@link C#ENCODING_PCM_32BIT}. If + * the bit depth is unsupported then {@link C#ENCODING_INVALID} is returned. */ @C.PcmEncoding public static int getPcmEncoding(int bitDepth) { @@ -1453,7 +1547,7 @@ public final class Util { /** * Returns the audio track channel configuration for the given channel count, or {@link - * AudioFormat#CHANNEL_INVALID} if output is not poossible. + * AudioFormat#CHANNEL_INVALID} if output is not possible. * * @param channelCount The number of channels in the input audio. * @return The channel configuration or {@link AudioFormat#CHANNEL_INVALID} if output is not @@ -1623,13 +1717,13 @@ public final class Util { } /** - * Makes a best guess to infer the type from a {@link Uri}. + * Makes a best guess to infer the {@link ContentType} from a {@link Uri}. * * @param uri The {@link Uri}. * @param overrideExtension If not null, used to infer the type. * @return The content type. */ - @C.ContentType + @ContentType public static int inferContentType(Uri uri, @Nullable String overrideExtension) { return TextUtils.isEmpty(overrideExtension) ? inferContentType(uri) @@ -1637,45 +1731,55 @@ public final class Util { } /** - * Makes a best guess to infer the type from a {@link Uri}. + * Makes a best guess to infer the {@link ContentType} from a {@link Uri}. * * @param uri The {@link Uri}. * @return The content type. */ - @C.ContentType + @ContentType public static int inferContentType(Uri uri) { - String path = uri.getPath(); + @Nullable String path = uri.getPath(); return path == null ? C.TYPE_OTHER : inferContentType(path); } /** - * Makes a best guess to infer the type from a file name. + * Makes a best guess to infer the {@link ContentType} from a file name. * * @param fileName Name of the file. It can include the path of the file. * @return The content type. */ - @C.ContentType + @ContentType public static int inferContentType(String fileName) { fileName = toLowerInvariant(fileName); if (fileName.endsWith(".mpd")) { return C.TYPE_DASH; } else if (fileName.endsWith(".m3u8")) { return C.TYPE_HLS; - } else if (fileName.matches(".*\\.ism(l)?(/manifest(\\(.+\\))?)?")) { - return C.TYPE_SS; - } else { - return C.TYPE_OTHER; } + Matcher ismMatcher = ISM_URL_PATTERN.matcher(fileName); + if (ismMatcher.matches()) { + @Nullable String extensions = ismMatcher.group(2); + if (extensions != null) { + if (extensions.contains(ISM_DASH_FORMAT_EXTENSION)) { + return C.TYPE_DASH; + } else if (extensions.contains(ISM_HLS_FORMAT_EXTENSION)) { + return C.TYPE_HLS; + } + } + return C.TYPE_SS; + } + return C.TYPE_OTHER; } /** - * Makes a best guess to infer the type from a {@link Uri} and MIME type. + * Makes a best guess to infer the {@link ContentType} from a {@link Uri} and optional MIME type. * * @param uri The {@link Uri}. - * @param mimeType If not null, used to infer the type. + * @param mimeType If MIME type, or {@code null}. * @return The content type. */ - public static int inferContentTypeWithMimeType(Uri uri, @Nullable String mimeType) { + @ContentType + public static int inferContentTypeForUriAndMimeType(Uri uri, @Nullable String mimeType) { if (mimeType == null) { return Util.inferContentType(uri); } @@ -1687,10 +1791,50 @@ public final class Util { case MimeTypes.APPLICATION_SS: return C.TYPE_SS; default: - return Util.inferContentType(uri); + return C.TYPE_OTHER; } } + /** + * Returns the MIME type corresponding to the given adaptive {@link ContentType}, or {@code null} + * if the content type is {@link C#TYPE_OTHER}. + */ + @Nullable + public static String getAdaptiveMimeTypeForContentType(int contentType) { + switch (contentType) { + case C.TYPE_DASH: + return MimeTypes.APPLICATION_MPD; + case C.TYPE_HLS: + return MimeTypes.APPLICATION_M3U8; + case C.TYPE_SS: + return MimeTypes.APPLICATION_SS; + case C.TYPE_OTHER: + default: + return null; + } + } + + /** + * If the provided URI is an ISM Presentation URI, returns the URI with "Manifest" appended to its + * path (i.e., the corresponding default manifest URI). Else returns the provided URI without + * modification. See [MS-SSTR] v20180912, section 2.2.1. + * + * @param uri The original URI. + * @return The fixed URI. + */ + public static Uri fixSmoothStreamingIsmManifestUri(Uri uri) { + @Nullable String path = toLowerInvariant(uri.getPath()); + if (path == null) { + return uri; + } + Matcher ismMatcher = ISM_URL_PATTERN.matcher(path); + if (ismMatcher.matches() && ismMatcher.group(1) == null) { + // Add missing "Manifest" suffix. + return Uri.withAppendedPath(uri, "Manifest"); + } + return uri; + } + /** * Returns the specified millisecond time formatted as a string. * @@ -1797,8 +1941,7 @@ public final class Util { Matcher matcher = ESCAPED_CHARACTER_PATTERN.matcher(fileName); int startOfNotEscaped = 0; while (percentCharacterCount > 0 && matcher.find()) { - char unescapedCharacter = - (char) Integer.parseInt(Assertions.checkNotNull(matcher.group(1)), 16); + char unescapedCharacter = (char) Integer.parseInt(checkNotNull(matcher.group(1)), 16); builder.append(fileName, startOfNotEscaped, matcher.start()).append(unescapedCharacter); startOfNotEscaped = matcher.end(); percentCharacterCount--; @@ -1905,6 +2048,8 @@ public final class Util { * @param context A context to access the connectivity manager. * @return The {@link C.NetworkType} of the current network connection. */ + // Intentional null check to guard against user input. + @SuppressWarnings("known.nonnull") @C.NetworkType public static int getNetworkType(Context context) { if (context == null) { @@ -1912,6 +2057,7 @@ public final class Util { return C.NETWORK_TYPE_UNKNOWN; } NetworkInfo networkInfo; + @Nullable ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager == null) { @@ -1951,6 +2097,7 @@ public final class Util { */ public static String getCountryCode(@Nullable Context context) { if (context != null) { + @Nullable TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager != null) { @@ -1992,14 +2139,14 @@ public final class Util { if (input.bytesLeft() <= 0) { return false; } - byte[] outputData = output.data; + byte[] outputData = output.getData(); if (outputData.length < input.bytesLeft()) { outputData = new byte[2 * input.bytesLeft()]; } if (inflater == null) { inflater = new Inflater(); } - inflater.setInput(input.data, input.getPosition(), input.bytesLeft()); + inflater.setInput(input.getData(), input.getPosition(), input.bytesLeft()); try { int outputSize = 0; while (true) { @@ -2030,6 +2177,7 @@ public final class Util { */ public static boolean isTv(Context context) { // See https://developer.android.com/training/tv/start/hardware.html#runtime-check. + @Nullable UiModeManager uiModeManager = (UiModeManager) context.getApplicationContext().getSystemService(UI_MODE_SERVICE); return uiModeManager != null @@ -2049,7 +2197,8 @@ public final class Util { * @return The size of the current mode, in pixels. */ public static Point getCurrentDisplayModeSize(Context context) { - WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + WindowManager windowManager = + checkNotNull((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)); return getCurrentDisplayModeSize(context, windowManager.getDefaultDisplay()); } @@ -2155,6 +2304,32 @@ public final class Util { : SystemClock.elapsedRealtime() + elapsedRealtimeEpochOffsetMs; } + /** + * Moves the elements starting at {@code fromIndex} to {@code newFromIndex}. + * + * @param items The list of which to move elements. + * @param fromIndex The index at which the items to move start. + * @param toIndex The index up to which elements should be moved (exclusive). + * @param newFromIndex The new from index. + */ + public static void moveItems( + List items, int fromIndex, int toIndex, int newFromIndex) { + ArrayDeque removedItems = new ArrayDeque<>(); + int removedItemsLength = toIndex - fromIndex; + for (int i = removedItemsLength - 1; i >= 0; i--) { + removedItems.addFirst(items.remove(fromIndex + i)); + } + items.addAll(min(newFromIndex, items.size()), removedItems); + } + + /** Returns whether the table exists in the database. */ + public static boolean tableExists(SQLiteDatabase database, String tableName) { + long count = + DatabaseUtils.queryNumEntries( + database, "sqlite_master", "tbl_name = ?", new String[] {tableName}); + return count > 0; + } + @Nullable private static String getSystemProperty(String name) { try { @@ -2223,7 +2398,7 @@ public final class Util { case TelephonyManager.NETWORK_TYPE_LTE: return C.NETWORK_TYPE_4G; case TelephonyManager.NETWORK_TYPE_NR: - return C.NETWORK_TYPE_5G; + return SDK_INT >= 29 ? C.NETWORK_TYPE_5G : C.NETWORK_TYPE_UNKNOWN; case TelephonyManager.NETWORK_TYPE_IWLAN: return C.NETWORK_TYPE_WIFI; case TelephonyManager.NETWORK_TYPE_GSM: @@ -2272,14 +2447,14 @@ public final class Util { private static boolean isTrafficRestricted(Uri uri) { return "http".equals(uri.getScheme()) && !NetworkSecurityPolicy.getInstance() - .isCleartextTrafficPermitted(Assertions.checkNotNull(uri.getHost())); + .isCleartextTrafficPermitted(checkNotNull(uri.getHost())); } - private static String maybeReplaceGrandfatheredLanguageTags(String languageTag) { - for (int i = 0; i < isoGrandfatheredTagReplacements.length; i += 2) { - if (languageTag.startsWith(isoGrandfatheredTagReplacements[i])) { - return isoGrandfatheredTagReplacements[i + 1] - + languageTag.substring(/* beginIndex= */ isoGrandfatheredTagReplacements[i].length()); + private static String maybeReplaceLegacyLanguageTags(String languageTag) { + for (int i = 0; i < isoLegacyTagReplacements.length; i += 2) { + if (languageTag.startsWith(isoLegacyTagReplacements[i])) { + return isoLegacyTagReplacements[i + 1] + + languageTag.substring(/* beginIndex= */ isoLegacyTagReplacements[i].length()); } } return languageTag; @@ -2339,9 +2514,9 @@ public final class Util { "hsn", "zh-hsn" }; - // "Grandfathered tags", replaced by modern equivalents (including macrolanguage) + // Legacy ("grandfathered") tags, replaced by modern equivalents (including macrolanguage) // See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry. - private static final String[] isoGrandfatheredTagReplacements = + private static final String[] isoLegacyTagReplacements = new String[] { "i-lux", "lb", "i-hak", "zh-hak", diff --git a/library/common/src/main/java/com/google/android/exoplayer2/video/AvcConfig.java b/library/common/src/main/java/com/google/android/exoplayer2/video/AvcConfig.java index 3886fdfb23..b794d2db90 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/video/AvcConfig.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/video/AvcConfig.java @@ -91,7 +91,7 @@ public final class AvcConfig { int length = data.readUnsignedShort(); int offset = data.getPosition(); data.skipBytes(length); - return CodecSpecificDataUtil.buildNalUnit(data.data, offset, length); + return CodecSpecificDataUtil.buildNalUnit(data.getData(), offset, length); } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/video/HevcConfig.java b/library/common/src/main/java/com/google/android/exoplayer2/video/HevcConfig.java index bb11ef0005..100a824a97 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/video/HevcConfig.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/video/HevcConfig.java @@ -69,8 +69,8 @@ public final class HevcConfig { System.arraycopy(NalUnitUtil.NAL_START_CODE, 0, buffer, bufferPosition, NalUnitUtil.NAL_START_CODE.length); bufferPosition += NalUnitUtil.NAL_START_CODE.length; - System - .arraycopy(data.data, data.getPosition(), buffer, bufferPosition, nalUnitLength); + System.arraycopy( + data.getData(), data.getPosition(), buffer, bufferPosition, nalUnitLength); bufferPosition += nalUnitLength; data.skipBytes(nalUnitLength); } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/CTest.java b/library/common/src/test/java/com/google/android/exoplayer2/CTest.java index ac5edc6f6b..de39888b41 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/CTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/CTest.java @@ -31,7 +31,7 @@ public class CTest { @SuppressLint("InlinedApi") @Test public void bufferFlagConstants_equalToMediaCodecConstants() { - // Sanity check that constant values match those defined by the platform. + // Check that constant values match those defined by the platform. assertThat(C.BUFFER_FLAG_KEY_FRAME).isEqualTo(MediaCodec.BUFFER_FLAG_KEY_FRAME); assertThat(C.BUFFER_FLAG_END_OF_STREAM).isEqualTo(MediaCodec.BUFFER_FLAG_END_OF_STREAM); assertThat(C.CRYPTO_MODE_AES_CTR).isEqualTo(MediaCodec.CRYPTO_MODE_AES_CTR); @@ -40,7 +40,7 @@ public class CTest { @SuppressLint("InlinedApi") @Test public void encodingConstants_equalToAudioFormatConstants() { - // Sanity check that encoding constant values match those defined by the platform. + // Check that encoding constant values match those defined by the platform. assertThat(C.ENCODING_PCM_16BIT).isEqualTo(AudioFormat.ENCODING_PCM_16BIT); assertThat(C.ENCODING_MP3).isEqualTo(AudioFormat.ENCODING_MP3); assertThat(C.ENCODING_PCM_FLOAT).isEqualTo(AudioFormat.ENCODING_PCM_FLOAT); diff --git a/library/common/src/test/java/com/google/android/exoplayer2/FormatTest.java b/library/common/src/test/java/com/google/android/exoplayer2/FormatTest.java index 135aace2a3..1ad888c868 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/FormatTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/FormatTest.java @@ -24,13 +24,14 @@ import android.os.Parcel; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.ExoMediaCrypto; +import com.google.android.exoplayer2.drm.UnsupportedMediaCrypto; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; -import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.video.ColorInfo; import java.util.ArrayList; import java.util.List; +import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; @@ -53,9 +54,10 @@ public final class FormatTest { parcel.setDataPosition(0); Format formatFromParcel = Format.CREATOR.createFromParcel(parcel); - Format expectedFormat = formatToParcel.buildUpon().setExoMediaCryptoType(null).build(); + Format expectedFormat = + formatToParcel.buildUpon().setExoMediaCryptoType(UnsupportedMediaCrypto.class).build(); - assertThat(formatFromParcel.exoMediaCryptoType).isNull(); + assertThat(formatFromParcel.exoMediaCryptoType).isEqualTo(UnsupportedMediaCrypto.class); assertThat(formatFromParcel).isEqualTo(expectedFormat); parcel.recycle(); @@ -69,11 +71,9 @@ public final class FormatTest { initializationData.add(initData2); DrmInitData.SchemeData drmData1 = - new DrmInitData.SchemeData( - WIDEVINE_UUID, VIDEO_MP4, TestUtil.buildTestData(128, 1 /* data seed */)); + new DrmInitData.SchemeData(WIDEVINE_UUID, VIDEO_MP4, buildTestData(128, 1 /* data seed */)); DrmInitData.SchemeData drmData2 = - new DrmInitData.SchemeData( - C.UUID_NIL, VIDEO_WEBM, TestUtil.buildTestData(128, 1 /* data seed */)); + new DrmInitData.SchemeData(C.UUID_NIL, VIDEO_WEBM, buildTestData(128, 1 /* data seed */)); DrmInitData drmInitData = new DrmInitData(drmData1, drmData2); byte[] projectionData = new byte[] {1, 2, 3}; @@ -90,36 +90,45 @@ public final class FormatTest { C.COLOR_TRANSFER_SDR, new byte[] {1, 2, 3, 4, 5, 6, 7}); - return new Format( - "id", - "label", - "language", - C.SELECTION_FLAG_DEFAULT, - C.ROLE_FLAG_MAIN, - /* averageBitrate= */ 1024, - /* peakBitrate= */ 2048, - "codec", - metadata, - /* containerMimeType= */ MimeTypes.VIDEO_MP4, - /* sampleMimeType= */ MimeTypes.VIDEO_H264, - /* maxInputSize= */ 5000, - initializationData, - drmInitData, - Format.OFFSET_SAMPLE_RELATIVE, - /* width= */ 1920, - /* height= */ 1080, - /* frameRate= */ 24, - /* rotationDegrees= */ 90, - /* pixelWidthHeightRatio= */ 4, - projectionData, - C.STEREO_MODE_TOP_BOTTOM, - colorInfo, - /* channelCount= */ 6, - /* sampleRate= */ 44100, - C.ENCODING_PCM_24BIT, - /* encoderDelay= */ 1001, - /* encoderPadding= */ 1002, - /* accessibilityChannel= */ 2, - /* exoMediaCryptoType= */ ExoMediaCrypto.class); + return new Format.Builder() + .setId("id") + .setLabel("label") + .setLanguage("language") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .setRoleFlags(C.ROLE_FLAG_MAIN) + .setAverageBitrate(1024) + .setPeakBitrate(2048) + .setCodecs("codec") + .setMetadata(metadata) + .setContainerMimeType(MimeTypes.VIDEO_MP4) + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setMaxInputSize(5000) + .setInitializationData(initializationData) + .setDrmInitData(drmInitData) + .setSubsampleOffsetUs(Format.OFFSET_SAMPLE_RELATIVE) + .setWidth(1920) + .setHeight(1080) + .setFrameRate(24) + .setRotationDegrees(90) + .setPixelWidthHeightRatio(4) + .setProjectionData(projectionData) + .setStereoMode(C.STEREO_MODE_TOP_BOTTOM) + .setColorInfo(colorInfo) + .setChannelCount(6) + .setSampleRate(44100) + .setPcmEncoding(C.ENCODING_PCM_24BIT) + .setEncoderDelay(1001) + .setEncoderPadding(1002) + .setAccessibilityChannel(2) + .setExoMediaCryptoType(ExoMediaCrypto.class) + .build(); + } + + /** Generates an array of random bytes with the specified length. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] buildTestData(int length, int seed) { + byte[] source = new byte[length]; + new Random(seed).nextBytes(source); + return source; } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java index d62735a6ba..86f03a3ddb 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/MediaItemTest.java @@ -95,7 +95,8 @@ public class MediaItemTest { .setDrmUuid(C.WIDEVINE_UUID) .setDrmLicenseUri(licenseUri) .setDrmLicenseRequestHeaders(requestHeaders) - .setDrmMultiSession(/* multiSession= */ true) + .setDrmMultiSession(true) + .setDrmForceDefaultLicenseUri(true) .setDrmPlayClearContentWithoutKey(true) .setDrmSessionForClearTypes(Collections.singletonList(C.TRACK_TYPE_AUDIO)) .setDrmKeySetId(keySetId) @@ -107,6 +108,7 @@ public class MediaItemTest { assertThat(mediaItem.playbackProperties.drmConfiguration.requestHeaders) .isEqualTo(requestHeaders); assertThat(mediaItem.playbackProperties.drmConfiguration.multiSession).isTrue(); + assertThat(mediaItem.playbackProperties.drmConfiguration.forceDefaultLicenseUri).isTrue(); assertThat(mediaItem.playbackProperties.drmConfiguration.playClearContentWithoutKey).isTrue(); assertThat(mediaItem.playbackProperties.drmConfiguration.sessionForClearTypes) .containsExactly(C.TRACK_TYPE_AUDIO); @@ -301,6 +303,7 @@ public class MediaItemTest { .setDrmLicenseRequestHeaders( Collections.singletonMap("Referer", "http://www.google.com")) .setDrmMultiSession(true) + .setDrmForceDefaultLicenseUri(true) .setDrmPlayClearContentWithoutKey(true) .setDrmSessionForClearTypes(Collections.singletonList(C.TRACK_TYPE_AUDIO)) .setDrmKeySetId(new byte[] {1, 2, 3}) diff --git a/library/common/src/test/java/com/google/android/exoplayer2/audio/OpusUtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/audio/OpusUtilTest.java new file mode 100644 index 0000000000..4fe18aa4d0 --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/audio/OpusUtilTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.common.collect.ImmutableList; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link OpusUtil}. */ +@RunWith(AndroidJUnit4.class) +public final class OpusUtilTest { + + private static final byte[] HEADER = + new byte[] {79, 112, 117, 115, 72, 101, 97, 100, 0, 2, 1, 56, 0, 0, -69, -128, 0, 0, 0}; + + private static final int HEADER_PRE_SKIP_SAMPLES = 14337; + private static final byte[] HEADER_PRE_SKIP_BYTES = + buildNativeOrderByteArray(sampleCountToNanoseconds(HEADER_PRE_SKIP_SAMPLES)); + + private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; + private static final byte[] DEFAULT_SEEK_PRE_ROLL_BYTES = + buildNativeOrderByteArray(sampleCountToNanoseconds(DEFAULT_SEEK_PRE_ROLL_SAMPLES)); + + private static final ImmutableList HEADER_ONLY_INITIALIZATION_DATA = + ImmutableList.of(HEADER); + + private static final long CUSTOM_PRE_SKIP_SAMPLES = 28674; + private static final byte[] CUSTOM_PRE_SKIP_BYTES = + buildNativeOrderByteArray(sampleCountToNanoseconds(CUSTOM_PRE_SKIP_SAMPLES)); + + private static final long CUSTOM_SEEK_PRE_ROLL_SAMPLES = 7680; + private static final byte[] CUSTOM_SEEK_PRE_ROLL_BYTES = + buildNativeOrderByteArray(sampleCountToNanoseconds(CUSTOM_SEEK_PRE_ROLL_SAMPLES)); + + private static final ImmutableList FULL_INITIALIZATION_DATA = + ImmutableList.of(HEADER, CUSTOM_PRE_SKIP_BYTES, CUSTOM_SEEK_PRE_ROLL_BYTES); + + @Test + public void buildInitializationData() { + List initializationData = OpusUtil.buildInitializationData(HEADER); + assertThat(initializationData).hasSize(3); + assertThat(initializationData.get(0)).isEqualTo(HEADER); + assertThat(initializationData.get(1)).isEqualTo(HEADER_PRE_SKIP_BYTES); + assertThat(initializationData.get(2)).isEqualTo(DEFAULT_SEEK_PRE_ROLL_BYTES); + } + + @Test + public void getChannelCount() { + int channelCount = OpusUtil.getChannelCount(HEADER); + assertThat(channelCount).isEqualTo(2); + } + + @Test + public void getPreSkipSamples_fullInitializationData_returnsOverrideValue() { + int preSkipSamples = OpusUtil.getPreSkipSamples(FULL_INITIALIZATION_DATA); + assertThat(preSkipSamples).isEqualTo(CUSTOM_PRE_SKIP_SAMPLES); + } + + @Test + public void getPreSkipSamples_headerOnlyInitializationData_returnsHeaderValue() { + int preSkipSamples = OpusUtil.getPreSkipSamples(HEADER_ONLY_INITIALIZATION_DATA); + assertThat(preSkipSamples).isEqualTo(HEADER_PRE_SKIP_SAMPLES); + } + + @Test + public void getSeekPreRollSamples_fullInitializationData_returnsInitializationDataValue() { + int seekPreRollSamples = OpusUtil.getSeekPreRollSamples(FULL_INITIALIZATION_DATA); + assertThat(seekPreRollSamples).isEqualTo(CUSTOM_SEEK_PRE_ROLL_SAMPLES); + } + + @Test + public void getSeekPreRollSamples_headerOnlyInitializationData_returnsDefaultValue() { + int seekPreRollSamples = OpusUtil.getSeekPreRollSamples(HEADER_ONLY_INITIALIZATION_DATA); + assertThat(seekPreRollSamples).isEqualTo(DEFAULT_SEEK_PRE_ROLL_SAMPLES); + } + + private static long sampleCountToNanoseconds(long sampleCount) { + return (sampleCount * C.NANOS_PER_SECOND) / OpusUtil.SAMPLE_RATE; + } + + private static byte[] buildNativeOrderByteArray(long value) { + return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).putLong(value).array(); + } +} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/decoder/DecoderInputBufferTest.java b/library/common/src/test/java/com/google/android/exoplayer2/decoder/DecoderInputBufferTest.java new file mode 100644 index 0000000000..58e2db93dc --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/decoder/DecoderInputBufferTest.java @@ -0,0 +1,87 @@ +/* + * 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.decoder; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link DecoderInputBuffer} */ +@RunWith(AndroidJUnit4.class) +public class DecoderInputBufferTest { + + @Test + public void ensureSpaceForWrite_replacementModeDisabled_doesNothingIfResizeNotNeeded() { + DecoderInputBuffer buffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); + ByteBuffer data = ByteBuffer.allocate(32); + buffer.data = data; + buffer.ensureSpaceForWrite(32); + assertThat(buffer.data).isSameInstanceAs(data); + } + + @Test + public void ensureSpaceForWrite_replacementModeDisabled_failsIfResizeNeeded() { + DecoderInputBuffer buffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); + buffer.data = ByteBuffer.allocate(16); + assertThrows(IllegalStateException.class, () -> buffer.ensureSpaceForWrite(32)); + } + + @Test + public void ensureSpaceForWrite_usesPaddingSize() { + DecoderInputBuffer buffer = + new DecoderInputBuffer( + DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL, /* paddingSize= */ 16); + buffer.data = ByteBuffer.allocate(32); + buffer.ensureSpaceForWrite(32); + assertThat(buffer.data.capacity()).isEqualTo(32 + 16); + } + + @Test + public void ensureSpaceForWrite_usesPosition() { + DecoderInputBuffer buffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + buffer.data = ByteBuffer.wrap(new byte[] {0, 1, 2, 3, 4, 5, 6, 7}); + buffer.data.position(4); + buffer.ensureSpaceForWrite(12); + // The new capacity should be the current position (4) + the required space (12). + assertThat(buffer.data.capacity()).isEqualTo(4 + 12); + // The current position should have been retained. + assertThat(buffer.data.position()).isEqualTo(4); + // Data should have been copied up to the current position. + byte[] expectedData = Arrays.copyOf(new byte[] {0, 1, 2, 3}, 16); + assertThat(buffer.data.array()).isEqualTo(expectedData); + } + + @Test + public void ensureSpaceForWrite_copiesByteOrder() { + DecoderInputBuffer buffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + buffer.data = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); + buffer.ensureSpaceForWrite(16); + assertThat(buffer.data.order()).isEqualTo(ByteOrder.LITTLE_ENDIAN); + buffer.data = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); + buffer.ensureSpaceForWrite(16); + assertThat(buffer.data.order()).isEqualTo(ByteOrder.BIG_ENDIAN); + } +} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/drm/DrmInitDataTest.java b/library/common/src/test/java/com/google/android/exoplayer2/drm/DrmInitDataTest.java index e7b46e5c99..f196641332 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/drm/DrmInitDataTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/drm/DrmInitDataTest.java @@ -25,9 +25,9 @@ import android.os.Parcel; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; -import com.google.android.exoplayer2.testutil.TestUtil; import java.util.ArrayList; import java.util.List; +import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; @@ -35,16 +35,16 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class DrmInitDataTest { - private static final SchemeData DATA_1 = new SchemeData(WIDEVINE_UUID, VIDEO_MP4, - TestUtil.buildTestData(128, 1 /* data seed */)); - private static final SchemeData DATA_2 = new SchemeData(PLAYREADY_UUID, VIDEO_MP4, - TestUtil.buildTestData(128, 2 /* data seed */)); - private static final SchemeData DATA_1B = new SchemeData(WIDEVINE_UUID, VIDEO_MP4, - TestUtil.buildTestData(128, 1 /* data seed */)); - private static final SchemeData DATA_2B = new SchemeData(PLAYREADY_UUID, VIDEO_MP4, - TestUtil.buildTestData(128, 2 /* data seed */)); - private static final SchemeData DATA_UNIVERSAL = new SchemeData(C.UUID_NIL, VIDEO_MP4, - TestUtil.buildTestData(128, 3 /* data seed */)); + private static final SchemeData DATA_1 = + new SchemeData(WIDEVINE_UUID, VIDEO_MP4, buildTestData(128, 1 /* data seed */)); + private static final SchemeData DATA_2 = + new SchemeData(PLAYREADY_UUID, VIDEO_MP4, buildTestData(128, 2 /* data seed */)); + private static final SchemeData DATA_1B = + new SchemeData(WIDEVINE_UUID, VIDEO_MP4, buildTestData(128, 1 /* data seed */)); + private static final SchemeData DATA_2B = + new SchemeData(PLAYREADY_UUID, VIDEO_MP4, buildTestData(128, 2 /* data seed */)); + private static final SchemeData DATA_UNIVERSAL = + new SchemeData(C.UUID_NIL, VIDEO_MP4, buildTestData(128, 3 /* data seed */)); @Test public void parcelable() { @@ -162,4 +162,11 @@ public class DrmInitDataTest { return schemeDatas; } + /** Generates an array of random bytes with the specified length. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] buildTestData(int length, int seed) { + byte[] source = new byte[length]; + new Random(seed).nextBytes(source); + return source; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/SimpleMetadataDecoderTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/SimpleMetadataDecoderTest.java new file mode 100644 index 0000000000..be969fc031 --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/SimpleMetadataDecoderTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.metadata; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import java.nio.ByteBuffer; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link SimpleMetadataDecoder}. */ +@RunWith(AndroidJUnit4.class) +public class SimpleMetadataDecoderTest { + + @Test + public void decode_nullDataInputBuffer_throwsNullPointerException() { + TestSimpleMetadataDecoder decoder = new TestSimpleMetadataDecoder(); + MetadataInputBuffer nullDataInputBuffer = new MetadataInputBuffer(); + nullDataInputBuffer.data = null; + + assertThrows(NullPointerException.class, () -> decoder.decode(nullDataInputBuffer)); + assertThat(decoder.decodeWasCalled).isFalse(); + } + + @Test + public void decode_directDataInputBuffer_throwsIllegalArgumentException() { + TestSimpleMetadataDecoder decoder = new TestSimpleMetadataDecoder(); + MetadataInputBuffer directDataInputBuffer = new MetadataInputBuffer(); + directDataInputBuffer.data = ByteBuffer.allocateDirect(8); + + assertThrows(IllegalArgumentException.class, () -> decoder.decode(directDataInputBuffer)); + assertThat(decoder.decodeWasCalled).isFalse(); + } + + @Test + public void decode_nonZeroPositionDataInputBuffer_throwsIllegalArgumentException() { + TestSimpleMetadataDecoder decoder = new TestSimpleMetadataDecoder(); + MetadataInputBuffer nonZeroPositionDataInputBuffer = new MetadataInputBuffer(); + nonZeroPositionDataInputBuffer.data = ByteBuffer.wrap(new byte[8]); + nonZeroPositionDataInputBuffer.data.position(1); + + assertThrows( + IllegalArgumentException.class, () -> decoder.decode(nonZeroPositionDataInputBuffer)); + assertThat(decoder.decodeWasCalled).isFalse(); + } + + @Test + public void decode_nonZeroOffsetDataInputBuffer_throwsIllegalArgumentException() { + TestSimpleMetadataDecoder decoder = new TestSimpleMetadataDecoder(); + MetadataInputBuffer directDataInputBuffer = new MetadataInputBuffer(); + directDataInputBuffer.data = ByteBuffer.wrap(new byte[8], /* offset= */ 4, /* length= */ 4); + + assertThrows(IllegalArgumentException.class, () -> decoder.decode(directDataInputBuffer)); + assertThat(decoder.decodeWasCalled).isFalse(); + } + + @Test + public void decode_decodeOnlyBuffer_notPassedToDecodeInternal() { + TestSimpleMetadataDecoder decoder = new TestSimpleMetadataDecoder(); + MetadataInputBuffer decodeOnlyBuffer = new MetadataInputBuffer(); + decodeOnlyBuffer.data = ByteBuffer.wrap(new byte[8]); + decodeOnlyBuffer.setFlags(C.BUFFER_FLAG_DECODE_ONLY); + + assertThat(decoder.decode(decodeOnlyBuffer)).isNull(); + assertThat(decoder.decodeWasCalled).isFalse(); + } + + @Test + public void decode_returnsDecodeInternalResult() { + TestSimpleMetadataDecoder decoder = new TestSimpleMetadataDecoder(); + MetadataInputBuffer buffer = new MetadataInputBuffer(); + buffer.data = ByteBuffer.wrap(new byte[8]); + + assertThat(decoder.decode(buffer)).isSameInstanceAs(decoder.result); + assertThat(decoder.decodeWasCalled).isTrue(); + } + + private static final class TestSimpleMetadataDecoder extends SimpleMetadataDecoder { + + public final Metadata result; + + public boolean decodeWasCalled; + + public TestSimpleMetadataDecoder() { + result = new Metadata(); + } + + @Nullable + @Override + protected Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer) { + decodeWasCalled = true; + return result; + } + } +} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java index ee2c55a735..ed06eb0aff 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoderTest.java @@ -15,15 +15,15 @@ */ package com.google.android.exoplayer2.metadata.emsg; -import static com.google.android.exoplayer2.testutil.TestUtil.createByteArray; -import static com.google.android.exoplayer2.testutil.TestUtil.createMetadataInputBuffer; -import static com.google.android.exoplayer2.testutil.TestUtil.joinByteArrays; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import com.google.android.exoplayer2.util.Assertions; +import com.google.common.primitives.Bytes; +import java.nio.ByteBuffer; import org.junit.Test; import org.junit.runner.RunWith; @@ -34,7 +34,7 @@ public final class EventMessageDecoderTest { @Test public void decodeEventMessage() { byte[] rawEmsgBody = - joinByteArrays( + Bytes.concat( createByteArray(117, 114, 110, 58, 116, 101, 115, 116, 0), // scheme_id_uri = "urn:test" createByteArray(49, 50, 51, 0), // value = "123" createByteArray(0, 0, 11, 184), // event_duration_ms = 3000 @@ -80,4 +80,27 @@ public final class EventMessageDecoderTest { assertThrows(IllegalArgumentException.class, () -> decoder.decode(buffer)); } + + /** Converts an array of integers in the range [0, 255] into an equivalent byte array. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; + for (int i = 0; i < byteArray.length; i++) { + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; + } + return byteArray; + } + + /** + * Create a new {@link MetadataInputBuffer} and copy {@code data} into the backing {@link + * ByteBuffer}. + */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static MetadataInputBuffer createMetadataInputBuffer(byte[] data) { + MetadataInputBuffer buffer = new MetadataInputBuffer(); + buffer.data = ByteBuffer.allocate(data.length).put(data); + buffer.data.flip(); + return buffer; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java index fc73b0cdaf..c6d2231eb2 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoderTest.java @@ -15,15 +15,15 @@ */ package com.google.android.exoplayer2.metadata.emsg; -import static com.google.android.exoplayer2.testutil.TestUtil.createByteArray; -import static com.google.android.exoplayer2.testutil.TestUtil.createMetadataInputBuffer; -import static com.google.android.exoplayer2.testutil.TestUtil.joinByteArrays; import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import com.google.android.exoplayer2.util.Assertions; +import com.google.common.primitives.Bytes; import java.io.IOException; +import java.nio.ByteBuffer; import org.junit.Test; import org.junit.runner.RunWith; @@ -35,7 +35,7 @@ public final class EventMessageEncoderTest { new EventMessage("urn:test", "123", 3000, 1000403, new byte[] {0, 1, 2, 3, 4}); private static final byte[] ENCODED_MESSAGE = - joinByteArrays( + Bytes.concat( createByteArray(117, 114, 110, 58, 116, 101, 115, 116, 0), // scheme_id_uri = "urn:test" createByteArray(49, 50, 51, 0), // value = "123" createByteArray(0, 0, 11, 184), // event_duration_ms = 3000 @@ -64,7 +64,7 @@ public final class EventMessageEncoderTest { EventMessage eventMessage1 = new EventMessage("urn:test", "123", 3000, 1000402, new byte[] {4, 3, 2, 1, 0}); byte[] expectedEmsgBody1 = - joinByteArrays( + Bytes.concat( createByteArray(117, 114, 110, 58, 116, 101, 115, 116, 0), // scheme_id_uri = "urn:test" createByteArray(49, 50, 51, 0), // value = "123" createByteArray(0, 0, 11, 184), // event_duration_ms = 3000 @@ -78,4 +78,26 @@ public final class EventMessageEncoderTest { assertThat(encodedByteArray1).isEqualTo(expectedEmsgBody1); } + /** Converts an array of integers in the range [0, 255] into an equivalent byte array. */ + // TODO(internal b/161804035): Move to a single file. + private static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; + for (int i = 0; i < byteArray.length; i++) { + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; + } + return byteArray; + } + + /** + * Create a new {@link MetadataInputBuffer} and copy {@code data} into the backing {@link + * ByteBuffer}. + */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static MetadataInputBuffer createMetadataInputBuffer(byte[] data) { + MetadataInputBuffer buffer = new MetadataInputBuffer(); + buffer.data = ByteBuffer.allocate(data.length).put(data); + buffer.data.flip(); + return buffer; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java b/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java index 6389417464..972e855a5b 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/metadata/id3/Id3DecoderTest.java @@ -15,17 +15,15 @@ */ package com.google.android.exoplayer2.metadata.id3; -import static com.google.android.exoplayer2.testutil.TestUtil.createByteArray; -import static com.google.android.exoplayer2.testutil.TestUtil.createMetadataInputBuffer; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; import com.google.android.exoplayer2.util.Assertions; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; +import java.nio.ByteBuffer; import java.util.Arrays; import org.junit.Test; import org.junit.runner.RunWith; @@ -287,7 +285,7 @@ public final class Id3DecoderTest { for (FrameSpec frame : frames) { byte[] frameData = frame.frameData; String frameId = frame.frameId; - byte[] frameIdBytes = frameId.getBytes(Charset.forName(C.UTF8_NAME)); + byte[] frameIdBytes = frameId.getBytes(Charsets.UTF_8); Assertions.checkState(frameIdBytes.length == 4); // Fill in the frame header. @@ -318,4 +316,27 @@ public final class Id3DecoderTest { this.frameData = frameData; } } + + /** Converts an array of integers in the range [0, 255] into an equivalent byte array. */ + // TODO(internal b/161804035): Move to a single file. + private static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; + for (int i = 0; i < byteArray.length; i++) { + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; + } + return byteArray; + } + + /** + * Create a new {@link MetadataInputBuffer} and copy {@code data} into the backing {@link + * ByteBuffer}. + */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static MetadataInputBuffer createMetadataInputBuffer(byte[] data) { + MetadataInputBuffer buffer = new MetadataInputBuffer(); + buffer.data = ByteBuffer.allocate(data.length).put(data); + buffer.data.flip(); + return buffer; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/upstream/DataSourceExceptionTest.java b/library/common/src/test/java/com/google/android/exoplayer2/upstream/DataSourceExceptionTest.java new file mode 100644 index 0000000000..59a4939a68 --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/upstream/DataSourceExceptionTest.java @@ -0,0 +1,56 @@ +/* + * 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.upstream; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link DataSourceException}. */ +@RunWith(AndroidJUnit4.class) +public class DataSourceExceptionTest { + + private static final int REASON_OTHER = DataSourceException.POSITION_OUT_OF_RANGE - 1; + + @Test + public void isCausedByPositionOutOfRange_reasonIsPositionOutOfRange_returnsTrue() { + DataSourceException e = new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE); + assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isTrue(); + } + + @Test + public void isCausedByPositionOutOfRange_reasonIsOther_returnsFalse() { + DataSourceException e = new DataSourceException(REASON_OTHER); + assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isFalse(); + } + + @Test + public void isCausedByPositionOutOfRange_indirectauseReasonIsPositionOutOfRange_returnsTrue() { + DataSourceException cause = new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE); + IOException e = new IOException(new IOException(cause)); + assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isTrue(); + } + + @Test + public void isCausedByPositionOutOfRange_causeReasonIsOther_returnsFalse() { + DataSourceException cause = new DataSourceException(REASON_OTHER); + IOException e = new IOException(new IOException(cause)); + assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isFalse(); + } +} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java new file mode 100644 index 0000000000..aee23f9c17 --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/FileTypesTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.android.exoplayer2.util.FileTypes.HEADER_CONTENT_TYPE; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromMimeType; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromUri; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link FileTypesTest}. */ +@RunWith(AndroidJUnit4.class) +public class FileTypesTest { + + @Test + public void inferFileFormat_fromResponseHeaders_returnsExpectedFormat() { + Map> responseHeaders = new HashMap<>(); + responseHeaders.put(HEADER_CONTENT_TYPE, Collections.singletonList(MimeTypes.VIDEO_MP4)); + + assertThat(FileTypes.inferFileTypeFromResponseHeaders(responseHeaders)) + .isEqualTo(FileTypes.MP4); + } + + @Test + public void inferFileFormat_fromResponseHeadersWithUnknownContentType_returnsUnknownFormat() { + Map> responseHeaders = new HashMap<>(); + responseHeaders.put(HEADER_CONTENT_TYPE, Collections.singletonList("unknown")); + + assertThat(FileTypes.inferFileTypeFromResponseHeaders(responseHeaders)) + .isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromResponseHeadersWithoutContentType_returnsUnknownFormat() { + assertThat(FileTypes.inferFileTypeFromResponseHeaders(new HashMap<>())) + .isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromMimeType_returnsExpectedFormat() { + assertThat(FileTypes.inferFileTypeFromMimeType("audio/x-flac")).isEqualTo(FileTypes.FLAC); + } + + @Test + public void inferFileFormat_fromUnknownMimeType_returnsUnknownFormat() { + assertThat(inferFileTypeFromMimeType(/* mimeType= */ "unknown")).isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromNullMimeType_returnsUnknownFormat() { + assertThat(inferFileTypeFromMimeType(/* mimeType= */ null)).isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromUri_returnsExpectedFormat() { + assertThat( + inferFileTypeFromUri( + Uri.parse("http://www.example.com/filename.mp3?query=myquery#fragment"))) + .isEqualTo(FileTypes.MP3); + } + + @Test + public void inferFileFormat_fromUriWithExtensionPrefix_returnsExpectedFormat() { + assertThat(inferFileTypeFromUri(Uri.parse("filename.mka"))).isEqualTo(FileTypes.MATROSKA); + } + + @Test + public void inferFileFormat_fromUriWithUnknownExtension_returnsUnknownFormat() { + assertThat(inferFileTypeFromUri(Uri.parse("filename.unknown"))).isEqualTo(FileTypes.UNKNOWN); + } + + @Test + public void inferFileFormat_fromEmptyUri_returnsUnknownFormat() { + assertThat(inferFileTypeFromUri(Uri.EMPTY)).isEqualTo(FileTypes.UNKNOWN); + } +} diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java index 34ad0a5946..46202a5991 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.util; +import static android.media.MediaCodecInfo.CodecProfileLevel.AACObjectHE; +import static android.media.MediaCodecInfo.CodecProfileLevel.AACObjectXHE; import static com.google.common.truth.Truth.assertThat; import androidx.annotation.Nullable; @@ -171,4 +173,17 @@ public final class MimeTypesTest { assertThat(objectType.objectTypeIndication).isEqualTo(expectedObjectTypeIndicator); assertThat(objectType.audioObjectTypeIndication).isEqualTo(expectedAudioObjectTypeIndicator); } + + @Test + public void allSamplesAreSyncSamples_forAac_usesCodec() { + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40." + AACObjectHE)) + .isTrue(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40." + AACObjectXHE)) + .isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40")).isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40.")).isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "invalid")).isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, /* codec= */ null)) + .isFalse(); + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java index 365cff8aff..fe081a99db 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/NalUnitUtilTest.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.util; -import static com.google.android.exoplayer2.testutil.TestUtil.createByteArray; import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -40,19 +39,19 @@ public final class NalUnitUtilTest { byte[] data = buildTestData(); // Should find NAL unit. - int result = NalUnitUtil.findNalUnit(data, 0, data.length, null); + int result = NalUnitUtil.findNalUnit(data, 0, data.length, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION); // Should find NAL unit whose prefix ends one byte before the limit. - result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 4, null); + result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 4, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION); // Shouldn't find NAL unit whose prefix ends at the limit (since the limit is exclusive). - result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 3, null); + result = NalUnitUtil.findNalUnit(data, 0, TEST_NAL_POSITION + 3, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION + 3); // Should find NAL unit whose prefix starts at the offset. - result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION, data.length, null); + result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION, data.length, new boolean[3]); assertThat(result).isEqualTo(TEST_NAL_POSITION); // Shouldn't find NAL unit whose prefix starts one byte past the offset. - result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION + 1, data.length, null); + result = NalUnitUtil.findNalUnit(data, TEST_NAL_POSITION + 1, data.length, new boolean[3]); assertThat(result).isEqualTo(data.length); } @@ -210,4 +209,14 @@ public final class NalUnitUtilTest { assertThat(Arrays.copyOf(buffer.array(), buffer.position())).isEqualTo(expectedOutputBitstream); } + /** Converts an array of integers in the range [0, 255] into an equivalent byte array. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; + for (int i = 0; i < byteArray.length; i++) { + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; + } + return byteArray; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java index 9a2d17cbfc..deab13880b 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableBitArrayTest.java @@ -19,9 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.testutil.TestUtil; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; import org.junit.Test; import org.junit.runner.RunWith; @@ -31,7 +29,7 @@ public final class ParsableBitArrayTest { @Test public void readAllBytes() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F); ParsableBitArray testArray = new ParsableBitArray(testData); byte[] bytesRead = new byte[testData.length]; @@ -44,7 +42,7 @@ public final class ParsableBitArrayTest { @Test public void readBitInSameByte() { - byte[] testData = TestUtil.createByteArray(0, 0b00110000); + byte[] testData = createByteArray(0, 0b00110000); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.setPosition(10); @@ -56,7 +54,7 @@ public final class ParsableBitArrayTest { @Test public void readBitInMultipleBytes() { - byte[] testData = TestUtil.createByteArray(1, 1 << 7); + byte[] testData = createByteArray(1, 1 << 7); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.setPosition(6); @@ -68,7 +66,7 @@ public final class ParsableBitArrayTest { @Test public void readBits0Bits() { - byte[] testData = TestUtil.createByteArray(0x3C); + byte[] testData = createByteArray(0x3C); ParsableBitArray testArray = new ParsableBitArray(testData); int result = testArray.readBits(0); @@ -78,7 +76,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.readBits(8); @@ -90,7 +88,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsNonByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.readBits(3); @@ -102,7 +100,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsNegativeValue() { - byte[] testData = TestUtil.createByteArray(0xF0, 0, 0, 0); + byte[] testData = createByteArray(0xF0, 0, 0, 0); ParsableBitArray testArray = new ParsableBitArray(testData); int result = testArray.readBits(32); @@ -112,7 +110,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsToLong0Bits() { - byte[] testData = TestUtil.createByteArray(0x3C); + byte[] testData = createByteArray(0x3C); ParsableBitArray testArray = new ParsableBitArray(testData); long result = testArray.readBitsToLong(0); @@ -122,7 +120,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsToLongByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01, 0xFF, 0x14, 0x60); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01, 0xFF, 0x14, 0x60); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.readBits(8); @@ -134,7 +132,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsToLongNonByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01, 0xFF, 0x14, 0x60); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01, 0xFF, 0x14, 0x60); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.readBits(3); @@ -146,7 +144,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsToLongNegativeValue() { - byte[] testData = TestUtil.createByteArray(0xF0, 0, 0, 0, 0, 0, 0, 0); + byte[] testData = createByteArray(0xF0, 0, 0, 0, 0, 0, 0, 0); ParsableBitArray testArray = new ParsableBitArray(testData); long result = testArray.readBitsToLong(64); @@ -156,7 +154,7 @@ public final class ParsableBitArrayTest { @Test public void readBitsToByteArray() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01, 0xFF, 0x14, 0x60, 0x99); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01, 0xFF, 0x14, 0x60, 0x99); ParsableBitArray testArray = new ParsableBitArray(testData); int numBytes = testData.length; @@ -205,7 +203,7 @@ public final class ParsableBitArrayTest { @Test public void skipBytes() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBytes(2); @@ -215,7 +213,7 @@ public final class ParsableBitArrayTest { @Test public void skipBitsByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBits(16); @@ -225,7 +223,7 @@ public final class ParsableBitArrayTest { @Test public void skipBitsNonByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBits(5); @@ -235,7 +233,7 @@ public final class ParsableBitArrayTest { @Test public void setPositionByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.setPosition(16); @@ -245,7 +243,7 @@ public final class ParsableBitArrayTest { @Test public void setPositionNonByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.setPosition(5); @@ -255,7 +253,7 @@ public final class ParsableBitArrayTest { @Test public void byteAlignFromNonByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.setPosition(11); @@ -268,7 +266,7 @@ public final class ParsableBitArrayTest { @Test public void byteAlignFromByteAligned() { - byte[] testData = TestUtil.createByteArray(0x3C, 0xD2, 0x5F, 0x01); + byte[] testData = createByteArray(0x3C, 0xD2, 0x5F, 0x01); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.setPosition(16); @@ -281,7 +279,7 @@ public final class ParsableBitArrayTest { @Test public void readBytesAsStringDefaultsToUtf8() { - byte[] testData = "a non-åscii strìng".getBytes(Charset.forName(C.UTF8_NAME)); + byte[] testData = "a non-åscii strìng".getBytes(Charsets.UTF_8); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBytes(2); @@ -290,18 +288,18 @@ public final class ParsableBitArrayTest { @Test public void readBytesAsStringExplicitCharset() { - byte[] testData = "a non-åscii strìng".getBytes(Charset.forName(C.UTF16_NAME)); + byte[] testData = "a non-åscii strìng".getBytes(Charsets.UTF_16); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBytes(6); - assertThat(testArray.readBytesAsString(testData.length - 6, Charset.forName(C.UTF16_NAME))) + assertThat(testArray.readBytesAsString(testData.length - 6, Charsets.UTF_16)) .isEqualTo("non-åscii strìng"); } @Test public void readBytesNotByteAligned() { String testString = "test string"; - byte[] testData = testString.getBytes(Charset.forName(C.UTF8_NAME)); + byte[] testData = testString.getBytes(Charsets.UTF_8); ParsableBitArray testArray = new ParsableBitArray(testData); testArray.skipBit(); @@ -364,8 +362,7 @@ public final class ParsableBitArrayTest { @Test public void noOverwriting() { - ParsableBitArray output = - new ParsableBitArray(TestUtil.createByteArray(0xFF, 0xFF, 0xFF, 0xFF, 0xFF)); + ParsableBitArray output = new ParsableBitArray(createByteArray(0xFF, 0xFF, 0xFF, 0xFF, 0xFF)); output.setPosition(1); output.putInt(0, 30); @@ -374,4 +371,14 @@ public final class ParsableBitArrayTest { assertThat(output.readBits(32)).isEqualTo(0x80000001); } + /** Converts an array of integers in the range [0, 255] into an equivalent byte array. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; + for (int i = 0; i < byteArray.length; i++) { + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; + } + return byteArray; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java index 894de47e6e..919f50fdc5 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableByteArrayTest.java @@ -34,7 +34,7 @@ public final class ParsableByteArrayTest { private static ParsableByteArray getTestDataArray() { ParsableByteArray testArray = new ParsableByteArray(TEST_DATA.length); - System.arraycopy(TEST_DATA, 0, testArray.data, 0, TEST_DATA.length); + System.arraycopy(TEST_DATA, 0, testArray.getData(), 0, TEST_DATA.length); return testArray; } @@ -246,7 +246,7 @@ public final class ParsableByteArrayTest { ParsableByteArray parsableByteArray = getTestDataArray(); // When modifying the wrapped byte array - byte[] data = parsableByteArray.data; + byte[] data = parsableByteArray.getData(); long readValue = parsableByteArray.readUnsignedInt(); data[0] = (byte) (TEST_DATA[0] + 1); parsableByteArray.setPosition(0); @@ -259,7 +259,7 @@ public final class ParsableByteArrayTest { ParsableByteArray parsableByteArray = getTestDataArray(); // Given an array with the most-significant bit set on the top byte - byte[] data = parsableByteArray.data; + byte[] data = parsableByteArray.getData(); data[0] = (byte) 0x80; // Then reading an unsigned long throws. try { @@ -291,7 +291,7 @@ public final class ParsableByteArrayTest { byte[] copy = new byte[length]; parsableByteArray.readBytes(copy, 0, length); // Then the array elements are the same. - assertThat(copy).isEqualTo(parsableByteArray.data); + assertThat(copy).isEqualTo(parsableByteArray.getData()); } @Test diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableNalUnitBitArrayTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableNalUnitBitArrayTest.java index 8fffb9a5d4..3e7b2e9558 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableNalUnitBitArrayTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/ParsableNalUnitBitArrayTest.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.util; -import static com.google.android.exoplayer2.testutil.TestUtil.createByteArray; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; @@ -121,4 +120,14 @@ public final class ParsableNalUnitBitArrayTest { assertThat(array.canReadBits(25)).isFalse(); } + /** Converts an array of integers in the range [0, 255] into an equivalent byte array. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; + for (int i = 0; i < byteArray.length; i++) { + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; + } + return byteArray; + } } diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java index 2e523a32c6..162dcbae9d 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java @@ -24,9 +24,15 @@ import static com.google.android.exoplayer2.util.Util.parseXsDuration; import static com.google.android.exoplayer2.util.Util.unescapeFileName; import static com.google.common.truth.Truth.assertThat; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.StrikethroughSpan; +import android.text.style.UnderlineSpan; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.testutil.TestUtil; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; @@ -84,15 +90,77 @@ public class UtilTest { } @Test - public void inferContentType_returnsInferredResult() { + public void inferContentType_handlesHlsIsmUris() { + assertThat(Util.inferContentType("http://a.b/c.ism/manifest(format=m3u8-aapl)")) + .isEqualTo(C.TYPE_HLS); + assertThat(Util.inferContentType("http://a.b/c.ism/manifest(format=m3u8-aapl,quality=hd)")) + .isEqualTo(C.TYPE_HLS); + assertThat(Util.inferContentType("http://a.b/c.ism/manifest(quality=hd,format=m3u8-aapl)")) + .isEqualTo(C.TYPE_HLS); + } + + @Test + public void inferContentType_handlesHlsIsmV3Uris() { + assertThat(Util.inferContentType("http://a.b/c.ism/manifest(format=m3u8-aapl-v3)")) + .isEqualTo(C.TYPE_HLS); + assertThat(Util.inferContentType("http://a.b/c.ism/manifest(format=m3u8-aapl-v3,quality=hd)")) + .isEqualTo(C.TYPE_HLS); + assertThat(Util.inferContentType("http://a.b/c.ism/manifest(quality=hd,format=m3u8-aapl-v3)")) + .isEqualTo(C.TYPE_HLS); + } + + @Test + public void inferContentType_handlesDashIsmUris() { + assertThat(Util.inferContentType("http://a.b/c.isml/manifest(format=mpd-time-csf)")) + .isEqualTo(C.TYPE_DASH); + assertThat(Util.inferContentType("http://a.b/c.isml/manifest(format=mpd-time-csf,quality=hd)")) + .isEqualTo(C.TYPE_DASH); + assertThat(Util.inferContentType("http://a.b/c.isml/manifest(quality=hd,format=mpd-time-csf)")) + .isEqualTo(C.TYPE_DASH); + } + + @Test + public void inferContentType_handlesSmoothStreamingIsmUris() { assertThat(Util.inferContentType("http://a.b/c.ism")).isEqualTo(C.TYPE_SS); assertThat(Util.inferContentType("http://a.b/c.isml")).isEqualTo(C.TYPE_SS); + assertThat(Util.inferContentType("http://a.b/c.ism/")).isEqualTo(C.TYPE_SS); + assertThat(Util.inferContentType("http://a.b/c.isml/")).isEqualTo(C.TYPE_SS); assertThat(Util.inferContentType("http://a.b/c.ism/Manifest")).isEqualTo(C.TYPE_SS); assertThat(Util.inferContentType("http://a.b/c.isml/manifest")).isEqualTo(C.TYPE_SS); assertThat(Util.inferContentType("http://a.b/c.isml/manifest(filter=x)")).isEqualTo(C.TYPE_SS); + assertThat(Util.inferContentType("http://a.b/c.isml/manifest_hd")).isEqualTo(C.TYPE_SS); + } + @Test + public void inferContentType_handlesOtherIsmUris() { + assertThat(Util.inferContentType("http://a.b/c.ism/video.mp4")).isEqualTo(C.TYPE_OTHER); assertThat(Util.inferContentType("http://a.b/c.ism/prefix-manifest")).isEqualTo(C.TYPE_OTHER); - assertThat(Util.inferContentType("http://a.b/c.ism/manifest-suffix")).isEqualTo(C.TYPE_OTHER); + } + + @Test + public void fixSmoothStreamingIsmManifestUri_addsManifestSuffix() { + assertThat(Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.ism"))) + .isEqualTo(Uri.parse("http://a.b/c.ism/Manifest")); + assertThat(Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.isml"))) + .isEqualTo(Uri.parse("http://a.b/c.isml/Manifest")); + + assertThat(Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.ism/"))) + .isEqualTo(Uri.parse("http://a.b/c.ism/Manifest")); + assertThat(Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.isml/"))) + .isEqualTo(Uri.parse("http://a.b/c.isml/Manifest")); + } + + @Test + public void fixSmoothStreamingIsmManifestUri_doesNotAlterManifestUri() { + assertThat(Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.ism/Manifest"))) + .isEqualTo(Uri.parse("http://a.b/c.ism/Manifest")); + assertThat(Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.isml/Manifest"))) + .isEqualTo(Uri.parse("http://a.b/c.isml/Manifest")); + assertThat( + Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.ism/Manifest(filter=x)"))) + .isEqualTo(Uri.parse("http://a.b/c.ism/Manifest(filter=x)")); + assertThat(Util.fixSmoothStreamingIsmManifestUri(Uri.parse("http://a.b/c.ism/Manifest_hd"))) + .isEqualTo(Uri.parse("http://a.b/c.ism/Manifest_hd")); } @Test @@ -713,9 +781,48 @@ public class UtilTest { assertThat(Util.toLong(0xFEDCBA, 0x87654321)).isEqualTo(0xFEDCBA_87654321L); } + @Test + public void truncateAscii_shortInput_returnsInput() { + String input = "a short string"; + + assertThat(Util.truncateAscii(input, 100)).isSameInstanceAs(input); + } + + @Test + public void truncateAscii_longInput_truncated() { + String input = "a much longer string"; + + assertThat(Util.truncateAscii(input, 5).toString()).isEqualTo("a muc"); + } + + @Test + public void truncateAscii_preservesStylingSpans() { + SpannableString input = new SpannableString("a short string"); + input.setSpan(new UnderlineSpan(), 0, 10, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + input.setSpan(new StrikethroughSpan(), 4, 10, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + CharSequence result = Util.truncateAscii(input, 7); + + assertThat(result).isInstanceOf(SpannableString.class); + assertThat(result.toString()).isEqualTo("a short"); + // TODO(internal b/161804035): Use SpannedSubject when it's available in a dependency we can use + // from here. + Spanned spannedResult = (Spanned) result; + Object[] spans = spannedResult.getSpans(0, result.length(), Object.class); + assertThat(spans).hasLength(2); + assertThat(spans[0]).isInstanceOf(UnderlineSpan.class); + assertThat(spannedResult.getSpanStart(spans[0])).isEqualTo(0); + assertThat(spannedResult.getSpanEnd(spans[0])).isEqualTo(7); + assertThat(spannedResult.getSpanFlags(spans[0])).isEqualTo(Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + assertThat(spans[1]).isInstanceOf(StrikethroughSpan.class); + assertThat(spannedResult.getSpanStart(spans[1])).isEqualTo(4); + assertThat(spannedResult.getSpanEnd(spans[1])).isEqualTo(7); + assertThat(spannedResult.getSpanFlags(spans[1])).isEqualTo(Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + @Test public void toHexString_returnsHexString() { - byte[] bytes = TestUtil.createByteArray(0x12, 0xFC, 0x06); + byte[] bytes = createByteArray(0x12, 0xFC, 0x06); assertThat(Util.toHexString(bytes)).isEqualTo("12fc06"); } @@ -762,7 +869,7 @@ public class UtilTest { Random random = new Random(0); for (int i = 0; i < 1000; i++) { - String string = TestUtil.buildTestString(1000, random); + String string = buildTestString(1000, random); assertEscapeUnescapeFileName(string); } } @@ -817,7 +924,7 @@ public class UtilTest { @Test public void inflate_withDeflatedData_success() { - byte[] testData = TestUtil.buildTestData(/*arbitrary test data size*/ 256 * 1024); + byte[] testData = buildTestData(/*arbitrary test data size*/ 256 * 1024); byte[] compressedData = new byte[testData.length * 2]; Deflater compresser = new Deflater(9); compresser.setInput(testData); @@ -829,7 +936,7 @@ public class UtilTest { ParsableByteArray output = new ParsableByteArray(); assertThat(Util.inflate(input, output, /* inflater= */ null)).isTrue(); assertThat(output.limit()).isEqualTo(testData.length); - assertThat(Arrays.copyOf(output.data, output.limit())).isEqualTo(testData); + assertThat(Arrays.copyOf(output.getData(), output.limit())).isEqualTo(testData); } // TODO: Revert to @Config(sdk = Config.ALL_SDKS) once b/143232359 is resolved @@ -903,7 +1010,7 @@ public class UtilTest { assertThat(Util.normalizeLanguageCode("ji")).isEqualTo(Util.normalizeLanguageCode("yi")); assertThat(Util.normalizeLanguageCode("ji")).isEqualTo(Util.normalizeLanguageCode("yid")); - // Grandfathered tags + // Legacy tags assertThat(Util.normalizeLanguageCode("i-lux")).isEqualTo(Util.normalizeLanguageCode("lb")); assertThat(Util.normalizeLanguageCode("i-lux")).isEqualTo(Util.normalizeLanguageCode("ltz")); assertThat(Util.normalizeLanguageCode("i-hak")).isEqualTo(Util.normalizeLanguageCode("hak")); @@ -961,18 +1068,18 @@ public class UtilTest { } @Test - public void toList() { - assertThat(Util.toList(0, 3, 4)).containsExactly(0, 3, 4).inOrder(); + public void tableExists_withExistingTable() { + SQLiteDatabase database = getInMemorySQLiteOpenHelper().getWritableDatabase(); + database.execSQL("CREATE TABLE TestTable (ID INTEGER NOT NULL)"); + + assertThat(Util.tableExists(database, "TestTable")).isTrue(); } @Test - public void toList_nullPassed_returnsEmptyList() { - assertThat(Util.toList(null)).isEmpty(); - } + public void tableExists_withNonExistingTable() { + SQLiteDatabase database = getInMemorySQLiteOpenHelper().getReadableDatabase(); - @Test - public void toList_emptyArrayPassed_returnsEmptyList() { - assertThat(Util.toList(new int[0])).isEmpty(); + assertThat(Util.tableExists(database, "table")).isFalse(); } private static void assertEscapeUnescapeFileName(String fileName, String escapedFileName) { @@ -992,4 +1099,51 @@ public class UtilTest { } return longArray; } + + /** Returns a {@link SQLiteOpenHelper} that provides an in-memory database. */ + private static SQLiteOpenHelper getInMemorySQLiteOpenHelper() { + return new SQLiteOpenHelper( + /* context= */ null, /* name= */ null, /* factory= */ null, /* version= */ 1) { + @Override + public void onCreate(SQLiteDatabase db) {} + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {} + }; + } + + /** Generates an array of random bytes with the specified length. */ + private static byte[] buildTestData(int length, int seed) { + byte[] source = new byte[length]; + new Random(seed).nextBytes(source); + return source; + } + + /** Equivalent to {@code buildTestData(length, length)}. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] buildTestData(int length) { + return buildTestData(length, length); + } + + /** Generates a random string with the specified maximum length. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static String buildTestString(int maximumLength, Random random) { + int length = random.nextInt(maximumLength); + StringBuilder builder = new StringBuilder(length); + for (int i = 0; i < length; i++) { + builder.append((char) random.nextInt()); + } + return builder.toString(); + } + + /** Converts an array of integers in the range [0, 255] into an equivalent byte array. */ + // TODO(internal b/161804035): Use TestUtils when it's available in a dependency we can use here. + private static byte[] createByteArray(int... bytes) { + byte[] byteArray = new byte[bytes.length]; + for (int i = 0; i < byteArray.length; i++) { + Assertions.checkState(0x00 <= bytes[i] && bytes[i] <= 0xFF); + byteArray[i] = (byte) bytes[i]; + } + return byteArray; + } } diff --git a/library/core/build.gradle b/library/core/build.gradle index 8b8c3fd520..ddeb734947 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -11,24 +11,10 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply plugin: 'com.android.library' -apply from: '../../constants.gradle' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - consumerProguardFiles 'proguard-rules.txt' - - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - // The following argument makes the Android Test Orchestrator run its // "pm clear" command after each test invocation. This command ensures // that the app's state is completely cleared between tests. @@ -45,26 +31,44 @@ android { androidTest.assets.srcDir '../../testdata/src/test/assets/' test.assets.srcDir '../../testdata/src/test/assets/' } - - testOptions.unitTests.includeAndroidResources = true } dependencies { api project(modulePrefix + 'library-common') api project(modulePrefix + 'library-extractor') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'com.google.code.findbugs:jsr305:' + jsr305Version compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion - androidTestImplementation 'com.google.guava:guava:' + guavaVersion + androidTestImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } androidTestImplementation 'com.linkedin.dexmaker:dexmaker:' + dexmakerVersion androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:' + dexmakerVersion androidTestImplementation(project(modulePrefix + 'testutils')) { exclude module: modulePrefix.substring(1) + 'library-core' } - testImplementation 'com.google.guava:guava:' + guavaVersion + testImplementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } + testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion testImplementation project(modulePrefix + 'testutils') } diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index cbeb74cf6c..67c33679cd 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -31,15 +31,15 @@ } -dontnote com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer { - (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioProcessor[]); + (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioSink); } -dontnote com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer { - (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioProcessor[]); + (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioSink); } -dontnote com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer -keepclassmembers class com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer { - (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioProcessor[]); + (android.os.Handler, com.google.android.exoplayer2.audio.AudioRendererEventListener, com.google.android.exoplayer2.audio.AudioSink); } # Constructors accessed via reflection in DefaultDataSource @@ -51,18 +51,18 @@ # Constructors accessed via reflection in DefaultDownloaderFactory -dontnote com.google.android.exoplayer2.source.dash.offline.DashDownloader -keepclassmembers class com.google.android.exoplayer2.source.dash.offline.DashDownloader { - (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor); + (com.google.android.exoplayer2.MediaItem, com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory, java.util.concurrent.Executor); } -dontnote com.google.android.exoplayer2.source.hls.offline.HlsDownloader -keepclassmembers class com.google.android.exoplayer2.source.hls.offline.HlsDownloader { - (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor); + (com.google.android.exoplayer2.MediaItem, com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory, java.util.concurrent.Executor); } -dontnote com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader -keepclassmembers class com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader { - (android.net.Uri, java.util.List, com.google.android.exoplayer2.upstream.cache.CacheDataSource.Factory, java.util.concurrent.Executor); + (com.google.android.exoplayer2.MediaItem, com.google.android.exoplayer2.upstream.cache.CacheDataSource$Factory, java.util.concurrent.Executor); } -# Constructors accessed via reflection in DefaultMediaSourceFactory and DownloadHelper +# Constructors accessed via reflection in DefaultMediaSourceFactory -dontnote com.google.android.exoplayer2.source.dash.DashMediaSource$Factory -keepclasseswithmembers class com.google.android.exoplayer2.source.dash.DashMediaSource$Factory { (com.google.android.exoplayer2.upstream.DataSource$Factory); @@ -75,8 +75,3 @@ -keepclasseswithmembers class com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory { (com.google.android.exoplayer2.upstream.DataSource$Factory); } - -# Don't warn about checkerframework and Kotlin annotations --dontwarn org.checkerframework.** --dontwarn kotlin.annotations.jvm.** --dontwarn javax.annotation.** diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/upstream/ContentDataSourceTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/upstream/ContentDataSourceTest.java index 39c12e1b75..22442ca85f 100644 --- a/library/core/src/androidTest/java/com/google/android/exoplayer2/upstream/ContentDataSourceTest.java +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/upstream/ContentDataSourceTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.upstream; import static com.google.common.truth.Truth.assertThat; import static junit.framework.Assert.fail; +import static org.junit.Assert.assertThrows; import android.content.ContentProvider; import android.content.ContentResolver; @@ -27,10 +28,12 @@ import android.net.Uri; import android.os.Bundle; import android.os.ParcelFileDescriptor; import androidx.annotation.Nullable; -import androidx.test.InstrumentationRegistry; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.ContentDataSource.ContentDataSourceException; +import java.io.EOFException; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; @@ -43,7 +46,7 @@ import org.junit.runner.RunWith; public final class ContentDataSourceTest { private static final String AUTHORITY = "com.google.android.exoplayer2.core.test"; - private static final String DATA_PATH = "mp3/1024_incrementing_bytes.mp3"; + private static final String DATA_PATH = "media/mp3/1024_incrementing_bytes.mp3"; @Test public void read() throws Exception { @@ -78,7 +81,7 @@ public final class ContentDataSourceTest { @Test public void readInvalidUri() throws Exception { ContentDataSource dataSource = - new ContentDataSource(InstrumentationRegistry.getTargetContext()); + new ContentDataSource(ApplicationProvider.getApplicationContext()); Uri contentUri = TestContentProvider.buildUri("does/not.exist", false); DataSpec dataSpec = new DataSpec(contentUri); try { @@ -92,14 +95,44 @@ public final class ContentDataSourceTest { } } + @Test + public void read_positionPastEndOfContent_throwsEOFException() throws Exception { + Uri contentUri = TestContentProvider.buildUri(DATA_PATH, /* pipeMode= */ false); + ContentDataSource dataSource = + new ContentDataSource(ApplicationProvider.getApplicationContext()); + DataSpec dataSpec = new DataSpec(contentUri, /* position= */ 1025, C.LENGTH_UNSET); + try { + ContentDataSourceException exception = + assertThrows(ContentDataSourceException.class, () -> dataSource.open(dataSpec)); + assertThat(exception).hasCauseThat().isInstanceOf(EOFException.class); + } finally { + dataSource.close(); + } + } + + @Test + public void readPipeMode_positionPastEndOfContent_throwsEOFException() throws Exception { + Uri contentUri = TestContentProvider.buildUri(DATA_PATH, /* pipeMode= */ true); + ContentDataSource dataSource = + new ContentDataSource(ApplicationProvider.getApplicationContext()); + DataSpec dataSpec = new DataSpec(contentUri, /* position= */ 1025, C.LENGTH_UNSET); + try { + ContentDataSourceException exception = + assertThrows(ContentDataSourceException.class, () -> dataSource.open(dataSpec)); + assertThat(exception).hasCauseThat().isInstanceOf(EOFException.class); + } finally { + dataSource.close(); + } + } + private static void assertData(int offset, int length, boolean pipeMode) throws IOException { Uri contentUri = TestContentProvider.buildUri(DATA_PATH, pipeMode); ContentDataSource dataSource = - new ContentDataSource(InstrumentationRegistry.getTargetContext()); + new ContentDataSource(ApplicationProvider.getApplicationContext()); try { DataSpec dataSpec = new DataSpec(contentUri, offset, length); byte[] completeData = - TestUtil.getByteArray(InstrumentationRegistry.getTargetContext(), DATA_PATH); + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), DATA_PATH); byte[] expectedData = Arrays.copyOfRange(completeData, offset, length == C.LENGTH_UNSET ? completeData.length : offset + length); TestUtil.assertDataSourceContent(dataSource, dataSpec, expectedData, !pipeMode); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java b/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java index 5aeca440ff..b56e8838c5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/AudioFocusManager.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.content.Context; import android.media.AudioFocusRequest; import android.media.AudioManager; @@ -116,7 +118,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public AudioFocusManager(Context context, Handler eventHandler, PlayerControl playerControl) { this.audioManager = - (AudioManager) context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + checkNotNull( + (AudioManager) context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE)); this.playerControl = playerControl; this.focusListener = new AudioFocusListener(eventHandler); this.audioFocusState = AUDIO_FOCUS_STATE_NO_FOCUS; @@ -212,7 +215,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private int requestAudioFocusDefault() { return audioManager.requestAudioFocus( focusListener, - Util.getStreamTypeForAudioUsage(Assertions.checkNotNull(audioAttributes).usage), + Util.getStreamTypeForAudioUsage(checkNotNull(audioAttributes).usage), focusGain); } @@ -227,7 +230,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; boolean willPauseWhenDucked = willPauseWhenDucked(); audioFocusRequest = builder - .setAudioAttributes(Assertions.checkNotNull(audioAttributes).getAudioAttributesV21()) + .setAudioAttributes(checkNotNull(audioAttributes).getAudioAttributesV21()) .setWillPauseWhenDucked(willPauseWhenDucked) .setOnAudioFocusChangeListener(focusListener) .build(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java index 5692b1dae7..9d7af2dce6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java @@ -158,11 +158,41 @@ public abstract class BasePlayer implements Player { getCurrentWindowIndex(), getRepeatModeForNavigation(), getShuffleModeEnabled()); } + /** + * @deprecated Use {@link #getCurrentMediaItem()} and {@link MediaItem.PlaybackProperties#tag} + * instead. + */ + @Deprecated @Override @Nullable public final Object getCurrentTag() { Timeline timeline = getCurrentTimeline(); - return timeline.isEmpty() ? null : timeline.getWindow(getCurrentWindowIndex(), window).tag; + if (timeline.isEmpty()) { + return null; + } + @Nullable + MediaItem.PlaybackProperties playbackProperties = + timeline.getWindow(getCurrentWindowIndex(), window).mediaItem.playbackProperties; + return playbackProperties != null ? playbackProperties.tag : null; + } + + @Override + @Nullable + public final MediaItem getCurrentMediaItem() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() + ? null + : timeline.getWindow(getCurrentWindowIndex(), window).mediaItem; + } + + @Override + public int getMediaItemCount() { + return getCurrentTimeline().getWindowCount(); + } + + @Override + public MediaItem getMediaItemAt(int index) { + return getCurrentTimeline().getWindow(index, window).mediaItem; } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java index fc2cbbce28..351f6c50f2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import static java.lang.Math.max; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.source.SampleStream; @@ -30,12 +32,13 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { private final int trackType; private final FormatHolder formatHolder; - private RendererConfiguration configuration; + @Nullable private RendererConfiguration configuration; private int index; private int state; - private SampleStream stream; - private Format[] streamFormats; + @Nullable private SampleStream stream; + @Nullable private Format[] streamFormats; private long streamOffsetUs; + private long lastResetPositionUs; private long readingPositionUs; private boolean streamIsFinal; private boolean throwRendererExceptionIsExecuting; @@ -84,13 +87,15 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { long positionUs, boolean joining, boolean mayRenderStartOfStream, + long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); this.configuration = configuration; state = STATE_ENABLED; + lastResetPositionUs = positionUs; onEnabled(joining, mayRenderStartOfStream); - replaceStream(formats, stream, offsetUs); + replaceStream(formats, stream, startPositionUs, offsetUs); onPositionReset(positionUs, joining); } @@ -102,14 +107,15 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { } @Override - public final void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + public final void replaceStream( + Format[] formats, SampleStream stream, long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(!streamIsFinal); this.stream = stream; readingPositionUs = offsetUs; streamFormats = formats; streamOffsetUs = offsetUs; - onStreamChanged(formats, offsetUs); + onStreamChanged(formats, startPositionUs, offsetUs); } @Override @@ -140,18 +146,19 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { @Override public final void maybeThrowStreamError() throws IOException { - stream.maybeThrowError(); + Assertions.checkNotNull(stream).maybeThrowError(); } @Override public final void resetPosition(long positionUs) throws ExoPlaybackException { streamIsFinal = false; + lastResetPositionUs = positionUs; readingPositionUs = positionUs; onPositionReset(positionUs, false); } @Override - public final void stop() throws ExoPlaybackException { + public final void stop() { Assertions.checkState(state == STATE_STARTED); state = STATE_ENABLED; onStopped(); @@ -215,24 +222,26 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { *

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

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

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

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

The default implementation is a no-op. * * @param positionUs The new playback position in microseconds. * @param joining Whether this renderer is being enabled to join an ongoing playback. @@ -255,12 +264,10 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { /** * Called when the renderer is stopped. - *

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

The default implementation is a no-op. */ - protected void onStopped() throws ExoPlaybackException { + protected void onStopped() { // Do nothing. } @@ -284,22 +291,38 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { // Methods to be called by subclasses. + /** + * Returns the position passed to the most recent call to {@link #enable} or {@link + * #resetPosition}. + */ + protected final long getLastResetPositionUs() { + return lastResetPositionUs; + } + /** Returns a clear {@link FormatHolder}. */ protected final FormatHolder getFormatHolder() { formatHolder.clear(); return formatHolder; } - /** Returns the formats of the currently enabled stream. */ + /** + * Returns the formats of the currently enabled stream. + * + *

This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. + */ protected final Format[] getStreamFormats() { - return streamFormats; + return Assertions.checkNotNull(streamFormats); } /** * Returns the configuration set when the renderer was most recently enabled. + * + *

This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. */ protected final RendererConfiguration getConfiguration() { - return configuration; + return Assertions.checkNotNull(configuration); } /** @@ -339,6 +362,9 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { * {@link C#RESULT_BUFFER_READ} is only returned if {@link #setCurrentStreamFinal()} has been * called. {@link C#RESULT_NOTHING_READ} is returned otherwise. * + *

This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. + * * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the * end of the stream. If the end of the stream has been reached, the {@link @@ -351,16 +377,17 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { @SampleStream.ReadDataResult protected final int readSource( FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { - @SampleStream.ReadDataResult int result = stream.readData(formatHolder, buffer, formatRequired); + @SampleStream.ReadDataResult + int result = Assertions.checkNotNull(stream).readData(formatHolder, buffer, formatRequired); if (result == C.RESULT_BUFFER_READ) { if (buffer.isEndOfStream()) { readingPositionUs = C.TIME_END_OF_SOURCE; return streamIsFinal ? C.RESULT_BUFFER_READ : C.RESULT_NOTHING_READ; } buffer.timeUs += streamOffsetUs; - readingPositionUs = Math.max(readingPositionUs, buffer.timeUs); + readingPositionUs = max(readingPositionUs, buffer.timeUs); } else if (result == C.RESULT_FORMAT_READ) { - Format format = formatHolder.format; + Format format = Assertions.checkNotNull(formatHolder.format); if (format.subsampleOffsetUs != Format.OFFSET_SAMPLE_RELATIVE) { format = format @@ -377,17 +404,23 @@ public abstract class BaseRenderer implements Renderer, RendererCapabilities { * Attempts to skip to the keyframe before the specified position, or to the end of the stream if * {@code positionUs} is beyond it. * + *

This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. + * * @param positionUs The position in microseconds. * @return The number of samples that were skipped. */ protected int skipSource(long positionUs) { - return stream.skipData(positionUs - streamOffsetUs); + return Assertions.checkNotNull(stream).skipData(positionUs - streamOffsetUs); } /** * Returns whether the upstream source is ready. + * + *

This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. */ protected final boolean isSourceReady() { - return hasReadStreamToEnd() ? streamIsFinal : stream.isReady(); + return hasReadStreamToEnd() ? streamIsFinal : Assertions.checkNotNull(stream).isReady(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java index 7f24e6113f..d46b939c1f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultControlDispatcher.java @@ -15,11 +15,14 @@ */ package com.google.android.exoplayer2; +import static java.lang.Math.max; +import static java.lang.Math.min; + /** Default {@link ControlDispatcher}. */ public class DefaultControlDispatcher implements ControlDispatcher { /** The default fast forward increment, in milliseconds. */ - public static final int DEFAULT_FAST_FORWARD_MS = 15000; + public static final int DEFAULT_FAST_FORWARD_MS = 15_000; /** The default rewind increment, in milliseconds. */ public static final int DEFAULT_REWIND_MS = 5000; @@ -174,9 +177,9 @@ public class DefaultControlDispatcher implements ControlDispatcher { long positionMs = player.getCurrentPosition() + offsetMs; long durationMs = player.getDuration(); if (durationMs != C.TIME_UNSET) { - positionMs = Math.min(positionMs, durationMs); + positionMs = min(positionMs, durationMs); } - positionMs = Math.max(positionMs, 0); + positionMs = max(positionMs, 0); player.seekTo(player.getCurrentWindowIndex(), positionMs); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java index 5eb14021a3..2b72fc6c09 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultLoadControl.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocator; @@ -32,12 +36,12 @@ public class DefaultLoadControl implements LoadControl { * The default minimum duration of media that the player will attempt to ensure is buffered at all * times, in milliseconds. */ - public static final int DEFAULT_MIN_BUFFER_MS = 50000; + public static final int DEFAULT_MIN_BUFFER_MS = 50_000; /** * The default maximum duration of media that the player will attempt to buffer, in milliseconds. */ - public static final int DEFAULT_MAX_BUFFER_MS = 50000; + public static final int DEFAULT_MAX_BUFFER_MS = 50_000; /** * The default duration of media that must be buffered for playback to start or resume following a @@ -94,7 +98,7 @@ public class DefaultLoadControl implements LoadControl { /** Builder for {@link DefaultLoadControl}. */ public static final class Builder { - private DefaultAllocator allocator; + @Nullable private DefaultAllocator allocator; private int minBufferMs; private int maxBufferMs; private int bufferForPlaybackMs; @@ -103,7 +107,7 @@ public class DefaultLoadControl implements LoadControl { private boolean prioritizeTimeOverSizeThresholds; private int backBufferDurationMs; private boolean retainBackBufferFromKeyframe; - private boolean createDefaultLoadControlCalled; + private boolean buildCalled; /** Constructs a new instance. */ public Builder() { @@ -122,10 +126,10 @@ public class DefaultLoadControl implements LoadControl { * * @param allocator The {@link DefaultAllocator}. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setAllocator(DefaultAllocator allocator) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); this.allocator = allocator; return this; } @@ -143,14 +147,14 @@ public class DefaultLoadControl implements LoadControl { * for playback to resume after a rebuffer, in milliseconds. A rebuffer is defined to be * caused by buffer depletion rather than a user action. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setBufferDurationsMs( int minBufferMs, int maxBufferMs, int bufferForPlaybackMs, int bufferForPlaybackAfterRebufferMs) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0"); assertGreaterOrEqual( bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0"); @@ -174,10 +178,10 @@ public class DefaultLoadControl implements LoadControl { * * @param targetBufferBytes The target buffer size in bytes. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setTargetBufferBytes(int targetBufferBytes) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); this.targetBufferBytes = targetBufferBytes; return this; } @@ -189,10 +193,10 @@ public class DefaultLoadControl implements LoadControl { * @param prioritizeTimeOverSizeThresholds Whether the load control prioritizes buffer time * constraints over buffer size constraints. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setPrioritizeTimeOverSizeThresholds(boolean prioritizeTimeOverSizeThresholds) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; return this; } @@ -205,20 +209,26 @@ public class DefaultLoadControl implements LoadControl { * @param retainBackBufferFromKeyframe Whether the back buffer is retained from the previous * keyframe. * @return This builder, for convenience. - * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + * @throws IllegalStateException If {@link #build()} has already been called. */ public Builder setBackBuffer(int backBufferDurationMs, boolean retainBackBufferFromKeyframe) { - Assertions.checkState(!createDefaultLoadControlCalled); + Assertions.checkState(!buildCalled); assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0"); this.backBufferDurationMs = backBufferDurationMs; this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe; return this; } - /** Creates a {@link DefaultLoadControl}. */ + /** @deprecated use {@link #build} instead. */ + @Deprecated public DefaultLoadControl createDefaultLoadControl() { - Assertions.checkState(!createDefaultLoadControlCalled); - createDefaultLoadControlCalled = true; + return build(); + } + + /** Creates a {@link DefaultLoadControl}. */ + public DefaultLoadControl build() { + Assertions.checkState(!buildCalled); + buildCalled = true; if (allocator == null) { allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE); } @@ -379,10 +389,10 @@ public class DefaultLoadControl implements LoadControl { // duration to keep enough media buffered for a playout duration of minBufferUs. long mediaDurationMinBufferUs = Util.getMediaDurationForPlayoutDuration(minBufferUs, playbackSpeed); - minBufferUs = Math.min(mediaDurationMinBufferUs, maxBufferUs); + minBufferUs = min(mediaDurationMinBufferUs, maxBufferUs); } // Prevent playback from getting stuck if minBufferUs is too small. - minBufferUs = Math.max(minBufferUs, 500_000); + minBufferUs = max(minBufferUs, 500_000); if (bufferedDurationUs < minBufferUs) { isBuffering = prioritizeTimeOverSizeThresholds || !targetBufferSizeReached; if (!isBuffering && bufferedDurationUs < 500_000) { @@ -423,7 +433,7 @@ public class DefaultLoadControl implements LoadControl { targetBufferSize += getDefaultBufferSize(renderers[i].getTrackType()); } } - return Math.max(DEFAULT_MIN_BUFFER_SIZE, targetBufferSize); + return max(DEFAULT_MIN_BUFFER_SIZE, targetBufferSize); } private void reset(boolean resetAllocator) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultMediaClock.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultMediaClock.java index 5700964967..9ee1846fc1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultMediaClock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultMediaClock.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.MediaClock; import com.google.android.exoplayer2.util.StandaloneMediaClock; @@ -26,20 +27,20 @@ import com.google.android.exoplayer2.util.StandaloneMediaClock; */ /* package */ final class DefaultMediaClock implements MediaClock { - /** Listener interface to be notified of changes to the active playback speed. */ - public interface PlaybackSpeedListener { + /** Listener interface to be notified of changes to the active playback parameters. */ + public interface PlaybackParametersListener { /** - * Called when the active playback speed changed. Will not be called for {@link - * #setPlaybackSpeed(float)}. + * Called when the active playback parameters changed. Will not be called for {@link + * #setPlaybackParameters(PlaybackParameters)}. * - * @param newPlaybackSpeed The newly active playback speed. + * @param newPlaybackParameters The newly active playback parameters. */ - void onPlaybackSpeedChanged(float newPlaybackSpeed); + void onPlaybackParametersChanged(PlaybackParameters newPlaybackParameters); } private final StandaloneMediaClock standaloneClock; - private final PlaybackSpeedListener listener; + private final PlaybackParametersListener listener; @Nullable private Renderer rendererClockSource; @Nullable private MediaClock rendererClock; @@ -47,13 +48,13 @@ import com.google.android.exoplayer2.util.StandaloneMediaClock; private boolean standaloneClockIsStarted; /** - * Creates a new instance with listener for playback speed changes and a {@link Clock} to use for - * the standalone clock implementation. + * Creates a new instance with a listener for playback parameters changes and a {@link Clock} to + * use for the standalone clock implementation. * - * @param listener A {@link PlaybackSpeedListener} to listen for playback speed changes. + * @param listener A {@link PlaybackParametersListener} to listen for playback parameters changes. * @param clock A {@link Clock}. */ - public DefaultMediaClock(PlaybackSpeedListener listener, Clock clock) { + public DefaultMediaClock(PlaybackParametersListener listener, Clock clock) { this.listener = listener; this.standaloneClock = new StandaloneMediaClock(clock); isUsingStandaloneClock = true; @@ -101,7 +102,7 @@ import com.google.android.exoplayer2.util.StandaloneMediaClock; } this.rendererClock = rendererMediaClock; this.rendererClockSource = renderer; - rendererClock.setPlaybackSpeed(standaloneClock.getPlaybackSpeed()); + rendererClock.setPlaybackParameters(standaloneClock.getPlaybackParameters()); } } @@ -133,23 +134,25 @@ import com.google.android.exoplayer2.util.StandaloneMediaClock; @Override public long getPositionUs() { - return isUsingStandaloneClock ? standaloneClock.getPositionUs() : rendererClock.getPositionUs(); + return isUsingStandaloneClock + ? standaloneClock.getPositionUs() + : Assertions.checkNotNull(rendererClock).getPositionUs(); } @Override - public void setPlaybackSpeed(float playbackSpeed) { + public void setPlaybackParameters(PlaybackParameters playbackParameters) { if (rendererClock != null) { - rendererClock.setPlaybackSpeed(playbackSpeed); - playbackSpeed = rendererClock.getPlaybackSpeed(); + rendererClock.setPlaybackParameters(playbackParameters); + playbackParameters = rendererClock.getPlaybackParameters(); } - standaloneClock.setPlaybackSpeed(playbackSpeed); + standaloneClock.setPlaybackParameters(playbackParameters); } @Override - public float getPlaybackSpeed() { + public PlaybackParameters getPlaybackParameters() { return rendererClock != null - ? rendererClock.getPlaybackSpeed() - : standaloneClock.getPlaybackSpeed(); + ? rendererClock.getPlaybackParameters() + : standaloneClock.getPlaybackParameters(); } private void syncClocks(boolean isReadingAhead) { @@ -160,6 +163,9 @@ import com.google.android.exoplayer2.util.StandaloneMediaClock; } return; } + // We are either already using the renderer clock or switching from the standalone to the + // renderer clock, so it must be non-null. + MediaClock rendererClock = Assertions.checkNotNull(this.rendererClock); long rendererClockPositionUs = rendererClock.getPositionUs(); if (isUsingStandaloneClock) { // Ensure enabling the renderer clock doesn't jump backwards in time. @@ -174,10 +180,10 @@ import com.google.android.exoplayer2.util.StandaloneMediaClock; } // Continuously sync stand-alone clock to renderer clock so that it can take over if needed. standaloneClock.resetPosition(rendererClockPositionUs); - float playbackSpeed = rendererClock.getPlaybackSpeed(); - if (playbackSpeed != standaloneClock.getPlaybackSpeed()) { - standaloneClock.setPlaybackSpeed(playbackSpeed); - listener.onPlaybackSpeedChanged(playbackSpeed); + PlaybackParameters playbackParameters = rendererClock.getPlaybackParameters(); + if (!playbackParameters.equals(standaloneClock.getPlaybackParameters())) { + standaloneClock.setPlaybackParameters(playbackParameters); + listener.onPlaybackParametersChanged(playbackParameters); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index a09f85d42f..3558a319ba 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -17,13 +17,16 @@ package com.google.android.exoplayer2; import android.content.Context; import android.media.MediaCodec; +import android.media.PlaybackParams; import android.os.Handler; import android.os.Looper; import androidx.annotation.IntDef; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.audio.AudioCapabilities; -import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.DefaultAudioSink; +import com.google.android.exoplayer2.audio.DefaultAudioSink.DefaultAudioProcessorChain; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; @@ -89,7 +92,11 @@ public class DefaultRenderersFactory implements RenderersFactory { private long allowedVideoJoiningTimeMs; private boolean enableDecoderFallback; private MediaCodecSelector mediaCodecSelector; - private @MediaCodecRenderer.MediaCodecOperationMode int mediaCodecOperationMode; + private @MediaCodecRenderer.MediaCodecOperationMode int audioMediaCodecOperationMode; + private @MediaCodecRenderer.MediaCodecOperationMode int videoMediaCodecOperationMode; + private boolean enableFloatOutput; + private boolean enableAudioTrackPlaybackParams; + private boolean enableOffload; /** @param context A {@link Context}. */ public DefaultRenderersFactory(Context context) { @@ -97,7 +104,8 @@ public class DefaultRenderersFactory implements RenderersFactory { extensionRendererMode = EXTENSION_RENDERER_MODE_OFF; allowedVideoJoiningTimeMs = DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; mediaCodecSelector = MediaCodecSelector.DEFAULT; - mediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; + audioMediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; + videoMediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; } /** @@ -143,7 +151,7 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} of {@link MediaCodecRenderer} + * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} of {@link MediaCodecAudioRenderer} * instances. * *

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

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

This method is experimental, and will be renamed or removed in a future release. + * + * @param mode The {@link MediaCodecRenderer.MediaCodecOperationMode} to set. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory experimentalSetMediaCodecOperationMode( + @MediaCodecRenderer.MediaCodecOperationMode int mode) { + experimentalSetAudioMediaCodecOperationMode(mode); + experimentalSetVideoMediaCodecOperationMode(mode); return this; } @@ -183,6 +222,68 @@ public class DefaultRenderersFactory implements RenderersFactory { return this; } + /** + * Sets whether floating point audio should be output when possible. + * + *

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

The default value is {@code false}. + * + * @param enableFloatOutput Whether to enable use of floating point audio output, if available. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setEnableAudioFloatOutput(boolean enableFloatOutput) { + this.enableFloatOutput = enableFloatOutput; + return this; + } + + /** + * Sets whether audio should be played using the offload path. + * + *

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

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

The default value is {@code false}. + * + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setEnableAudioOffload(boolean enableOffload) { + this.enableOffload = enableOffload; + return this; + } + + /** + * Sets whether to enable setting playback speed using {@link + * android.media.AudioTrack#setPlaybackParams(PlaybackParams)}, which is supported from API level + * 23, rather than using application-level audio speed adjustment. This setting has no effect on + * builds before API level 23 (application-level speed adjustment will be used in all cases). + * + *

If enabled and supported, new playback speed settings will take effect more quickly because + * they are applied at the audio mixer, rather than at the point of writing data to the track. + * + *

When using this mode, the maximum supported playback speed is limited by the size of the + * audio track's buffer. If the requested speed is not supported the player's event listener will + * be notified twice on setting playback speed, once with the requested speed, then again with the + * old playback speed reflecting the fact that the requested speed was not supported. + * + * @param enableAudioTrackPlaybackParams Whether to enable setting playback speed using {@link + * android.media.AudioTrack#setPlaybackParams(PlaybackParams)}. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setEnableAudioTrackPlaybackParams( + boolean enableAudioTrackPlaybackParams) { + this.enableAudioTrackPlaybackParams = enableAudioTrackPlaybackParams; + return this; + } + /** * Sets the maximum duration for which video renderers can attempt to seamlessly join an ongoing * playback. @@ -215,15 +316,20 @@ public class DefaultRenderersFactory implements RenderersFactory { videoRendererEventListener, allowedVideoJoiningTimeMs, renderersList); - buildAudioRenderers( - context, - extensionRendererMode, - mediaCodecSelector, - enableDecoderFallback, - buildAudioProcessors(), - eventHandler, - audioRendererEventListener, - renderersList); + @Nullable + AudioSink audioSink = + buildAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams, enableOffload); + if (audioSink != null) { + buildAudioRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + enableDecoderFallback, + audioSink, + eventHandler, + audioRendererEventListener, + renderersList); + } buildTextRenderers(context, textRendererOutput, eventHandler.getLooper(), extensionRendererMode, renderersList); buildMetadataRenderers(context, metadataRendererOutput, eventHandler.getLooper(), @@ -266,7 +372,7 @@ public class DefaultRenderersFactory implements RenderersFactory { eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); - videoRenderer.experimental_setMediaCodecOperationMode(mediaCodecOperationMode); + videoRenderer.experimentalSetMediaCodecOperationMode(videoMediaCodecOperationMode); out.add(videoRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { @@ -369,8 +475,7 @@ public class DefaultRenderersFactory implements RenderersFactory { * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder * initialization fails. This may result in using a decoder that is slower/less efficient than * the primary decoder. - * @param audioProcessors An array of {@link AudioProcessor}s that will process PCM audio buffers - * before output. May be empty. + * @param audioSink A sink to which the renderers will output. * @param eventHandler A handler to use when invoking event listeners and outputs. * @param eventListener An event listener. * @param out An array to which the built renderers should be appended. @@ -380,7 +485,7 @@ public class DefaultRenderersFactory implements RenderersFactory { @ExtensionRendererMode int extensionRendererMode, MediaCodecSelector mediaCodecSelector, boolean enableDecoderFallback, - AudioProcessor[] audioProcessors, + AudioSink audioSink, Handler eventHandler, AudioRendererEventListener eventListener, ArrayList out) { @@ -391,8 +496,8 @@ public class DefaultRenderersFactory implements RenderersFactory { enableDecoderFallback, eventHandler, eventListener, - new DefaultAudioSink(AudioCapabilities.getCapabilities(context), audioProcessors)); - audioRenderer.experimental_setMediaCodecOperationMode(mediaCodecOperationMode); + audioSink); + audioRenderer.experimentalSetMediaCodecOperationMode(audioMediaCodecOperationMode); out.add(audioRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { @@ -411,10 +516,10 @@ public class DefaultRenderersFactory implements RenderersFactory { clazz.getConstructor( android.os.Handler.class, com.google.android.exoplayer2.audio.AudioRendererEventListener.class, - com.google.android.exoplayer2.audio.AudioProcessor[].class); + com.google.android.exoplayer2.audio.AudioSink.class); // LINT.ThenChange(../../../../../../../proguard-rules.txt) Renderer renderer = - (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + (Renderer) constructor.newInstance(eventHandler, eventListener, audioSink); out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded LibopusAudioRenderer."); } catch (ClassNotFoundException e) { @@ -432,10 +537,10 @@ public class DefaultRenderersFactory implements RenderersFactory { clazz.getConstructor( android.os.Handler.class, com.google.android.exoplayer2.audio.AudioRendererEventListener.class, - com.google.android.exoplayer2.audio.AudioProcessor[].class); + com.google.android.exoplayer2.audio.AudioSink.class); // LINT.ThenChange(../../../../../../../proguard-rules.txt) Renderer renderer = - (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + (Renderer) constructor.newInstance(eventHandler, eventListener, audioSink); out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded LibflacAudioRenderer."); } catch (ClassNotFoundException e) { @@ -454,10 +559,10 @@ public class DefaultRenderersFactory implements RenderersFactory { clazz.getConstructor( android.os.Handler.class, com.google.android.exoplayer2.audio.AudioRendererEventListener.class, - com.google.android.exoplayer2.audio.AudioProcessor[].class); + com.google.android.exoplayer2.audio.AudioSink.class); // LINT.ThenChange(../../../../../../../proguard-rules.txt) Renderer renderer = - (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + (Renderer) constructor.newInstance(eventHandler, eventListener, audioSink); out.add(extensionRendererIndex++, renderer); Log.i(TAG, "Loaded FfmpegAudioRenderer."); } catch (ClassNotFoundException e) { @@ -530,10 +635,29 @@ public class DefaultRenderersFactory implements RenderersFactory { } /** - * Builds an array of {@link AudioProcessor}s that will process PCM audio before output. + * Builds an {@link AudioSink} to which the audio renderers will output. + * + * @param context The {@link Context} associated with the player. + * @param enableFloatOutput Whether to enable use of floating point audio output, if available. + * @param enableAudioTrackPlaybackParams Whether to enable setting playback speed using {@link + * android.media.AudioTrack#setPlaybackParams(PlaybackParams)}, if supported. + * @param enableOffload Whether to enable use of audio offload for supported formats, if + * available. + * @return The {@link AudioSink} to which the audio renderers will output. May be {@code null} if + * no audio renderers are required. If {@code null} is returned then {@link + * #buildAudioRenderers} will not be called. */ - protected AudioProcessor[] buildAudioProcessors() { - return new AudioProcessor[0]; + @Nullable + protected AudioSink buildAudioSink( + Context context, + boolean enableFloatOutput, + boolean enableAudioTrackPlaybackParams, + boolean enableOffload) { + return new DefaultAudioSink( + AudioCapabilities.getCapabilities(context), + new DefaultAudioProcessorChain(), + enableFloatOutput, + enableAudioTrackPlaybackParams, + enableOffload); } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java index cd9662a251..93fb4b0118 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2; import android.os.SystemClock; import android.text.TextUtils; +import androidx.annotation.CheckResult; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.RendererCapabilities.FormatSupport; @@ -26,6 +27,7 @@ import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.TimeoutException; /** * Thrown when a non-recoverable playback failure occurs. @@ -34,12 +36,20 @@ public final class ExoPlaybackException extends Exception { /** * The type of source that produced the error. One of {@link #TYPE_SOURCE}, {@link #TYPE_RENDERER} - * {@link #TYPE_UNEXPECTED}, {@link #TYPE_REMOTE} or {@link #TYPE_OUT_OF_MEMORY}. Note that new - * types may be added in the future and error handling should handle unknown type values. + * {@link #TYPE_UNEXPECTED}, {@link #TYPE_REMOTE}, {@link #TYPE_OUT_OF_MEMORY} or {@link + * #TYPE_TIMEOUT}. Note that new types may be added in the future and error handling should handle + * unknown type values. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({TYPE_SOURCE, TYPE_RENDERER, TYPE_UNEXPECTED, TYPE_REMOTE, TYPE_OUT_OF_MEMORY}) + @IntDef({ + TYPE_SOURCE, + TYPE_RENDERER, + TYPE_UNEXPECTED, + TYPE_REMOTE, + TYPE_OUT_OF_MEMORY, + TYPE_TIMEOUT + }) public @interface Type {} /** * The error occurred loading data from a {@link MediaSource}. @@ -67,10 +77,34 @@ public final class ExoPlaybackException extends Exception { public static final int TYPE_REMOTE = 3; /** The error was an {@link OutOfMemoryError}. */ public static final int TYPE_OUT_OF_MEMORY = 4; + /** The error was a {@link TimeoutException}. */ + public static final int TYPE_TIMEOUT = 5; /** The {@link Type} of the playback failure. */ @Type public final int type; + /** + * The operation which produced the timeout error. One of {@link #TIMEOUT_OPERATION_RELEASE}, + * {@link #TIMEOUT_OPERATION_SET_FOREGROUND_MODE} or {@link #TIMEOUT_OPERATION_UNDEFINED}. Note + * that new operations may be added in the future and error handling should handle unknown + * operation values. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + TIMEOUT_OPERATION_UNDEFINED, + TIMEOUT_OPERATION_RELEASE, + TIMEOUT_OPERATION_SET_FOREGROUND_MODE + }) + public @interface TimeoutOperation {} + + /** The operation where this error occurred is not defined. */ + public static final int TIMEOUT_OPERATION_UNDEFINED = 0; + /** The error occurred in {@link ExoPlayer#release}. */ + public static final int TIMEOUT_OPERATION_RELEASE = 1; + /** The error occurred in {@link ExoPlayer#setForegroundMode}. */ + public static final int TIMEOUT_OPERATION_SET_FOREGROUND_MODE = 2; + /** If {@link #type} is {@link #TYPE_RENDERER}, this is the name of the renderer. */ @Nullable public final String rendererName; @@ -90,9 +124,20 @@ public final class ExoPlaybackException extends Exception { */ @FormatSupport public final int rendererFormatSupport; + /** + * If {@link #type} is {@link #TYPE_TIMEOUT}, this is the operation where the timeout happened. + */ + @TimeoutOperation public final int timeoutOperation; + /** The value of {@link SystemClock#elapsedRealtime()} when this exception was created. */ public final long timestampMs; + /** + * The {@link MediaSource.MediaPeriodId} of the media associated with this error, or null if + * undetermined. + */ + @Nullable public final MediaSource.MediaPeriodId mediaPeriodId; + @Nullable private final Throwable cause; /** @@ -129,7 +174,8 @@ public final class ExoPlaybackException extends Exception { rendererName, rendererIndex, rendererFormat, - rendererFormat == null ? RendererCapabilities.FORMAT_HANDLED : rendererFormatSupport); + rendererFormat == null ? RendererCapabilities.FORMAT_HANDLED : rendererFormatSupport, + TIMEOUT_OPERATION_UNDEFINED); } /** @@ -158,10 +204,30 @@ public final class ExoPlaybackException extends Exception { * @param cause The cause of the failure. * @return The created instance. */ - public static ExoPlaybackException createForOutOfMemoryError(OutOfMemoryError cause) { + public static ExoPlaybackException createForOutOfMemory(OutOfMemoryError cause) { return new ExoPlaybackException(TYPE_OUT_OF_MEMORY, cause); } + /** + * Creates an instance of type {@link #TYPE_TIMEOUT}. + * + * @param cause The cause of the failure. + * @param timeoutOperation The operation that caused this timeout. + * @return The created instance. + */ + public static ExoPlaybackException createForTimeout( + TimeoutException cause, @TimeoutOperation int timeoutOperation) { + return new ExoPlaybackException( + TYPE_TIMEOUT, + cause, + /* customMessage= */ null, + /* rendererName= */ null, + /* rendererIndex= */ C.INDEX_UNSET, + /* rendererFormat= */ null, + /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, + timeoutOperation); + } + private ExoPlaybackException(@Type int type, Throwable cause) { this( type, @@ -170,7 +236,8 @@ public final class ExoPlaybackException extends Exception { /* rendererName= */ null, /* rendererIndex= */ C.INDEX_UNSET, /* rendererFormat= */ null, - /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED); + /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, + TIMEOUT_OPERATION_UNDEFINED); } private ExoPlaybackException(@Type int type, String message) { @@ -181,7 +248,8 @@ public final class ExoPlaybackException extends Exception { /* rendererName= */ null, /* rendererIndex= */ C.INDEX_UNSET, /* rendererFormat= */ null, - /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED); + /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, + /* timeoutOperation= */ TIMEOUT_OPERATION_UNDEFINED); } private ExoPlaybackException( @@ -191,8 +259,9 @@ public final class ExoPlaybackException extends Exception { @Nullable String rendererName, int rendererIndex, @Nullable Format rendererFormat, - @FormatSupport int rendererFormatSupport) { - super( + @FormatSupport int rendererFormatSupport, + @TimeoutOperation int timeoutOperation) { + this( deriveMessage( type, customMessage, @@ -200,14 +269,38 @@ public final class ExoPlaybackException extends Exception { rendererIndex, rendererFormat, rendererFormatSupport), - cause); + cause, + type, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + /* mediaPeriodId= */ null, + timeoutOperation, + /* timestampMs= */ SystemClock.elapsedRealtime()); + } + + private ExoPlaybackException( + @Nullable String message, + @Nullable Throwable cause, + @Type int type, + @Nullable String rendererName, + int rendererIndex, + @Nullable Format rendererFormat, + @FormatSupport int rendererFormatSupport, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + @TimeoutOperation int timeoutOperation, + long timestampMs) { + super(message, cause); this.type = type; this.cause = cause; this.rendererName = rendererName; this.rendererIndex = rendererIndex; this.rendererFormat = rendererFormat; this.rendererFormatSupport = rendererFormatSupport; - timestampMs = SystemClock.elapsedRealtime(); + this.mediaPeriodId = mediaPeriodId; + this.timeoutOperation = timeoutOperation; + this.timestampMs = timestampMs; } /** @@ -250,6 +343,38 @@ public final class ExoPlaybackException extends Exception { return (OutOfMemoryError) Assertions.checkNotNull(cause); } + /** + * Retrieves the underlying error when {@link #type} is {@link #TYPE_TIMEOUT}. + * + * @throws IllegalStateException If {@link #type} is not {@link #TYPE_TIMEOUT}. + */ + public TimeoutException getTimeoutException() { + Assertions.checkState(type == TYPE_TIMEOUT); + return (TimeoutException) Assertions.checkNotNull(cause); + } + + /** + * Returns a copy of this exception with the provided {@link MediaSource.MediaPeriodId}. + * + * @param mediaPeriodId The {@link MediaSource.MediaPeriodId}. + * @return The copied exception. + */ + @CheckResult + /* package= */ ExoPlaybackException copyWithMediaPeriodId( + @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + return new ExoPlaybackException( + getMessage(), + cause, + type, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + mediaPeriodId, + timeoutOperation, + timestampMs); + } + @Nullable private static String deriveMessage( @Type int type, @@ -280,6 +405,9 @@ public final class ExoPlaybackException extends Exception { case TYPE_OUT_OF_MEMORY: message = "Out of memory error"; break; + case TYPE_TIMEOUT: + message = "Timeout error"; + break; case TYPE_UNEXPECTED: default: message = "Unexpected runtime error"; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index d779037817..ccb67866a4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -16,10 +16,14 @@ package com.google.android.exoplayer2; import android.content.Context; +import android.media.AudioTrack; import android.os.Looper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.audio.AudioCapabilities; +import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.metadata.MetadataRenderer; import com.google.android.exoplayer2.source.ClippingMediaSource; @@ -59,7 +63,7 @@ import java.util.List; *

    *
  • A {@link MediaSource} that defines the media to be played, loads the media, and from * which the loaded media can be read. A MediaSource is injected via {@link - * #prepare(MediaSource)} at the start of playback. The library modules provide default + * #setMediaSource(MediaSource)} at the start of playback. The library modules provide default * implementations for progressive media files ({@link ProgressiveMediaSource}), DASH * (DashMediaSource), SmoothStreaming (SsMediaSource) and HLS (HlsMediaSource), an * implementation for loading single media samples ({@link SingleSampleMediaSource}) that's @@ -146,6 +150,8 @@ public interface ExoPlayer extends Player { private Looper looper; @Nullable private AnalyticsCollector analyticsCollector; private boolean useLazyPreparation; + private SeekParameters seekParameters; + private boolean pauseAtEndOfMediaItems; private boolean buildCalled; private long releaseTimeoutMs; @@ -166,6 +172,8 @@ public interface ExoPlayer extends Player { * Looper} *
  • {@link AnalyticsCollector}: {@link AnalyticsCollector} with {@link Clock#DEFAULT} *
  • {@code useLazyPreparation}: {@code true} + *
  • {@link SeekParameters}: {@link SeekParameters#DEFAULT} + *
  • {@code pauseAtEndOfMediaItems}: {@code false} *
  • {@link Clock}: {@link Clock#DEFAULT} *
* @@ -176,52 +184,40 @@ public interface ExoPlayer extends Player { this( renderers, new DefaultTrackSelector(context), - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), new DefaultLoadControl(), - DefaultBandwidthMeter.getSingletonInstance(context), - Util.getLooper(), - /* analyticsCollector= */ null, - /* useLazyPreparation= */ true, - Clock.DEFAULT); + DefaultBandwidthMeter.getSingletonInstance(context)); } /** * Creates a builder with the specified custom components. * - *

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

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

This means the player will pause at the end of each window in the current {@link + * #getCurrentTimeline() timeline}. Listeners will be informed by a call to {@link + * Player.EventListener#onPlayWhenReadyChanged(boolean, int)} with the reason {@link + * Player#PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM} when this happens. + * + * @param pauseAtEndOfMediaItems Whether to pause playback at the end of each media item. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setPauseAtEndOfMediaItems(boolean pauseAtEndOfMediaItems) { + Assertions.checkState(!buildCalled); + this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; + return this; + } + /** * Sets the {@link Clock} that will be used by the player. Should only be set for testing * purposes. @@ -379,14 +406,16 @@ public interface ExoPlayer extends Player { bandwidthMeter, analyticsCollector, useLazyPreparation, + seekParameters, + pauseAtEndOfMediaItems, clock, looper); if (releaseTimeoutMs > 0) { - player.experimental_setReleaseTimeoutMs(releaseTimeoutMs); + player.experimentalSetReleaseTimeoutMs(releaseTimeoutMs); } - if (throwWhenStuckBuffering) { - player.experimental_throwWhenStuckBuffering(); + if (!throwWhenStuckBuffering) { + player.experimentalDisableThrowWhenStuckBuffering(); } return player; @@ -573,4 +602,40 @@ public interface ExoPlayer extends Player { * @see #setPauseAtEndOfMediaItems(boolean) */ boolean getPauseAtEndOfMediaItems(); + + /** + * Sets whether audio offload scheduling is enabled. If enabled, ExoPlayer's main loop will as + * rarely as possible when playing an audio stream using audio offload. + * + *

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

While offload scheduling is enabled, player events may be delivered severely delayed and + * apps should not interact with the player. When returning to the foreground, disable offload + * scheduling and wait for {@link + * Player.EventListener#onExperimentalOffloadSchedulingEnabledChanged(boolean)} to be called with + * {@code offloadSchedulingEnabled = false} before interacting with the player. + * + *

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

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

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

This method is experimental, and will be renamed or removed in a future release. + * + * @param offloadSchedulingEnabled Whether to enable offload scheduling. + */ + void experimentalSetOffloadSchedulingEnabled(boolean offloadSchedulingEnabled); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index 32d00d90c1..dfe96ffa32 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -101,11 +101,7 @@ public final class ExoPlayerFactory { TrackSelector trackSelector, LoadControl loadControl) { return newSimpleInstance( - context, - renderersFactory, - trackSelector, - loadControl, - Util.getLooper()); + context, renderersFactory, trackSelector, loadControl, Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -124,7 +120,7 @@ public final class ExoPlayerFactory { loadControl, bandwidthMeter, new AnalyticsCollector(Clock.DEFAULT), - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -142,7 +138,7 @@ public final class ExoPlayerFactory { trackSelector, loadControl, analyticsCollector, - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -198,7 +194,7 @@ public final class ExoPlayerFactory { context, renderersFactory, trackSelector, - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), loadControl, bandwidthMeter, analyticsCollector, @@ -220,7 +216,8 @@ public final class ExoPlayerFactory { @SuppressWarnings("deprecation") public static ExoPlayer newInstance( Context context, Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl) { - return newInstance(context, renderers, trackSelector, loadControl, Util.getLooper()); + return newInstance( + context, renderers, trackSelector, loadControl, Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link ExoPlayer.Builder} instead. */ @@ -253,11 +250,13 @@ public final class ExoPlayerFactory { return new ExoPlayerImpl( renderers, trackSelector, - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), loadControl, bandwidthMeter, /* analyticsCollector= */ null, /* useLazyPreparation= */ true, + SeekParameters.DEFAULT, + /* pauseAtEndOfMediaItems= */ false, Clock.DEFAULT, applicationLooper); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 756a09dde3..b1f5736465 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -16,11 +16,14 @@ package com.google.android.exoplayer2; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; +import static java.lang.Math.min; import android.annotation.SuppressLint; import android.os.Handler; import android.os.Looper; -import android.os.Message; import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.PlayerMessage.Target; @@ -65,15 +68,19 @@ import java.util.concurrent.TimeoutException; private final Renderer[] renderers; private final TrackSelector trackSelector; - private final Handler applicationHandler; + private final Handler playbackInfoUpdateHandler; + private final ExoPlayerImplInternal.PlaybackInfoUpdateListener playbackInfoUpdateListener; private final ExoPlayerImplInternal internalPlayer; private final Handler internalPlayerHandler; private final CopyOnWriteArrayList listeners; private final Timeline.Period period; private final ArrayDeque pendingListenerNotifications; - private final List mediaSourceHolders; + private final List mediaSourceHolderSnapshots; private final boolean useLazyPreparation; private final MediaSourceFactory mediaSourceFactory; + @Nullable private final AnalyticsCollector analyticsCollector; + private final Looper applicationLooper; + private final BandwidthMeter bandwidthMeter; @RepeatMode private int repeatMode; private boolean shuffleModeEnabled; @@ -82,8 +89,6 @@ import java.util.concurrent.TimeoutException; @DiscontinuityReason private int pendingDiscontinuityReason; @PlayWhenReadyChangeReason private int pendingPlayWhenReadyChangeReason; private boolean foregroundMode; - private int pendingSetPlaybackSpeedAcks; - private float playbackSpeed; private SeekParameters seekParameters; private ShuffleOrder shuffleOrder; private boolean pauseAtEndOfMediaItems; @@ -109,6 +114,8 @@ import java.util.concurrent.TimeoutException; * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest * loads and other initial preparation steps happen immediately. If true, these initial * preparations are triggered only when the player starts buffering the media. + * @param seekParameters The {@link SeekParameters}. + * @param pauseAtEndOfMediaItems Whether to pause playback at the end of each media item. * @param clock The {@link Clock}. * @param applicationLooper The {@link Looper} that must be used for all calls to the player and * which is used to call listeners on. @@ -122,18 +129,25 @@ import java.util.concurrent.TimeoutException; BandwidthMeter bandwidthMeter, @Nullable AnalyticsCollector analyticsCollector, boolean useLazyPreparation, + SeekParameters seekParameters, + boolean pauseAtEndOfMediaItems, Clock clock, Looper applicationLooper) { Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "]"); - Assertions.checkState(renderers.length > 0); + checkState(renderers.length > 0); this.renderers = checkNotNull(renderers); this.trackSelector = checkNotNull(trackSelector); this.mediaSourceFactory = mediaSourceFactory; + this.bandwidthMeter = bandwidthMeter; + this.analyticsCollector = analyticsCollector; this.useLazyPreparation = useLazyPreparation; + this.seekParameters = seekParameters; + this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; + this.applicationLooper = applicationLooper; repeatMode = Player.REPEAT_MODE_OFF; listeners = new CopyOnWriteArrayList<>(); - mediaSourceHolders = new ArrayList<>(); + mediaSourceHolderSnapshots = new ArrayList<>(); shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0); emptyTrackSelectorResult = new TrackSelectorResult( @@ -141,20 +155,17 @@ import java.util.concurrent.TimeoutException; new TrackSelection[renderers.length], null); period = new Timeline.Period(); - playbackSpeed = Player.DEFAULT_PLAYBACK_SPEED; - seekParameters = SeekParameters.DEFAULT; maskingWindowIndex = C.INDEX_UNSET; - applicationHandler = - new Handler(applicationLooper) { - @Override - public void handleMessage(Message msg) { - ExoPlayerImpl.this.handleEvent(msg); - } - }; + playbackInfoUpdateHandler = new Handler(applicationLooper); + playbackInfoUpdateListener = + playbackInfoUpdate -> + playbackInfoUpdateHandler.post(() -> handlePlaybackInfo(playbackInfoUpdate)); playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult); pendingListenerNotifications = new ArrayDeque<>(); if (analyticsCollector != null) { analyticsCollector.setPlayer(this); + addListener(analyticsCollector); + bandwidthMeter.addEventListener(new Handler(applicationLooper), analyticsCollector); } internalPlayer = new ExoPlayerImplInternal( @@ -166,8 +177,11 @@ import java.util.concurrent.TimeoutException; repeatMode, shuffleModeEnabled, analyticsCollector, - applicationHandler, - clock); + seekParameters, + pauseAtEndOfMediaItems, + applicationLooper, + clock, + playbackInfoUpdateListener); internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); } @@ -181,18 +195,23 @@ import java.util.concurrent.TimeoutException; * * @param timeoutMs The time limit in milliseconds, or 0 for no limit. */ - public void experimental_setReleaseTimeoutMs(long timeoutMs) { - internalPlayer.experimental_setReleaseTimeoutMs(timeoutMs); + public void experimentalSetReleaseTimeoutMs(long timeoutMs) { + internalPlayer.experimentalSetReleaseTimeoutMs(timeoutMs); } /** - * Configures the player to throw when it detects it's stuck buffering. + * Configures the player to not throw when it detects it's stuck buffering. * *

This method is experimental, and will be renamed or removed in a future release. It should * only be called before the player is used. */ - public void experimental_throwWhenStuckBuffering() { - internalPlayer.experimental_throwWhenStuckBuffering(); + public void experimentalDisableThrowWhenStuckBuffering() { + internalPlayer.experimentalDisableThrowWhenStuckBuffering(); + } + + @Override + public void experimentalSetOffloadSchedulingEnabled(boolean offloadSchedulingEnabled) { + internalPlayer.experimentalSetOffloadSchedulingEnabled(offloadSchedulingEnabled); } @Override @@ -232,11 +251,12 @@ import java.util.concurrent.TimeoutException; @Override public Looper getApplicationLooper() { - return applicationHandler.getLooper(); + return applicationLooper; } @Override public void addListener(Player.EventListener listener) { + Assertions.checkNotNull(listener); listeners.addIfAbsent(new ListenerHolder(listener)); } @@ -287,13 +307,10 @@ import java.util.concurrent.TimeoutException; if (playbackInfo.playbackState != Player.STATE_IDLE) { return; } - PlaybackInfo playbackInfo = - getResetPlaybackInfo( - /* clearPlaylist= */ false, - /* resetError= */ true, - /* playbackState= */ this.playbackInfo.timeline.isEmpty() - ? Player.STATE_ENDED - : Player.STATE_BUFFERING); + PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlaybackError(null); + playbackInfo = + playbackInfo.copyWithPlaybackState( + playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); // Trigger internal prepare first before updating the playback info and notifying external // listeners to ensure that new operations issued in the listener notifications reach the // player after this prepare. The internal player can't change the playback info immediately @@ -375,7 +392,7 @@ import java.util.concurrent.TimeoutException; @Override public void addMediaItems(List mediaItems) { - addMediaItems(/* index= */ mediaSourceHolders.size(), mediaItems); + addMediaItems(/* index= */ mediaSourceHolderSnapshots.size(), mediaItems); } @Override @@ -395,23 +412,25 @@ import java.util.concurrent.TimeoutException; @Override public void addMediaSources(List mediaSources) { - addMediaSources(/* index= */ mediaSourceHolders.size(), mediaSources); + addMediaSources(/* index= */ mediaSourceHolderSnapshots.size(), mediaSources); } @Override public void addMediaSources(int index, List mediaSources) { Assertions.checkArgument(index >= 0); validateMediaSources(mediaSources, /* mediaSourceReplacement= */ false); - int currentWindowIndex = getCurrentWindowIndex(); - long currentPositionMs = getCurrentPosition(); Timeline oldTimeline = getCurrentTimeline(); pendingOperationAcks++; List holders = addMediaSourceHolders(index, mediaSources); - PlaybackInfo playbackInfo = - maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + Timeline newTimeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + newTimeline, + getPeriodPositionAfterTimelineChanged(oldTimeline, newTimeline)); internalPlayer.addMediaSources(index, holders, shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -421,8 +440,14 @@ import java.util.concurrent.TimeoutException; @Override public void removeMediaItems(int fromIndex, int toIndex) { - Assertions.checkArgument(toIndex > fromIndex); - removeMediaItemsInternal(fromIndex, toIndex); + PlaybackInfo playbackInfo = removeMediaItemsInternal(fromIndex, toIndex); + updatePlaybackInfo( + playbackInfo, + /* positionDiscontinuity= */ false, + /* ignored */ Player.DISCONTINUITY_REASON_INTERNAL, + /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + /* seekProcessed= */ false); } @Override @@ -430,19 +455,21 @@ import java.util.concurrent.TimeoutException; Assertions.checkArgument( fromIndex >= 0 && fromIndex <= toIndex - && toIndex <= mediaSourceHolders.size() + && toIndex <= mediaSourceHolderSnapshots.size() && newFromIndex >= 0); - int currentWindowIndex = getCurrentWindowIndex(); - long currentPositionMs = getCurrentPosition(); Timeline oldTimeline = getCurrentTimeline(); pendingOperationAcks++; - newFromIndex = Math.min(newFromIndex, mediaSourceHolders.size() - (toIndex - fromIndex)); - MediaSourceList.moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex); - PlaybackInfo playbackInfo = - maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + newFromIndex = min(newFromIndex, mediaSourceHolderSnapshots.size() - (toIndex - fromIndex)); + Util.moveItems(mediaSourceHolderSnapshots, fromIndex, toIndex, newFromIndex); + Timeline newTimeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + newTimeline, + getPeriodPositionAfterTimelineChanged(oldTimeline, newTimeline)); internalPlayer.moveMediaSources(fromIndex, toIndex, newFromIndex, shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -452,21 +479,23 @@ import java.util.concurrent.TimeoutException; @Override public void clearMediaItems() { - if (mediaSourceHolders.isEmpty()) { - return; - } - removeMediaItemsInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size()); + removeMediaItems(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolderSnapshots.size()); } @Override public void setShuffleOrder(ShuffleOrder shuffleOrder) { - PlaybackInfo playbackInfo = maskTimeline(); - maskWithCurrentPosition(); + Timeline timeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + timeline, + getPeriodPositionOrMaskWindowPosition( + timeline, getCurrentWindowIndex(), getCurrentPosition())); pendingOperationAcks++; this.shuffleOrder = shuffleOrder; internalPlayer.setShuffleOrder(shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -504,7 +533,6 @@ import java.util.concurrent.TimeoutException; && playbackInfo.playbackSuppressionReason == playbackSuppressionReason) { return; } - maskWithCurrentPosition(); pendingOperationAcks++; PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason); @@ -568,23 +596,22 @@ import java.util.concurrent.TimeoutException; // general because the midroll ad preceding the seek destination must be played before the // content position can be played, if a different ad is playing at the moment. Log.w(TAG, "seekTo ignored because an ad is playing"); - applicationHandler - .obtainMessage( - ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED, - /* operationAcks */ 1, - /* positionDiscontinuityReason */ C.INDEX_UNSET, - playbackInfo) - .sendToTarget(); + playbackInfoUpdateListener.onPlaybackInfoUpdate( + new ExoPlayerImplInternal.PlaybackInfoUpdate(playbackInfo)); return; } - maskWindowIndexAndPositionForSeek(timeline, windowIndex, positionMs); @Player.State int newPlaybackState = getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : Player.STATE_BUFFERING; - PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlaybackState(newPlaybackState); + PlaybackInfo newPlaybackInfo = this.playbackInfo.copyWithPlaybackState(newPlaybackState); + newPlaybackInfo = + maskTimelineAndPosition( + newPlaybackInfo, + timeline, + getPeriodPositionOrMaskWindowPosition(timeline, windowIndex, positionMs)); internalPlayer.seekTo(timeline, windowIndex, C.msToUs(positionMs)); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ true, /* positionDiscontinuityReason= */ DISCONTINUITY_REASON_SEEK, /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -592,44 +619,29 @@ import java.util.concurrent.TimeoutException; /* seekProcessed= */ true); } - /** @deprecated Use {@link #setPlaybackSpeed(float)} instead. */ - @SuppressWarnings("deprecation") - @Deprecated @Override public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { - setPlaybackSpeed( - playbackParameters != null ? playbackParameters.speed : Player.DEFAULT_PLAYBACK_SPEED); - } - - /** @deprecated Use {@link #getPlaybackSpeed()} instead. */ - @SuppressWarnings("deprecation") - @Deprecated - @Override - public PlaybackParameters getPlaybackParameters() { - return new PlaybackParameters(playbackSpeed); - } - - @SuppressWarnings("deprecation") - @Override - public void setPlaybackSpeed(float playbackSpeed) { - Assertions.checkState(playbackSpeed > 0); - if (this.playbackSpeed == playbackSpeed) { + if (playbackParameters == null) { + playbackParameters = PlaybackParameters.DEFAULT; + } + if (playbackInfo.playbackParameters.equals(playbackParameters)) { return; } - pendingSetPlaybackSpeedAcks++; - this.playbackSpeed = playbackSpeed; - PlaybackParameters playbackParameters = new PlaybackParameters(playbackSpeed); - internalPlayer.setPlaybackSpeed(playbackSpeed); - notifyListeners( - listener -> { - listener.onPlaybackParametersChanged(playbackParameters); - listener.onPlaybackSpeedChanged(playbackSpeed); - }); + PlaybackInfo newPlaybackInfo = playbackInfo.copyWithPlaybackParameters(playbackParameters); + pendingOperationAcks++; + internalPlayer.setPlaybackParameters(playbackParameters); + updatePlaybackInfo( + newPlaybackInfo, + /* positionDiscontinuity= */ false, + /* ignored */ DISCONTINUITY_REASON_INTERNAL, + /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + /* seekProcessed= */ false); } @Override - public float getPlaybackSpeed() { - return playbackSpeed; + public PlaybackParameters getPlaybackParameters() { + return playbackInfo.playbackParameters; } @Override @@ -652,23 +664,33 @@ import java.util.concurrent.TimeoutException; public void setForegroundMode(boolean foregroundMode) { if (this.foregroundMode != foregroundMode) { this.foregroundMode = foregroundMode; - internalPlayer.setForegroundMode(foregroundMode); + if (!internalPlayer.setForegroundMode(foregroundMode)) { + notifyListeners( + listener -> + listener.onPlayerError( + ExoPlaybackException.createForTimeout( + new TimeoutException("Setting foreground mode timed out."), + ExoPlaybackException.TIMEOUT_OPERATION_SET_FOREGROUND_MODE))); + } } } @Override public void stop(boolean reset) { - PlaybackInfo playbackInfo = - getResetPlaybackInfo( - /* clearPlaylist= */ reset, - /* resetError= */ reset, - /* playbackState= */ Player.STATE_IDLE); - // Trigger internal stop first before updating the playback info and notifying external - // listeners to ensure that new operations issued in the listener notifications reach the - // player after this stop. The internal player can't change the playback info immediately - // because it uses a callback. + PlaybackInfo playbackInfo; + if (reset) { + playbackInfo = + removeMediaItemsInternal( + /* fromIndex= */ 0, /* toIndex= */ mediaSourceHolderSnapshots.size()); + playbackInfo = playbackInfo.copyWithPlaybackError(null); + } else { + playbackInfo = this.playbackInfo.copyWithLoadingMediaPeriodId(this.playbackInfo.periodId); + playbackInfo.bufferedPositionUs = playbackInfo.positionUs; + playbackInfo.totalBufferedDurationUs = 0; + } + playbackInfo = playbackInfo.copyWithPlaybackState(Player.STATE_IDLE); pendingOperationAcks++; - internalPlayer.stop(reset); + internalPlayer.stop(); updatePlaybackInfo( playbackInfo, /* positionDiscontinuity= */ false, @@ -687,15 +709,18 @@ import java.util.concurrent.TimeoutException; notifyListeners( listener -> listener.onPlayerError( - ExoPlaybackException.createForUnexpected( - new RuntimeException(new TimeoutException("Player release timed out."))))); + ExoPlaybackException.createForTimeout( + new TimeoutException("Player release timed out."), + ExoPlaybackException.TIMEOUT_OPERATION_RELEASE))); } - applicationHandler.removeCallbacksAndMessages(null); - playbackInfo = - getResetPlaybackInfo( - /* clearPlaylist= */ false, - /* resetError= */ false, - /* playbackState= */ Player.STATE_IDLE); + playbackInfoUpdateHandler.removeCallbacksAndMessages(null); + if (analyticsCollector != null) { + bandwidthMeter.removeEventListener(analyticsCollector); + } + playbackInfo = playbackInfo.copyWithPlaybackState(Player.STATE_IDLE); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(playbackInfo.periodId); + playbackInfo.bufferedPositionUs = playbackInfo.positionUs; + playbackInfo.totalBufferedDurationUs = 0; } @Override @@ -710,7 +735,7 @@ import java.util.concurrent.TimeoutException; @Override public int getCurrentPeriodIndex() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingPeriodIndex; } else { return playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); @@ -736,7 +761,7 @@ import java.util.concurrent.TimeoutException; @Override public long getCurrentPosition() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowPositionMs; } else if (playbackInfo.periodId.isAd()) { return C.usToMs(playbackInfo.positionUs); @@ -762,7 +787,7 @@ import java.util.concurrent.TimeoutException; @Override public boolean isPlayingAd() { - return !shouldMaskPosition() && playbackInfo.periodId.isAd(); + return playbackInfo.periodId.isAd(); } @Override @@ -789,7 +814,7 @@ import java.util.concurrent.TimeoutException; @Override public long getContentBufferedPosition() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowPositionMs; } if (playbackInfo.loadingMediaPeriodId.windowSequenceNumber @@ -820,6 +845,12 @@ import java.util.concurrent.TimeoutException; return renderers[index].getTrackType(); } + @Override + @Nullable + public TrackSelector getTrackSelector() { + return trackSelector; + } + @Override public TrackGroupArray getCurrentTrackGroups() { return playbackInfo.trackGroups; @@ -835,22 +866,8 @@ import java.util.concurrent.TimeoutException; return playbackInfo.timeline; } - // Not private so it can be called from an inner class without going through a thunk method. - /* package */ void handleEvent(Message msg) { - switch (msg.what) { - case ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED: - handlePlaybackInfo((ExoPlayerImplInternal.PlaybackInfoUpdate) msg.obj); - break; - case ExoPlayerImplInternal.MSG_PLAYBACK_SPEED_CHANGED: - handlePlaybackSpeed((Float) msg.obj, /* operationAck= */ msg.arg1 != 0); - break; - default: - throw new IllegalStateException(); - } - } - private int getCurrentWindowIndexInternal() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowIndex; } else { return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period) @@ -866,23 +883,6 @@ import java.util.concurrent.TimeoutException; return mediaSources; } - @SuppressWarnings("deprecation") - private void handlePlaybackSpeed(float playbackSpeed, boolean operationAck) { - if (operationAck) { - pendingSetPlaybackSpeedAcks--; - } - if (pendingSetPlaybackSpeedAcks == 0) { - if (this.playbackSpeed != playbackSpeed) { - this.playbackSpeed = playbackSpeed; - notifyListeners( - listener -> { - listener.onPlaybackParametersChanged(new PlaybackParameters(playbackSpeed)); - listener.onPlaybackSpeedChanged(playbackSpeed); - }); - } - } - } - private void handlePlaybackInfo(ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfoUpdate) { pendingOperationAcks -= playbackInfoUpdate.operationAcks; if (playbackInfoUpdate.positionDiscontinuity) { @@ -893,10 +893,20 @@ import java.util.concurrent.TimeoutException; pendingPlayWhenReadyChangeReason = playbackInfoUpdate.playWhenReadyChangeReason; } if (pendingOperationAcks == 0) { - if (!this.playbackInfo.timeline.isEmpty() - && playbackInfoUpdate.playbackInfo.timeline.isEmpty()) { - // Update the masking variables, which are used when the timeline becomes empty. - resetMaskingPosition(); + Timeline newTimeline = playbackInfoUpdate.playbackInfo.timeline; + if (!this.playbackInfo.timeline.isEmpty() && newTimeline.isEmpty()) { + // Update the masking variables, which are used when the timeline becomes empty because a + // ConcatenatingMediaSource has been cleared. + maskingWindowIndex = C.INDEX_UNSET; + maskingWindowPositionMs = 0; + maskingPeriodIndex = 0; + } + if (!newTimeline.isEmpty()) { + List timelines = ((PlaylistTimeline) newTimeline).getChildTimelines(); + checkState(timelines.size() == mediaSourceHolderSnapshots.size()); + for (int i = 0; i < timelines.size(); i++) { + mediaSourceHolderSnapshots.get(i).timeline = timelines.get(i); + } } boolean positionDiscontinuity = hasPendingDiscontinuity; hasPendingDiscontinuity = false; @@ -910,43 +920,6 @@ import java.util.concurrent.TimeoutException; } } - private PlaybackInfo getResetPlaybackInfo( - boolean clearPlaylist, boolean resetError, @Player.State int playbackState) { - if (clearPlaylist) { - // Reset list of media source holders which are used for creating the masking timeline. - removeMediaSourceHolders( - /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size()); - resetMaskingPosition(); - } else { - maskWithCurrentPosition(); - } - Timeline timeline = playbackInfo.timeline; - MediaPeriodId mediaPeriodId = playbackInfo.periodId; - long requestedContentPositionUs = playbackInfo.requestedContentPositionUs; - long positionUs = playbackInfo.positionUs; - if (clearPlaylist) { - timeline = Timeline.EMPTY; - mediaPeriodId = PlaybackInfo.getDummyPeriodForEmptyTimeline(); - requestedContentPositionUs = C.TIME_UNSET; - positionUs = 0; - } - return new PlaybackInfo( - timeline, - mediaPeriodId, - requestedContentPositionUs, - playbackState, - resetError ? null : playbackInfo.playbackError, - /* isLoading= */ false, - clearPlaylist ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, - clearPlaylist ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, - mediaPeriodId, - playbackInfo.playWhenReady, - playbackInfo.playbackSuppressionReason, - positionUs, - /* totalBufferedDurationUs= */ 0, - positionUs); - } - private void updatePlaybackInfo( PlaybackInfo playbackInfo, boolean positionDiscontinuity, @@ -957,6 +930,22 @@ import java.util.concurrent.TimeoutException; // Assign playback info immediately such that all getters return the right values. PlaybackInfo previousPlaybackInfo = this.playbackInfo; this.playbackInfo = playbackInfo; + + Pair mediaItemTransitionInfo = + evaluateMediaItemTransitionReason( + playbackInfo, + previousPlaybackInfo, + positionDiscontinuity, + positionDiscontinuityReason, + !previousPlaybackInfo.timeline.equals(playbackInfo.timeline)); + boolean mediaItemTransitioned = mediaItemTransitionInfo.first; + int mediaItemTransitionReason = mediaItemTransitionInfo.second; + @Nullable MediaItem newMediaItem = null; + if (mediaItemTransitioned && !playbackInfo.timeline.isEmpty()) { + int windowIndex = + playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period).windowIndex; + newMediaItem = playbackInfo.timeline.getWindow(windowIndex, window).mediaItem; + } notifyListeners( new PlaybackInfoUpdate( playbackInfo, @@ -966,10 +955,59 @@ import java.util.concurrent.TimeoutException; positionDiscontinuity, positionDiscontinuityReason, timelineChangeReason, + mediaItemTransitioned, + mediaItemTransitionReason, + newMediaItem, playWhenReadyChangeReason, seekProcessed)); } + private Pair evaluateMediaItemTransitionReason( + PlaybackInfo playbackInfo, + PlaybackInfo oldPlaybackInfo, + boolean positionDiscontinuity, + @DiscontinuityReason int positionDiscontinuityReason, + boolean timelineChanged) { + + Timeline oldTimeline = oldPlaybackInfo.timeline; + Timeline newTimeline = playbackInfo.timeline; + if (newTimeline.isEmpty() && oldTimeline.isEmpty()) { + return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET); + } else if (newTimeline.isEmpty() != oldTimeline.isEmpty()) { + return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + int oldWindowIndex = + oldTimeline.getPeriodByUid(oldPlaybackInfo.periodId.periodUid, period).windowIndex; + Object oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid; + int newWindowIndex = + newTimeline.getPeriodByUid(playbackInfo.periodId.periodUid, period).windowIndex; + Object newWindowUid = newTimeline.getWindow(newWindowIndex, window).uid; + int firstPeriodIndexInNewWindow = window.firstPeriodIndex; + if (!oldWindowUid.equals(newWindowUid)) { + @Player.MediaItemTransitionReason int transitionReason; + if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_PERIOD_TRANSITION) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_AUTO; + } else if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_SEEK; + } else if (timelineChanged) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; + } else { + // A change in window uid must be justified by one of the reasons above. + throw new IllegalStateException(); + } + return new Pair<>(/* isTransitioning */ true, transitionReason); + } else if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_PERIOD_TRANSITION + && newTimeline.getIndexOfPeriod(playbackInfo.periodId.periodUid) + == firstPeriodIndexInNewWindow) { + return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_REPEAT); + } + return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET); + } + private void setMediaSourcesInternal( List mediaSources, int startWindowIndex, @@ -979,14 +1017,13 @@ import java.util.concurrent.TimeoutException; int currentWindowIndex = getCurrentWindowIndexInternal(); long currentPositionMs = getCurrentPosition(); pendingOperationAcks++; - if (!mediaSourceHolders.isEmpty()) { + if (!mediaSourceHolderSnapshots.isEmpty()) { removeMediaSourceHolders( - /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size()); + /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolderSnapshots.size()); } List holders = addMediaSourceHolders(/* index= */ 0, mediaSources); - PlaybackInfo playbackInfo = maskTimeline(); - Timeline timeline = playbackInfo.timeline; + Timeline timeline = createMaskingTimeline(); if (!timeline.isEmpty() && startWindowIndex >= timeline.getWindowCount()) { throw new IllegalSeekPositionException(timeline, startWindowIndex, startPositionMs); } @@ -998,11 +1035,14 @@ import java.util.concurrent.TimeoutException; startWindowIndex = currentWindowIndex; startPositionMs = currentPositionMs; } - maskWindowIndexAndPositionForSeek( - timeline, startWindowIndex == C.INDEX_UNSET ? 0 : startWindowIndex, startPositionMs); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + timeline, + getPeriodPositionOrMaskWindowPosition(timeline, startWindowIndex, startPositionMs)); // Mask the playback state. - int maskingPlaybackState = playbackInfo.playbackState; - if (startWindowIndex != C.INDEX_UNSET && playbackInfo.playbackState != STATE_IDLE) { + int maskingPlaybackState = newPlaybackInfo.playbackState; + if (startWindowIndex != C.INDEX_UNSET && newPlaybackInfo.playbackState != STATE_IDLE) { // Position reset to startWindowIndex (results in pending initial seek). if (timeline.isEmpty() || startWindowIndex >= timeline.getWindowCount()) { // Setting an empty timeline or invalid seek transitions to ended. @@ -1011,11 +1051,11 @@ import java.util.concurrent.TimeoutException; maskingPlaybackState = STATE_BUFFERING; } } - playbackInfo = playbackInfo.copyWithPlaybackState(maskingPlaybackState); + newPlaybackInfo = newPlaybackInfo.copyWithPlaybackState(maskingPlaybackState); internalPlayer.setMediaSources( holders, startWindowIndex, C.msToUs(startPositionMs), shuffleOrder); updatePlaybackInfo( - playbackInfo, + newPlaybackInfo, /* positionDiscontinuity= */ false, /* ignored */ Player.DISCONTINUITY_REASON_INTERNAL, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, @@ -1030,7 +1070,8 @@ import java.util.concurrent.TimeoutException; MediaSourceList.MediaSourceHolder holder = new MediaSourceList.MediaSourceHolder(mediaSources.get(i), useLazyPreparation); holders.add(holder); - mediaSourceHolders.add(i + index, holder); + mediaSourceHolderSnapshots.add( + i + index, new MediaSourceHolderSnapshot(holder.uid, holder.mediaSource.getTimeline())); } shuffleOrder = shuffleOrder.cloneAndInsert( @@ -1038,48 +1079,42 @@ import java.util.concurrent.TimeoutException; return holders; } - private void removeMediaItemsInternal(int fromIndex, int toIndex) { + private PlaybackInfo removeMediaItemsInternal(int fromIndex, int toIndex) { Assertions.checkArgument( - fromIndex >= 0 && toIndex >= fromIndex && toIndex <= mediaSourceHolders.size()); + fromIndex >= 0 && toIndex >= fromIndex && toIndex <= mediaSourceHolderSnapshots.size()); int currentWindowIndex = getCurrentWindowIndex(); - long currentPositionMs = getCurrentPosition(); Timeline oldTimeline = getCurrentTimeline(); - int currentMediaSourceCount = mediaSourceHolders.size(); + int currentMediaSourceCount = mediaSourceHolderSnapshots.size(); pendingOperationAcks++; removeMediaSourceHolders(fromIndex, /* toIndexExclusive= */ toIndex); - PlaybackInfo playbackInfo = - maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + Timeline newTimeline = createMaskingTimeline(); + PlaybackInfo newPlaybackInfo = + maskTimelineAndPosition( + playbackInfo, + newTimeline, + getPeriodPositionAfterTimelineChanged(oldTimeline, newTimeline)); // Player transitions to STATE_ENDED if the current index is part of the removed tail. final boolean transitionsToEnded = - playbackInfo.playbackState != STATE_IDLE - && playbackInfo.playbackState != STATE_ENDED + newPlaybackInfo.playbackState != STATE_IDLE + && newPlaybackInfo.playbackState != STATE_ENDED && fromIndex < toIndex && toIndex == currentMediaSourceCount - && currentWindowIndex >= playbackInfo.timeline.getWindowCount(); + && currentWindowIndex >= newPlaybackInfo.timeline.getWindowCount(); if (transitionsToEnded) { - playbackInfo = playbackInfo.copyWithPlaybackState(STATE_ENDED); + newPlaybackInfo = newPlaybackInfo.copyWithPlaybackState(STATE_ENDED); } internalPlayer.removeMediaSources(fromIndex, toIndex, shuffleOrder); - updatePlaybackInfo( - playbackInfo, - /* positionDiscontinuity= */ false, - /* ignored */ Player.DISCONTINUITY_REASON_INTERNAL, - /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, - /* seekProcessed= */ false); + return newPlaybackInfo; } - private List removeMediaSourceHolders( - int fromIndex, int toIndexExclusive) { - List removed = new ArrayList<>(); + private void removeMediaSourceHolders(int fromIndex, int toIndexExclusive) { for (int i = toIndexExclusive - 1; i >= fromIndex; i--) { - removed.add(mediaSourceHolders.remove(i)); + mediaSourceHolderSnapshots.remove(i); } shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndexExclusive); - if (mediaSourceHolders.isEmpty()) { + if (mediaSourceHolderSnapshots.isEmpty()) { hasAdsMediaSource = false; } - return removed; } /** @@ -1098,7 +1133,7 @@ import java.util.concurrent.TimeoutException; throw new IllegalStateException(); } int sizeAfterModification = - mediaSources.size() + (mediaSourceReplacement ? 0 : mediaSourceHolders.size()); + mediaSources.size() + (mediaSourceReplacement ? 0 : mediaSourceHolderSnapshots.size()); for (int i = 0; i < mediaSources.size(); i++) { MediaSource mediaSource = checkNotNull(mediaSources.get(i)); if (mediaSource instanceof AdsMediaSource) { @@ -1112,107 +1147,163 @@ import java.util.concurrent.TimeoutException; } } - private PlaybackInfo maskTimeline() { - return playbackInfo.copyWithTimeline( - mediaSourceHolders.isEmpty() - ? Timeline.EMPTY - : new MediaSourceList.PlaylistTimeline(mediaSourceHolders, shuffleOrder)); + private Timeline createMaskingTimeline() { + return new PlaylistTimeline(mediaSourceHolderSnapshots, shuffleOrder); } - private PlaybackInfo maskTimelineAndWindowIndex( - int currentWindowIndex, long currentPositionMs, Timeline oldTimeline) { - PlaybackInfo playbackInfo = maskTimeline(); - Timeline maskingTimeline = playbackInfo.timeline; - if (oldTimeline.isEmpty()) { - // The index is the default index or was set by a seek in the empty old timeline. - maskingWindowIndex = currentWindowIndex; - if (!maskingTimeline.isEmpty() && currentWindowIndex >= maskingTimeline.getWindowCount()) { - // The seek is not valid in the new timeline. - maskWithDefaultPosition(maskingTimeline); - } + private PlaybackInfo maskTimelineAndPosition( + PlaybackInfo playbackInfo, Timeline timeline, @Nullable Pair periodPosition) { + Assertions.checkArgument(timeline.isEmpty() || periodPosition != null); + Timeline oldTimeline = playbackInfo.timeline; + // Mask the timeline. + playbackInfo = playbackInfo.copyWithTimeline(timeline); + + if (timeline.isEmpty()) { + // Reset periodId and loadingPeriodId. + MediaPeriodId dummyMediaPeriodId = PlaybackInfo.getDummyPeriodForEmptyTimeline(); + playbackInfo = + playbackInfo.copyWithNewPosition( + dummyMediaPeriodId, + /* positionUs= */ C.msToUs(maskingWindowPositionMs), + /* requestedContentPositionUs= */ C.msToUs(maskingWindowPositionMs), + /* totalBufferedDurationUs= */ 0, + TrackGroupArray.EMPTY, + emptyTrackSelectorResult); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(dummyMediaPeriodId); + playbackInfo.bufferedPositionUs = playbackInfo.positionUs; return playbackInfo; } - @Nullable - Pair periodPosition = - oldTimeline.getPeriodPosition( - window, - period, - currentWindowIndex, - C.msToUs(currentPositionMs), - /* defaultPositionProjectionUs= */ 0); - Object periodUid = Util.castNonNull(periodPosition).first; - if (maskingTimeline.getIndexOfPeriod(periodUid) != C.INDEX_UNSET) { - // Get the window index of the current period that exists in the new timeline also. - maskingWindowIndex = maskingTimeline.getPeriodByUid(periodUid, period).windowIndex; - maskingPeriodIndex = maskingTimeline.getIndexOfPeriod(periodUid); - maskingWindowPositionMs = currentPositionMs; - } else { - // Period uid not found in new timeline. Try to get subsequent period. - @Nullable - Object nextPeriodUid = - ExoPlayerImplInternal.resolveSubsequentPeriod( - window, - period, - repeatMode, - shuffleModeEnabled, - periodUid, - oldTimeline, - maskingTimeline); - if (nextPeriodUid != null) { - // Set masking to the default position of the window of the subsequent period. - maskingWindowIndex = maskingTimeline.getPeriodByUid(nextPeriodUid, period).windowIndex; - maskingPeriodIndex = maskingTimeline.getWindow(maskingWindowIndex, window).firstPeriodIndex; - maskingWindowPositionMs = window.getDefaultPositionMs(); - } else { - // Reset if no subsequent period is found. - maskWithDefaultPosition(maskingTimeline); + + Object oldPeriodUid = playbackInfo.periodId.periodUid; + boolean playingPeriodChanged = !oldPeriodUid.equals(castNonNull(periodPosition).first); + MediaPeriodId newPeriodId = + playingPeriodChanged ? new MediaPeriodId(periodPosition.first) : playbackInfo.periodId; + long newContentPositionUs = periodPosition.second; + long oldContentPositionUs = C.msToUs(getContentPosition()); + if (!oldTimeline.isEmpty()) { + oldContentPositionUs -= + oldTimeline.getPeriodByUid(oldPeriodUid, period).getPositionInWindowUs(); + } + + if (playingPeriodChanged || newContentPositionUs < oldContentPositionUs) { + checkState(!newPeriodId.isAd()); + // The playing period changes or a backwards seek within the playing period occurs. + playbackInfo = + playbackInfo.copyWithNewPosition( + newPeriodId, + /* positionUs= */ newContentPositionUs, + /* requestedContentPositionUs= */ newContentPositionUs, + /* totalBufferedDurationUs= */ 0, + playingPeriodChanged ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, + playingPeriodChanged ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(newPeriodId); + playbackInfo.bufferedPositionUs = newContentPositionUs; + } else if (newContentPositionUs == oldContentPositionUs) { + // Period position remains unchanged. + int loadingPeriodIndex = + timeline.getIndexOfPeriod(playbackInfo.loadingMediaPeriodId.periodUid); + if (loadingPeriodIndex == C.INDEX_UNSET + || timeline.getPeriod(loadingPeriodIndex, period).windowIndex + != timeline.getPeriodByUid(newPeriodId.periodUid, period).windowIndex) { + // Discard periods after the playing period, if the loading period is discarded or the + // playing and loading period are not in the same window. + timeline.getPeriodByUid(newPeriodId.periodUid, period); + long maskedBufferedPositionUs = + newPeriodId.isAd() + ? period.getAdDurationUs(newPeriodId.adGroupIndex, newPeriodId.adIndexInAdGroup) + : period.durationUs; + playbackInfo = + playbackInfo.copyWithNewPosition( + newPeriodId, + /* positionUs= */ playbackInfo.positionUs, + /* requestedContentPositionUs= */ playbackInfo.positionUs, + /* totalBufferedDurationUs= */ maskedBufferedPositionUs - playbackInfo.positionUs, + playbackInfo.trackGroups, + playbackInfo.trackSelectorResult); + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(newPeriodId); + playbackInfo.bufferedPositionUs = maskedBufferedPositionUs; } + } else { + checkState(!newPeriodId.isAd()); + // A forward seek within the playing period (timeline did not change). + long maskedTotalBufferedDurationUs = + max( + 0, + playbackInfo.totalBufferedDurationUs - (newContentPositionUs - oldContentPositionUs)); + long maskedBufferedPositionUs = playbackInfo.bufferedPositionUs; + if (playbackInfo.loadingMediaPeriodId.equals(playbackInfo.periodId)) { + maskedBufferedPositionUs = newContentPositionUs + maskedTotalBufferedDurationUs; + } + playbackInfo = + playbackInfo.copyWithNewPosition( + newPeriodId, + /* positionUs= */ newContentPositionUs, + /* requestedContentPositionUs= */ newContentPositionUs, + maskedTotalBufferedDurationUs, + playbackInfo.trackGroups, + playbackInfo.trackSelectorResult); + playbackInfo.bufferedPositionUs = maskedBufferedPositionUs; } return playbackInfo; } - private void maskWindowIndexAndPositionForSeek( - Timeline timeline, int windowIndex, long positionMs) { - maskingWindowIndex = windowIndex; - if (timeline.isEmpty()) { - maskingWindowPositionMs = positionMs == C.TIME_UNSET ? 0 : positionMs; - maskingPeriodIndex = 0; - } else if (windowIndex >= timeline.getWindowCount()) { - // An initial seek now proves to be invalid in the actual timeline. - maskWithDefaultPosition(timeline); + @Nullable + private Pair getPeriodPositionAfterTimelineChanged( + Timeline oldTimeline, Timeline newTimeline) { + long currentPositionMs = getContentPosition(); + if (oldTimeline.isEmpty() || newTimeline.isEmpty()) { + boolean isCleared = !oldTimeline.isEmpty() && newTimeline.isEmpty(); + return getPeriodPositionOrMaskWindowPosition( + newTimeline, + isCleared ? C.INDEX_UNSET : getCurrentWindowIndexInternal(), + isCleared ? C.TIME_UNSET : currentPositionMs); + } + int currentWindowIndex = getCurrentWindowIndex(); + @Nullable + Pair oldPeriodPosition = + oldTimeline.getPeriodPosition( + window, period, currentWindowIndex, C.msToUs(currentPositionMs)); + Object periodUid = castNonNull(oldPeriodPosition).first; + if (newTimeline.getIndexOfPeriod(periodUid) != C.INDEX_UNSET) { + // The old period position is still available in the new timeline. + return oldPeriodPosition; + } + // Period uid not found in new timeline. Try to get subsequent period. + @Nullable + Object nextPeriodUid = + ExoPlayerImplInternal.resolveSubsequentPeriod( + window, period, repeatMode, shuffleModeEnabled, periodUid, oldTimeline, newTimeline); + if (nextPeriodUid != null) { + // Reset position to the default position of the window of the subsequent period. + newTimeline.getPeriodByUid(nextPeriodUid, period); + return getPeriodPositionOrMaskWindowPosition( + newTimeline, + period.windowIndex, + newTimeline.getWindow(period.windowIndex, window).getDefaultPositionMs()); } else { - long windowPositionUs = - positionMs == C.TIME_UNSET - ? timeline.getWindow(windowIndex, window).getDefaultPositionUs() - : C.msToUs(positionMs); - Pair periodUidAndPosition = - timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs); - maskingWindowPositionMs = C.usToMs(windowPositionUs); - maskingPeriodIndex = timeline.getIndexOfPeriod(periodUidAndPosition.first); + // No subsequent period found and the new timeline is not empty. Use the default position. + return getPeriodPositionOrMaskWindowPosition( + newTimeline, /* windowIndex= */ C.INDEX_UNSET, /* windowPositionMs= */ C.TIME_UNSET); } } - private void maskWithCurrentPosition() { - maskingWindowIndex = getCurrentWindowIndexInternal(); - maskingPeriodIndex = getCurrentPeriodIndex(); - maskingWindowPositionMs = getCurrentPosition(); - } - - private void maskWithDefaultPosition(Timeline timeline) { + @Nullable + private Pair getPeriodPositionOrMaskWindowPosition( + Timeline timeline, int windowIndex, long windowPositionMs) { if (timeline.isEmpty()) { - resetMaskingPosition(); - return; + // If empty we store the initial seek in the masking variables. + maskingWindowIndex = windowIndex; + maskingWindowPositionMs = windowPositionMs == C.TIME_UNSET ? 0 : windowPositionMs; + maskingPeriodIndex = 0; + return null; } - maskingWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); - timeline.getWindow(maskingWindowIndex, window); - maskingWindowPositionMs = window.getDefaultPositionMs(); - maskingPeriodIndex = window.firstPeriodIndex; - } - - private void resetMaskingPosition() { - maskingWindowIndex = C.INDEX_UNSET; - maskingWindowPositionMs = 0; - maskingPeriodIndex = 0; + if (windowIndex == C.INDEX_UNSET || windowIndex >= timeline.getWindowCount()) { + // Use default position of timeline if window index still unset or if a previous initial seek + // now turns out to be invalid. + windowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); + windowPositionMs = timeline.getWindow(windowIndex, window).getDefaultPositionMs(); + } + return timeline.getPeriodPosition(window, period, windowIndex, C.msToUs(windowPositionMs)); } private void notifyListeners(ListenerInvocation listenerInvocation) { @@ -1239,10 +1330,6 @@ import java.util.concurrent.TimeoutException; return positionMs; } - private boolean shouldMaskPosition() { - return playbackInfo.timeline.isEmpty() || pendingOperationAcks > 0; - } - private static final class PlaybackInfoUpdate implements Runnable { private final PlaybackInfo playbackInfo; @@ -1251,16 +1338,21 @@ import java.util.concurrent.TimeoutException; private final boolean positionDiscontinuity; @DiscontinuityReason private final int positionDiscontinuityReason; @TimelineChangeReason private final int timelineChangeReason; + private final boolean mediaItemTransitioned; + private final int mediaItemTransitionReason; + @Nullable private final MediaItem mediaItem; @PlayWhenReadyChangeReason private final int playWhenReadyChangeReason; private final boolean seekProcessed; private final boolean playbackStateChanged; private final boolean playbackErrorChanged; - private final boolean timelineChanged; private final boolean isLoadingChanged; + private final boolean timelineChanged; private final boolean trackSelectorResultChanged; - private final boolean isPlayingChanged; private final boolean playWhenReadyChanged; private final boolean playbackSuppressionReasonChanged; + private final boolean isPlayingChanged; + private final boolean playbackParametersChanged; + private final boolean offloadSchedulingEnabledChanged; public PlaybackInfoUpdate( PlaybackInfo playbackInfo, @@ -1270,6 +1362,9 @@ import java.util.concurrent.TimeoutException; boolean positionDiscontinuity, @DiscontinuityReason int positionDiscontinuityReason, @TimelineChangeReason int timelineChangeReason, + boolean mediaItemTransitioned, + @MediaItemTransitionReason int mediaItemTransitionReason, + @Nullable MediaItem mediaItem, @PlayWhenReadyChangeReason int playWhenReadyChangeReason, boolean seekProcessed) { this.playbackInfo = playbackInfo; @@ -1278,6 +1373,9 @@ import java.util.concurrent.TimeoutException; this.positionDiscontinuity = positionDiscontinuity; this.positionDiscontinuityReason = positionDiscontinuityReason; this.timelineChangeReason = timelineChangeReason; + this.mediaItemTransitioned = mediaItemTransitioned; + this.mediaItemTransitionReason = mediaItemTransitionReason; + this.mediaItem = mediaItem; this.playWhenReadyChangeReason = playWhenReadyChangeReason; this.seekProcessed = seekProcessed; playbackStateChanged = previousPlaybackInfo.playbackState != playbackInfo.playbackState; @@ -1292,6 +1390,10 @@ import java.util.concurrent.TimeoutException; playbackSuppressionReasonChanged = previousPlaybackInfo.playbackSuppressionReason != playbackInfo.playbackSuppressionReason; isPlayingChanged = isPlaying(previousPlaybackInfo) != isPlaying(playbackInfo); + playbackParametersChanged = + !previousPlaybackInfo.playbackParameters.equals(playbackInfo.playbackParameters); + offloadSchedulingEnabledChanged = + previousPlaybackInfo.offloadSchedulingEnabled != playbackInfo.offloadSchedulingEnabled; } @SuppressWarnings("deprecation") @@ -1307,6 +1409,11 @@ import java.util.concurrent.TimeoutException; listenerSnapshot, listener -> listener.onPositionDiscontinuity(positionDiscontinuityReason)); } + if (mediaItemTransitioned) { + invokeAll( + listenerSnapshot, + listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason)); + } if (playbackErrorChanged) { invokeAll(listenerSnapshot, listener -> listener.onPlayerError(playbackInfo.playbackError)); } @@ -1352,9 +1459,23 @@ import java.util.concurrent.TimeoutException; invokeAll( listenerSnapshot, listener -> listener.onIsPlayingChanged(isPlaying(playbackInfo))); } + if (playbackParametersChanged) { + invokeAll( + listenerSnapshot, + listener -> { + listener.onPlaybackParametersChanged(playbackInfo.playbackParameters); + }); + } if (seekProcessed) { invokeAll(listenerSnapshot, EventListener::onSeekProcessed); } + if (offloadSchedulingEnabledChanged) { + invokeAll( + listenerSnapshot, + listener -> + listener.onExperimentalOffloadSchedulingEnabledChanged( + playbackInfo.offloadSchedulingEnabled)); + } } private static boolean isPlaying(PlaybackInfo playbackInfo) { @@ -1370,4 +1491,26 @@ import java.util.concurrent.TimeoutException; listenerHolder.invoke(listenerInvocation); } } + + private static final class MediaSourceHolderSnapshot implements MediaSourceInfoHolder { + + private final Object uid; + + private Timeline timeline; + + public MediaSourceHolderSnapshot(Object uid, Timeline timeline) { + this.uid = uid; + this.timeline = timeline; + } + + @Override + public Object getUid() { + return uid; + } + + @Override + public Timeline getTimeline() { + return timeline; + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 96e8f3d8ac..9739680e79 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 @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; @@ -24,7 +27,7 @@ import android.os.SystemClock; import android.util.Pair; import androidx.annotation.CheckResult; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.DefaultMediaClock.PlaybackSpeedListener; +import com.google.android.exoplayer2.DefaultMediaClock.PlaybackParametersListener; import com.google.android.exoplayer2.Player.DiscontinuityReason; import com.google.android.exoplayer2.Player.PlayWhenReadyChangeReason; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; @@ -45,6 +48,7 @@ import com.google.android.exoplayer2.util.HandlerWrapper; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.TraceUtil; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Supplier; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -57,21 +61,67 @@ import java.util.concurrent.atomic.AtomicBoolean; MediaPeriod.Callback, TrackSelector.InvalidationListener, MediaSourceList.MediaSourceListInfoRefreshListener, - PlaybackSpeedListener, + PlaybackParametersListener, PlayerMessage.Sender { private static final String TAG = "ExoPlayerImplInternal"; - // External messages - public static final int MSG_PLAYBACK_INFO_CHANGED = 0; - public static final int MSG_PLAYBACK_SPEED_CHANGED = 1; + public static final class PlaybackInfoUpdate { + + private boolean hasPendingChange; + + public PlaybackInfo playbackInfo; + public int operationAcks; + public boolean positionDiscontinuity; + @DiscontinuityReason public int discontinuityReason; + public boolean hasPlayWhenReadyChangeReason; + @PlayWhenReadyChangeReason public int playWhenReadyChangeReason; + + public PlaybackInfoUpdate(PlaybackInfo playbackInfo) { + this.playbackInfo = playbackInfo; + } + + public void incrementPendingOperationAcks(int operationAcks) { + hasPendingChange |= operationAcks > 0; + this.operationAcks += operationAcks; + } + + public void setPlaybackInfo(PlaybackInfo playbackInfo) { + hasPendingChange |= this.playbackInfo != playbackInfo; + this.playbackInfo = playbackInfo; + } + + public void setPositionDiscontinuity(@DiscontinuityReason int discontinuityReason) { + if (positionDiscontinuity + && this.discontinuityReason != Player.DISCONTINUITY_REASON_INTERNAL) { + // We always prefer non-internal discontinuity reasons. We also assume that we won't report + // more than one non-internal discontinuity per message iteration. + Assertions.checkArgument(discontinuityReason == Player.DISCONTINUITY_REASON_INTERNAL); + return; + } + hasPendingChange = true; + positionDiscontinuity = true; + this.discontinuityReason = discontinuityReason; + } + + public void setPlayWhenReadyChangeReason( + @PlayWhenReadyChangeReason int playWhenReadyChangeReason) { + hasPendingChange = true; + this.hasPlayWhenReadyChangeReason = true; + this.playWhenReadyChangeReason = playWhenReadyChangeReason; + } + } + + public interface PlaybackInfoUpdateListener { + void onPlaybackInfoUpdate(ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfo); + } // Internal messages private static final int MSG_PREPARE = 0; private static final int MSG_SET_PLAY_WHEN_READY = 1; private static final int MSG_DO_SOME_WORK = 2; private static final int MSG_SEEK_TO = 3; - private static final int MSG_SET_PLAYBACK_SPEED = 4; + private static final int MSG_SET_PLAYBACK_PARAMETERS = 4; private static final int MSG_SET_SEEK_PARAMETERS = 5; private static final int MSG_STOP = 6; private static final int MSG_RELEASE = 7; @@ -83,7 +133,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int MSG_SET_FOREGROUND_MODE = 13; private static final int MSG_SEND_MESSAGE = 14; private static final int MSG_SEND_MESSAGE_TO_TARGET_THREAD = 15; - private static final int MSG_PLAYBACK_SPEED_CHANGED_INTERNAL = 16; + private static final int MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL = 16; private static final int MSG_SET_MEDIA_SOURCES = 17; private static final int MSG_ADD_MEDIA_SOURCES = 18; private static final int MSG_MOVE_MEDIA_SOURCES = 19; @@ -91,9 +141,19 @@ import java.util.concurrent.atomic.AtomicBoolean; private static final int MSG_SET_SHUFFLE_ORDER = 21; private static final int MSG_PLAYLIST_UPDATE_REQUESTED = 22; private static final int MSG_SET_PAUSE_AT_END_OF_WINDOW = 23; + private static final int MSG_SET_OFFLOAD_SCHEDULING_ENABLED = 24; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; + /** + * Duration under which pausing the main DO_SOME_WORK loop is not expected to yield significant + * power saving. + * + *

This value is probably too high, power measurements are needed adjust it, but as renderer + * sleep is currently only implemented for audio offload, which uses buffer much bigger than 2s, + * this does not matter for now. + */ + private static final long MIN_RENDERER_SLEEP_DURATION_MS = 2000; private final Renderer[] renderers; private final RendererCapabilities[] rendererCapabilities; @@ -103,7 +163,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private final BandwidthMeter bandwidthMeter; private final HandlerWrapper handler; private final HandlerThread internalPlaybackThread; - private final Handler eventHandler; + private final Looper playbackLooper; private final Timeline.Window window; private final Timeline.Period period; private final long backBufferDurationUs; @@ -111,6 +171,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private final DefaultMediaClock mediaClock; private final ArrayList pendingMessages; private final Clock clock; + private final PlaybackInfoUpdateListener playbackInfoUpdateListener; private final MediaPeriodQueue queue; private final MediaSourceList mediaSourceList; @@ -127,6 +188,8 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private boolean foregroundMode; + private boolean requestForRendererSleep; + private boolean offloadSchedulingEnabled; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; @@ -146,8 +209,12 @@ import java.util.concurrent.atomic.AtomicBoolean; @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled, @Nullable AnalyticsCollector analyticsCollector, - Handler eventHandler, - Clock clock) { + SeekParameters seekParameters, + boolean pauseAtEndOfWindow, + Looper applicationLooper, + Clock clock, + PlaybackInfoUpdateListener playbackInfoUpdateListener) { + this.playbackInfoUpdateListener = playbackInfoUpdateListener; this.renderers = renderers; this.trackSelector = trackSelector; this.emptyTrackSelectorResult = emptyTrackSelectorResult; @@ -155,14 +222,14 @@ import java.util.concurrent.atomic.AtomicBoolean; this.bandwidthMeter = bandwidthMeter; this.repeatMode = repeatMode; this.shuffleModeEnabled = shuffleModeEnabled; - this.eventHandler = eventHandler; + this.seekParameters = seekParameters; + this.pauseAtEndOfWindow = pauseAtEndOfWindow; this.clock = clock; - this.queue = new MediaPeriodQueue(); + throwWhenStuckBuffering = true; backBufferDurationUs = loadControl.getBackBufferDurationUs(); retainBackBufferFromKeyframe = loadControl.retainBackBufferFromKeyframe(); - seekParameters = SeekParameters.DEFAULT; playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult); playbackInfoUpdate = new PlaybackInfoUpdate(playbackInfo); rendererCapabilities = new RendererCapabilities[renderers.length]; @@ -176,24 +243,33 @@ import java.util.concurrent.atomic.AtomicBoolean; period = new Timeline.Period(); trackSelector.init(/* listener= */ this, bandwidthMeter); + deliverPendingMessageAtStartPositionRequired = true; + + Handler eventHandler = new Handler(applicationLooper); + queue = new MediaPeriodQueue(analyticsCollector, eventHandler); + mediaSourceList = new MediaSourceList(/* listener= */ this, analyticsCollector, eventHandler); + // Note: The documentation for Process.THREAD_PRIORITY_AUDIO that states "Applications can // not normally change to this priority" is incorrect. internalPlaybackThread = new HandlerThread("ExoPlayer:Playback", Process.THREAD_PRIORITY_AUDIO); internalPlaybackThread.start(); - handler = clock.createHandler(internalPlaybackThread.getLooper(), this); - deliverPendingMessageAtStartPositionRequired = true; - mediaSourceList = new MediaSourceList(this); - if (analyticsCollector != null) { - mediaSourceList.setAnalyticsCollector(eventHandler, analyticsCollector); - } + playbackLooper = internalPlaybackThread.getLooper(); + handler = clock.createHandler(playbackLooper, this); } - public void experimental_setReleaseTimeoutMs(long releaseTimeoutMs) { + public void experimentalSetReleaseTimeoutMs(long releaseTimeoutMs) { this.releaseTimeoutMs = releaseTimeoutMs; } - public void experimental_throwWhenStuckBuffering() { - throwWhenStuckBuffering = true; + public void experimentalDisableThrowWhenStuckBuffering() { + throwWhenStuckBuffering = false; + } + + public void experimentalSetOffloadSchedulingEnabled(boolean offloadSchedulingEnabled) { + handler + .obtainMessage( + MSG_SET_OFFLOAD_SCHEDULING_ENABLED, offloadSchedulingEnabled ? 1 : 0, /* unused */ 0) + .sendToTarget(); } public void prepare() { @@ -227,16 +303,16 @@ import java.util.concurrent.atomic.AtomicBoolean; .sendToTarget(); } - public void setPlaybackSpeed(float playbackSpeed) { - handler.obtainMessage(MSG_SET_PLAYBACK_SPEED, playbackSpeed).sendToTarget(); + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + handler.obtainMessage(MSG_SET_PLAYBACK_PARAMETERS, playbackParameters).sendToTarget(); } public void setSeekParameters(SeekParameters seekParameters) { handler.obtainMessage(MSG_SET_SEEK_PARAMETERS, seekParameters).sendToTarget(); } - public void stop(boolean reset) { - handler.obtainMessage(MSG_STOP, reset ? 1 : 0, 0).sendToTarget(); + public void stop() { + handler.obtainMessage(MSG_STOP).sendToTarget(); } public void setMediaSources( @@ -293,29 +369,24 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.obtainMessage(MSG_SEND_MESSAGE, message).sendToTarget(); } - public synchronized void setForegroundMode(boolean foregroundMode) { + public synchronized boolean setForegroundMode(boolean foregroundMode) { if (released || !internalPlaybackThread.isAlive()) { - return; + return true; } if (foregroundMode) { handler.obtainMessage(MSG_SET_FOREGROUND_MODE, /* foregroundMode */ 1, 0).sendToTarget(); + return true; } else { AtomicBoolean processedFlag = new AtomicBoolean(); handler .obtainMessage(MSG_SET_FOREGROUND_MODE, /* foregroundMode */ 0, 0, processedFlag) .sendToTarget(); - boolean wasInterrupted = false; - while (!processedFlag.get()) { - try { - wait(); - } catch (InterruptedException e) { - wasInterrupted = true; - } - } - if (wasInterrupted) { - // Restore the interrupted status. - Thread.currentThread().interrupt(); + if (releaseTimeoutMs > 0) { + waitUninterruptibly(/* condition= */ processedFlag::get, releaseTimeoutMs); + } else { + waitUninterruptibly(/* condition= */ processedFlag::get); } + return processedFlag.get(); } } @@ -325,21 +396,16 @@ import java.util.concurrent.atomic.AtomicBoolean; } handler.sendEmptyMessage(MSG_RELEASE); - try { - if (releaseTimeoutMs > 0) { - waitUntilReleased(releaseTimeoutMs); - } else { - waitUntilReleased(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + if (releaseTimeoutMs > 0) { + waitUninterruptibly(/* condition= */ () -> released, releaseTimeoutMs); + } else { + waitUninterruptibly(/* condition= */ () -> released); } - return released; } public Looper getPlaybackLooper() { - return internalPlaybackThread.getLooper(); + return playbackLooper; } // Playlist.PlaylistInfoRefreshListener implementation. @@ -368,11 +434,11 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.sendEmptyMessage(MSG_TRACK_SELECTION_INVALIDATED); } - // DefaultMediaClock.PlaybackSpeedListener implementation. + // DefaultMediaClock.PlaybackParametersListener implementation. @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { - sendPlaybackSpeedChangedInternal(playbackSpeed, /* acknowledgeCommand= */ false); + public void onPlaybackParametersChanged(PlaybackParameters newPlaybackParameters) { + sendPlaybackParametersChangedInternal(newPlaybackParameters, /* acknowledgeCommand= */ false); } // Handler.Callback implementation. @@ -403,8 +469,8 @@ import java.util.concurrent.atomic.AtomicBoolean; case MSG_SEEK_TO: seekToInternal((SeekPosition) msg.obj); break; - case MSG_SET_PLAYBACK_SPEED: - setPlaybackSpeedInternal((Float) msg.obj); + case MSG_SET_PLAYBACK_PARAMETERS: + setPlaybackParametersInternal((PlaybackParameters) msg.obj); break; case MSG_SET_SEEK_PARAMETERS: setSeekParametersInternal((SeekParameters) msg.obj); @@ -414,10 +480,7 @@ import java.util.concurrent.atomic.AtomicBoolean; /* foregroundMode= */ msg.arg1 != 0, /* processedFlag= */ (AtomicBoolean) msg.obj); break; case MSG_STOP: - stopInternal( - /* forceResetRenderers= */ false, - /* resetPositionAndState= */ msg.arg1 != 0, - /* acknowledgeStop= */ true); + stopInternal(/* forceResetRenderers= */ false, /* acknowledgeStop= */ true); break; case MSG_PERIOD_PREPARED: handlePeriodPrepared((MediaPeriod) msg.obj); @@ -428,8 +491,9 @@ import java.util.concurrent.atomic.AtomicBoolean; case MSG_TRACK_SELECTION_INVALIDATED: reselectTracksInternal(); break; - case MSG_PLAYBACK_SPEED_CHANGED_INTERNAL: - handlePlaybackSpeed((Float) msg.obj, /* acknowledgeCommand= */ msg.arg1 != 0); + case MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL: + handlePlaybackParameters( + (PlaybackParameters) msg.obj, /* acknowledgeCommand= */ msg.arg1 != 0); break; case MSG_SEND_MESSAGE: sendMessageInternal((PlayerMessage) msg.obj); @@ -458,6 +522,9 @@ import java.util.concurrent.atomic.AtomicBoolean; case MSG_SET_PAUSE_AT_END_OF_WINDOW: setPauseAtEndOfWindowInternal(msg.arg1 != 0); break; + case MSG_SET_OFFLOAD_SCHEDULING_ENABLED: + setOffloadSchedulingEnabledInternal(msg.arg1 == 1); + break; case MSG_RELEASE: releaseInternal(); // Return immediately to not send playback info updates after release. @@ -467,32 +534,36 @@ import java.util.concurrent.atomic.AtomicBoolean; } maybeNotifyPlaybackInfoChanged(); } catch (ExoPlaybackException e) { + if (e.type == ExoPlaybackException.TYPE_RENDERER) { + @Nullable MediaPeriodHolder readingPeriod = queue.getReadingPeriod(); + if (readingPeriod != null) { + // We can assume that all renderer errors happen in the context of the reading period. See + // [internal: b/150584930#comment4] for exceptions that aren't covered by this assumption. + e = e.copyWithMediaPeriodId(readingPeriod.info.id); + } + } Log.e(TAG, "Playback error", e); - stopInternal( - /* forceResetRenderers= */ true, - /* resetPositionAndState= */ false, - /* acknowledgeStop= */ false); + stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false); playbackInfo = playbackInfo.copyWithPlaybackError(e); maybeNotifyPlaybackInfoChanged(); } catch (IOException e) { ExoPlaybackException error = ExoPlaybackException.createForSource(e); + @Nullable MediaPeriodHolder playingPeriod = queue.getPlayingPeriod(); + if (playingPeriod != null) { + // We ensure that all IOException throwing methods are only executed for the playing period. + error = error.copyWithMediaPeriodId(playingPeriod.info.id); + } Log.e(TAG, "Playback error", error); - stopInternal( - /* forceResetRenderers= */ false, - /* resetPositionAndState= */ false, - /* acknowledgeStop= */ false); + stopInternal(/* forceResetRenderers= */ false, /* acknowledgeStop= */ false); playbackInfo = playbackInfo.copyWithPlaybackError(error); maybeNotifyPlaybackInfoChanged(); } catch (RuntimeException | OutOfMemoryError e) { ExoPlaybackException error = e instanceof OutOfMemoryError - ? ExoPlaybackException.createForOutOfMemoryError((OutOfMemoryError) e) + ? ExoPlaybackException.createForOutOfMemory((OutOfMemoryError) e) : ExoPlaybackException.createForUnexpected((RuntimeException) e); Log.e(TAG, "Playback error", error); - stopInternal( - /* forceResetRenderers= */ true, - /* resetPositionAndState= */ false, - /* acknowledgeStop= */ false); + stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false); playbackInfo = playbackInfo.copyWithPlaybackError(error); maybeNotifyPlaybackInfoChanged(); } @@ -502,59 +573,54 @@ import java.util.concurrent.atomic.AtomicBoolean; // Private methods. /** - * Blocks the current thread until {@link #releaseInternal()} is executed on the playback Thread. + * Blocks the current thread until a condition becomes true. * - *

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

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

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

If the current thread is interrupted while waiting for the condition to become true, this + * method will restore the interrupt after the condition became true or the operation times + * out. * - * @param timeoutMs the time in milliseconds to wait for {@link #releaseInternal()} to complete. - * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for - * {@link #releaseInternal()} to complete. + * @param condition The condition. + * @param timeoutMs The time in milliseconds to wait for the condition to become true. */ - private synchronized void waitUntilReleased(long timeoutMs) throws InterruptedException { + private synchronized void waitUninterruptibly(Supplier condition, long timeoutMs) { long deadlineMs = clock.elapsedRealtime() + timeoutMs; long remainingMs = timeoutMs; - InterruptedException interruptedException = null; - while (!released && remainingMs > 0) { + boolean wasInterrupted = false; + while (!condition.get() && remainingMs > 0) { try { wait(remainingMs); } catch (InterruptedException e) { - interruptedException = e; + wasInterrupted = true; } remainingMs = deadlineMs - clock.elapsedRealtime(); } - - if (interruptedException != null) { - throw interruptedException; + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); } } @@ -567,7 +633,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private void maybeNotifyPlaybackInfoChanged() { playbackInfoUpdate.setPlaybackInfo(playbackInfo); if (playbackInfoUpdate.hasPendingChange) { - eventHandler.obtainMessage(MSG_PLAYBACK_INFO_CHANGED, playbackInfoUpdate).sendToTarget(); + playbackInfoUpdateListener.onPlaybackInfoUpdate(playbackInfoUpdate); playbackInfoUpdate = new PlaybackInfoUpdate(playbackInfo); } } @@ -578,7 +644,6 @@ import java.util.concurrent.atomic.AtomicBoolean; /* resetRenderers= */ false, /* resetPosition= */ false, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); loadControl.onPrepared(); setState(playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); @@ -592,7 +657,7 @@ import java.util.concurrent.atomic.AtomicBoolean; if (mediaSourceListUpdateMessage.windowIndex != C.INDEX_UNSET) { pendingInitialSeekPosition = new SeekPosition( - new MediaSourceList.PlaylistTimeline( + new PlaylistTimeline( mediaSourceListUpdateMessage.mediaSourceHolders, mediaSourceListUpdateMessage.shuffleOrder), mediaSourceListUpdateMessage.windowIndex, @@ -671,11 +736,26 @@ import java.util.concurrent.atomic.AtomicBoolean; private void setPauseAtEndOfWindowInternal(boolean pauseAtEndOfWindow) throws ExoPlaybackException { this.pauseAtEndOfWindow = pauseAtEndOfWindow; - if (queue.getReadingPeriod() != queue.getPlayingPeriod()) { - seekToCurrentPosition(/* sendDiscontinuity= */ true); - } resetPendingPauseAtEndOfPeriod(); - handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); + if (pendingPauseAtEndOfPeriod && queue.getReadingPeriod() != queue.getPlayingPeriod()) { + // When pausing is required, we need to set the streams of the playing period final. If we + // already started reading the next period, we need to flush the renderers. + seekToCurrentPosition(/* sendDiscontinuity= */ true); + handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); + } + } + + private void setOffloadSchedulingEnabledInternal(boolean offloadSchedulingEnabled) { + if (offloadSchedulingEnabled == this.offloadSchedulingEnabled) { + return; + } + this.offloadSchedulingEnabled = offloadSchedulingEnabled; + @Player.State int state = playbackInfo.playbackState; + if (offloadSchedulingEnabled || state == Player.STATE_ENDED || state == Player.STATE_IDLE) { + playbackInfo = playbackInfo.copyWithOffloadSchedulingEnabled(offloadSchedulingEnabled); + } else { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } } private void setRepeatModeInternal(@Player.RepeatMode int repeatMode) @@ -879,15 +959,19 @@ import java.util.concurrent.atomic.AtomicBoolean; throw new IllegalStateException("Playback stuck buffering and not loading"); } } + if (offloadSchedulingEnabled != playbackInfo.offloadSchedulingEnabled) { + playbackInfo = playbackInfo.copyWithOffloadSchedulingEnabled(offloadSchedulingEnabled); + } if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); } else { handler.removeMessages(MSG_DO_SOME_WORK); } + requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); } @@ -897,6 +981,14 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } + private void maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { + if (offloadSchedulingEnabled && requestForRendererSleep) { + return; + } + + scheduleNextWork(operationStartTimeMs, intervalMs); + } + private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -918,7 +1010,7 @@ import java.util.concurrent.atomic.AtomicBoolean; // The seek position was valid for the timeline that it was performed into, but the // timeline has changed or is not ready and a suitable seek position could not be resolved. Pair firstPeriodAndPosition = - getDummyFirstMediaPeriodPosition(playbackInfo.timeline); + getPlaceholderFirstMediaPeriodPosition(playbackInfo.timeline); periodId = firstPeriodAndPosition.first; periodPositionUs = firstPeriodAndPosition.second; requestedContentPosition = C.TIME_UNSET; @@ -958,7 +1050,6 @@ import java.util.concurrent.atomic.AtomicBoolean; /* resetRenderers= */ false, /* resetPosition= */ true, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); } else { // Execute the seek in the current media periods. @@ -1060,7 +1151,7 @@ import java.util.concurrent.atomic.AtomicBoolean; if (newPlayingPeriodHolder.info.durationUs != C.TIME_UNSET && periodPositionUs >= newPlayingPeriodHolder.info.durationUs) { // Make sure seek position doesn't exceed period duration. - periodPositionUs = Math.max(0, newPlayingPeriodHolder.info.durationUs - 1); + periodPositionUs = max(0, newPlayingPeriodHolder.info.durationUs - 1); } if (newPlayingPeriodHolder.hasEnabledTracks) { periodPositionUs = newPlayingPeriodHolder.mediaPeriod.seekToUs(periodPositionUs); @@ -1096,9 +1187,10 @@ import java.util.concurrent.atomic.AtomicBoolean; notifyTrackSelectionDiscontinuity(); } - private void setPlaybackSpeedInternal(float playbackSpeed) { - mediaClock.setPlaybackSpeed(playbackSpeed); - sendPlaybackSpeedChangedInternal(mediaClock.getPlaybackSpeed(), /* acknowledgeCommand= */ true); + private void setPlaybackParametersInternal(PlaybackParameters playbackParameters) { + mediaClock.setPlaybackParameters(playbackParameters); + sendPlaybackParametersChangedInternal( + mediaClock.getPlaybackParameters(), /* acknowledgeCommand= */ true); } private void setSeekParametersInternal(SeekParameters seekParameters) { @@ -1125,14 +1217,12 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - private void stopInternal( - boolean forceResetRenderers, boolean resetPositionAndState, boolean acknowledgeStop) { + private void stopInternal(boolean forceResetRenderers, boolean acknowledgeStop) { resetInternal( /* resetRenderers= */ forceResetRenderers || !foregroundMode, - /* resetPosition= */ resetPositionAndState, + /* resetPosition= */ false, /* releaseMediaSourceList= */ true, - /* clearMediaSourceList= */ resetPositionAndState, - /* resetError= */ resetPositionAndState); + /* resetError= */ false); playbackInfoUpdate.incrementPendingOperationAcks(acknowledgeStop ? 1 : 0); loadControl.onStopped(); setState(Player.STATE_IDLE); @@ -1141,9 +1231,8 @@ import java.util.concurrent.atomic.AtomicBoolean; private void releaseInternal() { resetInternal( /* resetRenderers= */ true, - /* resetPosition= */ true, + /* resetPosition= */ false, /* releaseMediaSourceList= */ true, - /* clearMediaSourceList= */ true, /* resetError= */ false); loadControl.onReleased(); setState(Player.STATE_IDLE); @@ -1158,7 +1247,6 @@ import java.util.concurrent.atomic.AtomicBoolean; boolean resetRenderers, boolean resetPosition, boolean releaseMediaSourceList, - boolean clearMediaSourceList, boolean resetError) { handler.removeMessages(MSG_DO_SOME_WORK); rebuffering = false; @@ -1184,25 +1272,17 @@ import java.util.concurrent.atomic.AtomicBoolean; } enabledRendererCount = 0; - Timeline timeline = playbackInfo.timeline; - if (clearMediaSourceList) { - timeline = mediaSourceList.clear(/* shuffleOrder= */ null); - for (PendingMessageInfo pendingMessageInfo : pendingMessages) { - pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); - } - pendingMessages.clear(); - resetPosition = true; - } MediaPeriodId mediaPeriodId = playbackInfo.periodId; long startPositionUs = playbackInfo.positionUs; long requestedContentPositionUs = shouldUseRequestedContentPosition(playbackInfo, period, window) ? playbackInfo.requestedContentPositionUs : playbackInfo.positionUs; - boolean resetTrackInfo = clearMediaSourceList; + boolean resetTrackInfo = false; if (resetPosition) { pendingInitialSeekPosition = null; - Pair firstPeriodAndPosition = getDummyFirstMediaPeriodPosition(timeline); + Pair firstPeriodAndPosition = + getPlaceholderFirstMediaPeriodPosition(playbackInfo.timeline); mediaPeriodId = firstPeriodAndPosition.first; startPositionUs = firstPeriodAndPosition.second; requestedContentPositionUs = C.TIME_UNSET; @@ -1216,7 +1296,7 @@ import java.util.concurrent.atomic.AtomicBoolean; playbackInfo = new PlaybackInfo( - timeline, + playbackInfo.timeline, mediaPeriodId, requestedContentPositionUs, playbackInfo.playbackState, @@ -1227,15 +1307,17 @@ import java.util.concurrent.atomic.AtomicBoolean; mediaPeriodId, playbackInfo.playWhenReady, playbackInfo.playbackSuppressionReason, + playbackInfo.playbackParameters, startPositionUs, /* totalBufferedDurationUs= */ 0, - startPositionUs); + startPositionUs, + offloadSchedulingEnabled); if (releaseMediaSourceList) { mediaSourceList.release(); } } - private Pair getDummyFirstMediaPeriodPosition(Timeline timeline) { + private Pair getPlaceholderFirstMediaPeriodPosition(Timeline timeline) { if (timeline.isEmpty()) { return Pair.create(PlaybackInfo.getDummyPeriodForEmptyTimeline(), 0L); } @@ -1285,7 +1367,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } private void sendMessageToTarget(PlayerMessage message) throws ExoPlaybackException { - if (message.getHandler().getLooper() == handler.getLooper()) { + if (message.getHandler().getLooper() == playbackLooper) { deliverMessage(message); if (playbackInfo.playbackState == Player.STATE_READY || playbackInfo.playbackState == Player.STATE_BUFFERING) { @@ -1364,7 +1446,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()); + int nextPendingMessageIndex = min(nextPendingMessageIndexHint, pendingMessages.size()); PendingMessageInfo previousInfo = nextPendingMessageIndex > 0 ? pendingMessages.get(nextPendingMessageIndex - 1) : null; while (previousInfo != null @@ -1430,7 +1512,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } private void reselectTracksInternal() throws ExoPlaybackException { - float playbackSpeed = mediaClock.getPlaybackSpeed(); + float playbackSpeed = mediaClock.getPlaybackParameters().speed; // Reselect tracks on each period in turn, until the selection changes. MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); @@ -1492,8 +1574,7 @@ import java.util.concurrent.atomic.AtomicBoolean; queue.removeAfter(periodHolder); if (periodHolder.prepared) { long loadingPeriodPositionUs = - Math.max( - periodHolder.info.startPositionUs, periodHolder.toPeriodTime(rendererPositionUs)); + max(periodHolder.info.startPositionUs, periodHolder.toPeriodTime(rendererPositionUs)); periodHolder.applyTrackSelection(newTrackSelectorResult, loadingPeriodPositionUs, false); } } @@ -1549,7 +1630,7 @@ import java.util.concurrent.atomic.AtomicBoolean; boolean bufferedToEnd = loadingHolder.isFullyBuffered() && loadingHolder.info.isFinal; return bufferedToEnd || loadControl.shouldStartPlayback( - getTotalBufferedDurationUs(), mediaClock.getPlaybackSpeed(), rebuffering); + getTotalBufferedDurationUs(), mediaClock.getPlaybackParameters().speed, rebuffering); } private boolean isTimelineReady() { @@ -1561,19 +1642,6 @@ import java.util.concurrent.atomic.AtomicBoolean; || !shouldPlayWhenReady()); } - private void maybeThrowSourceInfoRefreshError() throws IOException { - MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); - if (loadingPeriodHolder != null) { - // Defer throwing until we read all available media periods. - for (Renderer renderer : renderers) { - if (isRendererEnabled(renderer) && !renderer.hasReadStreamToEnd()) { - return; - } - } - } - mediaSourceList.maybeThrowSourceInfoRefreshError(); - } - private void handleMediaSourceListInfoRefreshed(Timeline timeline) throws ExoPlaybackException { PositionUpdateForPlaylistChange positionUpdate = resolvePositionForPlaylistChange( @@ -1601,7 +1669,6 @@ import java.util.concurrent.atomic.AtomicBoolean; /* resetRenderers= */ false, /* resetPosition= */ false, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); } if (!periodPositionChanged) { @@ -1659,7 +1726,7 @@ import java.util.concurrent.atomic.AtomicBoolean; if (readingPositionUs == C.TIME_END_OF_SOURCE) { return C.TIME_END_OF_SOURCE; } else { - maxReadPositionUs = Math.max(readingPositionUs, maxReadPositionUs); + maxReadPositionUs = max(readingPositionUs, maxReadPositionUs); } } return maxReadPositionUs; @@ -1667,8 +1734,7 @@ import java.util.concurrent.atomic.AtomicBoolean; private void updatePeriods() throws ExoPlaybackException, IOException { if (playbackInfo.timeline.isEmpty() || !mediaSourceList.isPrepared()) { - // We're waiting to get information about periods. - mediaSourceList.maybeThrowSourceInfoRefreshError(); + // No periods available. return; } maybeUpdateLoadingPeriod(); @@ -1677,13 +1743,12 @@ import java.util.concurrent.atomic.AtomicBoolean; maybeUpdatePlayingPeriod(); } - private void maybeUpdateLoadingPeriod() throws ExoPlaybackException, IOException { + private void maybeUpdateLoadingPeriod() throws ExoPlaybackException { queue.reevaluateBuffer(rendererPositionUs); if (queue.shouldLoadNextMediaPeriod()) { + @Nullable MediaPeriodInfo info = queue.getNextMediaPeriodInfo(rendererPositionUs, playbackInfo); - if (info == null) { - maybeThrowSourceInfoRefreshError(); - } else { + if (info != null) { MediaPeriodHolder mediaPeriodHolder = queue.enqueueNextMediaPeriodHolder( rendererCapabilities, @@ -1807,7 +1872,10 @@ import java.util.concurrent.atomic.AtomicBoolean; // The renderer stream is not final, so we can replace the sample streams immediately. Format[] formats = getFormats(newTrackSelectorResult.selections.get(i)); renderer.replaceStream( - formats, readingPeriodHolder.sampleStreams[i], readingPeriodHolder.getRendererOffset()); + formats, + readingPeriodHolder.sampleStreams[i], + readingPeriodHolder.getStartPositionRendererTime(), + readingPeriodHolder.getRendererOffset()); } else if (renderer.isEnded()) { // The renderer has finished playback, so we can disable it now. disableRenderer(renderer); @@ -1898,7 +1966,8 @@ import java.util.concurrent.atomic.AtomicBoolean; return; } MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); - loadingPeriodHolder.handlePrepared(mediaClock.getPlaybackSpeed(), playbackInfo.timeline); + loadingPeriodHolder.handlePrepared( + mediaClock.getPlaybackParameters().speed, playbackInfo.timeline); updateLoadControlTrackSelection( loadingPeriodHolder.getTrackGroups(), loadingPeriodHolder.getTrackSelectorResult()); if (loadingPeriodHolder == queue.getPlayingPeriod()) { @@ -1923,15 +1992,15 @@ import java.util.concurrent.atomic.AtomicBoolean; maybeContinueLoading(); } - private void handlePlaybackSpeed(float playbackSpeed, boolean acknowledgeCommand) + private void handlePlaybackParameters( + PlaybackParameters playbackParameters, boolean acknowledgeCommand) throws ExoPlaybackException { - eventHandler - .obtainMessage(MSG_PLAYBACK_SPEED_CHANGED, acknowledgeCommand ? 1 : 0, 0, playbackSpeed) - .sendToTarget(); - updateTrackSelectionPlaybackSpeed(playbackSpeed); + playbackInfoUpdate.incrementPendingOperationAcks(acknowledgeCommand ? 1 : 0); + playbackInfo = playbackInfo.copyWithPlaybackParameters(playbackParameters); + updateTrackSelectionPlaybackSpeed(playbackParameters.speed); for (Renderer renderer : renderers) { if (renderer != null) { - renderer.setOperatingRate(playbackSpeed); + renderer.setOperatingRate(playbackParameters.speed); } } } @@ -1957,7 +2026,7 @@ import java.util.concurrent.atomic.AtomicBoolean; : loadingPeriodHolder.toPeriodTime(rendererPositionUs) - loadingPeriodHolder.info.startPositionUs; return loadControl.shouldContinueLoading( - playbackPositionUs, bufferedDurationUs, mediaClock.getPlaybackSpeed()); + playbackPositionUs, bufferedDurationUs, mediaClock.getPlaybackParameters().speed); } private boolean isLoadingPossible() { @@ -2064,7 +2133,26 @@ import java.util.concurrent.atomic.AtomicBoolean; rendererPositionUs, joining, mayRenderStartOfStream, + periodHolder.getStartPositionRendererTime(), periodHolder.getRendererOffset()); + + renderer.handleMessage( + Renderer.MSG_SET_WAKEUP_LISTENER, + new Renderer.WakeupListener() { + @Override + public void onSleep(long wakeupDeadlineMs) { + // Do not sleep if the expected sleep time is not long enough to save significant power. + if (wakeupDeadlineMs >= MIN_RENDERER_SLEEP_DURATION_MS) { + requestForRendererSleep = true; + } + } + + @Override + public void onWakeup() { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + }); + mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. if (playing) { @@ -2106,7 +2194,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } long totalBufferedDurationUs = bufferedPositionInLoadingPeriodUs - loadingPeriodHolder.toPeriodTime(rendererPositionUs); - return Math.max(0, totalBufferedDurationUs); + return max(0, totalBufferedDurationUs); } private void updateLoadControlTrackSelection( @@ -2114,10 +2202,14 @@ import java.util.concurrent.atomic.AtomicBoolean; loadControl.onTracksSelected(renderers, trackGroups, trackSelectorResult.selections); } - private void sendPlaybackSpeedChangedInternal(float playbackSpeed, boolean acknowledgeCommand) { + private void sendPlaybackParametersChangedInternal( + PlaybackParameters playbackParameters, boolean acknowledgeCommand) { handler .obtainMessage( - MSG_PLAYBACK_SPEED_CHANGED_INTERNAL, acknowledgeCommand ? 1 : 0, 0, playbackSpeed) + MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL, + acknowledgeCommand ? 1 : 0, + 0, + playbackParameters) .sendToTarget(); } @@ -2619,50 +2711,4 @@ import java.util.concurrent.atomic.AtomicBoolean; this.shuffleOrder = shuffleOrder; } } - - /* package */ static final class PlaybackInfoUpdate { - - private boolean hasPendingChange; - - public PlaybackInfo playbackInfo; - public int operationAcks; - public boolean positionDiscontinuity; - @DiscontinuityReason public int discontinuityReason; - public boolean hasPlayWhenReadyChangeReason; - @PlayWhenReadyChangeReason public int playWhenReadyChangeReason; - - public PlaybackInfoUpdate(PlaybackInfo playbackInfo) { - this.playbackInfo = playbackInfo; - } - - public void incrementPendingOperationAcks(int operationAcks) { - hasPendingChange |= operationAcks > 0; - this.operationAcks += operationAcks; - } - - public void setPlaybackInfo(PlaybackInfo playbackInfo) { - hasPendingChange |= this.playbackInfo != playbackInfo; - this.playbackInfo = playbackInfo; - } - - public void setPositionDiscontinuity(@DiscontinuityReason int discontinuityReason) { - if (positionDiscontinuity - && this.discontinuityReason != Player.DISCONTINUITY_REASON_INTERNAL) { - // We always prefer non-internal discontinuity reasons. We also assume that we won't report - // more than one non-internal discontinuity per message iteration. - Assertions.checkArgument(discontinuityReason == Player.DISCONTINUITY_REASON_INTERNAL); - return; - } - hasPendingChange = true; - positionDiscontinuity = true; - this.discontinuityReason = discontinuityReason; - } - - public void setPlayWhenReadyChangeReason( - @PlayWhenReadyChangeReason int playWhenReadyChangeReason) { - hasPendingChange = true; - this.hasPlayWhenReadyChangeReason = true; - this.playWhenReadyChangeReason = playWhenReadyChangeReason; - } - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java index 3c7a41439a..65f40e9c61 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import static java.lang.Math.max; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.ClippingMediaPeriod; import com.google.android.exoplayer2.source.EmptySampleStream; @@ -182,7 +184,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; long requestedStartPositionUs = info.startPositionUs; if (info.durationUs != C.TIME_UNSET && requestedStartPositionUs >= info.durationUs) { // Make sure start position doesn't exceed period duration. - requestedStartPositionUs = Math.max(0, info.durationUs - 1); + requestedStartPositionUs = max(0, info.durationUs - 1); } long newStartPositionUs = applyTrackSelection( @@ -297,7 +299,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; positionUs); associateNoSampleRenderersWithEmptySampleStream(sampleStreams); - // Update whether we have enabled tracks and sanity check the expected streams are non-null. + // Update whether we have enabled tracks and check that the expected streams are non-null. hasEnabledTracks = false; for (int i = 0; i < sampleStreams.length; i++) { if (sampleStreams[i] != null) { @@ -380,7 +382,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * For each renderer of type {@link C#TRACK_TYPE_NONE}, we will remove the dummy {@link + * For each renderer of type {@link C#TRACK_TYPE_NONE}, we will remove the {@link * EmptySampleStream} that was associated with it. */ private void disassociateNoSampleRenderersWithEmptySampleStream( @@ -394,7 +396,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** * For each renderer of type {@link C#TRACK_TYPE_NONE} that was enabled, we will associate it with - * a dummy {@link EmptySampleStream}. + * an {@link EmptySampleStream}. */ private void associateNoSampleRenderersWithEmptySampleStream( @NullableType SampleStream[] sampleStreams) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index a749f09f93..b64a9c8087 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -15,15 +15,20 @@ */ package com.google.android.exoplayer2; +import static java.lang.Math.max; + +import android.os.Handler; import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player.RepeatMode; +import com.google.android.exoplayer2.analytics.AnalyticsCollector; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.util.Assertions; +import com.google.common.collect.ImmutableList; /** * Holds a queue of media periods, from the currently playing media period at the front to the @@ -41,6 +46,8 @@ import com.google.android.exoplayer2.util.Assertions; private final Timeline.Period period; private final Timeline.Window window; + @Nullable private final AnalyticsCollector analyticsCollector; + private final Handler analyticsCollectorHandler; private long nextWindowSequenceNumber; private @RepeatMode int repeatMode; @@ -52,8 +59,18 @@ import com.google.android.exoplayer2.util.Assertions; @Nullable private Object oldFrontPeriodUid; private long oldFrontPeriodWindowSequenceNumber; - /** Creates a new media period queue. */ - public MediaPeriodQueue() { + /** + * Creates a new media period queue. + * + * @param analyticsCollector An optional {@link AnalyticsCollector} to be informed of queue + * changes. + * @param analyticsCollectorHandler The {@link Handler} to call {@link AnalyticsCollector} methods + * on. + */ + public MediaPeriodQueue( + @Nullable AnalyticsCollector analyticsCollector, Handler analyticsCollectorHandler) { + this.analyticsCollector = analyticsCollector; + this.analyticsCollectorHandler = analyticsCollectorHandler; period = new Timeline.Period(); window = new Timeline.Window(); } @@ -168,6 +185,7 @@ import com.google.android.exoplayer2.util.Assertions; oldFrontPeriodUid = null; loading = newPeriodHolder; length++; + notifyQueueUpdate(); return newPeriodHolder; } @@ -203,6 +221,7 @@ import com.google.android.exoplayer2.util.Assertions; public MediaPeriodHolder advanceReadingPeriod() { Assertions.checkState(reading != null && reading.getNext() != null); reading = reading.getNext(); + notifyQueueUpdate(); return reading; } @@ -228,6 +247,7 @@ import com.google.android.exoplayer2.util.Assertions; oldFrontPeriodWindowSequenceNumber = playing.info.id.windowSequenceNumber; } playing = playing.getNext(); + notifyQueueUpdate(); return playing; } @@ -241,6 +261,9 @@ import com.google.android.exoplayer2.util.Assertions; */ public boolean removeAfter(MediaPeriodHolder mediaPeriodHolder) { Assertions.checkState(mediaPeriodHolder != null); + if (mediaPeriodHolder.equals(loading)) { + return false; + } boolean removedReading = false; loading = mediaPeriodHolder; while (mediaPeriodHolder.getNext() != null) { @@ -253,22 +276,27 @@ import com.google.android.exoplayer2.util.Assertions; length--; } loading.setNext(null); + notifyQueueUpdate(); return removedReading; } /** Clears the queue. */ public void clear() { - MediaPeriodHolder front = playing; - if (front != null) { - oldFrontPeriodUid = front.uid; - oldFrontPeriodWindowSequenceNumber = front.info.id.windowSequenceNumber; - removeAfter(front); + if (length == 0) { + return; + } + MediaPeriodHolder front = Assertions.checkStateNotNull(playing); + oldFrontPeriodUid = front.uid; + oldFrontPeriodWindowSequenceNumber = front.info.id.windowSequenceNumber; + while (front != null) { front.release(); + front = front.getNext(); } playing = null; loading = null; reading = null; length = 0; + notifyQueueUpdate(); } /** @@ -392,6 +420,20 @@ import com.google.android.exoplayer2.util.Assertions; // Internal methods. + private void notifyQueueUpdate() { + if (analyticsCollector != null) { + ImmutableList.Builder builder = ImmutableList.builder(); + @Nullable MediaPeriodHolder period = playing; + while (period != null) { + builder.add(period.info.id); + period = period.getNext(); + } + @Nullable MediaPeriodId readingPeriodId = reading == null ? null : reading.info.id; + analyticsCollectorHandler.post( + () -> analyticsCollector.updateMediaPeriodQueueInfo(builder.build(), readingPeriodId)); + } + } + /** * Resolves the specified timeline period and position to a {@link MediaPeriodId} that should be * played, returning an identifier for an ad group if one needs to be played before the specified @@ -535,6 +577,7 @@ import com.google.android.exoplayer2.util.Assertions; /** * Returns the first {@link MediaPeriodInfo} to play, based on the specified playback position. */ + @Nullable private MediaPeriodInfo getFirstMediaPeriodInfo(PlaybackInfo playbackInfo) { return getMediaPeriodInfo( playbackInfo.timeline, @@ -594,7 +637,7 @@ import com.google.android.exoplayer2.util.Assertions; period, nextWindowIndex, /* windowPositionUs= */ C.TIME_UNSET, - /* defaultPositionProjectionUs= */ Math.max(0, bufferedDurationUs)); + /* defaultPositionProjectionUs= */ max(0, bufferedDurationUs)); if (defaultPosition == null) { return null; } @@ -651,7 +694,7 @@ import com.google.android.exoplayer2.util.Assertions; period, period.windowIndex, /* windowPositionUs= */ C.TIME_UNSET, - /* defaultPositionProjectionUs= */ Math.max(0, bufferedDurationUs)); + /* defaultPositionProjectionUs= */ max(0, bufferedDurationUs)); if (defaultPosition == null) { return null; } @@ -689,6 +732,7 @@ import com.google.android.exoplayer2.util.Assertions; } } + @Nullable private MediaPeriodInfo getMediaPeriodInfo( Timeline timeline, MediaPeriodId id, long requestedContentPositionUs, long startPositionUs) { timeline.getPeriodByUid(id.periodUid, period); @@ -732,7 +776,7 @@ import com.google.android.exoplayer2.util.Assertions; : 0; if (durationUs != C.TIME_UNSET && startPositionUs >= durationUs) { // Ensure start position doesn't exceed duration. - startPositionUs = Math.max(0, durationUs - 1); + startPositionUs = max(0, durationUs - 1); } return new MediaPeriodInfo( id, @@ -767,7 +811,7 @@ import com.google.android.exoplayer2.util.Assertions; : endPositionUs; if (durationUs != C.TIME_UNSET && startPositionUs >= durationUs) { // Ensure start position doesn't exceed duration. - startPositionUs = Math.max(0, durationUs - 1); + startPositionUs = max(0, durationUs - 1); } return new MediaPeriodInfo( id, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceInfoHolder.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceInfoHolder.java new file mode 100644 index 0000000000..f8624995ad --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceInfoHolder.java @@ -0,0 +1,28 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import com.google.android.exoplayer2.source.MediaSource; + +/** A holder of information about a {@link MediaSource}. */ +/* package */ interface MediaSourceInfoHolder { + + /** Returns the uid of the {@link MediaSourceList.MediaSourceHolder}. */ + Object getUid(); + + /** Returns the timeline. */ + Timeline getTimeline(); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java index e690ea3626..1227dbb397 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaSourceList.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.analytics.AnalyticsCollector; @@ -35,8 +38,6 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; @@ -52,7 +53,7 @@ import java.util.Set; * *

With the exception of the constructor, all methods are called on the playback thread. */ -/* package */ class MediaSourceList { +/* package */ final class MediaSourceList { /** Listener for source events. */ public interface MediaSourceListInfoRefreshListener { @@ -69,10 +70,11 @@ import java.util.Set; private static final String TAG = "MediaSourceList"; private final List mediaSourceHolders; - private final Map mediaSourceByMediaPeriod; + private final IdentityHashMap mediaSourceByMediaPeriod; private final Map mediaSourceByUid; private final MediaSourceListInfoRefreshListener mediaSourceListInfoListener; - private final MediaSourceEventListener.EventDispatcher eventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final HashMap childSources; private final Set enabledMediaSourceHolders; @@ -81,16 +83,33 @@ import java.util.Set; @Nullable private TransferListener mediaTransferListener; - @SuppressWarnings("initialization") - public MediaSourceList(MediaSourceListInfoRefreshListener listener) { + /** + * Creates the media source list. + * + * @param listener The {@link MediaSourceListInfoRefreshListener} to be informed of timeline + * changes. + * @param analyticsCollector An optional {@link AnalyticsCollector} to be registered for media + * source events. + * @param analyticsCollectorHandler The {@link Handler} to call {@link AnalyticsCollector} methods + * on. + */ + public MediaSourceList( + MediaSourceListInfoRefreshListener listener, + @Nullable AnalyticsCollector analyticsCollector, + Handler analyticsCollectorHandler) { mediaSourceListInfoListener = listener; shuffleOrder = new DefaultShuffleOrder(0); mediaSourceByMediaPeriod = new IdentityHashMap<>(); mediaSourceByUid = new HashMap<>(); mediaSourceHolders = new ArrayList<>(); - eventDispatcher = new MediaSourceEventListener.EventDispatcher(); + mediaSourceEventDispatcher = new MediaSourceEventListener.EventDispatcher(); + drmEventDispatcher = new DrmSessionEventListener.EventDispatcher(); childSources = new HashMap<>(); enabledMediaSourceHolders = new HashSet<>(); + if (analyticsCollector != null) { + mediaSourceEventDispatcher.addEventListener(analyticsCollectorHandler, analyticsCollector); + drmEventDispatcher.addEventListener(analyticsCollectorHandler, analyticsCollector); + } } /** @@ -100,8 +119,7 @@ import java.util.Set; * @param shuffleOrder The new shuffle order. * @return The new {@link Timeline}. */ - public final Timeline setMediaSources( - List holders, ShuffleOrder shuffleOrder) { + public Timeline setMediaSources(List holders, ShuffleOrder shuffleOrder) { removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size()); return addMediaSources(/* index= */ this.mediaSourceHolders.size(), holders, shuffleOrder); } @@ -115,7 +133,7 @@ import java.util.Set; * @param shuffleOrder The new shuffle order. * @return The new {@link Timeline}. */ - public final Timeline addMediaSources( + public Timeline addMediaSources( int index, List holders, ShuffleOrder shuffleOrder) { if (!holders.isEmpty()) { this.shuffleOrder = shuffleOrder; @@ -165,8 +183,7 @@ import java.util.Set; * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0, * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex} */ - public final Timeline removeMediaSourceRange( - int fromIndex, int toIndex, ShuffleOrder shuffleOrder) { + public Timeline removeMediaSourceRange(int fromIndex, int toIndex, ShuffleOrder shuffleOrder) { Assertions.checkArgument(fromIndex >= 0 && fromIndex <= toIndex && toIndex <= getSize()); this.shuffleOrder = shuffleOrder; removeMediaSourcesInternal(fromIndex, toIndex); @@ -185,7 +202,7 @@ import java.util.Set; * @throws IllegalArgumentException When an index is invalid, i.e. {@code currentIndex} < 0, * {@code currentIndex} >= {@link #getSize()}, {@code newIndex} < 0 */ - public final Timeline moveMediaSource(int currentIndex, int newIndex, ShuffleOrder shuffleOrder) { + public Timeline moveMediaSource(int currentIndex, int newIndex, ShuffleOrder shuffleOrder) { return moveMediaSourceRange(currentIndex, currentIndex + 1, newIndex, shuffleOrder); } @@ -214,11 +231,11 @@ import java.util.Set; if (fromIndex == toIndex || fromIndex == newFromIndex) { return createTimeline(); } - int startIndex = Math.min(fromIndex, newFromIndex); + int startIndex = min(fromIndex, newFromIndex); int newEndIndex = newFromIndex + (toIndex - fromIndex) - 1; - int endIndex = Math.max(newEndIndex, toIndex - 1); + int endIndex = max(newEndIndex, toIndex - 1); int windowOffset = mediaSourceHolders.get(startIndex).firstWindowIndexInChild; - moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex); + Util.moveItems(mediaSourceHolders, fromIndex, toIndex, newFromIndex); for (int i = startIndex; i <= endIndex; i++) { MediaSourceHolder holder = mediaSourceHolders.get(i); holder.firstWindowIndexInChild = windowOffset; @@ -228,39 +245,28 @@ import java.util.Set; } /** Clears the playlist. */ - public final Timeline clear(@Nullable ShuffleOrder shuffleOrder) { + public Timeline clear(@Nullable ShuffleOrder shuffleOrder) { this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear(); removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ getSize()); return createTimeline(); } /** Whether the playlist is prepared. */ - public final boolean isPrepared() { + public boolean isPrepared() { return isPrepared; } /** Returns the number of media sources in the playlist. */ - public final int getSize() { + public int getSize() { return mediaSourceHolders.size(); } - /** - * Sets the {@link AnalyticsCollector}. - * - * @param handler The handler on which to call the collector. - * @param analyticsCollector The analytics collector. - */ - public final void setAnalyticsCollector(Handler handler, AnalyticsCollector analyticsCollector) { - eventDispatcher.addEventListener(handler, analyticsCollector, MediaSourceEventListener.class); - eventDispatcher.addEventListener(handler, analyticsCollector, DrmSessionEventListener.class); - } - /** * Sets a new shuffle order to use when shuffling the child media sources. * * @param shuffleOrder A {@link ShuffleOrder}. */ - public final Timeline setShuffleOrder(ShuffleOrder shuffleOrder) { + public Timeline setShuffleOrder(ShuffleOrder shuffleOrder) { int size = getSize(); if (shuffleOrder.getLength() != size) { shuffleOrder = @@ -273,7 +279,7 @@ import java.util.Set; } /** Prepares the playlist. */ - public final void prepare(@Nullable TransferListener mediaTransferListener) { + public void prepare(@Nullable TransferListener mediaTransferListener) { Assertions.checkState(!isPrepared); this.mediaTransferListener = mediaTransferListener; for (int i = 0; i < mediaSourceHolders.size(); i++) { @@ -312,7 +318,7 @@ import java.util.Set; * * @param mediaPeriod The period to release. */ - public final void releasePeriod(MediaPeriod mediaPeriod) { + public void releasePeriod(MediaPeriod mediaPeriod) { MediaSourceHolder holder = Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); holder.mediaSource.releasePeriod(mediaPeriod); @@ -324,7 +330,7 @@ import java.util.Set; } /** Releases the playlist. */ - public final void release() { + public void release() { for (MediaSourceAndListener childSource : childSources.values()) { try { childSource.mediaSource.releaseSource(childSource.caller); @@ -339,15 +345,8 @@ import java.util.Set; isPrepared = false; } - /** Throws any pending error encountered while loading or refreshing. */ - public final void maybeThrowSourceInfoRefreshError() throws IOException { - for (MediaSourceAndListener childSource : childSources.values()) { - childSource.mediaSource.maybeThrowSourceInfoRefreshError(); - } - } - /** Creates a timeline reflecting the current state of the playlist. */ - public final Timeline createTimeline() { + public Timeline createTimeline() { if (mediaSourceHolders.isEmpty()) { return Timeline.EMPTY; } @@ -437,8 +436,8 @@ import java.util.Set; (source, timeline) -> mediaSourceListInfoListener.onPlaylistUpdateRequested(); ForwardingEventListener eventListener = new ForwardingEventListener(holder); childSources.put(holder, new MediaSourceAndListener(mediaSource, caller, eventListener)); - mediaSource.addEventListener(Util.createHandler(), eventListener); - mediaSource.addDrmEventListener(Util.createHandler(), eventListener); + mediaSource.addEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); + mediaSource.addDrmEventListener(Util.createHandlerForCurrentOrMainLooper(), eventListener); mediaSource.prepareSource(caller, mediaTransferListener); } @@ -467,18 +466,8 @@ import java.util.Set; return PlaylistTimeline.getConcatenatedUid(holder.uid, childPeriodUid); } - /* package */ static void moveMediaSourceHolders( - List mediaSourceHolders, int fromIndex, int toIndex, int newFromIndex) { - MediaSourceHolder[] removedItems = new MediaSourceHolder[toIndex - fromIndex]; - for (int i = removedItems.length - 1; i >= 0; i--) { - removedItems[i] = mediaSourceHolders.remove(fromIndex + i); - } - mediaSourceHolders.addAll( - Math.min(newFromIndex, mediaSourceHolders.size()), Arrays.asList(removedItems)); - } - /** Data class to hold playlist media sources together with meta data needed to process them. */ - /* package */ static final class MediaSourceHolder { + /* package */ static final class MediaSourceHolder implements MediaSourceInfoHolder { public final MaskingMediaSource mediaSource; public final Object uid; @@ -498,88 +487,15 @@ import java.util.Set; this.isRemoved = false; this.activeMediaPeriodIds.clear(); } - } - /** Timeline exposing concatenated timelines of playlist media sources. */ - /* package */ static final class PlaylistTimeline extends AbstractConcatenatedTimeline { - - private final int windowCount; - private final int periodCount; - private final int[] firstPeriodInChildIndices; - private final int[] firstWindowInChildIndices; - private final Timeline[] timelines; - private final Object[] uids; - private final HashMap childIndexByUid; - - public PlaylistTimeline( - Collection mediaSourceHolders, ShuffleOrder shuffleOrder) { - super(/* isAtomic= */ false, shuffleOrder); - int childCount = mediaSourceHolders.size(); - firstPeriodInChildIndices = new int[childCount]; - firstWindowInChildIndices = new int[childCount]; - timelines = new Timeline[childCount]; - uids = new Object[childCount]; - childIndexByUid = new HashMap<>(); - int index = 0; - int windowCount = 0; - int periodCount = 0; - for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { - timelines[index] = mediaSourceHolder.mediaSource.getTimeline(); - firstWindowInChildIndices[index] = windowCount; - firstPeriodInChildIndices[index] = periodCount; - windowCount += timelines[index].getWindowCount(); - periodCount += timelines[index].getPeriodCount(); - uids[index] = mediaSourceHolder.uid; - childIndexByUid.put(uids[index], index++); - } - this.windowCount = windowCount; - this.periodCount = periodCount; + @Override + public Object getUid() { + return uid; } @Override - protected int getChildIndexByPeriodIndex(int periodIndex) { - return Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex + 1, false, false); - } - - @Override - protected int getChildIndexByWindowIndex(int windowIndex) { - return Util.binarySearchFloor(firstWindowInChildIndices, windowIndex + 1, false, false); - } - - @Override - protected int getChildIndexByChildUid(Object childUid) { - Integer index = childIndexByUid.get(childUid); - return index == null ? C.INDEX_UNSET : index; - } - - @Override - protected Timeline getTimelineByChildIndex(int childIndex) { - return timelines[childIndex]; - } - - @Override - protected int getFirstPeriodIndexByChildIndex(int childIndex) { - return firstPeriodInChildIndices[childIndex]; - } - - @Override - protected int getFirstWindowIndexByChildIndex(int childIndex) { - return firstWindowInChildIndices[childIndex]; - } - - @Override - protected Object getChildUidByChildIndex(int childIndex) { - return uids[childIndex]; - } - - @Override - public int getWindowCount() { - return windowCount; - } - - @Override - public int getPeriodCount() { - return periodCount; + public Timeline getTimeline() { + return mediaSource.getTimeline(); } } @@ -603,29 +519,17 @@ import java.util.Set; implements MediaSourceEventListener, DrmSessionEventListener { private final MediaSourceList.MediaSourceHolder id; - private EventDispatcher eventDispatcher; + private MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; + private DrmSessionEventListener.EventDispatcher drmEventDispatcher; public ForwardingEventListener(MediaSourceList.MediaSourceHolder id) { - eventDispatcher = MediaSourceList.this.eventDispatcher; + mediaSourceEventDispatcher = MediaSourceList.this.mediaSourceEventDispatcher; + drmEventDispatcher = MediaSourceList.this.drmEventDispatcher; this.id = id; } // MediaSourceEventListener implementation - @Override - public void onMediaPeriodCreated(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.mediaPeriodCreated(); - } - } - - @Override - public void onMediaPeriodReleased(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.mediaPeriodReleased(); - } - } - @Override public void onLoadStarted( int windowIndex, @@ -633,7 +537,7 @@ import java.util.Set; LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadStarted(loadEventData, mediaLoadData); + mediaSourceEventDispatcher.loadStarted(loadEventData, mediaLoadData); } } @@ -644,7 +548,7 @@ import java.util.Set; LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCompleted(loadEventData, mediaLoadData); + mediaSourceEventDispatcher.loadCompleted(loadEventData, mediaLoadData); } } @@ -655,7 +559,7 @@ import java.util.Set; LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCanceled(loadEventData, mediaLoadData); + mediaSourceEventDispatcher.loadCanceled(loadEventData, mediaLoadData); } } @@ -668,14 +572,7 @@ import java.util.Set; IOException error, boolean wasCanceled) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadError(loadEventData, mediaLoadData, error, wasCanceled); - } - } - - @Override - public void onReadingStarted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.readingStarted(); + mediaSourceEventDispatcher.loadError(loadEventData, mediaLoadData, error, wasCanceled); } } @@ -685,7 +582,7 @@ import java.util.Set; @Nullable MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.upstreamDiscarded(mediaLoadData); + mediaSourceEventDispatcher.upstreamDiscarded(mediaLoadData); } } @@ -695,7 +592,7 @@ import java.util.Set; @Nullable MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.downstreamFormatChanged(mediaLoadData); + mediaSourceEventDispatcher.downstreamFormatChanged(mediaLoadData); } } @@ -705,8 +602,7 @@ import java.util.Set; public void onDrmSessionAcquired( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmSessionAcquired, DrmSessionEventListener.class); + drmEventDispatcher.drmSessionAcquired(); } } @@ -714,8 +610,7 @@ import java.util.Set; public void onDrmKeysLoaded( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysLoaded, DrmSessionEventListener.class); + drmEventDispatcher.drmKeysLoaded(); } } @@ -723,10 +618,7 @@ import java.util.Set; public void onDrmSessionManagerError( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, Exception error) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - (listener, innerWindowIndex, innerMediaPeriodId) -> - listener.onDrmSessionManagerError(innerWindowIndex, innerMediaPeriodId, error), - DrmSessionEventListener.class); + drmEventDispatcher.drmSessionManagerError(error); } } @@ -734,8 +626,7 @@ import java.util.Set; public void onDrmKeysRestored( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysRestored, DrmSessionEventListener.class); + drmEventDispatcher.drmKeysRestored(); } } @@ -743,8 +634,7 @@ import java.util.Set; public void onDrmKeysRemoved( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysRemoved, DrmSessionEventListener.class); + drmEventDispatcher.drmKeysRemoved(); } } @@ -752,8 +642,7 @@ import java.util.Set; public void onDrmSessionReleased( int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmSessionReleased, DrmSessionEventListener.class); + drmEventDispatcher.drmSessionReleased(); } } @@ -769,12 +658,17 @@ import java.util.Set; } } int windowIndex = getWindowIndexForChildWindowIndex(id, childWindowIndex); - if (eventDispatcher.windowIndex != windowIndex - || !Util.areEqual(eventDispatcher.mediaPeriodId, mediaPeriodId)) { - eventDispatcher = - MediaSourceList.this.eventDispatcher.withParameters( + if (mediaSourceEventDispatcher.windowIndex != windowIndex + || !Util.areEqual(mediaSourceEventDispatcher.mediaPeriodId, mediaPeriodId)) { + mediaSourceEventDispatcher = + MediaSourceList.this.mediaSourceEventDispatcher.withParameters( windowIndex, mediaPeriodId, /* mediaTimeOffsetMs= */ 0L); } + if (drmEventDispatcher.windowIndex != windowIndex + || !Util.areEqual(drmEventDispatcher.mediaPeriodId, mediaPeriodId)) { + drmEventDispatcher = + MediaSourceList.this.drmEventDispatcher.withParameters(windowIndex, mediaPeriodId); + } return true; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java new file mode 100644 index 0000000000..72f6957865 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java @@ -0,0 +1,199 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.content.Context; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.android.exoplayer2.util.Util; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +// TODO(internal b/161127201): discard samples written to the sample queue. +/** Retrieves the static metadata of {@link MediaItem MediaItems}. */ +public final class MetadataRetriever { + + private MetadataRetriever() {} + + /** + * Retrieves the {@link TrackGroupArray} corresponding to a {@link MediaItem}. + * + *

This is equivalent to using {@code retrieveMetadata(new DefaultMediaSourceFactory(context), + * mediaItem)}. + * + * @param context The {@link Context}. + * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. + * @return A {@link ListenableFuture} of the result. + */ + public static ListenableFuture retrieveMetadata( + Context context, MediaItem mediaItem) { + return retrieveMetadata(new DefaultMediaSourceFactory(context), mediaItem); + } + + /** + * Retrieves the {@link TrackGroupArray} corresponding to a {@link MediaItem}. + * + *

This method is thread-safe. + * + * @param mediaSourceFactory mediaSourceFactory The {@link MediaSourceFactory} to use to read the + * data. + * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. + * @return A {@link ListenableFuture} of the result. + */ + public static ListenableFuture retrieveMetadata( + MediaSourceFactory mediaSourceFactory, MediaItem mediaItem) { + // Recreate thread and handler every time this method is called so that it can be used + // concurrently. + return new MetadataRetrieverInternal(mediaSourceFactory).retrieveMetadata(mediaItem); + } + + private static final class MetadataRetrieverInternal { + + private static final int MESSAGE_PREPARE_SOURCE = 0; + private static final int MESSAGE_CHECK_FOR_FAILURE = 1; + private static final int MESSAGE_CONTINUE_LOADING = 2; + private static final int MESSAGE_RELEASE = 3; + + private final MediaSourceFactory mediaSourceFactory; + private final HandlerThread mediaSourceThread; + private final Handler mediaSourceHandler; + private final SettableFuture trackGroupsFuture; + + public MetadataRetrieverInternal(MediaSourceFactory mediaSourceFactory) { + this.mediaSourceFactory = mediaSourceFactory; + mediaSourceThread = new HandlerThread("ExoPlayer:MetadataRetriever"); + mediaSourceThread.start(); + mediaSourceHandler = + Util.createHandler(mediaSourceThread.getLooper(), new MediaSourceHandlerCallback()); + trackGroupsFuture = SettableFuture.create(); + } + + public ListenableFuture retrieveMetadata(MediaItem mediaItem) { + mediaSourceHandler.obtainMessage(MESSAGE_PREPARE_SOURCE, mediaItem).sendToTarget(); + return trackGroupsFuture; + } + + private final class MediaSourceHandlerCallback implements Handler.Callback { + + private static final int ERROR_POLL_INTERVAL_MS = 100; + + private final MediaSourceCaller mediaSourceCaller; + + private @MonotonicNonNull MediaSource mediaSource; + private @MonotonicNonNull MediaPeriod mediaPeriod; + + public MediaSourceHandlerCallback() { + mediaSourceCaller = new MediaSourceCaller(); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_PREPARE_SOURCE: + MediaItem mediaItem = (MediaItem) msg.obj; + mediaSource = mediaSourceFactory.createMediaSource(mediaItem); + mediaSource.prepareSource(mediaSourceCaller, /* mediaTransferListener= */ null); + mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_FOR_FAILURE); + return true; + case MESSAGE_CHECK_FOR_FAILURE: + try { + if (mediaPeriod == null) { + checkNotNull(mediaSource).maybeThrowSourceInfoRefreshError(); + } else { + mediaPeriod.maybeThrowPrepareError(); + } + mediaSourceHandler.sendEmptyMessageDelayed( + MESSAGE_CHECK_FOR_FAILURE, /* delayMillis= */ ERROR_POLL_INTERVAL_MS); + } catch (Exception e) { + trackGroupsFuture.setException(e); + mediaSourceHandler.obtainMessage(MESSAGE_RELEASE).sendToTarget(); + } + return true; + case MESSAGE_CONTINUE_LOADING: + checkNotNull(mediaPeriod).continueLoading(/* positionUs= */ 0); + return true; + case MESSAGE_RELEASE: + if (mediaPeriod != null) { + checkNotNull(mediaSource).releasePeriod(mediaPeriod); + } + checkNotNull(mediaSource).releaseSource(mediaSourceCaller); + mediaSourceHandler.removeCallbacksAndMessages(/* token= */ null); + mediaSourceThread.quit(); + return true; + default: + return false; + } + } + + private final class MediaSourceCaller implements MediaSource.MediaSourceCaller { + + private final MediaPeriodCallback mediaPeriodCallback; + private final Allocator allocator; + + private boolean mediaPeriodCreated; + + public MediaSourceCaller() { + mediaPeriodCallback = new MediaPeriodCallback(); + allocator = + new DefaultAllocator( + /* trimOnReset= */ true, + /* individualAllocationSize= */ C.DEFAULT_BUFFER_SEGMENT_SIZE); + } + + @Override + public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) { + if (mediaPeriodCreated) { + // Ignore dynamic updates. + return; + } + mediaPeriodCreated = true; + mediaPeriod = + source.createPeriod( + new MediaSource.MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ 0)), + allocator, + /* startPositionUs= */ 0); + mediaPeriod.prepare(mediaPeriodCallback, /* positionUs= */ 0); + } + + private final class MediaPeriodCallback implements MediaPeriod.Callback { + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + trackGroupsFuture.set(mediaPeriod.getTrackGroups()); + mediaSourceHandler.obtainMessage(MESSAGE_RELEASE).sendToTarget(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod mediaPeriod) { + mediaSourceHandler.obtainMessage(MESSAGE_CONTINUE_LOADING).sendToTarget(); + } + } + } + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java index 47ed8cec6a..fd5f0431e1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/NoSampleRenderer.java @@ -68,13 +68,14 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities long positionUs, boolean joining, boolean mayRenderStartOfStream, + long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(state == STATE_DISABLED); this.configuration = configuration; state = STATE_ENABLED; onEnabled(joining); - replaceStream(formats, stream, offsetUs); + replaceStream(formats, stream, startPositionUs, offsetUs); onPositionReset(positionUs, joining); } @@ -86,7 +87,8 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities } @Override - public final void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + public final void replaceStream( + Format[] formats, SampleStream stream, long startPositionUs, long offsetUs) throws ExoPlaybackException { Assertions.checkState(!streamIsFinal); this.stream = stream; @@ -130,7 +132,7 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities } @Override - public final void stop() throws ExoPlaybackException { + public final void stop() { Assertions.checkState(state == STATE_STARTED); state = STATE_ENABLED; onStopped(); @@ -237,12 +239,10 @@ public abstract class NoSampleRenderer implements Renderer, RendererCapabilities /** * Called when the renderer is stopped. - *

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

The default implementation is a no-op. */ - protected void onStopped() throws ExoPlaybackException { + protected void onStopped() { // Do nothing. } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java index f183af0d8c..9fb6563005 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java @@ -28,10 +28,10 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; /* package */ final class PlaybackInfo { /** - * Dummy media period id used while the timeline is empty and no period id is specified. This id - * is used when playback infos are created with {@link #createDummy(TrackSelectorResult)}. + * Placeholder media period id used while the timeline is empty and no period id is specified. + * This id is used when playback infos are created with {@link #createDummy(TrackSelectorResult)}. */ - private static final MediaPeriodId DUMMY_MEDIA_PERIOD_ID = + private static final MediaPeriodId PLACEHOLDER_MEDIA_PERIOD_ID = new MediaPeriodId(/* periodUid= */ new Object()); /** The current {@link Timeline}. */ @@ -63,6 +63,10 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; public final boolean playWhenReady; /** Reason why playback is suppressed even though {@link #playWhenReady} is {@code true}. */ @PlaybackSuppressionReason public final int playbackSuppressionReason; + /** The playback parameters. */ + public final PlaybackParameters playbackParameters; + /** Whether offload scheduling is enabled for the main player loop. */ + public final boolean offloadSchedulingEnabled; /** * Position up to which media is buffered in {@link #loadingMediaPeriodId) relative to the start @@ -81,29 +85,31 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; public volatile long positionUs; /** - * Creates empty dummy playback info which can be used for masking as long as no real playback - * info is available. + * Creates an empty placeholder playback info which can be used for masking as long as no real + * playback info is available. * * @param emptyTrackSelectorResult An empty track selector result with null entries for each * renderer. - * @return A dummy playback info. + * @return A placeholder playback info. */ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorResult) { return new PlaybackInfo( Timeline.EMPTY, - DUMMY_MEDIA_PERIOD_ID, + PLACEHOLDER_MEDIA_PERIOD_ID, /* requestedContentPositionUs= */ C.TIME_UNSET, Player.STATE_IDLE, /* playbackError= */ null, /* isLoading= */ false, TrackGroupArray.EMPTY, emptyTrackSelectorResult, - DUMMY_MEDIA_PERIOD_ID, + PLACEHOLDER_MEDIA_PERIOD_ID, /* playWhenReady= */ false, Player.PLAYBACK_SUPPRESSION_REASON_NONE, + PlaybackParameters.DEFAULT, /* bufferedPositionUs= */ 0, /* totalBufferedDurationUs= */ 0, - /* positionUs= */ 0); + /* positionUs= */ 0, + /* offloadSchedulingEnabled= */ false); } /** @@ -113,13 +119,18 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; * @param periodId See {@link #periodId}. * @param requestedContentPositionUs See {@link #requestedContentPositionUs}. * @param playbackState See {@link #playbackState}. + * @param playbackError See {@link #playbackError}. * @param isLoading See {@link #isLoading}. * @param trackGroups See {@link #trackGroups}. * @param trackSelectorResult See {@link #trackSelectorResult}. * @param loadingMediaPeriodId See {@link #loadingMediaPeriodId}. + * @param playWhenReady See {@link #playWhenReady}. + * @param playbackSuppressionReason See {@link #playbackSuppressionReason}. + * @param playbackParameters See {@link #playbackParameters}. * @param bufferedPositionUs See {@link #bufferedPositionUs}. * @param totalBufferedDurationUs See {@link #totalBufferedDurationUs}. * @param positionUs See {@link #positionUs}. + * @param offloadSchedulingEnabled See {@link #offloadSchedulingEnabled}. */ public PlaybackInfo( Timeline timeline, @@ -133,9 +144,11 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; MediaPeriodId loadingMediaPeriodId, boolean playWhenReady, @PlaybackSuppressionReason int playbackSuppressionReason, + PlaybackParameters playbackParameters, long bufferedPositionUs, long totalBufferedDurationUs, - long positionUs) { + long positionUs, + boolean offloadSchedulingEnabled) { this.timeline = timeline; this.periodId = periodId; this.requestedContentPositionUs = requestedContentPositionUs; @@ -147,14 +160,16 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; this.loadingMediaPeriodId = loadingMediaPeriodId; this.playWhenReady = playWhenReady; this.playbackSuppressionReason = playbackSuppressionReason; + this.playbackParameters = playbackParameters; this.bufferedPositionUs = bufferedPositionUs; this.totalBufferedDurationUs = totalBufferedDurationUs; this.positionUs = positionUs; + this.offloadSchedulingEnabled = offloadSchedulingEnabled; } - /** Returns dummy period id for an empty timeline. */ + /** Returns a placeholder period id for an empty timeline. */ public static MediaPeriodId getDummyPeriodForEmptyTimeline() { - return DUMMY_MEDIA_PERIOD_ID; + return PLACEHOLDER_MEDIA_PERIOD_ID; } /** @@ -190,9 +205,11 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled); } /** @@ -215,9 +232,11 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled); } /** @@ -240,9 +259,11 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled); } /** @@ -265,9 +286,11 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled); } /** @@ -290,9 +313,11 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled); } /** @@ -315,9 +340,11 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled); } /** @@ -344,8 +371,65 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled); + } + + /** + * Copies playback info with new playback parameters. + * + * @param playbackParameters New playback parameters. See {@link #playbackParameters}. + * @return Copied playback info with new playback parameters. + */ + @CheckResult + public PlaybackInfo copyWithPlaybackParameters(PlaybackParameters playbackParameters) { + return new PlaybackInfo( + timeline, + periodId, + requestedContentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + playWhenReady, + playbackSuppressionReason, + playbackParameters, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs, + offloadSchedulingEnabled); + } + + /** + * Copies playback info with new offloadSchedulingEnabled. + * + * @param offloadSchedulingEnabled New offloadSchedulingEnabled state. See {@link + * #offloadSchedulingEnabled}. + * @return Copied playback info with new offload scheduling state. + */ + @CheckResult + public PlaybackInfo copyWithOffloadSchedulingEnabled(boolean offloadSchedulingEnabled) { + return new PlaybackInfo( + timeline, + periodId, + requestedContentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + playWhenReady, + playbackSuppressionReason, + playbackParameters, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs, + offloadSchedulingEnabled); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackParameters.java b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackParameters.java index afa0a7ebc4..7dcd6f80aa 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackParameters.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackParameters.java @@ -17,13 +17,9 @@ package com.google.android.exoplayer2; import androidx.annotation.Nullable; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; -/** - * @deprecated Use {@link Player#setPlaybackSpeed(float)} and {@link - * Player.AudioComponent#setSkipSilenceEnabled(boolean)} instead. - */ -@SuppressWarnings("deprecation") -@Deprecated +/** Parameters that apply to playback, including speed setting. */ public final class PlaybackParameters { /** The default playback parameters: real-time playback with no silence skipping. */ @@ -32,16 +28,34 @@ public final class PlaybackParameters { /** The factor by which playback will be sped up. */ public final float speed; + /** The factor by which pitch will be shifted. */ + public final float pitch; + private final int scaledUsPerMs; /** - * Creates new playback parameters that set the playback speed. + * Creates new playback parameters that set the playback speed. The pitch of audio will not be + * adjusted, so the effect is to time-stretch the audio. * * @param speed The factor by which playback will be sped up. Must be greater than zero. */ public PlaybackParameters(float speed) { + this(speed, /* pitch= */ 1f); + } + + /** + * Creates new playback parameters that set the playback speed/pitch. + * + * @param speed The factor by which playback will be sped up. Must be greater than zero. + * @param pitch The factor by which the pitch of audio will be adjusted. Must be greater than + * zero. Useful values are {@code 1} (to time-stretch audio) and the same value as passed in + * as the {@code speed} (to resample audio, which is useful for slow-motion videos). + */ + public PlaybackParameters(float speed, float pitch) { Assertions.checkArgument(speed > 0); + Assertions.checkArgument(pitch > 0); this.speed = speed; + this.pitch = pitch; scaledUsPerMs = Math.round(speed * 1000f); } @@ -65,11 +79,19 @@ public final class PlaybackParameters { return false; } PlaybackParameters other = (PlaybackParameters) obj; - return this.speed == other.speed; + return this.speed == other.speed && this.pitch == other.pitch; } @Override public int hashCode() { - return Float.floatToRawIntBits(speed); + int result = 17; + result = 31 * result + Float.floatToRawIntBits(speed); + result = 31 * result + Float.floatToRawIntBits(pitch); + return result; + } + + @Override + public String toString() { + return Util.formatInvariant("PlaybackParameters(speed=%.2f, pitch=%.2f)", speed, pitch); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index b08b0336b5..7a52aae738 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -33,6 +33,7 @@ import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextOutput; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer; import com.google.android.exoplayer2.video.VideoFrameMetadataListener; @@ -310,9 +311,9 @@ public interface Player { /** * Sets the video decoder output buffer renderer. This is intended for use only with extension - * renderers that accept {@link C#MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER}. For most use - * cases, an output surface or view should be passed via {@link #setVideoSurface(Surface)} or - * {@link #setVideoSurfaceView(SurfaceView)} instead. + * renderers that accept {@link Renderer#MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER}. For most + * use cases, an output surface or view should be passed via {@link #setVideoSurface(Surface)} + * or {@link #setVideoSurfaceView(SurfaceView)} instead. * * @param videoDecoderOutputBufferRenderer The video decoder output buffer renderer, or {@code * null} to clear the output buffer renderer. @@ -373,8 +374,6 @@ public interface Player { } /** The device component of a {@link Player}. */ - // Note: It's mostly from the androidx.media.VolumeProviderCompat and - // androidx.media.MediaControllerCompat.PlaybackInfo. interface DeviceComponent { /** Adds a listener to receive device events. */ @@ -469,6 +468,19 @@ public interface Player { default void onTimelineChanged( Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {} + /** + * Called when playback transitions to a media item or starts repeating a media item according + * to the current {@link #getRepeatMode() repeat mode}. + * + *

Note that this callback is also called when the playlist becomes non-empty or empty as a + * consequence of a playlist change. + * + * @param mediaItem The {@link MediaItem}. May be null if the playlist becomes empty. + * @param reason The reason for the transition. + */ + default void onMediaItemTransition( + @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) {} + /** * Called when the available or selected tracks change. * @@ -569,27 +581,29 @@ public interface Player { default void onPositionDiscontinuity(@DiscontinuityReason int reason) {} /** - * @deprecated Use {@link #onPlaybackSpeedChanged(float)} and {@link - * AudioListener#onSkipSilenceEnabledChanged(boolean)} instead. + * Called when the current playback parameters change. The playback parameters may change due to + * a call to {@link #setPlaybackParameters(PlaybackParameters)}, or the player itself may change + * them (for example, if audio playback switches to passthrough or offload mode, where speed + * adjustment is no longer possible). + * + * @param playbackParameters The playback parameters. */ - @SuppressWarnings("deprecation") - @Deprecated default void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {} - /** - * Called when the current playback speed changes. The normal playback speed is 1. The speed may - * change due to a call to {@link #setPlaybackSpeed(float)}, or the player itself may change it - * (for example, if audio playback switches to passthrough mode, where speed adjustment is no - * longer possible). - */ - default void onPlaybackSpeedChanged(float playbackSpeed) {} - /** * @deprecated Seeks are processed without delay. Listen to {@link * #onPositionDiscontinuity(int)} with reason {@link #DISCONTINUITY_REASON_SEEK} instead. */ @Deprecated default void onSeekProcessed() {} + + /** + * Called when the player has started or stopped offload scheduling after a call to {@link + * ExoPlayer#experimentalSetOffloadSchedulingEnabled(boolean)}. + * + *

This method is experimental, and will be renamed or removed in a future release. + */ + default void onExperimentalOffloadSchedulingEnabledChanged(boolean offloadSchedulingEnabled) {} } /** @@ -765,8 +779,28 @@ public interface Player { /** Timeline changed as a result of a dynamic update introduced by the played media. */ int TIMELINE_CHANGE_REASON_SOURCE_UPDATE = 1; - /** The default playback speed. */ - float DEFAULT_PLAYBACK_SPEED = 1.0f; + /** Reasons for media item transitions. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + MEDIA_ITEM_TRANSITION_REASON_REPEAT, + MEDIA_ITEM_TRANSITION_REASON_AUTO, + MEDIA_ITEM_TRANSITION_REASON_SEEK, + MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED + }) + @interface MediaItemTransitionReason {} + /** The media item has been repeated. */ + int MEDIA_ITEM_TRANSITION_REASON_REPEAT = 0; + /** Playback has automatically transitioned to the next media item. */ + int MEDIA_ITEM_TRANSITION_REASON_AUTO = 1; + /** A seek to another media item has occurred. */ + int MEDIA_ITEM_TRANSITION_REASON_SEEK = 2; + /** + * The current media item has changed because of a change in the playlist. This can either be if + * the media item previously being played has been removed, or when the playlist becomes non-empty + * after being empty. + */ + int MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED = 3; /** Returns the component of this player for audio output, or null if audio is not supported. */ @Nullable @@ -840,6 +874,8 @@ public interface Player { * C#TIME_UNSET} is passed, the default position of the given window is used. In any case, if * {@code startWindowIndex} is set to {@link C#INDEX_UNSET}, this parameter is ignored and the * position is not reset at all. + * @throws IllegalSeekPositionException If the provided {@code windowIndex} is not within the + * bounds of the list of media items. */ void setMediaItems(List mediaItems, int startWindowIndex, long startPositionMs); @@ -1063,6 +1099,8 @@ public interface Player { * * @param windowIndex The index of the window whose associated default position should be seeked * to. + * @throws IllegalSeekPositionException If the player has a non-empty timeline and the provided + * {@code windowIndex} is not within the bounds of the current timeline. */ void seekToDefaultPosition(int windowIndex); @@ -1112,39 +1150,24 @@ public interface Player { void next(); /** - * @deprecated Use {@link #setPlaybackSpeed(float)} or {@link - * AudioComponent#setSkipSilenceEnabled(boolean)} instead. + * Attempts to set the playback parameters. Passing {@code null} sets the parameters to the + * default, {@link PlaybackParameters#DEFAULT}, which means there is no speed or pitch adjustment. + * + *

Playback parameters changes may cause the player to buffer. {@link + * EventListener#onPlaybackParametersChanged(PlaybackParameters)} will be called whenever the + * currently active playback parameters change. + * + * @param playbackParameters The playback parameters, or {@code null} to use the defaults. */ - @SuppressWarnings("deprecation") - @Deprecated void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters); /** - * @deprecated Use {@link #getPlaybackSpeed()} or {@link AudioComponent#getSkipSilenceEnabled()} - * instead. + * Returns the currently active playback parameters. + * + * @see EventListener#onPlaybackParametersChanged(PlaybackParameters) */ - @SuppressWarnings("deprecation") - @Deprecated PlaybackParameters getPlaybackParameters(); - /** - * Attempts to set the playback speed. - * - *

Playback speed changes may cause the player to buffer. {@link - * EventListener#onPlaybackSpeedChanged(float)} will be called whenever the currently active - * playback speed change. - * - * @param playbackSpeed The playback speed. - */ - void setPlaybackSpeed(float playbackSpeed); - - /** - * Returns the currently active playback speed. - * - * @see EventListener#onPlaybackSpeedChanged(float) - */ - float getPlaybackSpeed(); - /** * Stops playback without resetting the player. Use {@link #pause()} rather than this method if * the intention is to pause playback. @@ -1153,19 +1176,21 @@ public interface Player { * player instance can still be used, and {@link #release()} must still be called on the player if * it's no longer required. * - *

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

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

Calling this method will cause the playback state to transition to {@link #STATE_IDLE}. The * player instance can still be used, and {@link #release()} must still be called on the player if * it's no longer required. * - * @param reset Whether the player should be reset. + * @param reset Whether the playlist should be cleared and whether the playback position and + * playback error should be reset. */ void stop(boolean reset); @@ -1189,6 +1214,12 @@ public interface Player { */ int getRendererType(int index); + /** + * Returns the track selector that this player uses, or null if track selection is not supported. + */ + @Nullable + TrackSelector getTrackSelector(); + /** * Returns the available track groups. */ @@ -1202,7 +1233,8 @@ public interface Player { /** * Returns the current manifest. The type depends on the type of media being played. May be null. */ - @Nullable Object getCurrentManifest(); + @Nullable + Object getCurrentManifest(); /** * Returns the current {@link Timeline}. Never null, but may be empty. @@ -1215,29 +1247,48 @@ public interface Player { int getCurrentPeriodIndex(); /** - * Returns the index of the window currently being played. + * Returns the index of the current {@link Timeline.Window window} in the {@link + * #getCurrentTimeline() timeline}, or the prospective window index if the {@link + * #getCurrentTimeline() current timeline} is empty. */ int getCurrentWindowIndex(); /** * Returns the index of the next timeline window to be played, which may depend on the current * repeat mode and whether shuffle mode is enabled. Returns {@link C#INDEX_UNSET} if the window - * currently being played is the last window. + * currently being played is the last window or if the {@link #getCurrentTimeline() current + * timeline} is empty. */ int getNextWindowIndex(); /** * Returns the index of the previous timeline window to be played, which may depend on the current * repeat mode and whether shuffle mode is enabled. Returns {@link C#INDEX_UNSET} if the window - * currently being played is the first window. + * currently being played is the first window or if the {@link #getCurrentTimeline() current + * timeline} is empty. */ int getPreviousWindowIndex(); /** - * Returns the tag of the currently playing window in the timeline. May be null if no tag is set - * or the timeline is not yet available. + * @deprecated Use {@link #getCurrentMediaItem()} and {@link MediaItem.PlaybackProperties#tag} + * instead. */ - @Nullable Object getCurrentTag(); + @Deprecated + @Nullable + Object getCurrentTag(); + + /** + * Returns the media item of the current window in the timeline. May be null if the timeline is + * empty. + */ + @Nullable + MediaItem getCurrentMediaItem(); + + /** Returns the number of {@link MediaItem media items} in the playlist. */ + int getMediaItemCount(); + + /** Returns the {@link MediaItem} at the given index. */ + MediaItem getMediaItemAt(int index); /** * Returns the duration of the current content window or ad in milliseconds, or {@link @@ -1245,7 +1296,11 @@ public interface Player { */ long getDuration(); - /** Returns the playback position in the current content window or ad, in milliseconds. */ + /** + * Returns the playback position in the current content window or ad, in milliseconds, or the + * prospective position in milliseconds if the {@link #getCurrentTimeline() current timeline} is + * empty. + */ long getCurrentPosition(); /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java index be7c7ce973..7e2cb69bc6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java @@ -292,6 +292,20 @@ public final class PlayerMessage { return isDelivered; } + /** + * Marks the message as processed. Should only be called by a {@link Sender} and may be called + * multiple times. + * + * @param isDelivered Whether the message has been delivered to its target. The message is + * considered as being delivered when this method has been called with {@code isDelivered} set + * to true at least once. + */ + public synchronized void markAsProcessed(boolean isDelivered) { + this.isDelivered |= isDelivered; + isProcessed = true; + notifyAll(); + } + /** * Blocks until after the message has been delivered or the player is no longer able to deliver * the message or the specified waiting time elapses. @@ -309,27 +323,13 @@ public final class PlayerMessage { * @throws InterruptedException If the current thread is interrupted while waiting for the message * to be delivered. */ - public synchronized boolean experimental_blockUntilDelivered(long timeoutMs) + public synchronized boolean experimentalBlockUntilDelivered(long timeoutMs) throws InterruptedException, TimeoutException { - return experimental_blockUntilDelivered(timeoutMs, Clock.DEFAULT); - } - - /** - * Marks the message as processed. Should only be called by a {@link Sender} and may be called - * multiple times. - * - * @param isDelivered Whether the message has been delivered to its target. The message is - * considered as being delivered when this method has been called with {@code isDelivered} set - * to true at least once. - */ - public synchronized void markAsProcessed(boolean isDelivered) { - this.isDelivered |= isDelivered; - isProcessed = true; - notifyAll(); + return experimentalBlockUntilDelivered(timeoutMs, Clock.DEFAULT); } @VisibleForTesting() - /* package */ synchronized boolean experimental_blockUntilDelivered(long timeoutMs, Clock clock) + /* package */ synchronized boolean experimentalBlockUntilDelivered(long timeoutMs, Clock clock) throws InterruptedException, TimeoutException { Assertions.checkState(isSent); Assertions.checkState(handler.getLooper().getThread() != Thread.currentThread()); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlaylistTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/PlaylistTimeline.java new file mode 100644 index 0000000000..3b93041348 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaylistTimeline.java @@ -0,0 +1,113 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; + +/** Timeline exposing concatenated timelines of playlist media sources. */ +/* package */ final class PlaylistTimeline extends AbstractConcatenatedTimeline { + + private final int windowCount; + private final int periodCount; + private final int[] firstPeriodInChildIndices; + private final int[] firstWindowInChildIndices; + private final Timeline[] timelines; + private final Object[] uids; + private final HashMap childIndexByUid; + + /** Creates an instance. */ + public PlaylistTimeline( + Collection mediaSourceInfoHolders, + ShuffleOrder shuffleOrder) { + super(/* isAtomic= */ false, shuffleOrder); + int childCount = mediaSourceInfoHolders.size(); + firstPeriodInChildIndices = new int[childCount]; + firstWindowInChildIndices = new int[childCount]; + timelines = new Timeline[childCount]; + uids = new Object[childCount]; + childIndexByUid = new HashMap<>(); + int index = 0; + int windowCount = 0; + int periodCount = 0; + for (MediaSourceInfoHolder mediaSourceInfoHolder : mediaSourceInfoHolders) { + timelines[index] = mediaSourceInfoHolder.getTimeline(); + firstWindowInChildIndices[index] = windowCount; + firstPeriodInChildIndices[index] = periodCount; + windowCount += timelines[index].getWindowCount(); + periodCount += timelines[index].getPeriodCount(); + uids[index] = mediaSourceInfoHolder.getUid(); + childIndexByUid.put(uids[index], index++); + } + this.windowCount = windowCount; + this.periodCount = periodCount; + } + + /** Returns the child timelines. */ + /* package */ List getChildTimelines() { + return Arrays.asList(timelines); + } + + @Override + protected int getChildIndexByPeriodIndex(int periodIndex) { + return Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex + 1, false, false); + } + + @Override + protected int getChildIndexByWindowIndex(int windowIndex) { + return Util.binarySearchFloor(firstWindowInChildIndices, windowIndex + 1, false, false); + } + + @Override + protected int getChildIndexByChildUid(Object childUid) { + Integer index = childIndexByUid.get(childUid); + return index == null ? C.INDEX_UNSET : index; + } + + @Override + protected Timeline getTimelineByChildIndex(int childIndex) { + return timelines[childIndex]; + } + + @Override + protected int getFirstPeriodIndexByChildIndex(int childIndex) { + return firstPeriodInChildIndices[childIndex]; + } + + @Override + protected int getFirstWindowIndexByChildIndex(int childIndex) { + return firstWindowInChildIndices[childIndex]; + } + + @Override + protected Object getChildUidByChildIndex(int childIndex) { + return uids[childIndex]; + } + + @Override + public int getWindowCount() { + return windowCount; + } + + @Override + public int getPeriodCount() { + return periodCount; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java index fa73f9257d..10ffcc9f9f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Renderer.java @@ -46,6 +46,34 @@ import java.lang.annotation.RetentionPolicy; */ public interface Renderer extends PlayerMessage.Target { + /** + * Some renderers can signal when {@link #render(long, long)} should be called. + * + *

That allows the player to sleep until the next wakeup, instead of calling {@link + * #render(long, long)} in a tight loop. The aim of this interrupt based scheduling is to save + * power. + */ + interface WakeupListener { + + /** + * The renderer no longer needs to render until the next wakeup. + * + *

Must be called from the thread ExoPlayer invokes the renderer from. + * + * @param wakeupDeadlineMs Maximum time in milliseconds until {@link #onWakeup()} will be + * called. + */ + void onSleep(long wakeupDeadlineMs); + + /** + * The renderer needs to render some frames. The client should call {@link #render(long, long)} + * at its earliest convenience. + * + *

Can be called from any thread. + */ + void onWakeup(); + } + /** * The type of a message that can be passed to a video renderer via {@link * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or @@ -137,6 +165,14 @@ public interface Renderer extends PlayerMessage.Target { * representing the audio session ID that will be attached to the underlying audio track. */ int MSG_SET_AUDIO_SESSION_ID = 102; + /** + * A type of a message that can be passed to a {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}, to inform the renderer that it can schedule waking up another + * component. + * + *

The message payload must be a {@link WakeupListener} instance. + */ + int MSG_SET_WAKEUP_LISTENER = 103; /** * Applications or extensions may define custom {@code MSG_*} constants that can be passed to * renderers. These custom constants must be greater than or equal to this value. @@ -260,6 +296,7 @@ public interface Renderer extends PlayerMessage.Target { * @param joining Whether this renderer is being enabled to join an ongoing playback. * @param mayRenderStartOfStream Whether this renderer is allowed to render the start of the * stream even if the state is not {@link #STATE_STARTED} yet. + * @param startPositionUs The start position of the stream in renderer time (microseconds). * @param offsetUs The offset to be added to timestamps of buffers read from {@code stream} before * they are rendered. * @throws ExoPlaybackException If an error occurs. @@ -271,6 +308,7 @@ public interface Renderer extends PlayerMessage.Target { long positionUs, boolean joining, boolean mayRenderStartOfStream, + long startPositionUs, long offsetUs) throws ExoPlaybackException; @@ -287,17 +325,18 @@ public interface Renderer extends PlayerMessage.Target { /** * Replaces the {@link SampleStream} from which samples will be consumed. - *

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

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

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

This method should return quickly, and should not block if the renderer is unable to make * useful progress. @@ -434,13 +473,11 @@ public interface Renderer extends PlayerMessage.Target { /** * Stops the renderer, transitioning it to the {@link #STATE_ENABLED} state. - *

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

This method may be called when the renderer is in the following states: {@link + * #STATE_STARTED}. */ - void stop() throws ExoPlaybackException; + void stop(); /** * Disable the renderer, transitioning it to the {@link #STATE_DISABLED} state. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 844df27a6e..6652cbb03d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -38,6 +38,8 @@ import com.google.android.exoplayer2.audio.AuxEffectInfo; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.device.DeviceInfo; import com.google.android.exoplayer2.device.DeviceListener; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; @@ -99,16 +101,27 @@ public class SimpleExoPlayer extends BasePlayer private BandwidthMeter bandwidthMeter; private AnalyticsCollector analyticsCollector; private Looper looper; + @Nullable private PriorityTaskManager priorityTaskManager; + private AudioAttributes audioAttributes; + private boolean handleAudioFocus; + @C.WakeMode private int wakeMode; + private boolean handleAudioBecomingNoisy; + private boolean skipSilenceEnabled; + @Renderer.VideoScalingMode private int videoScalingMode; private boolean useLazyPreparation; + private SeekParameters seekParameters; + private boolean pauseAtEndOfMediaItems; private boolean throwWhenStuckBuffering; private boolean buildCalled; /** * Creates a builder. * - *

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

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

The builder uses the following default values: * @@ -122,14 +135,22 @@ public class SimpleExoPlayer extends BasePlayer * Looper} of the application's main thread if the current thread doesn't have a {@link * Looper} *

  • {@link AnalyticsCollector}: {@link AnalyticsCollector} with {@link Clock#DEFAULT} + *
  • {@link PriorityTaskManager}: {@code null} (not used) + *
  • {@link AudioAttributes}: {@link AudioAttributes#DEFAULT}, not handling audio focus + *
  • {@link C.WakeMode}: {@link C#WAKE_MODE_NONE} + *
  • {@code handleAudioBecomingNoisy}: {@code true} + *
  • {@code skipSilenceEnabled}: {@code false} + *
  • {@link Renderer.VideoScalingMode}: {@link Renderer#VIDEO_SCALING_MODE_DEFAULT} *
  • {@code useLazyPreparation}: {@code true} + *
  • {@link SeekParameters}: {@link SeekParameters#DEFAULT} + *
  • {@code pauseAtEndOfMediaItems}: {@code false} *
  • {@link Clock}: {@link Clock#DEFAULT} * * * @param context A {@link Context}. */ public Builder(Context context) { - this(context, new DefaultRenderersFactory(context)); + this(context, new DefaultRenderersFactory(context), new DefaultExtractorsFactory()); } /** @@ -142,25 +163,50 @@ public class SimpleExoPlayer extends BasePlayer * player. */ public Builder(Context context, RenderersFactory renderersFactory) { + this(context, renderersFactory, new DefaultExtractorsFactory()); + } + + /** + * Creates a builder with a custom {@link ExtractorsFactory}. + * + *

    See {@link #Builder(Context)} for a list of default values. + * + * @param context A {@link Context}. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public Builder(Context context, ExtractorsFactory extractorsFactory) { + this(context, new DefaultRenderersFactory(context), extractorsFactory); + } + + /** + * Creates a builder with a custom {@link RenderersFactory} and {@link ExtractorsFactory}. + * + *

    See {@link #Builder(Context)} for a list of default values. + * + * @param context A {@link Context}. + * @param renderersFactory A factory for creating {@link Renderer Renderers} to be used by the + * player. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public Builder( + Context context, RenderersFactory renderersFactory, ExtractorsFactory extractorsFactory) { this( context, renderersFactory, new DefaultTrackSelector(context), - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context, extractorsFactory), new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), - Util.getLooper(), - new AnalyticsCollector(Clock.DEFAULT), - /* useLazyPreparation= */ true, - Clock.DEFAULT); + new AnalyticsCollector(Clock.DEFAULT)); } /** * Creates a builder with the specified custom components. * - *

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

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

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

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

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

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

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

    This means the player will pause at the end of each window in the current {@link + * #getCurrentTimeline() timeline}. Listeners will be informed by a call to {@link + * Player.EventListener#onPlayWhenReadyChanged(boolean, int)} with the reason {@link + * Player#PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM} when this happens. + * + * @param pauseAtEndOfMediaItems Whether to pause playback at the end of each media item. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setPauseAtEndOfMediaItems(boolean pauseAtEndOfMediaItems) { + Assertions.checkState(!buildCalled); + this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; + return this; + } + /** * Sets whether the player should throw when it detects it's stuck buffering. * @@ -303,7 +482,7 @@ public class SimpleExoPlayer extends BasePlayer * @param throwWhenStuckBuffering Whether to throw when the player detects it's stuck buffering. * @return This builder. */ - public Builder experimental_setThrowWhenStuckBuffering(boolean throwWhenStuckBuffering) { + public Builder experimentalSetThrowWhenStuckBuffering(boolean throwWhenStuckBuffering) { this.throwWhenStuckBuffering = throwWhenStuckBuffering; return this; } @@ -326,7 +505,7 @@ public class SimpleExoPlayer extends BasePlayer /** * Builds a {@link SimpleExoPlayer} instance. * - * @throws IllegalStateException If {@link #build()} has already been called. + * @throws IllegalStateException If this method has already been called. */ public SimpleExoPlayer build() { Assertions.checkState(!buildCalled); @@ -352,7 +531,6 @@ public class SimpleExoPlayer extends BasePlayer private final CopyOnWriteArraySet deviceListeners; private final CopyOnWriteArraySet videoDebugListeners; private final CopyOnWriteArraySet audioDebugListeners; - private final BandwidthMeter bandwidthMeter; private final AnalyticsCollector analyticsCollector; private final AudioBecomingNoisyManager audioBecomingNoisyManager; private final AudioFocusManager audioFocusManager; @@ -414,8 +592,11 @@ public class SimpleExoPlayer extends BasePlayer /** @param builder The {@link Builder} to obtain all construction parameters. */ protected SimpleExoPlayer(Builder builder) { - bandwidthMeter = builder.bandwidthMeter; analyticsCollector = builder.analyticsCollector; + priorityTaskManager = builder.priorityTaskManager; + audioAttributes = builder.audioAttributes; + videoScalingMode = builder.videoScalingMode; + skipSilenceEnabled = builder.skipSilenceEnabled; componentListener = new ComponentListener(); videoListeners = new CopyOnWriteArraySet<>(); audioListeners = new CopyOnWriteArraySet<>(); @@ -436,8 +617,6 @@ public class SimpleExoPlayer extends BasePlayer // Set initial values. audioVolume = 1; audioSessionId = C.AUDIO_SESSION_ID_UNSET; - audioAttributes = AudioAttributes.DEFAULT; - videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT; currentCues = Collections.emptyList(); // Build the player and associated objects. @@ -447,30 +626,45 @@ public class SimpleExoPlayer extends BasePlayer builder.trackSelector, builder.mediaSourceFactory, builder.loadControl, - bandwidthMeter, + builder.bandwidthMeter, analyticsCollector, builder.useLazyPreparation, + builder.seekParameters, + builder.pauseAtEndOfMediaItems, builder.clock, builder.looper); - analyticsCollector.setPlayer(player); - player.addListener(analyticsCollector); player.addListener(componentListener); videoDebugListeners.add(analyticsCollector); videoListeners.add(analyticsCollector); audioDebugListeners.add(analyticsCollector); audioListeners.add(analyticsCollector); addMetadataOutput(analyticsCollector); - bandwidthMeter.addEventListener(eventHandler, analyticsCollector); + audioBecomingNoisyManager = new AudioBecomingNoisyManager(builder.context, eventHandler, componentListener); + audioBecomingNoisyManager.setEnabled(builder.handleAudioBecomingNoisy); audioFocusManager = new AudioFocusManager(builder.context, eventHandler, componentListener); + audioFocusManager.setAudioAttributes(builder.handleAudioFocus ? audioAttributes : null); streamVolumeManager = new StreamVolumeManager(builder.context, eventHandler, componentListener); + streamVolumeManager.setStreamType(Util.getStreamTypeForAudioUsage(audioAttributes.usage)); wakeLockManager = new WakeLockManager(builder.context); + wakeLockManager.setEnabled(builder.wakeMode != C.WAKE_MODE_NONE); wifiLockManager = new WifiLockManager(builder.context); + wifiLockManager.setEnabled(builder.wakeMode == C.WAKE_MODE_NETWORK); deviceInfo = createDeviceInfo(streamVolumeManager); - if (builder.throwWhenStuckBuffering) { - player.experimental_throwWhenStuckBuffering(); + if (!builder.throwWhenStuckBuffering) { + player.experimentalDisableThrowWhenStuckBuffering(); } + + sendRendererMessage(C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_AUDIO_ATTRIBUTES, audioAttributes); + sendRendererMessage(C.TRACK_TYPE_VIDEO, Renderer.MSG_SET_SCALING_MODE, videoScalingMode); + sendRendererMessage( + C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); + } + + @Override + public void experimentalSetOffloadSchedulingEnabled(boolean offloadSchedulingEnabled) { + player.experimentalSetOffloadSchedulingEnabled(offloadSchedulingEnabled); } @Override @@ -660,6 +854,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addAudioListener(AudioListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); audioListeners.add(listener); } @@ -810,6 +1005,7 @@ public class SimpleExoPlayer extends BasePlayer */ public void addAnalyticsListener(AnalyticsListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); analyticsCollector.addListener(listener); } @@ -867,19 +1063,23 @@ public class SimpleExoPlayer extends BasePlayer this.priorityTaskManager = priorityTaskManager; } - /** @deprecated Use {@link #setPlaybackSpeed(float)} instead. */ - @SuppressWarnings("deprecation") + /** + * Sets the {@link PlaybackParams} governing audio playback. + * + * @param params The {@link PlaybackParams}, or null to clear any previously set parameters. + * @deprecated Use {@link #setPlaybackParameters(PlaybackParameters)}. + */ @Deprecated @RequiresApi(23) public void setPlaybackParams(@Nullable PlaybackParams params) { - float playbackSpeed; + PlaybackParameters playbackParameters; if (params != null) { params.allowDefaults(); - playbackSpeed = params.getSpeed(); + playbackParameters = new PlaybackParameters(params.getSpeed(), params.getPitch()); } else { - playbackSpeed = 1.0f; + playbackParameters = null; } - setPlaybackSpeed(playbackSpeed); + setPlaybackParameters(playbackParameters); } /** Returns the video format currently being played, or null if no video is being played. */ @@ -909,6 +1109,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addVideoListener(com.google.android.exoplayer2.video.VideoListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); videoListeners.add(listener); } @@ -962,7 +1163,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated @SuppressWarnings("deprecation") - public void setVideoListener(VideoListener listener) { + public void setVideoListener(@Nullable VideoListener listener) { videoListeners.clear(); if (listener != null) { addVideoListener(listener); @@ -985,6 +1186,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addTextOutput(TextOutput listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); textOutputs.add(listener); } @@ -1028,6 +1230,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addMetadataOutput(MetadataOutput listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); metadataOutputs.add(listener); } @@ -1068,7 +1271,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated @SuppressWarnings("deprecation") - public void setVideoDebugListener(VideoRendererEventListener listener) { + public void setVideoDebugListener(@Nullable VideoRendererEventListener listener) { videoDebugListeners.retainAll(Collections.singleton(analyticsCollector)); if (listener != null) { addVideoDebugListener(listener); @@ -1081,6 +1284,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated public void addVideoDebugListener(VideoRendererEventListener listener) { + Assertions.checkNotNull(listener); videoDebugListeners.add(listener); } @@ -1099,7 +1303,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated @SuppressWarnings("deprecation") - public void setAudioDebugListener(AudioRendererEventListener listener) { + public void setAudioDebugListener(@Nullable AudioRendererEventListener listener) { audioDebugListeners.retainAll(Collections.singleton(analyticsCollector)); if (listener != null) { addAudioDebugListener(listener); @@ -1112,6 +1316,7 @@ public class SimpleExoPlayer extends BasePlayer */ @Deprecated public void addAudioDebugListener(AudioRendererEventListener listener) { + Assertions.checkNotNull(listener); audioDebugListeners.add(listener); } @@ -1139,6 +1344,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addListener(Player.EventListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); player.addListener(listener); } @@ -1455,39 +1661,18 @@ public class SimpleExoPlayer extends BasePlayer player.seekTo(windowIndex, positionMs); } - /** - * @deprecated Use {@link #setPlaybackSpeed(float)} and {@link #setSkipSilenceEnabled(boolean)} - * instead. - */ - @SuppressWarnings("deprecation") - @Deprecated @Override public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { verifyApplicationThread(); player.setPlaybackParameters(playbackParameters); } - /** @deprecated Use {@link #getPlaybackSpeed()} and {@link #getSkipSilenceEnabled()} instead. */ - @SuppressWarnings("deprecation") - @Deprecated @Override public PlaybackParameters getPlaybackParameters() { verifyApplicationThread(); return player.getPlaybackParameters(); } - @Override - public void setPlaybackSpeed(float playbackSpeed) { - verifyApplicationThread(); - player.setPlaybackSpeed(playbackSpeed); - } - - @Override - public float getPlaybackSpeed() { - verifyApplicationThread(); - return player.getPlaybackSpeed(); - } - @Override public void setSeekParameters(@Nullable SeekParameters seekParameters) { verifyApplicationThread(); @@ -1534,7 +1719,6 @@ public class SimpleExoPlayer extends BasePlayer Assertions.checkNotNull(priorityTaskManager).remove(C.PRIORITY_PLAYBACK); isPriorityTaskManagerRegistered = false; } - bandwidthMeter.removeEventListener(analyticsCollector); currentCues = Collections.emptyList(); playerReleased = true; } @@ -1557,6 +1741,13 @@ public class SimpleExoPlayer extends BasePlayer return player.getRendererType(index); } + @Override + @Nullable + public TrackSelector getTrackSelector() { + verifyApplicationThread(); + return player.getTrackSelector(); + } + @Override public TrackGroupArray getCurrentTrackGroups() { verifyApplicationThread(); @@ -1679,6 +1870,7 @@ public class SimpleExoPlayer extends BasePlayer * @param wakeMode The {@link C.WakeMode} option to keep the device awake during playback. */ public void setWakeMode(@C.WakeMode int wakeMode) { + verifyApplicationThread(); switch (wakeMode) { case C.WAKE_MODE_NONE: wakeLockManager.setEnabled(false); @@ -1700,6 +1892,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void addDeviceListener(DeviceListener listener) { // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); deviceListeners.add(listener); } @@ -2013,11 +2206,9 @@ public class SimpleExoPlayer extends BasePlayer } @Override - public void onVideoFrameProcessingOffset( - long totalProcessingOffsetUs, int frameCount, Format format) { + public void onVideoFrameProcessingOffset(long totalProcessingOffsetUs, int frameCount) { for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { - videoDebugListener.onVideoFrameProcessingOffset( - totalProcessingOffsetUs, frameCount, format); + videoDebugListener.onVideoFrameProcessingOffset(totalProcessingOffsetUs, frameCount); } } @@ -2058,10 +2249,16 @@ public class SimpleExoPlayer extends BasePlayer } @Override - public void onAudioSinkUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + public void onAudioPositionAdvancing(long playoutStartSystemTimeMs) { for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { - audioDebugListener.onAudioSinkUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + audioDebugListener.onAudioPositionAdvancing(playoutStartSystemTimeMs); + } + } + + @Override + public void onAudioUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index c3d9cab7ab..e992eb588d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2; +import android.net.Uri; import android.os.SystemClock; import android.util.Pair; import androidx.annotation.Nullable; @@ -47,62 +48,74 @@ import com.google.android.exoplayer2.util.Util; *

    Single media file or on-demand stream

    * *

    Example timeline for a
- * single file A timeline for a single media file or on-demand stream consists of a single period - * and window. The window spans the whole period, indicating that all parts of the media are - * available for playback. The window's default position is typically at the start of the period - * (indicated by the black dot in the figure above). + * single file"> + * + *

    A timeline for a single media file or on-demand stream consists of a single period and window. + * The window spans the whole period, indicating that all parts of the media are available for + * playback. The window's default position is typically at the start of the period (indicated by the + * black dot in the figure above). * *

    Playlist of media files or on-demand streams

    * *

    Example timeline for a
- * playlist of files A timeline for a playlist of media files or on-demand streams consists of - * multiple periods, each with its own window. Each window spans the whole of the corresponding - * period, and typically has a default position at the start of the period. The properties of the - * periods and windows (e.g. their durations and whether the window is seekable) will often only - * become known when the player starts buffering the corresponding file or stream. + * playlist of files"> + * + *

    A timeline for a playlist of media files or on-demand streams consists of multiple periods, + * each with its own window. Each window spans the whole of the corresponding period, and typically + * has a default position at the start of the period. The properties of the periods and windows + * (e.g. their durations and whether the window is seekable) will often only become known when the + * player starts buffering the corresponding file or stream. * *

    Live stream with limited availability

    * *

    Example timeline for
- * a live stream with limited availability A timeline for a live stream consists of a period whose - * duration is unknown, since it's continually extending as more content is broadcast. If content - * only remains available for a limited period of time then the window may start at a non-zero - * position, defining the region of content that can still be played. The window will have {@link - * Window#isLive} set to true to indicate it's a live stream and {@link Window#isDynamic} set to - * true as long as we expect changes to the live window. Its default position is typically near to - * the live edge (indicated by the black dot in the figure above). + * a live stream with limited availability"> + * + *

    A timeline for a live stream consists of a period whose duration is unknown, since it's + * continually extending as more content is broadcast. If content only remains available for a + * limited period of time then the window may start at a non-zero position, defining the region of + * content that can still be played. The window will have {@link Window#isLive} set to true to + * indicate it's a live stream and {@link Window#isDynamic} set to true as long as we expect changes + * to the live window. Its default position is typically near to the live edge (indicated by the + * black dot in the figure above). * *

    Live stream with indefinite availability

    * *

    Example timeline
- * for a live stream with indefinite availability A timeline for a live stream with indefinite - * availability is similar to the Live stream with limited availability - * case, except that the window starts at the beginning of the period to indicate that all of the - * previously broadcast content can still be played. + * for a live stream with indefinite availability"> + * + *

    A timeline for a live stream with indefinite availability is similar to the Live stream with limited availability case, except that the window + * starts at the beginning of the period to indicate that all of the previously broadcast content + * can still be played. * *

    Live stream with multiple periods

    * *

    Example timeline
- * for a live stream with multiple periods This case arises when a live stream is explicitly - * divided into separate periods, for example at content boundaries. This case is similar to the Live stream with limited availability case, except that the window may - * span more than one period. Multiple periods are also possible in the indefinite availability - * case. + * for a live stream with multiple periods"> + * + *

    This case arises when a live stream is explicitly divided into separate periods, for example + * at content boundaries. This case is similar to the Live stream with + * limited availability case, except that the window may span more than one period. Multiple + * periods are also possible in the indefinite availability case. * *

    On-demand stream followed by live stream

    * *

    Example timeline for an
- * on-demand stream followed by a live stream This case is the concatenation of the Single media file or on-demand stream and Live - * stream with multiple periods cases. When playback of the on-demand stream ends, playback of - * the live stream will start from its default position near the live edge. + * on-demand stream followed by a live stream"> + * + *

    This case is the concatenation of the Single media file or on-demand + * stream and Live stream with multiple periods cases. When playback + * of the on-demand stream ends, playback of the live stream will start from its default position + * near the live edge. * *

    On-demand stream with mid-roll ads

    * *

    Example
- * timeline for an on-demand stream with mid-roll ad groups This case includes mid-roll ad groups, - * which are defined as part of the timeline's single period. The period can be queried for - * information about the ad groups and the ads they contain. + * timeline for an on-demand stream with mid-roll ad groups"> + * + *

    This case includes mid-roll ad groups, which are defined as part of the timeline's single + * period. The period can be queried for information about the ad groups and the ads they contain. */ public abstract class Timeline { @@ -123,6 +136,12 @@ public abstract class Timeline { */ public static final Object SINGLE_WINDOW_UID = new Object(); + private static final MediaItem EMPTY_MEDIA_ITEM = + new MediaItem.Builder() + .setMediaId("com.google.android.exoplayer2.Timeline") + .setUri(Uri.EMPTY) + .build(); + /** * A unique identifier for the window. Single-window {@link Timeline Timelines} must use {@link * #SINGLE_WINDOW_UID}. @@ -133,7 +152,7 @@ public abstract class Timeline { @Deprecated @Nullable public Object tag; /** The {@link MediaItem} associated to the window. Not necessarily unique. */ - @Nullable public MediaItem mediaItem; + public MediaItem mediaItem; /** The manifest of the window. May be {@code null}. */ @Nullable public Object manifest; @@ -215,46 +234,7 @@ public abstract class Timeline { /** Creates window. */ public Window() { uid = SINGLE_WINDOW_UID; - } - - /** - * @deprecated Use {@link #set(Object, MediaItem, Object, long, long, long, boolean, boolean, - * boolean, long, long, int, int, long)} instead. - */ - @SuppressWarnings("deprecation") - @Deprecated - public Window set( - Object uid, - @Nullable Object tag, - @Nullable Object manifest, - long presentationStartTimeMs, - long windowStartTimeMs, - long elapsedRealtimeEpochOffsetMs, - boolean isSeekable, - boolean isDynamic, - boolean isLive, - long defaultPositionUs, - long durationUs, - int firstPeriodIndex, - int lastPeriodIndex, - long positionInFirstPeriodUs) { - set( - uid, - /* mediaItem= */ null, - manifest, - presentationStartTimeMs, - windowStartTimeMs, - elapsedRealtimeEpochOffsetMs, - isSeekable, - isDynamic, - isLive, - defaultPositionUs, - durationUs, - firstPeriodIndex, - lastPeriodIndex, - positionInFirstPeriodUs); - this.tag = tag; - return this; + mediaItem = EMPTY_MEDIA_ITEM; } /** Sets the data held by this window. */ @@ -275,7 +255,7 @@ public abstract class Timeline { int lastPeriodIndex, long positionInFirstPeriodUs) { this.uid = uid; - this.mediaItem = mediaItem; + this.mediaItem = mediaItem != null ? mediaItem : EMPTY_MEDIA_ITEM; this.tag = mediaItem != null && mediaItem.playbackProperties != null ? mediaItem.playbackProperties.tag @@ -356,6 +336,7 @@ public abstract class Timeline { return Util.getNowUnixTimeMs(elapsedRealtimeEpochOffsetMs); } + // Provide backward compatibility for tag. @Override public boolean equals(@Nullable Object obj) { if (this == obj) { @@ -366,7 +347,6 @@ public abstract class Timeline { } Window that = (Window) obj; return Util.areEqual(uid, that.uid) - && Util.areEqual(tag, that.tag) && Util.areEqual(mediaItem, that.mediaItem) && Util.areEqual(manifest, that.manifest) && presentationStartTimeMs == that.presentationStartTimeMs @@ -383,12 +363,12 @@ public abstract class Timeline { && positionInFirstPeriodUs == that.positionInFirstPeriodUs; } + // Provide backward compatibility for tag. @Override public int hashCode() { int result = 7; result = 31 * result + uid.hashCode(); - result = 31 * result + (tag == null ? 0 : tag.hashCode()); - result = 31 * result + (mediaItem == null ? 0 : mediaItem.hashCode()); + result = 31 * result + mediaItem.hashCode(); result = 31 * result + (manifest == null ? 0 : manifest.hashCode()); result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32)); result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32)); @@ -688,7 +668,7 @@ public abstract class Timeline { result = 31 * result + windowIndex; result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); result = 31 * result + (int) (positionInWindowUs ^ (positionInWindowUs >>> 32)); - result = 31 * result + (adPlaybackState == null ? 0 : adPlaybackState.hashCode()); + result = 31 * result + adPlaybackState.hashCode(); return result; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index fceaa14b73..35f3099dc9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -15,11 +15,14 @@ */ package com.google.android.exoplayer2.analytics; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; @@ -45,12 +48,12 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.video.VideoListener; import com.google.android.exoplayer2.video.VideoRendererEventListener; +import com.google.common.base.Objects; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -72,6 +75,7 @@ public class AnalyticsCollector private final CopyOnWriteArraySet listeners; private final Clock clock; + private final Period period; private final Window window; private final MediaPeriodQueueTracker mediaPeriodQueueTracker; @@ -84,10 +88,11 @@ public class AnalyticsCollector * @param clock A {@link Clock} used to generate timestamps. */ public AnalyticsCollector(Clock clock) { - this.clock = Assertions.checkNotNull(clock); + this.clock = checkNotNull(clock); listeners = new CopyOnWriteArraySet<>(); - mediaPeriodQueueTracker = new MediaPeriodQueueTracker(); + period = new Period(); window = new Window(); + mediaPeriodQueueTracker = new MediaPeriodQueueTracker(period); } /** @@ -96,6 +101,7 @@ public class AnalyticsCollector * @param listener The listener to add. */ public void addListener(AnalyticsListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } @@ -116,8 +122,22 @@ public class AnalyticsCollector */ public void setPlayer(Player player) { Assertions.checkState( - this.player == null || mediaPeriodQueueTracker.mediaPeriodInfoQueue.isEmpty()); - this.player = Assertions.checkNotNull(player); + this.player == null || mediaPeriodQueueTracker.mediaPeriodQueue.isEmpty()); + this.player = checkNotNull(player); + } + + /** + * Updates the playback queue information used for event association. + * + *

    Should only be called by the player controlling the queue and not from app code. + * + * @param queue The playback queue of media periods identified by their {@link MediaPeriodId}. + * @param readingPeriod The media period in the queue that is currently being read by renderers, + * or null if the queue is empty. + */ + public void updateMediaPeriodQueueInfo( + List queue, @Nullable MediaPeriodId readingPeriod) { + mediaPeriodQueueTracker.onQueueUpdated(queue, readingPeriod, checkNotNull(player)); } // External events. @@ -138,12 +158,7 @@ public class AnalyticsCollector /** Resets the analytics collector for a new playlist. */ public final void resetForNewPlaylist() { - // Copying the list is needed because onMediaPeriodReleased will modify the list. - List mediaPeriodInfos = - new ArrayList<>(mediaPeriodQueueTracker.mediaPeriodInfoQueue); - for (MediaPeriodInfo mediaPeriodInfo : mediaPeriodInfos) { - onMediaPeriodReleased(mediaPeriodInfo.windowIndex, mediaPeriodInfo.mediaPeriodId); - } + // TODO: remove method. } // MetadataOutput implementation. @@ -158,34 +173,48 @@ public class AnalyticsCollector // AudioRendererEventListener implementation. + @SuppressWarnings("deprecation") @Override public final void onAudioEnabled(DecoderCounters counters) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioEnabled(eventTime, counters); listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_AUDIO, counters); } } + @SuppressWarnings("deprecation") @Override public final void onAudioDecoderInitialized( String decoderName, long initializedTimestampMs, long initializationDurationMs) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioDecoderInitialized(eventTime, decoderName, initializationDurationMs); listener.onDecoderInitialized( eventTime, C.TRACK_TYPE_AUDIO, decoderName, initializationDurationMs); } } + @SuppressWarnings("deprecation") @Override public final void onAudioInputFormatChanged(Format format) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioInputFormatChanged(eventTime, format); listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_AUDIO, format); } } @Override - public final void onAudioSinkUnderrun( + public final void onAudioPositionAdvancing(long playoutStartSystemTimeMs) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioPositionAdvancing(eventTime, playoutStartSystemTimeMs); + } + } + + @Override + public final void onAudioUnderrun( int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { @@ -193,10 +222,12 @@ public class AnalyticsCollector } } + @SuppressWarnings("deprecation") @Override public final void onAudioDisabled(DecoderCounters counters) { EventTime eventTime = generatePlayingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onAudioDisabled(eventTime, counters); listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_AUDIO, counters); } } @@ -237,28 +268,34 @@ public class AnalyticsCollector // VideoRendererEventListener implementation. + @SuppressWarnings("deprecation") @Override public final void onVideoEnabled(DecoderCounters counters) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoEnabled(eventTime, counters); listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_VIDEO, counters); } } + @SuppressWarnings("deprecation") @Override public final void onVideoDecoderInitialized( String decoderName, long initializedTimestampMs, long initializationDurationMs) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoDecoderInitialized(eventTime, decoderName, initializationDurationMs); listener.onDecoderInitialized( eventTime, C.TRACK_TYPE_VIDEO, decoderName, initializationDurationMs); } } + @SuppressWarnings("deprecation") @Override public final void onVideoInputFormatChanged(Format format) { EventTime eventTime = generateReadingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoInputFormatChanged(eventTime, format); listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_VIDEO, format); } } @@ -271,10 +308,12 @@ public class AnalyticsCollector } } + @SuppressWarnings("deprecation") @Override public final void onVideoDisabled(DecoderCounters counters) { EventTime eventTime = generatePlayingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { + listener.onVideoDisabled(eventTime, counters); listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_VIDEO, counters); } } @@ -288,11 +327,10 @@ public class AnalyticsCollector } @Override - public final void onVideoFrameProcessingOffset( - long totalProcessingOffsetUs, int frameCount, Format format) { + public final void onVideoFrameProcessingOffset(long totalProcessingOffsetUs, int frameCount) { EventTime eventTime = generatePlayingMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { - listener.onVideoFrameProcessingOffset(eventTime, totalProcessingOffsetUs, frameCount, format); + listener.onVideoFrameProcessingOffset(eventTime, totalProcessingOffsetUs, frameCount); } } @@ -323,27 +361,6 @@ public class AnalyticsCollector // MediaSourceEventListener implementation. - @Override - public final void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { - mediaPeriodQueueTracker.onMediaPeriodCreated( - windowIndex, mediaPeriodId, Assertions.checkNotNull(player)); - EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); - for (AnalyticsListener listener : listeners) { - listener.onMediaPeriodCreated(eventTime); - } - } - - @Override - public final void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) { - EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); - if (mediaPeriodQueueTracker.onMediaPeriodReleased( - mediaPeriodId, Assertions.checkNotNull(player))) { - for (AnalyticsListener listener : listeners) { - listener.onMediaPeriodReleased(eventTime); - } - } - } - @Override public final void onLoadStarted( int windowIndex, @@ -394,15 +411,6 @@ public class AnalyticsCollector } } - @Override - public final void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) { - mediaPeriodQueueTracker.onReadingStarted(mediaPeriodId); - EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); - for (AnalyticsListener listener : listeners) { - listener.onReadingStarted(eventTime); - } - } - @Override public final void onUpstreamDiscarded( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { @@ -429,13 +437,22 @@ public class AnalyticsCollector @Override public final void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { - mediaPeriodQueueTracker.onTimelineChanged(timeline, Assertions.checkNotNull(player)); + mediaPeriodQueueTracker.onTimelineChanged(checkNotNull(player)); EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { listener.onTimelineChanged(eventTime, reason); } } + @Override + public final void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onMediaItemTransition(eventTime, mediaItem, reason); + } + } + @Override public final void onTracksChanged( TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { @@ -514,7 +531,10 @@ public class AnalyticsCollector @Override public final void onPlayerError(ExoPlaybackException error) { - EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); + EventTime eventTime = + error.mediaPeriodId != null + ? generateEventTime(error.mediaPeriodId) + : generateCurrentPlayerMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { listener.onPlayerError(eventTime, error); } @@ -525,19 +545,13 @@ public class AnalyticsCollector if (reason == Player.DISCONTINUITY_REASON_SEEK) { isSeeking = false; } - mediaPeriodQueueTracker.onPositionDiscontinuity(Assertions.checkNotNull(player)); + mediaPeriodQueueTracker.onPositionDiscontinuity(checkNotNull(player)); EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); for (AnalyticsListener listener : listeners) { listener.onPositionDiscontinuity(eventTime, reason); } } - /** - * @deprecated Use {@link #onPlaybackSpeedChanged(float)} and {@link - * #onSkipSilenceEnabledChanged(boolean)} instead. - */ - @SuppressWarnings("deprecation") - @Deprecated @Override public final void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); @@ -546,14 +560,6 @@ public class AnalyticsCollector } } - @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { - EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); - for (AnalyticsListener listener : listeners) { - listener.onPlaybackSpeedChanged(eventTime, playbackSpeed); - } - } - @SuppressWarnings("deprecation") @Override public final void onSeekProcessed() { @@ -626,10 +632,6 @@ public class AnalyticsCollector // Internal methods. - /** Returns read-only set of registered listeners. */ - protected Set getListeners() { - return Collections.unmodifiableSet(listeners); - } /** Returns a new {@link EventTime} for the specified timeline, window and media period id. */ @RequiresNonNull("player") @@ -659,27 +661,37 @@ public class AnalyticsCollector eventPositionMs = timeline.isEmpty() ? 0 : timeline.getWindow(windowIndex, window).getDefaultPositionMs(); } + @Nullable + MediaPeriodId currentMediaPeriodId = mediaPeriodQueueTracker.getCurrentPlayerMediaPeriod(); return new EventTime( realtimeMs, timeline, windowIndex, mediaPeriodId, eventPositionMs, + player.getCurrentTimeline(), + player.getCurrentWindowIndex(), + currentMediaPeriodId, player.getCurrentPosition(), player.getTotalBufferedDuration()); } - private EventTime generateEventTime(@Nullable MediaPeriodInfo mediaPeriodInfo) { - Assertions.checkNotNull(player); - if (mediaPeriodInfo == null) { + private EventTime generateEventTime(@Nullable MediaPeriodId mediaPeriodId) { + checkNotNull(player); + @Nullable + Timeline knownTimeline = + mediaPeriodId == null + ? null + : mediaPeriodQueueTracker.getMediaPeriodIdTimeline(mediaPeriodId); + if (mediaPeriodId == null || knownTimeline == null) { int windowIndex = player.getCurrentWindowIndex(); Timeline timeline = player.getCurrentTimeline(); boolean windowIsInTimeline = windowIndex < timeline.getWindowCount(); return generateEventTime( windowIsInTimeline ? timeline : Timeline.EMPTY, windowIndex, /* mediaPeriodId= */ null); } - return generateEventTime( - mediaPeriodInfo.timeline, mediaPeriodInfo.windowIndex, mediaPeriodInfo.mediaPeriodId); + int windowIndex = knownTimeline.getPeriodByUid(mediaPeriodId.periodUid, period).windowIndex; + return generateEventTime(knownTimeline, windowIndex, mediaPeriodId); } private EventTime generateCurrentPlayerMediaPeriodEventTime() { @@ -700,11 +712,12 @@ public class AnalyticsCollector private EventTime generateMediaPeriodEventTime( int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { - Assertions.checkNotNull(player); + checkNotNull(player); if (mediaPeriodId != null) { - MediaPeriodInfo mediaPeriodInfo = mediaPeriodQueueTracker.getMediaPeriodInfo(mediaPeriodId); - return mediaPeriodInfo != null - ? generateEventTime(mediaPeriodInfo) + boolean isInKnownTimeline = + mediaPeriodQueueTracker.getMediaPeriodIdTimeline(mediaPeriodId) != null; + return isInKnownTimeline + ? generateEventTime(mediaPeriodId) : generateEventTime(Timeline.EMPTY, windowIndex, mediaPeriodId); } Timeline timeline = player.getCurrentTimeline(); @@ -716,161 +729,149 @@ public class AnalyticsCollector /** Keeps track of the active media periods and currently playing and reading media period. */ private static final class MediaPeriodQueueTracker { - // TODO: Investigate reporting MediaPeriodId in renderer events and adding a listener of queue - // changes, which would hopefully remove the need to track the queue here. + // TODO: Investigate reporting MediaPeriodId in renderer events. - private final ArrayList mediaPeriodInfoQueue; - private final HashMap mediaPeriodIdToInfo; private final Period period; - @Nullable private MediaPeriodInfo currentPlayerMediaPeriod; - private @MonotonicNonNull MediaPeriodInfo playingMediaPeriod; - private @MonotonicNonNull MediaPeriodInfo readingMediaPeriod; - private Timeline timeline; + private ImmutableList mediaPeriodQueue; + private ImmutableMap mediaPeriodTimelines; + @Nullable private MediaPeriodId currentPlayerMediaPeriod; + private @MonotonicNonNull MediaPeriodId playingMediaPeriod; + private @MonotonicNonNull MediaPeriodId readingMediaPeriod; - public MediaPeriodQueueTracker() { - mediaPeriodInfoQueue = new ArrayList<>(); - mediaPeriodIdToInfo = new HashMap<>(); - period = new Period(); - timeline = Timeline.EMPTY; + public MediaPeriodQueueTracker(Period period) { + this.period = period; + mediaPeriodQueue = ImmutableList.of(); + mediaPeriodTimelines = ImmutableMap.of(); } /** - * Returns the {@link MediaPeriodInfo} of the media period corresponding the current position of + * Returns the {@link MediaPeriodId} of the media period corresponding the current position of * the player. * *

    May be null if no matching media period has been created yet. */ @Nullable - public MediaPeriodInfo getCurrentPlayerMediaPeriod() { + public MediaPeriodId getCurrentPlayerMediaPeriod() { return currentPlayerMediaPeriod; } /** - * Returns the {@link MediaPeriodInfo} of the media period at the front of the queue. If the - * queue is empty, this is the last media period which was at the front of the queue. + * Returns the {@link MediaPeriodId} of the media period at the front of the queue. If the queue + * is empty, this is the last media period which was at the front of the queue. * *

    May be null, if no media period has been created yet. */ @Nullable - public MediaPeriodInfo getPlayingMediaPeriod() { + public MediaPeriodId getPlayingMediaPeriod() { return playingMediaPeriod; } /** - * Returns the {@link MediaPeriodInfo} of the media period currently being read by the player. + * Returns the {@link MediaPeriodId} of the media period currently being read by the player. If + * the queue is empty, this is the last media period which was read by the player. * - *

    May be null, if the player has not started reading any media period. + *

    May be null, if no media period has been created yet. */ @Nullable - public MediaPeriodInfo getReadingMediaPeriod() { + public MediaPeriodId getReadingMediaPeriod() { return readingMediaPeriod; } /** - * Returns the {@link MediaPeriodInfo} of the media period at the end of the queue which is + * Returns the {@link MediaPeriodId} of the media period at the end of the queue which is * currently loading or will be the next one loading. * *

    May be null, if no media period is active yet. */ @Nullable - public MediaPeriodInfo getLoadingMediaPeriod() { - return mediaPeriodInfoQueue.isEmpty() - ? null - : mediaPeriodInfoQueue.get(mediaPeriodInfoQueue.size() - 1); - } - - /** Returns the {@link MediaPeriodInfo} for the given {@link MediaPeriodId}. */ - @Nullable - public MediaPeriodInfo getMediaPeriodInfo(MediaPeriodId mediaPeriodId) { - return mediaPeriodIdToInfo.get(mediaPeriodId); - } - - /** Updates the queue with a reported position discontinuity. */ - public void onPositionDiscontinuity(Player player) { - currentPlayerMediaPeriod = findMatchingMediaPeriodInQueue(player); - } - - /** Updates the queue with a reported timeline change. */ - public void onTimelineChanged(Timeline timeline, Player player) { - for (int i = 0; i < mediaPeriodInfoQueue.size(); i++) { - MediaPeriodInfo newMediaPeriodInfo = - updateMediaPeriodInfoToNewTimeline(mediaPeriodInfoQueue.get(i), timeline); - mediaPeriodInfoQueue.set(i, newMediaPeriodInfo); - mediaPeriodIdToInfo.put(newMediaPeriodInfo.mediaPeriodId, newMediaPeriodInfo); - } - if (!mediaPeriodInfoQueue.isEmpty()) { - playingMediaPeriod = mediaPeriodInfoQueue.get(0); - } else if (playingMediaPeriod != null) { - playingMediaPeriod = updateMediaPeriodInfoToNewTimeline(playingMediaPeriod, timeline); - } - if (readingMediaPeriod != null) { - readingMediaPeriod = updateMediaPeriodInfoToNewTimeline(readingMediaPeriod, timeline); - } else if (playingMediaPeriod != null) { - readingMediaPeriod = playingMediaPeriod; - } - this.timeline = timeline; - currentPlayerMediaPeriod = findMatchingMediaPeriodInQueue(player); - } - - /** Updates the queue with a newly created media period. */ - public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId, Player player) { - int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); - boolean isInTimeline = periodIndex != C.INDEX_UNSET; - MediaPeriodInfo mediaPeriodInfo = - new MediaPeriodInfo( - mediaPeriodId, - isInTimeline ? timeline : Timeline.EMPTY, - isInTimeline ? timeline.getPeriod(periodIndex, period).windowIndex : windowIndex); - mediaPeriodInfoQueue.add(mediaPeriodInfo); - mediaPeriodIdToInfo.put(mediaPeriodId, mediaPeriodInfo); - playingMediaPeriod = mediaPeriodInfoQueue.get(0); - if (currentPlayerMediaPeriod == null && isMatchingPlayingMediaPeriod(player)) { - currentPlayerMediaPeriod = playingMediaPeriod; - } - if (mediaPeriodInfoQueue.size() == 1) { - readingMediaPeriod = playingMediaPeriod; - } + public MediaPeriodId getLoadingMediaPeriod() { + return mediaPeriodQueue.isEmpty() ? null : Iterables.getLast(mediaPeriodQueue); } /** - * Updates the queue with a released media period. Returns whether the media period was still in - * the queue. + * Returns the most recent {@link Timeline} for the given {@link MediaPeriodId}, or null if no + * timeline is available. */ - public boolean onMediaPeriodReleased(MediaPeriodId mediaPeriodId, Player player) { - @Nullable MediaPeriodInfo mediaPeriodInfo = mediaPeriodIdToInfo.remove(mediaPeriodId); - if (mediaPeriodInfo == null) { - // The media period has already been removed from the queue in resetForNewPlaylist(). - return false; - } - mediaPeriodInfoQueue.remove(mediaPeriodInfo); - if (readingMediaPeriod != null && mediaPeriodId.equals(readingMediaPeriod.mediaPeriodId)) { - readingMediaPeriod = - mediaPeriodInfoQueue.isEmpty() - ? Assertions.checkNotNull(playingMediaPeriod) - : mediaPeriodInfoQueue.get(0); - } - if (!mediaPeriodInfoQueue.isEmpty()) { - playingMediaPeriod = mediaPeriodInfoQueue.get(0); - } - if (currentPlayerMediaPeriod == null && isMatchingPlayingMediaPeriod(player)) { - currentPlayerMediaPeriod = playingMediaPeriod; - } - return true; + @Nullable + public Timeline getMediaPeriodIdTimeline(MediaPeriodId mediaPeriodId) { + return mediaPeriodTimelines.get(mediaPeriodId); } - /** Update the queue with a change in the reading media period. */ - public void onReadingStarted(MediaPeriodId mediaPeriodId) { - @Nullable MediaPeriodInfo mediaPeriodInfo = mediaPeriodIdToInfo.get(mediaPeriodId); - if (mediaPeriodInfo == null) { - // The media period has already been removed from the queue in resetForNewPlaylist(). + /** Updates the queue tracker with a reported position discontinuity. */ + public void onPositionDiscontinuity(Player player) { + currentPlayerMediaPeriod = + findCurrentPlayerMediaPeriodInQueue(player, mediaPeriodQueue, playingMediaPeriod, period); + } + + /** Updates the queue tracker with a reported timeline change. */ + public void onTimelineChanged(Player player) { + currentPlayerMediaPeriod = + findCurrentPlayerMediaPeriodInQueue(player, mediaPeriodQueue, playingMediaPeriod, period); + updateMediaPeriodTimelines(/* preferredTimeline= */ player.getCurrentTimeline()); + } + + /** Updates the queue tracker to a new queue of media periods. */ + public void onQueueUpdated( + List queue, @Nullable MediaPeriodId readingPeriod, Player player) { + mediaPeriodQueue = ImmutableList.copyOf(queue); + if (!queue.isEmpty()) { + playingMediaPeriod = queue.get(0); + readingMediaPeriod = checkNotNull(readingPeriod); + } + if (currentPlayerMediaPeriod == null) { + currentPlayerMediaPeriod = + findCurrentPlayerMediaPeriodInQueue( + player, mediaPeriodQueue, playingMediaPeriod, period); + } + updateMediaPeriodTimelines(/* preferredTimeline= */ player.getCurrentTimeline()); + } + + private void updateMediaPeriodTimelines(Timeline preferredTimeline) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + if (mediaPeriodQueue.isEmpty()) { + addTimelineForMediaPeriodId(builder, playingMediaPeriod, preferredTimeline); + if (!Objects.equal(readingMediaPeriod, playingMediaPeriod)) { + addTimelineForMediaPeriodId(builder, readingMediaPeriod, preferredTimeline); + } + if (!Objects.equal(currentPlayerMediaPeriod, playingMediaPeriod) + && !Objects.equal(currentPlayerMediaPeriod, readingMediaPeriod)) { + addTimelineForMediaPeriodId(builder, currentPlayerMediaPeriod, preferredTimeline); + } + } else { + for (int i = 0; i < mediaPeriodQueue.size(); i++) { + addTimelineForMediaPeriodId(builder, mediaPeriodQueue.get(i), preferredTimeline); + } + if (!mediaPeriodQueue.contains(currentPlayerMediaPeriod)) { + addTimelineForMediaPeriodId(builder, currentPlayerMediaPeriod, preferredTimeline); + } + } + mediaPeriodTimelines = builder.build(); + } + + private void addTimelineForMediaPeriodId( + ImmutableMap.Builder mediaPeriodTimelinesBuilder, + @Nullable MediaPeriodId mediaPeriodId, + Timeline preferredTimeline) { + if (mediaPeriodId == null) { return; } - readingMediaPeriod = mediaPeriodInfo; + if (preferredTimeline.getIndexOfPeriod(mediaPeriodId.periodUid) != C.INDEX_UNSET) { + mediaPeriodTimelinesBuilder.put(mediaPeriodId, preferredTimeline); + } else { + @Nullable Timeline existingTimeline = mediaPeriodTimelines.get(mediaPeriodId); + if (existingTimeline != null) { + mediaPeriodTimelinesBuilder.put(mediaPeriodId, existingTimeline); + } + } } @Nullable - private MediaPeriodInfo findMatchingMediaPeriodInQueue(Player player) { + private static MediaPeriodId findCurrentPlayerMediaPeriodInQueue( + Player player, + ImmutableList mediaPeriodQueue, + @Nullable MediaPeriodId playingMediaPeriod, + Period period) { Timeline playerTimeline = player.getCurrentTimeline(); int playerPeriodIndex = player.getCurrentPeriodIndex(); @Nullable @@ -883,25 +884,21 @@ public class AnalyticsCollector .getPeriod(playerPeriodIndex, period) .getAdGroupIndexAfterPositionUs( C.msToUs(player.getCurrentPosition()) - period.getPositionInWindowUs()); - for (int i = 0; i < mediaPeriodInfoQueue.size(); i++) { - MediaPeriodInfo mediaPeriodInfo = mediaPeriodInfoQueue.get(i); + for (int i = 0; i < mediaPeriodQueue.size(); i++) { + MediaPeriodId mediaPeriodId = mediaPeriodQueue.get(i); if (isMatchingMediaPeriod( - mediaPeriodInfo, - playerTimeline, - player.getCurrentWindowIndex(), + mediaPeriodId, playerPeriodUid, player.isPlayingAd(), player.getCurrentAdGroupIndex(), player.getCurrentAdIndexInAdGroup(), playerNextAdGroupIndex)) { - return mediaPeriodInfo; + return mediaPeriodId; } } - if (mediaPeriodInfoQueue.isEmpty() && playingMediaPeriod != null) { + if (mediaPeriodQueue.isEmpty() && playingMediaPeriod != null) { if (isMatchingMediaPeriod( playingMediaPeriod, - playerTimeline, - player.getCurrentWindowIndex(), playerPeriodUid, player.isPlayingAd(), player.getCurrentAdGroupIndex(), @@ -913,89 +910,23 @@ public class AnalyticsCollector return null; } - private boolean isMatchingPlayingMediaPeriod(Player player) { - if (playingMediaPeriod == null) { - return false; - } - Timeline playerTimeline = player.getCurrentTimeline(); - int playerPeriodIndex = player.getCurrentPeriodIndex(); - @Nullable - Object playerPeriodUid = - playerTimeline.isEmpty() ? null : playerTimeline.getUidOfPeriod(playerPeriodIndex); - int playerNextAdGroupIndex = - player.isPlayingAd() || playerTimeline.isEmpty() - ? C.INDEX_UNSET - : playerTimeline - .getPeriod(playerPeriodIndex, period) - .getAdGroupIndexAfterPositionUs( - C.msToUs(player.getCurrentPosition()) - period.getPositionInWindowUs()); - return isMatchingMediaPeriod( - playingMediaPeriod, - playerTimeline, - player.getCurrentWindowIndex(), - playerPeriodUid, - player.isPlayingAd(), - player.getCurrentAdGroupIndex(), - player.getCurrentAdIndexInAdGroup(), - playerNextAdGroupIndex); - } - private static boolean isMatchingMediaPeriod( - MediaPeriodInfo mediaPeriodInfo, - Timeline playerTimeline, - int playerWindowIndex, + MediaPeriodId mediaPeriodId, @Nullable Object playerPeriodUid, boolean isPlayingAd, int playerAdGroupIndex, int playerAdIndexInAdGroup, int playerNextAdGroupIndex) { - if (mediaPeriodInfo.timeline.isEmpty() - || !mediaPeriodInfo.timeline.equals(playerTimeline) - || mediaPeriodInfo.windowIndex != playerWindowIndex - || !mediaPeriodInfo.mediaPeriodId.periodUid.equals(playerPeriodUid)) { + if (!mediaPeriodId.periodUid.equals(playerPeriodUid)) { return false; } // Timeline period matches. Still need to check ad information. return (isPlayingAd - && mediaPeriodInfo.mediaPeriodId.adGroupIndex == playerAdGroupIndex - && mediaPeriodInfo.mediaPeriodId.adIndexInAdGroup == playerAdIndexInAdGroup) + && mediaPeriodId.adGroupIndex == playerAdGroupIndex + && mediaPeriodId.adIndexInAdGroup == playerAdIndexInAdGroup) || (!isPlayingAd - && mediaPeriodInfo.mediaPeriodId.adGroupIndex == C.INDEX_UNSET - && mediaPeriodInfo.mediaPeriodId.nextAdGroupIndex == playerNextAdGroupIndex); - } - - private MediaPeriodInfo updateMediaPeriodInfoToNewTimeline( - MediaPeriodInfo info, Timeline newTimeline) { - int newPeriodIndex = newTimeline.getIndexOfPeriod(info.mediaPeriodId.periodUid); - if (newPeriodIndex == C.INDEX_UNSET) { - // Media period is not yet or no longer available in the new timeline. Keep it as it is. - return info; - } - int newWindowIndex = newTimeline.getPeriod(newPeriodIndex, period).windowIndex; - return new MediaPeriodInfo(info.mediaPeriodId, newTimeline, newWindowIndex); - } - } - - /** Information about a media period and its associated timeline. */ - private static final class MediaPeriodInfo { - - /** The {@link MediaPeriodId} of the media period. */ - public final MediaPeriodId mediaPeriodId; - /** - * The {@link Timeline} in which the media period can be found. Or {@link Timeline#EMPTY} if the - * media period is not part of a known timeline yet. - */ - public final Timeline timeline; - /** - * The window index of the media period in the timeline. If the timeline is empty, this is the - * prospective window index. - */ - public final int windowIndex; - - public MediaPeriodInfo(MediaPeriodId mediaPeriodId, Timeline timeline, int windowIndex) { - this.mediaPeriodId = mediaPeriodId; - this.timeline = timeline; - this.windowIndex = windowIndex; + && mediaPeriodId.adGroupIndex == C.INDEX_UNSET + && mediaPeriodId.nextAdGroupIndex == playerNextAdGroupIndex); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java index 0b841ab543..2e26019541 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -20,6 +20,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; @@ -27,7 +28,6 @@ import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; import com.google.android.exoplayer2.Player.TimelineChangeReason; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioAttributes; -import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.LoadEventInfo; @@ -35,6 +35,7 @@ import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.common.base.Objects; import java.io.IOException; /** @@ -56,7 +57,7 @@ public interface AnalyticsListener { */ public final long realtimeMs; - /** Timeline at the time of the event. */ + /** Most recent {@link Timeline} that contains the event position. */ public final Timeline timeline; /** @@ -66,8 +67,8 @@ public interface AnalyticsListener { public final int windowIndex; /** - * Media period identifier for the media period this event belongs to, or {@code null} if the - * event is not associated with a specific media period. + * {@link MediaPeriodId Media period identifier} for the media period this event belongs to, or + * {@code null} if the event is not associated with a specific media period. */ @Nullable public final MediaPeriodId mediaPeriodId; @@ -77,8 +78,27 @@ public interface AnalyticsListener { public final long eventPlaybackPositionMs; /** - * Position in the current timeline window ({@link Player#getCurrentWindowIndex()}) or the - * currently playing ad at the time of the event, in milliseconds. + * The current {@link Timeline} at the time of the event (equivalent to {@link + * Player#getCurrentTimeline()}). + */ + public final Timeline currentTimeline; + + /** + * The current window index in {@link #currentTimeline} at the time of the event, or the + * prospective window index if the timeline is not yet known and empty (equivalent to {@link + * Player#getCurrentWindowIndex()}). + */ + public final int currentWindowIndex; + + /** + * {@link MediaPeriodId Media period identifier} for the currently playing media period at the + * time of the event, or {@code null} if no current media period identifier is available. + */ + @Nullable public final MediaPeriodId currentMediaPeriodId; + + /** + * Position in the {@link #currentWindowIndex current timeline window} or the currently playing + * ad at the time of the event, in milliseconds. */ public final long currentPlaybackPositionMs; @@ -91,19 +111,27 @@ public interface AnalyticsListener { /** * @param realtimeMs Elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} at * the time of the event, in milliseconds. - * @param timeline Timeline at the time of the event. - * @param windowIndex Window index in the {@link #timeline} this event belongs to, or the + * @param timeline Most recent {@link Timeline} that contains the event position. + * @param windowIndex Window index in the {@code timeline} this event belongs to, or the * prospective window index if the timeline is not yet known and empty. - * @param mediaPeriodId Media period identifier for the media period this event belongs to, or - * {@code null} if the event is not associated with a specific media period. + * @param mediaPeriodId {@link MediaPeriodId Media period identifier} for the media period this + * event belongs to, or {@code null} if the event is not associated with a specific media + * period. * @param eventPlaybackPositionMs Position in the window or ad this event belongs to at the time * of the event, in milliseconds. - * @param currentPlaybackPositionMs Position in the current timeline window ({@link - * Player#getCurrentWindowIndex()}) or the currently playing ad at the time of the event, in - * milliseconds. - * @param totalBufferedDurationMs Total buffered duration from {@link - * #currentPlaybackPositionMs} at the time of the event, in milliseconds. This includes - * pre-buffered data for subsequent ads and windows. + * @param currentTimeline The current {@link Timeline} at the time of the event (equivalent to + * {@link Player#getCurrentTimeline()}). + * @param currentWindowIndex The current window index in {@code currentTimeline} at the time of + * the event, or the prospective window index if the timeline is not yet known and empty + * (equivalent to {@link Player#getCurrentWindowIndex()}). + * @param currentMediaPeriodId {@link MediaPeriodId Media period identifier} for the currently + * playing media period at the time of the event, or {@code null} if no current media period + * identifier is available. + * @param currentPlaybackPositionMs Position in the current timeline window or the currently + * playing ad at the time of the event, in milliseconds. + * @param totalBufferedDurationMs Total buffered duration from {@code currentPlaybackPositionMs} + * at the time of the event, in milliseconds. This includes pre-buffered data for subsequent + * ads and windows. */ public EventTime( long realtimeMs, @@ -111,6 +139,9 @@ public interface AnalyticsListener { int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long eventPlaybackPositionMs, + Timeline currentTimeline, + int currentWindowIndex, + @Nullable MediaPeriodId currentMediaPeriodId, long currentPlaybackPositionMs, long totalBufferedDurationMs) { this.realtimeMs = realtimeMs; @@ -118,9 +149,48 @@ public interface AnalyticsListener { this.windowIndex = windowIndex; this.mediaPeriodId = mediaPeriodId; this.eventPlaybackPositionMs = eventPlaybackPositionMs; + this.currentTimeline = currentTimeline; + this.currentWindowIndex = currentWindowIndex; + this.currentMediaPeriodId = currentMediaPeriodId; this.currentPlaybackPositionMs = currentPlaybackPositionMs; this.totalBufferedDurationMs = totalBufferedDurationMs; } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + EventTime eventTime = (EventTime) o; + return realtimeMs == eventTime.realtimeMs + && windowIndex == eventTime.windowIndex + && eventPlaybackPositionMs == eventTime.eventPlaybackPositionMs + && currentWindowIndex == eventTime.currentWindowIndex + && currentPlaybackPositionMs == eventTime.currentPlaybackPositionMs + && totalBufferedDurationMs == eventTime.totalBufferedDurationMs + && Objects.equal(timeline, eventTime.timeline) + && Objects.equal(mediaPeriodId, eventTime.mediaPeriodId) + && Objects.equal(currentTimeline, eventTime.currentTimeline) + && Objects.equal(currentMediaPeriodId, eventTime.currentMediaPeriodId); + } + + @Override + public int hashCode() { + return Objects.hashCode( + realtimeMs, + timeline, + windowIndex, + mediaPeriodId, + eventPlaybackPositionMs, + currentTimeline, + currentWindowIndex, + currentMediaPeriodId, + currentPlaybackPositionMs, + totalBufferedDurationMs); + } } /** @@ -174,6 +244,18 @@ public interface AnalyticsListener { */ default void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int reason) {} + /** + * Called when playback transitions to a different media item. + * + * @param eventTime The event time. + * @param mediaItem The media item. + * @param reason The reason for the media item transition. + */ + default void onMediaItemTransition( + EventTime eventTime, + @Nullable MediaItem mediaItem, + @Player.MediaItemTransitionReason int reason) {} + /** * Called when a position discontinuity occurred. * @@ -190,29 +272,20 @@ public interface AnalyticsListener { default void onSeekStarted(EventTime eventTime) {} /** - * @deprecated Seeks are processed without delay. Listen to {@link - * #onPositionDiscontinuity(EventTime, int)} with reason {@link - * Player#DISCONTINUITY_REASON_SEEK} instead. + * @deprecated Seeks are processed without delay. Use {@link #onPositionDiscontinuity(EventTime, + * int)} with reason {@link Player#DISCONTINUITY_REASON_SEEK} instead. */ @Deprecated default void onSeekProcessed(EventTime eventTime) {} /** - * @deprecated Use {@link #onPlaybackSpeedChanged(EventTime, float)} and {@link - * #onSkipSilenceEnabledChanged(EventTime, boolean)} instead. - */ - @SuppressWarnings("deprecation") - @Deprecated - default void onPlaybackParametersChanged( - EventTime eventTime, PlaybackParameters playbackParameters) {} - - /** - * Called when the playback speed changes. + * Called when the playback parameters changed. * * @param eventTime The event time. - * @param playbackSpeed The playback speed. + * @param playbackParameters The new playback parameters. */ - default void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) {} + default void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) {} /** * Called when the repeat mode changed. @@ -327,27 +400,6 @@ public interface AnalyticsListener { */ default void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData) {} - /** - * Called when a media source created a media period. - * - * @param eventTime The event time. - */ - default void onMediaPeriodCreated(EventTime eventTime) {} - - /** - * Called when a media source released a media period. - * - * @param eventTime The event time. - */ - default void onMediaPeriodReleased(EventTime eventTime) {} - - /** - * Called when the player started reading a media period. - * - * @param eventTime The event time. - */ - default void onReadingStarted(EventTime eventTime) {} - /** * Called when the bandwidth estimate for the current data source has been updated. * @@ -359,17 +411,6 @@ public interface AnalyticsListener { default void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {} - /** - * Called when the output surface size changed. - * - * @param eventTime The event time. - * @param width The surface width in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if the - * video is not rendered onto a surface. - * @param height The surface height in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if - * the video is not rendered onto a surface. - */ - default void onSurfaceSizeChanged(EventTime eventTime, int width, int height) {} - /** * Called when there is {@link Metadata} associated with the current playback time. * @@ -378,50 +419,88 @@ public interface AnalyticsListener { */ default void onMetadata(EventTime eventTime, Metadata metadata) {} - /** - * Called when an audio or video decoder has been enabled. - * - * @param eventTime The event time. - * @param trackType The track type of the enabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or - * {@link C#TRACK_TYPE_VIDEO}. - * @param decoderCounters The accumulated event counters associated with this decoder. - */ + /** @deprecated Use {@link #onAudioEnabled} and {@link #onVideoEnabled} instead. */ + @Deprecated default void onDecoderEnabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} /** - * Called when an audio or video decoder has been initialized. - * - * @param eventTime The event time. - * @param trackType The track type of the initialized decoder. Either {@link C#TRACK_TYPE_AUDIO} - * or {@link C#TRACK_TYPE_VIDEO}. - * @param decoderName The decoder that was created. - * @param initializationDurationMs Time taken to initialize the decoder, in milliseconds. + * @deprecated Use {@link #onAudioDecoderInitialized} and {@link #onVideoDecoderInitialized} + * instead. */ + @Deprecated default void onDecoderInitialized( EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) {} /** - * Called when an audio or video decoder input format changed. - * - * @param eventTime The event time. - * @param trackType The track type of the decoder whose format changed. Either {@link - * C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. - * @param format The new input format for the decoder. + * @deprecated Use {@link #onAudioInputFormatChanged} and {@link #onVideoInputFormatChanged} + * instead. */ + @Deprecated default void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) {} - /** - * Called when an audio or video decoder has been disabled. - * - * @param eventTime The event time. - * @param trackType The track type of the disabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or - * {@link C#TRACK_TYPE_VIDEO}. - * @param decoderCounters The accumulated event counters associated with this decoder. - */ + /** @deprecated Use {@link #onAudioDisabled} and {@link #onVideoDisabled} instead. */ + @Deprecated default void onDecoderDisabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + /** + * Called when an audio renderer is enabled. + * + * @param eventTime The event time. + * @param counters {@link DecoderCounters} that will be updated by the renderer for as long as it + * remains enabled. + */ + default void onAudioEnabled(EventTime eventTime, DecoderCounters counters) {} + + /** + * Called when an audio renderer creates a decoder. + * + * @param eventTime The event time. + * @param decoderName The decoder that was created. + * @param initializationDurationMs The time taken to initialize the decoder in milliseconds. + */ + default void onAudioDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) {} + + /** + * Called when the format of the media being consumed by an audio renderer changes. + * + * @param eventTime The event time. + * @param format The new format. + */ + default void onAudioInputFormatChanged(EventTime eventTime, Format format) {} + + /** + * Called when the audio position has increased for the first time since the last pause or + * position reset. + * + * @param eventTime The event time. + * @param playoutStartSystemTimeMs The approximate derived {@link System#currentTimeMillis()} at + * which playout started. + */ + default void onAudioPositionAdvancing(EventTime eventTime, long playoutStartSystemTimeMs) {} + + /** + * Called when an audio underrun occurs. + * + * @param eventTime The event time. + * @param bufferSize The size of the audio output buffer, in bytes. + * @param bufferSizeMs The size of the audio output buffer, in milliseconds, if it contains PCM + * encoded audio. {@link C#TIME_UNSET} if the output buffer contains non-PCM encoded audio. + * @param elapsedSinceLastFeedMs The time since audio was last written to the output buffer. + */ + default void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + + /** + * Called when an audio renderer is disabled. + * + * @param eventTime The event time. + * @param counters {@link DecoderCounters} that were updated by the renderer. + */ + default void onAudioDisabled(EventTime eventTime, DecoderCounters counters) {} + /** * Called when the audio session id is set. * @@ -438,6 +517,14 @@ public interface AnalyticsListener { */ default void onAudioAttributesChanged(EventTime eventTime, AudioAttributes audioAttributes) {} + /** + * Called when skipping silences is enabled or disabled in the audio stream. + * + * @param eventTime The event time. + * @param skipSilenceEnabled Whether skipping silences in the audio stream is enabled. + */ + default void onSkipSilenceEnabledChanged(EventTime eventTime, boolean skipSilenceEnabled) {} + /** * Called when the volume changes. * @@ -447,25 +534,31 @@ public interface AnalyticsListener { default void onVolumeChanged(EventTime eventTime, float volume) {} /** - * Called when an audio underrun occurred. + * Called when a video renderer is enabled. * * @param eventTime The event time. - * @param bufferSize The size of the {@link AudioSink}'s buffer, in bytes. - * @param bufferSizeMs The size of the {@link AudioSink}'s buffer, in milliseconds, if it is - * configured for PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, - * as the buffered media can have a variable bitrate so the duration may be unknown. - * @param elapsedSinceLastFeedMs The time since the {@link AudioSink} was last fed data. + * @param counters {@link DecoderCounters} that will be updated by the renderer for as long as it + * remains enabled. */ - default void onAudioUnderrun( - EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + default void onVideoEnabled(EventTime eventTime, DecoderCounters counters) {} /** - * Called when skipping silences is enabled or disabled in the audio stream. + * Called when a video renderer creates a decoder. * * @param eventTime The event time. - * @param skipSilenceEnabled Whether skipping silences in the audio stream is enabled. + * @param decoderName The decoder that was created. + * @param initializationDurationMs The time taken to initialize the decoder in milliseconds. */ - default void onSkipSilenceEnabledChanged(EventTime eventTime, boolean skipSilenceEnabled) {} + default void onVideoDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) {} + + /** + * Called when the format of the media being consumed by a video renderer changes. + * + * @param eventTime The event time. + * @param format The new format. + */ + default void onVideoInputFormatChanged(EventTime eventTime, Format format) {} /** * Called after video frames have been dropped. @@ -478,29 +571,41 @@ public interface AnalyticsListener { */ default void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {} + /** + * Called when a video renderer is disabled. + * + * @param eventTime The event time. + * @param counters {@link DecoderCounters} that were updated by the renderer. + */ + default void onVideoDisabled(EventTime eventTime, DecoderCounters counters) {} + /** * Called when there is an update to the video frame processing offset reported by a video * renderer. * - *

    Video processing offset represents how early a video frame is processed compared to the - * player's current position. For each video frame, the offset is calculated as Pvf - * - Ppl where Pvf is the presentation timestamp of the video - * frame and Ppl is the current position of the player. Positive values - * indicate the frame was processed early enough whereas negative values indicate that the - * player's position had progressed beyond the frame's timestamp when the frame was processed (and - * the frame was probably dropped). - * - *

    The renderer reports the sum of video processing offset samples (one sample per processed - * video frame: dropped, skipped or rendered) and the total number of samples (frames). + *

    The processing offset for a video frame is the difference between the time at which the + * frame became available to render, and the time at which it was scheduled to be rendered. A + * positive value indicates the frame became available early enough, whereas a negative value + * indicates that the frame wasn't available until after the time at which it should have been + * rendered. * * @param eventTime The event time. - * @param totalProcessingOffsetUs The sum of video frame processing offset samples for all video - * frames processed by the renderer in microseconds. - * @param frameCount The number to samples included in the {@code totalProcessingOffsetUs}. - * @param format The current output {@link Format} rendered by the video renderer. + * @param totalProcessingOffsetUs The sum of the video frame processing offsets for frames + * rendered since the last call to this method. + * @param frameCount The number to samples included in {@code totalProcessingOffsetUs}. */ default void onVideoFrameProcessingOffset( - EventTime eventTime, long totalProcessingOffsetUs, int frameCount, Format format) {} + EventTime eventTime, long totalProcessingOffsetUs, int frameCount) {} + + /** + * Called when a frame is rendered for the first time since setting the surface, or since the + * renderer was reset, or since the stream being rendered was changed. + * + * @param eventTime The event time. + * @param surface The {@link Surface} to which a frame has been rendered, or {@code null} if the + * renderer renders to something that isn't a {@link Surface}. + */ + default void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) {} /** * Called before a frame is rendered for the first time since setting the surface, and each time @@ -523,14 +628,15 @@ public interface AnalyticsListener { float pixelWidthHeightRatio) {} /** - * Called when a frame is rendered for the first time since setting the surface, and when a frame - * is rendered for the first time since the renderer was reset. + * Called when the output surface size changed. * * @param eventTime The event time. - * @param surface The {@link Surface} to which a first frame has been rendered, or {@code null} if - * the renderer renders to something that isn't a {@link Surface}. + * @param width The surface width in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if the + * video is not rendered onto a surface. + * @param height The surface height in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if + * the video is not rendered onto a surface. */ - default void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) {} + default void onSurfaceSizeChanged(EventTime eventTime, int width, int height) {} /** * Called each time a drm session is acquired. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java index 04536bb6c1..9746829107 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.analytics; +import static com.google.android.exoplayer2.C.usToMs; +import static java.lang.Math.max; + import android.util.Base64; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -24,8 +27,8 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.Supplier; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Supplier; import java.util.HashMap; import java.util.Iterator; import java.util.Random; @@ -120,6 +123,38 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag if (currentSessionId == null) { currentSessionId = eventSession.sessionId; } + if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { + // Ensure that the content session for an ad session is created first. + MediaPeriodId contentMediaPeriodId = + new MediaPeriodId( + eventTime.mediaPeriodId.periodUid, + eventTime.mediaPeriodId.windowSequenceNumber, + eventTime.mediaPeriodId.adGroupIndex); + SessionDescriptor contentSession = + getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); + if (!contentSession.isCreated) { + contentSession.isCreated = true; + eventTime.timeline.getPeriodByUid(eventTime.mediaPeriodId.periodUid, period); + long adGroupPositionMs = + usToMs(period.getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex)) + + period.getPositionInWindowMs(); + // getAdGroupTimeUs may return 0 for prerolls despite period offset. + adGroupPositionMs = max(0, adGroupPositionMs); + EventTime eventTimeForContent = + new EventTime( + eventTime.realtimeMs, + eventTime.timeline, + eventTime.windowIndex, + contentMediaPeriodId, + /* eventPlaybackPositionMs= */ adGroupPositionMs, + eventTime.currentTimeline, + eventTime.currentWindowIndex, + eventTime.currentMediaPeriodId, + eventTime.currentPlaybackPositionMs, + eventTime.totalBufferedDurationMs); + listener.onSessionCreated(eventTimeForContent, contentSession.sessionId); + } + } if (!eventSession.isCreated) { eventSession.isCreated = true; listener.onSessionCreated(eventTime, eventSession.sessionId); @@ -131,7 +166,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag } @Override - public synchronized void handleTimelineUpdate(EventTime eventTime) { + public synchronized void updateSessionsWithTimelineChange(EventTime eventTime) { Assertions.checkNotNull(listener); Timeline previousTimeline = currentTimeline; currentTimeline = eventTime.timeline; @@ -149,11 +184,11 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag } } } - handlePositionDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); + updateSessionsWithDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); } @Override - public synchronized void handlePositionDiscontinuity( + public synchronized void updateSessionsWithDiscontinuity( EventTime eventTime, @DiscontinuityReason int reason) { Assertions.checkNotNull(listener); boolean hasAutomaticTransition = @@ -179,6 +214,7 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag SessionDescriptor currentSessionDescriptor = getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); currentSessionId = currentSessionDescriptor.sessionId; + updateSessions(eventTime); if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd() && (previousSessionDescriptor == null @@ -195,10 +231,8 @@ public final class DefaultPlaybackSessionManager implements PlaybackSessionManag eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); SessionDescriptor contentSession = getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); - if (contentSession.isCreated && currentSessionDescriptor.isCreated) { - listener.onAdPlaybackStarted( - eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId); - } + listener.onAdPlaybackStarted( + eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java index 7045779125..1038f3b6e1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java @@ -99,24 +99,34 @@ public interface PlaybackSessionManager { /** * Updates or creates sessions based on a player {@link EventTime}. * + *

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

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

    Should be called instead of {@link #updateSessions(EventTime)} if a position discontinuity + * occurred. + * + * @param eventTime The {@link EventTime} of the position discontinuity. * @param reason The {@link DiscontinuityReason}. */ - void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); + void updateSessionsWithDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); /** * Finishes all existing sessions and calls their respective {@link diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java index 3b1e0567cb..3c6929bdbf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.analytics; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -276,7 +279,7 @@ public final class PlaybackStats { if (firstReportedTimeMs == C.TIME_UNSET) { firstReportedTimeMs = stats.firstReportedTimeMs; } else if (stats.firstReportedTimeMs != C.TIME_UNSET) { - firstReportedTimeMs = Math.min(firstReportedTimeMs, stats.firstReportedTimeMs); + firstReportedTimeMs = min(firstReportedTimeMs, stats.firstReportedTimeMs); } foregroundPlaybackCount += stats.foregroundPlaybackCount; abandonedBeforeReadyCount += stats.abandonedBeforeReadyCount; @@ -295,7 +298,7 @@ public final class PlaybackStats { if (maxRebufferTimeMs == C.TIME_UNSET) { maxRebufferTimeMs = stats.maxRebufferTimeMs; } else if (stats.maxRebufferTimeMs != C.TIME_UNSET) { - maxRebufferTimeMs = Math.max(maxRebufferTimeMs, stats.maxRebufferTimeMs); + maxRebufferTimeMs = max(maxRebufferTimeMs, stats.maxRebufferTimeMs); } adPlaybackCount += stats.adPlaybackCount; totalVideoFormatHeightTimeMs += stats.totalVideoFormatHeightTimeMs; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java index 0524f4d3b1..ab137f98e1 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 @@ -15,11 +15,14 @@ */ package com.google.android.exoplayer2.analytics; +import static java.lang.Math.max; + import android.os.SystemClock; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; @@ -150,16 +153,18 @@ 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. - EventTime dummyEventTime = + sessionManager.finishAllSessions( new EventTime( SystemClock.elapsedRealtime(), Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, - /* totalBufferedDurationMs= */ 0); - sessionManager.finishAllSessions(dummyEventTime); + /* totalBufferedDurationMs= */ 0)); } // PlaybackSessionManager.Listener implementation. @@ -210,6 +215,9 @@ public final class PlaybackStatsListener eventTime.mediaPeriodId.windowSequenceNumber, eventTime.mediaPeriodId.adGroupIndex), /* eventPlaybackPositionMs= */ C.usToMs(contentWindowPositionUs), + eventTime.timeline, + eventTime.currentWindowIndex, + eventTime.currentMediaPeriodId, eventTime.currentPlaybackPositionMs, eventTime.totalBufferedDurationMs); Assertions.checkNotNull(playbackStatsTrackers.get(contentSession)) @@ -279,8 +287,7 @@ public final class PlaybackStatsListener @Override public void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason int reason) { - sessionManager.handleTimelineUpdate(eventTime); - maybeAddSession(eventTime); + sessionManager.updateSessionsWithTimelineChange(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime, /* isSeek= */ false); @@ -290,8 +297,10 @@ public final class PlaybackStatsListener @Override public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) { - sessionManager.handlePositionDiscontinuity(eventTime, reason); - maybeAddSession(eventTime); + boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; + if (!isCompletelyIdle) { + sessionManager.updateSessionsWithDiscontinuity(eventTime, reason); + } if (reason == Player.DISCONTINUITY_REASON_SEEK) { onSeekStartedCalled = false; } @@ -326,8 +335,9 @@ public final class PlaybackStatsListener } @Override - public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { - this.playbackSpeed = playbackSpeed; + public void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) { + playbackSpeed = playbackParameters.speed; maybeAddSession(eventTime); for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); @@ -786,7 +796,7 @@ public final class PlaybackStatsListener long buildTimeMs = SystemClock.elapsedRealtime(); playbackStateDurationsMs = Arrays.copyOf(this.playbackStateDurationsMs, PlaybackStats.PLAYBACK_STATE_COUNT); - long lastStateDurationMs = Math.max(0, buildTimeMs - currentPlaybackStateStartTimeMs); + long lastStateDurationMs = max(0, buildTimeMs - currentPlaybackStateStartTimeMs); playbackStateDurationsMs[currentPlaybackState] += lastStateDurationMs; maybeUpdateMaxRebufferTimeMs(buildTimeMs); maybeRecordVideoFormatTime(buildTimeMs); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java index 991ed9ee97..c9c78a7422 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java @@ -65,7 +65,7 @@ public final class AudioCapabilitiesReceiver { context = context.getApplicationContext(); this.context = context; this.listener = Assertions.checkNotNull(listener); - handler = new Handler(Util.getLooper()); + handler = Util.createHandlerForCurrentOrMainLooper(); receiver = Util.SDK_INT >= 21 ? new HdmiAudioPlugBroadcastReceiver() : null; Uri externalSurroundSoundUri = AudioCapabilities.getExternalSurroundSoundGlobalSettingUri(); externalSurroundSoundSettingObserver = diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java index 7cb05cfa0d..f921141f24 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java @@ -66,16 +66,23 @@ public interface AudioRendererEventListener { default void onAudioInputFormatChanged(Format format) {} /** - * Called when an {@link AudioSink} underrun occurs. + * Called when the audio position has increased for the first time since the last pause or + * position reset. * - * @param bufferSize The size of the {@link AudioSink}'s buffer, in bytes. - * @param bufferSizeMs The size of the {@link AudioSink}'s buffer, in milliseconds, if it is - * configured for PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, - * as the buffered media can have a variable bitrate so the duration may be unknown. - * @param elapsedSinceLastFeedMs The time since the {@link AudioSink} was last fed data. + * @param playoutStartSystemTimeMs The approximate derived {@link System#currentTimeMillis()} at + * which playout started. */ - default void onAudioSinkUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + default void onAudioPositionAdvancing(long playoutStartSystemTimeMs) {} + + /** + * Called when an audio underrun occurs. + * + * @param bufferSize The size of the audio output buffer, in bytes. + * @param bufferSizeMs The size of the audio output buffer, in milliseconds, if it contains PCM + * encoded audio. {@link C#TIME_UNSET} if the output buffer contains non-PCM encoded audio. + * @param elapsedSinceLastFeedMs The time since audio was last written to the output buffer. + */ + default void onAudioUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} /** * Called when the renderer is disabled. @@ -91,37 +98,33 @@ public interface AudioRendererEventListener { */ default void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) {} - /** Dispatches events to a {@link AudioRendererEventListener}. */ + /** Dispatches events to an {@link AudioRendererEventListener}. */ final class EventDispatcher { @Nullable private final Handler handler; @Nullable private final AudioRendererEventListener listener; /** - * @param handler A handler for dispatching events, or null if creating a dummy instance. - * @param listener The listener to which events should be dispatched, or null if creating a - * dummy instance. + * @param handler A handler for dispatching events, or null if events should not be dispatched. + * @param listener The listener to which events should be dispatched, or null if events should + * not be dispatched. */ - public EventDispatcher(@Nullable Handler handler, - @Nullable AudioRendererEventListener listener) { + public EventDispatcher( + @Nullable Handler handler, @Nullable AudioRendererEventListener listener) { this.handler = listener != null ? Assertions.checkNotNull(handler) : null; this.listener = listener; } - /** - * Invokes {@link AudioRendererEventListener#onAudioEnabled(DecoderCounters)}. - */ - public void enabled(final DecoderCounters decoderCounters) { + /** Invokes {@link AudioRendererEventListener#onAudioEnabled(DecoderCounters)}. */ + public void enabled(DecoderCounters decoderCounters) { if (handler != null) { handler.post(() -> castNonNull(listener).onAudioEnabled(decoderCounters)); } } - /** - * Invokes {@link AudioRendererEventListener#onAudioDecoderInitialized(String, long, long)}. - */ - public void decoderInitialized(final String decoderName, - final long initializedTimestampMs, final long initializationDurationMs) { + /** Invokes {@link AudioRendererEventListener#onAudioDecoderInitialized(String, long, long)}. */ + public void decoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { if (handler != null) { handler.post( () -> @@ -131,32 +134,33 @@ public interface AudioRendererEventListener { } } - /** - * Invokes {@link AudioRendererEventListener#onAudioInputFormatChanged(Format)}. - */ - public void inputFormatChanged(final Format format) { + /** Invokes {@link AudioRendererEventListener#onAudioInputFormatChanged(Format)}. */ + public void inputFormatChanged(Format format) { if (handler != null) { handler.post(() -> castNonNull(listener).onAudioInputFormatChanged(format)); } } - /** - * Invokes {@link AudioRendererEventListener#onAudioSinkUnderrun(int, long, long)}. - */ - public void audioTrackUnderrun(final int bufferSize, final long bufferSizeMs, - final long elapsedSinceLastFeedMs) { + /** Invokes {@link AudioRendererEventListener#onAudioPositionAdvancing(long)}. */ + public void positionAdvancing(long playoutStartSystemTimeMs) { + if (handler != null) { + handler.post( + () -> castNonNull(listener).onAudioPositionAdvancing(playoutStartSystemTimeMs)); + } + } + + /** Invokes {@link AudioRendererEventListener#onAudioUnderrun(int, long, long)}. */ + public void underrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { if (handler != null) { handler.post( () -> castNonNull(listener) - .onAudioSinkUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)); + .onAudioUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)); } } - /** - * Invokes {@link AudioRendererEventListener#onAudioDisabled(DecoderCounters)}. - */ - public void disabled(final DecoderCounters counters) { + /** Invokes {@link AudioRendererEventListener#onAudioDisabled(DecoderCounters)}. */ + public void disabled(DecoderCounters counters) { counters.ensureUpdated(); if (handler != null) { handler.post( @@ -167,17 +171,15 @@ public interface AudioRendererEventListener { } } - /** - * Invokes {@link AudioRendererEventListener#onAudioSessionId(int)}. - */ - public void audioSessionId(final int audioSessionId) { + /** Invokes {@link AudioRendererEventListener#onAudioSessionId(int)}. */ + public void audioSessionId(int audioSessionId) { if (handler != null) { handler.post(() -> castNonNull(listener).onAudioSessionId(audioSessionId)); } } /** Invokes {@link AudioRendererEventListener#onSkipSilenceEnabledChanged(boolean)}. */ - public void skipSilenceEnabledChanged(final boolean skipSilenceEnabled) { + public void skipSilenceEnabledChanged(boolean skipSilenceEnabled) { if (handler != null) { handler.post(() -> castNonNull(listener).onSkipSilenceEnabledChanged(skipSilenceEnabled)); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index c4fa25d6bf..b7d375fd9d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -16,25 +16,28 @@ package com.google.android.exoplayer2.audio; import android.media.AudioTrack; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; /** * A sink that consumes audio data. * - *

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

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

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

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

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

    Call {@link #flush()} to prepare the sink to receive audio data from a new playback position. * @@ -70,10 +73,19 @@ public interface AudioSink { */ void onPositionDiscontinuity(); + /** + * Called when the audio sink's position has increased for the first time since it was last + * paused or flushed. + * + * @param playoutStartSystemTimeMs The approximate derived {@link System#currentTimeMillis()} at + * which playout started. Only valid if the audio track has not underrun. + */ + default void onPositionAdvancing(long playoutStartSystemTimeMs) {} + /** * Called when the audio sink runs out of data. - *

    - * An audio sink implementation may never call this method (for example, if audio data is + * + *

    An audio sink implementation may never call this method (for example, if audio data is * consumed in batches rather than based on the sink's own clock). * * @param bufferSize The size of the sink's buffer, in bytes. @@ -90,6 +102,17 @@ public interface AudioSink { * @param skipSilenceEnabled Whether skipping silences is enabled. */ void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled); + + /** Called when the offload buffer has been partially emptied. */ + default void onOffloadBufferEmptying() {} + + /** + * Called when the offload buffer has been filled completely. + * + * @param bufferEmptyingDeadlineMs Maximum time in milliseconds until {@link + * #onOffloadBufferEmptying()} will be called. + */ + default void onOffloadBufferFull(long bufferEmptyingDeadlineMs) {} } /** @@ -162,8 +185,29 @@ public interface AudioSink { } /** - * Returned by {@link #getCurrentPositionUs(boolean)} when the position is not set. + * The level of support the sink provides for a format. One of {@link + * #SINK_FORMAT_SUPPORTED_DIRECTLY}, {@link #SINK_FORMAT_SUPPORTED_WITH_TRANSCODING} or {@link + * #SINK_FORMAT_UNSUPPORTED}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SINK_FORMAT_SUPPORTED_DIRECTLY, + SINK_FORMAT_SUPPORTED_WITH_TRANSCODING, + SINK_FORMAT_UNSUPPORTED + }) + @interface SinkFormatSupport {} + /** The sink supports the format directly, without the need for internal transcoding. */ + int SINK_FORMAT_SUPPORTED_DIRECTLY = 2; + /** + * The sink supports the format, but needs to transcode it internally to do so. Internal + * transcoding may result in lower quality and higher CPU load in some cases. + */ + int SINK_FORMAT_SUPPORTED_WITH_TRANSCODING = 1; + /** The sink does not support the format. */ + int SINK_FORMAT_UNSUPPORTED = 0; + + /** Returned by {@link #getCurrentPositionUs(boolean)} when the position is not set. */ long CURRENT_POSITION_NOT_SET = Long.MIN_VALUE; /** @@ -174,18 +218,25 @@ public interface AudioSink { void setListener(Listener listener); /** - * Returns whether the sink supports the audio format. + * Returns whether the sink supports a given {@link Format}. * - * @param channelCount The number of channels, or {@link Format#NO_VALUE} if not known. - * @param sampleRate The sample rate, or {@link Format#NO_VALUE} if not known. - * @param encoding The audio encoding, or {@link Format#NO_VALUE} if not known. - * @return Whether the sink supports the audio format. + * @param format The format. + * @return Whether the sink supports the format. */ - boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding); + boolean supportsFormat(Format format); /** - * Returns the playback position in the stream starting at zero, in microseconds, or - * {@link #CURRENT_POSITION_NOT_SET} if it is not yet available. + * Returns the level of support that the sink provides for a given {@link Format}. + * + * @param format The format. + * @return The level of support provided. + */ + @SinkFormatSupport + int getFormatSupport(Format format); + + /** + * Returns the playback position in the stream starting at zero, in microseconds, or {@link + * #CURRENT_POSITION_NOT_SET} if it is not yet available. * * @param sourceEnded Specify {@code true} if no more input buffers will be provided. * @return The playback position relative to the start of playback, in microseconds. @@ -195,9 +246,7 @@ public interface AudioSink { /** * Configures (or reconfigures) the sink. * - * @param inputEncoding The encoding of audio data provided in the input buffers. - * @param inputChannelCount The number of channels. - * @param inputSampleRate The sample rate in Hz. + * @param inputFormat The format of audio data provided in the input buffers. * @param specifiedBufferSize A specific size for the playback buffer in bytes, or 0 to infer a * suitable buffer size. * @param outputChannels A mapping from input to output channels that is applied to this sink's @@ -205,20 +254,9 @@ public interface AudioSink { * input unchanged. Otherwise, the element at index {@code i} specifies index of the input * channel to map to output channel {@code i} when preprocessing input buffers. After the map * is applied the audio data will have {@code outputChannels.length} channels. - * @param trimStartFrames The number of audio frames to trim from the start of data written to the - * sink after this call. - * @param trimEndFrames The number of audio frames to trim from data written to the sink - * immediately preceding the next call to {@link #flush()} or this method. * @throws ConfigurationException If an error occurs configuring the sink. */ - void configure( - @C.Encoding int inputEncoding, - int inputChannelCount, - int inputSampleRate, - int specifiedBufferSize, - @Nullable int[] outputChannels, - int trimStartFrames, - int trimEndFrames) + void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException; /** @@ -237,8 +275,8 @@ public interface AudioSink { * *

    Returns whether the data was handled in full. If the data was not handled in full then the * same {@link ByteBuffer} must be provided to subsequent calls until it has been fully consumed, - * except in the case of an intervening call to {@link #flush()} (or to {@link #configure(int, - * int, int, int, int[], int, int)} that causes the sink to be flushed). + * except in the case of an intervening call to {@link #flush()} (or to {@link #configure(Format, + * int, int[])} that causes the sink to be flushed). * * @param buffer The buffer containing audio data. * @param presentationTimeUs The presentation timestamp of the buffer in microseconds. @@ -270,27 +308,20 @@ public interface AudioSink { boolean hasPendingData(); /** - * @deprecated Use {@link #setPlaybackSpeed(float)} and {@link #setSkipSilenceEnabled(boolean)} - * instead. + * Attempts to set the playback parameters. The audio sink may override these parameters if they + * are not supported. + * + * @param playbackParameters The new playback parameters to attempt to set. */ - @Deprecated void setPlaybackParameters(PlaybackParameters playbackParameters); - /** @deprecated Use {@link #getPlaybackSpeed()} and {@link #getSkipSilenceEnabled()} instead. */ - @SuppressWarnings("deprecation") - @Deprecated + /** Returns the active {@link PlaybackParameters}. */ PlaybackParameters getPlaybackParameters(); - /** Sets the playback speed. */ - void setPlaybackSpeed(float playbackSpeed); - - /** Gets the playback speed. */ - float getPlaybackSpeed(); - /** Sets whether silences should be skipped in the audio stream. */ void setSkipSilenceEnabled(boolean skipSilenceEnabled); - /** Gets whether silences are skipped in the audio stream. */ + /** Returns whether silences are skipped in the audio stream. */ boolean getSkipSilenceEnabled(); /** @@ -346,6 +377,18 @@ public interface AudioSink { */ void flush(); + /** + * Flushes the sink, after which it is ready to receive buffers from a new playback position. + * + *

    Does not release the {@link AudioTrack} held by the sink. + * + *

    This method is experimental, and will be renamed or removed in a future release. + * + *

    Only for experimental use as part of {@link + * MediaCodecAudioRenderer#experimentalSetEnableKeepAudioTrackOnSeek(boolean)}. + */ + void experimentalFlushWithoutAudioTrackRelease(); + /** Resets the renderer, releasing any resources that it currently holds. */ void reset(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java index 9e870735f2..6b34d7f13d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioTimestampPoller.java @@ -38,7 +38,7 @@ import java.lang.annotation.RetentionPolicy; * *

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

    Call {@link #setAudioTrack(AudioTrack, int, int, int)} to set the audio track to wrap. Call - * {@link #mayHandleBuffer(long)} if there is input data to write to the track. If it returns false, - * the audio track position is stabilizing and no data may be written. Call {@link #start()} - * immediately before calling {@link AudioTrack#play()}. Call {@link #pause()} when pausing the - * track. Call {@link #handleEndOfStream(long)} when no more data will be written to the track. When - * the audio track will no longer be used, call {@link #reset()}. + *

    Call {@link #setAudioTrack(AudioTrack, boolean, int, int, int)} to set the audio track to + * wrap. Call {@link #mayHandleBuffer(long)} if there is input data to write to the track. If it + * returns false, the audio track position is stabilizing and no data may be written. Call {@link + * #start()} immediately before calling {@link AudioTrack#play()}. Call {@link #pause()} when + * pausing the track. Call {@link #handleEndOfStream(long)} when no more data will be written to the + * track. When the audio track will no longer be used, call {@link #reset()}. */ /* package */ final class AudioTrackPositionTracker { /** Listener for position tracker events. */ public interface Listener { + /** + * Called when the position tracker's position has increased for the first time since it was + * last paused or reset. + * + * @param playoutStartSystemTimeMs The approximate derived {@link System#currentTimeMillis()} at + * which playout started. + */ + void onPositionAdvancing(long playoutStartSystemTimeMs); + /** * Called when the frame position is too far from the expected frame position. * @@ -123,12 +134,14 @@ 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; private static final int MAX_PLAYHEAD_OFFSET_COUNT = 10; - private static final int MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US = 30000; - private static final int MIN_LATENCY_SAMPLE_INTERVAL_US = 500000; + private static final int MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US = 30_000; + private static final int MIN_LATENCY_SAMPLE_INTERVAL_US = 50_0000; private final Listener listener; private final long[] playheadOffsets; @@ -140,6 +153,8 @@ import java.lang.reflect.Method; private int outputSampleRate; private boolean needsPassthroughWorkarounds; private long bufferSizeUs; + private float audioTrackPlaybackSpeed; + private boolean notifiedPositionIncreasing; private long smoothedPlayheadOffsetUs; private long lastPlayheadSampleTimeUs; @@ -160,6 +175,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. * @@ -182,6 +206,7 @@ import java.lang.reflect.Method; * track's position, until the next call to {@link #reset()}. * * @param audioTrack The audio track to wrap. + * @param isPassthrough Whether passthrough mode is being used. * @param outputEncoding The encoding of the audio track. * @param outputPcmFrameSize For PCM output encodings, the frame size. The value is ignored * otherwise. @@ -189,6 +214,7 @@ import java.lang.reflect.Method; */ public void setAudioTrack( AudioTrack audioTrack, + boolean isPassthrough, @C.Encoding int outputEncoding, int outputPcmFrameSize, int bufferSize) { @@ -197,7 +223,7 @@ import java.lang.reflect.Method; this.bufferSize = bufferSize; audioTimestampPoller = new AudioTimestampPoller(audioTrack); outputSampleRate = audioTrack.getSampleRate(); - needsPassthroughWorkarounds = needsPassthroughWorkarounds(outputEncoding); + needsPassthroughWorkarounds = isPassthrough && needsPassthroughWorkarounds(outputEncoding); isOutputPcm = Util.isEncodingLinearPcm(outputEncoding); bufferSizeUs = isOutputPcm ? framesToDurationUs(bufferSize / outputPcmFrameSize) : C.TIME_UNSET; lastRawPlaybackHeadPosition = 0; @@ -206,7 +232,18 @@ import java.lang.reflect.Method; hasData = false; stopTimestampUs = C.TIME_UNSET; forceResetWorkaroundTimeMs = C.TIME_UNSET; + lastLatencySampleTimeUs = 0; latencyUs = 0; + audioTrackPlaybackSpeed = 1f; + } + + public void setAudioTrackPlaybackSpeed(float audioTrackPlaybackSpeed) { + this.audioTrackPlaybackSpeed = audioTrackPlaybackSpeed; + // Extrapolation from the last audio timestamp relies on the audio rate being constant, so we + // reset audio timestamp tracking and wait for a new timestamp. + if (audioTimestampPoller != null) { + audioTimestampPoller.reset(); + } } public long getCurrentPositionUs(boolean sourceEnded) { @@ -217,18 +254,18 @@ 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; + elapsedSinceTimestampUs = + Util.getMediaDurationForPlayoutDuration(elapsedSinceTimestampUs, audioTrackPlaybackSpeed); + 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 +276,43 @@ import java.lang.reflect.Method; positionUs = systemTimeUs + smoothedPlayheadOffsetUs; } if (!sourceEnded) { - positionUs -= latencyUs; + positionUs = 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; + } + + if (!notifiedPositionIncreasing && positionUs > lastPositionUs) { + notifiedPositionIncreasing = true; + long mediaDurationSinceLastPositionUs = C.usToMs(positionUs - lastPositionUs); + long playoutDurationSinceLastPositionUs = + Util.getPlayoutDurationForMediaDuration( + mediaDurationSinceLastPositionUs, audioTrackPlaybackSpeed); + long playoutStartSystemTimeMs = + System.currentTimeMillis() - C.usToMs(playoutDurationSinceLastPositionUs); + listener.onPositionAdvancing(playoutStartSystemTimeMs); + } + + lastSystemTimeUs = systemTimeUs; + lastPositionUs = positionUs; + lastSampleUsedGetTimestampMode = useGetTimestampMode; + + return positionUs; } /** Starts position tracking. Must be called immediately before {@link AudioTrack#play()}. */ @@ -283,7 +353,7 @@ import java.lang.reflect.Method; boolean hadData = hasData; hasData = hasPendingData(writtenFrames); - if (hadData && !hasData && playState != PLAYSTATE_STOPPED && listener != null) { + if (hadData && !hasData && playState != PLAYSTATE_STOPPED) { listener.onUnderrun(bufferSize, C.usToMs(bufferSizeUs)); } @@ -304,6 +374,11 @@ import java.lang.reflect.Method; return bufferSize - bytesPending; } + /** Returns the duration of audio that is buffered but unplayed. */ + public long getPendingBufferDurationMs(long writtenFrames) { + return C.usToMs(framesToDurationUs(writtenFrames - getPlaybackHeadPosition())); + } + /** Returns whether the track is in an invalid state and must be recreated. */ public boolean isStalled(long writtenFrames) { return forceResetWorkaroundTimeMs != C.TIME_UNSET @@ -353,8 +428,8 @@ import java.lang.reflect.Method; } /** - * Resets the position tracker. Should be called when the audio track previous passed to {@link - * #setAudioTrack(AudioTrack, int, int, int)} is no longer in use. + * Resets the position tracker. Should be called when the audio track previously passed to {@link + * #setAudioTrack(AudioTrack, boolean, int, int, int)} is no longer in use. */ public void reset() { resetSyncParams(); @@ -399,7 +474,7 @@ import java.lang.reflect.Method; return; } - // Perform sanity checks on the timestamp and accept/reject it. + // Check the timestamp and accept/reject it. long audioTimestampSystemTimeUs = audioTimestampPoller.getTimestampSystemTimeUs(); long audioTimestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); if (Math.abs(audioTimestampSystemTimeUs - systemTimeUs) > MAX_AUDIO_TIMESTAMP_OFFSET_US) { @@ -433,9 +508,9 @@ import java.lang.reflect.Method; castNonNull((Integer) getLatencyMethod.invoke(Assertions.checkNotNull(audioTrack))) * 1000L - bufferSizeUs; - // Sanity check that the latency is non-negative. - latencyUs = Math.max(latencyUs, 0); - // Sanity check that the latency isn't too large. + // Check that the latency is non-negative. + latencyUs = max(latencyUs, 0); + // Check that the latency isn't too large. if (latencyUs > MAX_LATENCY_US) { listener.onInvalidLatency(latencyUs); latencyUs = 0; @@ -457,6 +532,9 @@ import java.lang.reflect.Method; playheadOffsetCount = 0; nextPlayheadOffsetIndex = 0; lastPlayheadSampleTimeUs = 0; + lastSystemTimeUs = 0; + previousModeSystemTimeUs = 0; + notifiedPositionIncreasing = false; } /** @@ -497,7 +575,7 @@ import java.lang.reflect.Method; // Simulate the playback head position up to the total number of frames submitted. long elapsedTimeSinceStopUs = (SystemClock.elapsedRealtime() * 1000) - stopTimestampUs; long framesSinceStop = (elapsedTimeSinceStopUs * outputSampleRate) / C.MICROS_PER_SECOND; - return Math.min(endPlaybackHeadPosition, stopPlaybackHeadPosition + framesSinceStop); + return min(endPlaybackHeadPosition, stopPlaybackHeadPosition + framesSinceStop); } int state = audioTrack.getPlayState(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AuxEffectInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AuxEffectInfo.java index 968d8acebd..f3ea686210 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AuxEffectInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AuxEffectInfo.java @@ -50,7 +50,7 @@ public final class AuxEffectInfo { * Creates an instance with the given effect identifier and send level. * * @param effectId The effect identifier. This is the value returned by {@link - * AudioEffect#getId()} on the effect, or {@value NO_AUX_EFFECT_ID} which represents no + * AudioEffect#getId()} on the effect, or {@value #NO_AUX_EFFECT_ID} which represents no * effect. This value is passed to {@link AudioTrack#attachAuxEffect(int)} on the underlying * audio track. * @param sendLevel The send level for the effect, where 0 represents no effect and a value of 1 diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java index b94d972dc5..c064c7e459 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java @@ -35,7 +35,7 @@ import java.nio.ByteBuffer; * * @param outputChannels The mapping from input to output channel indices, or {@code null} to * leave the input unchanged. - * @see AudioSink#configure(int, int, int, int, int[], int, int) + * @see AudioSink#configure(com.google.android.exoplayer2.Format, int, int[]) */ public void setChannelMap(@Nullable int[] outputChannels) { pendingOutputChannels = outputChannels; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java index 9f1fe07c39..1c1e593e22 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -15,9 +15,12 @@ */ package com.google.android.exoplayer2.audio; +import static java.lang.Math.max; + import android.media.audiofx.Virtualizer; import android.os.Handler; import android.os.SystemClock; +import androidx.annotation.CallSuper; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.BaseRenderer; @@ -26,9 +29,11 @@ import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener.EventDispatcher; +import com.google.android.exoplayer2.audio.AudioSink.SinkFormatSupport; import com.google.android.exoplayer2.decoder.Decoder; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.decoder.DecoderException; @@ -69,7 +74,10 @@ import java.lang.annotation.RetentionPolicy; * underlying audio track. * */ -public abstract class DecoderAudioRenderer extends BaseRenderer implements MediaClock { +public abstract class DecoderAudioRenderer< + T extends + Decoder> + extends BaseRenderer implements MediaClock { @Documented @Retention(RetentionPolicy.SOURCE) @@ -105,9 +113,9 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media private int encoderDelay; private int encoderPadding; - @Nullable - private Decoder - decoder; + private boolean experimentalKeepAudioTrackOnSeek; + + @Nullable private T decoder; @Nullable private DecoderInputBuffer inputBuffer; @Nullable private SimpleOutputBuffer outputBuffer; @@ -123,7 +131,6 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media private boolean allowPositionDiscontinuity; private boolean inputStreamEnded; private boolean outputStreamEnded; - private boolean waitingForKeys; public DecoderAudioRenderer() { this(/* eventHandler= */ null, /* eventListener= */ null); @@ -181,6 +188,19 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media audioTrackNeedsConfigure = true; } + /** + * Sets whether to enable the experimental feature that keeps and flushes the {@link + * android.media.AudioTrack} when a seek occurs, as opposed to releasing and reinitialising. Off + * by default. + * + *

    This method is experimental, and will be renamed or removed in a future release. + * + * @param enableKeepAudioTrackOnSeek Whether to keep the {@link android.media.AudioTrack} on seek. + */ + public void experimentalSetEnableKeepAudioTrackOnSeek(boolean enableKeepAudioTrackOnSeek) { + this.experimentalKeepAudioTrackOnSeek = enableKeepAudioTrackOnSeek; + } + @Override @Nullable public MediaClock getMediaClock() { @@ -212,13 +232,23 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media protected abstract int supportsFormatInternal(Format format); /** - * Returns whether the sink supports the audio format. + * Returns whether the renderer's {@link AudioSink} supports a given {@link Format}. * - * @see AudioSink#supportsOutput(int, int, int) + * @see AudioSink#supportsFormat(Format) */ - protected final boolean supportsOutput( - int channelCount, int sampleRateHz, @C.Encoding int encoding) { - return audioSink.supportsOutput(channelCount, sampleRateHz, encoding); + protected final boolean sinkSupportsFormat(Format format) { + return audioSink.supportsFormat(format); + } + + /** + * Returns the level of support that the renderer's {@link AudioSink} provides for a given {@link + * Format}. + * + * @see AudioSink#getFormatSupport(Format) (Format) + */ + @SinkFormatSupport + protected final int getSinkFormatSupport(Format format) { + return audioSink.getFormatSupport(format); } @Override @@ -244,7 +274,11 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media // End of stream read having not read a format. Assertions.checkState(flagsOnlyBuffer.isEndOfStream()); inputStreamEnded = true; - processEndOfStream(); + try { + processEndOfStream(); + } catch (AudioSink.WriteException e) { + throw createRendererException(e, /* format= */ null); + } return; } else { // We still don't have a format and can't make progress without one. @@ -285,19 +319,10 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media } /** See {@link AudioSink.Listener#onPositionDiscontinuity()}. */ - protected void onAudioTrackPositionDiscontinuity() { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onUnderrun(int, long, long)}. */ - protected void onAudioTrackUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onSkipSilenceEnabledChanged(boolean)}. */ - protected void onAudioTrackSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { - // Do nothing. + @CallSuper + protected void onPositionDiscontinuity() { + // We are out of sync so allow currentPositionUs to jump backwards. + allowPositionDiscontinuity = true; } /** @@ -309,15 +334,16 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media * @return The decoder. * @throws DecoderException If an error occurred creating a suitable decoder. */ - protected abstract Decoder< - DecoderInputBuffer, ? extends SimpleOutputBuffer, ? extends DecoderException> - createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) throws DecoderException; + protected abstract T createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) + throws DecoderException; /** * Returns the format of audio buffers output by the decoder. Will not be called until the first * output buffer has been dequeued, so the decoder may use input data to determine the format. + * + * @param decoder The decoder. */ - protected abstract Format getOutputFormat(); + protected abstract Format getOutputFormat(T decoder); /** * Returns whether the existing decoder can be kept for a new format. @@ -354,15 +380,23 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media } else { outputBuffer.release(); outputBuffer = null; - processEndOfStream(); + try { + processEndOfStream(); + } catch (AudioSink.WriteException e) { + throw createRendererException(e, getOutputFormat(decoder)); + } } return false; } if (audioTrackNeedsConfigure) { - Format outputFormat = getOutputFormat(); - audioSink.configure(outputFormat.pcmEncoding, outputFormat.channelCount, - outputFormat.sampleRate, 0, null, encoderDelay, encoderPadding); + Format outputFormat = + getOutputFormat(decoder) + .buildUpon() + .setEncoderDelay(encoderDelay) + .setEncoderPadding(encoderPadding) + .build(); + audioSink.configure(outputFormat, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); audioTrackNeedsConfigure = false; } @@ -399,66 +433,38 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media return false; } - @SampleStream.ReadDataResult int result; FormatHolder formatHolder = getFormatHolder(); - if (waitingForKeys) { - // We've already read an encrypted sample into buffer, and are waiting for keys. - result = C.RESULT_BUFFER_READ; - } else { - result = readSource(formatHolder, inputBuffer, false); + switch (readSource(formatHolder, inputBuffer, /* formatRequired= */ false)) { + case C.RESULT_NOTHING_READ: + return false; + case C.RESULT_FORMAT_READ: + onInputFormatChanged(formatHolder); + return true; + case C.RESULT_BUFFER_READ: + if (inputBuffer.isEndOfStream()) { + inputStreamEnded = true; + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + return false; + } + inputBuffer.flip(); + onQueueInputBuffer(inputBuffer); + decoder.queueInputBuffer(inputBuffer); + decoderReceivedBuffers = true; + decoderCounters.inputBufferCount++; + inputBuffer = null; + return true; + default: + throw new IllegalStateException(); } - - if (result == C.RESULT_NOTHING_READ) { - return false; - } - if (result == C.RESULT_FORMAT_READ) { - onInputFormatChanged(formatHolder); - return true; - } - if (inputBuffer.isEndOfStream()) { - inputStreamEnded = true; - decoder.queueInputBuffer(inputBuffer); - inputBuffer = null; - return false; - } - boolean bufferEncrypted = inputBuffer.isEncrypted(); - waitingForKeys = shouldWaitForKeys(bufferEncrypted); - if (waitingForKeys) { - return false; - } - inputBuffer.flip(); - onQueueInputBuffer(inputBuffer); - decoder.queueInputBuffer(inputBuffer); - decoderReceivedBuffers = true; - decoderCounters.inputBufferCount++; - inputBuffer = null; - return true; } - private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (decoderDrmSession == null - || (!bufferEncrypted && decoderDrmSession.playClearSamplesWithoutKeys())) { - return false; - } - @DrmSession.State int drmSessionState = decoderDrmSession.getState(); - if (drmSessionState == DrmSession.STATE_ERROR) { - throw createRendererException(decoderDrmSession.getError(), inputFormat); - } - return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; - } - - private void processEndOfStream() throws ExoPlaybackException { + private void processEndOfStream() throws AudioSink.WriteException { outputStreamEnded = true; - try { - audioSink.playToEndOfStream(); - } catch (AudioSink.WriteException e) { - // TODO(internal: b/145658993) Use outputFormat for the call from drainOutputBuffer. - throw createRendererException(e, inputFormat); - } + audioSink.playToEndOfStream(); } private void flushDecoder() throws ExoPlaybackException { - waitingForKeys = false; if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { releaseDecoder(); maybeInitDecoder(); @@ -481,7 +487,7 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media @Override public boolean isReady() { return audioSink.hasPendingData() - || (inputFormat != null && !waitingForKeys && (isSourceReady() || outputBuffer != null)); + || (inputFormat != null && (isSourceReady() || outputBuffer != null)); } @Override @@ -493,13 +499,13 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media } @Override - public void setPlaybackSpeed(float playbackSpeed) { - audioSink.setPlaybackSpeed(playbackSpeed); + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + audioSink.setPlaybackParameters(playbackParameters); } @Override - public float getPlaybackSpeed() { - return audioSink.getPlaybackSpeed(); + public PlaybackParameters getPlaybackParameters() { + return audioSink.getPlaybackParameters(); } @Override @@ -517,7 +523,12 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media @Override protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { - audioSink.flush(); + if (experimentalKeepAudioTrackOnSeek) { + audioSink.experimentalFlushWithoutAudioTrackRelease(); + } else { + audioSink.flush(); + } + currentPositionUs = positionUs; allowFirstBufferPositionDiscontinuity = true; allowPositionDiscontinuity = true; @@ -543,7 +554,6 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media protected void onDisabled() { inputFormat = null; audioTrackNeedsConfigure = true; - waitingForKeys = false; try { setSourceDrmSession(null); releaseDecoder(); @@ -681,7 +691,7 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media currentPositionUs = allowPositionDiscontinuity ? newCurrentPositionUs - : Math.max(currentPositionUs, newCurrentPositionUs); + : max(currentPositionUs, newCurrentPositionUs); allowPositionDiscontinuity = false; } } @@ -696,21 +706,22 @@ public abstract class DecoderAudioRenderer extends BaseRenderer implements Media @Override public void onPositionDiscontinuity() { - onAudioTrackPositionDiscontinuity(); - // We are out of sync so allow currentPositionUs to jump backwards. - DecoderAudioRenderer.this.allowPositionDiscontinuity = true; + DecoderAudioRenderer.this.onPositionDiscontinuity(); + } + + @Override + public void onPositionAdvancing(long playoutStartSystemTimeMs) { + eventDispatcher.positionAdvancing(playoutStartSystemTimeMs); } @Override public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); - onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + eventDispatcher.underrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); } @Override public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); - onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index 552a5f644a..1e04b1e8d7 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 @@ -15,12 +15,19 @@ */ package com.google.android.exoplayer2.audio; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.annotation.SuppressLint; import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; +import android.media.PlaybackParams; import android.os.ConditionVariable; +import android.os.Handler; import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; @@ -29,21 +36,26 @@ import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.audio.AudioProcessor.UnhandledAudioFormatException; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * Plays audio data. The implementation delegates to an {@link AudioTrack} and handles playback * position smoothing, non-blocking writes and reconfiguration. - *

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

    If tunneling mode is enabled, care must be taken that audio processors do not output buffers + * with a different duration than their input, and buffer processors must produce output + * corresponding to their last input immediately after that input is queued. This means that, for + * example, speed adjustment is not possible while using tunneling. */ public final class DefaultAudioSink implements AudioSink { @@ -81,21 +93,14 @@ public final class DefaultAudioSink implements AudioSink { AudioProcessor[] getAudioProcessors(); /** - * @deprecated Use {@link #applyPlaybackSpeed(float)} and {@link - * #applySkipSilenceEnabled(boolean)} instead. - */ - @Deprecated - PlaybackParameters applyPlaybackParameters(PlaybackParameters playbackParameters); - - /** - * Configures audio processors to apply the specified playback speed immediately, returning the - * new playback speed, which may differ from the speed passed in. Only called when processors - * have no input pending. + * Configures audio processors to apply the specified playback parameters immediately, returning + * the new playback parameters, which may differ from those passed in. Only called when + * processors have no input pending. * - * @param playbackSpeed The playback speed to try to apply. - * @return The playback speed that was actually applied. + * @param playbackParameters The playback parameters to try to apply. + * @return The playback parameters that were actually applied. */ - float applyPlaybackSpeed(float playbackSpeed); + PlaybackParameters applyPlaybackParameters(PlaybackParameters playbackParameters); /** * Configures audio processors to apply whether to skip silences immediately, returning the new @@ -131,9 +136,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]; @@ -143,8 +159,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; } @@ -154,20 +170,11 @@ public final class DefaultAudioSink implements AudioSink { return audioProcessors; } - /** - * @deprecated Use {@link #applyPlaybackSpeed(float)} and {@link - * #applySkipSilenceEnabled(boolean)} instead. - */ - @SuppressWarnings("deprecation") - @Deprecated @Override public PlaybackParameters applyPlaybackParameters(PlaybackParameters playbackParameters) { - return new PlaybackParameters(applyPlaybackSpeed(playbackParameters.speed)); - } - - @Override - public float applyPlaybackSpeed(float playbackSpeed) { - return sonicAudioProcessor.setSpeed(playbackSpeed); + float speed = sonicAudioProcessor.setSpeed(playbackParameters.speed); + float pitch = sonicAudioProcessor.setPitch(playbackParameters.pitch); + return new PlaybackParameters(speed, pitch); } @Override @@ -187,24 +194,43 @@ public final class DefaultAudioSink implements AudioSink { } } + /** The default playback speed. */ + public static final float DEFAULT_PLAYBACK_SPEED = 1f; + /** The minimum allowed playback speed. Lower values will be constrained to fall in range. */ + public static final float MIN_PLAYBACK_SPEED = 0.1f; + /** The maximum allowed playback speed. Higher values will be constrained to fall in range. */ + public static final float MAX_PLAYBACK_SPEED = 8f; + /** The minimum allowed pitch factor. Lower values will be constrained to fall in range. */ + public static final float MIN_PITCH = 0.1f; + /** The maximum allowed pitch factor. Higher values will be constrained to fall in range. */ + public static final float MAX_PITCH = 8f; + + /** The default skip silence flag. */ + private static final boolean DEFAULT_SKIP_SILENCE = false; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({OUTPUT_MODE_PCM, OUTPUT_MODE_OFFLOAD, OUTPUT_MODE_PASSTHROUGH}) + private @interface OutputMode {} + + private static final int OUTPUT_MODE_PCM = 0; + private static final int OUTPUT_MODE_OFFLOAD = 1; + private static final int OUTPUT_MODE_PASSTHROUGH = 2; + + /** A minimum length for the {@link AudioTrack} buffer, in microseconds. */ + private static final long MIN_BUFFER_DURATION_US = 250_000; + /** A maximum length for the {@link AudioTrack} buffer, in microseconds. */ + private static final long MAX_BUFFER_DURATION_US = 750_000; + /** The length for passthrough {@link AudioTrack} buffers, in microseconds. */ + private static final long PASSTHROUGH_BUFFER_DURATION_US = 250_000; + /** The length for offload {@link AudioTrack} buffers, in microseconds. */ + private static final long OFFLOAD_BUFFER_DURATION_US = 50_000_000; + /** - * A minimum length for the {@link AudioTrack} buffer, in microseconds. - */ - private static final long MIN_BUFFER_DURATION_US = 250000; - /** - * A maximum length for the {@link AudioTrack} buffer, in microseconds. - */ - private static final long MAX_BUFFER_DURATION_US = 750000; - /** - * The length for passthrough {@link AudioTrack} buffers, in microseconds. - */ - private static final long PASSTHROUGH_BUFFER_DURATION_US = 250000; - /** - * A multiplication factor to apply to the minimum buffer size requested by the underlying - * {@link AudioTrack}. + * A multiplication factor to apply to the minimum buffer size requested by the underlying {@link + * AudioTrack}. */ private static final int BUFFER_MULTIPLICATION_FACTOR = 4; - /** To avoid underruns on some devices (e.g., Broadcom 7271), scale up the AC3 buffer duration. */ private static final int AC3_BUFFER_MULTIPLICATION_FACTOR = 2; @@ -229,10 +255,6 @@ public final class DefaultAudioSink implements AudioSink { */ @SuppressLint("InlinedApi") private static final int WRITE_NON_BLOCKING = AudioTrack.WRITE_NON_BLOCKING; - /** The default playback speed. */ - private static final float DEFAULT_PLAYBACK_SPEED = 1.0f; - /** The default skip silence flag. */ - private static final boolean DEFAULT_SKIP_SILENCE = false; private static final String TAG = "AudioTrack"; @@ -264,18 +286,24 @@ public final class DefaultAudioSink implements AudioSink { private final ConditionVariable releasingConditionVariable; private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; + private final boolean enableAudioTrackPlaybackParams; + private final boolean enableOffload; + @MonotonicNonNull private StreamEventCallbackV29 offloadStreamEventCallbackV29; @Nullable private Listener listener; - /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ + /** + * Used to keep the audio session active on pre-V21 builds (see {@link #initializeAudioTrack()}). + */ @Nullable private AudioTrack keepSessionIdAudioTrack; @Nullable private Configuration pendingConfiguration; - private Configuration configuration; - private AudioTrack audioTrack; + @MonotonicNonNull private Configuration configuration; + @Nullable private AudioTrack audioTrack; private AudioAttributes audioAttributes; @Nullable private MediaPositionParameters afterDrainParameters; private MediaPositionParameters mediaPositionParameters; + private PlaybackParameters audioTrackPlaybackParameters; @Nullable private ByteBuffer avSyncHeader; private int bytesUntilNextAvSync; @@ -286,6 +314,7 @@ public final class DefaultAudioSink implements AudioSink { private long writtenEncodedFrames; private int framesPerEncodedSample; private boolean startMediaTimeUsNeedsSync; + private boolean startMediaTimeUsNeedsInit; private long startMediaTimeUs; private float volume; @@ -294,7 +323,7 @@ public final class DefaultAudioSink implements AudioSink { @Nullable private ByteBuffer inputBuffer; private int inputBufferAccessUnitCount; @Nullable private ByteBuffer outputBuffer; - private byte[] preV21OutputBuffer; + @MonotonicNonNull private byte[] preV21OutputBuffer; private int preV21OutputBufferOffset; private int drainingAudioProcessorIndex; private boolean handledEndOfStream; @@ -305,6 +334,7 @@ public final class DefaultAudioSink implements AudioSink { private AuxEffectInfo auxEffectInfo; private boolean tunneling; private long lastFeedElapsedRealtimeMs; + private boolean offloadDisabledUntilNextConfiguration; /** * Creates a new default audio sink. @@ -335,7 +365,12 @@ public final class DefaultAudioSink implements AudioSink { @Nullable AudioCapabilities audioCapabilities, AudioProcessor[] audioProcessors, boolean enableFloatOutput) { - this(audioCapabilities, new DefaultAudioProcessorChain(audioProcessors), enableFloatOutput); + this( + audioCapabilities, + new DefaultAudioProcessorChain(audioProcessors), + enableFloatOutput, + /* enableAudioTrackPlaybackParams= */ false, + /* enableOffload= */ false); } /** @@ -348,16 +383,29 @@ public final class DefaultAudioSink implements AudioSink { * parameters adjustments. The instance passed in must not be reused in other sinks. * @param enableFloatOutput Whether to enable 32-bit float output. Where possible, 32-bit float * output will be used if the input is 32-bit float, and also if the input is high resolution - * (24-bit or 32-bit) integer PCM. Audio processing (for example, speed adjustment) will not - * be available when float output is in use. + * (24-bit or 32-bit) integer PCM. Float output is supported from API level 21. Audio + * processing (for example, speed adjustment) will not be available when float output is in + * use. + * @param enableAudioTrackPlaybackParams Whether to enable setting playback speed using {@link + * android.media.AudioTrack#setPlaybackParams(PlaybackParams)}, if supported. + * @param enableOffload Whether to enable audio offload. If an audio format can be both played + * with offload and encoded audio passthrough, it will be played in offload. Audio offload is + * supported from API level 29. Most Android devices can only support one offload {@link + * android.media.AudioTrack} at a time and can invalidate it at any time. Thus an app can + * never be guaranteed that it will be able to play in offload. Audio processing (for example, + * speed adjustment) will not be available when offload is in use. */ public DefaultAudioSink( @Nullable AudioCapabilities audioCapabilities, AudioProcessorChain audioProcessorChain, - boolean enableFloatOutput) { + boolean enableFloatOutput, + boolean enableAudioTrackPlaybackParams, + boolean enableOffload) { this.audioCapabilities = audioCapabilities; this.audioProcessorChain = Assertions.checkNotNull(audioProcessorChain); - this.enableFloatOutput = enableFloatOutput; + this.enableFloatOutput = Util.SDK_INT >= 21 && enableFloatOutput; + this.enableAudioTrackPlaybackParams = Util.SDK_INT >= 23 && enableAudioTrackPlaybackParams; + this.enableOffload = Util.SDK_INT >= 29 && enableOffload; releasingConditionVariable = new ConditionVariable(true); audioTrackPositionTracker = new AudioTrackPositionTracker(new PositionTrackerListener()); channelMappingAudioProcessor = new ChannelMappingAudioProcessor(); @@ -371,16 +419,17 @@ public final class DefaultAudioSink implements AudioSink { Collections.addAll(toIntPcmAudioProcessors, audioProcessorChain.getAudioProcessors()); toIntPcmAvailableAudioProcessors = toIntPcmAudioProcessors.toArray(new AudioProcessor[0]); toFloatPcmAvailableAudioProcessors = new AudioProcessor[] {new FloatResamplingAudioProcessor()}; - volume = 1.0f; + volume = 1f; audioAttributes = AudioAttributes.DEFAULT; audioSessionId = C.AUDIO_SESSION_ID_UNSET; auxEffectInfo = new AuxEffectInfo(AuxEffectInfo.NO_AUX_EFFECT_ID, 0f); mediaPositionParameters = new MediaPositionParameters( - DEFAULT_PLAYBACK_SPEED, + PlaybackParameters.DEFAULT, DEFAULT_SKIP_SILENCE, /* mediaTimeUs= */ 0, /* audioTrackPositionUs= */ 0); + audioTrackPlaybackParameters = PlaybackParameters.DEFAULT; drainingAudioProcessorIndex = C.INDEX_UNSET; activeAudioProcessors = new AudioProcessor[0]; outputBuffers = new ByteBuffer[0]; @@ -395,66 +444,86 @@ public final class DefaultAudioSink implements AudioSink { } @Override - public boolean supportsOutput(int channelCount, int sampleRateHz, @C.Encoding int encoding) { - if (Util.isEncodingLinearPcm(encoding)) { - // AudioTrack supports 16-bit integer PCM output in all platform API versions, and float - // output from platform API version 21 only. Other integer PCM encodings are resampled by this - // sink to 16-bit PCM. We assume that the audio framework will downsample any number of - // channels to the output device's required number of channels. - return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; - } else { - return audioCapabilities != null - && audioCapabilities.supportsEncoding(encoding) - && (channelCount == Format.NO_VALUE - || channelCount <= audioCapabilities.getMaxChannelCount()); + public boolean supportsFormat(Format format) { + return getFormatSupport(format) != SINK_FORMAT_UNSUPPORTED; + } + + @Override + @SinkFormatSupport + public int getFormatSupport(Format format) { + if (MimeTypes.AUDIO_RAW.equals(format.sampleMimeType)) { + if (!Util.isEncodingLinearPcm(format.pcmEncoding)) { + Log.w(TAG, "Invalid PCM encoding: " + format.pcmEncoding); + return SINK_FORMAT_UNSUPPORTED; + } + if (format.pcmEncoding == C.ENCODING_PCM_16BIT + || (enableFloatOutput && format.pcmEncoding == C.ENCODING_PCM_FLOAT)) { + return SINK_FORMAT_SUPPORTED_DIRECTLY; + } + // We can resample all linear PCM encodings to 16-bit integer PCM, which AudioTrack is + // guaranteed to support. + return SINK_FORMAT_SUPPORTED_WITH_TRANSCODING; } + if (enableOffload + && !offloadDisabledUntilNextConfiguration + && isOffloadedPlaybackSupported(format, audioAttributes)) { + return SINK_FORMAT_SUPPORTED_DIRECTLY; + } + if (isPassthroughPlaybackSupported(format, audioCapabilities)) { + return SINK_FORMAT_SUPPORTED_DIRECTLY; + } + return SINK_FORMAT_UNSUPPORTED; } @Override public long getCurrentPositionUs(boolean sourceEnded) { - if (!isInitialized()) { + if (!isAudioTrackInitialized() || startMediaTimeUsNeedsInit) { return CURRENT_POSITION_NOT_SET; } long positionUs = audioTrackPositionTracker.getCurrentPositionUs(sourceEnded); - positionUs = Math.min(positionUs, configuration.framesToDurationUs(getWrittenFrames())); + positionUs = min(positionUs, configuration.framesToDurationUs(getWrittenFrames())); return applySkipping(applyMediaPositionParameters(positionUs)); } @Override - public void configure( - @C.Encoding int inputEncoding, - int inputChannelCount, - int inputSampleRate, - int specifiedBufferSize, - @Nullable int[] outputChannels, - int trimStartFrames, - int trimEndFrames) + public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException { - if (Util.SDK_INT < 21 && inputChannelCount == 8 && outputChannels == null) { - // AudioTrack doesn't support 8 channel output before Android L. Discard the last two (side) - // channels to give a 6 channel stream that is supported. - outputChannels = new int[6]; - for (int i = 0; i < outputChannels.length; i++) { - outputChannels[i] = i; - } - } + int inputPcmFrameSize; + @Nullable AudioProcessor[] availableAudioProcessors; + boolean canApplyPlaybackParameters; - boolean isInputPcm = Util.isEncodingLinearPcm(inputEncoding); - boolean processingEnabled = isInputPcm; - int sampleRate = inputSampleRate; - int channelCount = inputChannelCount; - @C.Encoding int encoding = inputEncoding; - boolean useFloatOutput = - enableFloatOutput - && supportsOutput(inputChannelCount, inputSampleRate, C.ENCODING_PCM_FLOAT) - && Util.isEncodingHighResolutionPcm(inputEncoding); - AudioProcessor[] availableAudioProcessors = - useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; - if (processingEnabled) { - trimmingAudioProcessor.setTrimFrameCount(trimStartFrames, trimEndFrames); + @OutputMode int outputMode; + @C.Encoding int outputEncoding; + int outputSampleRate; + int outputChannelConfig; + int outputPcmFrameSize; + + if (MimeTypes.AUDIO_RAW.equals(inputFormat.sampleMimeType)) { + Assertions.checkArgument(Util.isEncodingLinearPcm(inputFormat.pcmEncoding)); + + inputPcmFrameSize = Util.getPcmFrameSize(inputFormat.pcmEncoding, inputFormat.channelCount); + boolean useFloatOutput = + enableFloatOutput && Util.isEncodingHighResolutionPcm(inputFormat.pcmEncoding); + availableAudioProcessors = + useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; + canApplyPlaybackParameters = !useFloatOutput; + + trimmingAudioProcessor.setTrimFrameCount( + inputFormat.encoderDelay, inputFormat.encoderPadding); + + if (Util.SDK_INT < 21 && inputFormat.channelCount == 8 && outputChannels == null) { + // AudioTrack doesn't support 8 channel output before Android L. Discard the last two (side) + // channels to give a 6 channel stream that is supported. + outputChannels = new int[6]; + for (int i = 0; i < outputChannels.length; i++) { + outputChannels[i] = i; + } + } channelMappingAudioProcessor.setChannelMap(outputChannels); + AudioProcessor.AudioFormat outputFormat = - new AudioProcessor.AudioFormat(sampleRate, channelCount, encoding); + new AudioProcessor.AudioFormat( + inputFormat.sampleRate, inputFormat.channelCount, inputFormat.pcmEncoding); for (AudioProcessor audioProcessor : availableAudioProcessors) { try { AudioProcessor.AudioFormat nextFormat = audioProcessor.configure(outputFormat); @@ -465,35 +534,61 @@ public final class DefaultAudioSink implements AudioSink { throw new ConfigurationException(e); } } - sampleRate = outputFormat.sampleRate; - channelCount = outputFormat.channelCount; - encoding = outputFormat.encoding; + + outputMode = OUTPUT_MODE_PCM; + outputEncoding = outputFormat.encoding; + outputSampleRate = outputFormat.sampleRate; + outputChannelConfig = Util.getAudioTrackChannelConfig(outputFormat.channelCount); + outputPcmFrameSize = Util.getPcmFrameSize(outputEncoding, outputFormat.channelCount); + } else { + inputPcmFrameSize = C.LENGTH_UNSET; + availableAudioProcessors = new AudioProcessor[0]; + canApplyPlaybackParameters = false; + outputSampleRate = inputFormat.sampleRate; + outputPcmFrameSize = C.LENGTH_UNSET; + if (enableOffload && isOffloadedPlaybackSupported(inputFormat, audioAttributes)) { + outputMode = OUTPUT_MODE_OFFLOAD; + outputEncoding = + MimeTypes.getEncoding( + Assertions.checkNotNull(inputFormat.sampleMimeType), inputFormat.codecs); + outputChannelConfig = Util.getAudioTrackChannelConfig(inputFormat.channelCount); + } else { + outputMode = OUTPUT_MODE_PASSTHROUGH; + @Nullable + Pair encodingAndChannelConfig = + getEncodingAndChannelConfigForPassthrough(inputFormat, audioCapabilities); + if (encodingAndChannelConfig == null) { + throw new ConfigurationException("Unable to configure passthrough for: " + inputFormat); + } + outputEncoding = encodingAndChannelConfig.first; + outputChannelConfig = encodingAndChannelConfig.second; + } } - int outputChannelConfig = getChannelConfig(channelCount, isInputPcm); + if (outputEncoding == C.ENCODING_INVALID) { + throw new ConfigurationException( + "Invalid output encoding (mode=" + outputMode + ") for: " + inputFormat); + } if (outputChannelConfig == AudioFormat.CHANNEL_INVALID) { - throw new ConfigurationException("Unsupported channel count: " + channelCount); + throw new ConfigurationException( + "Invalid output channel config (mode=" + outputMode + ") for: " + inputFormat); } - int inputPcmFrameSize = - isInputPcm ? Util.getPcmFrameSize(inputEncoding, inputChannelCount) : C.LENGTH_UNSET; - int outputPcmFrameSize = - isInputPcm ? Util.getPcmFrameSize(encoding, channelCount) : C.LENGTH_UNSET; - boolean canApplyPlaybackParameters = processingEnabled && !useFloatOutput; + offloadDisabledUntilNextConfiguration = false; Configuration pendingConfiguration = new Configuration( - isInputPcm, + inputFormat, inputPcmFrameSize, - inputSampleRate, + outputMode, outputPcmFrameSize, - sampleRate, + outputSampleRate, outputChannelConfig, - encoding, + outputEncoding, specifiedBufferSize, - processingEnabled, + enableAudioTrackPlaybackParams, canApplyPlaybackParameters, availableAudioProcessors); - if (isInitialized()) { + if (isAudioTrackInitialized()) { this.pendingConfiguration = pendingConfiguration; } else { configuration = pendingConfiguration; @@ -524,7 +619,7 @@ public final class DefaultAudioSink implements AudioSink { } } - private void initialize(long presentationTimeUs) throws InitializationException { + private void initializeAudioTrack() throws InitializationException { // If we're asynchronously releasing a previous audio track then we block until it has been // released. This guarantees that we cannot end up in a state where we have multiple audio // track instances. Without this guarantee it would be possible, in extreme cases, to exhaust @@ -532,9 +627,12 @@ public final class DefaultAudioSink implements AudioSink { // initialization of the audio track to fail. releasingConditionVariable.block(); - audioTrack = - Assertions.checkNotNull(configuration) - .buildAudioTrack(tunneling, audioAttributes, audioSessionId); + audioTrack = buildAudioTrack(); + if (isOffloadedPlayback(audioTrack)) { + registerStreamEventCallbackV29(audioTrack); + audioTrack.setOffloadDelayPadding( + configuration.inputFormat.encoderDelay, configuration.inputFormat.encoderPadding); + } int audioSessionId = audioTrack.getAudioSessionId(); if (enablePreV21AudioSessionWorkaround) { if (Util.SDK_INT < 21) { @@ -556,13 +654,9 @@ public final class DefaultAudioSink implements AudioSink { } } - startMediaTimeUs = Math.max(0, presentationTimeUs); - startMediaTimeUsNeedsSync = false; - - applyPlaybackSpeedAndSkipSilence(presentationTimeUs); - audioTrackPositionTracker.setAudioTrack( audioTrack, + /* isPassthrough= */ configuration.outputMode == OUTPUT_MODE_PASSTHROUGH, configuration.outputEncoding, configuration.outputPcmFrameSize, configuration.bufferSize); @@ -572,12 +666,14 @@ public final class DefaultAudioSink implements AudioSink { audioTrack.attachAuxEffect(auxEffectInfo.effectId); audioTrack.setAuxEffectSendLevel(auxEffectInfo.sendLevel); } + + startMediaTimeUsNeedsInit = true; } @Override public void play() { playing = true; - if (isInitialized()) { + if (isAudioTrackInitialized()) { audioTrackPositionTracker.start(); audioTrack.play(); } @@ -611,13 +707,30 @@ public final class DefaultAudioSink implements AudioSink { // The current audio track can be reused for the new configuration. configuration = pendingConfiguration; pendingConfiguration = null; + if (isOffloadedPlayback(audioTrack)) { + audioTrack.setOffloadEndOfStream(); + audioTrack.setOffloadDelayPadding( + configuration.inputFormat.encoderDelay, configuration.inputFormat.encoderPadding); + } } // Re-apply playback parameters. - applyPlaybackSpeedAndSkipSilence(presentationTimeUs); + applyAudioProcessorPlaybackParametersAndSkipSilence(presentationTimeUs); } - if (!isInitialized()) { - initialize(presentationTimeUs); + if (!isAudioTrackInitialized()) { + initializeAudioTrack(); + } + + if (startMediaTimeUsNeedsInit) { + startMediaTimeUs = max(0, presentationTimeUs); + startMediaTimeUsNeedsSync = false; + startMediaTimeUsNeedsInit = false; + + if (enableAudioTrackPlaybackParams && Util.SDK_INT >= 23) { + setAudioTrackPlaybackParametersV23(audioTrackPlaybackParameters); + } + applyAudioProcessorPlaybackParametersAndSkipSilence(presentationTimeUs); + if (playing) { play(); } @@ -629,12 +742,13 @@ public final class DefaultAudioSink implements AudioSink { if (inputBuffer == null) { // We are seeing this buffer for the first time. + Assertions.checkArgument(buffer.order() == ByteOrder.LITTLE_ENDIAN); if (!buffer.hasRemaining()) { // The buffer is empty. return true; } - if (!configuration.isInputPcm && framesPerEncodedSample == 0) { + if (configuration.outputMode != OUTPUT_MODE_PCM && framesPerEncodedSample == 0) { // If this is the first encoded sample, calculate the sample size in frames. framesPerEncodedSample = getFramesPerEncodedSample(configuration.outputEncoding, buffer); if (framesPerEncodedSample == 0) { @@ -651,11 +765,11 @@ public final class DefaultAudioSink implements AudioSink { // Don't process any more input until draining completes. return false; } - applyPlaybackSpeedAndSkipSilence(presentationTimeUs); + applyAudioProcessorPlaybackParametersAndSkipSilence(presentationTimeUs); afterDrainParameters = null; } - // Sanity check that presentationTimeUs is consistent with the expected value. + // Check that presentationTimeUs is consistent with the expected value. long expectedPresentationTimeUs = startMediaTimeUs + configuration.inputFramesToDurationUs( @@ -682,13 +796,13 @@ public final class DefaultAudioSink implements AudioSink { startMediaTimeUs += adjustmentUs; startMediaTimeUsNeedsSync = false; // Re-apply playback parameters because the startMediaTimeUs changed. - applyPlaybackSpeedAndSkipSilence(presentationTimeUs); + applyAudioProcessorPlaybackParametersAndSkipSilence(presentationTimeUs); if (listener != null && adjustmentUs != 0) { listener.onPositionDiscontinuity(); } } - if (configuration.isInputPcm) { + if (configuration.outputMode == OUTPUT_MODE_PCM) { submittedPcmBytes += buffer.remaining(); } else { submittedEncodedFrames += framesPerEncodedSample * encodedAccessUnitCount; @@ -715,6 +829,26 @@ public final class DefaultAudioSink implements AudioSink { return false; } + private AudioTrack buildAudioTrack() throws InitializationException { + try { + return Assertions.checkNotNull(configuration) + .buildAudioTrack(tunneling, audioAttributes, audioSessionId); + } catch (InitializationException e) { + maybeDisableOffload(); + throw e; + } + } + + @RequiresApi(29) + private void registerStreamEventCallbackV29(AudioTrack audioTrack) { + if (offloadStreamEventCallbackV29 == null) { + // Must be lazily initialized to receive stream event callbacks on the current (playback) + // thread as the constructor is not called in the playback thread. + offloadStreamEventCallbackV29 = new StreamEventCallbackV29(); + } + offloadStreamEventCallbackV29.register(audioTrack); + } + private void processBuffers(long avSyncPresentationTimeUs) throws WriteException { int count = activeAudioProcessors.length; int index = count; @@ -767,11 +901,11 @@ public final class DefaultAudioSink implements AudioSink { } int bytesRemaining = buffer.remaining(); int bytesWritten = 0; - if (Util.SDK_INT < 21) { // isInputPcm == true + if (Util.SDK_INT < 21) { // outputMode == OUTPUT_MODE_PCM. // Work out how many bytes we can write without the risk of blocking. int bytesToWrite = audioTrackPositionTracker.getAvailableBufferSize(writtenPcmBytes); if (bytesToWrite > 0) { - bytesToWrite = Math.min(bytesRemaining, bytesToWrite); + bytesToWrite = min(bytesRemaining, bytesToWrite); bytesWritten = audioTrack.write(preV21OutputBuffer, preV21OutputBufferOffset, bytesToWrite); if (bytesWritten > 0) { preV21OutputBufferOffset += bytesWritten; @@ -780,8 +914,9 @@ public final class DefaultAudioSink implements AudioSink { } } else if (tunneling) { Assertions.checkState(avSyncPresentationTimeUs != C.TIME_UNSET); - bytesWritten = writeNonBlockingWithAvSyncV21(audioTrack, buffer, bytesRemaining, - avSyncPresentationTimeUs); + bytesWritten = + writeNonBlockingWithAvSyncV21( + audioTrack, buffer, bytesRemaining, avSyncPresentationTimeUs); } else { bytesWritten = writeNonBlockingV21(audioTrack, buffer, bytesRemaining); } @@ -789,14 +924,27 @@ public final class DefaultAudioSink implements AudioSink { lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); if (bytesWritten < 0) { + boolean isRecoverable = isAudioTrackDeadObject(bytesWritten); + if (isRecoverable) { + maybeDisableOffload(); + } throw new WriteException(bytesWritten); } - if (configuration.isInputPcm) { + if (playing + && listener != null + && bytesWritten < bytesRemaining + && isOffloadedPlayback(audioTrack)) { + long pendingDurationMs = + audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); + listener.onOffloadBufferFull(pendingDurationMs); + } + + if (configuration.outputMode == OUTPUT_MODE_PCM) { writtenPcmBytes += bytesWritten; } if (bytesWritten == bytesRemaining) { - if (!configuration.isInputPcm) { + if (configuration.outputMode != OUTPUT_MODE_PCM) { // When playing non-PCM, the inputBuffer is never processed, thus the last inputBuffer // must be the current input buffer. Assertions.checkState(buffer == inputBuffer); @@ -808,17 +956,30 @@ public final class DefaultAudioSink implements AudioSink { @Override public void playToEndOfStream() throws WriteException { - if (!handledEndOfStream && isInitialized() && drainToEndOfStream()) { + if (!handledEndOfStream && isAudioTrackInitialized() && drainToEndOfStream()) { playPendingData(); handledEndOfStream = true; } } + private void maybeDisableOffload() { + if (!configuration.outputModeIsOffload()) { + return; + } + // Offload was requested, but may not be available. There are cases when this can occur even if + // AudioManager.isOffloadedPlaybackSupported returned true. For example, due to use of an + // AudioPlaybackCaptureConfiguration. Disable offload until the sink is next configured. + offloadDisabledUntilNextConfiguration = true; + } + + private static boolean isAudioTrackDeadObject(int status) { + return Util.SDK_INT >= 24 && status == AudioTrack.ERROR_DEAD_OBJECT; + } + private boolean drainToEndOfStream() throws WriteException { boolean audioProcessorNeedsEndOfStream = false; if (drainingAudioProcessorIndex == C.INDEX_UNSET) { - drainingAudioProcessorIndex = - configuration.processingEnabled ? 0 : activeAudioProcessors.length; + drainingAudioProcessorIndex = 0; audioProcessorNeedsEndOfStream = true; } while (drainingAudioProcessorIndex < activeAudioProcessors.length) { @@ -847,47 +1008,40 @@ public final class DefaultAudioSink implements AudioSink { @Override public boolean isEnded() { - return !isInitialized() || (handledEndOfStream && !hasPendingData()); + return !isAudioTrackInitialized() || (handledEndOfStream && !hasPendingData()); } @Override public boolean hasPendingData() { - return isInitialized() && audioTrackPositionTracker.hasPendingData(getWrittenFrames()); + return isAudioTrackInitialized() + && audioTrackPositionTracker.hasPendingData(getWrittenFrames()); } - /** - * @deprecated Use {@link #setPlaybackSpeed(float)} and {@link #setSkipSilenceEnabled(boolean)} - * instead. - */ - @SuppressWarnings("deprecation") - @Deprecated @Override public void setPlaybackParameters(PlaybackParameters playbackParameters) { - setPlaybackSpeedAndSkipSilence(playbackParameters.speed, getSkipSilenceEnabled()); + playbackParameters = + new PlaybackParameters( + Util.constrainValue(playbackParameters.speed, MIN_PLAYBACK_SPEED, MAX_PLAYBACK_SPEED), + Util.constrainValue(playbackParameters.pitch, MIN_PITCH, MAX_PITCH)); + if (enableAudioTrackPlaybackParams && Util.SDK_INT >= 23) { + setAudioTrackPlaybackParametersV23(playbackParameters); + } else { + setAudioProcessorPlaybackParametersAndSkipSilence( + playbackParameters, getSkipSilenceEnabled()); + } } - /** @deprecated Use {@link #getPlaybackSpeed()} and {@link #getSkipSilenceEnabled()} instead. */ - @SuppressWarnings("deprecation") - @Deprecated @Override public PlaybackParameters getPlaybackParameters() { - MediaPositionParameters mediaPositionParameters = getMediaPositionParameters(); - return new PlaybackParameters(mediaPositionParameters.playbackSpeed); - } - - @Override - public void setPlaybackSpeed(float playbackSpeed) { - setPlaybackSpeedAndSkipSilence(playbackSpeed, getSkipSilenceEnabled()); - } - - @Override - public float getPlaybackSpeed() { - return getMediaPositionParameters().playbackSpeed; + return enableAudioTrackPlaybackParams + ? audioTrackPlaybackParameters + : getAudioProcessorPlaybackParameters(); } @Override public void setSkipSilenceEnabled(boolean skipSilenceEnabled) { - setPlaybackSpeedAndSkipSilence(getPlaybackSpeed(), skipSilenceEnabled); + setAudioProcessorPlaybackParametersAndSkipSilence( + getAudioProcessorPlaybackParameters(), skipSilenceEnabled); } @Override @@ -963,7 +1117,7 @@ public final class DefaultAudioSink implements AudioSink { } private void setVolumeInternal() { - if (!isInitialized()) { + if (!isAudioTrackInitialized()) { // Do nothing. } else if (Util.SDK_INT >= 21) { setVolumeInternalV21(audioTrack, volume); @@ -975,41 +1129,22 @@ public final class DefaultAudioSink implements AudioSink { @Override public void pause() { playing = false; - if (isInitialized() && audioTrackPositionTracker.pause()) { + if (isAudioTrackInitialized() && audioTrackPositionTracker.pause()) { audioTrack.pause(); } } @Override public void flush() { - if (isInitialized()) { - submittedPcmBytes = 0; - submittedEncodedFrames = 0; - writtenPcmBytes = 0; - writtenEncodedFrames = 0; - framesPerEncodedSample = 0; - mediaPositionParameters = - new MediaPositionParameters( - getPlaybackSpeed(), - getSkipSilenceEnabled(), - /* mediaTimeUs= */ 0, - /* audioTrackPositionUs= */ 0); - startMediaTimeUs = 0; - afterDrainParameters = null; - mediaPositionParametersCheckpoints.clear(); - trimmingAudioProcessor.resetTrimmedFrameCount(); - flushAudioProcessors(); - inputBuffer = null; - inputBufferAccessUnitCount = 0; - outputBuffer = null; - stoppedAudioTrack = false; - handledEndOfStream = false; - drainingAudioProcessorIndex = C.INDEX_UNSET; - avSyncHeader = null; - bytesUntilNextAvSync = 0; + if (isAudioTrackInitialized()) { + resetSinkStateForFlush(); + if (audioTrackPositionTracker.isPlaying()) { audioTrack.pause(); } + if (isOffloadedPlayback(audioTrack)) { + Assertions.checkNotNull(offloadStreamEventCallbackV29).unregister(audioTrack); + } // AudioTrack.release can take some time, so we call it on a background thread. final AudioTrack toRelease = audioTrack; audioTrack = null; @@ -1033,6 +1168,36 @@ public final class DefaultAudioSink implements AudioSink { } } + @Override + public void experimentalFlushWithoutAudioTrackRelease() { + // Prior to SDK 25, AudioTrack flush does not work as intended, and therefore it must be + // released and reinitialized. (Internal reference: b/143500232) + if (Util.SDK_INT < 25) { + flush(); + return; + } + + if (!isAudioTrackInitialized()) { + return; + } + + resetSinkStateForFlush(); + if (audioTrackPositionTracker.isPlaying()) { + audioTrack.pause(); + } + audioTrack.flush(); + + audioTrackPositionTracker.reset(); + audioTrackPositionTracker.setAudioTrack( + audioTrack, + /* isPassthrough= */ configuration.outputMode == OUTPUT_MODE_PASSTHROUGH, + configuration.outputEncoding, + configuration.outputPcmFrameSize, + configuration.bufferSize); + + startMediaTimeUsNeedsInit = true; + } + @Override public void reset() { flush(); @@ -1045,10 +1210,38 @@ public final class DefaultAudioSink implements AudioSink { } audioSessionId = C.AUDIO_SESSION_ID_UNSET; playing = false; + offloadDisabledUntilNextConfiguration = false; } // Internal methods. + private void resetSinkStateForFlush() { + submittedPcmBytes = 0; + submittedEncodedFrames = 0; + writtenPcmBytes = 0; + writtenEncodedFrames = 0; + framesPerEncodedSample = 0; + mediaPositionParameters = + new MediaPositionParameters( + getAudioProcessorPlaybackParameters(), + getSkipSilenceEnabled(), + /* mediaTimeUs= */ 0, + /* audioTrackPositionUs= */ 0); + startMediaTimeUs = 0; + afterDrainParameters = null; + mediaPositionParametersCheckpoints.clear(); + inputBuffer = null; + inputBufferAccessUnitCount = 0; + outputBuffer = null; + stoppedAudioTrack = false; + handledEndOfStream = false; + drainingAudioProcessorIndex = C.INDEX_UNSET; + avSyncHeader = null; + bytesUntilNextAvSync = 0; + trimmingAudioProcessor.resetTrimmedFrameCount(); + flushAudioProcessors(); + } + /** Releases {@link #keepSessionIdAudioTrack} asynchronously, if it is non-{@code null}. */ private void releaseKeepSessionIdAudioTrack() { if (keepSessionIdAudioTrack == null) { @@ -1066,17 +1259,41 @@ public final class DefaultAudioSink implements AudioSink { }.start(); } - private void setPlaybackSpeedAndSkipSilence(float playbackSpeed, boolean skipSilence) { + @RequiresApi(23) + private void setAudioTrackPlaybackParametersV23(PlaybackParameters audioTrackPlaybackParameters) { + if (isAudioTrackInitialized()) { + PlaybackParams playbackParams = + new PlaybackParams() + .allowDefaults() + .setSpeed(audioTrackPlaybackParameters.speed) + .setPitch(audioTrackPlaybackParameters.pitch) + .setAudioFallbackMode(PlaybackParams.AUDIO_FALLBACK_MODE_FAIL); + try { + audioTrack.setPlaybackParams(playbackParams); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Failed to set playback params", e); + } + // Update the speed using the actual effective speed from the audio track. + audioTrackPlaybackParameters = + new PlaybackParameters( + audioTrack.getPlaybackParams().getSpeed(), audioTrack.getPlaybackParams().getPitch()); + audioTrackPositionTracker.setAudioTrackPlaybackSpeed(audioTrackPlaybackParameters.speed); + } + this.audioTrackPlaybackParameters = audioTrackPlaybackParameters; + } + + private void setAudioProcessorPlaybackParametersAndSkipSilence( + PlaybackParameters playbackParameters, boolean skipSilence) { MediaPositionParameters currentMediaPositionParameters = getMediaPositionParameters(); - if (playbackSpeed != currentMediaPositionParameters.playbackSpeed + if (!playbackParameters.equals(currentMediaPositionParameters.playbackParameters) || skipSilence != currentMediaPositionParameters.skipSilence) { MediaPositionParameters mediaPositionParameters = new MediaPositionParameters( - playbackSpeed, + playbackParameters, skipSilence, /* mediaTimeUs= */ C.TIME_UNSET, /* audioTrackPositionUs= */ C.TIME_UNSET); - if (isInitialized()) { + if (isAudioTrackInitialized()) { // Drain the audio processors so we can determine the frame position at which the new // parameters apply. this.afterDrainParameters = mediaPositionParameters; @@ -1088,6 +1305,10 @@ public final class DefaultAudioSink implements AudioSink { } } + private PlaybackParameters getAudioProcessorPlaybackParameters() { + return getMediaPositionParameters().playbackParameters; + } + private MediaPositionParameters getMediaPositionParameters() { // Mask the already set parameters. return afterDrainParameters != null @@ -1097,20 +1318,20 @@ public final class DefaultAudioSink implements AudioSink { : mediaPositionParameters; } - private void applyPlaybackSpeedAndSkipSilence(long presentationTimeUs) { - float playbackSpeed = + private void applyAudioProcessorPlaybackParametersAndSkipSilence(long presentationTimeUs) { + PlaybackParameters playbackParameters = configuration.canApplyPlaybackParameters - ? audioProcessorChain.applyPlaybackSpeed(getPlaybackSpeed()) - : DEFAULT_PLAYBACK_SPEED; + ? audioProcessorChain.applyPlaybackParameters(getAudioProcessorPlaybackParameters()) + : PlaybackParameters.DEFAULT; boolean skipSilenceEnabled = configuration.canApplyPlaybackParameters ? audioProcessorChain.applySkipSilenceEnabled(getSkipSilenceEnabled()) : DEFAULT_SKIP_SILENCE; mediaPositionParametersCheckpoints.add( new MediaPositionParameters( - playbackSpeed, + playbackParameters, skipSilenceEnabled, - /* mediaTimeUs= */ Math.max(0, presentationTimeUs), + /* mediaTimeUs= */ max(0, presentationTimeUs), /* audioTrackPositionUs= */ configuration.framesToDurationUs(getWrittenFrames()))); setupAudioProcessors(); if (listener != null) { @@ -1133,7 +1354,7 @@ public final class DefaultAudioSink implements AudioSink { long playoutDurationSinceLastCheckpoint = positionUs - mediaPositionParameters.audioTrackPositionUs; - if (mediaPositionParameters.playbackSpeed != 1f) { + if (!mediaPositionParameters.playbackParameters.equals(PlaybackParameters.DEFAULT)) { if (mediaPositionParametersCheckpoints.isEmpty()) { playoutDurationSinceLastCheckpoint = audioProcessorChain.getMediaDuration(playoutDurationSinceLastCheckpoint); @@ -1141,7 +1362,8 @@ public final class DefaultAudioSink implements AudioSink { // Playing data at a previous playback speed, so fall back to multiplying by the speed. playoutDurationSinceLastCheckpoint = Util.getMediaDurationForPlayoutDuration( - playoutDurationSinceLastCheckpoint, mediaPositionParameters.playbackSpeed); + playoutDurationSinceLastCheckpoint, + mediaPositionParameters.playbackParameters.speed); } } return mediaPositionParameters.mediaTimeUs + playoutDurationSinceLastCheckpoint; @@ -1152,33 +1374,87 @@ public final class DefaultAudioSink implements AudioSink { + configuration.framesToDurationUs(audioProcessorChain.getSkippedOutputFrameCount()); } - private boolean isInitialized() { + private boolean isAudioTrackInitialized() { return audioTrack != null; } private long getSubmittedFrames() { - return configuration.isInputPcm + return configuration.outputMode == OUTPUT_MODE_PCM ? (submittedPcmBytes / configuration.inputPcmFrameSize) : submittedEncodedFrames; } private long getWrittenFrames() { - return configuration.isInputPcm + return configuration.outputMode == OUTPUT_MODE_PCM ? (writtenPcmBytes / configuration.outputPcmFrameSize) : writtenEncodedFrames; } - private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { - int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. - int channelConfig = AudioFormat.CHANNEL_OUT_MONO; - @C.PcmEncoding int encoding = C.ENCODING_PCM_16BIT; - int bufferSize = 2; // Use a two byte buffer, as it is not actually used for playback. - return new AudioTrack(C.STREAM_TYPE_DEFAULT, sampleRate, channelConfig, encoding, bufferSize, - MODE_STATIC, audioSessionId); + private static boolean isPassthroughPlaybackSupported( + Format format, @Nullable AudioCapabilities audioCapabilities) { + return getEncodingAndChannelConfigForPassthrough(format, audioCapabilities) != null; } - private static int getChannelConfig(int channelCount, boolean isInputPcm) { - if (Util.SDK_INT <= 28 && !isInputPcm) { + /** + * Returns the encoding and channel config to use when configuring an {@link AudioTrack} in + * passthrough mode for the specified {@link Format}. Returns {@code null} if passthrough of the + * format is unsupported. + * + * @param format The {@link Format}. + * @param audioCapabilities The device audio capabilities. + * @return The encoding and channel config to use, or {@code null} if passthrough of the format is + * unsupported. + */ + @Nullable + private static Pair getEncodingAndChannelConfigForPassthrough( + Format format, @Nullable AudioCapabilities audioCapabilities) { + if (audioCapabilities == null) { + return null; + } + + @C.Encoding + int encoding = + MimeTypes.getEncoding(Assertions.checkNotNull(format.sampleMimeType), format.codecs); + // Check for encodings that are known to work for passthrough with the implementation in this + // class. This avoids trying to use passthrough with an encoding where the device/app reports + // it's capable but it is untested or known to be broken (for example AAC-LC). + boolean supportedEncoding = + encoding == C.ENCODING_AC3 + || encoding == C.ENCODING_E_AC3 + || encoding == C.ENCODING_E_AC3_JOC + || encoding == C.ENCODING_AC4 + || encoding == C.ENCODING_DTS + || encoding == C.ENCODING_DTS_HD + || encoding == C.ENCODING_DOLBY_TRUEHD; + if (!supportedEncoding) { + return null; + } + + // E-AC3 JOC is object based, so any channel count specified in the format is arbitrary. Use 6, + // since the E-AC3 compatible part of the stream is 5.1. + int channelCount = encoding == C.ENCODING_E_AC3_JOC ? 6 : format.channelCount; + if (channelCount > audioCapabilities.getMaxChannelCount()) { + return null; + } + + int channelConfig = getChannelConfigForPassthrough(channelCount); + if (channelConfig == AudioFormat.CHANNEL_INVALID) { + return null; + } + + if (audioCapabilities.supportsEncoding(encoding)) { + return Pair.create(encoding, channelConfig); + } else if (encoding == C.ENCODING_E_AC3_JOC + && audioCapabilities.supportsEncoding(C.ENCODING_E_AC3)) { + // E-AC3 receivers support E-AC3 JOC streams (but decode in 2-D rather than 3-D). + return Pair.create(C.ENCODING_E_AC3, channelConfig); + } + + return null; + } + + private static int getChannelConfigForPassthrough(int channelCount) { + if (Util.SDK_INT <= 28) { // In passthrough mode the channel count used to configure the audio track doesn't affect how // the stream is handled, except that some devices do overly-strict channel configuration // checks. Therefore we override the channel count so that a known-working channel @@ -1190,15 +1466,69 @@ public final class DefaultAudioSink implements AudioSink { } } - // Workaround for Nexus Player not reporting support for mono passthrough. - // (See [Internal: b/34268671].) - if (Util.SDK_INT <= 26 && "fugu".equals(Util.DEVICE) && !isInputPcm && channelCount == 1) { + // Workaround for Nexus Player not reporting support for mono passthrough. See + // [Internal: b/34268671]. + if (Util.SDK_INT <= 26 && "fugu".equals(Util.DEVICE) && channelCount == 1) { channelCount = 2; } return Util.getAudioTrackChannelConfig(channelCount); } + private static boolean isOffloadedPlaybackSupported( + Format format, AudioAttributes audioAttributes) { + if (Util.SDK_INT < 29) { + return false; + } + @C.Encoding + int encoding = + MimeTypes.getEncoding(Assertions.checkNotNull(format.sampleMimeType), format.codecs); + if (encoding == C.ENCODING_INVALID) { + return false; + } + int channelConfig = Util.getAudioTrackChannelConfig(format.channelCount); + if (channelConfig == AudioFormat.CHANNEL_INVALID) { + return false; + } + AudioFormat audioFormat = getAudioFormat(format.sampleRate, channelConfig, encoding); + if (!AudioManager.isOffloadedPlaybackSupported( + audioFormat, audioAttributes.getAudioAttributesV21())) { + return false; + } + boolean notGapless = format.encoderDelay == 0 && format.encoderPadding == 0; + return notGapless || isOffloadedGaplessPlaybackSupported(); + } + + private static boolean isOffloadedPlayback(AudioTrack audioTrack) { + return Util.SDK_INT >= 29 && audioTrack.isOffloadedPlayback(); + } + + /** + * Returns whether the device supports gapless in offload playback. + * + *

    Gapless offload is not supported by all devices and there is no API to query its support. As + * a result this detection is currently based on manual testing. + */ + // TODO(internal b/158191844): Add an SDK API to query offload gapless support. + private static boolean isOffloadedGaplessPlaybackSupported() { + return Util.SDK_INT >= 30 && Util.MODEL.startsWith("Pixel"); + } + + private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { + int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. + int channelConfig = AudioFormat.CHANNEL_OUT_MONO; + @C.PcmEncoding int encoding = C.ENCODING_PCM_16BIT; + int bufferSize = 2; // Use a two byte buffer, as it is not actually used for playback. + return new AudioTrack( + C.STREAM_TYPE_DEFAULT, + sampleRate, + channelConfig, + encoding, + bufferSize, + MODE_STATIC, + audioSessionId); + } + private static int getMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) { switch (encoding) { case C.ENCODING_MP3: @@ -1232,6 +1562,7 @@ public final class DefaultAudioSink implements AudioSink { case C.ENCODING_PCM_32BIT: case C.ENCODING_PCM_8BIT: case C.ENCODING_PCM_FLOAT: + case C.ENCODING_AAC_ER_BSAC: case C.ENCODING_INVALID: case Format.NO_VALUE: default: @@ -1243,7 +1574,11 @@ public final class DefaultAudioSink implements AudioSink { switch (encoding) { case C.ENCODING_MP3: int headerDataInBigEndian = Util.getBigEndianInt(buffer, buffer.position()); - return MpegAudioUtil.parseMpegAudioFrameSampleCount(headerDataInBigEndian); + int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(headerDataInBigEndian); + if (frameCount == C.LENGTH_UNSET) { + throw new IllegalArgumentException(); + } + return frameCount; case C.ENCODING_AAC_LC: return AacUtil.AAC_LC_AUDIO_SAMPLE_COUNT; case C.ENCODING_AAC_HE_V1: @@ -1274,6 +1609,7 @@ public final class DefaultAudioSink implements AudioSink { case C.ENCODING_PCM_32BIT: case C.ENCODING_PCM_8BIT: case C.ENCODING_PCM_FLOAT: + case C.ENCODING_AAC_ER_BSAC: case C.ENCODING_INVALID: case Format.NO_VALUE: default: @@ -1342,11 +1678,37 @@ public final class DefaultAudioSink implements AudioSink { } } + @RequiresApi(29) + private final class StreamEventCallbackV29 extends AudioTrack.StreamEventCallback { + private final Handler handler; + + public StreamEventCallbackV29() { + handler = new Handler(); + } + + @Override + public void onDataRequest(AudioTrack track, int size) { + Assertions.checkState(track == DefaultAudioSink.this.audioTrack); + if (listener != null) { + listener.onOffloadBufferEmptying(); + } + } + + public void register(AudioTrack audioTrack) { + audioTrack.registerStreamEventCallback(handler::post, this); + } + + public void unregister(AudioTrack audioTrack) { + audioTrack.unregisterStreamEventCallback(this); + handler.removeCallbacksAndMessages(/* token= */ null); + } + } + /** Stores parameters used to calculate the current media position. */ private static final class MediaPositionParameters { - /** The playback speed. */ - public final float playbackSpeed; + /** The playback parameters. */ + public final PlaybackParameters playbackParameters; /** Whether to skip silences. */ public final boolean skipSilence; /** The media time from which the playback parameters apply, in microseconds. */ @@ -1355,14 +1717,26 @@ public final class DefaultAudioSink implements AudioSink { public final long audioTrackPositionUs; private MediaPositionParameters( - float playbackSpeed, boolean skipSilence, long mediaTimeUs, long audioTrackPositionUs) { - this.playbackSpeed = playbackSpeed; + PlaybackParameters playbackParameters, + boolean skipSilence, + long mediaTimeUs, + long audioTrackPositionUs) { + this.playbackParameters = playbackParameters; this.skipSilence = skipSilence; this.mediaTimeUs = mediaTimeUs; this.audioTrackPositionUs = audioTrackPositionUs; } } + @RequiresApi(21) + private static AudioFormat getAudioFormat(int sampleRate, int channelConfig, int encoding) { + return new AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .setEncoding(encoding) + .build(); + } + private final class PositionTrackerListener implements AudioTrackPositionTracker.Listener { @Override @@ -1420,6 +1794,13 @@ public final class DefaultAudioSink implements AudioSink { Log.w(TAG, "Ignoring impossibly large audio latency: " + latencyUs); } + @Override + public void onPositionAdvancing(long playoutStartSystemTimeMs) { + if (listener != null) { + listener.onPositionAdvancing(playoutStartSystemTimeMs); + } + } + @Override public void onUnderrun(int bufferSize, long bufferSizeMs) { if (listener != null) { @@ -1432,51 +1813,54 @@ public final class DefaultAudioSink implements AudioSink { /** Stores configuration relating to the audio format. */ private static final class Configuration { - public final boolean isInputPcm; + public final Format inputFormat; public final int inputPcmFrameSize; - public final int inputSampleRate; + @OutputMode public final int outputMode; public final int outputPcmFrameSize; public final int outputSampleRate; public final int outputChannelConfig; @C.Encoding public final int outputEncoding; public final int bufferSize; - public final boolean processingEnabled; public final boolean canApplyPlaybackParameters; public final AudioProcessor[] availableAudioProcessors; public Configuration( - boolean isInputPcm, + Format inputFormat, int inputPcmFrameSize, - int inputSampleRate, + @OutputMode int outputMode, int outputPcmFrameSize, int outputSampleRate, int outputChannelConfig, int outputEncoding, int specifiedBufferSize, - boolean processingEnabled, + boolean enableAudioTrackPlaybackParams, boolean canApplyPlaybackParameters, AudioProcessor[] availableAudioProcessors) { - this.isInputPcm = isInputPcm; + this.inputFormat = inputFormat; this.inputPcmFrameSize = inputPcmFrameSize; - this.inputSampleRate = inputSampleRate; + this.outputMode = outputMode; this.outputPcmFrameSize = outputPcmFrameSize; this.outputSampleRate = outputSampleRate; this.outputChannelConfig = outputChannelConfig; this.outputEncoding = outputEncoding; - this.bufferSize = specifiedBufferSize != 0 ? specifiedBufferSize : getDefaultBufferSize(); - this.processingEnabled = processingEnabled; this.canApplyPlaybackParameters = canApplyPlaybackParameters; this.availableAudioProcessors = availableAudioProcessors; + + // Call computeBufferSize() last as it depends on the other configuration values. + this.bufferSize = computeBufferSize(specifiedBufferSize, enableAudioTrackPlaybackParams); } + /** Returns if the configurations are sufficiently compatible to reuse the audio track. */ public boolean canReuseAudioTrack(Configuration audioTrackConfiguration) { - return audioTrackConfiguration.outputEncoding == outputEncoding + return audioTrackConfiguration.outputMode == outputMode + && audioTrackConfiguration.outputEncoding == outputEncoding && audioTrackConfiguration.outputSampleRate == outputSampleRate - && audioTrackConfiguration.outputChannelConfig == outputChannelConfig; + && audioTrackConfiguration.outputChannelConfig == outputChannelConfig + && audioTrackConfiguration.outputPcmFrameSize == outputPcmFrameSize; } public long inputFramesToDurationUs(long frameCount) { - return (frameCount * C.MICROS_PER_SECOND) / inputSampleRate; + return (frameCount * C.MICROS_PER_SECOND) / inputFormat.sampleRate; } public long framesToDurationUs(long frameCount) { @@ -1491,31 +1875,11 @@ public final class DefaultAudioSink implements AudioSink { boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) throws InitializationException { AudioTrack audioTrack; - if (Util.SDK_INT >= 21) { - audioTrack = createAudioTrackV21(tunneling, audioAttributes, audioSessionId); - } else { - int streamType = Util.getStreamTypeForAudioUsage(audioAttributes.usage); - if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { - audioTrack = - new AudioTrack( - streamType, - outputSampleRate, - outputChannelConfig, - outputEncoding, - bufferSize, - MODE_STREAM); - } else { - // Re-attach to the same audio session. - audioTrack = - new AudioTrack( - streamType, - outputSampleRate, - outputChannelConfig, - outputEncoding, - bufferSize, - MODE_STREAM, - audioSessionId); - } + try { + audioTrack = createAudioTrack(tunneling, audioAttributes, audioSessionId); + } catch (UnsupportedOperationException e) { + throw new InitializationException( + AudioTrack.STATE_UNINITIALIZED, outputSampleRate, outputChannelConfig, bufferSize); } int state = audioTrack.getState(); @@ -1531,56 +1895,132 @@ public final class DefaultAudioSink implements AudioSink { return audioTrack; } + private AudioTrack createAudioTrack( + boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) { + if (Util.SDK_INT >= 29) { + return createAudioTrackV29(tunneling, audioAttributes, audioSessionId); + } else if (Util.SDK_INT >= 21) { + return createAudioTrackV21(tunneling, audioAttributes, audioSessionId); + } else { + return createAudioTrackV9(audioAttributes, audioSessionId); + } + } + + @RequiresApi(29) + private AudioTrack createAudioTrackV29( + boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) { + AudioFormat audioFormat = + getAudioFormat(outputSampleRate, outputChannelConfig, outputEncoding); + android.media.AudioAttributes audioTrackAttributes = + getAudioTrackAttributesV21(audioAttributes, tunneling); + return new AudioTrack.Builder() + .setAudioAttributes(audioTrackAttributes) + .setAudioFormat(audioFormat) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(bufferSize) + .setSessionId(audioSessionId) + .setOffloadedPlayback(outputMode == OUTPUT_MODE_OFFLOAD) + .build(); + } + @RequiresApi(21) private AudioTrack createAudioTrackV21( boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) { - android.media.AudioAttributes attributes; - if (tunneling) { - attributes = - new android.media.AudioAttributes.Builder() - .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) - .setFlags(android.media.AudioAttributes.FLAG_HW_AV_SYNC) - .setUsage(android.media.AudioAttributes.USAGE_MEDIA) - .build(); - } else { - attributes = audioAttributes.getAudioAttributesV21(); - } - AudioFormat format = - new AudioFormat.Builder() - .setChannelMask(outputChannelConfig) - .setEncoding(outputEncoding) - .setSampleRate(outputSampleRate) - .build(); return new AudioTrack( - attributes, - format, + getAudioTrackAttributesV21(audioAttributes, tunneling), + getAudioFormat(outputSampleRate, outputChannelConfig, outputEncoding), bufferSize, MODE_STREAM, - audioSessionId != C.AUDIO_SESSION_ID_UNSET - ? audioSessionId - : AudioManager.AUDIO_SESSION_ID_GENERATE); + audioSessionId); } - private int getDefaultBufferSize() { - if (isInputPcm) { - int minBufferSize = - AudioTrack.getMinBufferSize(outputSampleRate, outputChannelConfig, outputEncoding); - Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); - int multipliedBufferSize = minBufferSize * BUFFER_MULTIPLICATION_FACTOR; - int minAppBufferSize = - (int) durationUsToFrames(MIN_BUFFER_DURATION_US) * outputPcmFrameSize; - int maxAppBufferSize = - (int) - Math.max( - minBufferSize, durationUsToFrames(MAX_BUFFER_DURATION_US) * outputPcmFrameSize); - return Util.constrainValue(multipliedBufferSize, minAppBufferSize, maxAppBufferSize); + private AudioTrack createAudioTrackV9(AudioAttributes audioAttributes, int audioSessionId) { + int streamType = Util.getStreamTypeForAudioUsage(audioAttributes.usage); + if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { + return new AudioTrack( + streamType, + outputSampleRate, + outputChannelConfig, + outputEncoding, + bufferSize, + MODE_STREAM); } else { - int rate = getMaximumEncodedRateBytesPerSecond(outputEncoding); - if (outputEncoding == C.ENCODING_AC3) { - rate *= AC3_BUFFER_MULTIPLICATION_FACTOR; - } - return (int) (PASSTHROUGH_BUFFER_DURATION_US * rate / C.MICROS_PER_SECOND); + // Re-attach to the same audio session. + return new AudioTrack( + streamType, + outputSampleRate, + outputChannelConfig, + outputEncoding, + bufferSize, + MODE_STREAM, + audioSessionId); } } + + private int computeBufferSize( + int specifiedBufferSize, boolean enableAudioTrackPlaybackParameters) { + if (specifiedBufferSize != 0) { + return specifiedBufferSize; + } + switch (outputMode) { + case OUTPUT_MODE_PCM: + return getPcmDefaultBufferSize( + enableAudioTrackPlaybackParameters ? MAX_PLAYBACK_SPEED : DEFAULT_PLAYBACK_SPEED); + case OUTPUT_MODE_OFFLOAD: + return getEncodedDefaultBufferSize(OFFLOAD_BUFFER_DURATION_US); + case OUTPUT_MODE_PASSTHROUGH: + return getEncodedDefaultBufferSize(PASSTHROUGH_BUFFER_DURATION_US); + default: + throw new IllegalStateException(); + } + } + + private int getEncodedDefaultBufferSize(long bufferDurationUs) { + int rate = getMaximumEncodedRateBytesPerSecond(outputEncoding); + if (outputEncoding == C.ENCODING_AC3) { + rate *= AC3_BUFFER_MULTIPLICATION_FACTOR; + } + return (int) (bufferDurationUs * rate / C.MICROS_PER_SECOND); + } + + private int getPcmDefaultBufferSize(float maxAudioTrackPlaybackSpeed) { + int minBufferSize = + AudioTrack.getMinBufferSize(outputSampleRate, outputChannelConfig, outputEncoding); + Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); + int multipliedBufferSize = minBufferSize * BUFFER_MULTIPLICATION_FACTOR; + int minAppBufferSize = (int) durationUsToFrames(MIN_BUFFER_DURATION_US) * outputPcmFrameSize; + int maxAppBufferSize = + max(minBufferSize, (int) durationUsToFrames(MAX_BUFFER_DURATION_US) * outputPcmFrameSize); + int bufferSize = + Util.constrainValue(multipliedBufferSize, minAppBufferSize, maxAppBufferSize); + if (maxAudioTrackPlaybackSpeed != 1f) { + // Maintain the buffer duration by scaling the size accordingly. + bufferSize = Math.round(bufferSize * maxAudioTrackPlaybackSpeed); + } + return bufferSize; + } + + @RequiresApi(21) + private static android.media.AudioAttributes getAudioTrackAttributesV21( + AudioAttributes audioAttributes, boolean tunneling) { + if (tunneling) { + return getAudioTrackTunnelingAttributesV21(); + } else { + return audioAttributes.getAudioAttributesV21(); + } + } + + @RequiresApi(21) + private static android.media.AudioAttributes getAudioTrackTunnelingAttributesV21() { + return new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) + .setFlags(android.media.AudioAttributes.FLAG_HW_AV_SYNC) + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .build(); + } + + public boolean outputModeIsOffload() { + return outputMode == OUTPUT_MODE_OFFLOAD; + } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java index e0703f2aa3..7460d12457 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -35,8 +35,14 @@ public class ForwardingAudioSink implements AudioSink { } @Override - public boolean supportsOutput(int channelCount, int sampleRate, @C.Encoding int encoding) { - return sink.supportsOutput(channelCount, sampleRate, encoding); + public boolean supportsFormat(Format format) { + return sink.supportsFormat(format); + } + + @Override + @SinkFormatSupport + public int getFormatSupport(Format format) { + return sink.getFormatSupport(format); } @Override @@ -45,23 +51,9 @@ public class ForwardingAudioSink implements AudioSink { } @Override - public void configure( - int inputEncoding, - int inputChannelCount, - int inputSampleRate, - int specifiedBufferSize, - @Nullable int[] outputChannels, - int trimStartFrames, - int trimEndFrames) + public void configure(Format inputFormat, int specifiedBufferSize, @Nullable int[] outputChannels) throws ConfigurationException { - sink.configure( - inputEncoding, - inputChannelCount, - inputSampleRate, - specifiedBufferSize, - outputChannels, - trimStartFrames, - trimEndFrames); + sink.configure(inputFormat, specifiedBufferSize, outputChannels); } @Override @@ -96,35 +88,16 @@ public class ForwardingAudioSink implements AudioSink { return sink.hasPendingData(); } - /** - * @deprecated Use {@link #setPlaybackSpeed(float)} and {@link #setSkipSilenceEnabled(boolean)} - * instead. - */ - @SuppressWarnings("deprecation") - @Deprecated @Override public void setPlaybackParameters(PlaybackParameters playbackParameters) { sink.setPlaybackParameters(playbackParameters); } - /** @deprecated Use {@link #getPlaybackSpeed()} and {@link #getSkipSilenceEnabled()} instead. */ - @SuppressWarnings("deprecation") - @Deprecated @Override public PlaybackParameters getPlaybackParameters() { return sink.getPlaybackParameters(); } - @Override - public void setPlaybackSpeed(float playbackSpeed) { - sink.setPlaybackSpeed(playbackSpeed); - } - - @Override - public float getPlaybackSpeed() { - return sink.getPlaybackSpeed(); - } - @Override public void setSkipSilenceEnabled(boolean skipSilenceEnabled) { sink.setSkipSilenceEnabled(skipSilenceEnabled); @@ -175,6 +148,11 @@ public class ForwardingAudioSink implements AudioSink { sink.flush(); } + @Override + public void experimentalFlushWithoutAudioTrackRelease() { + sink.experimentalFlushWithoutAudioTrackRelease(); + } + @Override public void reset() { sink.reset(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java index c69383ffe2..2d034335c8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -15,23 +15,30 @@ */ package com.google.android.exoplayer2.audio; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.max; + import android.annotation.SuppressLint; import android.content.Context; +import android.media.AudioFormat; import android.media.MediaCodec; import android.media.MediaCrypto; import android.media.MediaFormat; import android.media.audiofx.Virtualizer; import android.os.Handler; +import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.audio.AudioRendererEventListener.EventDispatcher; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; @@ -82,25 +89,25 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private final AudioSink audioSink; private int codecMaxInputSize; - private boolean passthroughEnabled; private boolean codecNeedsDiscardChannelsWorkaround; private boolean codecNeedsEosBufferTimestampWorkaround; - @Nullable private Format passthroughFormat; - @Nullable private Format inputFormat; + /** Codec used for DRM decryption only in passthrough and offload. */ + @Nullable private Format decryptOnlyCodecFormat; + private long currentPositionUs; private boolean allowFirstBufferPositionDiscontinuity; private boolean allowPositionDiscontinuity; + private boolean experimentalKeepAudioTrackOnSeek; + + @Nullable private WakeupListener wakeupListener; + /** * @param context A context. * @param mediaCodecSelector A decoder selector. */ public MediaCodecAudioRenderer(Context context, MediaCodecSelector mediaCodecSelector) { - this( - context, - mediaCodecSelector, - /* eventHandler= */ null, - /* eventListener= */ null); + this(context, mediaCodecSelector, /* eventHandler= */ null, /* eventListener= */ null); } /** @@ -115,12 +122,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media MediaCodecSelector mediaCodecSelector, @Nullable Handler eventHandler, @Nullable AudioRendererEventListener eventListener) { - this( - context, - mediaCodecSelector, - eventHandler, - eventListener, - (AudioCapabilities) null); + this(context, mediaCodecSelector, eventHandler, eventListener, (AudioCapabilities) null); } /** @@ -206,6 +208,19 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media return TAG; } + /** + * Sets whether to enable the experimental feature that keeps and flushes the {@link + * android.media.AudioTrack} when a seek occurs, as opposed to releasing and reinitialising. Off + * by default. + * + *

    This method is experimental, and will be renamed or removed in a future release. + * + * @param enableKeepAudioTrackOnSeek Whether to keep the {@link android.media.AudioTrack} on seek. + */ + public void experimentalSetEnableKeepAudioTrackOnSeek(boolean enableKeepAudioTrackOnSeek) { + this.experimentalKeepAudioTrackOnSeek = enableKeepAudioTrackOnSeek; + } + @Override @Capabilities protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) @@ -215,20 +230,23 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @TunnelingSupport int tunnelingSupport = Util.SDK_INT >= 21 ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; + boolean formatHasDrm = format.exoMediaCryptoType != null; boolean supportsFormatDrm = supportsFormatDrm(format); - // In passthrough mode, if DRM init data is present we need to use a passthrough decoder to - // decrypt the content. For passthrough of clear content we don't need a decoder at all. + // In direct mode, if the format has DRM then we need to use a decoder that only decrypts. + // Else we don't don't need a decoder at all. if (supportsFormatDrm - && usePassthrough(format) - && (format.drmInitData == null || MediaCodecUtil.getPassthroughDecoderInfo() != null)) { + && audioSink.supportsFormat(format) + && (!formatHasDrm || MediaCodecUtil.getDecryptOnlyDecoderInfo() != null)) { return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); } - if ((MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) - && !audioSink.supportsOutput( - format.channelCount, format.sampleRate, format.pcmEncoding)) - || !audioSink.supportsOutput( - format.channelCount, format.sampleRate, C.ENCODING_PCM_16BIT)) { - // Assume the decoder outputs 16-bit PCM, unless the input is raw. + // If the input is PCM then it will be passed directly to the sink. Hence the sink must support + // the input format directly. + if (MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) && !audioSink.supportsFormat(format)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + // For all other input formats, we expect the decoder to output 16-bit PCM. + if (!audioSink.supportsFormat( + Util.getPcmFormat(C.ENCODING_PCM_16BIT, format.channelCount, format.sampleRate))) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); } List decoderInfos = @@ -260,8 +278,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media if (mimeType == null) { return Collections.emptyList(); } - if (usePassthrough(format)) { - @Nullable MediaCodecInfo codecInfo = MediaCodecUtil.getPassthroughDecoderInfo(); + if (audioSink.supportsFormat(format)) { + // The format is supported directly, so a codec is only needed for decryption. + @Nullable MediaCodecInfo codecInfo = MediaCodecUtil.getDecryptOnlyDecoderInfo(); if (codecInfo != null) { return Collections.singletonList(codecInfo); } @@ -282,43 +301,34 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @Override - protected boolean usePassthrough(Format format) { - return getPassthroughEncoding(format) != C.ENCODING_INVALID; + protected boolean shouldUseBypass(Format format) { + return audioSink.supportsFormat(format); } @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate) { codecMaxInputSize = getCodecMaxInputSize(codecInfo, format, getStreamFormats()); codecNeedsDiscardChannelsWorkaround = codecNeedsDiscardChannelsWorkaround(codecInfo.name); codecNeedsEosBufferTimestampWorkaround = codecNeedsEosBufferTimestampWorkaround(codecInfo.name); - passthroughEnabled = - MimeTypes.AUDIO_RAW.equals(codecInfo.mimeType) - && !MimeTypes.AUDIO_RAW.equals(format.sampleMimeType); MediaFormat mediaFormat = getMediaFormat(format, codecInfo.codecMimeType, codecMaxInputSize, codecOperatingRate); - codec.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); - // Store the input MIME type if we're using the passthrough codec. - passthroughFormat = passthroughEnabled ? format : null; + codecAdapter.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); + // Store the input MIME type if we're only using the codec for decryption. + boolean decryptOnlyCodecEnabled = + MimeTypes.AUDIO_RAW.equals(codecInfo.mimeType) + && !MimeTypes.AUDIO_RAW.equals(format.sampleMimeType); + decryptOnlyCodecFormat = decryptOnlyCodecEnabled ? format : null; } @Override protected @KeepCodecResult int canKeepCodec( MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { - // TODO: We currently rely on recreating the codec when encoder delay or padding is non-zero. - // Re-creating the codec is necessary to guarantee that onOutputMediaFormatChanged is called, - // which is where encoder delay and padding are propagated to the sink. We should find a better - // way to propagate these values, and then allow the codec to be re-used in cases where this - // would otherwise be possible. - if (getCodecMaxInputSize(codecInfo, newFormat) > codecMaxInputSize - || oldFormat.encoderDelay != 0 - || oldFormat.encoderPadding != 0 - || newFormat.encoderDelay != 0 - || newFormat.encoderPadding != 0) { + if (getCodecMaxInputSize(codecInfo, newFormat) > codecMaxInputSize) { return KEEP_CODEC_RESULT_NO; } else if (codecInfo.isSeamlessAdaptationSupported( oldFormat, newFormat, /* isNewFormatComplete= */ true)) { @@ -367,113 +377,72 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media for (Format streamFormat : streamFormats) { int streamSampleRate = streamFormat.sampleRate; if (streamSampleRate != Format.NO_VALUE) { - maxSampleRate = Math.max(maxSampleRate, streamSampleRate); + maxSampleRate = max(maxSampleRate, streamSampleRate); } } return maxSampleRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxSampleRate * operatingRate); } @Override - protected void onCodecInitialized(String name, long initializedTimestampMs, - long initializationDurationMs) { + protected void onCodecInitialized( + String name, long initializedTimestampMs, long initializationDurationMs) { eventDispatcher.decoderInitialized(name, initializedTimestampMs, initializationDurationMs); } @Override protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { super.onInputFormatChanged(formatHolder); - inputFormat = formatHolder.format; - eventDispatcher.inputFormatChanged(inputFormat); + eventDispatcher.inputFormatChanged(formatHolder.format); } @Override - protected void onOutputMediaFormatChanged(MediaCodec codec, MediaFormat outputMediaFormat) + protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaFormat) throws ExoPlaybackException { - @C.Encoding int encoding; - int channelCount; - int sampleRate; - if (passthroughFormat != null) { - encoding = getPassthroughEncoding(passthroughFormat); - channelCount = passthroughFormat.channelCount; - sampleRate = passthroughFormat.sampleRate; - } else { - if (outputMediaFormat.containsKey(VIVO_BITS_PER_SAMPLE_KEY)) { - encoding = Util.getPcmEncoding(outputMediaFormat.getInteger(VIVO_BITS_PER_SAMPLE_KEY)); - } else { - encoding = getPcmEncoding(inputFormat); - } - channelCount = outputMediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT); - sampleRate = outputMediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE); - } + Format audioSinkInputFormat; @Nullable int[] channelMap = null; - if (codecNeedsDiscardChannelsWorkaround && channelCount == 6 && inputFormat.channelCount < 6) { - channelMap = new int[inputFormat.channelCount]; - for (int i = 0; i < inputFormat.channelCount; i++) { - channelMap[i] = i; - } - } - try { - audioSink.configure( - encoding, - channelCount, - sampleRate, - /* specifiedBufferSize= */ 0, - channelMap, - inputFormat.encoderDelay, - inputFormat.encoderPadding); - } catch (AudioSink.ConfigurationException e) { - // TODO(internal: b/145658993) Use outputFormat instead. - throw createRendererException(e, inputFormat); - } - } - - @Override - protected void onOutputPassthroughFormatChanged(Format outputFormat) throws ExoPlaybackException { - @C.Encoding int encoding = getPassthroughEncoding(outputFormat); - try { - audioSink.configure( - encoding, - outputFormat.channelCount, - outputFormat.sampleRate, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - outputFormat.encoderDelay, - outputFormat.encoderPadding); - } catch (AudioSink.ConfigurationException e) { - throw createRendererException(e, outputFormat); - } - } - - /** - * Returns the {@link C.Encoding} constant to use for passthrough of the given format, or {@link - * C#ENCODING_INVALID} if passthrough is not possible. - * - * @param format The format for which to get the encoding. - * @return The {@link C.Encoding} corresponding to the format, or {@link C#ENCODING_INVALID} if - * the format is not supported. - */ - @C.Encoding - protected int getPassthroughEncoding(Format format) { - @Nullable String mimeType = format.sampleMimeType; - if (MimeTypes.AUDIO_RAW.equals(mimeType)) { - // PCM passthrough is not supported. - return C.ENCODING_INVALID; - } - if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType)) { - // E-AC3 JOC is object-based so the output channel count is arbitrary. - if (audioSink.supportsOutput( - /* channelCount= */ Format.NO_VALUE, format.sampleRate, C.ENCODING_E_AC3_JOC)) { - return MimeTypes.getEncoding(MimeTypes.AUDIO_E_AC3_JOC, format.codecs); - } - // E-AC3 receivers can decode JOC streams, but in 2-D rather than 3-D, so try to fall back. - mimeType = MimeTypes.AUDIO_E_AC3; - } - - @C.Encoding int encoding = MimeTypes.getEncoding(mimeType, format.codecs); - if (audioSink.supportsOutput(format.channelCount, format.sampleRate, encoding)) { - return encoding; + if (decryptOnlyCodecFormat != null) { // Direct playback with a codec for decryption. + audioSinkInputFormat = decryptOnlyCodecFormat; + } else if (getCodec() == null) { // Direct playback with codec bypass. + audioSinkInputFormat = format; } else { - return C.ENCODING_INVALID; + @C.PcmEncoding int pcmEncoding; + if (MimeTypes.AUDIO_RAW.equals(format.sampleMimeType)) { + // For PCM streams, the encoder passes through int samples despite set to float mode. + pcmEncoding = format.pcmEncoding; + } else if (Util.SDK_INT >= 24 && mediaFormat.containsKey(MediaFormat.KEY_PCM_ENCODING)) { + pcmEncoding = mediaFormat.getInteger(MediaFormat.KEY_PCM_ENCODING); + } else if (mediaFormat.containsKey(VIVO_BITS_PER_SAMPLE_KEY)) { + pcmEncoding = Util.getPcmEncoding(mediaFormat.getInteger(VIVO_BITS_PER_SAMPLE_KEY)); + } else { + // If the format is anything other than PCM then we assume that the audio decoder will + // output 16-bit PCM. + pcmEncoding = + MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) + ? format.pcmEncoding + : C.ENCODING_PCM_16BIT; + } + audioSinkInputFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(pcmEncoding) + .setEncoderDelay(format.encoderDelay) + .setEncoderPadding(format.encoderPadding) + .setChannelCount(mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)) + .setSampleRate(mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)) + .build(); + if (codecNeedsDiscardChannelsWorkaround + && audioSinkInputFormat.channelCount == 6 + && format.channelCount < 6) { + channelMap = new int[format.channelCount]; + for (int i = 0; i < format.channelCount; i++) { + channelMap[i] = i; + } + } + } + try { + audioSink.configure(audioSinkInputFormat, /* specifiedBufferSize= */ 0, channelMap); + } catch (AudioSink.ConfigurationException e) { + throw createRendererException(e, format); } } @@ -490,19 +459,10 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } /** See {@link AudioSink.Listener#onPositionDiscontinuity()}. */ - protected void onAudioTrackPositionDiscontinuity() { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onUnderrun(int, long, long)}. */ - protected void onAudioTrackUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - // Do nothing. - } - - /** See {@link AudioSink.Listener#onSkipSilenceEnabledChanged(boolean)}. */ - protected void onAudioTrackSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { - // Do nothing. + @CallSuper + protected void onPositionDiscontinuity() { + // We are out of sync so allow currentPositionUs to jump backwards. + allowPositionDiscontinuity = true; } @Override @@ -521,7 +481,12 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { super.onPositionReset(positionUs, joining); - audioSink.flush(); + if (experimentalKeepAudioTrackOnSeek) { + audioSink.experimentalFlushWithoutAudioTrackRelease(); + } else { + audioSink.flush(); + } + currentPositionUs = positionUs; allowFirstBufferPositionDiscontinuity = true; allowPositionDiscontinuity = true; @@ -581,13 +546,13 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media } @Override - public void setPlaybackSpeed(float playbackSpeed) { - audioSink.setPlaybackSpeed(playbackSpeed); + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + audioSink.setPlaybackParameters(playbackParameters); } @Override - public float getPlaybackSpeed() { - return audioSink.getPlaybackSpeed(); + public PlaybackParameters getPlaybackParameters() { + return audioSink.getPlaybackParameters(); } @Override @@ -614,7 +579,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media long positionUs, long elapsedRealtimeUs, @Nullable MediaCodec codec, - ByteBuffer buffer, + @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, int sampleCount, @@ -623,6 +588,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media boolean isLastBuffer, Format format) throws ExoPlaybackException { + checkNotNull(buffer); if (codec != null && codecNeedsEosBufferTimestampWorkaround && bufferPresentationTimeUs == 0 @@ -631,9 +597,10 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media bufferPresentationTimeUs = getLargestQueuedPresentationTimeUs(); } - if (passthroughEnabled && (bufferFlags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + if (decryptOnlyCodecFormat != null + && (bufferFlags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { // Discard output buffers from the passthrough (raw) decoder containing codec specific data. - codec.releaseOutputBuffer(bufferIndex, false); + checkNotNull(codec).releaseOutputBuffer(bufferIndex, false); return true; } @@ -641,7 +608,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media if (codec != null) { codec.releaseOutputBuffer(bufferIndex, false); } - decoderCounters.skippedOutputBufferCount++; + decoderCounters.skippedOutputBufferCount += sampleCount; audioSink.handleDiscontinuity(); return true; } @@ -650,15 +617,14 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media try { fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount); } catch (AudioSink.InitializationException | AudioSink.WriteException e) { - // TODO(internal: b/145658993) Use outputFormat instead. - throw createRendererException(e, inputFormat); + throw createRendererException(e, format); } if (fullyConsumed) { if (codec != null) { codec.releaseOutputBuffer(bufferIndex, false); } - decoderCounters.renderedOutputBufferCount++; + decoderCounters.renderedOutputBufferCount += sampleCount; return true; } @@ -670,8 +636,8 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media try { audioSink.playToEndOfStream(); } catch (AudioSink.WriteException e) { - // TODO(internal: b/145658993) Use outputFormat instead. - throw createRendererException(e, inputFormat); + @Nullable Format outputFormat = getOutputFormat(); + throw createRendererException(e, outputFormat != null ? outputFormat : getInputFormat()); } } @@ -695,6 +661,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media case MSG_SET_AUDIO_SESSION_ID: audioSink.setAudioSessionId((Integer) message); break; + case MSG_SET_WAKEUP_LISTENER: + this.wakeupListener = (WakeupListener) message; + break; default: super.handleMessage(messageType, message); break; @@ -721,7 +690,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media for (Format streamFormat : streamFormats) { if (codecInfo.isSeamlessAdaptationSupported( format, streamFormat, /* isNewFormatComplete= */ false)) { - maxInputSize = Math.max(maxInputSize, getCodecMaxInputSize(codecInfo, streamFormat)); + maxInputSize = max(maxInputSize, getCodecMaxInputSize(codecInfo, streamFormat)); } } return maxInputSize; @@ -782,6 +751,12 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media // not sync frames. Set a format key to override this. mediaFormat.setInteger("ac4-is-sync", 1); } + if (Util.SDK_INT >= 24 + && audioSink.getFormatSupport( + Util.getPcmFormat(C.ENCODING_PCM_FLOAT, format.channelCount, format.sampleRate)) + == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY) { + mediaFormat.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_FLOAT); + } return mediaFormat; } @@ -791,7 +766,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media currentPositionUs = allowPositionDiscontinuity ? newCurrentPositionUs - : Math.max(currentPositionUs, newCurrentPositionUs); + : max(currentPositionUs, newCurrentPositionUs); allowPositionDiscontinuity = false; } } @@ -810,15 +785,17 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media /** * Returns whether the decoder is known to output six audio channels when provided with input with * fewer than six channels. - *

    - * See [Internal: b/35655036]. + * + *

    See [Internal: b/35655036]. */ private static boolean codecNeedsDiscardChannelsWorkaround(String codecName) { // The workaround applies to Samsung Galaxy S6 and Samsung Galaxy S7. - return Util.SDK_INT < 24 && "OMX.SEC.aac.dec".equals(codecName) + return Util.SDK_INT < 24 + && "OMX.SEC.aac.dec".equals(codecName) && "samsung".equals(Util.MANUFACTURER) - && (Util.DEVICE.startsWith("zeroflte") || Util.DEVICE.startsWith("herolte") - || Util.DEVICE.startsWith("heroqlte")); + && (Util.DEVICE.startsWith("zeroflte") + || Util.DEVICE.startsWith("herolte") + || Util.DEVICE.startsWith("heroqlte")); } /** @@ -839,15 +816,6 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media || Util.DEVICE.startsWith("ms01")); } - @C.Encoding - private static int getPcmEncoding(Format format) { - // If the format is anything other than PCM then we assume that the audio decoder will output - // 16-bit PCM. - return MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) - ? format.pcmEncoding - : C.ENCODING_PCM_16BIT; - } - private final class AudioSinkListener implements AudioSink.Listener { @Override @@ -858,21 +826,36 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override public void onPositionDiscontinuity() { - onAudioTrackPositionDiscontinuity(); - // We are out of sync so allow currentPositionUs to jump backwards. - MediaCodecAudioRenderer.this.allowPositionDiscontinuity = true; + MediaCodecAudioRenderer.this.onPositionDiscontinuity(); + } + + @Override + public void onPositionAdvancing(long playoutStartSystemTimeMs) { + eventDispatcher.positionAdvancing(playoutStartSystemTimeMs); } @Override public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); - onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + eventDispatcher.underrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); } @Override public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { eventDispatcher.skipSilenceEnabledChanged(skipSilenceEnabled); - onAudioTrackSkipSilenceEnabledChanged(skipSilenceEnabled); + } + + @Override + public void onOffloadBufferEmptying() { + if (wakeupListener != null) { + wakeupListener.onWakeup(); + } + } + + @Override + public void onOffloadBufferFull(long bufferEmptyingDeadlineMs) { + if (wakeupListener != null) { + wakeupListener.onSleep(bufferEmptyingDeadlineMs); + } } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java index 883f5bcb92..a4d2a1b67a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.audio; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; /** @@ -115,9 +116,13 @@ import java.nio.ByteBuffer; // 32 bit floating point -> 16 bit resampling. Floating point values are in the range // [-1.0, 1.0], so need to be scaled by Short.MAX_VALUE. for (int i = position; i < limit; i += 4) { - short value = (short) (inputBuffer.getFloat(i) * Short.MAX_VALUE); - buffer.put((byte) (value & 0xFF)); - buffer.put((byte) ((value >> 8) & 0xFF)); + // Clamp to avoid integer overflow if the floating point values exceed their nominal range + // [Internal ref: b/161204847]. + float floatValue = + Util.constrainValue(inputBuffer.getFloat(i), /* min= */ -1, /* max= */ 1); + short shortValue = (short) (floatValue * Short.MAX_VALUE); + buffer.put((byte) (shortValue & 0xFF)); + buffer.put((byte) ((shortValue >> 8) & 0xFF)); } break; case C.ENCODING_PCM_16BIT: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java index 454007194f..c571ce7500 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.audio; +import static java.lang.Math.min; + import androidx.annotation.IntDef; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; @@ -23,7 +25,6 @@ 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 @@ -32,17 +33,20 @@ import java.nio.ByteOrder; public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { /** - * The minimum duration of audio that must be below {@link #SILENCE_THRESHOLD_LEVEL} to classify - * that part of audio as silent, in microseconds. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * minimumSilenceDurationUs}. */ - private static final long MINIMUM_SILENCE_DURATION_US = 150_000; + public static final long DEFAULT_MINIMUM_SILENCE_DURATION_US = 150_000; /** - * The duration of silence by which to extend non-silent sections, in microseconds. The value must - * not exceed {@link #MINIMUM_SILENCE_DURATION_US}. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * paddingSilenceUs}. */ - private static final long PADDING_SILENCE_US = 20_000; - /** The absolute level below which an individual PCM sample is classified as silent. */ - private static final short SILENCE_THRESHOLD_LEVEL = 1024; + public static final long DEFAULT_PADDING_SILENCE_US = 20_000; + /** + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * silenceThresholdLevel}. + */ + public static final short DEFAULT_SILENCE_THRESHOLD_LEVEL = 1024; /** Trimming states. */ @Documented @@ -60,8 +64,10 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { /** State when the input is silent. */ private static final int STATE_SILENT = 2; + private final long minimumSilenceDurationUs; + private final long paddingSilenceUs; + private final short silenceThresholdLevel; private int bytesPerFrame; - private boolean enabled; /** @@ -83,8 +89,31 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { private boolean hasOutputNoise; private long skippedFrames; - /** Creates a new silence trimming audio processor. */ + /** Creates a new silence skipping audio processor. */ public SilenceSkippingAudioProcessor() { + this( + DEFAULT_MINIMUM_SILENCE_DURATION_US, + DEFAULT_PADDING_SILENCE_US, + DEFAULT_SILENCE_THRESHOLD_LEVEL); + } + + /** + * Creates a new silence skipping audio processor. + * + * @param minimumSilenceDurationUs The minimum duration of audio that must be below {@code + * silenceThresholdLevel} to classify that part of audio as silent, in microseconds. + * @param paddingSilenceUs The duration of silence by which to extend non-silent sections, in + * microseconds. The value must not exceed {@code minimumSilenceDurationUs}. + * @param silenceThresholdLevel The absolute level below which an individual PCM sample is + * classified as silent. + */ + public SilenceSkippingAudioProcessor( + long minimumSilenceDurationUs, long paddingSilenceUs, short silenceThresholdLevel) { + Assertions.checkArgument(paddingSilenceUs <= minimumSilenceDurationUs); + this.minimumSilenceDurationUs = minimumSilenceDurationUs; + this.paddingSilenceUs = paddingSilenceUs; + this.silenceThresholdLevel = silenceThresholdLevel; + maybeSilenceBuffer = Util.EMPTY_BYTE_ARRAY; paddingBuffer = Util.EMPTY_BYTE_ARRAY; } @@ -158,11 +187,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]; } @@ -191,7 +220,7 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { int limit = inputBuffer.limit(); // Check if there's any noise within the maybe silence buffer duration. - inputBuffer.limit(Math.min(limit, inputBuffer.position() + maybeSilenceBuffer.length)); + inputBuffer.limit(min(limit, inputBuffer.position() + maybeSilenceBuffer.length)); int noiseLimit = findNoiseLimit(inputBuffer); if (noiseLimit == inputBuffer.position()) { // The buffer contains the start of possible silence. @@ -221,7 +250,7 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { state = STATE_NOISY; } else { // Fill as much of the maybe silence buffer as possible. - int bytesToWrite = Math.min(maybeSilenceInputSize, maybeSilenceBufferRemaining); + int bytesToWrite = min(maybeSilenceInputSize, maybeSilenceBufferRemaining); inputBuffer.limit(inputBuffer.position() + bytesToWrite); inputBuffer.get(maybeSilenceBuffer, maybeSilenceBufferSize, bytesToWrite); maybeSilenceBufferSize += bytesToWrite; @@ -293,7 +322,7 @@ public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { * position. */ private void updatePaddingBuffer(ByteBuffer input, byte[] buffer, int size) { - int fromInputSize = Math.min(input.remaining(), paddingSize); + int fromInputSize = min(input.remaining(), paddingSize); int fromBufferSize = paddingSize - fromInputSize; System.arraycopy( /* src= */ buffer, @@ -317,10 +346,9 @@ 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(); i < buffer.limit(); i += 2) { - if (Math.abs(buffer.getShort(i)) > SILENCE_THRESHOLD_LEVEL) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Round to the start of the frame. return bytesPerFrame * (i / bytesPerFrame); } @@ -333,10 +361,9 @@ 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() - 2; i >= buffer.position(); i -= 2) { - if (Math.abs(buffer.getShort(i)) > SILENCE_THRESHOLD_LEVEL) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Return the start of the next frame. return bytesPerFrame * (i / bytesPerFrame) + bytesPerFrame; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/Sonic.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/Sonic.java index 50e424003d..ae65eacd13 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/Sonic.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/Sonic.java @@ -16,6 +16,8 @@ */ package com.google.android.exoplayer2.audio; +import static java.lang.Math.min; + import com.google.android.exoplayer2.util.Assertions; import java.nio.ShortBuffer; import java.util.Arrays; @@ -35,6 +37,7 @@ import java.util.Arrays; private final int inputSampleRateHz; private final int channelCount; private final float speed; + private final float pitch; private final float rate; private final int minPeriod; private final int maxPeriod; @@ -61,12 +64,15 @@ import java.util.Arrays; * @param inputSampleRateHz The sample rate of input audio, in hertz. * @param channelCount The number of channels in the input audio. * @param speed The speedup factor for output audio. + * @param pitch The pitch factor for output audio. * @param outputSampleRateHz The sample rate for output audio, in hertz. */ - public Sonic(int inputSampleRateHz, int channelCount, float speed, int outputSampleRateHz) { + public Sonic( + int inputSampleRateHz, int channelCount, float speed, float pitch, int outputSampleRateHz) { this.inputSampleRateHz = inputSampleRateHz; this.channelCount = channelCount; this.speed = speed; + this.pitch = pitch; rate = (float) inputSampleRateHz / outputSampleRateHz; minPeriod = inputSampleRateHz / MAXIMUM_PITCH; maxPeriod = inputSampleRateHz / MINIMUM_PITCH; @@ -99,7 +105,7 @@ import java.util.Arrays; * @param buffer A {@link ShortBuffer} into which output will be written. */ public void getOutput(ShortBuffer buffer) { - int framesToRead = Math.min(buffer.remaining() / channelCount, outputFrameCount); + int framesToRead = min(buffer.remaining() / channelCount, outputFrameCount); buffer.put(outputBuffer, 0, framesToRead * channelCount); outputFrameCount -= framesToRead; System.arraycopy( @@ -116,8 +122,10 @@ import java.util.Arrays; */ public void queueEndOfStream() { int remainingFrameCount = inputFrameCount; + float s = speed / pitch; + float r = rate * pitch; int expectedOutputFrames = - outputFrameCount + (int) ((remainingFrameCount / speed + pitchFrameCount) / rate + 0.5f); + outputFrameCount + (int) ((remainingFrameCount / s + pitchFrameCount) / r + 0.5f); // Add enough silence to flush both input and pitch buffers. inputBuffer = @@ -199,7 +207,7 @@ import java.util.Arrays; } private int copyInputToOutput(int positionFrames) { - int frameCount = Math.min(maxRequiredFrameCount, remainingInputToCopyFrameCount); + int frameCount = min(maxRequiredFrameCount, remainingInputToCopyFrameCount); copyToOutput(inputBuffer, positionFrames, frameCount); remainingInputToCopyFrameCount -= frameCount; return frameCount; @@ -462,14 +470,16 @@ import java.util.Arrays; private void processStreamInput() { // Resample as many pitch periods as we have buffered on the input. int originalOutputFrameCount = outputFrameCount; - if (speed > 1.00001 || speed < 0.99999) { - changeSpeed(speed); + float s = speed / pitch; + float r = rate * pitch; + if (s > 1.00001 || s < 0.99999) { + changeSpeed(s); } else { copyToOutput(inputBuffer, 0, inputFrameCount); inputFrameCount = 0; } - if (rate != 1.0f) { - adjustRate(rate, originalOutputFrameCount); + if (r != 1.0f) { + adjustRate(r, originalOutputFrameCount); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/SonicAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/SonicAudioProcessor.java index 48075bac50..5c3c1db0c7 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/SonicAudioProcessor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/SonicAudioProcessor.java @@ -29,22 +29,10 @@ import java.nio.ShortBuffer; */ public final class SonicAudioProcessor implements AudioProcessor { - /** - * The maximum allowed playback speed in {@link #setSpeed(float)}. - */ - public static final float MAXIMUM_SPEED = 8.0f; - /** - * The minimum allowed playback speed in {@link #setSpeed(float)}. - */ - public static final float MINIMUM_SPEED = 0.1f; - /** - * Indicates that the output sample rate should be the same as the input. - */ + /** Indicates that the output sample rate should be the same as the input. */ public static final int SAMPLE_RATE_NO_CHANGE = -1; - /** - * The threshold below which the difference between two pitch/speed factors is negligible. - */ + /** The threshold below which the difference between two pitch/speed factors is negligible. */ private static final float CLOSE_THRESHOLD = 0.01f; /** @@ -55,6 +43,7 @@ public final class SonicAudioProcessor implements AudioProcessor { private int pendingOutputSampleRate; private float speed; + private float pitch; private AudioFormat pendingInputAudioFormat; private AudioFormat pendingOutputAudioFormat; @@ -70,11 +59,10 @@ public final class SonicAudioProcessor implements AudioProcessor { private long outputBytes; private boolean inputEnded; - /** - * Creates a new Sonic audio processor. - */ + /** Creates a new Sonic audio processor. */ public SonicAudioProcessor() { speed = 1f; + pitch = 1f; pendingInputAudioFormat = AudioFormat.NOT_SET; pendingOutputAudioFormat = AudioFormat.NOT_SET; inputAudioFormat = AudioFormat.NOT_SET; @@ -94,7 +82,6 @@ public final class SonicAudioProcessor implements AudioProcessor { * @return The actual new playback speed. */ public float setSpeed(float speed) { - speed = Util.constrainValue(speed, MINIMUM_SPEED, MAXIMUM_SPEED); if (this.speed != speed) { this.speed = speed; pendingSonicRecreation = true; @@ -102,6 +89,22 @@ public final class SonicAudioProcessor implements AudioProcessor { return speed; } + /** + * Sets the playback pitch. This method may only be called after draining data through the + * processor. The value returned by {@link #isActive()} may change, and the processor must be + * {@link #flush() flushed} before queueing more data. + * + * @param pitch The requested new pitch. + * @return The actual new pitch. + */ + public float setPitch(float pitch) { + if (this.pitch != pitch) { + this.pitch = pitch; + pendingSonicRecreation = true; + } + return pitch; + } + /** * Sets the sample rate for output audio, in Hertz. Pass {@link #SAMPLE_RATE_NO_CHANGE} to output * audio at the same sample rate as the input. After calling this method, call {@link @@ -155,6 +158,7 @@ public final class SonicAudioProcessor implements AudioProcessor { public boolean isActive() { return pendingOutputAudioFormat.sampleRate != Format.NO_VALUE && (Math.abs(speed - 1f) >= CLOSE_THRESHOLD + || Math.abs(pitch - 1f) >= CLOSE_THRESHOLD || pendingOutputAudioFormat.sampleRate != pendingInputAudioFormat.sampleRate); } @@ -215,6 +219,7 @@ public final class SonicAudioProcessor implements AudioProcessor { inputAudioFormat.sampleRate, inputAudioFormat.channelCount, speed, + pitch, outputAudioFormat.sampleRate); } else if (sonic != null) { sonic.flush(); @@ -229,6 +234,7 @@ public final class SonicAudioProcessor implements AudioProcessor { @Override public void reset() { speed = 1f; + pitch = 1f; pendingInputAudioFormat = AudioFormat.NOT_SET; pendingOutputAudioFormat = AudioFormat.NOT_SET; inputAudioFormat = AudioFormat.NOT_SET; 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 a9afa47198..9ea230d38d 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.audio; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Assertions; @@ -198,7 +200,7 @@ public final class TeeAudioProcessor extends BaseAudioProcessor { private void writeBuffer(ByteBuffer buffer) throws IOException { RandomAccessFile randomAccessFile = Assertions.checkNotNull(this.randomAccessFile); while (buffer.hasRemaining()) { - int bytesToWrite = Math.min(buffer.remaining(), scratchBuffer.length); + int bytesToWrite = min(buffer.remaining(), scratchBuffer.length); buffer.get(scratchBuffer, 0, bytesToWrite); randomAccessFile.write(scratchBuffer, 0, bytesToWrite); bytesWritten += bytesToWrite; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java index f630c267e6..ed51726530 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.audio; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; @@ -45,7 +47,7 @@ import java.nio.ByteBuffer; * * @param trimStartFrames The number of audio frames to trim from the start of audio. * @param trimEndFrames The number of audio frames to trim from the end of audio. - * @see AudioSink#configure(int, int, int, int, int[], int, int) + * @see AudioSink#configure(com.google.android.exoplayer2.Format, int, int[]) */ public void setTrimFrameCount(int trimStartFrames, int trimEndFrames) { this.trimStartFrames = trimStartFrames; @@ -86,7 +88,7 @@ import java.nio.ByteBuffer; } // Trim any pending start bytes from the input buffer. - int trimBytes = Math.min(remaining, pendingTrimStartBytes); + int trimBytes = min(remaining, pendingTrimStartBytes); trimmedFrameCount += trimBytes / inputAudioFormat.bytesPerFrame; pendingTrimStartBytes -= trimBytes; inputBuffer.position(position + trimBytes); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java index f1d269ddbf..e69b9576dd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java @@ -17,11 +17,10 @@ package com.google.android.exoplayer2.database; import android.content.ContentValues; import android.database.Cursor; -import android.database.DatabaseUtils; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import androidx.annotation.IntDef; -import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -115,7 +114,7 @@ public final class VersionTable { SQLiteDatabase writableDatabase, @Feature int feature, String instanceUid) throws DatabaseIOException { try { - if (!tableExists(writableDatabase, TABLE_NAME)) { + if (!Util.tableExists(writableDatabase, TABLE_NAME)) { return; } writableDatabase.delete( @@ -140,7 +139,7 @@ public final class VersionTable { public static int getVersion(SQLiteDatabase database, @Feature int feature, String instanceUid) throws DatabaseIOException { try { - if (!tableExists(database, TABLE_NAME)) { + if (!Util.tableExists(database, TABLE_NAME)) { return VERSION_UNSET; } try (Cursor cursor = @@ -163,14 +162,6 @@ public final class VersionTable { } } - @VisibleForTesting - /* package */ static boolean tableExists(SQLiteDatabase readableDatabase, String tableName) { - long count = - DatabaseUtils.queryNumEntries( - readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName}); - return count > 0; - } - private static String[] featureAndInstanceUidArguments(int feature, String instance) { return new String[] {Integer.toString(feature), instance}; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java index 4552d190c3..c94eb2c38a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/Decoder.java @@ -24,7 +24,7 @@ import androidx.annotation.Nullable; * @param The type of buffer output from the decoder. * @param The type of exception thrown from the decoder. */ -public interface Decoder { +public interface Decoder { /** * Returns the name of the decoder. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderCounters.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderCounters.java index 5de4fcb126..698e329be5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderCounters.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderCounters.java @@ -15,12 +15,14 @@ */ package com.google.android.exoplayer2.decoder; +import static java.lang.Math.max; + /** * Maintains decoder event counts, for debugging purposes only. - *

    - * Counters should be written from the playback thread only. Counters may be read from any thread. - * To ensure that the counter values are made visible across threads, users of this class should - * invoke {@link #ensureUpdated()} prior to reading and after writing. + * + *

    Counters should be written from the playback thread only. Counters may be read from any + * thread. To ensure that the counter values are made visible across threads, users of this class + * should invoke {@link #ensureUpdated()} prior to reading and after writing. */ public final class DecoderCounters { @@ -74,19 +76,22 @@ public final class DecoderCounters { */ public int droppedToKeyframeCount; /** - * The sum of video frame processing offset samples in microseconds. + * The sum of the video frame processing offsets in microseconds. * - *

    Video frame processing offset measures how early a video frame was processed by a video - * renderer compared to the player's current position. + *

    The processing offset for a video frame is the difference between the time at which the + * frame became available to render, and the time at which it was scheduled to be rendered. A + * positive value indicates the frame became available early enough, whereas a negative value + * indicates that the frame wasn't available until after the time at which it should have been + * rendered. * - *

    Note: Use {@link #addVideoFrameProcessingOffsetSample(long)} to update this field instead of + *

    Note: Use {@link #addVideoFrameProcessingOffset(long)} to update this field instead of * updating it directly. */ public long totalVideoFrameProcessingOffsetUs; /** - * The number of video frame processing offset samples added. + * The number of video frame processing offsets added. * - *

    Note: Use {@link #addVideoFrameProcessingOffsetSample(long)} to update this field instead of + *

    Note: Use {@link #addVideoFrameProcessingOffset(long)} to update this field instead of * updating it directly. */ public int videoFrameProcessingOffsetCount; @@ -114,28 +119,27 @@ public final class DecoderCounters { renderedOutputBufferCount += other.renderedOutputBufferCount; skippedOutputBufferCount += other.skippedOutputBufferCount; droppedBufferCount += other.droppedBufferCount; - maxConsecutiveDroppedBufferCount = Math.max(maxConsecutiveDroppedBufferCount, - other.maxConsecutiveDroppedBufferCount); + maxConsecutiveDroppedBufferCount = + max(maxConsecutiveDroppedBufferCount, other.maxConsecutiveDroppedBufferCount); droppedToKeyframeCount += other.droppedToKeyframeCount; - - addVideoFrameProcessingOffsetSamples( + addVideoFrameProcessingOffsets( other.totalVideoFrameProcessingOffsetUs, other.videoFrameProcessingOffsetCount); } /** - * Adds a video frame processing offset sample to {@link #totalVideoFrameProcessingOffsetUs} and + * Adds a video frame processing offset to {@link #totalVideoFrameProcessingOffsetUs} and * increases {@link #videoFrameProcessingOffsetCount} by one. * - *

    Convenience method to ensure both fields are updated when adding a sample. + *

    Convenience method to ensure both fields are updated when adding a single offset. * - * @param sampleUs The sample in microseconds. + * @param processingOffsetUs The video frame processing offset in microseconds. */ - public void addVideoFrameProcessingOffsetSample(long sampleUs) { - addVideoFrameProcessingOffsetSamples(sampleUs, /* count= */ 1); + public void addVideoFrameProcessingOffset(long processingOffsetUs) { + addVideoFrameProcessingOffsets(processingOffsetUs, /* count= */ 1); } - private void addVideoFrameProcessingOffsetSamples(long sampleUs, int count) { - totalVideoFrameProcessingOffsetUs += sampleUs; + private void addVideoFrameProcessingOffsets(long totalProcessingOffsetUs, int count) { + totalVideoFrameProcessingOffsetUs += totalProcessingOffsetUs; videoFrameProcessingOffsetCount += count; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java index c07e646f09..0af3313ea3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/DecoderException.java @@ -15,20 +15,36 @@ */ package com.google.android.exoplayer2.decoder; +import androidx.annotation.Nullable; + /** Thrown when a {@link Decoder} error occurs. */ public class DecoderException extends Exception { - /** @param message The detail message for this exception. */ + /** + * Creates an instance. + * + * @param message The detail message for this exception. + */ public DecoderException(String message) { super(message); } /** - * @param message The detail message for this exception. - * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). - * A null value is permitted, and indicates that the cause is nonexistent or unknown. + * Creates an instance. + * + * @param cause The cause of this exception, or {@code null}. */ - public DecoderException(String message, Throwable cause) { + public DecoderException(@Nullable Throwable cause) { + super(cause); + } + + /** + * Creates an instance. + * + * @param message The detail message for this exception. + * @param cause The cause of this exception, or {@code null}. + */ + public DecoderException(String message, @Nullable Throwable cause) { super(message, cause); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java index 8f660c4c24..da69924e03 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/decoder/SimpleDecoder.java @@ -27,7 +27,7 @@ import java.util.ArrayDeque; */ @SuppressWarnings("UngroupedOverloads") public abstract class SimpleDecoder< - I extends DecoderInputBuffer, O extends OutputBuffer, E extends Exception> + I extends DecoderInputBuffer, O extends OutputBuffer, E extends DecoderException> implements Decoder { private final Thread decodeThread; @@ -153,7 +153,6 @@ public abstract class SimpleDecoder< while (!queuedOutputBuffers.isEmpty()) { queuedOutputBuffers.removeFirst().release(); } - exception = null; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java index fa6487587e..bb3ad910f0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.drm; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static java.lang.Math.min; + import android.annotation.SuppressLint; import android.media.NotProvisionedException; import android.os.Handler; @@ -29,11 +32,14 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; import com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Consumer; import com.google.android.exoplayer2.util.CopyOnWriteMultiset; import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.Arrays; @@ -53,8 +59,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** Thrown when an unexpected exception or error is thrown during provisioning or key requests. */ public static final class UnexpectedDrmSessionException extends IOException { - public UnexpectedDrmSessionException(Throwable cause) { - super("Unexpected " + cause.getClass().getSimpleName() + ": " + cause.getMessage(), cause); + public UnexpectedDrmSessionException(@Nullable Throwable cause) { + super(cause); } } @@ -82,15 +88,26 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; void onProvisionCompleted(); } - /** Callback to be notified when the session is released. */ - public interface ReleaseCallback { + /** Callback to be notified when the reference count of this session changes. */ + public interface ReferenceCountListener { /** - * Called immediately after releasing session resources. + * Called when the internal reference count of this session is incremented. * - * @param session The session. + * @param session This session. + * @param newReferenceCount The reference count after being incremented. */ - void onSessionReleased(DefaultDrmSession session); + void onReferenceCountIncremented(DefaultDrmSession session, int newReferenceCount); + + /** + * Called when the internal reference count of this session is decremented. + * + *

    {@code newReferenceCount == 0} indicates this session is in {@link #STATE_RELEASED}. + * + * @param session This session. + * @param newReferenceCount The reference count after being decremented. + */ + void onReferenceCountDecremented(DefaultDrmSession session, int newReferenceCount); } private static final String TAG = "DefaultDrmSession"; @@ -104,12 +121,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final ExoMediaDrm mediaDrm; private final ProvisioningManager provisioningManager; - private final ReleaseCallback releaseCallback; + private final ReferenceCountListener referenceCountListener; private final @DefaultDrmSessionManager.Mode int mode; private final boolean playClearSamplesWithoutKeys; private final boolean isPlaceholderSession; private final HashMap keyRequestParameters; - private final CopyOnWriteMultiset eventDispatchers; + private final CopyOnWriteMultiset eventDispatchers; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; /* package */ final MediaDrmCallback callback; @@ -134,7 +151,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * @param uuid The UUID of the drm scheme. * @param mediaDrm The media DRM. * @param provisioningManager The manager for provisioning. - * @param releaseCallback The {@link ReleaseCallback}. + * @param referenceCountListener The {@link ReferenceCountListener}. * @param schemeDatas DRM scheme datas for this session, or null if an {@code * offlineLicenseKeySetId} is provided or if {@code isPlaceholderSession} is true. * @param mode The DRM mode. Ignored if {@code isPlaceholderSession} is true. @@ -151,7 +168,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; UUID uuid, ExoMediaDrm mediaDrm, ProvisioningManager provisioningManager, - ReleaseCallback releaseCallback, + ReferenceCountListener referenceCountListener, @Nullable List schemeDatas, @DefaultDrmSessionManager.Mode int mode, boolean playClearSamplesWithoutKeys, @@ -167,7 +184,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } this.uuid = uuid; this.provisioningManager = provisioningManager; - this.releaseCallback = releaseCallback; + this.referenceCountListener = referenceCountListener; this.mediaDrm = mediaDrm; this.mode = mode; this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; @@ -257,31 +274,31 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } @Override - public void acquire(@Nullable MediaSourceEventDispatcher eventDispatcher) { - Assertions.checkState(referenceCount >= 0); + public void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { + checkState(referenceCount >= 0); if (eventDispatcher != null) { eventDispatchers.add(eventDispatcher); } if (++referenceCount == 1) { - Assertions.checkState(state == STATE_OPENING); + checkState(state == STATE_OPENING); requestHandlerThread = new HandlerThread("ExoPlayer:DrmRequestHandler"); requestHandlerThread.start(); requestHandler = new RequestHandler(requestHandlerThread.getLooper()); if (openInternal(true)) { doLicense(true); } - } else { + } else if (eventDispatcher != null && isOpen()) { + // If the session is already open then send the acquire event only to the provided dispatcher. // TODO: Add a parameter to onDrmSessionAcquired to indicate whether the session is being // re-used or not. - if (eventDispatcher != null) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmSessionAcquired, DrmSessionEventListener.class); - } + eventDispatcher.drmSessionAcquired(); } + referenceCountListener.onReferenceCountIncremented(this, referenceCount); } @Override - public void release(@Nullable MediaSourceEventDispatcher eventDispatcher) { + public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { + checkState(referenceCount > 0); if (--referenceCount == 0) { // Assigning null to various non-null variables for clean-up. state = STATE_RELEASED; @@ -298,12 +315,17 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; mediaDrm.closeSession(sessionId); sessionId = null; } - releaseCallback.onSessionReleased(this); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmSessionReleased); } - dispatchEvent(DrmSessionEventListener::onDrmSessionReleased); if (eventDispatcher != null) { + if (isOpen()) { + // If the session is still open then send the release event only to the provided dispatcher + // before removing it. + eventDispatcher.drmSessionReleased(); + } eventDispatchers.remove(eventDispatcher); } + referenceCountListener.onReferenceCountDecremented(this, referenceCount); } // Internal methods. @@ -325,7 +347,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; try { sessionId = mediaDrm.openSession(); mediaCrypto = mediaDrm.createMediaCrypto(sessionId); - dispatchEvent(DrmSessionEventListener::onDrmSessionAcquired); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmSessionAcquired); state = STATE_OPENED; Assertions.checkNotNull(sessionId); return true; @@ -389,7 +411,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; onError(new KeysExpiredException()); } else { state = STATE_OPENED_WITH_KEYS; - dispatchEvent(DrmSessionEventListener::onDrmKeysRestored); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysRestored); } } break; @@ -401,8 +423,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; case DefaultDrmSessionManager.MODE_RELEASE: Assertions.checkNotNull(offlineLicenseKeySetId); Assertions.checkNotNull(this.sessionId); - // It's not necessary to restore the key (and open a session to do that) before releasing it - // but this serves as a good sanity/fast-failure check. + // It's not necessary to restore the key before releasing it but this serves as a good + // fast-failure check. if (restoreKeys()) { postKeyRequest(offlineLicenseKeySetId, ExoMediaDrm.KEY_TYPE_RELEASE, allowRetry); } @@ -430,7 +452,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } Pair pair = Assertions.checkNotNull(WidevineUtil.getLicenseDurationRemainingSec(this)); - return Math.min(pair.first, pair.second); + return min(pair.first, pair.second); } private void postKeyRequest(byte[] scope, int type, boolean allowRetry) { @@ -459,7 +481,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; byte[] responseData = (byte[]) response; if (mode == DefaultDrmSessionManager.MODE_RELEASE) { mediaDrm.provideKeyResponse(Util.castNonNull(offlineLicenseKeySetId), responseData); - dispatchEvent(DrmSessionEventListener::onDrmKeysRestored); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysRemoved); } else { byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, responseData); if ((mode == DefaultDrmSessionManager.MODE_DOWNLOAD @@ -470,7 +492,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; offlineLicenseKeySetId = keySetId; } state = STATE_OPENED_WITH_KEYS; - dispatchEvent(DrmSessionEventListener::onDrmKeysLoaded); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysLoaded); } } catch (Exception e) { onKeysError(e); @@ -494,9 +516,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private void onError(final Exception e) { lastException = new DrmSessionException(e); - dispatchEvent( - (listener, windowIndex, mediaPeriodId) -> - listener.onDrmSessionManagerError(windowIndex, mediaPeriodId, e)); + dispatchEvent(eventDispatcher -> eventDispatcher.drmSessionManagerError(e)); if (state != STATE_OPENED_WITH_KEYS) { state = STATE_ERROR; } @@ -508,10 +528,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return state == STATE_OPENED || state == STATE_OPENED_WITH_KEYS; } - private void dispatchEvent( - MediaSourceEventDispatcher.EventWithPeriodId event) { - for (MediaSourceEventDispatcher eventDispatcher : eventDispatchers.elementSet()) { - eventDispatcher.dispatch(event, DrmSessionEventListener.class); + private void dispatchEvent(Consumer event) { + for (DrmSessionEventListener.EventDispatcher eventDispatcher : eventDispatchers.elementSet()) { + event.accept(eventDispatcher); } } @@ -552,7 +571,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; void post(int what, Object request, boolean allowRetry) { RequestTask requestTask = - new RequestTask(allowRetry, /* startTimeMs= */ SystemClock.elapsedRealtime(), request); + new RequestTask( + LoadEventInfo.getNewId(), + allowRetry, + /* startTimeMs= */ SystemClock.elapsedRealtime(), + request); obtainMessage(what, requestTask).sendToTarget(); } @@ -572,18 +595,22 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; default: throw new RuntimeException(); } - } catch (Exception e) { + } catch (MediaDrmCallbackException e) { if (maybeRetryRequest(msg, e)) { return; } response = e; + } catch (Exception e) { + Log.w(TAG, "Key/provisioning request produced an unexpected exception. Not retrying.", e); + response = e; } + loadErrorHandlingPolicy.onLoadTaskConcluded(requestTask.taskId); responseHandler .obtainMessage(msg.what, Pair.create(requestTask.request, response)) .sendToTarget(); } - private boolean maybeRetryRequest(Message originalMsg, Exception e) { + private boolean maybeRetryRequest(Message originalMsg, MediaDrmCallbackException exception) { RequestTask requestTask = (RequestTask) originalMsg.obj; if (!requestTask.allowRetry) { return false; @@ -593,14 +620,24 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; > loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_DRM)) { return false; } - IOException ioException = - e instanceof IOException ? (IOException) e : new UnexpectedDrmSessionException(e); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + requestTask.taskId, + exception.dataSpec, + exception.uriAfterRedirects, + exception.responseHeaders, + SystemClock.elapsedRealtime(), + /* loadDurationMs= */ SystemClock.elapsedRealtime() - requestTask.startTimeMs, + exception.bytesLoaded); + MediaLoadData mediaLoadData = new MediaLoadData(C.DATA_TYPE_DRM); + IOException loadErrorCause = + exception.getCause() instanceof IOException + ? (IOException) exception.getCause() + : new UnexpectedDrmSessionException(exception.getCause()); long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor( - C.DATA_TYPE_DRM, - /* loadDurationMs= */ SystemClock.elapsedRealtime() - requestTask.startTimeMs, - ioException, - requestTask.errorCount); + new LoadErrorInfo( + loadEventInfo, mediaLoadData, loadErrorCause, requestTask.errorCount)); if (retryDelayMs == C.TIME_UNSET) { // The error is fatal. return false; @@ -612,12 +649,14 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private static final class RequestTask { + public final long taskId; public final boolean allowRetry; public final long startTimeMs; public final Object request; public int errorCount; - public RequestTask(boolean allowRetry, long startTimeMs, Object request) { + public RequestTask(long taskId, boolean allowRetry, long startTimeMs, Object request) { + this.taskId = taskId; this.allowRetry = allowRetry; this.startTimeMs = startTimeMs; this.request = request; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index 8335831ce0..be02faeba8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -16,13 +16,16 @@ package com.google.android.exoplayer2.drm; import android.annotation.SuppressLint; +import android.media.ResourceBusyException; import android.os.Handler; import android.os.Looper; import android.os.Message; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; import com.google.android.exoplayer2.drm.ExoMediaDrm.OnEventListener; @@ -30,17 +33,20 @@ import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** A {@link DrmSessionManager} that supports playbacks using {@link ExoMediaDrm}. */ @RequiresApi(18) @@ -60,6 +66,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { private int[] useDrmSessionsForClearContentTrackTypes; private boolean playClearSamplesWithoutKeys; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private long sessionKeepaliveMs; /** * Creates a builder with default values. The default values are: @@ -82,6 +89,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { exoMediaDrmProvider = FrameworkMediaDrm.DEFAULT_PROVIDER; loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); useDrmSessionsForClearContentTrackTypes = new int[0]; + sessionKeepaliveMs = DEFAULT_SESSION_KEEPALIVE_MS; } /** @@ -180,6 +188,27 @@ public class DefaultDrmSessionManager implements DrmSessionManager { return this; } + /** + * Sets the time to keep {@link DrmSession DrmSessions} alive when they're not in use. + * + *

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

    Defaults to {@link #DEFAULT_SESSION_KEEPALIVE_MS}. Pass {@link C#TIME_UNSET} to disable + * keep-alive. + * + * @param sessionKeepaliveMs The time to keep {@link DrmSession}s alive before fully releasing, + * in milliseconds. Must be > 0 or {@link C#TIME_UNSET} to disable keep-alive. + * @return This builder. + */ + public Builder setSessionKeepaliveMs(long sessionKeepaliveMs) { + Assertions.checkArgument(sessionKeepaliveMs > 0 || sessionKeepaliveMs == C.TIME_UNSET); + this.sessionKeepaliveMs = sessionKeepaliveMs; + return this; + } + /** Builds a {@link DefaultDrmSessionManager} instance. */ public DefaultDrmSessionManager build(MediaDrmCallback mediaDrmCallback) { return new DefaultDrmSessionManager( @@ -190,13 +219,14 @@ public class DefaultDrmSessionManager implements DrmSessionManager { multiSession, useDrmSessionsForClearContentTrackTypes, playClearSamplesWithoutKeys, - loadErrorHandlingPolicy); + loadErrorHandlingPolicy, + sessionKeepaliveMs); } } /** - * Signals that the {@link DrmInitData} passed to {@link #acquireSession} does not contain does - * not contain scheme data for the required UUID. + * Signals that the {@link Format#drmInitData} passed to {@link #acquireSession} does not contain + * scheme data for the required UUID. */ public static final class MissingSchemeDataException extends Exception { @@ -232,6 +262,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { public static final int MODE_RELEASE = 3; /** Number of times to retry for initial provisioning and key request for reporting error. */ public static final int INITIAL_DRM_REQUEST_RETRY_COUNT = 3; + /** Default value for {@link Builder#setSessionKeepaliveMs(long)}. */ + public static final long DEFAULT_SESSION_KEEPALIVE_MS = 5 * 60 * C.MILLIS_PER_SECOND; private static final String TAG = "DefaultDrmSessionMgr"; @@ -244,15 +276,19 @@ public class DefaultDrmSessionManager implements DrmSessionManager { private final boolean playClearSamplesWithoutKeys; private final ProvisioningManagerImpl provisioningManagerImpl; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final ReferenceCountListenerImpl referenceCountListener; + private final long sessionKeepaliveMs; private final List sessions; private final List provisioningSessions; + private final Set keepaliveSessions; private int prepareCallsCount; @Nullable private ExoMediaDrm exoMediaDrm; @Nullable private DefaultDrmSession placeholderDrmSession; @Nullable private DefaultDrmSession noMultiSessionDrmSession; @Nullable private Looper playbackLooper; + private @MonotonicNonNull Handler sessionReleasingHandler; private int mode; @Nullable private byte[] offlineLicenseKeySetId; @@ -292,6 +328,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { * Default is false. * @deprecated Use {@link Builder} instead. */ + @SuppressWarnings("deprecation") @Deprecated public DefaultDrmSessionManager( UUID uuid, @@ -336,7 +373,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { multiSession, /* useDrmSessionsForClearContentTrackTypes= */ new int[0], /* playClearSamplesWithoutKeys= */ false, - new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount)); + new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount), + DEFAULT_SESSION_KEEPALIVE_MS); } private DefaultDrmSessionManager( @@ -347,7 +385,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { boolean multiSession, int[] useDrmSessionsForClearContentTrackTypes, boolean playClearSamplesWithoutKeys, - LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + long sessionKeepaliveMs) { Assertions.checkNotNull(uuid); Assertions.checkArgument(!C.COMMON_PSSH_UUID.equals(uuid), "Use C.CLEARKEY_UUID instead"); this.uuid = uuid; @@ -359,15 +398,18 @@ public class DefaultDrmSessionManager implements DrmSessionManager { this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; provisioningManagerImpl = new ProvisioningManagerImpl(); + referenceCountListener = new ReferenceCountListenerImpl(); mode = MODE_PLAYBACK; sessions = new ArrayList<>(); provisioningSessions = new ArrayList<>(); + keepaliveSessions = Sets.newIdentityHashSet(); + this.sessionKeepaliveMs = sessionKeepaliveMs; } /** * Sets the mode, which determines the role of sessions acquired from the instance. This must be - * called before {@link #acquireSession(Looper, MediaSourceEventDispatcher, DrmInitData)} or - * {@link #acquirePlaceholderSession} is called. + * called before {@link #acquireSession(Looper, DrmSessionEventListener.EventDispatcher, Format)} + * is called. * *

    By default, the mode is {@link #MODE_PLAYBACK} and a streaming license is requested when * required. @@ -375,14 +417,14 @@ public class DefaultDrmSessionManager implements DrmSessionManager { *

    {@code mode} must be one of these: * *

      - *
    • {@link #MODE_PLAYBACK}: If {@code offlineLicenseKeySetId} is null, a streaming license is - * requested otherwise the offline license is restored. - *
    • {@link #MODE_QUERY}: {@code offlineLicenseKeySetId} can not be null. The offline license - * is restored. - *
    • {@link #MODE_DOWNLOAD}: If {@code offlineLicenseKeySetId} is null, an offline license is - * requested otherwise the offline license is renewed. - *
    • {@link #MODE_RELEASE}: {@code offlineLicenseKeySetId} can not be null. The offline - * license is released. + *
    • {@link #MODE_PLAYBACK}: If {@code offlineLicenseKeySetId} is null then a streaming + * license is requested. Otherwise, the offline license is restored. + *
    • {@link #MODE_QUERY}: {@code offlineLicenseKeySetId} cannot be null. The offline license + * is restored to allow its status to be queried. + *
    • {@link #MODE_DOWNLOAD}: If {@code offlineLicenseKeySetId} is null then an offline license + * is requested. Otherwise, the offline license is renewed. + *
    • {@link #MODE_RELEASE}: {@code offlineLicenseKeySetId} cannot be null. The offline license + * is released. *
    * * @param mode The mode to be set. @@ -401,97 +443,51 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public final void prepare() { - if (prepareCallsCount++ == 0) { - Assertions.checkState(exoMediaDrm == null); - exoMediaDrm = exoMediaDrmProvider.acquireExoMediaDrm(uuid); - exoMediaDrm.setOnEventListener(new MediaDrmEventListener()); + if (prepareCallsCount++ != 0) { + return; } + Assertions.checkState(exoMediaDrm == null); + exoMediaDrm = exoMediaDrmProvider.acquireExoMediaDrm(uuid); + exoMediaDrm.setOnEventListener(new MediaDrmEventListener()); } @Override public final void release() { - if (--prepareCallsCount == 0) { - Assertions.checkNotNull(exoMediaDrm).release(); - exoMediaDrm = null; + if (--prepareCallsCount != 0) { + return; } - } - - @Override - public boolean canAcquireSession(DrmInitData drmInitData) { - if (offlineLicenseKeySetId != null) { - // An offline license can be restored so a session can always be acquired. - return true; + // Make a local copy, because sessions are removed from this.sessions during release (via + // callback). + List sessions = new ArrayList<>(this.sessions); + for (int i = 0; i < sessions.size(); i++) { + // Release all the keepalive acquisitions. + sessions.get(i).release(/* eventDispatcher= */ null); } - List schemeDatas = getSchemeDatas(drmInitData, uuid, true); - if (schemeDatas.isEmpty()) { - if (drmInitData.schemeDataCount == 1 && drmInitData.get(0).matches(C.COMMON_PSSH_UUID)) { - // Assume scheme specific data will be added before the session is opened. - Log.w( - TAG, "DrmInitData only contains common PSSH SchemeData. Assuming support for: " + uuid); - } else { - // No data for this manager's scheme. - return false; - } - } - String schemeType = drmInitData.schemeType; - if (schemeType == null || C.CENC_TYPE_cenc.equals(schemeType)) { - // If there is no scheme information, assume patternless AES-CTR. - return true; - } else if (C.CENC_TYPE_cbc1.equals(schemeType) - || C.CENC_TYPE_cbcs.equals(schemeType) - || C.CENC_TYPE_cens.equals(schemeType)) { - // API support for AES-CBC and pattern encryption was added in API 24. However, the - // implementation was not stable until API 25. - return Util.SDK_INT >= 25; - } - // Unknown schemes, assume one of them is supported. - return true; + Assertions.checkNotNull(exoMediaDrm).release(); + exoMediaDrm = null; } @Override @Nullable - public DrmSession acquirePlaceholderSession(Looper playbackLooper, int trackType) { - assertExpectedPlaybackLooper(playbackLooper); - ExoMediaDrm exoMediaDrm = Assertions.checkNotNull(this.exoMediaDrm); - boolean avoidPlaceholderDrmSessions = - FrameworkMediaCrypto.class.equals(exoMediaDrm.getExoMediaCryptoType()) - && FrameworkMediaCrypto.WORKAROUND_DEVICE_NEEDS_KEYS_TO_CONFIGURE_CODEC; - // Avoid attaching a session to sparse formats. - if (avoidPlaceholderDrmSessions - || Util.linearSearch(useDrmSessionsForClearContentTrackTypes, trackType) == C.INDEX_UNSET - || exoMediaDrm.getExoMediaCryptoType() == null) { - return null; - } - maybeCreateMediaDrmHandler(playbackLooper); - if (placeholderDrmSession == null) { - DefaultDrmSession placeholderDrmSession = - createNewDefaultSession( - /* schemeDatas= */ Collections.emptyList(), /* isPlaceholderSession= */ true); - sessions.add(placeholderDrmSession); - this.placeholderDrmSession = placeholderDrmSession; - } - placeholderDrmSession.acquire(/* eventDispatcher= */ null); - return placeholderDrmSession; - } - - @Override public DrmSession acquireSession( Looper playbackLooper, - @Nullable MediaSourceEventDispatcher eventDispatcher, - DrmInitData drmInitData) { - assertExpectedPlaybackLooper(playbackLooper); + @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, + Format format) { + initPlaybackLooper(playbackLooper); maybeCreateMediaDrmHandler(playbackLooper); + if (format.drmInitData == null) { + // Content is not encrypted. + return maybeAcquirePlaceholderSession(MimeTypes.getTrackType(format.sampleMimeType)); + } + @Nullable List schemeDatas = null; if (offlineLicenseKeySetId == null) { - schemeDatas = getSchemeDatas(drmInitData, uuid, false); + schemeDatas = getSchemeDatas(Assertions.checkNotNull(format.drmInitData), uuid, false); if (schemeDatas.isEmpty()) { final MissingSchemeDataException error = new MissingSchemeDataException(uuid); if (eventDispatcher != null) { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onDrmSessionManagerError(windowIndex, mediaPeriodId, error), - DrmSessionEventListener.class); + eventDispatcher.drmSessionManagerError(error); } return new ErrorStateDrmSession(new DrmSessionException(error)); } @@ -513,29 +509,107 @@ public class DefaultDrmSessionManager implements DrmSessionManager { if (session == null) { // Create a new session. - session = createNewDefaultSession(schemeDatas, /* isPlaceholderSession= */ false); + session = + createAndAcquireSessionWithRetry( + schemeDatas, /* isPlaceholderSession= */ false, eventDispatcher); if (!multiSession) { noMultiSessionDrmSession = session; } sessions.add(session); + } else { + session.acquire(eventDispatcher); } - session.acquire(eventDispatcher); + return session; } @Override @Nullable - public Class getExoMediaCryptoType(DrmInitData drmInitData) { - return canAcquireSession(drmInitData) - ? Assertions.checkNotNull(exoMediaDrm).getExoMediaCryptoType() - : null; + public Class getExoMediaCryptoType(Format format) { + Class exoMediaCryptoType = + Assertions.checkNotNull(exoMediaDrm).getExoMediaCryptoType(); + if (format.drmInitData == null) { + int trackType = MimeTypes.getTrackType(format.sampleMimeType); + return Util.linearSearch(useDrmSessionsForClearContentTrackTypes, trackType) != C.INDEX_UNSET + ? exoMediaCryptoType + : null; + } else { + return canAcquireSession(format.drmInitData) + ? exoMediaCryptoType + : UnsupportedMediaCrypto.class; + } } // Internal methods. - private void assertExpectedPlaybackLooper(Looper playbackLooper) { - Assertions.checkState(this.playbackLooper == null || this.playbackLooper == playbackLooper); - this.playbackLooper = playbackLooper; + @Nullable + private DrmSession maybeAcquirePlaceholderSession(int trackType) { + ExoMediaDrm exoMediaDrm = Assertions.checkNotNull(this.exoMediaDrm); + boolean avoidPlaceholderDrmSessions = + FrameworkMediaCrypto.class.equals(exoMediaDrm.getExoMediaCryptoType()) + && FrameworkMediaCrypto.WORKAROUND_DEVICE_NEEDS_KEYS_TO_CONFIGURE_CODEC; + // Avoid attaching a session to sparse formats. + if (avoidPlaceholderDrmSessions + || Util.linearSearch(useDrmSessionsForClearContentTrackTypes, trackType) == C.INDEX_UNSET + || UnsupportedMediaCrypto.class.equals(exoMediaDrm.getExoMediaCryptoType())) { + return null; + } + if (placeholderDrmSession == null) { + DefaultDrmSession placeholderDrmSession = + createAndAcquireSessionWithRetry( + /* schemeDatas= */ ImmutableList.of(), + /* isPlaceholderSession= */ true, + /* eventDispatcher= */ null); + sessions.add(placeholderDrmSession); + this.placeholderDrmSession = placeholderDrmSession; + } else { + placeholderDrmSession.acquire(/* eventDispatcher= */ null); + } + return placeholderDrmSession; + } + + private boolean canAcquireSession(DrmInitData drmInitData) { + if (offlineLicenseKeySetId != null) { + // An offline license can be restored so a session can always be acquired. + return true; + } + List schemeDatas = getSchemeDatas(drmInitData, uuid, true); + if (schemeDatas.isEmpty()) { + if (drmInitData.schemeDataCount == 1 && drmInitData.get(0).matches(C.COMMON_PSSH_UUID)) { + // Assume scheme specific data will be added before the session is opened. + Log.w( + TAG, "DrmInitData only contains common PSSH SchemeData. Assuming support for: " + uuid); + } else { + // No data for this manager's scheme. + return false; + } + } + String schemeType = drmInitData.schemeType; + if (schemeType == null || C.CENC_TYPE_cenc.equals(schemeType)) { + // If there is no scheme information, assume patternless AES-CTR. + return true; + } else if (C.CENC_TYPE_cbcs.equals(schemeType)) { + // Support for cbcs (AES-CBC with pattern encryption) was added in API 24. However, the + // implementation was not stable until API 25. + return Util.SDK_INT >= 25; + } else if (C.CENC_TYPE_cbc1.equals(schemeType) || C.CENC_TYPE_cens.equals(schemeType)) { + // Support for cbc1 (AES-CTR with pattern encryption) and cens (AES-CBC without pattern + // encryption) was also added in API 24 and made stable from API 25, however support was + // removed from API 30. Since the range of API levels for which these modes are usable is too + // small to be useful, we don't indicate support on any API level. + return false; + } + // Unknown schemes, assume one of them is supported. + return true; + } + + private void initPlaybackLooper(Looper playbackLooper) { + if (this.playbackLooper == null) { + this.playbackLooper = playbackLooper; + this.sessionReleasingHandler = new Handler(playbackLooper); + } else { + Assertions.checkState(this.playbackLooper == playbackLooper); + } } private void maybeCreateMediaDrmHandler(Looper playbackLooper) { @@ -544,41 +618,77 @@ public class DefaultDrmSessionManager implements DrmSessionManager { } } - private DefaultDrmSession createNewDefaultSession( - @Nullable List schemeDatas, boolean isPlaceholderSession) { + private DefaultDrmSession createAndAcquireSessionWithRetry( + @Nullable List schemeDatas, + boolean isPlaceholderSession, + @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { + DefaultDrmSession session = + createAndAcquireSession(schemeDatas, isPlaceholderSession, eventDispatcher); + if (session.getState() == DrmSession.STATE_ERROR + && (Util.SDK_INT < 19 + || Assertions.checkNotNull(session.getError()).getCause() + instanceof ResourceBusyException)) { + // We're short on DRM session resources, so eagerly release all our keepalive sessions. + // ResourceBusyException is only available at API 19, so on earlier versions we always + // eagerly release regardless of the underlying error. + if (!keepaliveSessions.isEmpty()) { + // Make a local copy, because sessions are removed from this.timingOutSessions during + // release (via callback). + ImmutableList timingOutSessions = + ImmutableList.copyOf(this.keepaliveSessions); + for (DrmSession timingOutSession : timingOutSessions) { + timingOutSession.release(/* eventDispatcher= */ null); + } + // Undo the acquisitions from createAndAcquireSession(). + session.release(eventDispatcher); + if (sessionKeepaliveMs != C.TIME_UNSET) { + session.release(/* eventDispatcher= */ null); + } + session = createAndAcquireSession(schemeDatas, isPlaceholderSession, eventDispatcher); + } + } + return session; + } + + /** + * Creates a new {@link DefaultDrmSession} and acquires it on behalf of the caller (passing in + * {@code eventDispatcher}). + * + *

    If {@link #sessionKeepaliveMs} != {@link C#TIME_UNSET} then acquires it again to allow the + * manager to keep it alive (passing in {@code eventDispatcher=null}. + */ + private DefaultDrmSession createAndAcquireSession( + @Nullable List schemeDatas, + boolean isPlaceholderSession, + @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { Assertions.checkNotNull(exoMediaDrm); // Placeholder sessions should always play clear samples without keys. boolean playClearSamplesWithoutKeys = this.playClearSamplesWithoutKeys | isPlaceholderSession; - return new DefaultDrmSession( - uuid, - exoMediaDrm, - /* provisioningManager= */ provisioningManagerImpl, - /* releaseCallback= */ this::onSessionReleased, - schemeDatas, - mode, - playClearSamplesWithoutKeys, - isPlaceholderSession, - offlineLicenseKeySetId, - keyRequestParameters, - callback, - Assertions.checkNotNull(playbackLooper), - loadErrorHandlingPolicy); - } - - private void onSessionReleased(DefaultDrmSession drmSession) { - sessions.remove(drmSession); - if (placeholderDrmSession == drmSession) { - placeholderDrmSession = null; + DefaultDrmSession session = + new DefaultDrmSession( + uuid, + exoMediaDrm, + /* provisioningManager= */ provisioningManagerImpl, + referenceCountListener, + schemeDatas, + mode, + playClearSamplesWithoutKeys, + isPlaceholderSession, + offlineLicenseKeySetId, + keyRequestParameters, + callback, + Assertions.checkNotNull(playbackLooper), + loadErrorHandlingPolicy); + // Acquire the session once on behalf of the caller to DrmSessionManager - this is the + // reference 'assigned' to the caller which they're responsible for releasing. Do this first, + // to ensure that eventDispatcher receives all events related to the initial + // acquisition/opening. + session.acquire(eventDispatcher); + if (sessionKeepaliveMs != C.TIME_UNSET) { + // Acquire the session once more so the Manager can keep it alive. + session.acquire(/* eventDispatcher= */ null); } - if (noMultiSessionDrmSession == drmSession) { - noMultiSessionDrmSession = null; - } - if (provisioningSessions.size() > 1 && provisioningSessions.get(0) == drmSession) { - // Other sessions were waiting for the released session to complete a provision operation. - // We need to have one of those sessions perform the provision operation instead. - provisioningSessions.get(1).provision(); - } - provisioningSessions.remove(drmSession); + return session; } /** @@ -661,6 +771,50 @@ public class DefaultDrmSessionManager implements DrmSessionManager { } } + private class ReferenceCountListenerImpl implements DefaultDrmSession.ReferenceCountListener { + + @Override + public void onReferenceCountIncremented(DefaultDrmSession session, int newReferenceCount) { + if (sessionKeepaliveMs != C.TIME_UNSET) { + // The session has been acquired elsewhere so we want to cancel our timeout. + keepaliveSessions.remove(session); + Assertions.checkNotNull(sessionReleasingHandler).removeCallbacksAndMessages(session); + } + } + + @Override + public void onReferenceCountDecremented(DefaultDrmSession session, int newReferenceCount) { + if (newReferenceCount == 1 && sessionKeepaliveMs != C.TIME_UNSET) { + // Only the internal keep-alive reference remains, so we can start the timeout. + keepaliveSessions.add(session); + Assertions.checkNotNull(sessionReleasingHandler) + .postAtTime( + () -> session.release(/* eventDispatcher= */ null), + session, + /* uptimeMillis= */ SystemClock.uptimeMillis() + sessionKeepaliveMs); + } else if (newReferenceCount == 0) { + // This session is fully released. + sessions.remove(session); + if (placeholderDrmSession == session) { + placeholderDrmSession = null; + } + if (noMultiSessionDrmSession == session) { + noMultiSessionDrmSession = null; + } + if (provisioningSessions.size() > 1 && provisioningSessions.get(0) == session) { + // Other sessions were waiting for the released session to complete a provision operation. + // We need to have one of those sessions perform the provision operation instead. + provisioningSessions.get(1).provision(); + } + provisioningSessions.remove(session); + if (sessionKeepaliveMs != C.TIME_UNSET) { + Assertions.checkNotNull(sessionReleasingHandler).removeCallbacksAndMessages(session); + keepaliveSessions.remove(session); + } + } + } + } + private class MediaDrmEventListener implements OnEventListener { @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java index 3f2aae7b30..97bb4b3dd1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSession.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.drm; import android.media.MediaDrm; import androidx.annotation.IntDef; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -31,10 +30,10 @@ public interface DrmSession { /** * Acquires {@code newSession} then releases {@code previousSession}. * - *

    Invokes {@code newSession's} {@link #acquire(MediaSourceEventDispatcher)} and {@code - * previousSession's} {@link #release(MediaSourceEventDispatcher)} in that order (passing {@code - * eventDispatcher = null}). Null arguments are ignored. Does nothing if {@code previousSession} - * and {@code newSession} are the same session. + *

    Invokes {@code newSession's} {@link #acquire(DrmSessionEventListener.EventDispatcher)} and + * {@code previousSession's} {@link #release(DrmSessionEventListener.EventDispatcher)} in that + * order (passing {@code eventDispatcher = null}). Null arguments are ignored. Does nothing if + * {@code previousSession} and {@code newSession} are the same session. */ static void replaceSession( @Nullable DrmSession previousSession, @Nullable DrmSession newSession) { @@ -134,20 +133,21 @@ public interface DrmSession { /** * Increments the reference count. When the caller no longer needs to use the instance, it must - * call {@link #release(MediaSourceEventDispatcher)} to decrement the reference count. + * call {@link #release(DrmSessionEventListener.EventDispatcher)} to decrement the reference + * count. * - * @param eventDispatcher The {@link MediaSourceEventDispatcher} used to route DRM-related events - * dispatched from this session, or null if no event handling is needed. + * @param eventDispatcher The {@link DrmSessionEventListener.EventDispatcher} used to route + * DRM-related events dispatched from this session, or null if no event handling is needed. */ - void acquire(@Nullable MediaSourceEventDispatcher eventDispatcher); + void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher); /** * Decrements the reference count. If the reference count drops to 0 underlying resources are * released, and the instance cannot be re-used. * - * @param eventDispatcher The {@link MediaSourceEventDispatcher} to disconnect when the session is - * released (the same instance (possibly null) that was passed by the caller to {@link - * #acquire(MediaSourceEventDispatcher)}). + * @param eventDispatcher The {@link DrmSessionEventListener.EventDispatcher} to disconnect when + * the session is released (the same instance (possibly null) that was passed by the caller to + * {@link #acquire(DrmSessionEventListener.EventDispatcher)}). */ - void release(@Nullable MediaSourceEventDispatcher eventDispatcher); + void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java index b4482408b1..0720d9677f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java @@ -15,9 +15,15 @@ */ package com.google.android.exoplayer2.drm; +import static com.google.android.exoplayer2.util.Util.postOrRun; + +import android.os.Handler; +import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.util.Assertions; +import java.util.concurrent.CopyOnWriteArrayList; /** Listener of {@link DrmSessionManager} events. */ public interface DrmSessionEventListener { @@ -78,4 +84,139 @@ public interface DrmSessionEventListener { * @param mediaPeriodId The {@link MediaPeriodId} associated with the drm session. */ default void onDrmSessionReleased(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {} + + /** Dispatches events to {@link DrmSessionEventListener DrmSessionEventListeners}. */ + class EventDispatcher { + + /** The timeline window index reported with the events. */ + public final int windowIndex; + /** The {@link MediaPeriodId} reported with the events. */ + @Nullable public final MediaPeriodId mediaPeriodId; + + private final CopyOnWriteArrayList listenerAndHandlers; + + /** Creates an event dispatcher. */ + public EventDispatcher() { + this( + /* listenerAndHandlers= */ new CopyOnWriteArrayList<>(), + /* windowIndex= */ 0, + /* mediaPeriodId= */ null); + } + + private EventDispatcher( + CopyOnWriteArrayList listenerAndHandlers, + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId) { + this.listenerAndHandlers = listenerAndHandlers; + this.windowIndex = windowIndex; + this.mediaPeriodId = mediaPeriodId; + } + + /** + * Creates a view of the event dispatcher with the provided window index and media period id. + * + * @param windowIndex The timeline window index to be reported with the events. + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. + * @return A view of the event dispatcher with the pre-configured parameters. + */ + @CheckResult + public EventDispatcher withParameters(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + return new EventDispatcher(listenerAndHandlers, windowIndex, mediaPeriodId); + } + + /** + * Adds a listener to the event dispatcher. + * + * @param handler A handler on the which listener events will be posted. + * @param eventListener The listener to be added. + */ + public void addEventListener(Handler handler, DrmSessionEventListener eventListener) { + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); + listenerAndHandlers.add(new ListenerAndHandler(handler, eventListener)); + } + + /** + * Removes a listener from the event dispatcher. + * + * @param eventListener The listener to be removed. + */ + public void removeEventListener(DrmSessionEventListener eventListener) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + if (listenerAndHandler.listener == eventListener) { + listenerAndHandlers.remove(listenerAndHandler); + } + } + } + + /** Dispatches {@link #onDrmSessionAcquired(int, MediaPeriodId)}. */ + public void drmSessionAcquired() { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + DrmSessionEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDrmSessionAcquired(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onDrmKeysLoaded(int, MediaPeriodId)}. */ + public void drmKeysLoaded() { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + DrmSessionEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, () -> listener.onDrmKeysLoaded(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onDrmSessionManagerError(int, MediaPeriodId, Exception)}. */ + public void drmSessionManagerError(Exception error) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + DrmSessionEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDrmSessionManagerError(windowIndex, mediaPeriodId, error)); + } + } + + /** Dispatches {@link #onDrmKeysRestored(int, MediaPeriodId)}. */ + public void drmKeysRestored() { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + DrmSessionEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDrmKeysRestored(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onDrmKeysRemoved(int, MediaPeriodId)}. */ + public void drmKeysRemoved() { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + DrmSessionEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDrmKeysRemoved(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onDrmSessionReleased(int, MediaPeriodId)}. */ + public void drmSessionReleased() { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + DrmSessionEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDrmSessionReleased(windowIndex, mediaPeriodId)); + } + } + + private static final class ListenerAndHandler { + + public Handler handler; + public DrmSessionEventListener listener; + + public ListenerAndHandler(Handler handler, DrmSessionEventListener listener) { + this.handler = handler; + this.listener = listener; + } + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java index 0283470765..1168884d76 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionManager.java @@ -17,9 +17,7 @@ package com.google.android.exoplayer2.drm; import android.os.Looper; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; +import com.google.android.exoplayer2.Format; /** Manages a DRM session. */ public interface DrmSessionManager { @@ -34,24 +32,25 @@ public interface DrmSessionManager { new DrmSessionManager() { @Override - public boolean canAcquireSession(DrmInitData drmInitData) { - return false; - } - - @Override + @Nullable public DrmSession acquireSession( Looper playbackLooper, - @Nullable MediaSourceEventDispatcher eventDispatcher, - DrmInitData drmInitData) { - return new ErrorStateDrmSession( - new DrmSession.DrmSessionException( - new UnsupportedDrmException(UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME))); + @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, + Format format) { + if (format.drmInitData == null) { + return null; + } else { + return new ErrorStateDrmSession( + new DrmSession.DrmSessionException( + new UnsupportedDrmException( + UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME))); + } } @Override @Nullable - public Class getExoMediaCryptoType(DrmInitData drmInitData) { - return null; + public Class getExoMediaCryptoType(Format format) { + return format.drmInitData != null ? UnsupportedMediaCrypto.class : null; } }; @@ -71,56 +70,45 @@ public interface DrmSessionManager { } /** - * Returns whether the manager is capable of acquiring a session for the given - * {@link DrmInitData}. + * Returns a {@link DrmSession} for the specified {@link Format}, with an incremented reference + * count. May return null if the {@link Format#drmInitData} is null and the DRM session manager is + * not configured to attach a {@link DrmSession} to clear content. When the caller no longer needs + * to use a returned {@link DrmSession}, it must call {@link + * DrmSession#release(DrmSessionEventListener.EventDispatcher)} to decrement the reference count. * - * @param drmInitData DRM initialization data. - * @return Whether the manager is capable of acquiring a session for the given - * {@link DrmInitData}. - */ - boolean canAcquireSession(DrmInitData drmInitData); - - /** - * Returns a {@link DrmSession} that does not execute key requests, with an incremented reference - * count. When the caller no longer needs to use the instance, it must call {@link - * DrmSession#release(MediaSourceEventDispatcher)} to decrement the reference count. - * - *

    Placeholder {@link DrmSession DrmSessions} may be used to configure secure decoders for - * playback of clear content periods. This can reduce the cost of transitioning between clear and - * encrypted content periods. + *

    If the provided {@link Format} contains a null {@link Format#drmInitData}, the returned + * {@link DrmSession} (if not null) will be a placeholder session which does not execute key + * requests, and cannot be used to handle encrypted content. However, a placeholder session may be + * used to configure secure decoders for playback of clear content periods, which can reduce the + * cost of transitioning between clear and encrypted content. * * @param playbackLooper The looper associated with the media playback thread. - * @param trackType The type of the track to acquire a placeholder session for. Must be one of the - * {@link C}{@code .TRACK_TYPE_*} constants. - * @return The placeholder DRM session, or null if this DRM session manager does not support - * placeholder sessions. + * @param eventDispatcher The {@link DrmSessionEventListener.EventDispatcher} used to distribute + * events, and passed on to {@link + * DrmSession#acquire(DrmSessionEventListener.EventDispatcher)}. + * @param format The {@link Format} for which to acquire a {@link DrmSession}. + * @return The DRM session. May be null if the given {@link Format#drmInitData} is null. */ @Nullable - default DrmSession acquirePlaceholderSession(Looper playbackLooper, int trackType) { - return null; - } - - /** - * Returns a {@link DrmSession} for the specified {@link DrmInitData}, with an incremented - * reference count. When the caller no longer needs to use the instance, it must call {@link - * DrmSession#release(MediaSourceEventDispatcher)} to decrement the reference count. - * - * @param playbackLooper The looper associated with the media playback thread. - * @param eventDispatcher The {@link MediaSourceEventDispatcher} used to distribute events, and - * passed on to {@link DrmSession#acquire(MediaSourceEventDispatcher)}. - * @param drmInitData DRM initialization data. All contained {@link SchemeData}s must contain - * non-null {@link SchemeData#data}. - * @return The DRM session. - */ DrmSession acquireSession( Looper playbackLooper, - @Nullable MediaSourceEventDispatcher eventDispatcher, - DrmInitData drmInitData); + @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, + Format format); /** - * Returns the {@link ExoMediaCrypto} type returned by sessions acquired using the given {@link - * DrmInitData}, or null if a session cannot be acquired with the given {@link DrmInitData}. + * Returns the {@link ExoMediaCrypto} type associated to sessions acquired for the given {@link + * Format}. Returns the {@link UnsupportedMediaCrypto} type if this DRM session manager does not + * support any of the DRM schemes defined in the given {@link Format}. Returns null if {@link + * Format#drmInitData} is null and {@link #acquireSession} would return null for the given {@link + * Format}. + * + * @param format The {@link Format} for which to return the {@link ExoMediaCrypto} type. + * @return The {@link ExoMediaCrypto} type associated to sessions acquired using the given {@link + * Format}, or {@link UnsupportedMediaCrypto} if this DRM session manager does not support any + * of the DRM schemes defined in the given {@link Format}. May be null if {@link + * Format#drmInitData} is null and {@link #acquireSession} would return null for the given + * {@link Format}. */ @Nullable - Class getExoMediaCryptoType(DrmInitData drmInitData); + Class getExoMediaCryptoType(Format format); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java index d8311f6701..9631b76491 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java @@ -142,9 +142,7 @@ public final class DummyExoMediaDrm implements ExoMediaDrm { } @Override - @Nullable - public Class getExoMediaCryptoType() { - // No ExoMediaCrypto type is supported. - return null; + public Class getExoMediaCryptoType() { + return UnsupportedMediaCrypto.class; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java index ff0a861f4b..4253d3011c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.drm; import androidx.annotation.Nullable; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import java.util.Map; /** A {@link DrmSession} that's in a terminal error state. */ @@ -64,12 +63,12 @@ public final class ErrorStateDrmSession implements DrmSession { } @Override - public void acquire(@Nullable MediaSourceEventDispatcher eventDispatcher) { + public void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { // Do nothing. } @Override - public void release(@Nullable MediaSourceEventDispatcher eventDispatcher) { + public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { // Do nothing. } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java index 957945fa2a..6684064f63 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java @@ -369,10 +369,6 @@ public interface ExoMediaDrm { */ ExoMediaCrypto createMediaCrypto(byte[] sessionId) throws MediaCryptoException; - /** - * Returns the {@link ExoMediaCrypto} type created by {@link #createMediaCrypto(byte[])}, or null - * if this instance cannot create any {@link ExoMediaCrypto} instances. - */ - @Nullable + /** Returns the {@link ExoMediaCrypto} type created by {@link #createMediaCrypto(byte[])}. */ Class getExoMediaCryptoType(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index 2227738ed5..26fe66e792 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.drm; import android.annotation.SuppressLint; -import android.annotation.TargetApi; import android.media.DeniedByServerException; import android.media.MediaCryptoException; import android.media.MediaDrm; @@ -35,9 +34,9 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -45,7 +44,6 @@ import java.util.Map; import java.util.UUID; /** An {@link ExoMediaDrm} implementation that wraps the framework {@link MediaDrm}. */ -@TargetApi(23) @RequiresApi(18) public final class FrameworkMediaDrm implements ExoMediaDrm { @@ -75,6 +73,15 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { private final MediaDrm mediaDrm; private int referenceCount; + /** + * Returns whether the DRM scheme with the given UUID is supported on this device. + * + * @see MediaDrm#isCryptoSchemeSupported(UUID) + */ + public static boolean isCryptoSchemeSupported(UUID uuid) { + return MediaDrm.isCryptoSchemeSupported(adjustUuid(uuid)); + } + /** * Creates an instance with an initial reference count of 1. {@link #release()} must be called on * the instance when it's no longer required. @@ -158,9 +165,8 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { mediaDrm.setOnExpirationUpdateListener( listener == null ? null - : (mediaDrm, sessionId, expirationTimeMs) -> { - listener.onExpirationUpdate(FrameworkMediaDrm.this, sessionId, expirationTimeMs); - }, + : (mediaDrm, sessionId, expirationTimeMs) -> + listener.onExpirationUpdate(FrameworkMediaDrm.this, sessionId, expirationTimeMs), /* handler= */ null); } @@ -254,7 +260,6 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { @Override @Nullable - @TargetApi(28) public PersistableBundle getMetrics() { if (Util.SDK_INT < 28) { return null; @@ -310,7 +315,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { boolean canConcatenateData = true; for (int i = 0; i < schemeDatas.size(); i++) { SchemeData schemeData = schemeDatas.get(i); - byte[] schemeDataData = Util.castNonNull(schemeData.data); + byte[] schemeDataData = Assertions.checkNotNull(schemeData.data); if (Util.areEqual(schemeData.mimeType, firstSchemeData.mimeType) && Util.areEqual(schemeData.licenseServerUrl, firstSchemeData.licenseServerUrl) && PsshAtomUtil.isPsshAtom(schemeDataData)) { @@ -325,7 +330,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { int concatenatedDataPosition = 0; for (int i = 0; i < schemeDatas.size(); i++) { SchemeData schemeData = schemeDatas.get(i); - byte[] schemeDataData = Util.castNonNull(schemeData.data); + byte[] schemeDataData = Assertions.checkNotNull(schemeData.data); int schemeDataLength = schemeDataData.length; System.arraycopy( schemeDataData, 0, concatenatedData, concatenatedDataPosition, schemeDataLength); @@ -339,7 +344,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { // the first V0 box. for (int i = 0; i < schemeDatas.size(); i++) { SchemeData schemeData = schemeDatas.get(i); - int version = PsshAtomUtil.parseVersion(Util.castNonNull(schemeData.data)); + int version = PsshAtomUtil.parseVersion(Assertions.checkNotNull(schemeData.data)); if (Util.SDK_INT < 23 && version == 0) { return schemeData; } else if (Util.SDK_INT >= 23 && version == 1) { @@ -441,7 +446,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { return data; } int recordLength = byteArray.readLittleEndianShort(); - String xml = byteArray.readString(recordLength, Charset.forName(C.UTF16LE_NAME)); + String xml = byteArray.readString(recordLength, Charsets.UTF_16LE); if (xml.contains("")) { // LA_URL already present. Do nothing. return data; @@ -462,7 +467,7 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { newData.putShort((short) objectRecordCount); newData.putShort((short) recordType); newData.putShort((short) (xmlWithMockLaUrl.length() * UTF_16_BYTES_PER_CHARACTER)); - newData.put(xmlWithMockLaUrl.getBytes(Charset.forName(C.UTF16LE_NAME))); + newData.put(xmlWithMockLaUrl.getBytes(Charsets.UTF_16LE)); return newData.array(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java index 655a8d92d8..7ab90b023e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java @@ -24,9 +24,9 @@ import com.google.android.exoplayer2.upstream.DataSourceInputStream; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; +import com.google.android.exoplayer2.upstream.StatsDataSource; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; -import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -104,14 +104,19 @@ public final class HttpMediaDrmCallback implements MediaDrmCallback { } @Override - public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws IOException { + public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) + throws MediaDrmCallbackException { String url = request.getDefaultUrl() + "&signedRequest=" + Util.fromUtf8Bytes(request.getData()); - return executePost(dataSourceFactory, url, /* httpBody= */ null, /* requestProperties= */ null); + return executePost( + dataSourceFactory, + url, + /* httpBody= */ null, + /* requestProperties= */ Collections.emptyMap()); } @Override - public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception { + public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws MediaDrmCallbackException { String url = request.getLicenseServerUrl(); if (forceDefaultLicenseUrl || TextUtils.isEmpty(url)) { url = defaultLicenseUrl; @@ -136,41 +141,56 @@ public final class HttpMediaDrmCallback implements MediaDrmCallback { HttpDataSource.Factory dataSourceFactory, String url, @Nullable byte[] httpBody, - @Nullable Map requestProperties) - throws IOException { - HttpDataSource dataSource = dataSourceFactory.createDataSource(); + Map requestProperties) + throws MediaDrmCallbackException { + StatsDataSource dataSource = new StatsDataSource(dataSourceFactory.createDataSource()); int manualRedirectCount = 0; - while (true) { - DataSpec dataSpec = - new DataSpec.Builder() - .setUri(url) - .setHttpRequestHeaders( - requestProperties != null ? requestProperties : Collections.emptyMap()) - .setHttpMethod(DataSpec.HTTP_METHOD_POST) - .setHttpBody(httpBody) - .setFlags(DataSpec.FLAG_ALLOW_GZIP) - .build(); - DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); - try { - return Util.toByteArray(inputStream); - } catch (InvalidResponseCodeException e) { - // For POST requests, the underlying network stack will not normally follow 307 or 308 - // redirects automatically. Do so manually here. - boolean manuallyRedirect = - (e.responseCode == 307 || e.responseCode == 308) - && manualRedirectCount++ < MAX_MANUAL_REDIRECTS; - @Nullable String redirectUrl = manuallyRedirect ? getRedirectUrl(e) : null; - if (redirectUrl == null) { - throw e; + DataSpec dataSpec = + new DataSpec.Builder() + .setUri(url) + .setHttpRequestHeaders(requestProperties) + .setHttpMethod(DataSpec.HTTP_METHOD_POST) + .setHttpBody(httpBody) + .setFlags(DataSpec.FLAG_ALLOW_GZIP) + .build(); + DataSpec originalDataSpec = dataSpec; + try { + while (true) { + DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); + try { + return Util.toByteArray(inputStream); + } catch (InvalidResponseCodeException e) { + @Nullable String redirectUrl = getRedirectUrl(e, manualRedirectCount); + if (redirectUrl == null) { + throw e; + } + manualRedirectCount++; + dataSpec = dataSpec.buildUpon().setUri(redirectUrl).build(); + } finally { + Util.closeQuietly(inputStream); } - url = redirectUrl; - } finally { - Util.closeQuietly(inputStream); } + } catch (Exception e) { + throw new MediaDrmCallbackException( + originalDataSpec, + Assertions.checkNotNull(dataSource.getLastOpenedUri()), + dataSource.getResponseHeaders(), + dataSource.getBytesRead(), + /* cause= */ e); } } - private static @Nullable String getRedirectUrl(InvalidResponseCodeException exception) { + @Nullable + private static String getRedirectUrl( + InvalidResponseCodeException exception, int manualRedirectCount) { + // For POST requests, the underlying network stack will not normally follow 307 or 308 + // redirects automatically. Do so manually here. + boolean manuallyRedirect = + (exception.responseCode == 307 || exception.responseCode == 308) + && manualRedirectCount < MAX_MANUAL_REDIRECTS; + if (!manuallyRedirect) { + return null; + } Map> headerFields = exception.headerFields; if (headerFields != null) { @Nullable List locationHeaders = headerFields.get("Location"); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java index 7b9aeca30a..d141b6c4c1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.drm; import com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; import com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; import com.google.android.exoplayer2.util.Assertions; -import java.io.IOException; import java.util.UUID; /** @@ -39,12 +38,12 @@ public final class LocalMediaDrmCallback implements MediaDrmCallback { } @Override - public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws IOException { + public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) { throw new UnsupportedOperationException(); } @Override - public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception { + public byte[] executeKeyRequest(UUID uuid, KeyRequest request) { return keyResponse; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java index 5b0ed04f81..14b817e713 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallback.java @@ -30,9 +30,10 @@ public interface MediaDrmCallback { * @param uuid The UUID of the content protection scheme. * @param request The request. * @return The response data. - * @throws Exception If an error occurred executing the request. + * @throws MediaDrmCallbackException If an error occurred executing the request. */ - byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws Exception; + byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) + throws MediaDrmCallbackException; /** * Executes a key request. @@ -40,7 +41,7 @@ public interface MediaDrmCallback { * @param uuid The UUID of the content protection scheme. * @param request The request. * @return The response data. - * @throws Exception If an error occurred executing the request. + * @throws MediaDrmCallbackException If an error occurred executing the request. */ - byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception; + byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws MediaDrmCallbackException; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallbackException.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallbackException.java new file mode 100644 index 0000000000..37b2e03504 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/MediaDrmCallbackException.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.drm; + +import android.net.Uri; +import com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Thrown when an error occurs while executing a DRM {@link MediaDrmCallback#executeKeyRequest key} + * or {@link MediaDrmCallback#executeProvisionRequest provisioning} request. + */ +public final class MediaDrmCallbackException extends IOException { + + /** The {@link DataSpec} associated with the request. */ + public final DataSpec dataSpec; + /** + * The {@link Uri} after redirections, or {@link #dataSpec dataSpec.uri} if no redirection + * occurred. + */ + public final Uri uriAfterRedirects; + /** The HTTP request headers included in the response. */ + public final Map> responseHeaders; + /** The number of bytes obtained from the server. */ + public final long bytesLoaded; + + /** + * Creates a new instance with the given values. + * + * @param dataSpec See {@link #dataSpec}. + * @param uriAfterRedirects See {@link #uriAfterRedirects}. + * @param responseHeaders See {@link #responseHeaders}. + * @param bytesLoaded See {@link #bytesLoaded}. + * @param cause The cause of the exception. + */ + public MediaDrmCallbackException( + DataSpec dataSpec, + Uri uriAfterRedirects, + Map> responseHeaders, + long bytesLoaded, + Throwable cause) { + super(cause); + this.dataSpec = dataSpec; + this.uriAfterRedirects = uriAfterRedirects; + this.responseHeaders = responseHeaders; + this.bytesLoaded = bytesLoaded; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java index 5b4c9daac7..b218d0cadb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java @@ -22,12 +22,12 @@ import android.os.HandlerThread; import android.util.Pair; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager.Mode; import com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import java.util.Map; import java.util.UUID; @@ -35,12 +35,13 @@ import java.util.UUID; @RequiresApi(18) public final class OfflineLicenseHelper { - private static final DrmInitData DUMMY_DRM_INIT_DATA = new DrmInitData(); + private static final Format FORMAT_WITH_EMPTY_DRM_INIT_DATA = + new Format.Builder().setDrmInitData(new DrmInitData()).build(); private final ConditionVariable conditionVariable; private final DefaultDrmSessionManager drmSessionManager; private final HandlerThread handlerThread; - private final MediaSourceEventDispatcher eventDispatcher; + private final DrmSessionEventListener.EventDispatcher eventDispatcher; /** * Instantiates a new instance which uses Widevine CDM. Call {@link #release()} when the instance @@ -49,14 +50,14 @@ public final class OfflineLicenseHelper { * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify * their own license URL. * @param httpDataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. - * @param eventDispatcher A {@link MediaSourceEventDispatcher} used to distribute DRM-related - * events. + * @param eventDispatcher A {@link DrmSessionEventListener.EventDispatcher} used to distribute + * DRM-related events. * @return A new instance which uses Widevine CDM. */ public static OfflineLicenseHelper newWidevineInstance( String defaultLicenseUrl, HttpDataSource.Factory httpDataSourceFactory, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher eventDispatcher) { return newWidevineInstance( defaultLicenseUrl, /* forceDefaultLicenseUrl= */ false, @@ -73,15 +74,15 @@ public final class OfflineLicenseHelper { * @param forceDefaultLicenseUrl Whether to use {@code defaultLicenseUrl} for key requests that * include their own license URL. * @param httpDataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. - * @param eventDispatcher A {@link MediaSourceEventDispatcher} used to distribute DRM-related - * events. + * @param eventDispatcher A {@link DrmSessionEventListener.EventDispatcher} used to distribute + * DRM-related events. * @return A new instance which uses Widevine CDM. */ public static OfflineLicenseHelper newWidevineInstance( String defaultLicenseUrl, boolean forceDefaultLicenseUrl, HttpDataSource.Factory httpDataSourceFactory, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher eventDispatcher) { return newWidevineInstance( defaultLicenseUrl, forceDefaultLicenseUrl, @@ -100,8 +101,8 @@ public final class OfflineLicenseHelper { * include their own license URL. * @param optionalKeyRequestParameters An optional map of parameters to pass as the last argument * to {@link MediaDrm#getKeyRequest}. May be null. - * @param eventDispatcher A {@link MediaSourceEventDispatcher} used to distribute DRM-related - * events. + * @param eventDispatcher A {@link DrmSessionEventListener.EventDispatcher} used to distribute + * DRM-related events. * @return A new instance which uses Widevine CDM. * @see DefaultDrmSessionManager.Builder */ @@ -110,7 +111,7 @@ public final class OfflineLicenseHelper { boolean forceDefaultLicenseUrl, HttpDataSource.Factory httpDataSourceFactory, @Nullable Map optionalKeyRequestParameters, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher eventDispatcher) { return new OfflineLicenseHelper( new DefaultDrmSessionManager.Builder() .setKeyRequestParameters(optionalKeyRequestParameters) @@ -122,7 +123,7 @@ public final class OfflineLicenseHelper { /** * @deprecated Use {@link #OfflineLicenseHelper(DefaultDrmSessionManager, - * MediaSourceEventDispatcher)} instead. + * DrmSessionEventListener.EventDispatcher)} instead. */ @Deprecated public OfflineLicenseHelper( @@ -130,7 +131,7 @@ public final class OfflineLicenseHelper { ExoMediaDrm.Provider mediaDrmProvider, MediaDrmCallback callback, @Nullable Map optionalKeyRequestParameters, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher eventDispatcher) { this( new DefaultDrmSessionManager.Builder() .setUuidAndExoMediaDrmProvider(uuid, mediaDrmProvider) @@ -143,12 +144,12 @@ public final class OfflineLicenseHelper { * Constructs an instance. Call {@link #release()} when the instance is no longer required. * * @param defaultDrmSessionManager The {@link DefaultDrmSessionManager} used to download licenses. - * @param eventDispatcher A {@link MediaSourceEventDispatcher} used to distribute DRM-related - * events. + * @param eventDispatcher A {@link DrmSessionEventListener.EventDispatcher} used to distribute + * DRM-related events. */ public OfflineLicenseHelper( DefaultDrmSessionManager defaultDrmSessionManager, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher eventDispatcher) { this.drmSessionManager = defaultDrmSessionManager; this.eventDispatcher = eventDispatcher; handlerThread = new HandlerThread("ExoPlayer:OfflineLicenseHelper"); @@ -177,20 +178,20 @@ public final class OfflineLicenseHelper { conditionVariable.open(); } }; - eventDispatcher.addEventListener( - new Handler(handlerThread.getLooper()), eventListener, DrmSessionEventListener.class); + eventDispatcher.addEventListener(new Handler(handlerThread.getLooper()), eventListener); } /** * Downloads an offline license. * - * @param drmInitData The {@link DrmInitData} for the content whose license is to be downloaded. + * @param format The {@link Format} of the content whose license is to be downloaded. Must contain + * a non-null {@link Format#drmInitData}. * @return The key set id for the downloaded license. * @throws DrmSessionException Thrown when a DRM session error occurs. */ - public synchronized byte[] downloadLicense(DrmInitData drmInitData) throws DrmSessionException { - Assertions.checkArgument(drmInitData != null); - return blockingKeyRequest(DefaultDrmSessionManager.MODE_DOWNLOAD, null, drmInitData); + public synchronized byte[] downloadLicense(Format format) throws DrmSessionException { + Assertions.checkArgument(format.drmInitData != null); + return blockingKeyRequest(DefaultDrmSessionManager.MODE_DOWNLOAD, null, format); } /** @@ -204,7 +205,9 @@ public final class OfflineLicenseHelper { throws DrmSessionException { Assertions.checkNotNull(offlineLicenseKeySetId); return blockingKeyRequest( - DefaultDrmSessionManager.MODE_DOWNLOAD, offlineLicenseKeySetId, DUMMY_DRM_INIT_DATA); + DefaultDrmSessionManager.MODE_DOWNLOAD, + offlineLicenseKeySetId, + FORMAT_WITH_EMPTY_DRM_INIT_DATA); } /** @@ -217,7 +220,9 @@ public final class OfflineLicenseHelper { throws DrmSessionException { Assertions.checkNotNull(offlineLicenseKeySetId); blockingKeyRequest( - DefaultDrmSessionManager.MODE_RELEASE, offlineLicenseKeySetId, DUMMY_DRM_INIT_DATA); + DefaultDrmSessionManager.MODE_RELEASE, + offlineLicenseKeySetId, + FORMAT_WITH_EMPTY_DRM_INIT_DATA); } /** @@ -233,7 +238,9 @@ public final class OfflineLicenseHelper { drmSessionManager.prepare(); DrmSession drmSession = openBlockingKeyRequest( - DefaultDrmSessionManager.MODE_QUERY, offlineLicenseKeySetId, DUMMY_DRM_INIT_DATA); + DefaultDrmSessionManager.MODE_QUERY, + offlineLicenseKeySetId, + FORMAT_WITH_EMPTY_DRM_INIT_DATA); DrmSessionException error = drmSession.getError(); Pair licenseDurationRemainingSec = WidevineUtil.getLicenseDurationRemainingSec(drmSession); @@ -256,11 +263,10 @@ public final class OfflineLicenseHelper { } private byte[] blockingKeyRequest( - @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, DrmInitData drmInitData) + @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format) throws DrmSessionException { drmSessionManager.prepare(); - DrmSession drmSession = - openBlockingKeyRequest(licenseMode, offlineLicenseKeySetId, drmInitData); + DrmSession drmSession = openBlockingKeyRequest(licenseMode, offlineLicenseKeySetId, format); DrmSessionException error = drmSession.getError(); byte[] keySetId = drmSession.getOfflineLicenseKeySetId(); drmSession.release(eventDispatcher); @@ -272,14 +278,15 @@ public final class OfflineLicenseHelper { } private DrmSession openBlockingKeyRequest( - @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, DrmInitData drmInitData) { + @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format) { + Assertions.checkNotNull(format.drmInitData); drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId); conditionVariable.close(); DrmSession drmSession = - drmSessionManager.acquireSession(handlerThread.getLooper(), eventDispatcher, drmInitData); + drmSessionManager.acquireSession(handlerThread.getLooper(), eventDispatcher, format); // Block current thread until key loading is finished conditionVariable.block(); - return drmSession; + return Assertions.checkNotNull(drmSession); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java index 040ef340ed..84ff985495 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java @@ -17,132 +17,283 @@ package com.google.android.exoplayer2.mediacodec; import android.media.MediaCodec; +import android.media.MediaCrypto; import android.media.MediaFormat; import android.os.Handler; -import android.os.Looper; +import android.os.HandlerThread; +import android.view.Surface; +import androidx.annotation.GuardedBy; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.decoder.CryptoInfo; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** - * A {@link MediaCodecAdapter} that operates the {@link MediaCodec} in asynchronous mode. + * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in asynchronous mode + * and routes {@link MediaCodec.Callback} callbacks on a dedicated thread that is managed + * internally. * - *

    The AsynchronousMediaCodecAdapter routes callbacks to the current thread's {@link Looper} - * obtained via {@link Looper#myLooper()} + *

    This adapter supports queueing input buffers asynchronously. */ -@RequiresApi(21) -/* package */ final class AsynchronousMediaCodecAdapter implements MediaCodecAdapter { +@RequiresApi(23) +/* package */ final class AsynchronousMediaCodecAdapter extends MediaCodec.Callback + implements MediaCodecAdapter { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_CREATED, STATE_CONFIGURED, STATE_STARTED, STATE_SHUT_DOWN}) + private @interface State {} + + private static final int STATE_CREATED = 0; + private static final int STATE_CONFIGURED = 1; + private static final int STATE_STARTED = 2; + private static final int STATE_SHUT_DOWN = 3; + + private final Object lock; + + @GuardedBy("lock") private final MediaCodecAsyncCallback mediaCodecAsyncCallback; - private final Handler handler; + private final MediaCodec codec; - @Nullable private IllegalStateException internalException; - private boolean flushing; - private Runnable codecStartRunnable; + private final HandlerThread handlerThread; + private @MonotonicNonNull Handler handler; + + @GuardedBy("lock") + private long pendingFlushCount; + + private @State int state; + private final MediaCodecInputBufferEnqueuer bufferEnqueuer; + + @GuardedBy("lock") + @Nullable + private IllegalStateException internalException; /** - * Create a new {@code AsynchronousMediaCodecAdapter}. + * Creates an instance that wraps the specified {@link MediaCodec}. Instances created with this + * constructor will queue input buffers to the {@link MediaCodec} synchronously. * * @param codec The {@link MediaCodec} to wrap. + * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for + * labelling the internal thread accordingly. */ - public AsynchronousMediaCodecAdapter(MediaCodec codec) { - this(codec, Assertions.checkNotNull(Looper.myLooper())); + /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, int trackType) { + this( + codec, + /* enableAsynchronousQueueing= */ false, + trackType, + new HandlerThread(createThreadLabel(trackType))); + } + + /** + * Creates an instance that wraps the specified {@link MediaCodec}. + * + * @param codec The {@link MediaCodec} to wrap. + * @param enableAsynchronousQueueing Whether input buffers will be queued asynchronously. + * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for + * labelling the internal thread accordingly. + */ + /* package */ AsynchronousMediaCodecAdapter( + MediaCodec codec, boolean enableAsynchronousQueueing, int trackType) { + this( + codec, + enableAsynchronousQueueing, + trackType, + new HandlerThread(createThreadLabel(trackType))); } @VisibleForTesting - /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, Looper looper) { - mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); - handler = new Handler(looper); + /* package */ AsynchronousMediaCodecAdapter( + MediaCodec codec, + boolean enableAsynchronousQueueing, + int trackType, + HandlerThread handlerThread) { + this.lock = new Object(); + this.mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); this.codec = codec; - this.codec.setCallback(mediaCodecAsyncCallback); - codecStartRunnable = codec::start; + this.handlerThread = handlerThread; + this.bufferEnqueuer = + enableAsynchronousQueueing + ? new AsynchronousMediaCodecBufferEnqueuer(codec, trackType) + : new SynchronousMediaCodecBufferEnqueuer(this.codec); + this.state = STATE_CREATED; + } + + @Override + public void configure( + @Nullable MediaFormat mediaFormat, + @Nullable Surface surface, + @Nullable MediaCrypto crypto, + int flags) { + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + codec.setCallback(this, handler); + codec.configure(mediaFormat, surface, crypto, flags); + state = STATE_CONFIGURED; } @Override public void start() { - codecStartRunnable.run(); + bufferEnqueuer.start(); + codec.start(); + state = STATE_STARTED; } @Override public void queueInputBuffer( int index, int offset, int size, long presentationTimeUs, int flags) { - codec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); + bufferEnqueuer.queueInputBuffer(index, offset, size, presentationTimeUs, flags); } @Override public void queueSecureInputBuffer( int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { - codec.queueSecureInputBuffer( - index, offset, info.getFrameworkCryptoInfo(), presentationTimeUs, flags); + bufferEnqueuer.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); } @Override public int dequeueInputBufferIndex() { - if (flushing) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueInputBufferIndex(); + synchronized (lock) { + if (isFlushing()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + return mediaCodecAsyncCallback.dequeueInputBufferIndex(); + } } } @Override public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - if (flushing) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); + synchronized (lock) { + if (isFlushing()) { + return MediaCodec.INFO_TRY_AGAIN_LATER; + } else { + maybeThrowException(); + return mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); + } } } @Override public MediaFormat getOutputFormat() { - return mediaCodecAsyncCallback.getOutputFormat(); + synchronized (lock) { + return mediaCodecAsyncCallback.getOutputFormat(); + } } @Override public void flush() { - clearPendingFlushState(); - flushing = true; - codec.flush(); - handler.post(this::onCompleteFlush); + synchronized (lock) { + bufferEnqueuer.flush(); + codec.flush(); + ++pendingFlushCount; + Util.castNonNull(handler).post(this::onFlushCompleted); + } } @Override public void shutdown() { - clearPendingFlushState(); + synchronized (lock) { + if (state == STATE_STARTED) { + bufferEnqueuer.shutdown(); + } + if (state == STATE_CONFIGURED || state == STATE_STARTED) { + handlerThread.quit(); + mediaCodecAsyncCallback.flush(); + // Leave the adapter in a flushing state so that + // it will not dequeue anything. + ++pendingFlushCount; + } + state = STATE_SHUT_DOWN; + } } - @VisibleForTesting - /* package */ MediaCodec.Callback getMediaCodecCallback() { - return mediaCodecAsyncCallback; + @Override + public MediaCodec getCodec() { + return codec; } - private void onCompleteFlush() { - flushing = false; + // Called from the handler thread. + + @Override + public void onInputBufferAvailable(MediaCodec codec, int index) { + synchronized (lock) { + mediaCodecAsyncCallback.onInputBufferAvailable(codec, index); + } + } + + @Override + public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) { + synchronized (lock) { + mediaCodecAsyncCallback.onOutputBufferAvailable(codec, index, info); + } + } + + @Override + public void onError(MediaCodec codec, MediaCodec.CodecException e) { + synchronized (lock) { + mediaCodecAsyncCallback.onError(codec, e); + } + } + + @Override + public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { + synchronized (lock) { + mediaCodecAsyncCallback.onOutputFormatChanged(codec, format); + } + } + + private void onFlushCompleted() { + synchronized (lock) { + onFlushCompletedSynchronized(); + } + } + + @GuardedBy("lock") + private void onFlushCompletedSynchronized() { + if (state == STATE_SHUT_DOWN) { + return; + } + + --pendingFlushCount; + if (pendingFlushCount > 0) { + // Another flush() has been called. + return; + } else if (pendingFlushCount < 0) { + // This should never happen. + internalException = new IllegalStateException(); + return; + } + mediaCodecAsyncCallback.flush(); try { - codecStartRunnable.run(); + codec.start(); } catch (IllegalStateException e) { - // Catch IllegalStateException directly so that we don't have to wrap it. internalException = e; } catch (Exception e) { internalException = new IllegalStateException(e); } } - @VisibleForTesting - /* package */ void setCodecStartRunnable(Runnable codecStartRunnable) { - this.codecStartRunnable = codecStartRunnable; + @GuardedBy("lock") + private boolean isFlushing() { + return pendingFlushCount > 0; } - private void maybeThrowException() throws IllegalStateException { + @GuardedBy("lock") + private void maybeThrowException() { maybeThrowInternalException(); mediaCodecAsyncCallback.maybeThrowMediaCodecException(); } + @GuardedBy("lock") private void maybeThrowInternalException() { if (internalException != null) { IllegalStateException e = internalException; @@ -151,9 +302,15 @@ import com.google.android.exoplayer2.util.Assertions; } } - /** Clear state related to pending flush events. */ - private void clearPendingFlushState() { - handler.removeCallbacksAndMessages(null); - internalException = null; + private static String createThreadLabel(int trackType) { + StringBuilder labelBuilder = new StringBuilder("ExoPlayer:MediaCodecAsyncAdapter:"); + if (trackType == C.TRACK_TYPE_AUDIO) { + labelBuilder.append("Audio"); + } else if (trackType == C.TRACK_TYPE_VIDEO) { + labelBuilder.append("Video"); + } else { + labelBuilder.append("Unknown(").append(trackType).append(")"); + } + return labelBuilder.toString(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java index 428d64d0b1..dd9a086446 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java @@ -16,11 +16,14 @@ package com.google.android.exoplayer2.mediacodec; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.media.MediaCodec; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; @@ -301,8 +304,8 @@ class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnque copy(cryptoInfo.numBytesOfClearData, frameworkCryptoInfo.numBytesOfClearData); frameworkCryptoInfo.numBytesOfEncryptedData = copy(cryptoInfo.numBytesOfEncryptedData, frameworkCryptoInfo.numBytesOfEncryptedData); - frameworkCryptoInfo.key = copy(cryptoInfo.key, frameworkCryptoInfo.key); - frameworkCryptoInfo.iv = copy(cryptoInfo.iv, frameworkCryptoInfo.iv); + frameworkCryptoInfo.key = checkNotNull(copy(cryptoInfo.key, frameworkCryptoInfo.key)); + frameworkCryptoInfo.iv = checkNotNull(copy(cryptoInfo.iv, frameworkCryptoInfo.iv)); frameworkCryptoInfo.mode = cryptoInfo.mode; if (Util.SDK_INT >= 24) { android.media.MediaCodec.CryptoInfo.Pattern pattern = @@ -319,7 +322,8 @@ class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnque * @param dst The destination array, which will be reused if it's at least as long as {@code src}. * @return The copy, which may be {@code dst} if it was reused. */ - private static int[] copy(int[] src, int[] dst) { + @Nullable + private static int[] copy(@Nullable int[] src, @Nullable int[] dst) { if (src == null) { return dst; } @@ -339,7 +343,8 @@ class AsynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnque * @param dst The destination array, which will be reused if it's at least as long as {@code src}. * @return The copy, which may be {@code dst} if it was reused. */ - private static byte[] copy(byte[] src, byte[] dst) { + @Nullable + private static byte[] copy(@Nullable byte[] src, @Nullable byte[] dst) { if (src == null) { return dst; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java new file mode 100644 index 0000000000..0c3fe9facf --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTracker.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.mediacodec; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.audio.MpegAudioUtil; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import java.nio.ByteBuffer; + +/** + * Tracks the number of processed samples to calculate an accurate current timestamp, matching the + * calculations made in the Codec2 Mp3 decoder. + */ +/* package */ final class C2Mp3TimestampTracker { + + // Mirroring the actual codec, as can be found at + // https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/codec2/components/mp3/C2SoftMp3Dec.h;l=55;drc=3665390c9d32a917398b240c5a46ced07a3b65eb + private static final long DECODER_DELAY_SAMPLES = 529; + private static final String TAG = "C2Mp3TimestampTracker"; + + private long processedSamples; + private long anchorTimestampUs; + private boolean seenInvalidMpegAudioHeader; + + /** + * Resets the timestamp tracker. + * + *

    This should be done when the codec is flushed. + */ + public void reset() { + processedSamples = 0; + anchorTimestampUs = 0; + seenInvalidMpegAudioHeader = false; + } + + /** + * Updates the tracker with the given input buffer and returns the expected output timestamp. + * + * @param format The format associated with the buffer. + * @param buffer The current input buffer. + * @return The expected output presentation time, in microseconds. + */ + public long updateAndGetPresentationTimeUs(Format format, DecoderInputBuffer buffer) { + if (seenInvalidMpegAudioHeader) { + return buffer.timeUs; + } + + ByteBuffer data = Assertions.checkNotNull(buffer.data); + int sampleHeaderData = 0; + for (int i = 0; i < 4; i++) { + sampleHeaderData <<= 8; + sampleHeaderData |= data.get(i) & 0xFF; + } + + int frameCount = MpegAudioUtil.parseMpegAudioFrameSampleCount(sampleHeaderData); + if (frameCount == C.LENGTH_UNSET) { + seenInvalidMpegAudioHeader = true; + Log.w(TAG, "MPEG audio header is invalid."); + return buffer.timeUs; + } + + // These calculations mirror the timestamp calculations in the Codec2 Mp3 Decoder. + // https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/codec2/components/mp3/C2SoftMp3Dec.cpp;l=464;drc=ed134640332fea70ca4b05694289d91a5265bb46 + if (processedSamples == 0) { + anchorTimestampUs = buffer.timeUs; + processedSamples = frameCount - DECODER_DELAY_SAMPLES; + return anchorTimestampUs; + } + long processedDurationUs = getProcessedDurationUs(format); + processedSamples += frameCount; + return anchorTimestampUs + processedDurationUs; + } + + private long getProcessedDurationUs(Format format) { + return processedSamples * C.MICROS_PER_SECOND / format.sampleRate; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapter.java deleted file mode 100644 index 88e3f56daa..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapter.java +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.HandlerThread; -import androidx.annotation.IntDef; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.decoder.CryptoInfo; -import com.google.android.exoplayer2.util.Util; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; - -/** - * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in asynchronous mode - * and routes {@link MediaCodec.Callback} callbacks on a dedicated thread that is managed - * internally. - * - *

    This adapter supports queueing input buffers asynchronously. - */ -@RequiresApi(23) -/* package */ final class DedicatedThreadAsyncMediaCodecAdapter extends MediaCodec.Callback - implements MediaCodecAdapter { - - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_CREATED, STATE_STARTED, STATE_SHUT_DOWN}) - private @interface State {} - - private static final int STATE_CREATED = 0; - private static final int STATE_STARTED = 1; - private static final int STATE_SHUT_DOWN = 2; - - private final MediaCodecAsyncCallback mediaCodecAsyncCallback; - private final MediaCodec codec; - private final HandlerThread handlerThread; - private @MonotonicNonNull Handler handler; - private long pendingFlushCount; - private @State int state; - private Runnable codecStartRunnable; - private final MediaCodecInputBufferEnqueuer bufferEnqueuer; - @Nullable private IllegalStateException internalException; - - /** - * Creates an instance that wraps the specified {@link MediaCodec}. Instances created with this - * constructor will queue input buffers to the {@link MediaCodec} synchronously. - * - * @param codec The {@link MediaCodec} to wrap. - * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for - * labelling the internal thread accordingly. - */ - /* package */ DedicatedThreadAsyncMediaCodecAdapter(MediaCodec codec, int trackType) { - this( - codec, - /* enableAsynchronousQueueing= */ false, - trackType, - new HandlerThread(createThreadLabel(trackType))); - } - - /** - * Creates an instance that wraps the specified {@link MediaCodec}. - * - * @param codec The {@link MediaCodec} to wrap. - * @param enableAsynchronousQueueing Whether input buffers will be queued asynchronously. - * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for - * labelling the internal thread accordingly. - */ - /* package */ DedicatedThreadAsyncMediaCodecAdapter( - MediaCodec codec, boolean enableAsynchronousQueueing, int trackType) { - this( - codec, - enableAsynchronousQueueing, - trackType, - new HandlerThread(createThreadLabel(trackType))); - } - - @VisibleForTesting - /* package */ DedicatedThreadAsyncMediaCodecAdapter( - MediaCodec codec, - boolean enableAsynchronousQueueing, - int trackType, - HandlerThread handlerThread) { - mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); - this.codec = codec; - this.handlerThread = handlerThread; - state = STATE_CREATED; - codecStartRunnable = codec::start; - if (enableAsynchronousQueueing) { - bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, trackType); - } else { - bufferEnqueuer = new SynchronousMediaCodecBufferEnqueuer(this.codec); - } - } - - @Override - public synchronized void start() { - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); - codec.setCallback(this, handler); - bufferEnqueuer.start(); - codecStartRunnable.run(); - state = STATE_STARTED; - } - - @Override - public void queueInputBuffer( - int index, int offset, int size, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it does not interact with the - // mediaCodecAsyncCallback. - bufferEnqueuer.queueInputBuffer(index, offset, size, presentationTimeUs, flags); - } - - @Override - public void queueSecureInputBuffer( - int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it does not interact with the - // mediaCodecAsyncCallback. - bufferEnqueuer.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); - } - - @Override - public synchronized int dequeueInputBufferIndex() { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueInputBufferIndex(); - } - } - - @Override - public synchronized int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return mediaCodecAsyncCallback.dequeueOutputBufferIndex(bufferInfo); - } - } - - @Override - public synchronized MediaFormat getOutputFormat() { - return mediaCodecAsyncCallback.getOutputFormat(); - } - - @Override - public synchronized void flush() { - bufferEnqueuer.flush(); - codec.flush(); - ++pendingFlushCount; - Util.castNonNull(handler).post(this::onFlushCompleted); - } - - @Override - public synchronized void shutdown() { - if (state == STATE_STARTED) { - bufferEnqueuer.shutdown(); - handlerThread.quit(); - mediaCodecAsyncCallback.flush(); - } - state = STATE_SHUT_DOWN; - } - - @Override - public synchronized void onInputBufferAvailable(MediaCodec codec, int index) { - mediaCodecAsyncCallback.onInputBufferAvailable(codec, index); - } - - @Override - public synchronized void onOutputBufferAvailable( - MediaCodec codec, int index, MediaCodec.BufferInfo info) { - mediaCodecAsyncCallback.onOutputBufferAvailable(codec, index, info); - } - - @Override - public synchronized void onError(MediaCodec codec, MediaCodec.CodecException e) { - mediaCodecAsyncCallback.onError(codec, e); - } - - @Override - public synchronized void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { - mediaCodecAsyncCallback.onOutputFormatChanged(codec, format); - } - - @VisibleForTesting - /* package */ void onMediaCodecError(IllegalStateException e) { - mediaCodecAsyncCallback.onMediaCodecError(e); - } - - @VisibleForTesting - /* package */ void setCodecStartRunnable(Runnable codecStartRunnable) { - this.codecStartRunnable = codecStartRunnable; - } - - private synchronized void onFlushCompleted() { - if (state != STATE_STARTED) { - // The adapter has been shutdown. - return; - } - - --pendingFlushCount; - if (pendingFlushCount > 0) { - // Another flush() has been called. - return; - } else if (pendingFlushCount < 0) { - // This should never happen. - internalException = new IllegalStateException(); - return; - } - - mediaCodecAsyncCallback.flush(); - try { - codecStartRunnable.run(); - } catch (IllegalStateException e) { - internalException = e; - } catch (Exception e) { - internalException = new IllegalStateException(e); - } - } - - private synchronized boolean isFlushing() { - return pendingFlushCount > 0; - } - - private synchronized void maybeThrowException() { - maybeThrowInternalException(); - mediaCodecAsyncCallback.maybeThrowMediaCodecException(); - } - - private synchronized void maybeThrowInternalException() { - if (internalException != null) { - IllegalStateException e = internalException; - internalException = null; - throw e; - } - } - - private static String createThreadLabel(int trackType) { - StringBuilder labelBuilder = new StringBuilder("ExoPlayer:MediaCodecAsyncAdapter:"); - if (trackType == C.TRACK_TYPE_AUDIO) { - labelBuilder.append("Audio"); - } else if (trackType == C.TRACK_TYPE_VIDEO) { - labelBuilder.append("Video"); - } else { - labelBuilder.append("Unknown(").append(trackType).append(")"); - } - return labelBuilder.toString(); - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java index 1be850c899..69875f2367 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java @@ -17,25 +17,37 @@ package com.google.android.exoplayer2.mediacodec; import android.media.MediaCodec; +import android.media.MediaCrypto; import android.media.MediaFormat; +import android.view.Surface; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.decoder.CryptoInfo; +import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.MediaCodecOperationMode; /** * Abstracts {@link MediaCodec} operations. * *

    {@code MediaCodecAdapter} offers a common interface to interact with a {@link MediaCodec} - * regardless of the {@link - * com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.MediaCodecOperationMode} the {@link - * MediaCodec} is operating in. - * - * @see com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.MediaCodecOperationMode + * regardless of the {@link MediaCodecOperationMode} the {@link MediaCodec} is operating in. */ -/* package */ interface MediaCodecAdapter { +public interface MediaCodecAdapter { /** - * Starts this instance. + * Configures this adapter and the underlying {@link MediaCodec}. Needs to be called before {@link + * #start()}. * - * @see MediaCodec#start(). + * @see MediaCodec#configure(MediaFormat, Surface, MediaCrypto, int) + */ + void configure( + @Nullable MediaFormat mediaFormat, + @Nullable Surface surface, + @Nullable MediaCrypto crypto, + int flags); + + /** + * Starts this instance. Needs to be called after {@link #configure}. + * + * @see MediaCodec#start() */ void start(); @@ -82,31 +94,26 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; *

    The {@code index} must be an input buffer index that has been obtained from a previous call * to {@link #dequeueInputBufferIndex()}. * - *

    Note: This method behaves as {@link MediaCodec#queueSecureInputBuffer} with the difference - * that {@code info} is of type {@link CryptoInfo} and not {@link - * android.media.MediaCodec.CryptoInfo}. + *

    This method behaves like {@link MediaCodec#queueSecureInputBuffer}, with the difference that + * {@code info} is of type {@link CryptoInfo} and not {@link android.media.MediaCodec.CryptoInfo}. * * @see MediaCodec#queueSecureInputBuffer */ void queueSecureInputBuffer( int index, int offset, CryptoInfo info, long presentationTimeUs, int flags); - /** - * Flushes the {@code MediaCodecAdapter}. - * - *

    Note: {@link #flush()} should also call any {@link MediaCodec} methods needed to flush the - * {@link MediaCodec}, i.e., {@link MediaCodec#flush()} and optionally {@link - * MediaCodec#start()}, if the {@link MediaCodec} operates in asynchronous mode. - */ + /** Flushes both the adapter and the underlying {@link MediaCodec}. */ void flush(); /** - * Shutdown the {@code MediaCodecAdapter}. + * Shuts down the adapter. * - *

    Note: This method does not release the underlying {@link MediaCodec}. Make sure to call - * {@link #shutdown()} before stopping or releasing the underlying {@link MediaCodec} to ensure - * the adapter is fully shutdown before the {@link MediaCodec} stops executing. Otherwise, there - * is a risk the adapter might interact with a stopped or released {@link MediaCodec}. + *

    This method does not stop or release the underlying {@link MediaCodec}. It should be called + * before stopping or releasing the {@link MediaCodec} to avoid the possibility of the adapter + * interacting with a stopped or released {@link MediaCodec}. */ void shutdown(); + + /** Returns the {@link MediaCodec} instance of this adapter. */ + MediaCodec getCodec(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java index 5be6031356..65f0c266a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallback.java @@ -31,6 +31,7 @@ import java.util.ArrayDeque; private final ArrayDeque bufferInfos; private final ArrayDeque formats; @Nullable private MediaFormat currentFormat; + @Nullable private MediaFormat pendingOutputFormat; @Nullable private IllegalStateException mediaCodecException; /** Creates a new MediaCodecAsyncCallback. */ @@ -82,14 +83,13 @@ import java.util.ArrayDeque; *

    Call this after {@link #dequeueOutputBufferIndex} returned {@link * MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}. * - * @throws {@link IllegalStateException} if you call this method before before { - * @link #dequeueOutputBufferIndex} returned {@link MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}. + * @throws IllegalStateException If called before {@link #dequeueOutputBufferIndex} has returned + * {@link MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}. */ public MediaFormat getOutputFormat() throws IllegalStateException { if (currentFormat == null) { throw new IllegalStateException(); } - return currentFormat; } @@ -100,7 +100,6 @@ import java.util.ArrayDeque; public void maybeThrowMediaCodecException() throws IllegalStateException { IllegalStateException exception = mediaCodecException; mediaCodecException = null; - if (exception != null) { throw exception; } @@ -111,6 +110,7 @@ import java.util.ArrayDeque; * and any error that was previously set. */ public void flush() { + pendingOutputFormat = formats.isEmpty() ? null : formats.getLast(); availableInputBuffers.clear(); availableOutputBuffers.clear(); bufferInfos.clear(); @@ -119,14 +119,18 @@ import java.util.ArrayDeque; } @Override - public void onInputBufferAvailable(MediaCodec mediaCodec, int i) { - availableInputBuffers.add(i); + public void onInputBufferAvailable(MediaCodec mediaCodec, int index) { + availableInputBuffers.add(index); } @Override public void onOutputBufferAvailable( - MediaCodec mediaCodec, int i, MediaCodec.BufferInfo bufferInfo) { - availableOutputBuffers.add(i); + MediaCodec mediaCodec, int index, MediaCodec.BufferInfo bufferInfo) { + if (pendingOutputFormat != null) { + addOutputFormat(pendingOutputFormat); + pendingOutputFormat = null; + } + availableOutputBuffers.add(index); bufferInfos.add(bufferInfo); } @@ -137,12 +141,17 @@ import java.util.ArrayDeque; @Override public void onOutputFormatChanged(MediaCodec mediaCodec, MediaFormat mediaFormat) { - availableOutputBuffers.add(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - formats.add(mediaFormat); + addOutputFormat(mediaFormat); + pendingOutputFormat = null; } @VisibleForTesting() void onMediaCodecError(IllegalStateException e) { mediaCodecException = e; } + + private void addOutputFormat(MediaFormat mediaFormat) { + availableOutputBuffers.add(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + formats.add(mediaFormat); + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java index 736f941152..404066e96d 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 @@ -328,7 +328,7 @@ public final class MediaCodecInfo { public boolean isSeamlessAdaptationSupported( Format oldFormat, Format newFormat, boolean isNewFormatComplete) { if (isVideo) { - return oldFormat.sampleMimeType.equals(newFormat.sampleMimeType) + return Assertions.checkNotNull(oldFormat.sampleMimeType).equals(newFormat.sampleMimeType) && oldFormat.rotationDegrees == newFormat.rotationDegrees && (adaptive || (oldFormat.width == newFormat.width && oldFormat.height == newFormat.height)) @@ -336,14 +336,16 @@ public final class MediaCodecInfo { || Util.areEqual(oldFormat.colorInfo, newFormat.colorInfo)); } else { if (!MimeTypes.AUDIO_AAC.equals(mimeType) - || !oldFormat.sampleMimeType.equals(newFormat.sampleMimeType) + || !Assertions.checkNotNull(oldFormat.sampleMimeType).equals(newFormat.sampleMimeType) || oldFormat.channelCount != newFormat.channelCount || oldFormat.sampleRate != newFormat.sampleRate) { return false; } // Check the codec profile levels support adaptation. + @Nullable Pair oldCodecProfileLevel = MediaCodecUtil.getCodecProfileAndLevel(oldFormat); + @Nullable Pair newCodecProfileLevel = MediaCodecUtil.getCodecProfileAndLevel(newFormat); if (oldCodecProfileLevel == null || newCodecProfileLevel == null) { @@ -402,6 +404,7 @@ public final class MediaCodecInfo { * the {@link MediaCodec}'s width and height alignment requirements, or null if not a video * codec. */ + @Nullable @RequiresApi(21) public Point alignVideoSizeV21(int width, int height) { if (capabilities == null) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 5818b51f7f..de3f595976 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.mediacodec; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static java.lang.Math.max; + import android.annotation.TargetApi; import android.media.MediaCodec; import android.media.MediaCodec.CodecException; @@ -34,7 +37,6 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; -import com.google.android.exoplayer2.decoder.CryptoInfo; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmSession; @@ -74,9 +76,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * *

      *
    • {@link #OPERATION_MODE_SYNCHRONOUS} - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD} *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK} + *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING} *
    */ @Documented @@ -84,42 +85,27 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) @IntDef({ OPERATION_MODE_SYNCHRONOUS, - OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD, OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD, - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK, OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING, - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING }) public @interface MediaCodecOperationMode {} + // TODO: Refactor these constants once internal evaluation completed. + // Do not assign values 1, 3 and 5 to a new operation mode until the evaluation is completed, + // otherwise existing clients may operate one of the dropped modes. + // [Internal ref: b/132684114] /** Operates the {@link MediaCodec} in synchronous mode. */ public static final int OPERATION_MODE_SYNCHRONOUS = 0; - /** - * Operates the {@link MediaCodec} in asynchronous mode and routes {@link MediaCodec.Callback} - * callbacks to the playback thread. - */ - public static final int OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD = 1; /** * Operates the {@link MediaCodec} in asynchronous mode and routes {@link MediaCodec.Callback} * callbacks to a dedicated thread. */ public static final int OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD = 2; - /** - * Operates the {@link MediaCodec} in asynchronous mode and routes {@link MediaCodec.Callback} - * callbacks to a dedicated thread. Uses granular locking for input and output buffers. - */ - public static final int OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK = 3; /** * Same as {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD}, and offloads queueing to another * thread. */ public static final int OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING = 4; - /** - * Same as {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK}, and offloads queueing - * to another thread. - */ - public static final int - OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING = 5; /** Thrown when a failure occurs instantiating a decoder. */ public static class DecoderInitializationException extends Exception { @@ -363,10 +349,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private final float assumedMinimumCodecOperatingRate; private final DecoderInputBuffer buffer; private final DecoderInputBuffer flagsOnlyBuffer; - private final BatchBuffer passthroughBatchBuffer; + private final BatchBuffer bypassBatchBuffer; private final TimedValueQueue formatQueue; private final ArrayList decodeOnlyPresentationTimestamps; private final MediaCodec.BufferInfo outputBufferInfo; + private final long[] pendingOutputStreamStartPositionsUs; private final long[] pendingOutputStreamOffsetsUs; private final long[] pendingOutputStreamSwitchTimesUs; @@ -376,11 +363,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Nullable private DrmSession sourceDrmSession; @Nullable private MediaCrypto mediaCrypto; private boolean mediaCryptoRequiresSecureDecoder; - private long renderTimeLimitMs; private float operatingRate; @Nullable private MediaCodec codec; @Nullable private MediaCodecAdapter codecAdapter; - @Nullable private Format codecFormat; + @Nullable private Format codecInputFormat; + @Nullable private MediaFormat codecOutputMediaFormat; + private boolean codecOutputMediaFormatChanged; private float codecOperatingRate; @Nullable private ArrayDeque availableCodecInfos; @Nullable private DecoderInitializationException preferredDecoderInitializationException; @@ -396,16 +384,17 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private boolean codecNeedsAdaptationWorkaroundBuffer; private boolean shouldSkipAdaptationWorkaroundOutputBuffer; private boolean codecNeedsEosPropagation; + @Nullable private C2Mp3TimestampTracker c2Mp3TimestampTracker; private ByteBuffer[] inputBuffers; private ByteBuffer[] outputBuffers; private long codecHotswapDeadlineMs; private int inputIndex; private int outputIndex; - private ByteBuffer outputBuffer; + @Nullable private ByteBuffer outputBuffer; private boolean isDecodeOnlyOutputBuffer; private boolean isLastOutputBuffer; - private boolean passthroughEnabled; - private boolean passthroughDrainAndReinitialize; + private boolean bypassEnabled; + private boolean bypassDrainAndReinitialize; private boolean codecReconfigured; @ReconfigurationState private int codecReconfigurationState; @DrainState private int codecDrainState; @@ -417,15 +406,14 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private long lastBufferInStreamPresentationTimeUs; private boolean inputStreamEnded; private boolean outputStreamEnded; - private boolean waitingForKeys; - private boolean waitingForFirstSyncSample; private boolean waitingForFirstSampleInFormat; private boolean pendingOutputEndOfStream; @MediaCodecOperationMode private int mediaCodecOperationMode; + @Nullable private ExoPlaybackException pendingPlaybackException; protected DecoderCounters decoderCounters; + private long outputStreamStartPositionUs; private long outputStreamOffsetUs; private int pendingOutputStreamOffsetCount; - private boolean receivedOutputMediaFormatChange; /** * @param trackType The track type that the renderer handles. One of the {@code C.TRACK_TYPE_*} @@ -453,29 +441,16 @@ public abstract class MediaCodecRenderer extends BaseRenderer { decodeOnlyPresentationTimestamps = new ArrayList<>(); outputBufferInfo = new MediaCodec.BufferInfo(); operatingRate = 1f; - renderTimeLimitMs = C.TIME_UNSET; mediaCodecOperationMode = OPERATION_MODE_SYNCHRONOUS; + pendingOutputStreamStartPositionsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamSwitchTimesUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; + outputStreamStartPositionUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET; - passthroughBatchBuffer = new BatchBuffer(); + bypassBatchBuffer = new BatchBuffer(); resetCodecStateForRelease(); } - /** - * Set a limit on the time a single {@link #render(long, long)} call can spend draining and - * filling the decoder. - * - *

    This method is experimental, and will be renamed or removed in a future release. It should - * only be called before the renderer is used. - * - * @param renderTimeLimitMs The render time limit in milliseconds, or {@link C#TIME_UNSET} for no - * limit. - */ - public void experimental_setRenderTimeLimitMs(long renderTimeLimitMs) { - this.renderTimeLimitMs = renderTimeLimitMs; - } - /** * Set the mode of operation of the underlying {@link MediaCodec}. * @@ -486,30 +461,18 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

      *
    • {@link #OPERATION_MODE_SYNCHRONOUS}: The {@link MediaCodec} will operate in * synchronous mode. - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD}: The {@link MediaCodec} will - * operate in asynchronous mode and {@link MediaCodec.Callback} callbacks will be routed - * to the playback thread. This mode requires API level ≥ 21; if the API level is - * ≤ 20, the operation mode will be set to {@link - * MediaCodecRenderer#OPERATION_MODE_SYNCHRONOUS}. *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD}: The {@link MediaCodec} will * operate in asynchronous mode and {@link MediaCodec.Callback} callbacks will be routed * to a dedicated thread. This mode requires API level ≥ 23; if the API level is ≤ * 22, the operation mode will be set to {@link #OPERATION_MODE_SYNCHRONOUS}. - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK}: Same as {@link - * #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} and, in addition, input buffers will - * submitted to the {@link MediaCodec} in a separate thread. *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING}: Same as * {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} and, in addition, input buffers * will be submitted to the {@link MediaCodec} in a separate thread. - *
    • {@link - * #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING}: Same - * as {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK} and, in addition, - * input buffers will be submitted to the {@link MediaCodec} in a separate thread. *
    * By default, the operation mode is set to {@link * MediaCodecRenderer#OPERATION_MODE_SYNCHRONOUS}. */ - public void experimental_setMediaCodecOperationMode(@MediaCodecOperationMode int mode) { + public void experimentalSetMediaCodecOperationMode(@MediaCodecOperationMode int mode) { mediaCodecOperationMode = mode; } @@ -560,7 +523,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * Configures a newly created {@link MediaCodec}. * * @param codecInfo Information about the {@link MediaCodec} being configured. - * @param codec The {@link MediaCodec} to configure. + * @param codecAdapter The {@link MediaCodecAdapter} to configure. * @param format The {@link Format} for which the codec is being configured. * @param crypto For drm protected playbacks, a {@link MediaCrypto} to use for decryption. * @param codecOperatingRate The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if @@ -568,19 +531,19 @@ public abstract class MediaCodecRenderer extends BaseRenderer { */ protected abstract void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate); - protected final void maybeInitCodecOrPassthrough() throws ExoPlaybackException { - if (codec != null || passthroughEnabled || inputFormat == null) { - // We have a codec or using passthrough, or don't have a format to decide how to render. + protected final void maybeInitCodecOrBypass() throws ExoPlaybackException { + if (codec != null || bypassEnabled || inputFormat == null) { + // We have a codec, are bypassing it, or don't have a format to decide how to render. return; } - if (inputFormat.drmInitData == null && usePassthrough(inputFormat)) { - initPassthrough(inputFormat); + if (sourceDrmSession == null && shouldUseBypass(inputFormat)) { + initBypass(inputFormat); return; } @@ -630,12 +593,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } /** - * Returns whether encoded passthrough should be used for playing back the input format. + * Returns whether buffers in the input format can be processed without a codec. + * + *

    This method is only called if the content is not DRM protected, because if the content is + * DRM protected use of bypass is never possible. * * @param format The input {@link Format}. - * @return Whether passthrough playback is supported. + * @return Whether playback bypassing {@link MediaCodec} is supported. */ - protected boolean usePassthrough(Format format) { + protected boolean shouldUseBypass(Format format) { return false; } @@ -652,27 +618,51 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } /** - * Polls the pending output format queue for a given buffer timestamp. If a format is present, it - * is removed and returned. Otherwise returns {@code null}. Subclasses should only call this - * method if they are taking over responsibility for output format propagation (e.g., when using - * video tunneling). + * Sets an exception to be re-thrown by render. + * + * @param exception The exception. */ - protected final void updateOutputFormatForTime(long presentationTimeUs) { + protected final void setPendingPlaybackException(ExoPlaybackException exception) { + pendingPlaybackException = exception; + } + + /** + * Updates the output formats for the specified output buffer timestamp, calling {@link + * #onOutputFormatChanged} if a change has occurred. + * + *

    Subclasses should only call this method if operating in a mode where buffers are not + * dequeued from the decoder, for example when using video tunneling). + * + * @throws ExoPlaybackException Thrown if an error occurs as a result of the output format change. + */ + protected final void updateOutputFormatForTime(long presentationTimeUs) + throws ExoPlaybackException { + boolean outputFormatChanged = false; @Nullable Format format = formatQueue.pollFloor(presentationTimeUs); + if (format == null && codecOutputMediaFormatChanged) { + // If the codec's output MediaFormat has changed then there should be a corresponding Format + // change, which we've not found. Check the Format queue in case the corresponding + // presentation timestamp is greater than presentationTimeUs, which can happen for some codecs + // [Internal ref: b/162719047]. + format = formatQueue.pollFirst(); + } if (format != null) { outputFormat = format; - onOutputFormatChanged(outputFormat); - } else if (receivedOutputMediaFormatChange && outputFormat != null) { - // No Format change with the MediaFormat change, so we need to update based on the existing - // Format. - configureOutput(outputFormat); + outputFormatChanged = true; + } + if (outputFormatChanged || (codecOutputMediaFormatChanged && outputFormat != null)) { + onOutputFormatChanged(outputFormat, codecOutputMediaFormat); + codecOutputMediaFormatChanged = false; } - - receivedOutputMediaFormatChange = false; } @Nullable - protected final Format getCurrentOutputFormat() { + protected Format getInputFormat() { + return inputFormat; + } + + @Nullable + protected final Format getOutputFormat() { return outputFormat; } @@ -681,6 +671,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return codec; } + @Nullable + protected final MediaFormat getCodecOutputMediaFormat() { + return codecOutputMediaFormat; + } + @Nullable protected final MediaCodecInfo getCodecInfo() { return codecInfo; @@ -693,9 +688,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { - if (outputStreamOffsetUs == C.TIME_UNSET) { - outputStreamOffsetUs = offsetUs; + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { + if (this.outputStreamOffsetUs == C.TIME_UNSET) { + checkState(this.outputStreamStartPositionUs == C.TIME_UNSET); + this.outputStreamStartPositionUs = startPositionUs; + this.outputStreamOffsetUs = offsetUs; } else { if (pendingOutputStreamOffsetCount == pendingOutputStreamOffsetsUs.length) { Log.w( @@ -705,6 +703,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } else { pendingOutputStreamOffsetCount++; } + pendingOutputStreamStartPositionsUs[pendingOutputStreamOffsetCount - 1] = startPositionUs; pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1] = offsetUs; pendingOutputStreamSwitchTimesUs[pendingOutputStreamOffsetCount - 1] = largestQueuedPresentationTimeUs; @@ -716,8 +715,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { inputStreamEnded = false; outputStreamEnded = false; pendingOutputEndOfStream = false; - if (passthroughEnabled) { - passthroughBatchBuffer.flush(); + if (bypassEnabled) { + bypassBatchBuffer.flush(); } else { flushOrReinitializeCodec(); } @@ -730,6 +729,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { formatQueue.clear(); if (pendingOutputStreamOffsetCount != 0) { outputStreamOffsetUs = pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1]; + outputStreamStartPositionUs = + pendingOutputStreamStartPositionsUs[pendingOutputStreamOffsetCount - 1]; pendingOutputStreamOffsetCount = 0; } } @@ -747,6 +748,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override protected void onDisabled() { inputFormat = null; + outputStreamStartPositionUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET; pendingOutputStreamOffsetCount = 0; if (sourceDrmSession != null || codecDrmSession != null) { @@ -760,17 +762,17 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override protected void onReset() { try { - disablePassthrough(); + disableBypass(); releaseCodec(); } finally { setSourceDrmSession(null); } } - private void disablePassthrough() { - passthroughDrainAndReinitialize = false; - passthroughBatchBuffer.clear(); - passthroughEnabled = false; + private void disableBypass() { + bypassDrainAndReinitialize = false; + bypassBatchBuffer.clear(); + bypassEnabled = false; } protected void releaseCodec() { @@ -813,6 +815,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { pendingOutputEndOfStream = false; processEndOfStream(); } + if (pendingPlaybackException != null) { + ExoPlaybackException playbackException = pendingPlaybackException; + pendingPlaybackException = null; + throw playbackException; + } + try { if (outputStreamEnded) { renderToEndOfStream(); @@ -823,17 +831,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return; } // We have a format. - maybeInitCodecOrPassthrough(); - if (passthroughEnabled) { - TraceUtil.beginSection("renderPassthrough"); - while (renderPassthrough(positionUs, elapsedRealtimeUs)) {} + maybeInitCodecOrBypass(); + if (bypassEnabled) { + TraceUtil.beginSection("bypassRender"); + while (bypassRender(positionUs, elapsedRealtimeUs)) {} TraceUtil.endSection(); } else if (codec != null) { - long renderStartTimeMs = SystemClock.elapsedRealtime(); TraceUtil.beginSection("drainAndFeed"); - while (drainOutputBuffer(positionUs, elapsedRealtimeUs) - && shouldContinueRendering(renderStartTimeMs)) {} - while (feedInputBuffer() && shouldContinueRendering(renderStartTimeMs)) {} + while (drainOutputBuffer(positionUs, elapsedRealtimeUs)) {} + while (feedInputBuffer()) {} TraceUtil.endSection(); } else { decoderCounters.skippedInputBufferCount += skipSource(positionUs); @@ -846,7 +852,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { decoderCounters.ensureUpdated(); } catch (IllegalStateException e) { if (isMediaCodecException(e)) { - throw createRendererException(e, inputFormat); + throw createRendererException(createDecoderException(e, getCodecInfo()), inputFormat); } throw e; } @@ -857,7 +863,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * This method is a no-op if the codec is {@code null}. * *

    The implementation of this method calls {@link #flushOrReleaseCodec()}, and {@link - * #maybeInitCodecOrPassthrough()} if the codec needs to be re-instantiated. + * #maybeInitCodecOrBypass()} if the codec needs to be re-instantiated. * * @return Whether the codec was released and reinitialized, rather than being flushed. * @throws ExoPlaybackException If an error occurs re-instantiating the codec. @@ -865,7 +871,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { protected final boolean flushOrReinitializeCodec() throws ExoPlaybackException { boolean released = flushOrReleaseCodec(); if (released) { - maybeInitCodecOrPassthrough(); + maybeInitCodecOrBypass(); } return released; } @@ -903,15 +909,16 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecHotswapDeadlineMs = C.TIME_UNSET; codecReceivedEos = false; codecReceivedBuffers = false; - waitingForFirstSyncSample = true; codecNeedsAdaptationWorkaroundBuffer = false; shouldSkipAdaptationWorkaroundOutputBuffer = false; isDecodeOnlyOutputBuffer = false; isLastOutputBuffer = false; - waitingForKeys = false; decodeOnlyPresentationTimestamps.clear(); largestQueuedPresentationTimeUs = C.TIME_UNSET; lastBufferInStreamPresentationTimeUs = C.TIME_UNSET; + if (c2Mp3TimestampTracker != null) { + c2Mp3TimestampTracker.reset(); + } codecDrainState = DRAIN_STATE_NONE; codecDrainAction = DRAIN_ACTION_NONE; // Reconfiguration data sent shortly before the flush may not have been processed by the @@ -931,9 +938,13 @@ public abstract class MediaCodecRenderer extends BaseRenderer { protected void resetCodecStateForRelease() { resetCodecStateForFlush(); + pendingPlaybackException = null; + c2Mp3TimestampTracker = null; availableCodecInfos = null; codecInfo = null; - codecFormat = null; + codecInputFormat = null; + codecOutputMediaFormat = null; + codecOutputMediaFormatChanged = false; codecHasOutputMediaFormat = false; codecOperatingRate = CODEC_OPERATING_RATE_UNSET; codecAdaptationWorkaroundMode = ADAPTATION_WORKAROUND_MODE_NEVER; @@ -1059,23 +1070,23 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } /** - * Configures passthrough where no codec is used. Called instead of {@link - * #configureCodec(MediaCodecInfo, MediaCodec, Format, MediaCrypto, float)} when no codec is used - * in passthrough. + * Configures rendering where no codec is used. Called instead of {@link + * #configureCodec(MediaCodecInfo, MediaCodecAdapter, Format, MediaCrypto, float)} when no codec + * is used to render. */ - private void initPassthrough(Format format) { - disablePassthrough(); // In case of transition between 2 passthrough formats. + private void initBypass(Format format) { + disableBypass(); // In case of transition between 2 bypass formats. String mimeType = format.sampleMimeType; if (!MimeTypes.AUDIO_AAC.equals(mimeType) && !MimeTypes.AUDIO_MPEG.equals(mimeType) && !MimeTypes.AUDIO_OPUS.equals(mimeType)) { - // TODO(b/154746451): Batching provokes frame drops in non offload passthrough. - passthroughBatchBuffer.setMaxAccessUnitCount(1); + // TODO(b/154746451): Batching provokes frame drops in non offload. + bypassBatchBuffer.setMaxAccessUnitCount(1); } else { - passthroughBatchBuffer.setMaxAccessUnitCount(BatchBuffer.DEFAULT_BATCH_SIZE_ACCESS_UNITS); + bypassBatchBuffer.setMaxAccessUnitCount(BatchBuffer.DEFAULT_BATCH_SIZE_ACCESS_UNITS); } - passthroughEnabled = true; + bypassEnabled = true; } private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exception { @@ -1097,26 +1108,14 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecInitializingTimestamp = SystemClock.elapsedRealtime(); TraceUtil.beginSection("createCodec:" + codecName); codec = MediaCodec.createByCodecName(codecName); - if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD - && Util.SDK_INT >= 21) { - codecAdapter = new AsynchronousMediaCodecAdapter(codec); - } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD + if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD && Util.SDK_INT >= 23) { - codecAdapter = new DedicatedThreadAsyncMediaCodecAdapter(codec, getTrackType()); - } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK - && Util.SDK_INT >= 23) { - codecAdapter = new MultiLockAsyncMediaCodecAdapter(codec, getTrackType()); + codecAdapter = new AsynchronousMediaCodecAdapter(codec, getTrackType()); } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING && Util.SDK_INT >= 23) { codecAdapter = - new DedicatedThreadAsyncMediaCodecAdapter( - codec, /* enableAsynchronousQueueing= */ true, getTrackType()); - } else if (mediaCodecOperationMode - == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING - && Util.SDK_INT >= 23) { - codecAdapter = - new MultiLockAsyncMediaCodecAdapter( + new AsynchronousMediaCodecAdapter( codec, /* enableAsynchronousQueueing= */ true, getTrackType()); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec); @@ -1124,7 +1123,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { TraceUtil.endSection(); TraceUtil.beginSection("configureCodec"); - configureCodec(codecInfo, codec, inputFormat, crypto, codecOperatingRate); + configureCodec(codecInfo, codecAdapter, inputFormat, crypto, codecOperatingRate); TraceUtil.endSection(); TraceUtil.beginSection("startCodec"); codecAdapter.start(); @@ -1146,18 +1145,23 @@ public abstract class MediaCodecRenderer extends BaseRenderer { this.codecAdapter = codecAdapter; this.codecInfo = codecInfo; this.codecOperatingRate = codecOperatingRate; - codecFormat = inputFormat; + codecInputFormat = inputFormat; codecAdaptationWorkaroundMode = codecAdaptationWorkaroundMode(codecName); codecNeedsReconfigureWorkaround = codecNeedsReconfigureWorkaround(codecName); - codecNeedsDiscardToSpsWorkaround = codecNeedsDiscardToSpsWorkaround(codecName, codecFormat); + codecNeedsDiscardToSpsWorkaround = + codecNeedsDiscardToSpsWorkaround(codecName, codecInputFormat); codecNeedsFlushWorkaround = codecNeedsFlushWorkaround(codecName); codecNeedsSosFlushWorkaround = codecNeedsSosFlushWorkaround(codecName); codecNeedsEosFlushWorkaround = codecNeedsEosFlushWorkaround(codecName); codecNeedsEosOutputExceptionWorkaround = codecNeedsEosOutputExceptionWorkaround(codecName); codecNeedsMonoChannelCountWorkaround = - codecNeedsMonoChannelCountWorkaround(codecName, codecFormat); + codecNeedsMonoChannelCountWorkaround(codecName, codecInputFormat); codecNeedsEosPropagation = codecNeedsEosPropagationWorkaround(codecInfo) || getCodecNeedsEosPropagation(); + if ("c2.android.mp3.decoder".equals(codecInfo.name)) { + c2Mp3TimestampTracker = new C2Mp3TimestampTracker(); + } + if (getState() == STATE_STARTED) { codecHotswapDeadlineMs = SystemClock.elapsedRealtime() + MAX_CODEC_HOTSWAP_TIME_MS; } @@ -1167,11 +1171,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { onCodecInitialized(codecName, codecInitializedTimestamp, elapsed); } - private boolean shouldContinueRendering(long renderStartTimeMs) { - return renderTimeLimitMs == C.TIME_UNSET - || SystemClock.elapsedRealtime() - renderStartTimeMs < renderTimeLimitMs; - } - private void getCodecBuffers(MediaCodec codec) { if (Util.SDK_INT < 21) { inputBuffers = codec.getInputBuffers(); @@ -1194,6 +1193,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } } + @Nullable private ByteBuffer getOutputBuffer(int outputIndex) { if (Util.SDK_INT >= 21) { return codec.getOutputBuffer(outputIndex); @@ -1267,25 +1267,20 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return true; } - @SampleStream.ReadDataResult int result; - FormatHolder formatHolder = getFormatHolder(); - int adaptiveReconfigurationBytes = 0; - if (waitingForKeys) { - // We've already read an encrypted sample into buffer, and are waiting for keys. - result = C.RESULT_BUFFER_READ; - } else { - // For adaptive reconfiguration OMX decoders expect all reconfiguration data to be supplied - // at the start of the buffer that also contains the first frame in the new format. - if (codecReconfigurationState == RECONFIGURATION_STATE_WRITE_PENDING) { - for (int i = 0; i < codecFormat.initializationData.size(); i++) { - byte[] data = codecFormat.initializationData.get(i); - buffer.data.put(data); - } - codecReconfigurationState = RECONFIGURATION_STATE_QUEUE_PENDING; + // For adaptive reconfiguration, decoders expect all reconfiguration data to be supplied at + // the start of the buffer that also contains the first frame in the new format. + if (codecReconfigurationState == RECONFIGURATION_STATE_WRITE_PENDING) { + for (int i = 0; i < codecInputFormat.initializationData.size(); i++) { + byte[] data = codecInputFormat.initializationData.get(i); + buffer.data.put(data); } - adaptiveReconfigurationBytes = buffer.data.position(); - result = readSource(formatHolder, buffer, false); + codecReconfigurationState = RECONFIGURATION_STATE_QUEUE_PENDING; } + int adaptiveReconfigurationBytes = buffer.data.position(); + + FormatHolder formatHolder = getFormatHolder(); + @SampleStream.ReadDataResult + int result = readSource(formatHolder, buffer, /* formatRequired= */ false); if (hasReadStreamToEnd()) { // Notify output queue of the last buffer's timestamp. @@ -1311,7 +1306,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { if (codecReconfigurationState == RECONFIGURATION_STATE_QUEUE_PENDING) { // We received a new format immediately before the end of the stream. We need to clear // the corresponding reconfiguration data from the current buffer, but re-write it into - // a subsequent buffer if there are any (e.g. if the user seeks backwards). + // a subsequent buffer if there are any (for example, if the user seeks backwards). buffer.clear(); codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; } @@ -1325,7 +1320,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { // Do nothing. } else { codecReceivedEos = true; - codecAdapter.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + codecAdapter.queueInputBuffer( + inputIndex, + /* offset= */ 0, + /* size= */ 0, + /* presentationTimeUs= */ 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); resetInputBuffer(); } } catch (CryptoException e) { @@ -1333,20 +1333,25 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } return false; } - if (waitingForFirstSyncSample && !buffer.isKeyFrame()) { + + // This logic is required for cases where the decoder needs to be flushed or re-instantiated + // during normal consumption of samples from the source (i.e., without a corresponding + // Renderer.enable or Renderer.resetPosition call). This is necessary for certain legacy and + // workaround behaviors, for example when switching the output Surface on API levels prior to + // the introduction of MediaCodec.setOutputSurface. + if (!codecReceivedBuffers && !buffer.isKeyFrame()) { buffer.clear(); if (codecReconfigurationState == RECONFIGURATION_STATE_QUEUE_PENDING) { - // The buffer we just cleared contained reconfiguration data. We need to re-write this - // data into a subsequent buffer (if there is one). + // The buffer we just cleared contained reconfiguration data. We need to re-write this data + // into a subsequent buffer (if there is one). codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; } return true; } - waitingForFirstSyncSample = false; + boolean bufferEncrypted = buffer.isEncrypted(); - waitingForKeys = shouldWaitForKeys(bufferEncrypted); - if (waitingForKeys) { - return false; + if (bufferEncrypted) { + buffer.cryptoInfo.increaseClearDataFirstSubSampleBy(adaptiveReconfigurationBytes); } if (codecNeedsDiscardToSpsWorkaround && !bufferEncrypted) { NalUnitUtil.discardToSps(buffer.data); @@ -1355,51 +1360,52 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } codecNeedsDiscardToSpsWorkaround = false; } + + long presentationTimeUs = buffer.timeUs; + + if (c2Mp3TimestampTracker != null) { + presentationTimeUs = + c2Mp3TimestampTracker.updateAndGetPresentationTimeUs(inputFormat, buffer); + } + + if (buffer.isDecodeOnly()) { + decodeOnlyPresentationTimestamps.add(presentationTimeUs); + } + if (waitingForFirstSampleInFormat) { + formatQueue.add(presentationTimeUs, inputFormat); + waitingForFirstSampleInFormat = false; + } + + // TODO(b/158483277): Find the root cause of why a gap is introduced in MP3 playback when using + // presentationTimeUs from the c2Mp3TimestampTracker. + if (c2Mp3TimestampTracker != null) { + largestQueuedPresentationTimeUs = max(largestQueuedPresentationTimeUs, buffer.timeUs); + } else { + largestQueuedPresentationTimeUs = max(largestQueuedPresentationTimeUs, presentationTimeUs); + } + buffer.flip(); + if (buffer.hasSupplementalData()) { + handleInputBufferSupplementalData(buffer); + } + + onQueueInputBuffer(buffer); try { - long presentationTimeUs = buffer.timeUs; - if (buffer.isDecodeOnly()) { - decodeOnlyPresentationTimestamps.add(presentationTimeUs); - } - if (waitingForFirstSampleInFormat) { - formatQueue.add(presentationTimeUs, inputFormat); - waitingForFirstSampleInFormat = false; - } - largestQueuedPresentationTimeUs = - Math.max(largestQueuedPresentationTimeUs, presentationTimeUs); - - buffer.flip(); - if (buffer.hasSupplementalData()) { - handleInputBufferSupplementalData(buffer); - } - onQueueInputBuffer(buffer); - if (bufferEncrypted) { - CryptoInfo cryptoInfo = buffer.cryptoInfo; - cryptoInfo.increaseClearDataFirstSubSampleBy(adaptiveReconfigurationBytes); - codecAdapter.queueSecureInputBuffer(inputIndex, 0, cryptoInfo, presentationTimeUs, 0); + codecAdapter.queueSecureInputBuffer( + inputIndex, /* offset= */ 0, buffer.cryptoInfo, presentationTimeUs, /* flags= */ 0); } else { - codecAdapter.queueInputBuffer(inputIndex, 0, buffer.data.limit(), presentationTimeUs, 0); + codecAdapter.queueInputBuffer( + inputIndex, /* offset= */ 0, buffer.data.limit(), presentationTimeUs, /* flags= */ 0); } - resetInputBuffer(); - codecReceivedBuffers = true; - codecReconfigurationState = RECONFIGURATION_STATE_NONE; - decoderCounters.inputBufferCount++; } catch (CryptoException e) { throw createRendererException(e, inputFormat); } - return true; - } - private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - if (codecDrmSession == null - || (!bufferEncrypted && codecDrmSession.playClearSamplesWithoutKeys())) { - return false; - } - @DrmSession.State int drmSessionState = codecDrmSession.getState(); - if (drmSessionState == DrmSession.STATE_ERROR) { - throw createRendererException(codecDrmSession.getError(), inputFormat); - } - return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; + resetInputBuffer(); + codecReceivedBuffers = true; + codecReconfigurationState = RECONFIGURATION_STATE_NONE; + decoderCounters.inputBufferCount++; + return true; } /** @@ -1423,24 +1429,28 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * @param formatHolder A {@link FormatHolder} that holds the new {@link Format}. * @throws ExoPlaybackException If an error occurs re-initializing the {@link MediaCodec}. */ + @CallSuper protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { waitingForFirstSampleInFormat = true; Format newFormat = Assertions.checkNotNull(formatHolder.format); setSourceDrmSession(formatHolder.drmSession); inputFormat = newFormat; - if (passthroughEnabled) { - passthroughDrainAndReinitialize = true; - return; // Need to drain passthrough first. + if (bypassEnabled) { + bypassDrainAndReinitialize = true; + return; // Need to drain batch buffer first. } if (codec == null) { - maybeInitCodecOrPassthrough(); + if (!legacyKeepAvailableCodecInfosWithoutCodec()) { + availableCodecInfos = null; + } + maybeInitCodecOrBypass(); return; } // We have an existing codec that we may need to reconfigure or re-initialize or release it to - // switch to passthrough. If the existing codec instance is being kept then its operating rate + // switch to bypass. If the existing codec instance is being kept then its operating rate // may need to be updated. if ((sourceDrmSession == null && codecDrmSession != null) @@ -1456,12 +1466,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return; } - switch (canKeepCodec(codec, codecInfo, codecFormat, newFormat)) { + switch (canKeepCodec(codec, codecInfo, codecInputFormat, newFormat)) { case KEEP_CODEC_RESULT_NO: drainAndReinitializeCodec(); break; case KEEP_CODEC_RESULT_YES_WITH_FLUSH: - codecFormat = newFormat; + codecInputFormat = newFormat; updateCodecOperatingRate(); if (sourceDrmSession != codecDrmSession) { drainAndUpdateCodecDrmSession(); @@ -1478,9 +1488,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { codecNeedsAdaptationWorkaroundBuffer = codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION - && newFormat.width == codecFormat.width - && newFormat.height == codecFormat.height); - codecFormat = newFormat; + && newFormat.width == codecInputFormat.width + && newFormat.height == codecInputFormat.height); + codecInputFormat = newFormat; updateCodecOperatingRate(); if (sourceDrmSession != codecDrmSession) { drainAndUpdateCodecDrmSession(); @@ -1488,7 +1498,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } break; case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: - codecFormat = newFormat; + codecInputFormat = newFormat; updateCodecOperatingRate(); if (sourceDrmSession != codecDrmSession) { drainAndUpdateCodecDrmSession(); @@ -1500,54 +1510,29 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } /** - * Called when the output {@link MediaFormat} of the {@link MediaCodec} changes. + * Returns whether to keep available codec infos when the codec hasn't been initialized, which is + * the behavior before a bug fix. See also [Internal: b/162837741]. + */ + protected boolean legacyKeepAvailableCodecInfosWithoutCodec() { + return false; + } + + /** + * Called when one of the output formats changes. * *

    The default implementation is a no-op. * - * @param codec The {@link MediaCodec} instance. - * @param outputMediaFormat The new output {@link MediaFormat}. - * @throws ExoPlaybackException Thrown if an error occurs handling the new output media format. + * @param format The input {@link Format} to which future output now corresponds. If the renderer + * is in bypass mode, this is also the output format. + * @param mediaFormat The codec output {@link MediaFormat}, or {@code null} if the renderer is in + * bypass mode. + * @throws ExoPlaybackException Thrown if an error occurs configuring the output. */ - protected void onOutputMediaFormatChanged(MediaCodec codec, MediaFormat outputMediaFormat) + protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaFormat) throws ExoPlaybackException { // Do nothing. } - /** - * Called when the output {@link Format} changes. - * - *

    The default implementation is a no-op. - * - * @param outputFormat The new output {@link Format}. - */ - protected void onOutputFormatChanged(Format outputFormat) { - // Do nothing. - } - - /** - * Configures the renderer output based on a {@link Format}. - * - *

    The default implementation is a no-op. - * - * @param outputFormat The format to configure the output with. - */ - protected void configureOutput(Format outputFormat) { - // Do nothing. - } - - /** - * Called when the output {@link Format} changes in passthrough. - * - *

    The default implementation is a no-op. - * - * @param outputFormat The new output {@link MediaFormat}. - * @throws ExoPlaybackException Thrown if an error occurs handling the new output media format. - */ - // TODO(b/154849417): merge with {@link #onOutputFormatChanged(Format)}. - protected void onOutputPassthroughFormatChanged(Format outputFormat) throws ExoPlaybackException { - // Do nothing. - } - /** * Handles supplemental data associated with an input buffer. * @@ -1567,8 +1552,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { *

    The default implementation is a no-op. * * @param buffer The buffer to be queued. + * @throws ExoPlaybackException Thrown if an error occurs handling the input buffer. */ - protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + protected void onQueueInputBuffer(DecoderInputBuffer buffer) throws ExoPlaybackException { // Do nothing. } @@ -1581,8 +1567,15 @@ public abstract class MediaCodecRenderer extends BaseRenderer { protected void onProcessedOutputBuffer(long presentationTimeUs) { while (pendingOutputStreamOffsetCount != 0 && presentationTimeUs >= pendingOutputStreamSwitchTimesUs[0]) { + outputStreamStartPositionUs = pendingOutputStreamStartPositionsUs[0]; outputStreamOffsetUs = pendingOutputStreamOffsetsUs[0]; pendingOutputStreamOffsetCount--; + System.arraycopy( + pendingOutputStreamStartPositionsUs, + /* srcPos= */ 1, + pendingOutputStreamStartPositionsUs, + /* destPos= */ 0, + pendingOutputStreamOffsetCount); System.arraycopy( pendingOutputStreamOffsetsUs, /* srcPos= */ 1, @@ -1629,7 +1622,6 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override public boolean isReady() { return inputFormat != null - && !waitingForKeys && (isSourceReady() || hasOutputBuffer() || (codecHotswapDeadlineMs != C.TIME_UNSET @@ -1641,6 +1633,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return operatingRate; } + /** Returns the operating rate used by the current codec */ + protected float getCodecOperatingRate() { + return codecOperatingRate; + } + /** * Returns the {@link MediaFormat#KEY_OPERATING_RATE} value for a given renderer operating rate, * current {@link Format} and set of possible stream formats. @@ -1669,12 +1666,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } float newCodecOperatingRate = - getCodecOperatingRateV23(operatingRate, codecFormat, getStreamFormats()); + getCodecOperatingRateV23(operatingRate, codecInputFormat, getStreamFormats()); if (codecOperatingRate == newCodecOperatingRate) { // No change. } else if (newCodecOperatingRate == CODEC_OPERATING_RATE_UNSET) { // The only way to clear the operating rate is to instantiate a new codec instance. See - // [Internal ref: b/71987865]. + // [Internal ref: b/111543954]. drainAndReinitializeCodec(); } else if (codecOperatingRate != CODEC_OPERATING_RATE_UNSET || newCodecOperatingRate > assumedMinimumCodecOperatingRate) { @@ -1757,8 +1754,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { if (outputIndex < 0) { if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED /* (-2) */) { - processOutputMediaFormat(); - receivedOutputMediaFormatChange = true; + processOutputMediaFormatChanged(); return true; } else if (outputIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED /* (-3) */) { processOutputBuffersChanged(); @@ -1786,6 +1782,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { this.outputIndex = outputIndex; outputBuffer = getOutputBuffer(outputIndex); + // The dequeued buffer is a media buffer. Do some initial setup. // It will be processed by calling processOutputBuffer (possibly multiple times). if (outputBuffer != null) { @@ -1851,8 +1848,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return false; } - /** Processes a new output {@link MediaFormat}. */ - private void processOutputMediaFormat() throws ExoPlaybackException { + /** Processes a change in the decoder output {@link MediaFormat}. */ + private void processOutputMediaFormatChanged() { codecHasOutputMediaFormat = true; MediaFormat mediaFormat = codecAdapter.getOutputFormat(); if (codecAdaptationWorkaroundMode != ADAPTATION_WORKAROUND_MODE_NEVER @@ -1866,7 +1863,8 @@ public abstract class MediaCodecRenderer extends BaseRenderer { if (codecNeedsMonoChannelCountWorkaround) { mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1); } - onOutputMediaFormatChanged(codec, mediaFormat); + codecOutputMediaFormat = mediaFormat; + codecOutputMediaFormatChanged = true; } /** @@ -1896,8 +1894,11 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * iteration of the rendering loop. * @param elapsedRealtimeUs {@link SystemClock#elapsedRealtime()} in microseconds, measured at the * start of the current iteration of the rendering loop. - * @param codec The {@link MediaCodec} instance, or null in passthrough mode. - * @param buffer The output buffer to process. + * @param codec The {@link MediaCodec} instance, or null in bypass mode were no codec is used. + * @param buffer The output buffer to process, or null if the buffer data is not made available to + * the application layer (see {@link MediaCodec#getOutputBuffer(int)}). This {@code buffer} + * can only be null for video data. Note that the buffer data can still be rendered in this + * case by using the {@code bufferIndex}. * @param bufferIndex The index of the output buffer. * @param bufferFlags The flags attached to the output buffer. * @param sampleCount The number of samples extracted from the sample queue in the buffer. This @@ -1907,14 +1908,14 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * by the source. * @param isLastBuffer Whether the buffer is the last sample of the current stream. * @param format The {@link Format} associated with the buffer. - * @return Whether the output buffer was fully processed (e.g. rendered or skipped). + * @return Whether the output buffer was fully processed (for example, rendered or skipped). * @throws ExoPlaybackException If an error occurs processing the output buffer. */ protected abstract boolean processOutputBuffer( long positionUs, long elapsedRealtimeUs, @Nullable MediaCodec codec, - ByteBuffer buffer, + @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, int sampleCount, @@ -1973,6 +1974,13 @@ public abstract class MediaCodecRenderer extends BaseRenderer { return largestQueuedPresentationTimeUs; } + /** + * Returns the start position of the output {@link SampleStream}, in renderer time microseconds. + */ + protected final long getOutputStreamStartPositionUs() { + return outputStreamStartPositionUs; + } + /** * Returns the offset that should be subtracted from {@code bufferPresentationTimeUs} in {@link * #processOutputBuffer(long, long, MediaCodec, ByteBuffer, int, int, int, long, boolean, boolean, @@ -1984,7 +1992,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { /** Returns whether this renderer supports the given {@link Format Format's} DRM scheme. */ protected static boolean supportsFormatDrm(Format format) { - return format.drmInitData == null + return format.exoMediaCryptoType == null || FrameworkMediaCrypto.class.equals(format.exoMediaCryptoType); } @@ -2025,7 +2033,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { private void reinitializeCodec() throws ExoPlaybackException { releaseCodec(); - maybeInitCodecOrPassthrough(); + maybeInitCodecOrBypass(); } private boolean isDecodeOnlyBuffer(long presentationTimeUs) { @@ -2103,12 +2111,12 @@ public abstract class MediaCodecRenderer extends BaseRenderer { * @throws ExoPlaybackException If an error occurred while processing a buffer or handling a * format change. */ - private boolean renderPassthrough(long positionUs, long elapsedRealtimeUs) + private boolean bypassRender(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { - BatchBuffer batchBuffer = passthroughBatchBuffer; + BatchBuffer batchBuffer = bypassBatchBuffer; // Let's process the pending buffer if any. - Assertions.checkState(!outputStreamEnded); + checkState(!outputStreamEnded); if (!batchBuffer.isEmpty()) { // Optimisation: Do not process buffer if empty. if (processOutputBuffer( positionUs, @@ -2134,27 +2142,27 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } batchBuffer.batchWasConsumed(); - if (passthroughDrainAndReinitialize) { + if (bypassDrainAndReinitialize) { if (!batchBuffer.isEmpty()) { return true; // Drain the batch buffer before propagating the format change. } - disablePassthrough(); // The new format might not be supported in passthrough. - passthroughDrainAndReinitialize = false; - maybeInitCodecOrPassthrough(); - if (!passthroughEnabled) { - return false; // The new format is not supported in passthrough. + disableBypass(); // The new format might require a codec. + bypassDrainAndReinitialize = false; + maybeInitCodecOrBypass(); + if (!bypassEnabled) { + return false; // The new format is not supported in codec bypass. } } // Now refill the empty buffer for the next iteration. - Assertions.checkState(!inputStreamEnded); + checkState(!inputStreamEnded); FormatHolder formatHolder = getFormatHolder(); boolean formatChange = readBatchFromSource(formatHolder, batchBuffer); if (!batchBuffer.isEmpty() && waitingForFirstSampleInFormat) { // This is the first buffer in a new format, the output format must be updated. outputFormat = Assertions.checkNotNull(inputFormat); - onOutputPassthroughFormatChanged(outputFormat); + onOutputFormatChanged(outputFormat, /* mediaFormat= */ null); waitingForFirstSampleInFormat = false; } @@ -2312,6 +2320,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { String name = codecInfo.name; return (Util.SDK_INT <= 25 && "OMX.rk.video_decoder.avc".equals(name)) || (Util.SDK_INT <= 17 && "OMX.allwinner.video.decoder.avc".equals(name)) + || (Util.SDK_INT <= 29 + && ("OMX.broadcom.video_decoder.tunnel".equals(name) + || "OMX.broadcom.video_decoder.tunnel.secure".equals(name))) || ("Amazon".equals(Util.MANUFACTURER) && "AFTS".equals(Util.MODEL) && codecInfo.secure); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java index db68fb3e89..64eb0bb837 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java @@ -15,13 +15,14 @@ */ package com.google.android.exoplayer2.mediacodec; +import static java.lang.Math.max; + import android.annotation.SuppressLint; import android.media.MediaCodecInfo.CodecCapabilities; import android.media.MediaCodecInfo.CodecProfileLevel; import android.media.MediaCodecList; import android.text.TextUtils; import android.util.Pair; -import android.util.SparseIntArray; import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -35,7 +36,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; @@ -67,26 +67,16 @@ public final class MediaCodecUtil { // Codecs to constant mappings. // AVC. - private static final SparseIntArray AVC_PROFILE_NUMBER_TO_CONST; - private static final SparseIntArray AVC_LEVEL_NUMBER_TO_CONST; private static final String CODEC_ID_AVC1 = "avc1"; private static final String CODEC_ID_AVC2 = "avc2"; // VP9 - private static final SparseIntArray VP9_PROFILE_NUMBER_TO_CONST; - private static final SparseIntArray VP9_LEVEL_NUMBER_TO_CONST; private static final String CODEC_ID_VP09 = "vp09"; // HEVC. - private static final Map HEVC_CODEC_STRING_TO_PROFILE_LEVEL; private static final String CODEC_ID_HEV1 = "hev1"; private static final String CODEC_ID_HVC1 = "hvc1"; - // Dolby Vision. - private static final Map DOLBY_VISION_STRING_TO_PROFILE; - private static final Map DOLBY_VISION_STRING_TO_LEVEL; // AV1. - private static final SparseIntArray AV1_LEVEL_NUMBER_TO_CONST; private static final String CODEC_ID_AV01 = "av01"; // MP4A AAC. - private static final SparseIntArray MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE; private static final String CODEC_ID_MP4A = "mp4a"; // Lazily initialized. @@ -116,13 +106,13 @@ public final class MediaCodecUtil { } /** - * Returns information about a decoder suitable for audio passthrough. + * Returns information about a decoder that will only decrypt data, without decoding it. * * @return A {@link MediaCodecInfo} describing the decoder, or null if no suitable decoder exists. * @throws DecoderQueryException If there was an error querying the available decoders. */ @Nullable - public static MediaCodecInfo getPassthroughDecoderInfo() throws DecoderQueryException { + public static MediaCodecInfo getDecryptOnlyDecoderInfo() throws DecoderQueryException { return getDecoderInfo(MimeTypes.AUDIO_RAW, /* secure= */ false, /* tunneling= */ false); } @@ -218,11 +208,11 @@ public final class MediaCodecUtil { getDecoderInfo(MimeTypes.VIDEO_H264, /* secure= */ false, /* tunneling= */ false); if (decoderInfo != null) { for (CodecProfileLevel profileLevel : decoderInfo.getProfileLevels()) { - result = Math.max(avcLevelToMaxFrameSize(profileLevel.level), result); + result = max(avcLevelToMaxFrameSize(profileLevel.level), result); } // We assume support for at least 480p (SDK_INT >= 21) or 360p (SDK_INT < 21), which are // the levels mandated by the Android CDD. - result = Math.max(result, Util.SDK_INT >= 21 ? (720 * 480) : (480 * 360)); + result = max(result, Util.SDK_INT >= 21 ? (720 * 480) : (480 * 360)); } maxH264DecodableFrameSize = result; } @@ -689,13 +679,13 @@ public final class MediaCodecUtil { return null; } @Nullable String profileString = matcher.group(1); - @Nullable Integer profile = DOLBY_VISION_STRING_TO_PROFILE.get(profileString); + @Nullable Integer profile = dolbyVisionStringToProfile(profileString); if (profile == null) { Log.w(TAG, "Unknown Dolby Vision profile string: " + profileString); return null; } String levelString = parts[2]; - @Nullable Integer level = DOLBY_VISION_STRING_TO_LEVEL.get(levelString); + @Nullable Integer level = dolbyVisionStringToLevel(levelString); if (level == null) { Log.w(TAG, "Unknown Dolby Vision level string: " + levelString); return null; @@ -727,7 +717,7 @@ public final class MediaCodecUtil { return null; } @Nullable String levelString = parts[3]; - @Nullable Integer level = HEVC_CODEC_STRING_TO_PROFILE_LEVEL.get(levelString); + @Nullable Integer level = hevcCodecStringToProfileLevel(levelString); if (level == null) { Log.w(TAG, "Unknown HEVC level string: " + levelString); return null; @@ -763,12 +753,12 @@ public final class MediaCodecUtil { return null; } - int profile = AVC_PROFILE_NUMBER_TO_CONST.get(profileInteger, -1); + int profile = avcProfileNumberToConst(profileInteger); if (profile == -1) { Log.w(TAG, "Unknown AVC profile: " + profileInteger); return null; } - int level = AVC_LEVEL_NUMBER_TO_CONST.get(levelInteger, -1); + int level = avcLevelNumberToConst(levelInteger); if (level == -1) { Log.w(TAG, "Unknown AVC level: " + levelInteger); return null; @@ -792,12 +782,12 @@ public final class MediaCodecUtil { return null; } - int profile = VP9_PROFILE_NUMBER_TO_CONST.get(profileInteger, -1); + int profile = vp9ProfileNumberToConst(profileInteger); if (profile == -1) { Log.w(TAG, "Unknown VP9 profile: " + profileInteger); return null; } - int level = VP9_LEVEL_NUMBER_TO_CONST.get(levelInteger, -1); + int level = vp9LevelNumberToConst(levelInteger); if (level == -1) { Log.w(TAG, "Unknown VP9 level: " + levelInteger); return null; @@ -844,7 +834,7 @@ public final class MediaCodecUtil { profile = CodecProfileLevel.AV1ProfileMain10; } - int level = AV1_LEVEL_NUMBER_TO_CONST.get(levelInteger, -1); + int level = av1LevelNumberToConst(levelInteger); if (level == -1) { Log.w(TAG, "Unknown AV1 level: " + levelInteger); return null; @@ -905,7 +895,7 @@ public final class MediaCodecUtil { if (MimeTypes.AUDIO_AAC.equals(mimeType)) { // For MPEG-4 audio this is followed by an audio object type indication as a decimal number. int audioObjectTypeIndication = Integer.parseInt(parts[2]); - int profile = MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.get(audioObjectTypeIndication, -1); + int profile = mp4aAudioObjectTypeToProfile(audioObjectTypeIndication); if (profile != -1) { // Level is set to zero in AAC decoder CodecProfileLevels. return new Pair<>(profile, 0); @@ -1075,150 +1065,325 @@ public final class MediaCodecUtil { && secure == other.secure && tunneling == other.tunneling; } - } - static { - AVC_PROFILE_NUMBER_TO_CONST = new SparseIntArray(); - AVC_PROFILE_NUMBER_TO_CONST.put(66, CodecProfileLevel.AVCProfileBaseline); - AVC_PROFILE_NUMBER_TO_CONST.put(77, CodecProfileLevel.AVCProfileMain); - AVC_PROFILE_NUMBER_TO_CONST.put(88, CodecProfileLevel.AVCProfileExtended); - AVC_PROFILE_NUMBER_TO_CONST.put(100, CodecProfileLevel.AVCProfileHigh); - AVC_PROFILE_NUMBER_TO_CONST.put(110, CodecProfileLevel.AVCProfileHigh10); - AVC_PROFILE_NUMBER_TO_CONST.put(122, CodecProfileLevel.AVCProfileHigh422); - AVC_PROFILE_NUMBER_TO_CONST.put(244, CodecProfileLevel.AVCProfileHigh444); + private static int avcProfileNumberToConst(int profileNumber) { + switch (profileNumber) { + case 66: + return CodecProfileLevel.AVCProfileBaseline; + case 77: + return CodecProfileLevel.AVCProfileMain; + case 88: + return CodecProfileLevel.AVCProfileExtended; + case 100: + return CodecProfileLevel.AVCProfileHigh; + case 110: + return CodecProfileLevel.AVCProfileHigh10; + case 122: + return CodecProfileLevel.AVCProfileHigh422; + case 244: + return CodecProfileLevel.AVCProfileHigh444; + default: + return -1; + } + } - AVC_LEVEL_NUMBER_TO_CONST = new SparseIntArray(); - AVC_LEVEL_NUMBER_TO_CONST.put(10, CodecProfileLevel.AVCLevel1); + private static int avcLevelNumberToConst(int levelNumber) { // TODO: Find int for CodecProfileLevel.AVCLevel1b. - AVC_LEVEL_NUMBER_TO_CONST.put(11, CodecProfileLevel.AVCLevel11); - AVC_LEVEL_NUMBER_TO_CONST.put(12, CodecProfileLevel.AVCLevel12); - AVC_LEVEL_NUMBER_TO_CONST.put(13, CodecProfileLevel.AVCLevel13); - AVC_LEVEL_NUMBER_TO_CONST.put(20, CodecProfileLevel.AVCLevel2); - AVC_LEVEL_NUMBER_TO_CONST.put(21, CodecProfileLevel.AVCLevel21); - AVC_LEVEL_NUMBER_TO_CONST.put(22, CodecProfileLevel.AVCLevel22); - AVC_LEVEL_NUMBER_TO_CONST.put(30, CodecProfileLevel.AVCLevel3); - AVC_LEVEL_NUMBER_TO_CONST.put(31, CodecProfileLevel.AVCLevel31); - AVC_LEVEL_NUMBER_TO_CONST.put(32, CodecProfileLevel.AVCLevel32); - AVC_LEVEL_NUMBER_TO_CONST.put(40, CodecProfileLevel.AVCLevel4); - AVC_LEVEL_NUMBER_TO_CONST.put(41, CodecProfileLevel.AVCLevel41); - AVC_LEVEL_NUMBER_TO_CONST.put(42, CodecProfileLevel.AVCLevel42); - AVC_LEVEL_NUMBER_TO_CONST.put(50, CodecProfileLevel.AVCLevel5); - AVC_LEVEL_NUMBER_TO_CONST.put(51, CodecProfileLevel.AVCLevel51); - AVC_LEVEL_NUMBER_TO_CONST.put(52, CodecProfileLevel.AVCLevel52); + switch (levelNumber) { + case 10: + return CodecProfileLevel.AVCLevel1; + case 11: + return CodecProfileLevel.AVCLevel11; + case 12: + return CodecProfileLevel.AVCLevel12; + case 13: + return CodecProfileLevel.AVCLevel13; + case 20: + return CodecProfileLevel.AVCLevel2; + case 21: + return CodecProfileLevel.AVCLevel21; + case 22: + return CodecProfileLevel.AVCLevel22; + case 30: + return CodecProfileLevel.AVCLevel3; + case 31: + return CodecProfileLevel.AVCLevel31; + case 32: + return CodecProfileLevel.AVCLevel32; + case 40: + return CodecProfileLevel.AVCLevel4; + case 41: + return CodecProfileLevel.AVCLevel41; + case 42: + return CodecProfileLevel.AVCLevel42; + case 50: + return CodecProfileLevel.AVCLevel5; + case 51: + return CodecProfileLevel.AVCLevel51; + case 52: + return CodecProfileLevel.AVCLevel52; + default: + return -1; + } + } - VP9_PROFILE_NUMBER_TO_CONST = new SparseIntArray(); - VP9_PROFILE_NUMBER_TO_CONST.put(0, CodecProfileLevel.VP9Profile0); - VP9_PROFILE_NUMBER_TO_CONST.put(1, CodecProfileLevel.VP9Profile1); - VP9_PROFILE_NUMBER_TO_CONST.put(2, CodecProfileLevel.VP9Profile2); - VP9_PROFILE_NUMBER_TO_CONST.put(3, CodecProfileLevel.VP9Profile3); - VP9_LEVEL_NUMBER_TO_CONST = new SparseIntArray(); - VP9_LEVEL_NUMBER_TO_CONST.put(10, CodecProfileLevel.VP9Level1); - VP9_LEVEL_NUMBER_TO_CONST.put(11, CodecProfileLevel.VP9Level11); - VP9_LEVEL_NUMBER_TO_CONST.put(20, CodecProfileLevel.VP9Level2); - VP9_LEVEL_NUMBER_TO_CONST.put(21, CodecProfileLevel.VP9Level21); - VP9_LEVEL_NUMBER_TO_CONST.put(30, CodecProfileLevel.VP9Level3); - VP9_LEVEL_NUMBER_TO_CONST.put(31, CodecProfileLevel.VP9Level31); - VP9_LEVEL_NUMBER_TO_CONST.put(40, CodecProfileLevel.VP9Level4); - VP9_LEVEL_NUMBER_TO_CONST.put(41, CodecProfileLevel.VP9Level41); - VP9_LEVEL_NUMBER_TO_CONST.put(50, CodecProfileLevel.VP9Level5); - VP9_LEVEL_NUMBER_TO_CONST.put(51, CodecProfileLevel.VP9Level51); - VP9_LEVEL_NUMBER_TO_CONST.put(60, CodecProfileLevel.VP9Level6); - VP9_LEVEL_NUMBER_TO_CONST.put(61, CodecProfileLevel.VP9Level61); - VP9_LEVEL_NUMBER_TO_CONST.put(62, CodecProfileLevel.VP9Level62); + private static int vp9ProfileNumberToConst(int profileNumber) { + switch (profileNumber) { + case 0: + return CodecProfileLevel.VP9Profile0; + case 1: + return CodecProfileLevel.VP9Profile1; + case 2: + return CodecProfileLevel.VP9Profile2; + case 3: + return CodecProfileLevel.VP9Profile3; + default: + return -1; + } + } - HEVC_CODEC_STRING_TO_PROFILE_LEVEL = new HashMap<>(); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L30", CodecProfileLevel.HEVCMainTierLevel1); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L60", CodecProfileLevel.HEVCMainTierLevel2); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L63", CodecProfileLevel.HEVCMainTierLevel21); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L90", CodecProfileLevel.HEVCMainTierLevel3); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L93", CodecProfileLevel.HEVCMainTierLevel31); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L120", CodecProfileLevel.HEVCMainTierLevel4); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L123", CodecProfileLevel.HEVCMainTierLevel41); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L150", CodecProfileLevel.HEVCMainTierLevel5); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L153", CodecProfileLevel.HEVCMainTierLevel51); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L156", CodecProfileLevel.HEVCMainTierLevel52); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L180", CodecProfileLevel.HEVCMainTierLevel6); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L183", CodecProfileLevel.HEVCMainTierLevel61); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L186", CodecProfileLevel.HEVCMainTierLevel62); + private static int vp9LevelNumberToConst(int levelNumber) { + switch (levelNumber) { + case 10: + return CodecProfileLevel.VP9Level1; + case 11: + return CodecProfileLevel.VP9Level11; + case 20: + return CodecProfileLevel.VP9Level2; + case 21: + return CodecProfileLevel.VP9Level21; + case 30: + return CodecProfileLevel.VP9Level3; + case 31: + return CodecProfileLevel.VP9Level31; + case 40: + return CodecProfileLevel.VP9Level4; + case 41: + return CodecProfileLevel.VP9Level41; + case 50: + return CodecProfileLevel.VP9Level5; + case 51: + return CodecProfileLevel.VP9Level51; + case 60: + return CodecProfileLevel.VP9Level6; + case 61: + return CodecProfileLevel.VP9Level61; + case 62: + return CodecProfileLevel.VP9Level62; + default: + return -1; + } + } - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H30", CodecProfileLevel.HEVCHighTierLevel1); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H60", CodecProfileLevel.HEVCHighTierLevel2); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H63", CodecProfileLevel.HEVCHighTierLevel21); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H90", CodecProfileLevel.HEVCHighTierLevel3); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H93", CodecProfileLevel.HEVCHighTierLevel31); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H120", CodecProfileLevel.HEVCHighTierLevel4); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H123", CodecProfileLevel.HEVCHighTierLevel41); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H150", CodecProfileLevel.HEVCHighTierLevel5); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H153", CodecProfileLevel.HEVCHighTierLevel51); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H156", CodecProfileLevel.HEVCHighTierLevel52); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H180", CodecProfileLevel.HEVCHighTierLevel6); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H183", CodecProfileLevel.HEVCHighTierLevel61); - HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H186", CodecProfileLevel.HEVCHighTierLevel62); + @Nullable + private static Integer hevcCodecStringToProfileLevel(@Nullable String codecString) { + if (codecString == null) { + return null; + } + switch (codecString) { + case "L30": + return CodecProfileLevel.HEVCMainTierLevel1; + case "L60": + return CodecProfileLevel.HEVCMainTierLevel2; + case "L63": + return CodecProfileLevel.HEVCMainTierLevel21; + case "L90": + return CodecProfileLevel.HEVCMainTierLevel3; + case "L93": + return CodecProfileLevel.HEVCMainTierLevel31; + case "L120": + return CodecProfileLevel.HEVCMainTierLevel4; + case "L123": + return CodecProfileLevel.HEVCMainTierLevel41; + case "L150": + return CodecProfileLevel.HEVCMainTierLevel5; + case "L153": + return CodecProfileLevel.HEVCMainTierLevel51; + case "L156": + return CodecProfileLevel.HEVCMainTierLevel52; + case "L180": + return CodecProfileLevel.HEVCMainTierLevel6; + case "L183": + return CodecProfileLevel.HEVCMainTierLevel61; + case "L186": + return CodecProfileLevel.HEVCMainTierLevel62; + case "H30": + return CodecProfileLevel.HEVCHighTierLevel1; + case "H60": + return CodecProfileLevel.HEVCHighTierLevel2; + case "H63": + return CodecProfileLevel.HEVCHighTierLevel21; + case "H90": + return CodecProfileLevel.HEVCHighTierLevel3; + case "H93": + return CodecProfileLevel.HEVCHighTierLevel31; + case "H120": + return CodecProfileLevel.HEVCHighTierLevel4; + case "H123": + return CodecProfileLevel.HEVCHighTierLevel41; + case "H150": + return CodecProfileLevel.HEVCHighTierLevel5; + case "H153": + return CodecProfileLevel.HEVCHighTierLevel51; + case "H156": + return CodecProfileLevel.HEVCHighTierLevel52; + case "H180": + return CodecProfileLevel.HEVCHighTierLevel6; + case "H183": + return CodecProfileLevel.HEVCHighTierLevel61; + case "H186": + return CodecProfileLevel.HEVCHighTierLevel62; + default: + return null; + } + } - DOLBY_VISION_STRING_TO_PROFILE = new HashMap<>(); - DOLBY_VISION_STRING_TO_PROFILE.put("00", CodecProfileLevel.DolbyVisionProfileDvavPer); - DOLBY_VISION_STRING_TO_PROFILE.put("01", CodecProfileLevel.DolbyVisionProfileDvavPen); - DOLBY_VISION_STRING_TO_PROFILE.put("02", CodecProfileLevel.DolbyVisionProfileDvheDer); - DOLBY_VISION_STRING_TO_PROFILE.put("03", CodecProfileLevel.DolbyVisionProfileDvheDen); - DOLBY_VISION_STRING_TO_PROFILE.put("04", CodecProfileLevel.DolbyVisionProfileDvheDtr); - DOLBY_VISION_STRING_TO_PROFILE.put("05", CodecProfileLevel.DolbyVisionProfileDvheStn); - DOLBY_VISION_STRING_TO_PROFILE.put("06", CodecProfileLevel.DolbyVisionProfileDvheDth); - DOLBY_VISION_STRING_TO_PROFILE.put("07", CodecProfileLevel.DolbyVisionProfileDvheDtb); - DOLBY_VISION_STRING_TO_PROFILE.put("08", CodecProfileLevel.DolbyVisionProfileDvheSt); - DOLBY_VISION_STRING_TO_PROFILE.put("09", CodecProfileLevel.DolbyVisionProfileDvavSe); + @Nullable + private static Integer dolbyVisionStringToProfile(@Nullable String profileString) { + if (profileString == null) { + return null; + } + switch (profileString) { + case "00": + return CodecProfileLevel.DolbyVisionProfileDvavPer; + case "01": + return CodecProfileLevel.DolbyVisionProfileDvavPen; + case "02": + return CodecProfileLevel.DolbyVisionProfileDvheDer; + case "03": + return CodecProfileLevel.DolbyVisionProfileDvheDen; + case "04": + return CodecProfileLevel.DolbyVisionProfileDvheDtr; + case "05": + return CodecProfileLevel.DolbyVisionProfileDvheStn; + case "06": + return CodecProfileLevel.DolbyVisionProfileDvheDth; + case "07": + return CodecProfileLevel.DolbyVisionProfileDvheDtb; + case "08": + return CodecProfileLevel.DolbyVisionProfileDvheSt; + case "09": + return CodecProfileLevel.DolbyVisionProfileDvavSe; + default: + return null; + } + } - DOLBY_VISION_STRING_TO_LEVEL = new HashMap<>(); - DOLBY_VISION_STRING_TO_LEVEL.put("01", CodecProfileLevel.DolbyVisionLevelHd24); - DOLBY_VISION_STRING_TO_LEVEL.put("02", CodecProfileLevel.DolbyVisionLevelHd30); - DOLBY_VISION_STRING_TO_LEVEL.put("03", CodecProfileLevel.DolbyVisionLevelFhd24); - DOLBY_VISION_STRING_TO_LEVEL.put("04", CodecProfileLevel.DolbyVisionLevelFhd30); - DOLBY_VISION_STRING_TO_LEVEL.put("05", CodecProfileLevel.DolbyVisionLevelFhd60); - DOLBY_VISION_STRING_TO_LEVEL.put("06", CodecProfileLevel.DolbyVisionLevelUhd24); - DOLBY_VISION_STRING_TO_LEVEL.put("07", CodecProfileLevel.DolbyVisionLevelUhd30); - DOLBY_VISION_STRING_TO_LEVEL.put("08", CodecProfileLevel.DolbyVisionLevelUhd48); - DOLBY_VISION_STRING_TO_LEVEL.put("09", CodecProfileLevel.DolbyVisionLevelUhd60); + @Nullable + private static Integer dolbyVisionStringToLevel(@Nullable String levelString) { + if (levelString == null) { + return null; + } + switch (levelString) { + case "01": + return CodecProfileLevel.DolbyVisionLevelHd24; + case "02": + return CodecProfileLevel.DolbyVisionLevelHd30; + case "03": + return CodecProfileLevel.DolbyVisionLevelFhd24; + case "04": + return CodecProfileLevel.DolbyVisionLevelFhd30; + case "05": + return CodecProfileLevel.DolbyVisionLevelFhd60; + case "06": + return CodecProfileLevel.DolbyVisionLevelUhd24; + case "07": + return CodecProfileLevel.DolbyVisionLevelUhd30; + case "08": + return CodecProfileLevel.DolbyVisionLevelUhd48; + case "09": + return CodecProfileLevel.DolbyVisionLevelUhd60; + default: + return null; + } + } + private static int av1LevelNumberToConst(int levelNumber) { // See https://aomediacodec.github.io/av1-spec/av1-spec.pdf Annex A: Profiles and levels for // more information on mapping AV1 codec strings to levels. - AV1_LEVEL_NUMBER_TO_CONST = new SparseIntArray(); - AV1_LEVEL_NUMBER_TO_CONST.put(0, CodecProfileLevel.AV1Level2); - AV1_LEVEL_NUMBER_TO_CONST.put(1, CodecProfileLevel.AV1Level21); - AV1_LEVEL_NUMBER_TO_CONST.put(2, CodecProfileLevel.AV1Level22); - AV1_LEVEL_NUMBER_TO_CONST.put(3, CodecProfileLevel.AV1Level23); - AV1_LEVEL_NUMBER_TO_CONST.put(4, CodecProfileLevel.AV1Level3); - AV1_LEVEL_NUMBER_TO_CONST.put(5, CodecProfileLevel.AV1Level31); - AV1_LEVEL_NUMBER_TO_CONST.put(6, CodecProfileLevel.AV1Level32); - AV1_LEVEL_NUMBER_TO_CONST.put(7, CodecProfileLevel.AV1Level33); - AV1_LEVEL_NUMBER_TO_CONST.put(8, CodecProfileLevel.AV1Level4); - AV1_LEVEL_NUMBER_TO_CONST.put(9, CodecProfileLevel.AV1Level41); - AV1_LEVEL_NUMBER_TO_CONST.put(10, CodecProfileLevel.AV1Level42); - AV1_LEVEL_NUMBER_TO_CONST.put(11, CodecProfileLevel.AV1Level43); - AV1_LEVEL_NUMBER_TO_CONST.put(12, CodecProfileLevel.AV1Level5); - AV1_LEVEL_NUMBER_TO_CONST.put(13, CodecProfileLevel.AV1Level51); - AV1_LEVEL_NUMBER_TO_CONST.put(14, CodecProfileLevel.AV1Level52); - AV1_LEVEL_NUMBER_TO_CONST.put(15, CodecProfileLevel.AV1Level53); - AV1_LEVEL_NUMBER_TO_CONST.put(16, CodecProfileLevel.AV1Level6); - AV1_LEVEL_NUMBER_TO_CONST.put(17, CodecProfileLevel.AV1Level61); - AV1_LEVEL_NUMBER_TO_CONST.put(18, CodecProfileLevel.AV1Level62); - AV1_LEVEL_NUMBER_TO_CONST.put(19, CodecProfileLevel.AV1Level63); - AV1_LEVEL_NUMBER_TO_CONST.put(20, CodecProfileLevel.AV1Level7); - AV1_LEVEL_NUMBER_TO_CONST.put(21, CodecProfileLevel.AV1Level71); - AV1_LEVEL_NUMBER_TO_CONST.put(22, CodecProfileLevel.AV1Level72); - AV1_LEVEL_NUMBER_TO_CONST.put(23, CodecProfileLevel.AV1Level73); + switch (levelNumber) { + case 0: + return CodecProfileLevel.AV1Level2; + case 1: + return CodecProfileLevel.AV1Level21; + case 2: + return CodecProfileLevel.AV1Level22; + case 3: + return CodecProfileLevel.AV1Level23; + case 4: + return CodecProfileLevel.AV1Level3; + case 5: + return CodecProfileLevel.AV1Level31; + case 6: + return CodecProfileLevel.AV1Level32; + case 7: + return CodecProfileLevel.AV1Level33; + case 8: + return CodecProfileLevel.AV1Level4; + case 9: + return CodecProfileLevel.AV1Level41; + case 10: + return CodecProfileLevel.AV1Level42; + case 11: + return CodecProfileLevel.AV1Level43; + case 12: + return CodecProfileLevel.AV1Level5; + case 13: + return CodecProfileLevel.AV1Level51; + case 14: + return CodecProfileLevel.AV1Level52; + case 15: + return CodecProfileLevel.AV1Level53; + case 16: + return CodecProfileLevel.AV1Level6; + case 17: + return CodecProfileLevel.AV1Level61; + case 18: + return CodecProfileLevel.AV1Level62; + case 19: + return CodecProfileLevel.AV1Level63; + case 20: + return CodecProfileLevel.AV1Level7; + case 21: + return CodecProfileLevel.AV1Level71; + case 22: + return CodecProfileLevel.AV1Level72; + case 23: + return CodecProfileLevel.AV1Level73; + default: + return -1; + } + } - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE = new SparseIntArray(); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(1, CodecProfileLevel.AACObjectMain); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(2, CodecProfileLevel.AACObjectLC); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(3, CodecProfileLevel.AACObjectSSR); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(4, CodecProfileLevel.AACObjectLTP); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(5, CodecProfileLevel.AACObjectHE); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(6, CodecProfileLevel.AACObjectScalable); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(17, CodecProfileLevel.AACObjectERLC); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(20, CodecProfileLevel.AACObjectERScalable); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(23, CodecProfileLevel.AACObjectLD); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(29, CodecProfileLevel.AACObjectHE_PS); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(39, CodecProfileLevel.AACObjectELD); - MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(42, CodecProfileLevel.AACObjectXHE); + private static int mp4aAudioObjectTypeToProfile(int profileNumber) { + switch (profileNumber) { + case 1: + return CodecProfileLevel.AACObjectMain; + case 2: + return CodecProfileLevel.AACObjectLC; + case 3: + return CodecProfileLevel.AACObjectSSR; + case 4: + return CodecProfileLevel.AACObjectLTP; + case 5: + return CodecProfileLevel.AACObjectHE; + case 6: + return CodecProfileLevel.AACObjectScalable; + case 17: + return CodecProfileLevel.AACObjectERLC; + case 20: + return CodecProfileLevel.AACObjectERScalable; + case 23: + return CodecProfileLevel.AACObjectLD; + case 29: + return CodecProfileLevel.AACObjectHE_PS; + case 39: + return CodecProfileLevel.AACObjectELD; + case 42: + return CodecProfileLevel.AACObjectXHE; + default: + return -1; + } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java index 118445835b..0ed58db266 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java @@ -83,7 +83,7 @@ public final class MediaFormatUtil { * * @param format The {@link MediaFormat} being configured. * @param key The key to set. - * @param value The {@link byte[]} that will be wrapped to obtain the value. + * @param value The byte array that will be wrapped to obtain the value. */ public static void maybeSetByteBuffer(MediaFormat format, String key, @Nullable byte[] value) { if (value != null) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapter.java deleted file mode 100644 index d51f985ed7..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapter.java +++ /dev/null @@ -1,385 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.HandlerThread; -import androidx.annotation.GuardedBy; -import androidx.annotation.IntDef; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.decoder.CryptoInfo; -import com.google.android.exoplayer2.util.IntArrayQueue; -import com.google.android.exoplayer2.util.Util; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.ArrayDeque; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; - -/** - * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in asynchronous mode - * and routes {@link MediaCodec.Callback} callbacks on a dedicated thread that is managed - * internally. - * - *

    The main difference of this class compared to the {@link - * DedicatedThreadAsyncMediaCodecAdapter} is that its internal implementation applies finer-grained - * locking. The {@link DedicatedThreadAsyncMediaCodecAdapter} uses a single lock to synchronize - * access, whereas this class uses a different lock to access the available input and available - * output buffer indexes returned from the {@link MediaCodec}. This class assumes that the {@link - * MediaCodecAdapter} methods will be accessed by the playback thread and the {@link - * MediaCodec.Callback} methods will be accessed by the internal thread. This class is - * NOT generally thread-safe in the sense that its public methods cannot be called - * by any thread. - */ -@RequiresApi(23) -/* package */ final class MultiLockAsyncMediaCodecAdapter extends MediaCodec.Callback - implements MediaCodecAdapter { - - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_CREATED, STATE_STARTED, STATE_SHUT_DOWN}) - private @interface State {} - - private static final int STATE_CREATED = 0; - private static final int STATE_STARTED = 1; - private static final int STATE_SHUT_DOWN = 2; - - private final MediaCodec codec; - private final Object inputBufferLock; - private final Object outputBufferLock; - private final Object objectStateLock; - - @GuardedBy("inputBufferLock") - private final IntArrayQueue availableInputBuffers; - - @GuardedBy("outputBufferLock") - private final IntArrayQueue availableOutputBuffers; - - @GuardedBy("outputBufferLock") - private final ArrayDeque bufferInfos; - - @GuardedBy("outputBufferLock") - private final ArrayDeque formats; - - @GuardedBy("objectStateLock") - private @MonotonicNonNull MediaFormat currentFormat; - - @GuardedBy("objectStateLock") - private long pendingFlush; - - @GuardedBy("objectStateLock") - @Nullable - private IllegalStateException codecException; - - private final HandlerThread handlerThread; - private @MonotonicNonNull Handler handler; - private Runnable codecStartRunnable; - private final MediaCodecInputBufferEnqueuer bufferEnqueuer; - - @GuardedBy("objectStateLock") - @State - private int state; - - /** - * Creates a new instance that wraps the specified {@link MediaCodec}. An instance created with - * this constructor will queue input buffers synchronously. - * - * @param codec The {@link MediaCodec} to wrap. - * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for - * labelling the internal thread accordingly. - */ - /* package */ MultiLockAsyncMediaCodecAdapter(MediaCodec codec, int trackType) { - this( - codec, - /* enableAsynchronousQueueing= */ false, - trackType, - new HandlerThread(createThreadLabel(trackType))); - } - - /** - * Creates a new instance that wraps the specified {@link MediaCodec}. - * - * @param codec The {@link MediaCodec} to wrap. - * @param enableAsynchronousQueueing Whether input buffers will be queued asynchronously. - * @param trackType One of {@link C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. Used for - * labelling the internal thread accordingly. - */ - /* package */ MultiLockAsyncMediaCodecAdapter( - MediaCodec codec, boolean enableAsynchronousQueueing, int trackType) { - this( - codec, - enableAsynchronousQueueing, - trackType, - new HandlerThread(createThreadLabel(trackType))); - } - - @VisibleForTesting - /* package */ MultiLockAsyncMediaCodecAdapter( - MediaCodec codec, - boolean enableAsynchronousQueueing, - int trackType, - HandlerThread handlerThread) { - this.codec = codec; - inputBufferLock = new Object(); - outputBufferLock = new Object(); - objectStateLock = new Object(); - availableInputBuffers = new IntArrayQueue(); - availableOutputBuffers = new IntArrayQueue(); - bufferInfos = new ArrayDeque<>(); - formats = new ArrayDeque<>(); - codecException = null; - this.handlerThread = handlerThread; - codecStartRunnable = codec::start; - if (enableAsynchronousQueueing) { - bufferEnqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, trackType); - } else { - bufferEnqueuer = new SynchronousMediaCodecBufferEnqueuer(codec); - } - state = STATE_CREATED; - } - - @Override - public void start() { - synchronized (objectStateLock) { - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); - codec.setCallback(this, handler); - bufferEnqueuer.start(); - codecStartRunnable.run(); - state = STATE_STARTED; - } - } - - @Override - public int dequeueInputBufferIndex() { - synchronized (objectStateLock) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return dequeueAvailableInputBufferIndex(); - } - } - } - - @Override - public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - synchronized (objectStateLock) { - if (isFlushing()) { - return MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - maybeThrowException(); - return dequeueAvailableOutputBufferIndex(bufferInfo); - } - } - } - - @Override - public MediaFormat getOutputFormat() { - synchronized (objectStateLock) { - if (currentFormat == null) { - throw new IllegalStateException(); - } - - return currentFormat; - } - } - - @Override - public void queueInputBuffer( - int index, int offset, int size, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it is not interacting with - // MediaCodec.Callback and dequeueing buffers operations. - bufferEnqueuer.queueInputBuffer(index, offset, size, presentationTimeUs, flags); - } - - @Override - public void queueSecureInputBuffer( - int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { - // This method does not need to be synchronized because it is not interacting with - // MediaCodec.Callback and dequeueing buffers operations. - bufferEnqueuer.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); - } - - @Override - public void flush() { - synchronized (objectStateLock) { - bufferEnqueuer.flush(); - codec.flush(); - pendingFlush++; - Util.castNonNull(handler).post(this::onFlushComplete); - } - } - - @Override - public void shutdown() { - synchronized (objectStateLock) { - if (state == STATE_STARTED) { - bufferEnqueuer.shutdown(); - handlerThread.quit(); - } - state = STATE_SHUT_DOWN; - } - } - - @VisibleForTesting - /* package */ void setCodecStartRunnable(Runnable codecStartRunnable) { - this.codecStartRunnable = codecStartRunnable; - } - - private int dequeueAvailableInputBufferIndex() { - synchronized (inputBufferLock) { - return availableInputBuffers.isEmpty() - ? MediaCodec.INFO_TRY_AGAIN_LATER - : availableInputBuffers.remove(); - } - } - - @GuardedBy("objectStateLock") - private int dequeueAvailableOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { - int bufferIndex; - synchronized (outputBufferLock) { - if (availableOutputBuffers.isEmpty()) { - bufferIndex = MediaCodec.INFO_TRY_AGAIN_LATER; - } else { - bufferIndex = availableOutputBuffers.remove(); - if (bufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - currentFormat = formats.remove(); - } else if (bufferIndex >= 0) { - MediaCodec.BufferInfo outBufferInfo = bufferInfos.remove(); - bufferInfo.set( - outBufferInfo.offset, - outBufferInfo.size, - outBufferInfo.presentationTimeUs, - outBufferInfo.flags); - } - } - } - return bufferIndex; - } - - @GuardedBy("objectStateLock") - private boolean isFlushing() { - return pendingFlush > 0; - } - - @GuardedBy("objectStateLock") - private void maybeThrowException() { - @Nullable IllegalStateException exception = codecException; - if (exception != null) { - codecException = null; - throw exception; - } - } - - // Called by the internal thread. - - @Override - public void onInputBufferAvailable(MediaCodec codec, int index) { - synchronized (inputBufferLock) { - availableInputBuffers.add(index); - } - } - - @Override - public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) { - synchronized (outputBufferLock) { - availableOutputBuffers.add(index); - bufferInfos.add(info); - } - } - - @Override - public void onError(MediaCodec codec, MediaCodec.CodecException e) { - onMediaCodecError(e); - } - - @Override - public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { - synchronized (outputBufferLock) { - availableOutputBuffers.add(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - formats.add(format); - } - } - - @VisibleForTesting - /* package */ void onMediaCodecError(IllegalStateException e) { - synchronized (objectStateLock) { - codecException = e; - } - } - - private void onFlushComplete() { - synchronized (objectStateLock) { - if (state == STATE_SHUT_DOWN) { - return; - } - - --pendingFlush; - if (pendingFlush > 0) { - // Another flush() has been called. - return; - } else if (pendingFlush < 0) { - // This should never happen. - codecException = new IllegalStateException(); - return; - } - - clearAvailableInput(); - clearAvailableOutput(); - codecException = null; - try { - codecStartRunnable.run(); - } catch (IllegalStateException e) { - codecException = e; - } catch (Exception e) { - codecException = new IllegalStateException(e); - } - } - } - - private void clearAvailableInput() { - synchronized (inputBufferLock) { - availableInputBuffers.clear(); - } - } - - private void clearAvailableOutput() { - synchronized (outputBufferLock) { - availableOutputBuffers.clear(); - bufferInfos.clear(); - formats.clear(); - } - } - - private static String createThreadLabel(int trackType) { - StringBuilder labelBuilder = new StringBuilder("ExoPlayer:MediaCodecAsyncAdapter:"); - if (trackType == C.TRACK_TYPE_AUDIO) { - labelBuilder.append("Audio"); - } else if (trackType == C.TRACK_TYPE_VIDEO) { - labelBuilder.append("Video"); - } else { - labelBuilder.append("Unknown(").append(trackType).append(")"); - } - return labelBuilder.toString(); - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java index f50b49e602..f5138e90f0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java @@ -17,7 +17,10 @@ package com.google.android.exoplayer2.mediacodec; import android.media.MediaCodec; +import android.media.MediaCrypto; import android.media.MediaFormat; +import android.view.Surface; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.decoder.CryptoInfo; /** @@ -31,6 +34,15 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; this.codec = mediaCodec; } + @Override + public void configure( + @Nullable MediaFormat mediaFormat, + @Nullable Surface surface, + @Nullable MediaCrypto crypto, + int flags) { + codec.configure(mediaFormat, surface, crypto, flags); + } + @Override public void start() { codec.start(); @@ -71,4 +83,9 @@ import com.google.android.exoplayer2.decoder.CryptoInfo; @Override public void shutdown() {} + + @Override + public MediaCodec getCodec() { + return codec; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java index 238d515caf..d2b75635b1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java @@ -103,14 +103,14 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { public int supportsFormat(Format format) { if (decoderFactory.supportsFormat(format)) { return RendererCapabilities.create( - format.drmInitData == null ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_DRM); + format.exoMediaCryptoType == null ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_DRM); } else { return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); } } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { decoder = decoderFactory.createDecoder(formats[0]); } @@ -129,10 +129,6 @@ public final class MetadataRenderer extends BaseRenderer implements Callback { if (result == C.RESULT_BUFFER_READ) { if (buffer.isEndOfStream()) { inputStreamEnded = true; - } else if (buffer.isDecodeOnly()) { - // Do nothing. Note this assumes that all metadata buffers can be decoded independently. - // If we ever need to support a metadata format where this is not the case, we'll need to - // pass the buffer to the decoder and discard the output. } else { buffer.subsampleOffsetUs = subsampleOffsetUs; buffer.flip(); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java index f533b97d13..fb16945d82 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoder.java @@ -16,14 +16,12 @@ package com.google.android.exoplayer2.metadata.dvbsi; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.metadata.SimpleMetadataDecoder; import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; -import java.nio.charset.Charset; import java.util.ArrayList; /** @@ -33,7 +31,7 @@ import java.util.ArrayList; * href="https://www.etsi.org/deliver/etsi_ts/102800_102899/102809/01.01.01_60/ts_102809v010101p.pdf"> * DVB ETSI TS 102 809 v1.1.1 spec. */ -public final class AppInfoTableDecoder implements MetadataDecoder { +public final class AppInfoTableDecoder extends SimpleMetadataDecoder { /** See section 5.3.6. */ private static final int DESCRIPTOR_TRANSPORT_PROTOCOL = 0x02; @@ -48,10 +46,8 @@ public final class AppInfoTableDecoder implements MetadataDecoder { @Override @Nullable - public Metadata decode(MetadataInputBuffer inputBuffer) { - ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); - Assertions.checkArgument( - buffer.position() == 0 && buffer.hasArray() && buffer.arrayOffset() == 0); + @SuppressWarnings("ByteBufferBackingArray") // Buffer validated by SimpleMetadataDecoder.decode + protected Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer) { int tableId = buffer.get(); return tableId == APPLICATION_INFORMATION_TABLE_ID ? parseAit(new ParsableBitArray(buffer.array(), buffer.limit())) @@ -109,7 +105,7 @@ public final class AppInfoTableDecoder implements MetadataDecoder { // See section 5.3.6.2. while (sectionData.getBytePosition() < positionOfNextDescriptor) { int urlBaseLength = sectionData.readBits(8); - urlBase = sectionData.readBytesAsString(urlBaseLength, Charset.forName(C.ASCII_NAME)); + urlBase = sectionData.readBytesAsString(urlBaseLength, Charsets.US_ASCII); int extensionCount = sectionData.readBits(8); for (int urlExtensionIndex = 0; @@ -122,8 +118,7 @@ public final class AppInfoTableDecoder implements MetadataDecoder { } } else if (descriptorTag == DESCRIPTOR_SIMPLE_APPLICATION_LOCATION) { // See section 5.3.7. - urlExtension = - sectionData.readBytesAsString(descriptorLength, Charset.forName(C.ASCII_NAME)); + urlExtension = sectionData.readBytesAsString(descriptorLength, Charsets.US_ASCII); } sectionData.setPosition(positionOfNextDescriptor * 8); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java index cd3c1dfb63..8f0254d83f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java @@ -16,21 +16,19 @@ package com.google.android.exoplayer2.metadata.icy; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.metadata.SimpleMetadataDecoder; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; -import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; import java.util.regex.Matcher; import java.util.regex.Pattern; /** Decodes ICY stream information. */ -public final class IcyDecoder implements MetadataDecoder { +public final class IcyDecoder extends SimpleMetadataDecoder { private static final Pattern METADATA_ELEMENT = Pattern.compile("(.+?)='(.*?)';", Pattern.DOTALL); private static final String STREAM_KEY_NAME = "streamtitle"; @@ -40,15 +38,12 @@ public final class IcyDecoder implements MetadataDecoder { private final CharsetDecoder iso88591Decoder; public IcyDecoder() { - utf8Decoder = Charset.forName(C.UTF8_NAME).newDecoder(); - iso88591Decoder = Charset.forName(C.ISO88591_NAME).newDecoder(); + utf8Decoder = Charsets.UTF_8.newDecoder(); + iso88591Decoder = Charsets.ISO_8859_1.newDecoder(); } @Override - public Metadata decode(MetadataInputBuffer inputBuffer) { - ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); - Assertions.checkArgument( - buffer.position() == 0 && buffer.hasArray() && buffer.arrayOffset() == 0); + protected Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer) { @Nullable String icyString = decodeToString(buffer); byte[] icyBytes = new byte[buffer.limit()]; buffer.get(icyBytes); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java index 647e1296a9..fbcf9da6f3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java @@ -17,19 +17,16 @@ package com.google.android.exoplayer2.metadata.scte35; import androidx.annotation.Nullable; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.metadata.MetadataDecoder; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.metadata.SimpleMetadataDecoder; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.nio.ByteBuffer; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -/** - * Decodes splice info sections and produces splice commands. - */ -public final class SpliceInfoDecoder implements MetadataDecoder { +/** Decodes splice info sections and produces splice commands. */ +public final class SpliceInfoDecoder extends SimpleMetadataDecoder { private static final int TYPE_SPLICE_NULL = 0x00; private static final int TYPE_SPLICE_SCHEDULE = 0x04; @@ -48,11 +45,8 @@ public final class SpliceInfoDecoder implements MetadataDecoder { } @Override - public Metadata decode(MetadataInputBuffer inputBuffer) { - ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); - Assertions.checkArgument( - buffer.position() == 0 && buffer.hasArray() && buffer.arrayOffset() == 0); - + @SuppressWarnings("ByteBufferBackingArray") // Buffer validated by SimpleMetadataDecoder.decode + protected Metadata decode(MetadataInputBuffer inputBuffer, ByteBuffer buffer) { // Internal timestamps adjustment. if (timestampAdjuster == null || inputBuffer.subsampleOffsetUs != timestampAdjuster.getTimestampOffsetUs()) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java index c69908c746..2f7db22326 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ActionFile.java @@ -19,6 +19,7 @@ import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.offline.DownloadRequest.UnsupportedRequestException; import com.google.android.exoplayer2.util.AtomicFile; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.DataInputStream; import java.io.File; @@ -37,6 +38,10 @@ import java.util.List; /* package */ final class ActionFile { private static final int VERSION = 0; + private static final String DOWNLOAD_TYPE_PROGRESSIVE = "progressive"; + private static final String DOWNLOAD_TYPE_DASH = "dash"; + private static final String DOWNLOAD_TYPE_HLS = "hls"; + private static final String DOWNLOAD_TYPE_SS = "ss"; private final AtomicFile atomicFile; @@ -92,7 +97,7 @@ import java.util.List; } private static DownloadRequest readDownloadRequest(DataInputStream input) throws IOException { - String type = input.readUTF(); + String downloadType = input.readUTF(); int version = input.readInt(); Uri uri = Uri.parse(input.readUTF()); @@ -108,21 +113,21 @@ import java.util.List; } // Serialized version 0 progressive actions did not contain keys. - boolean isLegacyProgressive = version == 0 && DownloadRequest.TYPE_PROGRESSIVE.equals(type); + boolean isLegacyProgressive = version == 0 && DOWNLOAD_TYPE_PROGRESSIVE.equals(downloadType); List keys = new ArrayList<>(); if (!isLegacyProgressive) { int keyCount = input.readInt(); for (int i = 0; i < keyCount; i++) { - keys.add(readKey(type, version, input)); + keys.add(readKey(downloadType, version, input)); } } // Serialized version 0 and 1 DASH/HLS/SS actions did not contain a custom cache key. boolean isLegacySegmented = version < 2 - && (DownloadRequest.TYPE_DASH.equals(type) - || DownloadRequest.TYPE_HLS.equals(type) - || DownloadRequest.TYPE_SS.equals(type)); + && (DOWNLOAD_TYPE_DASH.equals(downloadType) + || DOWNLOAD_TYPE_HLS.equals(downloadType) + || DOWNLOAD_TYPE_SS.equals(downloadType)); @Nullable String customCacheKey = null; if (!isLegacySegmented) { customCacheKey = input.readBoolean() ? input.readUTF() : null; @@ -135,7 +140,13 @@ import java.util.List; // Remove actions are not supported anymore. throw new UnsupportedRequestException(); } - return new DownloadRequest(id, type, uri, keys, customCacheKey, data); + + return new DownloadRequest.Builder(id, uri) + .setMimeType(inferMimeType(downloadType)) + .setStreamKeys(keys) + .setCustomCacheKey(customCacheKey) + .setData(data) + .build(); } private static StreamKey readKey(String type, int version, DataInputStream input) @@ -145,8 +156,7 @@ import java.util.List; int trackIndex; // Serialized version 0 HLS/SS actions did not contain a period index. - if ((DownloadRequest.TYPE_HLS.equals(type) || DownloadRequest.TYPE_SS.equals(type)) - && version == 0) { + if ((DOWNLOAD_TYPE_HLS.equals(type) || DOWNLOAD_TYPE_SS.equals(type)) && version == 0) { periodIndex = 0; groupIndex = input.readInt(); trackIndex = input.readInt(); @@ -158,6 +168,20 @@ import java.util.List; return new StreamKey(periodIndex, groupIndex, trackIndex); } + private static String inferMimeType(String downloadType) { + switch (downloadType) { + case DOWNLOAD_TYPE_DASH: + return MimeTypes.APPLICATION_MPD; + case DOWNLOAD_TYPE_HLS: + return MimeTypes.APPLICATION_M3U8; + case DOWNLOAD_TYPE_SS: + return MimeTypes.APPLICATION_SS; + case DOWNLOAD_TYPE_PROGRESSIVE: + default: + return MimeTypes.VIDEO_UNKNOWN; + } + } + private static String generateDownloadId(Uri uri, @Nullable String customCacheKey) { return customCacheKey != null ? customCacheKey : uri.toString(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java index 4437fccd16..d9a060fe2d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.database.VersionTable; import com.google.android.exoplayer2.offline.Download.FailureReason; import com.google.android.exoplayer2.offline.Download.State; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; import java.util.List; @@ -38,10 +39,10 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "Downloads"; - @VisibleForTesting /* package */ static final int TABLE_VERSION = 2; + @VisibleForTesting /* package */ static final int TABLE_VERSION = 3; private static final String COLUMN_ID = "id"; - private static final String COLUMN_TYPE = "title"; + private static final String COLUMN_MIME_TYPE = "mime_type"; private static final String COLUMN_URI = "uri"; private static final String COLUMN_STREAM_KEYS = "stream_keys"; private static final String COLUMN_CUSTOM_CACHE_KEY = "custom_cache_key"; @@ -54,9 +55,10 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final String COLUMN_FAILURE_REASON = "failure_reason"; private static final String COLUMN_PERCENT_DOWNLOADED = "percent_downloaded"; private static final String COLUMN_BYTES_DOWNLOADED = "bytes_downloaded"; + private static final String COLUMN_KEY_SET_ID = "key_set_id"; private static final int COLUMN_INDEX_ID = 0; - private static final int COLUMN_INDEX_TYPE = 1; + private static final int COLUMN_INDEX_MIME_TYPE = 1; private static final int COLUMN_INDEX_URI = 2; private static final int COLUMN_INDEX_STREAM_KEYS = 3; private static final int COLUMN_INDEX_CUSTOM_CACHE_KEY = 4; @@ -69,6 +71,7 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final int COLUMN_INDEX_FAILURE_REASON = 11; private static final int COLUMN_INDEX_PERCENT_DOWNLOADED = 12; private static final int COLUMN_INDEX_BYTES_DOWNLOADED = 13; + private static final int COLUMN_INDEX_KEY_SET_ID = 14; private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?"; private static final String WHERE_STATE_IS_DOWNLOADING = @@ -79,7 +82,7 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { private static final String[] COLUMNS = new String[] { COLUMN_ID, - COLUMN_TYPE, + COLUMN_MIME_TYPE, COLUMN_URI, COLUMN_STREAM_KEYS, COLUMN_CUSTOM_CACHE_KEY, @@ -92,14 +95,15 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { COLUMN_FAILURE_REASON, COLUMN_PERCENT_DOWNLOADED, COLUMN_BYTES_DOWNLOADED, + COLUMN_KEY_SET_ID }; private static final String TABLE_SCHEMA = "(" + COLUMN_ID + " TEXT PRIMARY KEY NOT NULL," - + COLUMN_TYPE - + " TEXT NOT NULL," + + COLUMN_MIME_TYPE + + " TEXT," + COLUMN_URI + " TEXT NOT NULL," + COLUMN_STREAM_KEYS @@ -123,7 +127,9 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { + COLUMN_PERCENT_DOWNLOADED + " REAL NOT NULL," + COLUMN_BYTES_DOWNLOADED - + " INTEGER NOT NULL)"; + + " INTEGER NOT NULL," + + COLUMN_KEY_SET_ID + + " BLOB NOT NULL)"; private static final String TRUE = "1"; @@ -189,24 +195,9 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { @Override public void putDownload(Download download) throws DatabaseIOException { ensureInitialized(); - ContentValues values = new ContentValues(); - values.put(COLUMN_ID, download.request.id); - values.put(COLUMN_TYPE, download.request.type); - values.put(COLUMN_URI, download.request.uri.toString()); - values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(download.request.streamKeys)); - values.put(COLUMN_CUSTOM_CACHE_KEY, download.request.customCacheKey); - values.put(COLUMN_DATA, download.request.data); - values.put(COLUMN_STATE, download.state); - values.put(COLUMN_START_TIME_MS, download.startTimeMs); - values.put(COLUMN_UPDATE_TIME_MS, download.updateTimeMs); - values.put(COLUMN_CONTENT_LENGTH, download.contentLength); - values.put(COLUMN_STOP_REASON, download.stopReason); - values.put(COLUMN_FAILURE_REASON, download.failureReason); - values.put(COLUMN_PERCENT_DOWNLOADED, download.getPercentDownloaded()); - values.put(COLUMN_BYTES_DOWNLOADED, download.getBytesDownloaded()); try { SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); - writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + putDownloadInternal(download, writableDatabase); } catch (SQLiteException e) { throw new DatabaseIOException(e); } @@ -294,8 +285,13 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { try { VersionTable.setVersion( writableDatabase, VersionTable.FEATURE_OFFLINE, name, TABLE_VERSION); + List upgradedDownloads = + version == 2 ? loadDownloadsFromVersion2(writableDatabase) : new ArrayList<>(); writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName); writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA); + for (Download download : upgradedDownloads) { + putDownloadInternal(download, writableDatabase); + } writableDatabase.setTransactionSuccessful(); } finally { writableDatabase.endTransaction(); @@ -307,6 +303,80 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { } } + private void putDownloadInternal(Download download, SQLiteDatabase database) { + byte[] keySetId = + download.request.keySetId == null ? Util.EMPTY_BYTE_ARRAY : download.request.keySetId; + ContentValues values = new ContentValues(); + values.put(COLUMN_ID, download.request.id); + values.put(COLUMN_MIME_TYPE, download.request.mimeType); + values.put(COLUMN_URI, download.request.uri.toString()); + values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(download.request.streamKeys)); + values.put(COLUMN_CUSTOM_CACHE_KEY, download.request.customCacheKey); + values.put(COLUMN_DATA, download.request.data); + values.put(COLUMN_STATE, download.state); + values.put(COLUMN_START_TIME_MS, download.startTimeMs); + values.put(COLUMN_UPDATE_TIME_MS, download.updateTimeMs); + values.put(COLUMN_CONTENT_LENGTH, download.contentLength); + values.put(COLUMN_STOP_REASON, download.stopReason); + values.put(COLUMN_FAILURE_REASON, download.failureReason); + values.put(COLUMN_PERCENT_DOWNLOADED, download.getPercentDownloaded()); + values.put(COLUMN_BYTES_DOWNLOADED, download.getBytesDownloaded()); + values.put(COLUMN_KEY_SET_ID, keySetId); + database.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + } + + private List loadDownloadsFromVersion2(SQLiteDatabase database) { + List downloads = new ArrayList<>(); + if (!Util.tableExists(database, tableName)) { + return downloads; + } + + String[] columnsV2 = + new String[] { + "id", + "title", + "uri", + "stream_keys", + "custom_cache_key", + "data", + "state", + "start_time_ms", + "update_time_ms", + "content_length", + "stop_reason", + "failure_reason", + "percent_downloaded", + "bytes_downloaded" + }; + try (Cursor cursor = + database.query( + tableName, + columnsV2, + /* selection= */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null); ) { + while (cursor.moveToNext()) { + downloads.add(getDownloadForCurrentRowV2(cursor)); + } + return downloads; + } + } + + /** Infers the MIME type from a v2 table row. */ + private static String inferMimeType(String downloadType) { + if ("dash".equals(downloadType)) { + return MimeTypes.APPLICATION_MPD; + } else if ("hls".equals(downloadType)) { + return MimeTypes.APPLICATION_M3U8; + } else if ("ss".equals(downloadType)) { + return MimeTypes.APPLICATION_SS; + } else { + return MimeTypes.VIDEO_UNKNOWN; + } + } + private Cursor getCursor(String selection, @Nullable String[] selectionArgs) throws DatabaseIOException { try { @@ -326,6 +396,25 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { } } + @VisibleForTesting + /* package*/ static String encodeStreamKeys(List streamKeys) { + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < streamKeys.size(); i++) { + StreamKey streamKey = streamKeys.get(i); + stringBuilder + .append(streamKey.periodIndex) + .append('.') + .append(streamKey.groupIndex) + .append('.') + .append(streamKey.trackIndex) + .append(','); + } + if (stringBuilder.length() > 0) { + stringBuilder.setLength(stringBuilder.length() - 1); + } + return stringBuilder.toString(); + } + private static String getStateQuery(@Download.State int... states) { if (states.length == 0) { return TRUE; @@ -343,14 +432,17 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { } private static Download getDownloadForCurrentRow(Cursor cursor) { + byte[] keySetId = cursor.getBlob(COLUMN_INDEX_KEY_SET_ID); DownloadRequest request = - new DownloadRequest( - /* id= */ cursor.getString(COLUMN_INDEX_ID), - /* type= */ cursor.getString(COLUMN_INDEX_TYPE), - /* uri= */ Uri.parse(cursor.getString(COLUMN_INDEX_URI)), - /* streamKeys= */ decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS)), - /* customCacheKey= */ cursor.getString(COLUMN_INDEX_CUSTOM_CACHE_KEY), - /* data= */ cursor.getBlob(COLUMN_INDEX_DATA)); + new DownloadRequest.Builder( + /* id= */ cursor.getString(COLUMN_INDEX_ID), + /* uri= */ Uri.parse(cursor.getString(COLUMN_INDEX_URI))) + .setMimeType(cursor.getString(COLUMN_INDEX_MIME_TYPE)) + .setStreamKeys(decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS))) + .setKeySetId(keySetId.length > 0 ? keySetId : null) + .setCustomCacheKey(cursor.getString(COLUMN_INDEX_CUSTOM_CACHE_KEY)) + .setData(cursor.getBlob(COLUMN_INDEX_DATA)) + .build(); DownloadProgress downloadProgress = new DownloadProgress(); downloadProgress.bytesDownloaded = cursor.getLong(COLUMN_INDEX_BYTES_DOWNLOADED); downloadProgress.percentDownloaded = cursor.getFloat(COLUMN_INDEX_PERCENT_DOWNLOADED); @@ -373,22 +465,52 @@ public final class DefaultDownloadIndex implements WritableDownloadIndex { downloadProgress); } - private static String encodeStreamKeys(List streamKeys) { - StringBuilder stringBuilder = new StringBuilder(); - for (int i = 0; i < streamKeys.size(); i++) { - StreamKey streamKey = streamKeys.get(i); - stringBuilder - .append(streamKey.periodIndex) - .append('.') - .append(streamKey.groupIndex) - .append('.') - .append(streamKey.trackIndex) - .append(','); - } - if (stringBuilder.length() > 0) { - stringBuilder.setLength(stringBuilder.length() - 1); - } - return stringBuilder.toString(); + /** Read a {@link Download} from a table row of version 2. */ + private static Download getDownloadForCurrentRowV2(Cursor cursor) { + /* + * Version 2 schema + * Index Column Type + * 0 id string + * 1 type string + * 2 uri string + * 3 stream_keys string + * 4 custom_cache_key string + * 5 data blob + * 6 state integer + * 7 start_time_ms integer + * 8 update_time_ms integer + * 9 content_length integer + * 10 stop_reason integer + * 11 failure_reason integer + * 12 percent_downloaded real + * 13 bytes_downloaded integer + */ + DownloadRequest request = + new DownloadRequest.Builder( + /* id= */ cursor.getString(0), /* uri= */ Uri.parse(cursor.getString(2))) + .setMimeType(inferMimeType(cursor.getString(1))) + .setStreamKeys(decodeStreamKeys(cursor.getString(3))) + .setCustomCacheKey(cursor.getString(4)) + .setData(cursor.getBlob(5)) + .build(); + DownloadProgress downloadProgress = new DownloadProgress(); + downloadProgress.bytesDownloaded = cursor.getLong(13); + downloadProgress.percentDownloaded = cursor.getFloat(12); + @State int state = cursor.getInt(6); + // It's possible the database contains failure reasons for non-failed downloads, which is + // invalid. Clear them here. See https://github.com/google/ExoPlayer/issues/6785. + @FailureReason + int failureReason = + state == Download.STATE_FAILED ? cursor.getInt(11) : Download.FAILURE_REASON_NONE; + return new Download( + request, + state, + /* startTimeMs= */ cursor.getLong(7), + /* updateTimeMs= */ cursor.getLong(8), + /* contentLength= */ cursor.getLong(9), + /* stopReason= */ cursor.getInt(10), + failureReason, + downloadProgress); } private static List decodeStreamKeys(String encodedStreamKeys) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java index 0b7434c339..365a4439a1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java @@ -15,11 +15,14 @@ */ package com.google.android.exoplayer2.offline; -import android.net.Uri; +import android.util.SparseArray; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; import java.lang.reflect.Constructor; -import java.util.List; import java.util.concurrent.Executor; /** @@ -29,46 +32,8 @@ import java.util.concurrent.Executor; */ public class DefaultDownloaderFactory implements DownloaderFactory { - @Nullable private static final Constructor DASH_DOWNLOADER_CONSTRUCTOR; - @Nullable private static final Constructor HLS_DOWNLOADER_CONSTRUCTOR; - @Nullable private static final Constructor SS_DOWNLOADER_CONSTRUCTOR; - - static { - @Nullable Constructor dashDownloaderConstructor = null; - try { - // LINT.IfChange - dashDownloaderConstructor = - getDownloaderConstructor( - Class.forName("com.google.android.exoplayer2.source.dash.offline.DashDownloader")); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the DASH module. - } - DASH_DOWNLOADER_CONSTRUCTOR = dashDownloaderConstructor; - @Nullable Constructor hlsDownloaderConstructor = null; - try { - // LINT.IfChange - hlsDownloaderConstructor = - getDownloaderConstructor( - Class.forName("com.google.android.exoplayer2.source.hls.offline.HlsDownloader")); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the HLS module. - } - HLS_DOWNLOADER_CONSTRUCTOR = hlsDownloaderConstructor; - @Nullable Constructor ssDownloaderConstructor = null; - try { - // LINT.IfChange - ssDownloaderConstructor = - getDownloaderConstructor( - Class.forName( - "com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader")); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the SmoothStreaming module. - } - SS_DOWNLOADER_CONSTRUCTOR = ssDownloaderConstructor; - } + private static final SparseArray> CONSTRUCTORS = + createDownloaderConstructors(); private final CacheDataSource.Factory cacheDataSourceFactory; private final Executor executor; @@ -78,9 +43,11 @@ public class DefaultDownloaderFactory implements DownloaderFactory { * * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which * downloads will be written. + * @deprecated Use {@link #DefaultDownloaderFactory(CacheDataSource.Factory, Executor)}. */ + @Deprecated public DefaultDownloaderFactory(CacheDataSource.Factory cacheDataSourceFactory) { - this(cacheDataSourceFactory, Runnable::run); + this(cacheDataSourceFactory, /* executor= */ Runnable::run); } /** @@ -88,55 +55,99 @@ public class DefaultDownloaderFactory implements DownloaderFactory { * * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which * downloads will be written. - * @param executor An {@link Executor} used to make requests for media being downloaded. Providing - * an {@link Executor} that uses multiple threads will speed up download tasks that can be - * split into smaller parts for parallel execution. + * @param executor An {@link Executor} used to download data. Passing {@code Runnable::run} will + * cause each download task to download data on its own thread. Passing an {@link Executor} + * that uses multiple threads will speed up download tasks that can be split into smaller + * parts for parallel execution. */ public DefaultDownloaderFactory( CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - this.cacheDataSourceFactory = cacheDataSourceFactory; - this.executor = executor; + this.cacheDataSourceFactory = Assertions.checkNotNull(cacheDataSourceFactory); + this.executor = Assertions.checkNotNull(executor); } @Override public Downloader createDownloader(DownloadRequest request) { - switch (request.type) { - case DownloadRequest.TYPE_PROGRESSIVE: + @C.ContentType + int contentType = Util.inferContentTypeForUriAndMimeType(request.uri, request.mimeType); + switch (contentType) { + case C.TYPE_DASH: + case C.TYPE_HLS: + case C.TYPE_SS: + return createDownloader(request, contentType); + case C.TYPE_OTHER: return new ProgressiveDownloader( - request.uri, request.customCacheKey, cacheDataSourceFactory, executor); - case DownloadRequest.TYPE_DASH: - return createDownloader(request, DASH_DOWNLOADER_CONSTRUCTOR); - case DownloadRequest.TYPE_HLS: - return createDownloader(request, HLS_DOWNLOADER_CONSTRUCTOR); - case DownloadRequest.TYPE_SS: - return createDownloader(request, SS_DOWNLOADER_CONSTRUCTOR); + new MediaItem.Builder() + .setUri(request.uri) + .setCustomCacheKey(request.customCacheKey) + .build(), + cacheDataSourceFactory, + executor); default: - throw new IllegalArgumentException("Unsupported type: " + request.type); + throw new IllegalArgumentException("Unsupported type: " + contentType); } } - private Downloader createDownloader( - DownloadRequest request, @Nullable Constructor constructor) { + private Downloader createDownloader(DownloadRequest request, @C.ContentType int contentType) { + @Nullable Constructor constructor = CONSTRUCTORS.get(contentType); if (constructor == null) { - throw new IllegalStateException("Module missing for: " + request.type); + throw new IllegalStateException("Module missing for content type " + contentType); } + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(request.uri) + .setStreamKeys(request.streamKeys) + .setCustomCacheKey(request.customCacheKey) + .setDrmKeySetId(request.keySetId) + .build(); try { - return constructor.newInstance( - request.uri, request.streamKeys, cacheDataSourceFactory, executor); + return constructor.newInstance(mediaItem, cacheDataSourceFactory, executor); } catch (Exception e) { - throw new RuntimeException("Failed to instantiate downloader for: " + request.type, e); + throw new IllegalStateException( + "Failed to instantiate downloader for content type " + contentType); } } // LINT.IfChange + private static SparseArray> createDownloaderConstructors() { + SparseArray> array = new SparseArray<>(); + try { + array.put( + C.TYPE_DASH, + getDownloaderConstructor( + Class.forName("com.google.android.exoplayer2.source.dash.offline.DashDownloader"))); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the DASH module. + } + + try { + array.put( + C.TYPE_HLS, + getDownloaderConstructor( + Class.forName("com.google.android.exoplayer2.source.hls.offline.HlsDownloader"))); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the HLS module. + } + try { + array.put( + C.TYPE_SS, + getDownloaderConstructor( + Class.forName( + "com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader"))); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the SmoothStreaming module. + } + return array; + } + private static Constructor getDownloaderConstructor(Class clazz) { try { return clazz .asSubclass(Downloader.class) - .getConstructor(Uri.class, List.class, CacheDataSource.Factory.class, Executor.class); + .getConstructor(MediaItem.class, CacheDataSource.Factory.class, Executor.class); } catch (NoSuchMethodException e) { // The downloader is present, but the expected constructor is missing. - throw new RuntimeException("Downloader constructor missing", e); + throw new IllegalStateException("Downloader constructor missing", e); } } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 8e50d70020..ba8a799381 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.offline; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + import android.content.Context; import android.net.Uri; import android.os.Handler; @@ -24,18 +27,19 @@ import android.util.SparseIntArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; -import com.google.android.exoplayer2.source.MediaSourceFactory; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.chunk.MediaChunk; @@ -50,14 +54,13 @@ import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSource.Factory; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoRendererEventListener; import java.io.IOException; -import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -75,7 +78,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; *

    A typical usage of DownloadHelper follows these steps: * *

      - *
    1. Build the helper using one of the {@code forXXX} methods. + *
    2. Build the helper using one of the {@code forMediaItem} methods. *
    3. Prepare the helper using {@link #prepare(Callback)} and wait for the callback. *
    4. Optional: Inspect the selected tracks using {@link #getMappedTrackInfo(int)} and {@link * #getTrackSelections(int, int)}, and make adjustments using {@link @@ -144,18 +147,6 @@ public final class DownloadHelper { /** Thrown at an attempt to download live content. */ public static class LiveContentUnsupportedException extends IOException {} - @Nullable - private static final Constructor DASH_FACTORY_CONSTRUCTOR = - getConstructor("com.google.android.exoplayer2.source.dash.DashMediaSource$Factory"); - - @Nullable - private static final Constructor SS_FACTORY_CONSTRUCTOR = - getConstructor("com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory"); - - @Nullable - private static final Constructor HLS_FACTORY_CONSTRUCTOR = - getConstructor("com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory"); - /** * Extracts renderer capabilities for the renderers created by the provided renderers factory. * @@ -166,7 +157,7 @@ public final class DownloadHelper { public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) { Renderer[] renderers = renderersFactory.createRenderers( - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), new VideoRendererEventListener() {}, new AudioRendererEventListener() {}, (cues) -> {}, @@ -178,266 +169,264 @@ public final class DownloadHelper { return capabilities; } - /** @deprecated Use {@link #forProgressive(Context, Uri)} */ + /** @deprecated Use {@link #forMediaItem(Context, MediaItem)} */ @Deprecated - @SuppressWarnings("deprecation") - public static DownloadHelper forProgressive(Uri uri) { - return forProgressive(uri, /* cacheKey= */ null); - } - - /** - * Creates a {@link DownloadHelper} for progressive streams. - * - * @param context Any {@link Context}. - * @param uri A stream {@link Uri}. - * @return A {@link DownloadHelper} for progressive streams. - */ public static DownloadHelper forProgressive(Context context, Uri uri) { - return forProgressive(context, uri, /* cacheKey= */ null); + return forMediaItem(context, new MediaItem.Builder().setUri(uri).build()); } - /** @deprecated Use {@link #forProgressive(Context, Uri, String)} */ + /** @deprecated Use {@link #forMediaItem(Context, MediaItem)} */ @Deprecated - public static DownloadHelper forProgressive(Uri uri, @Nullable String cacheKey) { - return new DownloadHelper( - DownloadRequest.TYPE_PROGRESSIVE, - uri, - cacheKey, - /* mediaSource= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, - /* rendererCapabilities= */ new RendererCapabilities[0]); - } - - /** - * Creates a {@link DownloadHelper} for progressive streams. - * - * @param context Any {@link Context}. - * @param uri A stream {@link Uri}. - * @param cacheKey An optional cache key. - * @return A {@link DownloadHelper} for progressive streams. - */ public static DownloadHelper forProgressive(Context context, Uri uri, @Nullable String cacheKey) { - return new DownloadHelper( - DownloadRequest.TYPE_PROGRESSIVE, + return forMediaItem( + context, new MediaItem.Builder().setUri(uri).setCustomCacheKey(cacheKey).build()); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forDash( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forDash( uri, - cacheKey, - /* mediaSource= */ null, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory, DrmSessionManager)} instead. + */ + @Deprecated + public static DownloadHelper forDash( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(), + trackSelectorParameters, + renderersFactory, + dataSourceFactory, + drmSessionManager); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forHls( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forHls( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory, DrmSessionManager)} instead. + */ + @Deprecated + public static DownloadHelper forHls( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_M3U8).build(), + trackSelectorParameters, + renderersFactory, + dataSourceFactory, + drmSessionManager); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forSmoothStreaming( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forSmoothStreaming( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DownloadHelper forSmoothStreaming( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forSmoothStreaming( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory, DrmSessionManager)} instead. + */ + @Deprecated + public static DownloadHelper forSmoothStreaming( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_SS).build(), + trackSelectorParameters, + renderersFactory, + dataSourceFactory, + drmSessionManager); + } + + /** + * Creates a {@link DownloadHelper} for the given progressive media item. + * + * @param context The context. + * @param mediaItem A {@link MediaItem}. + * @return A {@link DownloadHelper} for progressive streams. + * @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming. + */ + public static DownloadHelper forMediaItem(Context context, MediaItem mediaItem) { + Assertions.checkArgument(isProgressive(checkNotNull(mediaItem.playbackProperties))); + return forMediaItem( + mediaItem, getDefaultTrackSelectorParameters(context), - /* rendererCapabilities= */ new RendererCapabilities[0]); - } - - /** @deprecated Use {@link #forDash(Context, Uri, Factory, RenderersFactory)} */ - @Deprecated - public static DownloadHelper forDash( - Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { - return forDash( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + /* renderersFactory= */ null, + /* dataSourceFactory= */ null, + /* drmSessionManager= */ null); } /** - * Creates a {@link DownloadHelper} for DASH streams. + * Creates a {@link DownloadHelper} for the given media item. * - * @param context Any {@link Context}. - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param context The context. + * @param mediaItem A {@link MediaItem}. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @return A {@link DownloadHelper} for DASH streams. - * @throws IllegalStateException If the DASH module is missing. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive + * streams. This argument is required for adaptive streams and ignored for progressive + * streams. + * @return A {@link DownloadHelper}. + * @throws IllegalStateException If the the corresponding module is missing for DASH, HLS or + * SmoothStreaming media items. + * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ - public static DownloadHelper forDash( + public static DownloadHelper forMediaItem( Context context, - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory) { - return forDash( - uri, - dataSourceFactory, + MediaItem mediaItem, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory) { + return forMediaItem( + mediaItem, + getDefaultTrackSelectorParameters(context), renderersFactory, - /* drmSessionManager= */ null, - getDefaultTrackSelectorParameters(context)); + dataSourceFactory, + /* drmSessionManager= */ null); } /** - * Creates a {@link DownloadHelper} for DASH streams. + * Creates a {@link DownloadHelper} for the given media item. * - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param mediaItem A {@link MediaItem}. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which - * tracks can be selected. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. - * @return A {@link DownloadHelper} for DASH streams. - * @throws IllegalStateException If the DASH module is missing. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive + * streams. This argument is required for adaptive streams and ignored for progressive + * streams. + * @return A {@link DownloadHelper}. + * @throws IllegalStateException If the the corresponding module is missing for DASH, HLS or + * SmoothStreaming media items. + * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ - public static DownloadHelper forDash( - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager, - DefaultTrackSelector.Parameters trackSelectorParameters) { - return new DownloadHelper( - DownloadRequest.TYPE_DASH, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - DASH_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), + public static DownloadHelper forMediaItem( + MediaItem mediaItem, + DefaultTrackSelector.Parameters trackSelectorParameters, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory) { + return forMediaItem( + mediaItem, trackSelectorParameters, - getRendererCapabilities(renderersFactory)); - } - - /** @deprecated Use {@link #forHls(Context, Uri, Factory, RenderersFactory)} */ - @Deprecated - public static DownloadHelper forHls( - Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { - return forHls( - uri, - dataSourceFactory, renderersFactory, - /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + dataSourceFactory, + /* drmSessionManager= */ null); } /** - * Creates a {@link DownloadHelper} for HLS streams. + * Creates a {@link DownloadHelper} for the given media item. * - * @param context Any {@link Context}. - * @param uri A playlist {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. + * @param mediaItem A {@link MediaItem}. * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. - * @return A {@link DownloadHelper} for HLS streams. - * @throws IllegalStateException If the HLS module is missing. - */ - public static DownloadHelper forHls( - Context context, - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory) { - return forHls( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - getDefaultTrackSelectorParameters(context)); - } - - /** - * Creates a {@link DownloadHelper} for HLS streams. - * - * @param uri A playlist {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. - * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are - * selected. - * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which - * tracks can be selected. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for * downloading. - * @return A {@link DownloadHelper} for HLS streams. - * @throws IllegalStateException If the HLS module is missing. - */ - public static DownloadHelper forHls( - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager, - DefaultTrackSelector.Parameters trackSelectorParameters) { - return new DownloadHelper( - DownloadRequest.TYPE_HLS, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - HLS_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), - trackSelectorParameters, - getRendererCapabilities(renderersFactory)); - } - - /** @deprecated Use {@link #forSmoothStreaming(Context, Uri, Factory, RenderersFactory)} */ - @Deprecated - public static DownloadHelper forSmoothStreaming( - Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { - return forSmoothStreaming( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); - } - - /** - * Creates a {@link DownloadHelper} for SmoothStreaming streams. - * - * @param context Any {@link Context}. - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are - * selected. - * @return A {@link DownloadHelper} for SmoothStreaming streams. - * @throws IllegalStateException If the SmoothStreaming module is missing. - */ - public static DownloadHelper forSmoothStreaming( - Context context, - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory) { - return forSmoothStreaming( - uri, - dataSourceFactory, - renderersFactory, - /* drmSessionManager= */ null, - getDefaultTrackSelectorParameters(context)); - } - - /** - * Creates a {@link DownloadHelper} for SmoothStreaming streams. - * - * @param uri A manifest {@link Uri}. - * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. - * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are - * selected. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive + * streams. This argument is required for adaptive streams and ignored for progressive + * streams. * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which * tracks can be selected. - * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for - * downloading. - * @return A {@link DownloadHelper} for SmoothStreaming streams. - * @throws IllegalStateException If the SmoothStreaming module is missing. + * @return A {@link DownloadHelper}. + * @throws IllegalStateException If the the corresponding module is missing for DASH, HLS or + * SmoothStreaming media items. + * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. */ - public static DownloadHelper forSmoothStreaming( - Uri uri, - DataSource.Factory dataSourceFactory, - RenderersFactory renderersFactory, - @Nullable DrmSessionManager drmSessionManager, - DefaultTrackSelector.Parameters trackSelectorParameters) { + public static DownloadHelper forMediaItem( + MediaItem mediaItem, + DefaultTrackSelector.Parameters trackSelectorParameters, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory, + @Nullable DrmSessionManager drmSessionManager) { + boolean isProgressive = isProgressive(checkNotNull(mediaItem.playbackProperties)); + Assertions.checkArgument(isProgressive || dataSourceFactory != null); return new DownloadHelper( - DownloadRequest.TYPE_SS, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - SS_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), + mediaItem, + isProgressive + ? null + : createMediaSourceInternal( + mediaItem, castNonNull(dataSourceFactory), drmSessionManager), trackSelectorParameters, - getRendererCapabilities(renderersFactory)); + renderersFactory != null + ? getRendererCapabilities(renderersFactory) + : new RendererCapabilities[0]); } /** - * Equivalent to {@link #createMediaSource(DownloadRequest, Factory, DrmSessionManager) + * Equivalent to {@link #createMediaSource(DownloadRequest, DataSource.Factory, DrmSessionManager) * createMediaSource(downloadRequest, dataSourceFactory, null)}. */ public static MediaSource createMediaSource( @@ -459,35 +448,11 @@ public final class DownloadHelper { DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory, @Nullable DrmSessionManager drmSessionManager) { - @Nullable Constructor constructor; - switch (downloadRequest.type) { - case DownloadRequest.TYPE_DASH: - constructor = DASH_FACTORY_CONSTRUCTOR; - break; - case DownloadRequest.TYPE_SS: - constructor = SS_FACTORY_CONSTRUCTOR; - break; - case DownloadRequest.TYPE_HLS: - constructor = HLS_FACTORY_CONSTRUCTOR; - break; - case DownloadRequest.TYPE_PROGRESSIVE: - return new ProgressiveMediaSource.Factory(dataSourceFactory) - .setCustomCacheKey(downloadRequest.customCacheKey) - .createMediaSource(downloadRequest.uri); - default: - throw new IllegalStateException("Unsupported type: " + downloadRequest.type); - } return createMediaSourceInternal( - constructor, - downloadRequest.uri, - dataSourceFactory, - drmSessionManager, - downloadRequest.streamKeys); + downloadRequest.toMediaItem(), dataSourceFactory, drmSessionManager); } - private final String downloadType; - private final Uri uri; - @Nullable private final String cacheKey; + private final MediaItem.PlaybackProperties playbackProperties; @Nullable private final MediaSource mediaSource; private final DefaultTrackSelector trackSelector; private final RendererCapabilities[] rendererCapabilities; @@ -506,9 +471,7 @@ public final class DownloadHelper { /** * Creates download helper. * - * @param downloadType A download type. This value will be used as {@link DownloadRequest#type}. - * @param uri A {@link Uri}. - * @param cacheKey An optional cache key. + * @param mediaItem The media item. * @param mediaSource A {@link MediaSource} for which tracks are selected, or null if no track * selection needs to be made. * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for @@ -517,22 +480,18 @@ public final class DownloadHelper { * are selected. */ public DownloadHelper( - String downloadType, - Uri uri, - @Nullable String cacheKey, + MediaItem mediaItem, @Nullable MediaSource mediaSource, DefaultTrackSelector.Parameters trackSelectorParameters, RendererCapabilities[] rendererCapabilities) { - this.downloadType = downloadType; - this.uri = uri; - this.cacheKey = cacheKey; + this.playbackProperties = checkNotNull(mediaItem.playbackProperties); this.mediaSource = mediaSource; this.trackSelector = new DefaultTrackSelector(trackSelectorParameters, new DownloadTrackSelection.Factory()); this.rendererCapabilities = rendererCapabilities; this.scratchSet = new SparseIntArray(); - trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter()); - callbackHandler = new Handler(Util.getLooper()); + trackSelector.init(/* listener= */ () -> {}, new FakeBandwidthMeter()); + callbackHandler = Util.createHandlerForCurrentOrMainLooper(); window = new Timeline.Window(); } @@ -766,7 +725,7 @@ public final class DownloadHelper { * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(@Nullable byte[] data) { - return getDownloadRequest(uri.toString(), data); + return getDownloadRequest(playbackProperties.uri.toString(), data); } /** @@ -778,9 +737,17 @@ public final class DownloadHelper { * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) { + DownloadRequest.Builder requestBuilder = + new DownloadRequest.Builder(id, playbackProperties.uri) + .setMimeType(playbackProperties.mimeType) + .setKeySetId( + playbackProperties.drmConfiguration != null + ? playbackProperties.drmConfiguration.getKeySetId() + : null) + .setCustomCacheKey(playbackProperties.customCacheKey) + .setData(data); if (mediaSource == null) { - return new DownloadRequest( - id, downloadType, uri, /* streamKeys= */ Collections.emptyList(), cacheKey, data); + return requestBuilder.build(); } assertPreparedWithMedia(); List streamKeys = new ArrayList<>(); @@ -794,15 +761,15 @@ public final class DownloadHelper { } streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); } - return new DownloadRequest(id, downloadType, uri, streamKeys, cacheKey, data); + return requestBuilder.setStreamKeys(streamKeys).build(); } // Initialization of array of Lists. @SuppressWarnings("unchecked") private void onMediaPrepared() { - Assertions.checkNotNull(mediaPreparer); - Assertions.checkNotNull(mediaPreparer.mediaPeriods); - Assertions.checkNotNull(mediaPreparer.timeline); + checkNotNull(mediaPreparer); + checkNotNull(mediaPreparer.mediaPeriods); + checkNotNull(mediaPreparer.timeline); int periodCount = mediaPreparer.mediaPeriods.length; int rendererCount = rendererCapabilities.length; trackSelectionsByPeriodAndRenderer = @@ -822,16 +789,14 @@ public final class DownloadHelper { trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups(); TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); trackSelector.onSelectionActivated(trackSelectorResult.info); - mappedTrackInfos[i] = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + mappedTrackInfos[i] = checkNotNull(trackSelector.getCurrentMappedTrackInfo()); } setPreparedWithMedia(); - Assertions.checkNotNull(callbackHandler) - .post(() -> Assertions.checkNotNull(callback).onPrepared(this)); + checkNotNull(callbackHandler).post(() -> checkNotNull(callback).onPrepared(this)); } private void onMediaPreparationFailed(IOException error) { - Assertions.checkNotNull(callbackHandler) - .post(() -> Assertions.checkNotNull(callback).onPrepareError(this, error)); + checkNotNull(callbackHandler).post(() -> checkNotNull(callback).onPrepareError(this, error)); } @RequiresNonNull({ @@ -921,44 +886,19 @@ public final class DownloadHelper { } } - @Nullable - private static Constructor getConstructor(String className) { - try { - // LINT.IfChange - Class factoryClazz = - Class.forName(className).asSubclass(MediaSourceFactory.class); - return factoryClazz.getConstructor(Factory.class); - // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - } catch (ClassNotFoundException e) { - // Expected if the app was built without the respective module. - return null; - } catch (NoSuchMethodException e) { - // Something is wrong with the library or the proguard configuration. - throw new IllegalStateException(e); - } + private static MediaSource createMediaSourceInternal( + MediaItem mediaItem, + DataSource.Factory dataSourceFactory, + @Nullable DrmSessionManager drmSessionManager) { + return new DefaultMediaSourceFactory(dataSourceFactory, ExtractorsFactory.EMPTY) + .setDrmSessionManager(drmSessionManager) + .createMediaSource(mediaItem); } - private static MediaSource createMediaSourceInternal( - @Nullable Constructor constructor, - Uri uri, - Factory dataSourceFactory, - @Nullable DrmSessionManager drmSessionManager, - @Nullable List streamKeys) { - if (constructor == null) { - throw new IllegalStateException("Module missing to create media source."); - } - try { - MediaSourceFactory factory = constructor.newInstance(dataSourceFactory); - if (drmSessionManager != null) { - factory.setDrmSessionManager(drmSessionManager); - } - if (streamKeys != null) { - factory.setStreamKeys(streamKeys); - } - return Assertions.checkNotNull(factory.createMediaSource(uri)); - } catch (Exception e) { - throw new IllegalStateException("Failed to instantiate media source.", e); - } + private static boolean isProgressive(MediaItem.PlaybackProperties playbackProperties) { + return Util.inferContentTypeForUriAndMimeType( + playbackProperties.uri, playbackProperties.mimeType) + == C.TYPE_OTHER; } private static final class MediaPreparer @@ -991,7 +931,8 @@ public final class DownloadHelper { allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); pendingMediaPeriods = new ArrayList<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler downloadThreadHandler = Util.createHandler(this::handleDownloadHelperCallbackMessage); + Handler downloadThreadHandler = + Util.createHandlerForCurrentOrMainLooper(this::handleDownloadHelperCallbackMessage); this.downloadHelperHandler = downloadThreadHandler; mediaSourceThread = new HandlerThread("ExoPlayer:DownloadHelper"); mediaSourceThread.start(); @@ -1115,7 +1056,7 @@ public final class DownloadHelper { return true; case DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED: release(); - downloadHelper.onMediaPreparationFailed((IOException) Util.castNonNull(msg.obj)); + downloadHelper.onMediaPreparationFailed((IOException) castNonNull(msg.obj)); return true; default: return false; @@ -1172,7 +1113,7 @@ public final class DownloadHelper { } } - private static final class DummyBandwidthMeter implements BandwidthMeter { + private static final class FakeBandwidthMeter implements BandwidthMeter { @Override public long getBitrateEstimate() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java index 32931d9f32..b6228025cf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadManager.java @@ -25,6 +25,7 @@ import static com.google.android.exoplayer2.offline.Download.STATE_REMOVING; import static com.google.android.exoplayer2.offline.Download.STATE_RESTARTING; import static com.google.android.exoplayer2.offline.Download.STATE_STOPPED; import static com.google.android.exoplayer2.offline.Download.STOP_REASON_NONE; +import static java.lang.Math.min; import android.content.Context; import android.os.Handler; @@ -52,6 +53,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executor; /** * Manages downloads. @@ -93,8 +95,11 @@ public final class DownloadManager { * * @param downloadManager The reporting instance. * @param download The state of the download. + * @param finalException If the download is transitioning to {@link Download#STATE_FAILED}, this + * is the final exception that resulted in the failure. */ - default void onDownloadChanged(DownloadManager downloadManager, Download download) {} + default void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Exception finalException) {} /** * Called when a download is removed. @@ -194,16 +199,42 @@ public final class DownloadManager { * an {@link CacheEvictor} that will not evict downloaded content, for example {@link * NoOpCacheEvictor}. * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data. + * @deprecated Use {@link #DownloadManager(Context, DatabaseProvider, Cache, Factory, Executor)}. */ + @Deprecated public DownloadManager( Context context, DatabaseProvider databaseProvider, Cache cache, Factory upstreamFactory) { + this(context, databaseProvider, cache, upstreamFactory, Runnable::run); + } + + /** + * Constructs a {@link DownloadManager}. + * + * @param context Any context. + * @param databaseProvider Provides the SQLite database in which downloads are persisted. + * @param cache A cache to be used to store downloaded data. The cache should be configured with + * an {@link CacheEvictor} that will not evict downloaded content, for example {@link + * NoOpCacheEvictor}. + * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data. + * @param executor An {@link Executor} used to download data. Passing {@code Runnable::run} will + * cause each download task to download data on its own thread. Passing an {@link Executor} + * that uses multiple threads will speed up download tasks that can be split into smaller + * parts for parallel execution. + */ + public DownloadManager( + Context context, + DatabaseProvider databaseProvider, + Cache cache, + Factory upstreamFactory, + Executor executor) { this( context, new DefaultDownloadIndex(databaseProvider), new DefaultDownloaderFactory( new CacheDataSource.Factory() .setCache(cache) - .setUpstreamDataSourceFactory(upstreamFactory))); + .setUpstreamDataSourceFactory(upstreamFactory), + executor)); } /** @@ -225,7 +256,7 @@ public final class DownloadManager { listeners = new CopyOnWriteArraySet<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler mainHandler = Util.createHandler(this::handleMainMessage); + Handler mainHandler = Util.createHandlerForCurrentOrMainLooper(this::handleMainMessage); this.applicationHandler = mainHandler; HandlerThread internalThread = new HandlerThread("ExoPlayer:DownloadManager"); internalThread.start(); @@ -294,6 +325,7 @@ public final class DownloadManager { * @param listener The listener to be added. */ public void addListener(Listener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } @@ -614,7 +646,7 @@ public final class DownloadManager { } } else { for (Listener listener : listeners) { - listener.onDownloadChanged(this, updatedDownload); + listener.onDownloadChanged(this, updatedDownload, update.finalException); } } if (waitingForRequirementsChanged) { @@ -906,7 +938,8 @@ public final class DownloadManager { ArrayList updateList = new ArrayList<>(downloads); for (int i = 0; i < downloads.size(); i++) { DownloadUpdate update = - new DownloadUpdate(downloads.get(i), /* isRemove= */ false, updateList); + new DownloadUpdate( + downloads.get(i), /* isRemove= */ false, updateList, /* finalException= */ null); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); } syncTasks(); @@ -1073,9 +1106,9 @@ public final class DownloadManager { return; } - @Nullable Throwable finalError = task.finalError; - if (finalError != null) { - Log.e(TAG, "Task failed: " + task.request + ", " + isRemove, finalError); + @Nullable Exception finalException = task.finalException; + if (finalException != null) { + Log.e(TAG, "Task failed: " + task.request + ", " + isRemove, finalException); } Download download = @@ -1083,7 +1116,7 @@ public final class DownloadManager { switch (download.state) { case STATE_DOWNLOADING: Assertions.checkState(!isRemove); - onDownloadTaskStopped(download, finalError); + onDownloadTaskStopped(download, finalException); break; case STATE_REMOVING: case STATE_RESTARTING: @@ -1101,16 +1134,16 @@ public final class DownloadManager { syncTasks(); } - private void onDownloadTaskStopped(Download download, @Nullable Throwable finalError) { + private void onDownloadTaskStopped(Download download, @Nullable Exception finalException) { download = new Download( download.request, - finalError == null ? STATE_COMPLETED : STATE_FAILED, + finalException == null ? STATE_COMPLETED : STATE_FAILED, download.startTimeMs, /* updateTimeMs= */ System.currentTimeMillis(), download.contentLength, download.stopReason, - finalError == null ? FAILURE_REASON_NONE : FAILURE_REASON_UNKNOWN, + finalException == null ? FAILURE_REASON_NONE : FAILURE_REASON_UNKNOWN, download.progress); // The download is now in a terminal state, so should not be in the downloads list. downloads.remove(getDownloadIndex(download.request.id)); @@ -1121,7 +1154,8 @@ public final class DownloadManager { Log.e(TAG, "Failed to update index.", e); } DownloadUpdate update = - new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); + new DownloadUpdate( + download, /* isRemove= */ false, new ArrayList<>(downloads), finalException); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); } @@ -1139,7 +1173,11 @@ public final class DownloadManager { Log.e(TAG, "Failed to remove from database"); } DownloadUpdate update = - new DownloadUpdate(download, /* isRemove= */ true, new ArrayList<>(downloads)); + new DownloadUpdate( + download, + /* isRemove= */ true, + new ArrayList<>(downloads), + /* finalException= */ null); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); } } @@ -1194,7 +1232,11 @@ public final class DownloadManager { Log.e(TAG, "Failed to update index.", e); } DownloadUpdate update = - new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); + new DownloadUpdate( + download, + /* isRemove= */ false, + new ArrayList<>(downloads), + /* finalException= */ null); mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); return download; } @@ -1252,7 +1294,7 @@ public final class DownloadManager { @Nullable private volatile InternalHandler internalHandler; private volatile boolean isCanceled; - @Nullable private Throwable finalError; + @Nullable private Exception finalException; private long contentLength; @@ -1284,6 +1326,7 @@ public final class DownloadManager { if (!isCanceled) { isCanceled = true; downloader.cancel(); + interrupt(); } } @@ -1316,8 +1359,10 @@ public final class DownloadManager { } } } - } catch (Throwable e) { - finalError = e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + finalException = e; } @Nullable Handler internalHandler = this.internalHandler; if (internalHandler != null) { @@ -1345,7 +1390,7 @@ public final class DownloadManager { } private static int getRetryDelayMillis(int errorCount) { - return Math.min((errorCount - 1) * 1000, 5000); + return min((errorCount - 1) * 1000, 5000); } } @@ -1354,11 +1399,17 @@ public final class DownloadManager { public final Download download; public final boolean isRemove; public final List downloads; + @Nullable public final Exception finalException; - public DownloadUpdate(Download download, boolean isRemove, List downloads) { + public DownloadUpdate( + Download download, + boolean isRemove, + List downloads, + @Nullable Exception finalException) { this.download = download; this.isRemove = isRemove; this.downloads = downloads; + this.finalException = finalException; } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java index 988b908140..1fa1655445 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java @@ -21,8 +21,11 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -35,23 +38,78 @@ public final class DownloadRequest implements Parcelable { /** Thrown when the encoded request data belongs to an unsupported request type. */ public static class UnsupportedRequestException extends IOException {} - /** Type for progressive downloads. */ - public static final String TYPE_PROGRESSIVE = "progressive"; - /** Type for DASH downloads. */ - public static final String TYPE_DASH = "dash"; - /** Type for HLS downloads. */ - public static final String TYPE_HLS = "hls"; - /** Type for SmoothStreaming downloads. */ - public static final String TYPE_SS = "ss"; + /** A builder for download requests. */ + public static class Builder { + private final String id; + private final Uri uri; + @Nullable private String mimeType; + @Nullable private List streamKeys; + @Nullable private byte[] keySetId; + @Nullable private String customCacheKey; + @Nullable private byte[] data; + + /** Creates a new instance with the specified id and uri. */ + public Builder(String id, Uri uri) { + this.id = id; + this.uri = uri; + } + + /** Sets the {@link DownloadRequest#mimeType}. */ + public Builder setMimeType(@Nullable String mimeType) { + this.mimeType = mimeType; + return this; + } + + /** Sets the {@link DownloadRequest#streamKeys}. */ + public Builder setStreamKeys(@Nullable List streamKeys) { + this.streamKeys = streamKeys; + return this; + } + + /** Sets the {@link DownloadRequest#keySetId}. */ + public Builder setKeySetId(@Nullable byte[] keySetId) { + this.keySetId = keySetId; + return this; + } + + /** Sets the {@link DownloadRequest#customCacheKey}. */ + public Builder setCustomCacheKey(@Nullable String customCacheKey) { + this.customCacheKey = customCacheKey; + return this; + } + + /** Sets the {@link DownloadRequest#data}. */ + public Builder setData(@Nullable byte[] data) { + this.data = data; + return this; + } + + public DownloadRequest build() { + return new DownloadRequest( + id, + uri, + mimeType, + streamKeys != null ? streamKeys : ImmutableList.of(), + keySetId, + customCacheKey, + data); + } + } /** The unique content id. */ public final String id; - /** The type of the request. */ - public final String type; /** The uri being downloaded. */ public final Uri uri; + /** + * The MIME type of this content. Used as a hint to infer the content's type (DASH, HLS, + * SmoothStreaming). If null, a {@link DownloadService} will infer the content type from the + * {@link #uri}. + */ + @Nullable public final String mimeType; /** Stream keys to be downloaded. If empty, all streams will be downloaded. */ public final List streamKeys; + /** The key set id of the offline licence if the content is protected with DRM. */ + @Nullable public final byte[] keySetId; /** * Custom key for cache indexing, or null. Must be null for DASH, HLS and SmoothStreaming * downloads. @@ -62,43 +120,47 @@ public final class DownloadRequest implements Parcelable { /** * @param id See {@link #id}. - * @param type See {@link #type}. * @param uri See {@link #uri}. + * @param mimeType See {@link #mimeType} * @param streamKeys See {@link #streamKeys}. * @param customCacheKey See {@link #customCacheKey}. * @param data See {@link #data}. */ - public DownloadRequest( + private DownloadRequest( String id, - String type, Uri uri, + @Nullable String mimeType, List streamKeys, + @Nullable byte[] keySetId, @Nullable String customCacheKey, @Nullable byte[] data) { - if (TYPE_DASH.equals(type) || TYPE_HLS.equals(type) || TYPE_SS.equals(type)) { + @C.ContentType int contentType = Util.inferContentTypeForUriAndMimeType(uri, mimeType); + if (contentType == C.TYPE_DASH || contentType == C.TYPE_HLS || contentType == C.TYPE_SS) { Assertions.checkArgument( - customCacheKey == null, "customCacheKey must be null for type: " + type); + customCacheKey == null, "customCacheKey must be null for type: " + contentType); } this.id = id; - this.type = type; this.uri = uri; + this.mimeType = mimeType; ArrayList mutableKeys = new ArrayList<>(streamKeys); Collections.sort(mutableKeys); this.streamKeys = Collections.unmodifiableList(mutableKeys); + this.keySetId = keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : null; this.customCacheKey = customCacheKey; this.data = data != null ? Arrays.copyOf(data, data.length) : Util.EMPTY_BYTE_ARRAY; } /* package */ DownloadRequest(Parcel in) { id = castNonNull(in.readString()); - type = castNonNull(in.readString()); uri = Uri.parse(castNonNull(in.readString())); + mimeType = in.readString(); int streamKeyCount = in.readInt(); ArrayList mutableStreamKeys = new ArrayList<>(streamKeyCount); for (int i = 0; i < streamKeyCount; i++) { mutableStreamKeys.add(in.readParcelable(StreamKey.class.getClassLoader())); } streamKeys = Collections.unmodifiableList(mutableStreamKeys); + keySetId = in.createByteArray(); customCacheKey = in.readString(); data = castNonNull(in.createByteArray()); } @@ -110,24 +172,32 @@ public final class DownloadRequest implements Parcelable { * @return The copy with the specified ID. */ public DownloadRequest copyWithId(String id) { - return new DownloadRequest(id, type, uri, streamKeys, customCacheKey, data); + return new DownloadRequest(id, uri, mimeType, streamKeys, keySetId, customCacheKey, data); + } + + /** + * Returns a copy with the specified key set ID. + * + * @param keySetId The key set ID of the copy. + * @return The copy with the specified key set ID. + */ + public DownloadRequest copyWithKeySetId(@Nullable byte[] keySetId) { + return new DownloadRequest(id, uri, mimeType, streamKeys, keySetId, customCacheKey, data); } /** * Returns the result of merging {@code newRequest} into this request. The requests must have the - * same {@link #id} and {@link #type}. + * same {@link #id}. * - *

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

      The resulting request contains the stream keys from both requests. For all other member + * variables, those in {@code newRequest} are preferred. * * @param newRequest The request being merged. * @return The merged result. - * @throws IllegalArgumentException If the requests do not have the same {@link #id} and {@link - * #type}. + * @throws IllegalArgumentException If the requests do not have the same {@link #id}. */ public DownloadRequest copyWithMergedRequest(DownloadRequest newRequest) { Assertions.checkArgument(id.equals(newRequest.id)); - Assertions.checkArgument(type.equals(newRequest.type)); List mergedKeys; if (streamKeys.isEmpty() || newRequest.streamKeys.isEmpty()) { // If either streamKeys is empty then all streams should be downloaded. @@ -142,12 +212,30 @@ public final class DownloadRequest implements Parcelable { } } return new DownloadRequest( - id, type, newRequest.uri, mergedKeys, newRequest.customCacheKey, newRequest.data); + id, + newRequest.uri, + newRequest.mimeType, + mergedKeys, + newRequest.keySetId, + newRequest.customCacheKey, + newRequest.data); + } + + /** Returns a {@link MediaItem} for the content defined by the request. */ + public MediaItem toMediaItem() { + return new MediaItem.Builder() + .setMediaId(id) + .setUri(uri) + .setCustomCacheKey(customCacheKey) + .setMimeType(mimeType) + .setStreamKeys(streamKeys) + .setDrmKeySetId(keySetId) + .build(); } @Override public String toString() { - return type + ":" + id; + return mimeType + ":" + id; } @Override @@ -157,20 +245,21 @@ public final class DownloadRequest implements Parcelable { } DownloadRequest that = (DownloadRequest) o; return id.equals(that.id) - && type.equals(that.type) && uri.equals(that.uri) + && Util.areEqual(mimeType, that.mimeType) && streamKeys.equals(that.streamKeys) + && Arrays.equals(keySetId, that.keySetId) && Util.areEqual(customCacheKey, that.customCacheKey) && Arrays.equals(data, that.data); } @Override public final int hashCode() { - int result = type.hashCode(); - result = 31 * result + id.hashCode(); - result = 31 * result + type.hashCode(); + int result = 31 * id.hashCode(); result = 31 * result + uri.hashCode(); + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); result = 31 * result + streamKeys.hashCode(); + result = 31 * result + Arrays.hashCode(keySetId); result = 31 * result + (customCacheKey != null ? customCacheKey.hashCode() : 0); result = 31 * result + Arrays.hashCode(data); return result; @@ -186,12 +275,13 @@ public final class DownloadRequest implements Parcelable { @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(id); - dest.writeString(type); dest.writeString(uri.toString()); + dest.writeString(mimeType); dest.writeInt(streamKeys.size()); for (int i = 0; i < streamKeys.size(); i++) { dest.writeParcelable(streamKeys.get(i), /* parcelableFlags= */ 0); } + dest.writeByteArray(keySetId); dest.writeString(customCacheKey); dest.writeByteArray(data); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java index 0ee9a83260..527c51ea83 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadService.java @@ -658,6 +658,22 @@ public abstract class DownloadService extends Service { if (requirements == null) { Log.e(TAG, "Ignored SET_REQUIREMENTS: Missing " + KEY_REQUIREMENTS + " extra"); } else { + @Nullable Scheduler scheduler = getScheduler(); + if (scheduler != null) { + Requirements supportedRequirements = scheduler.getSupportedRequirements(requirements); + if (!supportedRequirements.equals(requirements)) { + Log.w( + TAG, + "Ignoring requirements not supported by the Scheduler: " + + (requirements.getRequirements() ^ supportedRequirements.getRequirements())); + // We need to make sure DownloadManager only uses requirements supported by the + // Scheduler. If we don't do this, DownloadManager can report itself as idle due to an + // unmet requirement that the Scheduler doesn't support. This can then lead to the + // service being destroyed, even though the Scheduler won't be able to restart it when + // the requirement is subsequently met. + requirements = supportedRequirements; + } + } downloadManager.setRequirements(requirements); } break; @@ -934,7 +950,7 @@ public abstract class DownloadService extends Service { // DownloadService.getForegroundNotification, and concrete subclass implementations may // not anticipate the possibility of this method being called before their onCreate // implementation has finished executing. - Util.createHandler() + Util.createHandlerForCurrentOrMainLooper() .postAtFrontOfQueue( () -> downloadService.notifyDownloads(downloadManager.getCurrentDownloads())); } @@ -958,7 +974,8 @@ public abstract class DownloadService extends Service { } @Override - public void onDownloadChanged(DownloadManager downloadManager, Download download) { + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Exception finalException) { if (downloadService != null) { downloadService.notifyDownloadChanged(download); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java index 98079bf200..1059157d34 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.offline; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import java.io.IOException; +import java.util.concurrent.CancellationException; /** Downloads and removes a piece of content. */ public interface Downloader { @@ -30,7 +31,7 @@ public interface Downloader { * *

      May be called directly from {@link #download}, or from any other thread used by the * downloader. In all cases, {@link #download} is guaranteed not to return until after the last - * call to {@link #onProgress} has finished executing. + * call to this method has finished executing. * * @param contentLength The length of the content in bytes, or {@link C#LENGTH_UNSET} if * unknown. @@ -44,13 +45,30 @@ public interface Downloader { /** * Downloads the content. * + *

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

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

      Once canceled, {@link #download} cannot be called again. + */ void cancel(); /** Removes the content. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java index 6ad186b575..09fa444cf3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -18,41 +18,72 @@ package com.google.android.exoplayer2.offline; import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.upstream.cache.CacheWriter; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; +import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; +import com.google.android.exoplayer2.util.RunnableFutureTask; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** A downloader for progressive media streams. */ public final class ProgressiveDownloader implements Downloader { - private static final int BUFFER_SIZE_BYTES = 128 * 1024; - + private final Executor executor; private final DataSpec dataSpec; private final CacheDataSource dataSource; - private final AtomicBoolean isCanceled; + @Nullable private final PriorityTaskManager priorityTaskManager; - @Nullable private volatile Thread downloadThread; + @Nullable private ProgressListener progressListener; + private volatile @MonotonicNonNull RunnableFutureTask downloadRunnable; + private volatile boolean isCanceled; - /** - * @param uri Uri of the data to be downloaded. - * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache - * indexing. May be null. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. - */ + /** @deprecated Use {@link #ProgressiveDownloader(MediaItem, CacheDataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public ProgressiveDownloader( Uri uri, @Nullable String customCacheKey, CacheDataSource.Factory cacheDataSourceFactory) { this(uri, customCacheKey, cacheDataSourceFactory, Runnable::run); } /** - * @param uri Uri of the data to be downloaded. - * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache - * indexing. May be null. + * Creates a new instance. + * + * @param mediaItem The media item with a uri to the stream to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public ProgressiveDownloader( + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #ProgressiveDownloader(MediaItem, CacheDataSource.Factory, Executor)} + * instead. + */ + @Deprecated + public ProgressiveDownloader( + Uri uri, + @Nullable String customCacheKey, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(uri).setCustomCacheKey(customCacheKey).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The media item with a uri to the stream to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. In @@ -60,39 +91,75 @@ public final class ProgressiveDownloader implements Downloader { * download by allowing parts of it to be executed in parallel. */ public ProgressiveDownloader( - Uri uri, - @Nullable String customCacheKey, - CacheDataSource.Factory cacheDataSourceFactory, - Executor executor) { + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + this.executor = Assertions.checkNotNull(executor); + Assertions.checkNotNull(mediaItem.playbackProperties); dataSpec = new DataSpec.Builder() - .setUri(uri) - .setKey(customCacheKey) + .setUri(mediaItem.playbackProperties.uri) + .setKey(mediaItem.playbackProperties.customCacheKey) .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) .build(); dataSource = cacheDataSourceFactory.createDataSourceForDownloading(); - isCanceled = new AtomicBoolean(); + priorityTaskManager = cacheDataSourceFactory.getUpstreamPriorityTaskManager(); } @Override - public void download(@Nullable ProgressListener progressListener) throws IOException { - downloadThread = Thread.currentThread(); - if (isCanceled.get()) { - return; + public void download(@Nullable ProgressListener progressListener) + throws IOException, InterruptedException { + this.progressListener = progressListener; + if (downloadRunnable == null) { + CacheWriter cacheWriter = + new CacheWriter( + dataSource, + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + this::onProgress); + downloadRunnable = + new RunnableFutureTask() { + @Override + protected Void doWork() throws IOException { + cacheWriter.cache(); + return null; + } + + @Override + protected void cancelWork() { + cacheWriter.cancel(); + } + }; } - @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager(); + if (priorityTaskManager != null) { priorityTaskManager.add(C.PRIORITY_DOWNLOAD); } try { - CacheUtil.cache( - dataSource, - dataSpec, - progressListener == null ? null : new ProgressForwarder(progressListener), - isCanceled, - /* enableEOFException= */ true, - /* temporaryBuffer= */ new byte[BUFFER_SIZE_BYTES]); + boolean finished = false; + while (!finished && !isCanceled) { + if (priorityTaskManager != null) { + priorityTaskManager.proceed(C.PRIORITY_DOWNLOAD); + } + executor.execute(downloadRunnable); + try { + downloadRunnable.get(); + finished = true; + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof PriorityTooLowException) { + // The next loop iteration will block until the task is able to proceed. + } else if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(cause); + } + } + } } finally { + // If the main download thread was interrupted as part of cancelation, then it's possible that + // the runnable is still doing work. We need to wait until it's finished before returning. + downloadRunnable.blockUntilFinished(); if (priorityTaskManager != null) { priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); } @@ -101,33 +168,26 @@ public final class ProgressiveDownloader implements Downloader { @Override public void cancel() { - isCanceled.set(true); - @Nullable Thread downloadThread = this.downloadThread; - if (downloadThread != null) { - downloadThread.interrupt(); + isCanceled = true; + RunnableFutureTask downloadRunnable = this.downloadRunnable; + if (downloadRunnable != null) { + downloadRunnable.cancel(/* interruptIfRunning= */ true); } } @Override public void remove() { - CacheUtil.remove(dataSpec, dataSource.getCache(), dataSource.getCacheKeyFactory()); + dataSource.getCache().removeResource(dataSource.getCacheKeyFactory().buildCacheKey(dataSpec)); } - private static final class ProgressForwarder implements CacheUtil.ProgressListener { - - private final ProgressListener progessListener; - - public ProgressForwarder(ProgressListener progressListener) { - this.progessListener = progressListener; - } - - @Override - public void onProgress(long contentLength, long bytesCached, long newBytesCached) { - float percentDownloaded = - contentLength == C.LENGTH_UNSET || contentLength == 0 - ? C.PERCENTAGE_UNSET - : ((bytesCached * 100f) / contentLength); - progessListener.onProgress(contentLength, bytesCached, percentDownloaded); + private void onProgress(long contentLength, long bytesCached, long newBytesCached) { + if (progressListener == null) { + return; } + float percentDownloaded = + contentLength == C.LENGTH_UNSET || contentLength == 0 + ? C.PERCENTAGE_UNSET + : ((bytesCached * 100f) / contentLength); + progressListener.onProgress(contentLength, bytesCached, percentDownloaded); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java index 601945c69d..7cf31bc030 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -15,10 +15,12 @@ */ package com.google.android.exoplayer2.offline; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; -import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.ParsingLoadable; @@ -26,17 +28,21 @@ import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; -import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.upstream.cache.CacheWriter; +import com.google.android.exoplayer2.upstream.cache.ContentMetadata; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.PriorityTaskManager; +import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; +import com.google.android.exoplayer2.util.RunnableFutureTask; import com.google.android.exoplayer2.util.Util; import java.io.IOException; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; /** * Base class for multi segment stream downloaders. @@ -73,16 +79,26 @@ public abstract class SegmentDownloader> impleme private final Parser manifestParser; private final ArrayList streamKeys; private final CacheDataSource.Factory cacheDataSourceFactory; + private final Cache cache; + private final CacheKeyFactory cacheKeyFactory; + @Nullable private final PriorityTaskManager priorityTaskManager; private final Executor executor; - private final AtomicBoolean isCanceled; - - @Nullable private volatile Thread downloadThread; /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param manifestParser A parser for the manifest. - * @param streamKeys Keys defining which streams in the manifest should be selected for download. - * If empty, all streams are downloaded. + * The currently active runnables. + * + *

      Note: Only the {@link #download} thread is permitted to modify this list. Modifications, as + * well as the iteration on the {@link #cancel} thread, must be synchronized on the instance for + * thread safety. Iterations on the {@link #download} thread do not need to be synchronized, and + * should not be synchronized because doing so can erroneously block {@link #cancel}. + */ + private final ArrayList> activeRunnables; + + private volatile boolean isCanceled; + + /** + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param manifestParser A parser for manifests belonging to the media to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -90,42 +106,41 @@ public abstract class SegmentDownloader> impleme * allowing parts of it to be executed in parallel. */ public SegmentDownloader( - Uri manifestUri, + MediaItem mediaItem, Parser manifestParser, - List streamKeys, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - this.manifestDataSpec = getCompressibleDataSpec(manifestUri); + checkNotNull(mediaItem.playbackProperties); + this.manifestDataSpec = getCompressibleDataSpec(mediaItem.playbackProperties.uri); this.manifestParser = manifestParser; - this.streamKeys = new ArrayList<>(streamKeys); + this.streamKeys = new ArrayList<>(mediaItem.playbackProperties.streamKeys); this.cacheDataSourceFactory = cacheDataSourceFactory; this.executor = executor; - isCanceled = new AtomicBoolean(); + cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); + cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); + priorityTaskManager = cacheDataSourceFactory.getUpstreamPriorityTaskManager(); + activeRunnables = new ArrayList<>(); } @Override - public final void download(@Nullable ProgressListener progressListener) throws IOException { - downloadThread = Thread.currentThread(); - if (isCanceled.get()) { - return; - } - @Nullable - PriorityTaskManager priorityTaskManager = - cacheDataSourceFactory.getUpstreamPriorityTaskManager(); + public final void download(@Nullable ProgressListener progressListener) + throws IOException, InterruptedException { + ArrayDeque pendingSegments = new ArrayDeque<>(); + ArrayDeque recycledRunnables = new ArrayDeque<>(); if (priorityTaskManager != null) { priorityTaskManager.add(C.PRIORITY_DOWNLOAD); } try { - Cache cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); - CacheKeyFactory cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForDownloading(); - // Get the manifest and all of the segments. - M manifest = getManifest(dataSource, manifestDataSpec); + M manifest = getManifest(dataSource, manifestDataSpec, /* removing= */ false); if (!streamKeys.isEmpty()) { manifest = manifest.copy(streamKeys); } - List segments = getSegments(dataSource, manifest, /* allowIncompleteList= */ false); + List segments = getSegments(dataSource, manifest, /* removing= */ false); + + // Sort the segments so that we download media in the right order from the start of the + // content, and merge segments where possible to minimize the number of server round trips. Collections.sort(segments); mergeSegments(segments, cacheKeyFactory); @@ -135,11 +150,18 @@ public abstract class SegmentDownloader> impleme long contentLength = 0; long bytesDownloaded = 0; for (int i = segments.size() - 1; i >= 0; i--) { - Segment segment = segments.get(i); - Pair segmentLengthAndBytesDownloaded = - CacheUtil.getCached(segment.dataSpec, cache, cacheKeyFactory); - long segmentLength = segmentLengthAndBytesDownloaded.first; - long segmentBytesDownloaded = segmentLengthAndBytesDownloaded.second; + DataSpec dataSpec = segments.get(i).dataSpec; + String cacheKey = cacheKeyFactory.buildCacheKey(dataSpec); + long segmentLength = dataSpec.length; + if (segmentLength == C.LENGTH_UNSET) { + long resourceLength = + ContentMetadata.getContentLength(cache.getContentMetadata(cacheKey)); + if (resourceLength != C.LENGTH_UNSET) { + segmentLength = resourceLength - dataSpec.position; + } + } + long segmentBytesDownloaded = + cache.getCachedBytes(cacheKey, dataSpec.position, segmentLength); bytesDownloaded += segmentBytesDownloaded; if (segmentLength != C.LENGTH_UNSET) { if (segmentLength == segmentBytesDownloaded) { @@ -166,20 +188,76 @@ public abstract class SegmentDownloader> impleme bytesDownloaded, segmentsDownloaded) : null; - byte[] temporaryBuffer = new byte[BUFFER_SIZE_BYTES]; - for (int i = 0; i < segments.size(); i++) { - CacheUtil.cache( - dataSource, - segments.get(i).dataSpec, - progressNotifier, - isCanceled, - /* enableEOFException= */ true, - temporaryBuffer); - if (progressNotifier != null) { - progressNotifier.onSegmentDownloaded(); + pendingSegments.addAll(segments); + while (!isCanceled && !pendingSegments.isEmpty()) { + // Block until there aren't any higher priority tasks. + if (priorityTaskManager != null) { + priorityTaskManager.proceed(C.PRIORITY_DOWNLOAD); } + + // Create and execute a runnable to download the next segment. + CacheDataSource segmentDataSource; + byte[] temporaryBuffer; + if (!recycledRunnables.isEmpty()) { + SegmentDownloadRunnable recycledRunnable = recycledRunnables.removeFirst(); + segmentDataSource = recycledRunnable.dataSource; + temporaryBuffer = recycledRunnable.temporaryBuffer; + } else { + segmentDataSource = cacheDataSourceFactory.createDataSourceForDownloading(); + temporaryBuffer = new byte[BUFFER_SIZE_BYTES]; + } + Segment segment = pendingSegments.removeFirst(); + SegmentDownloadRunnable downloadRunnable = + new SegmentDownloadRunnable( + segment, segmentDataSource, progressNotifier, temporaryBuffer); + addActiveRunnable(downloadRunnable); + executor.execute(downloadRunnable); + + // Clean up runnables that have finished. + for (int j = activeRunnables.size() - 1; j >= 0; j--) { + SegmentDownloadRunnable activeRunnable = (SegmentDownloadRunnable) activeRunnables.get(j); + // Only block until the runnable has finished if we don't have any more pending segments + // to start. If we do have pending segments to start then only process the runnable if + // it's already finished. + if (pendingSegments.isEmpty() || activeRunnable.isDone()) { + try { + activeRunnable.get(); + removeActiveRunnable(j); + recycledRunnables.addLast(activeRunnable); + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof PriorityTooLowException) { + // We need to schedule this segment again in a future loop iteration. + pendingSegments.addFirst(activeRunnable.segment); + removeActiveRunnable(j); + recycledRunnables.addLast(activeRunnable); + } else if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(cause); + } + } + } + } + + // Don't move on to the next segment until the runnable for this segment has started. This + // drip feeds runnables to the executor, rather than providing them all up front. + downloadRunnable.blockUntilStarted(); } } finally { + // If one of the runnables has thrown an exception, then it's possible there are other active + // runnables still doing work. We need to wait until they finish before exiting this method. + // Cancel them to speed this up. + for (int i = 0; i < activeRunnables.size(); i++) { + activeRunnables.get(i).cancel(/* interruptIfRunning= */ true); + } + // Wait until the runnables have finished. In addition to the failure case, we also need to + // do this for the case where the main download thread was interrupted as part of cancelation. + for (int i = activeRunnables.size() - 1; i >= 0; i--) { + activeRunnables.get(i).blockUntilFinished(); + removeActiveRunnable(i); + } if (priorityTaskManager != null) { priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); } @@ -188,29 +266,30 @@ public abstract class SegmentDownloader> impleme @Override public void cancel() { - isCanceled.set(true); - @Nullable Thread downloadThread = this.downloadThread; - if (downloadThread != null) { - downloadThread.interrupt(); + synchronized (activeRunnables) { + isCanceled = true; + for (int i = 0; i < activeRunnables.size(); i++) { + activeRunnables.get(i).cancel(/* interruptIfRunning= */ true); + } } } @Override public final void remove() { - Cache cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); - CacheKeyFactory cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForRemovingDownload(); try { - M manifest = getManifest(dataSource, manifestDataSpec); - List segments = getSegments(dataSource, manifest, true); + M manifest = getManifest(dataSource, manifestDataSpec, /* removing= */ true); + List segments = getSegments(dataSource, manifest, /* removing= */ true); for (int i = 0; i < segments.size(); i++) { - CacheUtil.remove(segments.get(i).dataSpec, cache, cacheKeyFactory); + cache.removeResource(cacheKeyFactory.buildCacheKey(segments.get(i).dataSpec)); } - } catch (IOException e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { // Ignore exceptions when removing. } finally { // Always attempt to remove the manifest. - CacheUtil.remove(manifestDataSpec, cache, cacheKeyFactory); + cache.removeResource(cacheKeyFactory.buildCacheKey(manifestDataSpec)); } } @@ -219,34 +298,121 @@ public abstract class SegmentDownloader> impleme /** * Loads and parses a manifest. * - * @param dataSource The {@link DataSource} through which to load. * @param dataSpec The manifest {@link DataSpec}. - * @return The manifest. - * @throws IOException If an error occurs reading data. + * @param removing Whether the manifest is being loaded as part of the download being removed. + * @return The loaded manifest. + * @throws InterruptedException If the thread on which the method is called is interrupted. + * @throws IOException If an error occurs during execution. */ - protected final M getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException { - return ParsingLoadable.load(dataSource, manifestParser, dataSpec, C.DATA_TYPE_MANIFEST); + protected final M getManifest(DataSource dataSource, DataSpec dataSpec, boolean removing) + throws InterruptedException, IOException { + return execute( + new RunnableFutureTask() { + @Override + protected M doWork() throws IOException { + return ParsingLoadable.load(dataSource, manifestParser, dataSpec, C.DATA_TYPE_MANIFEST); + } + }, + removing); } /** - * Returns a list of all downloadable {@link Segment}s for a given manifest. + * Executes the provided {@link RunnableFutureTask}. + * + * @param runnable The {@link RunnableFutureTask} to execute. + * @param removing Whether the execution is part of the download being removed. + * @return The result. + * @throws InterruptedException If the thread on which the method is called is interrupted. + * @throws IOException If an error occurs during execution. + */ + protected final T execute(RunnableFutureTask runnable, boolean removing) + throws InterruptedException, IOException { + if (removing) { + runnable.run(); + try { + return runnable.get(); + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(e); + } + } + } + while (true) { + if (isCanceled) { + throw new InterruptedException(); + } + // Block until there aren't any higher priority tasks. + if (priorityTaskManager != null) { + priorityTaskManager.proceed(C.PRIORITY_DOWNLOAD); + } + addActiveRunnable(runnable); + executor.execute(runnable); + try { + return runnable.get(); + } catch (ExecutionException e) { + Throwable cause = Assertions.checkNotNull(e.getCause()); + if (cause instanceof PriorityTooLowException) { + // The next loop iteration will block until the task is able to proceed. + } else if (cause instanceof IOException) { + throw (IOException) cause; + } else { + // The cause must be an uncaught Throwable type. + Util.sneakyThrow(e); + } + } finally { + // We don't want to return for as long as the runnable might still be doing work. + runnable.blockUntilFinished(); + removeActiveRunnable(runnable); + } + } + } + + /** + * Returns a list of all downloadable {@link Segment}s for a given manifest. Any required data + * should be loaded using {@link #getManifest} or {@link #execute}. * * @param dataSource The {@link DataSource} through which to load any required data. * @param manifest The manifest containing the segments. - * @param allowIncompleteList Whether to continue in the case that a load error prevents all - * segments from being listed. If true then a partial segment list will be returned. If false - * an {@link IOException} will be thrown. + * @param removing Whether the segments are being obtained as part of a removal. If true then a + * partial segment list is returned in the case that a load error prevents all segments from + * being listed. If false then an {@link IOException} will be thrown in this case. * @return The list of downloadable {@link Segment}s. - * @throws IOException Thrown if {@code allowPartialIndex} is false and a load error occurs, or if - * the media is not in a form that allows for its segments to be listed. + * @throws IOException Thrown if {@code allowPartialIndex} is false and an execution error occurs, + * or if the media is not in a form that allows for its segments to be listed. */ - protected abstract List getSegments( - DataSource dataSource, M manifest, boolean allowIncompleteList) throws IOException; + protected abstract List getSegments(DataSource dataSource, M manifest, boolean removing) + throws IOException, InterruptedException; protected static DataSpec getCompressibleDataSpec(Uri uri) { return new DataSpec.Builder().setUri(uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); } + private void addActiveRunnable(RunnableFutureTask runnable) + throws InterruptedException { + synchronized (activeRunnables) { + if (isCanceled) { + throw new InterruptedException(); + } + activeRunnables.add(runnable); + } + } + + private void removeActiveRunnable(RunnableFutureTask runnable) { + synchronized (activeRunnables) { + activeRunnables.remove(runnable); + } + } + + private void removeActiveRunnable(int index) { + synchronized (activeRunnables) { + activeRunnables.remove(index); + } + } + private static void mergeSegments(List segments, CacheKeyFactory keyFactory) { HashMap lastIndexByCacheKey = new HashMap<>(); int nextOutIndex = 0; @@ -285,7 +451,48 @@ public abstract class SegmentDownloader> impleme && dataSpec1.httpRequestHeaders.equals(dataSpec2.httpRequestHeaders); } - private static final class ProgressNotifier implements CacheUtil.ProgressListener { + private static final class SegmentDownloadRunnable extends RunnableFutureTask { + + public final Segment segment; + public final CacheDataSource dataSource; + @Nullable private final ProgressNotifier progressNotifier; + public final byte[] temporaryBuffer; + private final CacheWriter cacheWriter; + + public SegmentDownloadRunnable( + Segment segment, + CacheDataSource dataSource, + @Nullable ProgressNotifier progressNotifier, + byte[] temporaryBuffer) { + this.segment = segment; + this.dataSource = dataSource; + this.progressNotifier = progressNotifier; + this.temporaryBuffer = temporaryBuffer; + this.cacheWriter = + new CacheWriter( + dataSource, + segment.dataSpec, + /* allowShortContent= */ false, + temporaryBuffer, + progressNotifier); + } + + @Override + protected Void doWork() throws IOException { + cacheWriter.cache(); + if (progressNotifier != null) { + progressNotifier.onSegmentDownloaded(); + } + return null; + } + + @Override + protected void cancelWork() { + cacheWriter.cancel(); + } + } + + private static final class ProgressNotifier implements CacheWriter.ProgressListener { private final ProgressListener progressListener; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java index c4861abdf3..357fdab957 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.scheduler; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.app.job.JobInfo; import android.app.job.JobParameters; import android.app.job.JobScheduler; @@ -25,7 +27,6 @@ import android.content.Intent; import android.os.PersistableBundle; import androidx.annotation.RequiresApi; import androidx.annotation.RequiresPermission; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; @@ -45,11 +46,16 @@ import com.google.android.exoplayer2.util.Util; @RequiresApi(21) public final class PlatformScheduler implements Scheduler { - private static final boolean DEBUG = false; private static final String TAG = "PlatformScheduler"; private static final String KEY_SERVICE_ACTION = "service_action"; private static final String KEY_SERVICE_PACKAGE = "service_package"; private static final String KEY_REQUIREMENTS = "requirements"; + private static final int SUPPORTED_REQUIREMENTS = + Requirements.NETWORK + | Requirements.NETWORK_UNMETERED + | Requirements.DEVICE_IDLE + | Requirements.DEVICE_CHARGING + | (Util.SDK_INT >= 26 ? Requirements.DEVICE_STORAGE_NOT_LOW : 0); private final int jobId; private final ComponentName jobServiceComponentName; @@ -67,7 +73,8 @@ public final class PlatformScheduler implements Scheduler { context = context.getApplicationContext(); this.jobId = jobId; jobServiceComponentName = new ComponentName(context, PlatformSchedulerService.class); - jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + jobScheduler = + checkNotNull((JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE)); } @Override @@ -75,17 +82,20 @@ public final class PlatformScheduler implements Scheduler { JobInfo jobInfo = buildJobInfo(jobId, jobServiceComponentName, requirements, serviceAction, servicePackage); int result = jobScheduler.schedule(jobInfo); - logd("Scheduling job: " + jobId + " result: " + result); return result == JobScheduler.RESULT_SUCCESS; } @Override public boolean cancel() { - logd("Canceling job: " + jobId); jobScheduler.cancel(jobId); return true; } + @Override + public Requirements getSupportedRequirements(Requirements requirements) { + return requirements.filterRequirements(SUPPORTED_REQUIREMENTS); + } + // @RequiresPermission constructor annotation should ensure the permission is present. @SuppressWarnings("MissingPermission") private static JobInfo buildJobInfo( @@ -94,8 +104,15 @@ public final class PlatformScheduler implements Scheduler { Requirements requirements, String serviceAction, String servicePackage) { - JobInfo.Builder builder = new JobInfo.Builder(jobId, jobServiceComponentName); + Requirements filteredRequirements = requirements.filterRequirements(SUPPORTED_REQUIREMENTS); + if (!filteredRequirements.equals(requirements)) { + Log.w( + TAG, + "Ignoring unsupported requirements: " + + (filteredRequirements.getRequirements() ^ requirements.getRequirements())); + } + JobInfo.Builder builder = new JobInfo.Builder(jobId, jobServiceComponentName); if (requirements.isUnmeteredNetworkRequired()) { builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED); } else if (requirements.isNetworkRequired()) { @@ -103,6 +120,9 @@ public final class PlatformScheduler implements Scheduler { } builder.setRequiresDeviceIdle(requirements.isIdleRequired()); builder.setRequiresCharging(requirements.isChargingRequired()); + if (Util.SDK_INT >= 26 && requirements.isStorageNotLowRequired()) { + builder.setRequiresStorageNotLow(true); + } builder.setPersisted(true); PersistableBundle extras = new PersistableBundle(); @@ -114,30 +134,21 @@ public final class PlatformScheduler implements Scheduler { return builder.build(); } - private static void logd(String message) { - if (DEBUG) { - Log.d(TAG, message); - } - } - /** A {@link JobService} that starts the target service if the requirements are met. */ public static final class PlatformSchedulerService extends JobService { @Override public boolean onStartJob(JobParameters params) { - logd("PlatformSchedulerService started"); PersistableBundle extras = params.getExtras(); Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS)); - if (requirements.checkRequirements(this)) { - logd("Requirements are met"); - String serviceAction = extras.getString(KEY_SERVICE_ACTION); - String servicePackage = extras.getString(KEY_SERVICE_PACKAGE); - Intent intent = - new Intent(Assertions.checkNotNull(serviceAction)).setPackage(servicePackage); - logd("Starting service action: " + serviceAction + " package: " + servicePackage); + int notMetRequirements = requirements.getNotMetRequirements(this); + if (notMetRequirements == 0) { + String serviceAction = checkNotNull(extras.getString(KEY_SERVICE_ACTION)); + String servicePackage = checkNotNull(extras.getString(KEY_SERVICE_PACKAGE)); + Intent intent = new Intent(serviceAction).setPackage(servicePackage); Util.startForegroundService(this, intent); } else { - logd("Requirements are not met"); - jobFinished(params, /* needsReschedule */ true); + Log.w(TAG, "Requirements not met: " + notMetRequirements); + jobFinished(params, /* wantsReschedule= */ true); } return false; } 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..7a2946d012 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Requirements.java @@ -39,13 +39,13 @@ public final class Requirements implements Parcelable { /** * Requirement flags. Possible flag values are {@link #NETWORK}, {@link #NETWORK_UNMETERED}, - * {@link #DEVICE_IDLE} and {@link #DEVICE_CHARGING}. + * {@link #DEVICE_IDLE}, {@link #DEVICE_CHARGING} and {@link #DEVICE_STORAGE_NOT_LOW}. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {NETWORK, NETWORK_UNMETERED, DEVICE_IDLE, DEVICE_CHARGING}) + value = {NETWORK, NETWORK_UNMETERED, DEVICE_IDLE, DEVICE_CHARGING, DEVICE_STORAGE_NOT_LOW}) public @interface RequirementFlags {} /** Requirement that the device has network connectivity. */ @@ -56,6 +56,11 @@ public final class Requirements implements Parcelable { public static final int DEVICE_IDLE = 1 << 2; /** Requirement that the device is charging. */ public static final int DEVICE_CHARGING = 1 << 3; + /** + * Requirement that the device's internal storage is not low. Note that this requirement + * is not affected by the status of external storage. + */ + public static final int DEVICE_STORAGE_NOT_LOW = 1 << 4; @RequirementFlags private final int requirements; @@ -74,6 +79,18 @@ public final class Requirements implements Parcelable { return requirements; } + /** + * Filters the requirements, returning the subset that are enabled by the provided filter. + * + * @param requirementsFilter The enabled {@link RequirementFlags}. + * @return The filtered requirements. If the filter does not cause a change in the requirements + * then this instance will be returned. + */ + public Requirements filterRequirements(int requirementsFilter) { + int filteredRequirements = requirements & requirementsFilter; + return filteredRequirements == requirements ? this : new Requirements(filteredRequirements); + } + /** Returns whether network connectivity is required. */ public boolean isNetworkRequired() { return (requirements & NETWORK) != 0; @@ -94,6 +111,11 @@ public final class Requirements implements Parcelable { return (requirements & DEVICE_IDLE) != 0; } + /** Returns whether the device is required to not be low on internal storage. */ + public boolean isStorageNotLowRequired() { + return (requirements & DEVICE_STORAGE_NOT_LOW) != 0; + } + /** * Returns whether the requirements are met. * @@ -119,6 +141,9 @@ public final class Requirements implements Parcelable { if (isIdleRequired() && !isDeviceIdle(context)) { notMetRequirements |= DEVICE_IDLE; } + if (isStorageNotLowRequired() && !isStorageNotLow(context)) { + notMetRequirements |= DEVICE_STORAGE_NOT_LOW; + } return notMetRequirements; } @@ -129,8 +154,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)) { @@ -145,8 +171,10 @@ public final class Requirements implements Parcelable { } private boolean isDeviceCharging(Context context) { + @Nullable Intent batteryStatus = - context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + context.registerReceiver( + /* receiver= */ null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); if (batteryStatus == null) { return false; } @@ -156,23 +184,33 @@ 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 boolean isStorageNotLow(Context context) { + return context.registerReceiver( + /* receiver= */ null, new IntentFilter(Intent.ACTION_DEVICE_STORAGE_LOW)) + == null; + } + 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 797b7f7170..6293cbf36d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.scheduler; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -27,7 +29,6 @@ import android.os.Looper; import android.os.PowerManager; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; /** @@ -71,7 +72,7 @@ public final class RequirementsWatcher { this.context = context.getApplicationContext(); this.listener = listener; this.requirements = requirements; - handler = new Handler(Util.getLooper()); + handler = Util.createHandlerForCurrentOrMainLooper(); } /** @@ -104,6 +105,10 @@ public final class RequirementsWatcher { filter.addAction(Intent.ACTION_SCREEN_OFF); } } + if (requirements.isStorageNotLowRequired()) { + filter.addAction(Intent.ACTION_DEVICE_STORAGE_LOW); + filter.addAction(Intent.ACTION_DEVICE_STORAGE_OK); + } receiver = new DeviceStatusChangeReceiver(); context.registerReceiver(receiver, filter, null, handler); return notMetRequirements; @@ -111,7 +116,7 @@ public final class RequirementsWatcher { /** Stops watching for changes. */ public void stop() { - context.unregisterReceiver(Assertions.checkNotNull(receiver)); + context.unregisterReceiver(checkNotNull(receiver)); receiver = null; if (Util.SDK_INT >= 24 && networkCallback != null) { unregisterNetworkCallbackV24(); @@ -126,8 +131,7 @@ public final class RequirementsWatcher { @RequiresApi(24) private void registerNetworkCallbackV24() { ConnectivityManager connectivityManager = - Assertions.checkNotNull( - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + checkNotNull((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); networkCallback = new NetworkCallback(); connectivityManager.registerDefaultNetworkCallback(networkCallback); } @@ -135,8 +139,8 @@ public final class RequirementsWatcher { @RequiresApi(24) private void unregisterNetworkCallbackV24() { ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - connectivityManager.unregisterNetworkCallback(Assertions.checkNotNull(networkCallback)); + checkNotNull((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + connectivityManager.unregisterNetworkCallback(checkNotNull(networkCallback)); networkCallback = null; } @@ -149,6 +153,23 @@ public final class RequirementsWatcher { } } + /** + * Re-checks the requirements if there are network requirements that are currently not met. + * + *

      When we receive an event that implies newly established network connectivity, we re-check + * the requirements by calling {@link #checkRequirements()}. This check sometimes sees that there + * is still no active network, meaning that any network requirements will remain not met. By + * calling this method when we receive other events that imply continued network connectivity, we + * can detect that the requirements are met once an active network does exist. + */ + private void recheckNotMetNetworkRequirements() { + if ((notMetRequirements & (Requirements.NETWORK | Requirements.NETWORK_UNMETERED)) == 0) { + // No unmet network requirements to recheck. + return; + } + checkRequirements(); + } + private class DeviceStatusChangeReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { @@ -160,17 +181,25 @@ public final class RequirementsWatcher { @RequiresApi(24) private final class NetworkCallback extends ConnectivityManager.NetworkCallback { - boolean receivedCapabilitiesChange; - boolean networkValidated; + + private boolean receivedCapabilitiesChange; + private boolean networkValidated; @Override public void onAvailable(Network network) { - onNetworkCallback(); + postCheckRequirements(); } @Override public void onLost(Network network) { - onNetworkCallback(); + postCheckRequirements(); + } + + @Override + public void onBlockedStatusChanged(Network network, boolean blocked) { + if (!blocked) { + postRecheckNotMetNetworkRequirements(); + } } @Override @@ -180,11 +209,13 @@ public final class RequirementsWatcher { if (!receivedCapabilitiesChange || this.networkValidated != networkValidated) { receivedCapabilitiesChange = true; this.networkValidated = networkValidated; - onNetworkCallback(); + postCheckRequirements(); + } else if (networkValidated) { + postRecheckNotMetNetworkRequirements(); } } - private void onNetworkCallback() { + private void postCheckRequirements() { handler.post( () -> { if (networkCallback != null) { @@ -192,5 +223,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/scheduler/Scheduler.java b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Scheduler.java index b5a6f40424..c34c77b2cf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Scheduler.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/scheduler/Scheduler.java @@ -45,4 +45,14 @@ public interface Scheduler { * @return Whether cancellation was successful. */ boolean cancel(); + + /** + * Checks whether this {@link Scheduler} supports the provided {@link Requirements}. If all of the + * requirements are supported then the same {@link Requirements} instance is returned. If not then + * a new instance is returned containing the subset of the requirements that are supported. + * + * @param requirements The requirements to check. + * @return The supported requirements. + */ + Requirements getSupportedRequirements(Requirements requirements); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java index 461b146b8d..96ef4b0c6d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/BaseMediaSource.java @@ -22,7 +22,6 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import java.util.ArrayList; import java.util.HashSet; @@ -38,6 +37,7 @@ public abstract class BaseMediaSource implements MediaSource { private final ArrayList mediaSourceCallers; private final HashSet enabledMediaSourceCallers; private final MediaSourceEventListener.EventDispatcher eventDispatcher; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; @Nullable private Looper looper; @Nullable private Timeline timeline; @@ -46,6 +46,7 @@ public abstract class BaseMediaSource implements MediaSource { mediaSourceCallers = new ArrayList<>(/* initialCapacity= */ 1); enabledMediaSourceCallers = new HashSet<>(/* initialCapacity= */ 1); eventDispatcher = new MediaSourceEventListener.EventDispatcher(); + drmEventDispatcher = new DrmSessionEventListener.EventDispatcher(); } /** @@ -127,6 +128,33 @@ public abstract class BaseMediaSource implements MediaSource { return eventDispatcher.withParameters(windowIndex, mediaPeriodId, mediaTimeOffsetMs); } + /** + * Returns a {@link DrmSessionEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified media period id. + * + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. May be null, if + * the events do not belong to a specific media period. + * @return An event dispatcher with pre-configured media period id. + */ + protected final DrmSessionEventListener.EventDispatcher createDrmEventDispatcher( + @Nullable MediaPeriodId mediaPeriodId) { + return drmEventDispatcher.withParameters(/* windowIndex= */ 0, mediaPeriodId); + } + + /** + * Returns a {@link DrmSessionEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified window index and media period id. + * + * @param windowIndex The timeline window index to be reported with the events. + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. May be null, if + * the events do not belong to a specific media period. + * @return An event dispatcher with pre-configured media period id and time offset. + */ + protected final DrmSessionEventListener.EventDispatcher createDrmEventDispatcher( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + return drmEventDispatcher.withParameters(windowIndex, mediaPeriodId); + } + /** Returns whether the source is enabled. */ protected final boolean isEnabled() { return !enabledMediaSourceCallers.isEmpty(); @@ -134,44 +162,26 @@ public abstract class BaseMediaSource implements MediaSource { @Override public final void addEventListener(Handler handler, MediaSourceEventListener eventListener) { - addEventListenerInternal(handler, eventListener, MediaSourceEventListener.class); + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); + eventDispatcher.addEventListener(handler, eventListener); } @Override public final void removeEventListener(MediaSourceEventListener eventListener) { - removeEventListenerInternal(eventListener, MediaSourceEventListener.class); + eventDispatcher.removeEventListener(eventListener); } @Override public final void addDrmEventListener(Handler handler, DrmSessionEventListener eventListener) { - addEventListenerInternal(handler, eventListener, DrmSessionEventListener.class); + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); + drmEventDispatcher.addEventListener(handler, eventListener); } @Override public final void removeDrmEventListener(DrmSessionEventListener eventListener) { - removeEventListenerInternal(eventListener, DrmSessionEventListener.class); - } - - /** - * Adds a listener to the internal {@link MediaSourceEventDispatcher} with the provided type. - * - *

      NOTE: Read the caveats on {@link MediaSourceEventDispatcher#addEventListener(Handler, - * Object, Class)} when deciding what value to pass for {@code listenerClass}. - * - * @see MediaSourceEventDispatcher#addEventListener(Handler, Object, Class) - */ - protected final void addEventListenerInternal( - Handler handler, T eventListener, Class listenerClass) { - eventDispatcher.addEventListener(handler, eventListener, listenerClass); - } - - /** - * Removes a listener from the internal {@link MediaSourceEventDispatcher}. - * - * @see MediaSourceEventDispatcher#removeEventListener(Object, Class) - */ - protected final void removeEventListenerInternal(T eventListener, Class listenerClass) { - eventDispatcher.removeEventListener(eventListener, listenerClass); + drmEventDispatcher.removeEventListener(eventListener); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java index f8764585aa..7e770d4e39 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/BundledExtractorsAdapter.java @@ -22,6 +22,7 @@ import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; import com.google.android.exoplayer2.upstream.DataReader; @@ -29,6 +30,8 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.EOFException; import java.io.IOException; +import java.util.List; +import java.util.Map; /** * {@link ProgressiveMediaExtractor} built on top of {@link Extractor} instances, whose @@ -36,7 +39,7 @@ import java.io.IOException; */ /* package */ final class BundledExtractorsAdapter implements ProgressiveMediaExtractor { - private final Extractor[] extractors; + private final ExtractorsFactory extractorsFactory; @Nullable private Extractor extractor; @Nullable private ExtractorInput extractorInput; @@ -44,20 +47,27 @@ import java.io.IOException; /** * Creates a holder that will select an extractor and initialize it using the specified output. * - * @param extractors One or more extractors to choose from. + * @param extractorsFactory The {@link ExtractorsFactory} providing the extractors to choose from. */ - public BundledExtractorsAdapter(Extractor[] extractors) { - this.extractors = extractors; + public BundledExtractorsAdapter(ExtractorsFactory extractorsFactory) { + this.extractorsFactory = extractorsFactory; } @Override public void init( - DataReader dataReader, Uri uri, long position, long length, ExtractorOutput output) + DataReader dataReader, + Uri uri, + Map> responseHeaders, + long position, + long length, + ExtractorOutput output) throws IOException { - extractorInput = new DefaultExtractorInput(dataReader, position, length); + ExtractorInput extractorInput = new DefaultExtractorInput(dataReader, position, length); + this.extractorInput = extractorInput; if (extractor != null) { return; } + Extractor[] extractors = extractorsFactory.createExtractors(uri, responseHeaders); if (extractors.length == 1) { this.extractor = extractors[0]; } else { @@ -70,6 +80,7 @@ import java.io.IOException; } catch (EOFException e) { // Do nothing. } finally { + Assertions.checkState(this.extractor != null || extractorInput.getPosition() == position); extractorInput.resetPeekPosition(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java index c5484a8f45..7bb6a83add 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java @@ -258,13 +258,14 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb // negative timestamp, its offset timestamp can jump backwards compared to the last timestamp // read in the previous period. Renderer implementations may not allow this, so we signal a // discontinuity which resets the renderers before they read the clipping sample stream. - // However, for audio-only track selections we assume to have random access seek behaviour and - // do not need an initial discontinuity to reset the renderer. + // However, for tracks where all samples are sync samples, we assume they have random access + // seek behaviour and do not need an initial discontinuity to reset the renderer. if (startUs != 0) { for (TrackSelection trackSelection : selections) { if (trackSelection != null) { Format selectedFormat = trackSelection.getSelectedFormat(); - if (!MimeTypes.isAudio(selectedFormat.sampleMimeType)) { + if (!MimeTypes.allSamplesAreSyncSamples( + selectedFormat.sampleMimeType, selectedFormat.codecs)) { return true; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java index d4ede3e59e..581a0b17e3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaSource.java @@ -15,9 +15,13 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.max; +import static java.lang.Math.min; + import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; @@ -184,12 +188,22 @@ public final class ClippingMediaSource extends CompositeMediaSource { window = new Timeline.Window(); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return mediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return mediaSource.getMediaItem(); + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); @@ -285,9 +299,9 @@ public final class ClippingMediaSource extends CompositeMediaSource { return C.TIME_UNSET; } long startMs = C.usToMs(startUs); - long clippedTimeMs = Math.max(0, mediaTimeMs - startMs); + long clippedTimeMs = max(0, mediaTimeMs - startMs); if (endUs != C.TIME_END_OF_SOURCE) { - clippedTimeMs = Math.min(C.usToMs(endUs) - startMs, clippedTimeMs); + clippedTimeMs = min(C.usToMs(endUs) - startMs, clippedTimeMs); } return clippedTimeMs; } @@ -318,11 +332,11 @@ public final class ClippingMediaSource extends CompositeMediaSource { throw new IllegalClippingException(IllegalClippingException.REASON_INVALID_PERIOD_COUNT); } Window window = timeline.getWindow(0, new Window()); - startUs = Math.max(0, startUs); + startUs = max(0, startUs); if (!window.isPlaceholder && startUs != 0 && !window.isSeekable) { throw new IllegalClippingException(IllegalClippingException.REASON_NOT_SEEKABLE_TO_START); } - long resolvedEndUs = endUs == C.TIME_END_OF_SOURCE ? window.durationUs : Math.max(0, endUs); + long resolvedEndUs = endUs == C.TIME_END_OF_SOURCE ? window.durationUs : max(0, endUs); if (window.durationUs != C.TIME_UNSET) { if (resolvedEndUs > window.durationUs) { resolvedEndUs = window.durationUs; @@ -347,9 +361,9 @@ public final class ClippingMediaSource extends CompositeMediaSource { window.durationUs = durationUs; window.isDynamic = isDynamic; if (window.defaultPositionUs != C.TIME_UNSET) { - window.defaultPositionUs = Math.max(window.defaultPositionUs, startUs); - window.defaultPositionUs = endUs == C.TIME_UNSET ? window.defaultPositionUs - : Math.min(window.defaultPositionUs, endUs); + window.defaultPositionUs = max(window.defaultPositionUs, startUs); + window.defaultPositionUs = + endUs == C.TIME_UNSET ? window.defaultPositionUs : min(window.defaultPositionUs, endUs); window.defaultPositionUs -= startUs; } long startMs = C.usToMs(startUs); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java index b742d3b431..5f1464721c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -48,7 +48,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { @CallSuper protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; - eventHandler = Util.createHandler(); + eventHandler = Util.createHandlerForCurrentLooper(); } @Override @@ -192,18 +192,6 @@ public abstract class CompositeMediaSource extends BaseMediaSource { return mediaTimeMs; } - /** - * Returns whether {@link MediaSourceEventListener#onMediaPeriodCreated(int, MediaPeriodId)} and - * {@link MediaSourceEventListener#onMediaPeriodReleased(int, MediaPeriodId)} events of the given - * media period should be reported. The default implementation is to always report these events. - * - * @param mediaPeriodId A {@link MediaPeriodId} in the composite media source. - * @return Whether create and release events for this media period should be reported. - */ - protected boolean shouldDispatchCreateOrReleaseEvent(MediaPeriodId mediaPeriodId) { - return true; - } - private static final class MediaSourceAndListener { public final MediaSource mediaSource; @@ -222,35 +210,17 @@ public abstract class CompositeMediaSource extends BaseMediaSource { implements MediaSourceEventListener, DrmSessionEventListener { @UnknownNull private final T id; - private EventDispatcher eventDispatcher; + private MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; + private DrmSessionEventListener.EventDispatcher drmEventDispatcher; public ForwardingEventListener(@UnknownNull T id) { - this.eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); + this.mediaSourceEventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); + this.drmEventDispatcher = createDrmEventDispatcher(/* mediaPeriodId= */ null); this.id = id; } // MediaSourceEventListener implementation - @Override - public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - if (shouldDispatchCreateOrReleaseEvent( - Assertions.checkNotNull(eventDispatcher.mediaPeriodId))) { - eventDispatcher.mediaPeriodCreated(); - } - } - } - - @Override - public void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - if (shouldDispatchCreateOrReleaseEvent( - Assertions.checkNotNull(eventDispatcher.mediaPeriodId))) { - eventDispatcher.mediaPeriodReleased(); - } - } - } - @Override public void onLoadStarted( int windowIndex, @@ -258,7 +228,8 @@ public abstract class CompositeMediaSource extends BaseMediaSource { LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadStarted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.loadStarted( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -269,7 +240,8 @@ public abstract class CompositeMediaSource extends BaseMediaSource { LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCompleted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.loadCompleted( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -280,7 +252,8 @@ public abstract class CompositeMediaSource extends BaseMediaSource { LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCanceled(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.loadCanceled( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -293,23 +266,16 @@ public abstract class CompositeMediaSource extends BaseMediaSource { IOException error, boolean wasCanceled) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadError( + mediaSourceEventDispatcher.loadError( loadEventData, maybeUpdateMediaLoadData(mediaLoadData), error, wasCanceled); } } - @Override - public void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) { - if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.readingStarted(); - } - } - @Override public void onUpstreamDiscarded( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.upstreamDiscarded(maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.upstreamDiscarded(maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -317,7 +283,7 @@ public abstract class CompositeMediaSource extends BaseMediaSource { public void onDownstreamFormatChanged( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.downstreamFormatChanged(maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.downstreamFormatChanged(maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -326,16 +292,14 @@ public abstract class CompositeMediaSource extends BaseMediaSource { @Override public void onDrmSessionAcquired(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmSessionAcquired, DrmSessionEventListener.class); + drmEventDispatcher.drmSessionAcquired(); } } @Override public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysLoaded, DrmSessionEventListener.class); + drmEventDispatcher.drmKeysLoaded(); } } @@ -343,34 +307,28 @@ public abstract class CompositeMediaSource extends BaseMediaSource { public void onDrmSessionManagerError( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception error) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - (listener, innerWindowIndex, innerMediaPeriodId) -> - listener.onDrmSessionManagerError(innerWindowIndex, innerMediaPeriodId, error), - DrmSessionEventListener.class); + drmEventDispatcher.drmSessionManagerError(error); } } @Override public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysRestored, DrmSessionEventListener.class); + drmEventDispatcher.drmKeysRestored(); } } @Override public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysRemoved, DrmSessionEventListener.class); + drmEventDispatcher.drmKeysRemoved(); } } @Override public void onDrmSessionReleased(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmSessionReleased, DrmSessionEventListener.class); + drmEventDispatcher.drmSessionReleased(); } } @@ -386,11 +344,15 @@ public abstract class CompositeMediaSource extends BaseMediaSource { } } int windowIndex = getWindowIndexForChildWindowIndex(id, childWindowIndex); - if (eventDispatcher.windowIndex != windowIndex - || !Util.areEqual(eventDispatcher.mediaPeriodId, mediaPeriodId)) { - eventDispatcher = + if (mediaSourceEventDispatcher.windowIndex != windowIndex + || !Util.areEqual(mediaSourceEventDispatcher.mediaPeriodId, mediaPeriodId)) { + mediaSourceEventDispatcher = createEventDispatcher(windowIndex, mediaPeriodId, /* mediaTimeOffsetMs= */ 0); } + if (drmEventDispatcher.windowIndex != windowIndex + || !Util.areEqual(drmEventDispatcher.mediaPeriodId, mediaPeriodId)) { + drmEventDispatcher = createDrmEventDispatcher(windowIndex, mediaPeriodId); + } return true; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java index b583705170..ce5fb868f5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; /** @@ -34,7 +36,7 @@ public class CompositeSequenceableLoader implements SequenceableLoader { for (SequenceableLoader loader : loaders) { long loaderBufferedPositionUs = loader.getBufferedPositionUs(); if (loaderBufferedPositionUs != C.TIME_END_OF_SOURCE) { - bufferedPositionUs = Math.min(bufferedPositionUs, loaderBufferedPositionUs); + bufferedPositionUs = min(bufferedPositionUs, loaderBufferedPositionUs); } } return bufferedPositionUs == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : bufferedPositionUs; @@ -46,7 +48,7 @@ public class CompositeSequenceableLoader implements SequenceableLoader { for (SequenceableLoader loader : loaders) { long loaderNextLoadPositionUs = loader.getNextLoadPositionUs(); if (loaderNextLoadPositionUs != C.TIME_END_OF_SOURCE) { - nextLoadPositionUs = Math.min(nextLoadPositionUs, loaderNextLoadPositionUs); + nextLoadPositionUs = min(nextLoadPositionUs, loaderNextLoadPositionUs); } } return nextLoadPositionUs == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : nextLoadPositionUs; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 8664c4367b..48305bc916 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -15,12 +15,17 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.net.Uri; import android.os.Handler; import android.os.Message; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import com.google.android.exoplayer2.AbstractConcatenatedTimeline; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ConcatenatingMediaSource.MediaSourceHolder; import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; @@ -54,6 +59,9 @@ public final class ConcatenatingMediaSource extends CompositeMediaSource mediaSourcesPublic; @@ -67,7 +75,7 @@ public final class ConcatenatingMediaSource extends CompositeMediaSource mediaSourceHolders; - private final Map mediaSourceByMediaPeriod; + private final IdentityHashMap mediaSourceByMediaPeriod; private final Map mediaSourceByUid; private final Set enabledMediaSourceHolders; private final boolean isAtomic; @@ -438,9 +446,10 @@ public final class ConcatenatingMediaSource extends CompositeMediaSource{@link ProgressiveMediaSource.Factory} serves as a fallback if the item's {@link * MediaItem.PlaybackProperties#uri uri} doesn't match one of the above. It tries to infer the * required extractor by using the {@link - * com.google.android.exoplayer2.extractor.DefaultExtractorsFactory}. An {@link - * UnrecognizedInputFormatException} is thrown if none of the available extractors can read - * the stream. + * com.google.android.exoplayer2.extractor.DefaultExtractorsFactory} or the {@link + * ExtractorsFactory} provided in the constructor. An {@link UnrecognizedInputFormatException} + * is thrown if none of the available extractors can read the stream. * * - *

      DrmSessionManager creation for protected content

      + *

      Ad support for media items with ad tag URIs

      * - *

      For a media item with a valid {@link - * com.google.android.exoplayer2.MediaItem.DrmConfiguration}, a {@link DefaultDrmSessionManager} is - * created. The following setter can be used to optionally configure the creation: - * - *

        - *
      • {@link #setDrmHttpDataSourceFactory(HttpDataSource.Factory)}: Sets the data source factory - * to be used by the {@link HttpMediaDrmCallback} for network requests (default: {@link - * DefaultHttpDataSourceFactory}). - *
      - * - *

      For media items without a drm configuration {@link DrmSessionManager#DUMMY} is used. To use an - * alternative dummy, apps can pass a drm session manager to {@link - * #setDrmSessionManager(DrmSessionManager)} which will be used for all items without a drm - * configuration. - * - *

      Ad support for media items with ad tag uri

      - * - *

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

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

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

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

      This is functionally equivalent with calling {@code #newInstance(Context, - * DefaultDataSourceFactory)}. - * - * @param context The {@link Context}. - */ - public static DefaultMediaSourceFactory newInstance(Context context) { - return newInstance( - context, - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY))); - } - - /** - * Creates a new instance with the given {@link Context} and {@link DataSource.Factory}. - * - * @param context The {@link Context}. - * @param dataSourceFactory A {@link DataSource.Factory} to be used to create media sources. - */ - public static DefaultMediaSourceFactory newInstance( - Context context, DataSource.Factory dataSourceFactory) { - return new DefaultMediaSourceFactory(context, dataSourceFactory, /* adSupportProvider= */ null); } private static final String TAG = "DefaultMediaSourceFactory"; + private final MediaSourceDrmHelper mediaSourceDrmHelper; private final DataSource.Factory dataSourceFactory; - @Nullable private final AdSupportProvider adSupportProvider; private final SparseArray mediaSourceFactories; @C.ContentType private final int[] supportedTypes; - private final String userAgent; - private DrmSessionManager drmSessionManager; - private HttpDataSource.Factory drmHttpDataSourceFactory; + @Nullable private AdsLoaderProvider adsLoaderProvider; + @Nullable private AdViewProvider adViewProvider; + @Nullable private DrmSessionManager drmSessionManager; @Nullable private List streamKeys; + @Nullable private LoadErrorHandlingPolicy loadErrorHandlingPolicy; /** - * Creates a new instance with the given {@link Context} and {@link DataSource.Factory}. + * Creates a new instance. * - * @param context The {@link Context}. - * @param dataSourceFactory A {@link DataSource.Factory} to be used to create media sources. - * @param adSupportProvider An {@link AdSupportProvider} to get ads loaders and ad view providers - * to be used to create {@link AdsMediaSource AdsMediaSources}. + * @param context Any context. + */ + public DefaultMediaSourceFactory(Context context) { + this(new DefaultDataSourceFactory(context)); + } + + /** + * Creates a new instance. + * + * @param context Any context. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public DefaultMediaSourceFactory(Context context, ExtractorsFactory extractorsFactory) { + this(new DefaultDataSourceFactory(context), extractorsFactory); + } + + /** + * Creates a new instance. + * + * @param dataSourceFactory A {@link DataSource.Factory} to create {@link DataSource} instances + * for requesting media data. + */ + public DefaultMediaSourceFactory(DataSource.Factory dataSourceFactory) { + this(dataSourceFactory, new DefaultExtractorsFactory()); + } + + /** + * Creates a new instance. + * + * @param dataSourceFactory A {@link DataSource.Factory} to create {@link DataSource} instances + * for requesting media data. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. */ public DefaultMediaSourceFactory( - Context context, - DataSource.Factory dataSourceFactory, - @Nullable AdSupportProvider adSupportProvider) { + DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { this.dataSourceFactory = dataSourceFactory; - this.adSupportProvider = adSupportProvider; - drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); - userAgent = Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY); - drmHttpDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); - mediaSourceFactories = loadDelegates(dataSourceFactory); + mediaSourceDrmHelper = new MediaSourceDrmHelper(); + mediaSourceFactories = loadDelegates(dataSourceFactory, extractorsFactory); supportedTypes = new int[mediaSourceFactories.size()]; for (int i = 0; i < mediaSourceFactories.size(); i++) { supportedTypes[i] = mediaSourceFactories.keyAt(i); @@ -184,43 +157,53 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { } /** - * Sets the {@link HttpDataSource.Factory} to be used for creating {@link HttpMediaDrmCallback - * HttpMediaDrmCallbacks} which executes key and provisioning requests over HTTP. If {@code null} - * is passed the {@link DefaultHttpDataSourceFactory} is used. + * Sets the {@link AdsLoaderProvider} that provides {@link AdsLoader} instances for media items + * that have {@link MediaItem.PlaybackProperties#adTagUri ad tag URIs}. * - * @param drmHttpDataSourceFactory The HTTP data source factory or {@code null} to use {@link - * DefaultHttpDataSourceFactory}. + * @param adsLoaderProvider A provider for {@link AdsLoader} instances. * @return This factory, for convenience. */ + public DefaultMediaSourceFactory setAdsLoaderProvider( + @Nullable AdsLoaderProvider adsLoaderProvider) { + this.adsLoaderProvider = adsLoaderProvider; + return this; + } + + /** + * Sets the {@link AdViewProvider} that provides information about views for the ad playback UI. + * + * @param adViewProvider A provider for {@link AdsLoader} instances. + * @return This factory, for convenience. + */ + public DefaultMediaSourceFactory setAdViewProvider(@Nullable AdViewProvider adViewProvider) { + this.adViewProvider = adViewProvider; + return this; + } + + @Override public DefaultMediaSourceFactory setDrmHttpDataSourceFactory( @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { - this.drmHttpDataSourceFactory = - drmHttpDataSourceFactory != null - ? drmHttpDataSourceFactory - : new DefaultHttpDataSourceFactory(userAgent); + mediaSourceDrmHelper.setDrmHttpDataSourceFactory(drmHttpDataSourceFactory); + return this; + } + + @Override + public DefaultMediaSourceFactory setDrmUserAgent(@Nullable String userAgent) { + mediaSourceDrmHelper.setDrmUserAgent(userAgent); return this; } @Override public DefaultMediaSourceFactory setDrmSessionManager( @Nullable DrmSessionManager drmSessionManager) { - this.drmSessionManager = - drmSessionManager != null - ? drmSessionManager - : DrmSessionManager.getDummyDrmSessionManager(); + this.drmSessionManager = drmSessionManager; return this; } @Override public DefaultMediaSourceFactory setLoadErrorHandlingPolicy( @Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { - LoadErrorHandlingPolicy newLoadErrorHandlingPolicy = - loadErrorHandlingPolicy != null - ? loadErrorHandlingPolicy - : new DefaultLoadErrorHandlingPolicy(); - for (int i = 0; i < mediaSourceFactories.size(); i++) { - mediaSourceFactories.valueAt(i).setLoadErrorHandlingPolicy(newLoadErrorHandlingPolicy); - } + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; return this; } @@ -247,16 +230,18 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { Assertions.checkNotNull(mediaItem.playbackProperties); @C.ContentType int type = - Util.inferContentTypeWithMimeType( + Util.inferContentTypeForUriAndMimeType( mediaItem.playbackProperties.uri, mediaItem.playbackProperties.mimeType); @Nullable MediaSourceFactory mediaSourceFactory = mediaSourceFactories.get(type); Assertions.checkNotNull( mediaSourceFactory, "No suitable media source factory found for content type: " + type); - mediaSourceFactory.setDrmSessionManager(createDrmSessionManager(mediaItem)); + mediaSourceFactory.setDrmSessionManager( + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem)); mediaSourceFactory.setStreamKeys( !mediaItem.playbackProperties.streamKeys.isEmpty() ? mediaItem.playbackProperties.streamKeys : streamKeys); + mediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); MediaSource mediaSource = mediaSourceFactory.createMediaSource(mediaItem); @@ -267,16 +252,9 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { SingleSampleMediaSource.Factory singleSampleSourceFactory = new SingleSampleMediaSource.Factory(dataSourceFactory); for (int i = 0; i < subtitles.size(); i++) { - MediaItem.Subtitle subtitle = subtitles.get(i); - Format subtitleFormat = - new Format.Builder() - .setSampleMimeType(subtitle.mimeType) - .setLanguage(subtitle.language) - .setSelectionFlags(subtitle.selectionFlags) - .build(); mediaSources[i + 1] = singleSampleSourceFactory.createMediaSource( - subtitle.uri, subtitleFormat, /* durationUs= */ C.TIME_UNSET); + subtitles.get(i), /* durationUs= */ C.TIME_UNSET); } mediaSource = new MergingMediaSource(mediaSources); } @@ -285,34 +263,6 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { // internal methods - private DrmSessionManager createDrmSessionManager(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); - if (mediaItem.playbackProperties.drmConfiguration == null - || mediaItem.playbackProperties.drmConfiguration.licenseUri == null - || Util.SDK_INT < 18) { - return drmSessionManager; - } - return new DefaultDrmSessionManager.Builder() - .setUuidAndExoMediaDrmProvider( - mediaItem.playbackProperties.drmConfiguration.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER) - .setMultiSession(mediaItem.playbackProperties.drmConfiguration.multiSession) - .setPlayClearSamplesWithoutKeys( - mediaItem.playbackProperties.drmConfiguration.playClearContentWithoutKey) - .setUseDrmSessionsForClearContent( - Util.toArray(mediaItem.playbackProperties.drmConfiguration.sessionForClearTypes)) - .build(createHttpMediaDrmCallback(mediaItem.playbackProperties.drmConfiguration)); - } - - private MediaDrmCallback createHttpMediaDrmCallback(MediaItem.DrmConfiguration drmConfiguration) { - Assertions.checkNotNull(drmConfiguration.licenseUri); - HttpMediaDrmCallback drmCallback = - new HttpMediaDrmCallback(drmConfiguration.licenseUri.toString(), drmHttpDataSourceFactory); - for (Map.Entry entry : drmConfiguration.requestHeaders.entrySet()) { - drmCallback.setKeyRequestProperty(entry.getKey(), entry.getValue()); - } - return drmCallback; - } - private static MediaSource maybeClipMediaSource(MediaItem mediaItem, MediaSource mediaSource) { if (mediaItem.clippingProperties.startPositionMs == 0 && mediaItem.clippingProperties.endPositionMs == C.TIME_END_OF_SOURCE @@ -333,31 +283,27 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { if (mediaItem.playbackProperties.adTagUri == null) { return mediaSource; } - if (adSupportProvider == null) { + AdsLoaderProvider adsLoaderProvider = this.adsLoaderProvider; + AdViewProvider adViewProvider = this.adViewProvider; + if (adsLoaderProvider == null || adViewProvider == null) { Log.w( TAG, - "Playing media without ads. Pass an AdsSupportProvider to the constructor for supporting" - + " media items with an ad tag uri."); + "Playing media without ads. Configure ad support by calling setAdsLoaderProvider and" + + " setAdViewProvider."); return mediaSource; } - AdsLoader adsLoader = adSupportProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri); + @Nullable + AdsLoader adsLoader = adsLoaderProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri); if (adsLoader == null) { - Log.w( - TAG, - String.format( - "Playing media without ads. No AdsLoader for media item with mediaId '%s'.", - mediaItem.mediaId)); + Log.w(TAG, "Playing media without ads. No AdsLoader for provided adTagUri"); return mediaSource; } return new AdsMediaSource( - mediaSource, - /* adMediaSourceFactory= */ this, - adsLoader, - adSupportProvider.getAdViewProvider()); + mediaSource, /* adMediaSourceFactory= */ this, adsLoader, adViewProvider); } private static SparseArray loadDelegates( - DataSource.Factory dataSourceFactory) { + DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { SparseArray factories = new SparseArray<>(); // LINT.IfChange try { @@ -392,7 +338,8 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { // Expected if the app was built without the hls module. } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - factories.put(C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory)); + factories.put( + C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)); return factories; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java index 2e1c92067c..38146c92b2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaSource.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; @@ -168,6 +169,23 @@ public final class ExtractorMediaSource extends CompositeMediaSource { throw new UnsupportedOperationException(); } + /** + * @deprecated Use {@link ProgressiveMediaSource.Factory#setDrmHttpDataSourceFactory} instead. + */ + @Deprecated + @Override + public MediaSourceFactory setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { + throw new UnsupportedOperationException(); + } + + /** @deprecated Use {@link ProgressiveMediaSource.Factory#setDrmUserAgent} instead. */ + @Deprecated + @Override + public MediaSourceFactory setDrmUserAgent(@Nullable String userAgent) { + throw new UnsupportedOperationException(); + } + /** @deprecated Use {@link #createMediaSource(MediaItem)} instead. */ @SuppressWarnings("deprecation") @Deprecated @@ -197,7 +215,7 @@ public final class ExtractorMediaSource extends CompositeMediaSource { } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ @Deprecated @@ -321,22 +339,34 @@ public final class ExtractorMediaSource extends CompositeMediaSource { @Nullable Object tag) { progressiveMediaSource = new ProgressiveMediaSource( - uri, + new MediaItem.Builder() + .setUri(uri) + .setCustomCacheKey(customCacheKey) + .setTag(tag) + .build(), dataSourceFactory, extractorsFactory, DrmSessionManager.getDummyDrmSessionManager(), loadableLoadErrorHandlingPolicy, - customCacheKey, - continueLoadingCheckIntervalBytes, - tag); + continueLoadingCheckIntervalBytes); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return progressiveMediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return progressiveMediaSource.getMediaItem(); + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java index 84d2902c53..285b9f3fef 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/IcyDataSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.min; + import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -67,6 +69,7 @@ import java.util.Map; @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } @@ -84,7 +87,7 @@ import java.util.Map; return C.RESULT_END_OF_INPUT; } } - int bytesRead = upstream.read(buffer, offset, Math.min(bytesUntilMetadata, readLength)); + int bytesRead = upstream.read(buffer, offset, min(bytesUntilMetadata, readLength)); if (bytesRead != C.RESULT_END_OF_INPUT) { bytesUntilMetadata -= bytesRead; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java index 13f9758a73..6d08147a63 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.AbstractConcatenatedTimeline; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ShuffleOrder.UnshuffledShuffleOrder; @@ -65,12 +66,22 @@ public final class LoopingMediaSource extends CompositeMediaSource { mediaPeriodToChildMediaPeriodId = new HashMap<>(); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return maskingMediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return maskingMediaSource.getMediaItem(); + } + @Override @Nullable public Timeline getInitialTimeline() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaPeriod.java index 142527af7d..9514241035 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaPeriod.java @@ -34,8 +34,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; */ public final class MaskingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { - /** Listener for preparation errors. */ - public interface PrepareErrorListener { + /** Listener for preparation events. */ + public interface PrepareListener { + + /** Called when preparing the media period completes. */ + void onPrepareComplete(MediaPeriodId mediaPeriodId); /** * Called the first time an error occurs while refreshing source info or preparing the period. @@ -53,7 +56,7 @@ public final class MaskingMediaPeriod implements MediaPeriod, MediaPeriod.Callba @Nullable private MediaPeriod mediaPeriod; @Nullable private Callback callback; private long preparePositionUs; - @Nullable private PrepareErrorListener listener; + @Nullable private PrepareListener listener; private boolean notifiedPrepareError; private long preparePositionOverrideUs; @@ -75,13 +78,13 @@ public final class MaskingMediaPeriod implements MediaPeriod, MediaPeriod.Callba } /** - * Sets a listener for preparation errors. + * Sets a listener for preparation events. * - * @param listener An listener to be notified of media period preparation errors. If a listener is + * @param listener An listener to be notified of media period preparation events. If a listener is * set, {@link #maybeThrowPrepareError()} will not throw but will instead pass the first * preparation error (if any) to the listener. */ - public void setPrepareErrorListener(PrepareErrorListener listener) { + public void setPrepareListener(PrepareListener listener) { this.listener = listener; } @@ -231,6 +234,9 @@ public final class MaskingMediaPeriod implements MediaPeriod, MediaPeriod.Callba @Override public void onPrepared(MediaPeriod mediaPeriod) { castNonNull(callback).onPrepared(this); + if (listener != null) { + listener.onPrepareComplete(id); + } } private long getPreparePositionWithOverride(long preparePositionUs) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java index 35b3e1848e..19f5df2aa5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -15,13 +15,15 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.max; + import android.util.Pair; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Window; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; @@ -41,7 +43,6 @@ public final class MaskingMediaSource extends CompositeMediaSource { private MaskingTimeline timeline; @Nullable private MaskingMediaPeriod unpreparedMaskingMediaPeriod; - @Nullable private EventDispatcher unpreparedMaskingMediaPeriodEventDispatcher; private boolean hasStartedPreparing; private boolean isPrepared; private boolean hasRealTimeline; @@ -66,12 +67,12 @@ public final class MaskingMediaSource extends CompositeMediaSource { initialTimeline, /* firstWindowUid= */ null, /* firstPeriodUid= */ null); hasRealTimeline = true; } else { - timeline = MaskingTimeline.createWithDummyTimeline(mediaSource.getTag()); + timeline = MaskingTimeline.createWithPlaceholderTimeline(mediaSource.getMediaItem()); } } /** Returns the {@link Timeline}. */ - public synchronized Timeline getTimeline() { + public Timeline getTimeline() { return timeline; } @@ -84,12 +85,22 @@ public final class MaskingMediaSource extends CompositeMediaSource { } } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return mediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return mediaSource.getMediaItem(); + } + @Override @SuppressWarnings("MissingSuperCall") public void maybeThrowSourceInfoRefreshError() { @@ -110,9 +121,6 @@ public final class MaskingMediaSource extends CompositeMediaSource { // unset and we don't load beyond periods with unset duration. We need to figure out how to // handle the prepare positions of multiple deferred media periods, should that ever change. unpreparedMaskingMediaPeriod = mediaPeriod; - unpreparedMaskingMediaPeriodEventDispatcher = - createEventDispatcher(/* windowIndex= */ 0, id, /* mediaTimeOffsetMs= */ 0); - unpreparedMaskingMediaPeriodEventDispatcher.mediaPeriodCreated(); if (!hasStartedPreparing) { hasStartedPreparing = true; prepareChildSource(/* id= */ null, mediaSource); @@ -125,8 +133,6 @@ public final class MaskingMediaSource extends CompositeMediaSource { public void releasePeriod(MediaPeriod mediaPeriod) { ((MaskingMediaPeriod) mediaPeriod).releasePeriod(); if (mediaPeriod == unpreparedMaskingMediaPeriod) { - Assertions.checkNotNull(unpreparedMaskingMediaPeriodEventDispatcher).mediaPeriodReleased(); - unpreparedMaskingMediaPeriodEventDispatcher = null; unpreparedMaskingMediaPeriod = null; } } @@ -139,7 +145,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { } @Override - protected synchronized void onChildSourceInfoRefreshed( + protected void onChildSourceInfoRefreshed( Void id, MediaSource mediaSource, Timeline newTimeline) { @Nullable MediaPeriodId idForMaskingPeriodPreparation = null; if (isPrepared) { @@ -154,7 +160,9 @@ public final class MaskingMediaSource extends CompositeMediaSource { hasRealTimeline ? timeline.cloneWithUpdatedTimeline(newTimeline) : MaskingTimeline.createWithRealTimeline( - newTimeline, Window.SINGLE_WINDOW_UID, MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID); + newTimeline, + Window.SINGLE_WINDOW_UID, + MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID); } else { // Determine first period and the start position. // This will be: @@ -163,7 +171,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { // a non-zero initial seek position in the window. // 3. The default window start position if the deferred period has a prepare position of zero // under the assumption that the prepare position of zero was used because it's the - // default position of the DummyTimeline window. Note that this will override an + // default position of the PlaceholderTimeline window. Note that this will override an // intentional seek to zero for a window with a non-zero default position. This is // unlikely to be a problem as a non-zero default position usually only occurs for live // playbacks and seeking to zero in a live window would cause BehindLiveWindowExceptions @@ -209,17 +217,9 @@ public final class MaskingMediaSource extends CompositeMediaSource { return mediaPeriodId.copyWithPeriodUid(getExternalPeriodUid(mediaPeriodId.periodUid)); } - @Override - protected boolean shouldDispatchCreateOrReleaseEvent(MediaPeriodId mediaPeriodId) { - // Suppress create and release events for the period created while the source was still - // unprepared, as we send these events from this class. - return unpreparedMaskingMediaPeriod == null - || !mediaPeriodId.equals(unpreparedMaskingMediaPeriod.id); - } - private Object getInternalPeriodUid(Object externalPeriodUid) { return timeline.replacedInternalPeriodUid != null - && externalPeriodUid.equals(MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID) + && externalPeriodUid.equals(MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID) ? timeline.replacedInternalPeriodUid : externalPeriodUid; } @@ -227,7 +227,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { private Object getExternalPeriodUid(Object internalPeriodUid) { return timeline.replacedInternalPeriodUid != null && timeline.replacedInternalPeriodUid.equals(internalPeriodUid) - ? MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID + ? MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID : internalPeriodUid; } @@ -246,7 +246,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { if (periodDurationUs != C.TIME_UNSET) { // Ensure the overridden position doesn't exceed the period duration. if (preparePositionOverrideUs >= periodDurationUs) { - preparePositionOverrideUs = Math.max(0, periodDurationUs - 1); + preparePositionOverrideUs = max(0, periodDurationUs - 1); } } maskingPeriod.overridePreparePositionUs(preparePositionOverrideUs); @@ -254,34 +254,36 @@ public final class MaskingMediaSource extends CompositeMediaSource { /** * Timeline used as placeholder for an unprepared media source. After preparation, a - * MaskingTimeline is used to keep the originally assigned dummy period ID. + * MaskingTimeline is used to keep the originally assigned masking period ID. */ private static final class MaskingTimeline extends ForwardingTimeline { - public static final Object DUMMY_EXTERNAL_PERIOD_UID = new Object(); + public static final Object MASKING_EXTERNAL_PERIOD_UID = new Object(); @Nullable private final Object replacedInternalWindowUid; @Nullable private final Object replacedInternalPeriodUid; /** - * Returns an instance with a dummy timeline using the provided window tag. + * Returns an instance with a placeholder timeline using the provided {@link MediaItem}. * - * @param windowTag A window tag. + * @param mediaItem A {@link MediaItem}. */ - public static MaskingTimeline createWithDummyTimeline(@Nullable Object windowTag) { + public static MaskingTimeline createWithPlaceholderTimeline(MediaItem mediaItem) { return new MaskingTimeline( - new DummyTimeline(windowTag), Window.SINGLE_WINDOW_UID, DUMMY_EXTERNAL_PERIOD_UID); + new PlaceholderTimeline(mediaItem), + Window.SINGLE_WINDOW_UID, + MASKING_EXTERNAL_PERIOD_UID); } /** * Returns an instance with a real timeline, replacing the provided period ID with the already - * assigned dummy period ID. + * assigned masking period ID. * * @param timeline The real timeline. * @param firstWindowUid The window UID in the timeline which will be replaced by the already * assigned {@link Window#SINGLE_WINDOW_UID}. * @param firstPeriodUid The period UID in the timeline which will be replaced by the already - * assigned {@link #DUMMY_EXTERNAL_PERIOD_UID}. + * assigned {@link #MASKING_EXTERNAL_PERIOD_UID}. */ public static MaskingTimeline createWithRealTimeline( Timeline timeline, @Nullable Object firstWindowUid, @Nullable Object firstPeriodUid) { @@ -324,7 +326,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { public Period getPeriod(int periodIndex, Period period, boolean setIds) { timeline.getPeriod(periodIndex, period, setIds); if (Util.areEqual(period.uid, replacedInternalPeriodUid) && setIds) { - period.uid = DUMMY_EXTERNAL_PERIOD_UID; + period.uid = MASKING_EXTERNAL_PERIOD_UID; } return period; } @@ -332,7 +334,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { @Override public int getIndexOfPeriod(Object uid) { return timeline.getIndexOfPeriod( - DUMMY_EXTERNAL_PERIOD_UID.equals(uid) && replacedInternalPeriodUid != null + MASKING_EXTERNAL_PERIOD_UID.equals(uid) && replacedInternalPeriodUid != null ? replacedInternalPeriodUid : uid); } @@ -340,18 +342,19 @@ public final class MaskingMediaSource extends CompositeMediaSource { @Override public Object getUidOfPeriod(int periodIndex) { Object uid = timeline.getUidOfPeriod(periodIndex); - return Util.areEqual(uid, replacedInternalPeriodUid) ? DUMMY_EXTERNAL_PERIOD_UID : uid; + return Util.areEqual(uid, replacedInternalPeriodUid) ? MASKING_EXTERNAL_PERIOD_UID : uid; } } - /** Dummy placeholder timeline with one dynamic window with a period of indeterminate duration. */ + /** A timeline with one dynamic window with a period of indeterminate duration. */ @VisibleForTesting - public static final class DummyTimeline extends Timeline { + public static final class PlaceholderTimeline extends Timeline { - @Nullable private final Object tag; + private final MediaItem mediaItem; - public DummyTimeline(@Nullable Object tag) { - this.tag = tag; + /** Creates a new instance with the given media item. */ + public PlaceholderTimeline(MediaItem mediaItem) { + this.mediaItem = mediaItem; } @Override @@ -363,7 +366,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { window.set( Window.SINGLE_WINDOW_UID, - tag, + mediaItem, /* manifest= */ null, /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, @@ -390,7 +393,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { public Period getPeriod(int periodIndex, Period period, boolean setIds) { return period.set( /* id= */ setIds ? 0 : null, - /* uid= */ setIds ? MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID : null, + /* uid= */ setIds ? MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID : null, /* windowIndex= */ 0, /* durationUs = */ C.TIME_UNSET, /* positionInWindowUs= */ 0); @@ -398,12 +401,12 @@ public final class MaskingMediaSource extends CompositeMediaSource { @Override public int getIndexOfPeriod(Object uid) { - return uid == MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID ? 0 : C.INDEX_UNSET; + return uid == MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID ? 0 : C.INDEX_UNSET; } @Override public Object getUidOfPeriod(int periodIndex) { - return MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID; + return MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID; } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java index 2e2cf9caba..39b207e264 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java @@ -239,8 +239,8 @@ public interface MediaPeriod extends SequenceableLoader { * *

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

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

      A period may choose to discard buffered media or cancel ongoing loads so that media can be + * re-buffered in a different quality. * * @param positionUs The current playback position in microseconds. If playback of this period has * not yet started, the value will be the starting position in this period minus the duration diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java index 479db2adc2..94a9f82030 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source; import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.upstream.Allocator; @@ -92,8 +93,8 @@ public interface MediaSource { public final int nextAdGroupIndex; /** - * Creates a media period identifier for a dummy period which is not part of a buffered sequence - * of windows. + * Creates a media period identifier for a period which is not part of a buffered sequence of + * windows. * * @param periodUid The unique id of the timeline period. */ @@ -247,8 +248,8 @@ public interface MediaSource { void removeDrmEventListener(DrmSessionEventListener eventListener); /** - * Returns the initial dummy timeline that is returned immediately when the real timeline is not - * yet known, or null to let the player create an initial timeline. + * Returns the initial placeholder timeline that is returned immediately when the real timeline is + * not yet known, or null to let the player create an initial timeline. * *

      The initial timeline must use the same uids for windows and periods that the real timeline * will use. It also must provide windows which are marked as dynamic to indicate that the window @@ -273,12 +274,18 @@ public interface MediaSource { return true; } - /** Returns the tag set on the media source, or null if none was set. */ + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @Deprecated @Nullable default Object getTag() { return null; } + /** Returns the {@link MediaItem} whose media is provided by the source. */ + MediaItem getMediaItem(); + /** * Registers a {@link MediaSourceCaller}. Starts source preparation if needed and enables the * source for the creation of {@link MediaPeriod MediaPerods}. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java new file mode 100644 index 0000000000..7859254401 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceDrmHelper.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; +import static com.google.android.exoplayer2.drm.DefaultDrmSessionManager.MODE_PLAYBACK; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.FrameworkMediaDrm; +import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; +import java.util.Map; + +/** A helper to create a {@link DrmSessionManager} from a {@link MediaItem}. */ +public final class MediaSourceDrmHelper { + + @Nullable private HttpDataSource.Factory drmHttpDataSourceFactory; + @Nullable private String userAgent; + + /** + * Sets the {@link HttpDataSource.Factory} to be used for creating {@link HttpMediaDrmCallback + * HttpMediaDrmCallbacks} which executes key and provisioning requests over HTTP. If {@code null} + * is passed the {@link DefaultHttpDataSourceFactory} is used. + * + * @param drmHttpDataSourceFactory The HTTP data source factory or {@code null} to use {@link + * DefaultHttpDataSourceFactory}. + */ + public void setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { + this.drmHttpDataSourceFactory = drmHttpDataSourceFactory; + } + + /** + * Sets the optional user agent to be used for DRM requests. + * + *

      In case a factory has been set by {@link + * #setDrmHttpDataSourceFactory(HttpDataSource.Factory)}, this user agent is ignored. + * + * @param userAgent The user agent to be used for DRM requests. + */ + public void setDrmUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + } + + /** Creates a {@link DrmSessionManager} for the given media item. */ + public DrmSessionManager create(MediaItem mediaItem) { + Assertions.checkNotNull(mediaItem.playbackProperties); + @Nullable + MediaItem.DrmConfiguration drmConfiguration = mediaItem.playbackProperties.drmConfiguration; + if (drmConfiguration == null || drmConfiguration.licenseUri == null || Util.SDK_INT < 18) { + return DrmSessionManager.getDummyDrmSessionManager(); + } + HttpDataSource.Factory dataSourceFactory = + drmHttpDataSourceFactory != null + ? drmHttpDataSourceFactory + : new DefaultHttpDataSourceFactory(userAgent != null ? userAgent : DEFAULT_USER_AGENT); + HttpMediaDrmCallback httpDrmCallback = + new HttpMediaDrmCallback( + castNonNull(drmConfiguration.licenseUri).toString(), + drmConfiguration.forceDefaultLicenseUri, + dataSourceFactory); + for (Map.Entry entry : drmConfiguration.requestHeaders.entrySet()) { + httpDrmCallback.setKeyRequestProperty(entry.getKey(), entry.getValue()); + } + DefaultDrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + drmConfiguration.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER) + .setMultiSession(drmConfiguration.multiSession) + .setPlayClearSamplesWithoutKeys(drmConfiguration.playClearContentWithoutKey) + .setUseDrmSessionsForClearContent(Ints.toArray(drmConfiguration.sessionForClearTypes)) + .build(httpDrmCallback); + drmSessionManager.setMode(MODE_PLAYBACK, drmConfiguration.getKeySetId()); + return drmSessionManager; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java index 7c9dc34b4f..39fd6d53a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java @@ -15,35 +15,22 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Util.postOrRun; + +import android.os.Handler; +import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.CopyOnWriteMultiset; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import java.io.IOException; +import java.util.concurrent.CopyOnWriteArrayList; /** Interface for callbacks to be notified of {@link MediaSource} events. */ public interface MediaSourceEventListener { - /** - * Called when a media period is created by the media source. - * - * @param windowIndex The window index in the timeline this media period belongs to. - * @param mediaPeriodId The {@link MediaPeriodId} of the created media period. - */ - default void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) {} - - /** - * Called when a media period is released by the media source. - * - * @param windowIndex The window index in the timeline this media period belongs to. - * @param mediaPeriodId The {@link MediaPeriodId} of the released media period. - */ - default void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) {} - /** * Called when a load begins. * @@ -130,14 +117,6 @@ public interface MediaSourceEventListener { IOException error, boolean wasCanceled) {} - /** - * Called when a media period is first being read from. - * - * @param windowIndex The window index in the timeline this media period belongs to. - * @param mediaPeriodId The {@link MediaPeriodId} of the media period being read from. - */ - default void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) {} - /** * Called when data is removed from the back of a media buffer, typically so that it can be * re-buffered in a different format. @@ -160,42 +139,79 @@ public interface MediaSourceEventListener { default void onDownstreamFormatChanged( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) {} - /** @deprecated Use {@link MediaSourceEventDispatcher} directly instead. */ - @Deprecated - final class EventDispatcher extends MediaSourceEventDispatcher { + /** Dispatches events to {@link MediaSourceEventListener MediaSourceEventListeners}. */ + class EventDispatcher { + /** The timeline window index reported with the events. */ + public final int windowIndex; + /** The {@link MediaPeriodId} reported with the events. */ + @Nullable public final MediaPeriodId mediaPeriodId; + + private final CopyOnWriteArrayList listenerAndHandlers; + private final long mediaTimeOffsetMs; + + /** Creates an event dispatcher. */ public EventDispatcher() { - super(); + this( + /* listenerAndHandlers= */ new CopyOnWriteArrayList<>(), + /* windowIndex= */ 0, + /* mediaPeriodId= */ null, + /* mediaTimeOffsetMs= */ 0); } private EventDispatcher( - CopyOnWriteMultiset listeners, + CopyOnWriteArrayList listenerAndHandlers, int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { - super(listeners, windowIndex, mediaPeriodId, mediaTimeOffsetMs); + this.listenerAndHandlers = listenerAndHandlers; + this.windowIndex = windowIndex; + this.mediaPeriodId = mediaPeriodId; + this.mediaTimeOffsetMs = mediaTimeOffsetMs; } - @Override + /** + * Creates a view of the event dispatcher with pre-configured window index, media period id, and + * media time offset. + * + * @param windowIndex The timeline window index to be reported with the events. + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. + * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. + * @return A view of the event dispatcher with the pre-configured parameters. + */ + @CheckResult public EventDispatcher withParameters( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { - return new EventDispatcher(listenerInfos, windowIndex, mediaPeriodId, mediaTimeOffsetMs); + return new EventDispatcher( + listenerAndHandlers, windowIndex, mediaPeriodId, mediaTimeOffsetMs); } - public void mediaPeriodCreated() { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onMediaPeriodCreated(windowIndex, Assertions.checkNotNull(mediaPeriodId)), - MediaSourceEventListener.class); + /** + * Adds a listener to the event dispatcher. + * + * @param handler A handler on the which listener events will be posted. + * @param eventListener The listener to be added. + */ + public void addEventListener(Handler handler, MediaSourceEventListener eventListener) { + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); + listenerAndHandlers.add(new ListenerAndHandler(handler, eventListener)); } - public void mediaPeriodReleased() { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onMediaPeriodReleased(windowIndex, Assertions.checkNotNull(mediaPeriodId)), - MediaSourceEventListener.class); + /** + * Removes a listener from the event dispatcher. + * + * @param eventListener The listener to be removed. + */ + public void removeEventListener(MediaSourceEventListener eventListener) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + if (listenerAndHandler.listener == eventListener) { + listenerAndHandlers.remove(listenerAndHandler); + } + } } + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadStarted(LoadEventInfo loadEventInfo, int dataType) { loadStarted( loadEventInfo, @@ -208,6 +224,7 @@ public interface MediaSourceEventListener { /* mediaEndTimeUs= */ C.TIME_UNSET); } + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadStarted( LoadEventInfo loadEventInfo, int dataType, @@ -229,13 +246,17 @@ public interface MediaSourceEventListener { adjustMediaTime(mediaEndTimeUs))); } + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadStarted(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onLoadStarted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData), - MediaSourceEventListener.class); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onLoadStarted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } } + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCompleted(LoadEventInfo loadEventInfo, int dataType) { loadCompleted( loadEventInfo, @@ -248,6 +269,7 @@ public interface MediaSourceEventListener { /* mediaEndTimeUs= */ C.TIME_UNSET); } + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCompleted( LoadEventInfo loadEventInfo, int dataType, @@ -269,13 +291,18 @@ public interface MediaSourceEventListener { adjustMediaTime(mediaEndTimeUs))); } + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCompleted(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onLoadCompleted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData), - MediaSourceEventListener.class); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadCompleted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } } + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCanceled(LoadEventInfo loadEventInfo, int dataType) { loadCanceled( loadEventInfo, @@ -288,6 +315,7 @@ public interface MediaSourceEventListener { /* mediaEndTimeUs= */ C.TIME_UNSET); } + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCanceled( LoadEventInfo loadEventInfo, int dataType, @@ -309,13 +337,21 @@ public interface MediaSourceEventListener { adjustMediaTime(mediaEndTimeUs))); } + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCanceled(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onLoadCanceled(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData), - MediaSourceEventListener.class); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadCanceled(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } } + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ public void loadError( LoadEventInfo loadEventInfo, int dataType, IOException error, boolean wasCanceled) { loadError( @@ -331,6 +367,10 @@ public interface MediaSourceEventListener { wasCanceled); } + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ public void loadError( LoadEventInfo loadEventInfo, int dataType, @@ -356,25 +396,26 @@ public interface MediaSourceEventListener { wasCanceled); } + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ public void loadError( LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onLoadError( - windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData, error, wasCanceled), - MediaSourceEventListener.class); - } - - public void readingStarted() { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onReadingStarted(windowIndex, Assertions.checkNotNull(mediaPeriodId)), - MediaSourceEventListener.class); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadError( + windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData, error, wasCanceled)); + } } + /** Dispatches {@link #onUpstreamDiscarded(int, MediaPeriodId, MediaLoadData)}. */ public void upstreamDiscarded(int trackType, long mediaStartTimeUs, long mediaEndTimeUs) { upstreamDiscarded( new MediaLoadData( @@ -387,14 +428,18 @@ public interface MediaSourceEventListener { adjustMediaTime(mediaEndTimeUs))); } + /** Dispatches {@link #onUpstreamDiscarded(int, MediaPeriodId, MediaLoadData)}. */ public void upstreamDiscarded(MediaLoadData mediaLoadData) { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onUpstreamDiscarded( - windowIndex, Assertions.checkNotNull(mediaPeriodId), mediaLoadData), - MediaSourceEventListener.class); + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onUpstreamDiscarded(windowIndex, mediaPeriodId, mediaLoadData)); + } } + /** Dispatches {@link #onDownstreamFormatChanged(int, MediaPeriodId, MediaLoadData)}. */ public void downstreamFormatChanged( int trackType, @Nullable Format trackFormat, @@ -412,15 +457,30 @@ public interface MediaSourceEventListener { /* mediaEndTimeMs= */ C.TIME_UNSET)); } + /** Dispatches {@link #onDownstreamFormatChanged(int, MediaPeriodId, MediaLoadData)}. */ public void downstreamFormatChanged(MediaLoadData mediaLoadData) { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onDownstreamFormatChanged(windowIndex, mediaPeriodId, mediaLoadData), - MediaSourceEventListener.class); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDownstreamFormatChanged(windowIndex, mediaPeriodId, mediaLoadData)); + } } private long adjustMediaTime(long mediaTimeUs) { - return adjustMediaTime(mediaTimeUs, mediaTimeOffsetMs); + long mediaTimeMs = C.usToMs(mediaTimeUs); + return mediaTimeMs == C.TIME_UNSET ? C.TIME_UNSET : mediaTimeOffsetMs + mediaTimeMs; + } + + private static final class ListenerAndHandler { + + public Handler handler; + public MediaSourceEventListener listener; + + public ListenerAndHandler(Handler handler, MediaSourceEventListener listener) { + this.handler = handler; + this.listener = listener; + } } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java index e1c52c097b..204220e334 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java @@ -19,13 +19,35 @@ import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import java.util.List; -/** Factory for creating {@link MediaSource}s from URIs. */ +/** + * Factory for creating {@link MediaSource}s from URIs. + * + *

      DrmSessionManager creation for protected content

      + * + *

      In case a {@link DrmSessionManager} is passed to {@link + * #setDrmSessionManager(DrmSessionManager)}, it will be used regardless of the drm configuration of + * the media item. + * + *

      For a media item with a {@link MediaItem.DrmConfiguration}, a {@link DefaultDrmSessionManager} + * is created based on that configuration. The following setter can be used to optionally configure + * the creation: + * + *

        + *
      • {@link #setDrmHttpDataSourceFactory(HttpDataSource.Factory)}: Sets the data source factory + * to be used by the {@link HttpMediaDrmCallback} for network requests (default: {@link + * DefaultHttpDataSourceFactory}). + *
      + */ public interface MediaSourceFactory { /** @deprecated Use {@link MediaItem.PlaybackProperties#streamKeys} instead. */ @@ -35,17 +57,47 @@ public interface MediaSourceFactory { } /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. + * Sets the {@link DrmSessionManager} to use for all media items regardless of their {@link + * MediaItem.DrmConfiguration}. * - * @param drmSessionManager The {@link DrmSessionManager}. + * @param drmSessionManager The {@link DrmSessionManager}, or {@code null} to use the {@link + * DefaultDrmSessionManager}. * @return This factory, for convenience. */ MediaSourceFactory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManager); + /** + * Sets the {@link HttpDataSource.Factory} to be used for creating {@link HttpMediaDrmCallback + * HttpMediaDrmCallbacks} to execute key and provisioning requests over HTTP. + * + *

      In case a {@link DrmSessionManager} has been set by {@link + * #setDrmSessionManager(DrmSessionManager)}, this data source factory is ignored. + * + * @param drmHttpDataSourceFactory The HTTP data source factory, or {@code null} to use {@link + * DefaultHttpDataSourceFactory}. + * @return This factory, for convenience. + */ + MediaSourceFactory setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory); + + /** + * Sets the optional user agent to be used for DRM requests. + * + *

      In case a factory has been set by {@link + * #setDrmHttpDataSourceFactory(HttpDataSource.Factory)} or a {@link DrmSessionManager} has been + * set by {@link #setDrmSessionManager(DrmSessionManager)}, this user agent is ignored. + * + * @param userAgent The user agent to be used for DRM requests, or {@code null} to use the + * default. + * @return This factory, for convenience. + */ + MediaSourceFactory setDrmUserAgent(@Nullable String userAgent); + /** * Sets an optional {@link LoadErrorHandlingPolicy}. * - * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}, or {@code null} to use the + * {@link DefaultLoadErrorHandlingPolicy}. * @return This factory, for convenience. */ MediaSourceFactory setLoadErrorHandlingPolicy( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java index 0c5af44816..0dae1ad6f9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.max; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.FormatHolder; @@ -442,7 +444,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { int readResult = sampleStream.readData(formatHolder, buffer, formatRequired); if (readResult == C.RESULT_BUFFER_READ) { - buffer.timeUs = Math.max(0, buffer.timeUs + timeOffsetUs); + buffer.timeUs = max(0, buffer.timeUs + timeOffsetUs); } return readResult; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java index d69c037a5a..8df7a639c6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaSource.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; @@ -65,6 +66,8 @@ public final class MergingMediaSource extends CompositeMediaSource { } private static final int PERIOD_COUNT_UNSET = -1; + private static final MediaItem EMPTY_MEDIA_ITEM = + new MediaItem.Builder().setMediaId("MergingMediaSource").build(); private final boolean adjustPeriodTimeOffsets; private final MediaSource[] mediaSources; @@ -121,12 +124,22 @@ public final class MergingMediaSource extends CompositeMediaSource { periodTimeOffsetsUs = new long[0][]; } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return mediaSources.length > 0 ? mediaSources[0].getTag() : null; } + @Override + public MediaItem getMediaItem() { + return mediaSources.length > 0 ? mediaSources[0].getMediaItem() : EMPTY_MEDIA_ITEM; + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java index 6cc7c91232..9efe6acba1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaExtractor.java @@ -22,6 +22,8 @@ import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.upstream.DataReader; import java.io.IOException; +import java.util.List; +import java.util.Map; /** Extracts the contents of a container file from a progressive media stream. */ /* package */ interface ProgressiveMediaExtractor { @@ -31,6 +33,7 @@ import java.io.IOException; * * @param dataReader The {@link DataReader} from which data should be read. * @param uri The {@link Uri} from which the media is obtained. + * @param responseHeaders The response headers of the media, or an empty map if there are none. * @param position The initial position of the {@code dataReader} in the stream. * @param length The length of the stream, or {@link C#LENGTH_UNSET} if length is unknown. * @param output The {@link ExtractorOutput} that will be used to initialize the selected @@ -38,7 +41,13 @@ import java.io.IOException; * @throws UnrecognizedInputFormatException Thrown if the input format could not be detected. * @throws IOException Thrown if the input could not be read. */ - void init(DataReader dataReader, Uri uri, long position, long length, ExtractorOutput output) + void init( + DataReader dataReader, + Uri uri, + Map> responseHeaders, + long position, + long length, + ExtractorOutput output) throws IOException; /** Releases any held resources. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java index 32c96e14f7..121eeb940d 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 @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.net.Uri; import android.os.Handler; import androidx.annotation.Nullable; @@ -24,9 +27,11 @@ import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.SeekMap.SeekPoints; @@ -34,7 +39,6 @@ import com.google.android.exoplayer2.extractor.SeekMap.Unseekable; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.icy.IcyHeaders; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.Allocator; @@ -89,7 +93,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * When the source's duration is unknown, it is calculated by adding this value to the largest * sample timestamp seen when buffering completes. */ - private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10000; + private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10_000; private static final Map ICY_METADATA_HEADERS = createIcyMetadataHeaders(); @@ -100,7 +104,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private final DataSource dataSource; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; - private final EventDispatcher eventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final Listener listener; private final Allocator allocator; @Nullable private final String customCacheKey; @@ -128,7 +133,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private boolean seenFirstTrackSelection; private boolean notifyDiscontinuity; - private boolean notifiedReadingStarted; private int enabledTrackCount; private long length; @@ -143,9 +147,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * @param uri The {@link Uri} of the media stream. * @param dataSource The data source to read the media. - * @param extractors The extractors to use to read the data source. + * @param extractorsFactory The {@link ExtractorsFactory} to use to read the data source. + * @param drmSessionManager A {@link DrmSessionManager} to allow DRM interactions. + * @param drmEventDispatcher A dispatcher to notify of {@link DrmSessionEventListener} events. * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. - * @param eventDispatcher A dispatcher to notify of events. + * @param mediaSourceEventDispatcher A dispatcher to notify of {@link MediaSourceEventListener} + * events. * @param listener A listener to notify when information about the period changes. * @param allocator An {@link Allocator} from which to obtain media buffer allocations. * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache @@ -161,10 +168,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public ProgressiveMediaPeriod( Uri uri, DataSource dataSource, - Extractor[] extractors, + ExtractorsFactory extractorsFactory, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, Listener listener, Allocator allocator, @Nullable String customCacheKey, @@ -172,14 +180,16 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; this.uri = uri; this.dataSource = dataSource; this.drmSessionManager = drmSessionManager; + this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; - this.eventDispatcher = eventDispatcher; + this.mediaSourceEventDispatcher = mediaSourceEventDispatcher; this.listener = listener; this.allocator = allocator; this.customCacheKey = customCacheKey; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; loader = new Loader("Loader:ProgressiveMediaPeriod"); - ProgressiveMediaExtractor progressiveMediaExtractor = new BundledExtractorsAdapter(extractors); + ProgressiveMediaExtractor progressiveMediaExtractor = + new BundledExtractorsAdapter(extractorsFactory); this.progressiveMediaExtractor = progressiveMediaExtractor; loadCondition = new ConditionVariable(); maybeFinishPrepareRunnable = this::maybeFinishPrepare; @@ -190,14 +200,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; .onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentLooper(); sampleQueueTrackIds = new TrackId[0]; sampleQueues = new SampleQueue[0]; pendingResetPositionUs = C.TIME_UNSET; length = C.LENGTH_UNSET; durationUs = C.TIME_UNSET; dataType = C.DATA_TYPE_MEDIA; - eventDispatcher.mediaPeriodCreated(); } public void release() { @@ -212,7 +221,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; handler.removeCallbacksAndMessages(null); callback = null; released = true; - eventDispatcher.mediaPeriodReleased(); } @Override @@ -366,10 +374,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } if (notifyDiscontinuity && (loadingFinished || getExtractedSamplesCount() > extractedSamplesCountAtStartOfLoad)) { notifyDiscontinuity = false; @@ -393,8 +397,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; int trackCount = sampleQueues.length; for (int i = 0; i < trackCount; i++) { if (trackIsAudioVideoFlags[i] && !sampleQueues[i].isLastSampleQueued()) { - largestQueuedTimestampUs = Math.min(largestQueuedTimestampUs, - sampleQueues[i].getLargestQueuedTimestampUs()); + largestQueuedTimestampUs = + min(largestQueuedTimestampUs, sampleQueues[i].getLargestQueuedTimestampUs()); } } } @@ -478,8 +482,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } maybeNotifyDownstreamFormat(sampleQueueIndex); int result = - sampleQueues[sampleQueueIndex].read( - formatHolder, buffer, formatRequired, loadingFinished, lastSeekPositionUs); + sampleQueues[sampleQueueIndex].read(formatHolder, buffer, formatRequired, loadingFinished); if (result == C.RESULT_NOTHING_READ) { maybeStartDeferredRetry(sampleQueueIndex); } @@ -492,12 +495,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } maybeNotifyDownstreamFormat(track); SampleQueue sampleQueue = sampleQueues[track]; - int skipCount; - if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { - skipCount = sampleQueue.advanceToEnd(); - } else { - skipCount = sampleQueue.advanceTo(positionUs); - } + int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished); + sampleQueue.skip(skipCount); if (skipCount == 0) { maybeStartDeferredRetry(track); } @@ -509,7 +508,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; boolean[] trackNotifiedDownstreamFormats = trackState.trackNotifiedDownstreamFormats; if (!trackNotifiedDownstreamFormats[track]) { Format trackFormat = trackState.tracks.get(track).getFormat(/* index= */ 0); - eventDispatcher.downstreamFormatChanged( + mediaSourceEventDispatcher.downstreamFormatChanged( MimeTypes.getTrackType(trackFormat.sampleMimeType), trackFormat, C.SELECTION_REASON_UNKNOWN, @@ -565,7 +564,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; loadDurationMs, dataSource.getBytesRead()); loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); - eventDispatcher.loadCompleted( + mediaSourceEventDispatcher.loadCompleted( loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, @@ -593,7 +592,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; loadDurationMs, dataSource.getBytesRead()); loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); - eventDispatcher.loadCanceled( + mediaSourceEventDispatcher.loadCanceled( loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, @@ -656,7 +655,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } boolean wasCanceled = !loadErrorAction.isRetry(); - eventDispatcher.loadError( + mediaSourceEventDispatcher.loadError( loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, @@ -718,7 +717,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; allocator, /* playbackLooper= */ handler.getLooper(), drmSessionManager, - eventDispatcher); + drmEventDispatcher); trackOutput.setUpstreamFormatChangeListener(this); @NullableType TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1); @@ -732,13 +731,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private void setSeekMap(SeekMap seekMap) { this.seekMap = icyHeaders == null ? seekMap : new Unseekable(/* durationUs= */ C.TIME_UNSET); - if (!prepared) { - maybeFinishPrepare(); - } durationUs = seekMap.getDurationUs(); isLive = length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET; dataType = isLive ? C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE : C.DATA_TYPE_MEDIA; listener.onSourceInfoRefreshed(durationUs, seekMap.isSeekable(), isLive); + if (!prepared) { + maybeFinishPrepare(); + } } private void maybeFinishPrepare() { @@ -781,6 +780,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; trackFormat = trackFormat.buildUpon().setAverageBitrate(icyHeaders.bitrate).build(); } } + trackFormat = + trackFormat.copyWithExoMediaCryptoType( + drmSessionManager.getExoMediaCryptoType(trackFormat)); trackArray[i] = new TrackGroup(trackFormat); } trackState = new TrackState(new TrackGroupArray(trackArray), trackIsAudioVideoFlags); @@ -808,6 +810,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; loadable.setLoadPosition( Assertions.checkNotNull(seekMap).getSeekPoints(pendingResetPositionUs).first.position, pendingResetPositionUs); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.setStartTimeUs(pendingResetPositionUs); + } pendingResetPositionUs = C.TIME_UNSET; } extractedSamplesCountAtStartOfLoad = getExtractedSamplesCount(); @@ -815,7 +820,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; loader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(dataType)); DataSpec dataSpec = loadable.dataSpec; - eventDispatcher.loadStarted( + mediaSourceEventDispatcher.loadStarted( new LoadEventInfo(loadable.loadTaskId, dataSpec, elapsedRealtimeMs), C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, @@ -904,8 +909,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private long getLargestQueuedTimestampUs() { long largestQueuedTimestampUs = Long.MIN_VALUE; for (SampleQueue sampleQueue : sampleQueues) { - largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, - sampleQueue.getLargestQueuedTimestampUs()); + largestQueuedTimestampUs = + max(largestQueuedTimestampUs, sampleQueue.getLargestQueuedTimestampUs()); } return largestQueuedTimestampUs; } @@ -1017,7 +1022,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; icyTrackOutput.format(ICY_FORMAT); } progressiveMediaExtractor.init( - extractorDataSource, uri, position, length, extractorOutput); + extractorDataSource, + uri, + dataSource.getResponseHeaders(), + position, + length, + extractorOutput); if (icyHeaders != null) { progressiveMediaExtractor.disableSeekingOnMp3Streams(); @@ -1058,8 +1068,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public void onIcyMetadata(ParsableByteArray metadata) { // Always output the first ICY metadata at the start time. This helps minimize any delay // between the start of playback and the first ICY metadata event. - long timeUs = - !seenIcyMetadata ? seekTimeUs : Math.max(getLargestQueuedTimestampUs(), seekTimeUs); + long timeUs = !seenIcyMetadata ? seekTimeUs : max(getLargestQueuedTimestampUs(), seekTimeUs); int length = metadata.bytesLeft(); TrackOutput icyTrackOutput = Assertions.checkNotNull(this.icyTrackOutput); icyTrackOutput.sampleData(metadata, length); 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 8885a716ba..4d7230cc3a 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 @@ -15,12 +15,13 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.drm.DrmSession; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.extractor.Extractor; @@ -28,9 +29,9 @@ import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Assertions; /** * Provides one period that loads data from a {@link Uri} and extracted using an {@link Extractor}. @@ -50,9 +51,10 @@ public final class ProgressiveMediaSource extends BaseMediaSource public static final class Factory implements MediaSourceFactory { private final DataSource.Factory dataSourceFactory; + private final MediaSourceDrmHelper mediaSourceDrmHelper; private ExtractorsFactory extractorsFactory; - private DrmSessionManager drmSessionManager; + @Nullable private DrmSessionManager drmSessionManager; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private int continueLoadingCheckIntervalBytes; @Nullable private String customCacheKey; @@ -77,7 +79,7 @@ public final class ProgressiveMediaSource extends BaseMediaSource public Factory(DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { this.dataSourceFactory = dataSourceFactory; this.extractorsFactory = extractorsFactory; - drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); + mediaSourceDrmHelper = new MediaSourceDrmHelper(); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); continueLoadingCheckIntervalBytes = DEFAULT_LOADING_CHECK_INTERVAL_BYTES; } @@ -145,19 +147,22 @@ public final class ProgressiveMediaSource extends BaseMediaSource return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - */ @Override public Factory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManager) { - this.drmSessionManager = - drmSessionManager != null - ? drmSessionManager - : DrmSessionManager.getDummyDrmSessionManager(); + this.drmSessionManager = drmSessionManager; + return this; + } + + @Override + public Factory setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { + mediaSourceDrmHelper.setDrmHttpDataSourceFactory(drmHttpDataSourceFactory); + return this; + } + + @Override + public Factory setDrmUserAgent(@Nullable String userAgent) { + mediaSourceDrmHelper.setDrmUserAgent(userAgent); return this; } @@ -178,18 +183,24 @@ public final class ProgressiveMediaSource extends BaseMediaSource */ @Override public ProgressiveMediaSource createMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); + checkNotNull(mediaItem.playbackProperties); + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsCustomCacheKey = + mediaItem.playbackProperties.customCacheKey == null && customCacheKey != null; + if (needsTag && needsCustomCacheKey) { + mediaItem = mediaItem.buildUpon().setTag(tag).setCustomCacheKey(customCacheKey).build(); + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsCustomCacheKey) { + mediaItem = mediaItem.buildUpon().setCustomCacheKey(customCacheKey).build(); + } return new ProgressiveMediaSource( - mediaItem.playbackProperties.uri, + mediaItem, dataSourceFactory, extractorsFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, - mediaItem.playbackProperties.customCacheKey != null - ? mediaItem.playbackProperties.customCacheKey - : customCacheKey, - continueLoadingCheckIntervalBytes, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + continueLoadingCheckIntervalBytes); } @Override @@ -204,14 +215,13 @@ public final class ProgressiveMediaSource extends BaseMediaSource */ public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = 1024 * 1024; - private final Uri uri; + private final MediaItem mediaItem; + private final MediaItem.PlaybackProperties playbackProperties; private final DataSource.Factory dataSourceFactory; private final ExtractorsFactory extractorsFactory; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy; - @Nullable private final String customCacheKey; private final int continueLoadingCheckIntervalBytes; - @Nullable private final Object tag; private boolean timelineIsPlaceholder; private long timelineDurationUs; @@ -221,30 +231,37 @@ public final class ProgressiveMediaSource extends BaseMediaSource // TODO: Make private when ExtractorMediaSource is deleted. /* package */ ProgressiveMediaSource( - Uri uri, + MediaItem mediaItem, DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory, DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, - @Nullable String customCacheKey, - int continueLoadingCheckIntervalBytes, - @Nullable Object tag) { - this.uri = uri; + int continueLoadingCheckIntervalBytes) { + this.playbackProperties = checkNotNull(mediaItem.playbackProperties); + this.mediaItem = mediaItem; this.dataSourceFactory = dataSourceFactory; this.extractorsFactory = extractorsFactory; this.drmSessionManager = drmSessionManager; this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy; - this.customCacheKey = customCacheKey; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; this.timelineIsPlaceholder = true; this.timelineDurationUs = C.TIME_UNSET; - this.tag = tag; } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { - return tag; + return playbackProperties.tag; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -266,15 +283,16 @@ public final class ProgressiveMediaSource extends BaseMediaSource dataSource.addTransferListener(transferListener); } return new ProgressiveMediaPeriod( - uri, + playbackProperties.uri, dataSource, - extractorsFactory.createExtractors(), + extractorsFactory, drmSessionManager, + createDrmEventDispatcher(id), loadableLoadErrorHandlingPolicy, createEventDispatcher(id), this, allocator, - customCacheKey, + playbackProperties.customCacheKey, continueLoadingCheckIntervalBytes); } @@ -320,7 +338,7 @@ public final class ProgressiveMediaSource extends BaseMediaSource /* isDynamic= */ false, /* isLive= */ timelineIsLive, /* manifest= */ null, - tag); + mediaItem); if (timelineIsPlaceholder) { // TODO: Actually prepare the extractors during prepatation so that we don't need a // placeholder. See https://github.com/google/ExoPlayer/issues/4727. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java index 7fd95df34f..797b5ad30b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleDataQueue.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.decoder.CryptoInfo; @@ -127,7 +129,7 @@ import java.util.Arrays; if (buffer.hasSupplementalData()) { // If there is supplemental data, the sample data is prefixed by its size. scratch.reset(4); - readData(extrasHolder.offset, scratch.data, 4); + readData(extrasHolder.offset, scratch.getData(), 4); int sampleSize = scratch.readUnsignedIntToInt(); extrasHolder.offset += 4; extrasHolder.size -= 4; @@ -223,9 +225,9 @@ import java.util.Arrays; // Read the signal byte. scratch.reset(1); - readData(offset, scratch.data, 1); + readData(offset, scratch.getData(), 1); offset++; - byte signalByte = scratch.data[0]; + byte signalByte = scratch.getData()[0]; boolean subsampleEncryption = (signalByte & 0x80) != 0; int ivSize = signalByte & 0x7F; @@ -244,7 +246,7 @@ import java.util.Arrays; int subsampleCount; if (subsampleEncryption) { scratch.reset(2); - readData(offset, scratch.data, 2); + readData(offset, scratch.getData(), 2); offset += 2; subsampleCount = scratch.readUnsignedShort(); } else { @@ -263,7 +265,7 @@ import java.util.Arrays; if (subsampleEncryption) { int subsampleDataLength = 6 * subsampleCount; scratch.reset(subsampleDataLength); - readData(offset, scratch.data, subsampleDataLength); + readData(offset, scratch.getData(), subsampleDataLength); offset += subsampleDataLength; scratch.setPosition(0); for (int i = 0; i < subsampleCount; i++) { @@ -304,7 +306,7 @@ import java.util.Arrays; advanceReadTo(absolutePosition); int remaining = length; while (remaining > 0) { - int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + int toCopy = min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); Allocation allocation = readAllocationNode.allocation; target.put(allocation.data, readAllocationNode.translateOffset(absolutePosition), toCopy); remaining -= toCopy; @@ -326,7 +328,7 @@ import java.util.Arrays; advanceReadTo(absolutePosition); int remaining = length; while (remaining > 0) { - int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + int toCopy = min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); Allocation allocation = readAllocationNode.allocation; System.arraycopy( allocation.data, @@ -392,7 +394,7 @@ import java.util.Arrays; allocator.allocate(), new AllocationNode(writeAllocationNode.endPosition, allocationLength)); } - return Math.min(length, (int) (writeAllocationNode.endPosition - totalBytesWritten)); + return min(length, (int) (writeAllocationNode.endPosition - totalBytesWritten)); } /** 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 3c08012cb8..20d9f44562 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -15,7 +15,11 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static java.lang.Math.max; + import android.os.Looper; +import android.util.Log; import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -25,12 +29,12 @@ import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataReader; import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; @@ -52,12 +56,13 @@ public class SampleQueue implements TrackOutput { } @VisibleForTesting /* package */ static final int SAMPLE_CAPACITY_INCREMENT = 1000; + private static final String TAG = "SampleQueue"; private final SampleDataQueue sampleDataQueue; private final SampleExtrasHolder extrasHolder; private final Looper playbackLooper; private final DrmSessionManager drmSessionManager; - private final MediaSourceEventDispatcher eventDispatcher; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; @Nullable private UpstreamFormatChangedListener upstreamFormatChangeListener; @Nullable private Format downstreamFormat; @@ -77,6 +82,7 @@ public class SampleQueue implements TrackOutput { private int relativeFirstIndex; private int readPosition; + private long startTimeUs; private long largestDiscardedTimestampUs; private long largestQueuedTimestampUs; private boolean isLastSampleQueued; @@ -87,6 +93,8 @@ public class SampleQueue implements TrackOutput { @Nullable private Format upstreamFormat; @Nullable private Format upstreamCommittedFormat; private int upstreamSourceId; + private boolean upstreamAllSamplesAreSyncSamples; + private boolean loggedUnexpectedNonSyncSample; private long sampleOffsetUs; private boolean pendingSplice; @@ -98,17 +106,17 @@ public class SampleQueue implements TrackOutput { * @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}. - * @param eventDispatcher A {@link MediaSourceEventDispatcher} to notify of events related to this - * SampleQueue. + * @param drmEventDispatcher A {@link DrmSessionEventListener.EventDispatcher} to notify of events + * related to this SampleQueue. */ public SampleQueue( Allocator allocator, Looper playbackLooper, DrmSessionManager drmSessionManager, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher drmEventDispatcher) { this.playbackLooper = playbackLooper; this.drmSessionManager = drmSessionManager; - this.eventDispatcher = eventDispatcher; + this.drmEventDispatcher = drmEventDispatcher; sampleDataQueue = new SampleDataQueue(allocator); extrasHolder = new SampleExtrasHolder(); capacity = SAMPLE_CAPACITY_INCREMENT; @@ -119,6 +127,7 @@ public class SampleQueue implements TrackOutput { sizes = new int[capacity]; cryptoDatas = new CryptoData[capacity]; formats = new Format[capacity]; + startTimeUs = Long.MIN_VALUE; largestDiscardedTimestampUs = Long.MIN_VALUE; largestQueuedTimestampUs = Long.MIN_VALUE; upstreamFormatRequired = true; @@ -155,6 +164,7 @@ public class SampleQueue implements TrackOutput { relativeFirstIndex = 0; readPosition = 0; upstreamKeyframeRequired = true; + startTimeUs = Long.MIN_VALUE; largestDiscardedTimestampUs = Long.MIN_VALUE; largestQueuedTimestampUs = Long.MIN_VALUE; isLastSampleQueued = false; @@ -166,6 +176,16 @@ public class SampleQueue implements TrackOutput { } } + /** + * Sets the start time for the queue. Samples with earlier timestamps will be discarded or have + * the {@link C#BUFFER_FLAG_DECODE_ONLY} flag set when read. + * + * @param startTimeUs The start time, in microseconds. + */ + public final void setStartTimeUs(long startTimeUs) { + this.startTimeUs = startTimeUs; + } + /** * Sets a source identifier for subsequent samples. * @@ -195,6 +215,22 @@ public class SampleQueue implements TrackOutput { sampleDataQueue.discardUpstreamSampleBytes(discardUpstreamSampleMetadata(discardFromIndex)); } + /** + * Discards samples from the write side of the queue. + * + * @param timeUs Samples will be discarded from the write end of the queue until a sample with a + * timestamp smaller than timeUs is encountered (this sample is not discarded). Must be larger + * than {@link #getLargestReadTimestampUs()}. + */ + public final void discardUpstreamFrom(long timeUs) { + if (length == 0) { + return; + } + checkArgument(timeUs > getLargestReadTimestampUs()); + int retainCount = countUnreadSamplesBefore(timeUs); + discardUpstreamSamples(absoluteFirstIndex + retainCount); + } + // Called by the consuming thread. /** Calls {@link #discardToEnd()} and releases any resources owned by the queue. */ @@ -258,6 +294,16 @@ public class SampleQueue implements TrackOutput { return largestQueuedTimestampUs; } + /** + * Returns the largest sample timestamp that has been read since the last {@link #reset}. + * + * @return The largest sample timestamp that has been read, or {@link Long#MIN_VALUE} if no + * samples have been read. + */ + public final synchronized long getLargestReadTimestampUs() { + return max(largestDiscardedTimestampUs, getLargestTimestamp(readPosition)); + } + /** * Returns whether the last sample of the stream has knowingly been queued. A return value of * {@code false} means that the last sample had not been queued or that it's unknown whether the @@ -307,13 +353,7 @@ public class SampleQueue implements TrackOutput { * Attempts to read from the queue. * *

      {@link Format Formats} read from this method may be associated to a {@link DrmSession} - * through {@link FormatHolder#drmSession}, which is populated in two scenarios: - * - *

        - *
      • The {@link Format} has a non-null {@link Format#drmInitData}. - *
      • The {@link DrmSessionManager} provides placeholder sessions for this queue's track type. - * See {@link DrmSessionManager#acquirePlaceholderSession(Looper, int)}. - *
      + * through {@link FormatHolder#drmSession}. * * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the @@ -325,8 +365,6 @@ public class SampleQueue implements TrackOutput { * it's not changing. A sample will never be read if set to true, however it is still possible * for the end of stream or nothing to be read. * @param loadingFinished True if an empty queue should be considered the end of the stream. - * @param decodeOnlyUntilUs If a buffer is read, the {@link C#BUFFER_FLAG_DECODE_ONLY} flag will - * be set if the buffer's timestamp is less than this value. * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or * {@link C#RESULT_BUFFER_READ}. */ @@ -335,11 +373,9 @@ public class SampleQueue implements TrackOutput { FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired, - boolean loadingFinished, - long decodeOnlyUntilUs) { + boolean loadingFinished) { int result = - readSampleMetadata( - formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilUs, extrasHolder); + readSampleMetadata(formatHolder, buffer, formatRequired, loadingFinished, extrasHolder); if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream() && !buffer.isFlagsOnly()) { sampleDataQueue.readToBuffer(buffer, extrasHolder); } @@ -357,6 +393,7 @@ public class SampleQueue implements TrackOutput { if (sampleIndex < absoluteFirstIndex || sampleIndex > absoluteFirstIndex + length) { return false; } + startTimeUs = Long.MIN_VALUE; readPosition = sampleIndex - absoluteFirstIndex; return true; } @@ -382,39 +419,45 @@ public class SampleQueue implements TrackOutput { if (offset == -1) { return false; } + startTimeUs = timeUs; readPosition += offset; return true; } /** - * Advances the read position to the keyframe before or at the specified time. + * Returns the number of samples that need to be {@link #skip(int) skipped} to advance the read + * position to the keyframe before or at the specified time. * * @param timeUs The time to advance to. - * @return The number of samples that were skipped, which may be equal to 0. + * @param allowEndOfQueue Whether the end of the queue is considered a keyframe when {@code + * timeUs} is larger than the largest queued timestamp. + * @return The number of samples that need to be skipped, which may be equal to 0. */ - public final synchronized int advanceTo(long timeUs) { + public final synchronized int getSkipCount(long timeUs, boolean allowEndOfQueue) { int relativeReadIndex = getRelativeIndex(readPosition); if (!hasNextSample() || timeUs < timesUs[relativeReadIndex]) { return 0; } + if (timeUs > largestQueuedTimestampUs && allowEndOfQueue) { + return length - readPosition; + } int offset = findSampleBefore(relativeReadIndex, length - readPosition, timeUs, /* keyframe= */ true); if (offset == -1) { return 0; } - readPosition += offset; return offset; } /** - * Advances the read position to the end of the queue. + * Advances the read position by the specified number of samples. * - * @return The number of samples that were skipped. + * @param count The number of samples to advance the read position by. Must be at least 0 and at + * most {@link #getWriteIndex()} - {@link #getReadIndex()}. */ - public final synchronized int advanceToEnd() { - int skipCount = length - readPosition; - readPosition = length; - return skipCount; + public final synchronized void skip(int count) { + checkArgument(count >= 0 && readPosition + count <= length); + readPosition += count; } /** @@ -503,13 +546,39 @@ public class SampleQueue implements TrackOutput { if (upstreamFormatAdjustmentRequired) { format(Assertions.checkStateNotNull(unadjustedUpstreamFormat)); } + + boolean isKeyframe = (flags & C.BUFFER_FLAG_KEY_FRAME) != 0; + if (upstreamKeyframeRequired) { + if (!isKeyframe) { + return; + } + upstreamKeyframeRequired = false; + } + timeUs += sampleOffsetUs; + if (upstreamAllSamplesAreSyncSamples) { + if (timeUs < startTimeUs) { + // If we know that all samples are sync samples, we can discard those that come before the + // start time on the write side of the queue. + return; + } + if ((flags & C.BUFFER_FLAG_KEY_FRAME) == 0) { + // The flag should always be set unless the source content has incorrect sample metadata. + // Log a warning (once per format change, to avoid log spam) and override the flag. + if (!loggedUnexpectedNonSyncSample) { + Log.w(TAG, "Overriding unexpected non-sync sample for format: " + upstreamFormat); + loggedUnexpectedNonSyncSample = true; + } + flags |= C.BUFFER_FLAG_KEY_FRAME; + } + } if (pendingSplice) { - if ((flags & C.BUFFER_FLAG_KEY_FRAME) == 0 || !attemptSplice(timeUs)) { + if (!isKeyframe || !attemptSplice(timeUs)) { return; } pendingSplice = false; } + long absoluteOffset = sampleDataQueue.getTotalBytesWritten() - size - offset; commitSample(timeUs, flags, absoluteOffset, size, cryptoData); } @@ -558,25 +627,9 @@ public class SampleQueue implements TrackOutput { DecoderInputBuffer buffer, boolean formatRequired, boolean loadingFinished, - long decodeOnlyUntilUs, SampleExtrasHolder extrasHolder) { buffer.waitingForKeys = false; - // This is a temporary fix for https://github.com/google/ExoPlayer/issues/6155. - // TODO: Remove it and replace it with a fix that discards samples when writing to the queue. - boolean hasNextSample; - int relativeReadIndex = C.INDEX_UNSET; - while ((hasNextSample = hasNextSample())) { - relativeReadIndex = getRelativeIndex(readPosition); - long timeUs = timesUs[relativeReadIndex]; - if (timeUs < decodeOnlyUntilUs - && MimeTypes.allSamplesAreSyncSamples(formats[relativeReadIndex].sampleMimeType)) { - readPosition++; - } else { - break; - } - } - - if (!hasNextSample) { + if (!hasNextSample()) { if (loadingFinished || isLastSampleQueued) { buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); return C.RESULT_BUFFER_READ; @@ -588,6 +641,7 @@ public class SampleQueue implements TrackOutput { } } + int relativeReadIndex = getRelativeIndex(readPosition); if (formatRequired || formats[relativeReadIndex] != downstreamFormat) { onFormatResult(formats[relativeReadIndex], formatHolder); return C.RESULT_FORMAT_READ; @@ -600,7 +654,7 @@ public class SampleQueue implements TrackOutput { buffer.setFlags(flags[relativeReadIndex]); buffer.timeUs = timesUs[relativeReadIndex]; - if (buffer.timeUs < decodeOnlyUntilUs) { + if (buffer.timeUs < startTimeUs) { buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); } if (buffer.isFlagsOnly()) { @@ -621,16 +675,19 @@ public class SampleQueue implements TrackOutput { // current upstreamFormat so we can detect format changes on the read side using cheap // referential quality. return false; - } else if (Util.areEqual(format, upstreamCommittedFormat)) { + } + if (Util.areEqual(format, upstreamCommittedFormat)) { // The format has changed back to the format of the last committed sample. If they are // different objects, we revert back to using upstreamCommittedFormat as the upstreamFormat // so we can detect format changes on the read side using cheap referential equality. upstreamFormat = upstreamCommittedFormat; - return true; } else { upstreamFormat = format; - return true; } + upstreamAllSamplesAreSyncSamples = + MimeTypes.allSamplesAreSyncSamples(upstreamFormat.sampleMimeType, upstreamFormat.codecs); + loggedUnexpectedNonSyncSample = false; + return true; } private synchronized long discardSampleMetadataTo( @@ -662,7 +719,7 @@ public class SampleQueue implements TrackOutput { private void releaseDrmSessionReferences() { if (currentDrmSession != null) { - currentDrmSession.release(eventDispatcher); + currentDrmSession.release(drmEventDispatcher); currentDrmSession = null; // Clear downstream format to avoid violating the assumption that downstreamFormat.drmInitData // != null implies currentSession != null @@ -676,16 +733,15 @@ public class SampleQueue implements TrackOutput { long offset, int size, @Nullable CryptoData cryptoData) { - if (upstreamKeyframeRequired) { - if ((sampleFlags & C.BUFFER_FLAG_KEY_FRAME) == 0) { - return; - } - upstreamKeyframeRequired = false; + if (length > 0) { + // Ensure sample data doesn't overlap. + int previousSampleRelativeIndex = getRelativeIndex(length - 1); + checkArgument( + offsets[previousSampleRelativeIndex] + sizes[previousSampleRelativeIndex] <= offset); } - Assertions.checkState(!upstreamFormatRequired); isLastSampleQueued = (sampleFlags & C.BUFFER_FLAG_LAST_SAMPLE) != 0; - largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); + largestQueuedTimestampUs = max(largestQueuedTimestampUs, timeUs); int relativeEndIndex = getRelativeIndex(length); timesUs[relativeEndIndex] = timeUs; @@ -747,29 +803,19 @@ public class SampleQueue implements TrackOutput { if (length == 0) { return timeUs > largestDiscardedTimestampUs; } - long largestReadTimestampUs = - Math.max(largestDiscardedTimestampUs, getLargestTimestamp(readPosition)); - if (largestReadTimestampUs >= timeUs) { + if (getLargestReadTimestampUs() >= timeUs) { return false; } - int retainCount = length; - int relativeSampleIndex = getRelativeIndex(length - 1); - while (retainCount > readPosition && timesUs[relativeSampleIndex] >= timeUs) { - retainCount--; - relativeSampleIndex--; - if (relativeSampleIndex == -1) { - relativeSampleIndex = capacity - 1; - } - } + int retainCount = countUnreadSamplesBefore(timeUs); discardUpstreamSampleMetadata(absoluteFirstIndex + retainCount); return true; } private long discardUpstreamSampleMetadata(int discardFromIndex) { int discardCount = getWriteIndex() - discardFromIndex; - Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); + checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); length -= discardCount; - largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); + largestQueuedTimestampUs = max(largestDiscardedTimestampUs, getLargestTimestamp(length)); isLastSampleQueued = discardCount == 0 && isLastSampleQueued; if (length != 0) { int relativeLastWriteIndex = getRelativeIndex(length - 1); @@ -790,11 +836,13 @@ public class SampleQueue implements TrackOutput { * @param outputFormatHolder The output {@link FormatHolder}. */ private void onFormatResult(Format newFormat, FormatHolder outputFormatHolder) { - outputFormatHolder.format = newFormat; boolean isFirstFormat = downstreamFormat == null; @Nullable DrmInitData oldDrmInitData = isFirstFormat ? null : downstreamFormat.drmInitData; downstreamFormat = newFormat; @Nullable DrmInitData newDrmInitData = newFormat.drmInitData; + + outputFormatHolder.format = + newFormat.copyWithExoMediaCryptoType(drmSessionManager.getExoMediaCryptoType(newFormat)); outputFormatHolder.drmSession = currentDrmSession; if (!isFirstFormat && Util.areEqual(oldDrmInitData, newDrmInitData)) { // Nothing to do. @@ -804,14 +852,11 @@ public class SampleQueue implements TrackOutput { // is being used for both DrmInitData. @Nullable DrmSession previousSession = currentDrmSession; currentDrmSession = - newDrmInitData != null - ? drmSessionManager.acquireSession(playbackLooper, eventDispatcher, newDrmInitData) - : drmSessionManager.acquirePlaceholderSession( - playbackLooper, MimeTypes.getTrackType(newFormat.sampleMimeType)); + drmSessionManager.acquireSession(playbackLooper, drmEventDispatcher, newFormat); outputFormatHolder.drmSession = currentDrmSession; if (previousSession != null) { - previousSession.release(eventDispatcher); + previousSession.release(drmEventDispatcher); } } @@ -858,6 +903,26 @@ public class SampleQueue implements TrackOutput { return sampleCountToTarget; } + /** + * Counts the number of samples that haven't been read that have a timestamp smaller than {@code + * timeUs}. + * + * @param timeUs The specified time. + * @return The number of unread samples with a timestamp smaller than {@code timeUs}. + */ + private int countUnreadSamplesBefore(long timeUs) { + int count = length; + int relativeSampleIndex = getRelativeIndex(length - 1); + while (count > readPosition && timesUs[relativeSampleIndex] >= timeUs) { + count--; + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + return count; + } + /** * Discards the specified number of samples. * @@ -866,7 +931,7 @@ public class SampleQueue implements TrackOutput { */ private long discardSamples(int discardCount) { largestDiscardedTimestampUs = - Math.max(largestDiscardedTimestampUs, getLargestTimestamp(discardCount)); + max(largestDiscardedTimestampUs, getLargestTimestamp(discardCount)); length -= discardCount; absoluteFirstIndex += discardCount; relativeFirstIndex += discardCount; @@ -900,7 +965,7 @@ public class SampleQueue implements TrackOutput { long largestTimestampUs = Long.MIN_VALUE; int relativeSampleIndex = getRelativeIndex(length - 1); for (int i = 0; i < length; i++) { - largestTimestampUs = Math.max(largestTimestampUs, timesUs[relativeSampleIndex]); + largestTimestampUs = max(largestTimestampUs, timesUs[relativeSampleIndex]); if ((flags[relativeSampleIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { break; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java index 189c13ef0f..fb6af1136a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java @@ -66,8 +66,8 @@ public interface SequenceableLoader { /** * Re-evaluates the buffer given the playback position. * - *

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

      Re-evaluation may discard buffered media or cancel ongoing loads so that media can be + * re-buffered in a different quality. * * @param positionUs The current playback position in microseconds. If playback of this period has * not yet started, the value will be the starting position in this period minus the duration diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SilenceMediaSource.java index f4fb376248..26b783f970 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 @@ -15,10 +15,14 @@ */ package com.google.android.exoplayer2.source; +import static java.lang.Math.min; + +import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.trackselection.TrackSelection; @@ -40,7 +44,7 @@ public final class SilenceMediaSource extends BaseMediaSource { @Nullable private Object tag; /** - * Sets the duration of the silent audio. + * Sets the duration of the silent audio. The value needs to be a positive value. * * @param durationUs The duration of silent audio to output, in microseconds. * @return This factory, for convenience. @@ -53,7 +57,8 @@ public final class SilenceMediaSource extends BaseMediaSource { /** * Sets a tag for the media source which will be published in the {@link * com.google.android.exoplayer2.Timeline} of the source as {@link - * com.google.android.exoplayer2.Timeline.Window#tag}. + * com.google.android.exoplayer2.MediaItem.PlaybackProperties#tag + * Window#mediaItem.playbackProperties.tag}. * * @param tag A tag for the media source. * @return This factory, for convenience. @@ -63,12 +68,20 @@ public final class SilenceMediaSource extends BaseMediaSource { return this; } - /** Creates a new {@link SilenceMediaSource}. */ + /** + * Creates a new {@link SilenceMediaSource}. + * + * @throws IllegalStateException if the duration is a non-positive value. + */ public SilenceMediaSource createMediaSource() { - return new SilenceMediaSource(durationUs, tag); + Assertions.checkState(durationUs > 0); + return new SilenceMediaSource(durationUs, MEDIA_ITEM.buildUpon().setTag(tag).build()); } } + /** The media id used by any media item of silence media sources. */ + public static final String MEDIA_ID = "SilenceMediaSource"; + private static final int SAMPLE_RATE_HZ = 44100; @C.PcmEncoding private static final int PCM_ENCODING = C.ENCODING_PCM_16BIT; private static final int CHANNEL_COUNT = 2; @@ -79,11 +92,17 @@ public final class SilenceMediaSource extends BaseMediaSource { .setSampleRate(SAMPLE_RATE_HZ) .setPcmEncoding(PCM_ENCODING) .build(); + private static final MediaItem MEDIA_ITEM = + new MediaItem.Builder() + .setMediaId(MEDIA_ID) + .setUri(Uri.EMPTY) + .setMimeType(FORMAT.sampleMimeType) + .build(); private static final byte[] SILENCE_SAMPLE = new byte[Util.getPcmFrameSize(PCM_ENCODING, CHANNEL_COUNT) * 1024]; private final long durationUs; - @Nullable private final Object tag; + private final MediaItem mediaItem; /** * Creates a new media source providing silent audio of the given duration. @@ -91,13 +110,19 @@ 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); + this(durationUs, MEDIA_ITEM); } - private SilenceMediaSource(long durationUs, @Nullable Object tag) { + /** + * Creates a new media source providing silent audio of the given duration. + * + * @param durationUs The duration of silent audio to output, in microseconds. + * @param mediaItem The media item associated with this media source. + */ + private SilenceMediaSource(long durationUs, MediaItem mediaItem) { Assertions.checkArgument(durationUs >= 0); this.durationUs = durationUs; - this.tag = tag; + this.mediaItem = mediaItem; } @Override @@ -109,7 +134,7 @@ public final class SilenceMediaSource extends BaseMediaSource { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - tag)); + mediaItem)); } @Override @@ -123,6 +148,22 @@ public final class SilenceMediaSource extends BaseMediaSource { @Override public void releasePeriod(MediaPeriod mediaPeriod) {} + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + @Nullable + @Override + public Object getTag() { + return Assertions.checkNotNull(mediaItem.playbackProperties).tag; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + @Override protected void releaseSourceInternal() {} @@ -264,7 +305,7 @@ public final class SilenceMediaSource extends BaseMediaSource { return C.RESULT_BUFFER_READ; } - int bytesToWrite = (int) Math.min(SILENCE_SAMPLE.length, bytesRemaining); + int bytesToWrite = (int) min(SILENCE_SAMPLE.length, bytesRemaining); buffer.ensureSpaceForWrite(bytesToWrite); buffer.data.put(SILENCE_SAMPLE, /* offset= */ 0, bytesToWrite); buffer.timeUs = getAudioPositionUs(positionBytes); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java index 5b47398dd5..54230a8b4f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SinglePeriodTimeline.java @@ -15,8 +15,12 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.util.Assertions; @@ -26,6 +30,11 @@ import com.google.android.exoplayer2.util.Assertions; public final class SinglePeriodTimeline extends Timeline { private static final Object UID = new Object(); + private static final MediaItem MEDIA_ITEM = + new MediaItem.Builder() + .setMediaId("com.google.android.exoplayer2.source.SinglePeriodTimeline") + .setUri(Uri.EMPTY) + .build(); private final long presentationStartTimeMs; private final long windowStartTimeMs; @@ -37,32 +46,16 @@ public final class SinglePeriodTimeline extends Timeline { private final boolean isSeekable; private final boolean isDynamic; private final boolean isLive; - @Nullable private final Object tag; @Nullable private final Object manifest; + @Nullable private final MediaItem mediaItem; /** - * Creates a timeline containing a single period and a window that spans it. - * - * @param durationUs The duration of the period, in microseconds. - * @param isSeekable Whether seeking is supported within the period. - * @param isDynamic Whether the window may change when the timeline is updated. - * @param isLive Whether the window is live. - */ - public SinglePeriodTimeline( - long durationUs, boolean isSeekable, boolean isDynamic, boolean isLive) { - this(durationUs, isSeekable, isDynamic, isLive, /* manifest= */ null, /* tag= */ null); - } - - /** - * Creates a timeline containing a single period and a window that spans it. - * - * @param durationUs The duration of the period, in microseconds. - * @param isSeekable Whether seeking is supported within the period. - * @param isDynamic Whether the window may change when the timeline is updated. - * @param isLive Whether the window is live. - * @param manifest The manifest. May be {@code null}. - * @param tag A tag used for {@link Window#tag}. + * @deprecated Use {@link #SinglePeriodTimeline(long, boolean, boolean, boolean, Object, + * MediaItem)} instead. */ + // Provide backwards compatibility. + @SuppressWarnings("deprecation") + @Deprecated public SinglePeriodTimeline( long durationUs, boolean isSeekable, @@ -83,21 +76,41 @@ public final class SinglePeriodTimeline extends Timeline { } /** - * Creates a timeline with one period, and a window of known duration starting at a specified - * position in the period. + * Creates a timeline containing a single period and a window that spans it. * - * @param periodDurationUs The duration of the period in microseconds. - * @param windowDurationUs The duration of the window in microseconds. - * @param windowPositionInPeriodUs The position of the start of the window in the period, in - * microseconds. - * @param windowDefaultStartPositionUs The default position relative to the start of the window at - * which to begin playback, in microseconds. - * @param isSeekable Whether seeking is supported within the window. + * @param durationUs The duration of the period, in microseconds. + * @param isSeekable Whether seeking is supported within the period. * @param isDynamic Whether the window may change when the timeline is updated. * @param isLive Whether the window is live. - * @param manifest The manifest. May be (@code null}. - * @param tag A tag used for {@link Timeline.Window#tag}. + * @param manifest The manifest. May be {@code null}. + * @param mediaItem A media item used for {@link Window#mediaItem}. */ + public SinglePeriodTimeline( + long durationUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + MediaItem mediaItem) { + this( + durationUs, + durationUs, + /* windowPositionInPeriodUs= */ 0, + /* windowDefaultStartPositionUs= */ 0, + isSeekable, + isDynamic, + isLive, + manifest, + mediaItem); + } + + /** + * @deprecated Use {@link #SinglePeriodTimeline(long, long, long, long, boolean, boolean, boolean, + * Object, MediaItem)} instead. + */ + // Provide backwards compatibility. + @SuppressWarnings("deprecation") + @Deprecated public SinglePeriodTimeline( long periodDurationUs, long windowDurationUs, @@ -123,6 +136,80 @@ public final class SinglePeriodTimeline extends Timeline { tag); } + /** + * Creates a timeline with one period, and a window of known duration starting at a specified + * position in the period. + * + * @param periodDurationUs The duration of the period in microseconds. + * @param windowDurationUs The duration of the window in microseconds. + * @param windowPositionInPeriodUs The position of the start of the window in the period, in + * microseconds. + * @param windowDefaultStartPositionUs The default position relative to the start of the window at + * which to begin playback, in microseconds. + * @param isSeekable Whether seeking is supported within the window. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + * @param manifest The manifest. May be (@code null}. + * @param mediaItem A media item used for {@link Timeline.Window#mediaItem}. + */ + public SinglePeriodTimeline( + long periodDurationUs, + long windowDurationUs, + long windowPositionInPeriodUs, + long windowDefaultStartPositionUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + MediaItem mediaItem) { + this( + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, + periodDurationUs, + windowDurationUs, + windowPositionInPeriodUs, + windowDefaultStartPositionUs, + isSeekable, + isDynamic, + isLive, + manifest, + mediaItem); + } + + /** + * @deprecated Use {@link #SinglePeriodTimeline(long, long, long, long, long, long, long, boolean, + * boolean, boolean, Object, MediaItem)} instead. + */ + @Deprecated + public SinglePeriodTimeline( + long presentationStartTimeMs, + long windowStartTimeMs, + long elapsedRealtimeEpochOffsetMs, + long periodDurationUs, + long windowDurationUs, + long windowPositionInPeriodUs, + long windowDefaultStartPositionUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this( + presentationStartTimeMs, + windowStartTimeMs, + elapsedRealtimeEpochOffsetMs, + periodDurationUs, + windowDurationUs, + windowPositionInPeriodUs, + windowDefaultStartPositionUs, + isSeekable, + isDynamic, + isLive, + manifest, + MEDIA_ITEM.buildUpon().setTag(tag).build()); + } + /** * Creates a timeline with one period, and a window of known duration starting at a specified * position in the period. @@ -144,7 +231,7 @@ public final class SinglePeriodTimeline extends Timeline { * @param isDynamic Whether the window may change when the timeline is updated. * @param isLive Whether the window is live. * @param manifest The manifest. May be {@code null}. - * @param tag A tag used for {@link Timeline.Window#tag}. + * @param mediaItem A media item used for {@link Timeline.Window#mediaItem}. */ public SinglePeriodTimeline( long presentationStartTimeMs, @@ -158,7 +245,7 @@ public final class SinglePeriodTimeline extends Timeline { boolean isDynamic, boolean isLive, @Nullable Object manifest, - @Nullable Object tag) { + MediaItem mediaItem) { this.presentationStartTimeMs = presentationStartTimeMs; this.windowStartTimeMs = windowStartTimeMs; this.elapsedRealtimeEpochOffsetMs = elapsedRealtimeEpochOffsetMs; @@ -170,7 +257,7 @@ public final class SinglePeriodTimeline extends Timeline { this.isDynamic = isDynamic; this.isLive = isLive; this.manifest = manifest; - this.tag = tag; + this.mediaItem = checkNotNull(mediaItem); } @Override @@ -178,6 +265,7 @@ public final class SinglePeriodTimeline extends Timeline { return 1; } + // Provide backwards compatibility. @Override public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { Assertions.checkIndex(windowIndex, 0, 1); @@ -196,7 +284,7 @@ public final class SinglePeriodTimeline extends Timeline { } return window.set( Window.SINGLE_WINDOW_UID, - tag, + mediaItem, manifest, presentationStartTimeMs, windowStartTimeMs, @@ -219,7 +307,7 @@ public final class SinglePeriodTimeline extends Timeline { @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { Assertions.checkIndex(periodIndex, 0, 1); - Object uid = setIds ? UID : null; + @Nullable Object uid = setIds ? UID : null; return period.set(/* id= */ null, uid, 0, periodDurationUs, -windowPositionInPeriodUs); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java index 34d1bbd86c..352785d37d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java @@ -66,7 +66,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /* package */ final Format format; /* package */ final boolean treatLoadErrorsAsEndOfStream; - /* package */ boolean notifiedReadingStarted; /* package */ boolean loadingFinished; /* package */ byte @MonotonicNonNull [] sampleData; /* package */ int sampleSize; @@ -91,12 +90,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; tracks = new TrackGroupArray(new TrackGroup(format)); sampleStreams = new ArrayList<>(); loader = new Loader("Loader:SingleSampleMediaPeriod"); - eventDispatcher.mediaPeriodCreated(); } public void release() { loader.release(); - eventDispatcher.mediaPeriodReleased(); } @Override @@ -180,10 +177,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java index 4365c8fda5..ab63ed83e6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -15,10 +15,15 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + import android.net.Uri; import android.os.Handler; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; @@ -26,8 +31,8 @@ import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Assertions; import java.io.IOException; +import java.util.Collections; /** * Loads data at a given {@link Uri} as a single sample belonging to a single {@link MediaPeriod}. @@ -60,6 +65,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private boolean treatLoadErrorsAsEndOfStream; @Nullable private Object tag; + @Nullable private String trackId; /** * Creates a factory for {@link SingleSampleMediaSource}s. @@ -68,23 +74,34 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * be obtained. */ public Factory(DataSource.Factory dataSourceFactory) { - this.dataSourceFactory = Assertions.checkNotNull(dataSourceFactory); + this.dataSourceFactory = checkNotNull(dataSourceFactory); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); } /** * Sets a tag for the media source which will be published in the {@link Timeline} of the source - * as {@link Timeline.Window#tag}. + * as {@link com.google.android.exoplayer2.MediaItem.PlaybackProperties#tag + * Window#mediaItem.playbackProperties.tag}. * * @param tag A tag for the media source. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. */ public Factory setTag(@Nullable Object tag) { this.tag = tag; return this; } + /** + * Sets an optional track id to be used. + * + * @param trackId An optional track id. + * @return This factory, for convenience. + */ + public Factory setTrackId(@Nullable String trackId) { + this.trackId = trackId; + return this; + } + /** * Sets the minimum number of times to retry if a loading error occurs. See {@link * #setLoadErrorHandlingPolicy} for the default value. @@ -95,7 +112,6 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. */ @Deprecated @@ -111,7 +127,6 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. */ public Factory setLoadErrorHandlingPolicy( @Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { @@ -130,7 +145,6 @@ public final class SingleSampleMediaSource extends BaseMediaSource { * streams, treating them as ended instead. If false, load errors will be propagated * normally by {@link SampleStream#maybeThrowError()}. * @return This factory, for convenience. - * @throws IllegalStateException If one of the {@code create} methods has already been called. */ public Factory setTreatLoadErrorsAsEndOfStream(boolean treatLoadErrorsAsEndOfStream) { this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; @@ -140,40 +154,34 @@ public final class SingleSampleMediaSource extends BaseMediaSource { /** * Returns a new {@link SingleSampleMediaSource} using the current parameters. * - * @param uri The {@link Uri}. - * @param format The {@link Format} of the media stream. + * @param subtitle The {@link MediaItem.Subtitle}. * @param durationUs The duration of the media stream in microseconds. * @return The new {@link SingleSampleMediaSource}. */ - public SingleSampleMediaSource createMediaSource(Uri uri, Format format, long durationUs) { + public SingleSampleMediaSource createMediaSource(MediaItem.Subtitle subtitle, long durationUs) { return new SingleSampleMediaSource( - uri, + trackId, + subtitle, dataSourceFactory, - format, durationUs, loadErrorHandlingPolicy, treatLoadErrorsAsEndOfStream, tag); } - /** - * @deprecated Use {@link #createMediaSource(Uri, Format, long)} and {@link - * #addEventListener(Handler, MediaSourceEventListener)} instead. - */ + /** @deprecated Use {@link #createMediaSource(MediaItem.Subtitle, long)} instead. */ @Deprecated - public SingleSampleMediaSource createMediaSource( - Uri uri, - Format format, - long durationUs, - @Nullable Handler eventHandler, - @Nullable MediaSourceEventListener eventListener) { - SingleSampleMediaSource mediaSource = createMediaSource(uri, format, durationUs); - if (eventHandler != null && eventListener != null) { - mediaSource.addEventListener(eventHandler, eventListener); - } - return mediaSource; + public SingleSampleMediaSource createMediaSource(Uri uri, Format format, long durationUs) { + return new SingleSampleMediaSource( + format.id == null ? trackId : format.id, + new MediaItem.Subtitle( + uri, checkNotNull(format.sampleMimeType), format.language, format.selectionFlags), + dataSourceFactory, + durationUs, + loadErrorHandlingPolicy, + treatLoadErrorsAsEndOfStream, + tag); } - } private final DataSpec dataSpec; @@ -183,18 +191,11 @@ public final class SingleSampleMediaSource extends BaseMediaSource { private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final boolean treatLoadErrorsAsEndOfStream; private final Timeline timeline; - @Nullable private final Object tag; + private final MediaItem mediaItem; @Nullable private TransferListener transferListener; - /** - * @param uri The {@link Uri} of the media stream. - * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will - * be obtained. - * @param format The {@link Format} associated with the output track. - * @param durationUs The duration of the media stream in microseconds. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public SingleSampleMediaSource( @@ -207,15 +208,8 @@ public final class SingleSampleMediaSource extends BaseMediaSource { DefaultLoadErrorHandlingPolicy.DEFAULT_MIN_LOADABLE_RETRY_COUNT); } - /** - * @param uri The {@link Uri} of the media stream. - * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will - * be obtained. - * @param format The {@link Format} associated with the output track. - * @param durationUs The duration of the media stream in microseconds. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ + @SuppressWarnings("deprecation") @Deprecated public SingleSampleMediaSource( Uri uri, @@ -228,28 +222,16 @@ public final class SingleSampleMediaSource extends BaseMediaSource { dataSourceFactory, format, durationUs, - new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), - /* treatLoadErrorsAsEndOfStream= */ false, - /* tag= */ null); + minLoadableRetryCount, + /* eventHandler= */ null, + /* eventListener= */ null, + /* ignored */ C.INDEX_UNSET, + /* treatLoadErrorsAsEndOfStream= */ false); } - /** - * @param uri The {@link Uri} of the media stream. - * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will - * be obtained. - * @param format The {@link Format} associated with the output track. - * @param durationUs The duration of the media stream in microseconds. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @param eventSourceId An identifier that gets passed to {@code eventListener} methods. - * @param treatLoadErrorsAsEndOfStream If true, load errors will not be propagated by sample - * streams, treating them as ended instead. If false, load errors will be propagated normally - * by {@link SampleStream#maybeThrowError()}. - * @deprecated Use {@link Factory} instead. - */ - @Deprecated + /** @deprecated Use {@link Factory} instead. */ @SuppressWarnings("deprecation") + @Deprecated public SingleSampleMediaSource( Uri uri, DataSource.Factory dataSourceFactory, @@ -261,9 +243,10 @@ public final class SingleSampleMediaSource extends BaseMediaSource { int eventSourceId, boolean treatLoadErrorsAsEndOfStream) { this( - uri, + /* trackId= */ null, + new MediaItem.Subtitle( + uri, checkNotNull(format.sampleMimeType), format.language, format.selectionFlags), dataSourceFactory, - format, durationUs, new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), treatLoadErrorsAsEndOfStream, @@ -274,20 +257,33 @@ public final class SingleSampleMediaSource extends BaseMediaSource { } private SingleSampleMediaSource( - Uri uri, + @Nullable String trackId, + MediaItem.Subtitle subtitle, DataSource.Factory dataSourceFactory, - Format format, long durationUs, LoadErrorHandlingPolicy loadErrorHandlingPolicy, boolean treatLoadErrorsAsEndOfStream, @Nullable Object tag) { this.dataSourceFactory = dataSourceFactory; - this.format = format; this.durationUs = durationUs; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; - this.tag = tag; - dataSpec = new DataSpec.Builder().setUri(uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); + mediaItem = + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setMediaId(subtitle.uri.toString()) + .setSubtitles(Collections.singletonList(subtitle)) + .setTag(tag) + .build(); + format = + new Format.Builder() + .setId(trackId) + .setSampleMimeType(subtitle.mimeType) + .setLanguage(subtitle.language) + .setSelectionFlags(subtitle.selectionFlags) + .build(); + dataSpec = + new DataSpec.Builder().setUri(subtitle.uri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); timeline = new SinglePeriodTimeline( durationUs, @@ -295,15 +291,25 @@ public final class SingleSampleMediaSource extends BaseMediaSource { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - tag); + mediaItem); } // MediaSource implementation. + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { - return tag; + return castNonNull(mediaItem.playbackProperties).tag; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -352,7 +358,7 @@ public final class SingleSampleMediaSource extends BaseMediaSource { private final int eventSourceId; public EventListenerWrapper(EventListener eventListener, int eventSourceId) { - this.eventListener = Assertions.checkNotNull(eventListener); + this.eventListener = checkNotNull(eventListener); this.eventSourceId = eventSourceId; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdPlaybackState.java index dee63d819e..9493746669 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.ads; +import static java.lang.Math.max; + import android.net.Uri; import androidx.annotation.CheckResult; import androidx.annotation.IntDef; @@ -124,13 +126,9 @@ public final class AdPlaybackState { return result; } - /** - * Returns a new instance with the ad count set to {@code count}. This method may only be called - * if this instance's ad count has not yet been specified. - */ + /** Returns a new instance with the ad count set to {@code count}. */ @CheckResult public AdGroup withAdCount(int count) { - Assertions.checkArgument(this.count == C.LENGTH_UNSET && states.length <= count); @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, count); long[] durationsUs = copyDurationsUsWithSpaceForAdCount(this.durationsUs, count); @NullableType Uri[] uris = Arrays.copyOf(this.uris, count); @@ -139,17 +137,11 @@ public final class AdPlaybackState { /** * Returns a new instance with the specified {@code uri} set for the specified ad, and the ad - * marked as {@link #AD_STATE_AVAILABLE}. The specified ad must currently be in {@link - * #AD_STATE_UNAVAILABLE}, which is the default state. - * - *

      This instance's ad count may be unknown, in which case {@code index} must be less than the - * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + * marked as {@link #AD_STATE_AVAILABLE}. */ @CheckResult public AdGroup withAdUri(Uri uri, int index) { - Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); - Assertions.checkArgument(states[index] == AD_STATE_UNAVAILABLE); long[] durationsUs = this.durationsUs.length == states.length ? this.durationsUs @@ -223,7 +215,7 @@ public final class AdPlaybackState { @CheckResult private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) { int oldStateCount = states.length; - int newStateCount = Math.max(count, oldStateCount); + int newStateCount = max(count, oldStateCount); states = Arrays.copyOf(states, newStateCount); Arrays.fill(states, oldStateCount, newStateCount, AD_STATE_UNAVAILABLE); return states; @@ -232,7 +224,7 @@ public final class AdPlaybackState { @CheckResult private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int count) { int oldDurationsUsCount = durationsUs.length; - int newDurationsUsCount = Math.max(count, oldDurationsUsCount); + int newDurationsUsCount = max(count, oldDurationsUsCount); durationsUs = Arrays.copyOf(durationsUs, newDurationsUsCount); Arrays.fill(durationsUs, oldDurationsUsCount, newDurationsUsCount, C.TIME_UNSET); return durationsUs; @@ -280,7 +272,9 @@ public final class AdPlaybackState { public final AdGroup[] adGroups; /** The position offset in the first unplayed ad at which to begin playback, in microseconds. */ public final long adResumePositionUs; - /** The content duration in microseconds, if known. {@link C#TIME_UNSET} otherwise. */ + /** + * The duration of the content period in microseconds, if known. {@link C#TIME_UNSET} otherwise. + */ public final long contentDurationUs; /** @@ -360,6 +354,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. @@ -477,6 +483,54 @@ public final class AdPlaybackState { return result; } + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("AdPlaybackState(adResumePositionUs="); + sb.append(adResumePositionUs); + sb.append(", adGroups=["); + for (int i = 0; i < adGroups.length; i++) { + sb.append("adGroup(timeUs="); + sb.append(adGroupTimesUs[i]); + sb.append(", ads=["); + for (int j = 0; j < adGroups[i].states.length; j++) { + sb.append("ad(state="); + switch (adGroups[i].states[j]) { + case AD_STATE_UNAVAILABLE: + sb.append('_'); + break; + case AD_STATE_ERROR: + sb.append('!'); + break; + case AD_STATE_AVAILABLE: + sb.append('R'); + break; + case AD_STATE_PLAYED: + sb.append('P'); + break; + case AD_STATE_SKIPPED: + sb.append('S'); + break; + default: + sb.append('?'); + break; + } + sb.append(", durationUs="); + sb.append(adGroups[i].durationsUs[j]); + sb.append(')'); + if (j < adGroups[i].states.length - 1) { + sb.append(", "); + } + } + sb.append("])"); + if (i < adGroups.length - 1) { + sb.append(", "); + } + } + sb.append("])"); + return sb.toString(); + } + private boolean isPositionBeforeAdGroup( long positionUs, long periodDurationUs, int adGroupIndex) { if (positionUs == C.TIME_END_OF_SOURCE) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java index 11947218a3..f1c17c1093 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsLoader.java @@ -17,12 +17,18 @@ package com.google.android.exoplayer2.source.ads; import android.view.View; import android.view.ViewGroup; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.common.collect.ImmutableList; import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; /** * Interface for loaders of ads, which can be used with {@link AdsMediaSource}. @@ -70,23 +76,92 @@ public interface AdsLoader { default void onAdTapped() {} } - /** Provides views for the ad UI. */ + /** Provides information about views for the ad playback UI. */ interface AdViewProvider { - /** Returns the {@link ViewGroup} on top of the player that will show any ad UI. */ + /** + * Returns the {@link ViewGroup} on top of the player that will show any ad UI, or {@code null} + * if playing audio-only ads. Any views on top of the returned view group must be described by + * {@link OverlayInfo OverlayInfos} returned by {@link #getAdOverlayInfos()}, for accurate + * viewability measurement. + */ + @Nullable ViewGroup getAdViewGroup(); + /** @deprecated Use {@link #getAdOverlayInfos()} instead. */ + @Deprecated + default View[] getAdOverlayViews() { + return new View[0]; + } + /** - * Returns an array of views that are shown on top of the ad view group, but that are essential - * for controlling playback and should be excluded from ad viewability measurements by the - * {@link AdsLoader} (if it supports this). + * Returns a list of {@link OverlayInfo} instances describing views that are on top of the ad + * view group, but that are essential for controlling playback and should be excluded from ad + * viewability measurements by the {@link AdsLoader} (if it supports this). * *

      Each view must be either a fully transparent overlay (for capturing touch events), or a * small piece of transient UI that is essential to the user experience of playback (such as a * button to pause/resume playback or a transient full-screen or cast button). For more * information see the documentation for your ads loader. */ - View[] getAdOverlayViews(); + @SuppressWarnings("deprecation") + default List getAdOverlayInfos() { + ImmutableList.Builder listBuilder = new ImmutableList.Builder<>(); + // Call through to deprecated version. + for (View view : getAdOverlayViews()) { + listBuilder.add(new OverlayInfo(view, OverlayInfo.PURPOSE_CONTROLS)); + } + return listBuilder.build(); + } + } + + /** Provides information about an overlay view shown on top of an ad view group. */ + final class OverlayInfo { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({PURPOSE_CONTROLS, PURPOSE_CLOSE_AD, PURPOSE_OTHER, PURPOSE_NOT_VISIBLE}) + public @interface Purpose {} + /** Purpose for playback controls overlaying the player. */ + public static final int PURPOSE_CONTROLS = 0; + /** Purpose for ad close buttons overlaying the player. */ + public static final int PURPOSE_CLOSE_AD = 1; + /** Purpose for other overlays. */ + public static final int PURPOSE_OTHER = 2; + /** Purpose for overlays that are not visible. */ + public static final int PURPOSE_NOT_VISIBLE = 3; + + /** The overlay view. */ + public final View view; + /** The purpose of the overlay view. */ + @Purpose public final int purpose; + /** An optional, detailed reason that the overlay view is needed. */ + @Nullable public final String reasonDetail; + + /** + * Creates a new overlay info. + * + * @param view The view that is overlaying the player. + * @param purpose The purpose of the view. + */ + public OverlayInfo(View view, @Purpose int purpose) { + this(view, purpose, /* detailedReason= */ null); + } + + /** + * Creates a new overlay info. + * + * @param view The view that is overlaying the player. + * @param purpose The purpose of the view. + * @param detailedReason An optional, detailed reason that the view is on top of the player. See + * the documentation for the {@link AdsLoader} implementation for more information on this + * string's formatting. + */ + public OverlayInfo(View view, @Purpose int purpose, @Nullable String detailedReason) { + this.view = view; + this.purpose = purpose; + this.reasonDetail = detailedReason; + } } // Methods called by the application. @@ -137,6 +212,15 @@ public interface AdsLoader { */ void stop(); + /** + * Notifies the ads loader that preparation of an ad media period is complete. Called on the main + * thread by {@link AdsMediaSource}. + * + * @param adGroupIndex The index of the ad group. + * @param adIndexInAdGroup The index of the ad in the ad group. + */ + void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup); + /** * Notifies the ads loader that the player was not able to prepare media for a given ad. * Implementations should update the ad playback state as the specified ad has failed to load. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java index 07a46f06a9..62c3e2ed17 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -22,6 +22,7 @@ import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.LoadEventInfo; @@ -120,7 +121,7 @@ public final class AdsMediaSource extends CompositeMediaSource { } // Used to identify the content "child" source for CompositeMediaSource. - private static final MediaPeriodId DUMMY_CONTENT_MEDIA_PERIOD_ID = + private static final MediaPeriodId CHILD_SOURCE_MEDIA_PERIOD_ID = new MediaPeriodId(/* periodUid= */ new Object()); private final MediaSource contentMediaSource; @@ -181,18 +182,28 @@ public final class AdsMediaSource extends CompositeMediaSource { adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes()); } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { return contentMediaSource.getTag(); } + @Override + public MediaItem getMediaItem() { + return contentMediaSource.getMediaItem(); + } + @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); ComponentListener componentListener = new ComponentListener(); this.componentListener = componentListener; - prepareChildSource(DUMMY_CONTENT_MEDIA_PERIOD_ID, contentMediaSource); + prepareChildSource(CHILD_SOURCE_MEDIA_PERIOD_ID, contentMediaSource); mainHandler.post(() -> adsLoader.start(componentListener, adViewProvider)); } @@ -213,7 +224,8 @@ public final class AdsMediaSource extends CompositeMediaSource { AdMediaSourceHolder adMediaSourceHolder = adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]; if (adMediaSourceHolder == null) { - MediaSource adMediaSource = adMediaSourceFactory.createMediaSource(adUri); + MediaSource adMediaSource = + adMediaSourceFactory.createMediaSource(MediaItem.fromUri(adUri)); adMediaSourceHolder = new AdMediaSourceHolder(adMediaSource); adMediaSourceHolders[adGroupIndex][adIndexInAdGroup] = adMediaSourceHolder; prepareChildSource(id, adMediaSource); @@ -273,8 +285,8 @@ public final class AdsMediaSource extends CompositeMediaSource { @Override 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. + // The child id for the content period is just CHILD_SOURCE_MEDIA_PERIOD_ID. That's why + // we need to forward the reported mediaPeriodId in this case. return childId.isAd() ? childId : mediaPeriodId; } @@ -325,7 +337,7 @@ public final class AdsMediaSource extends CompositeMediaSource { * events on the external event listener thread. */ public ComponentListener() { - playerHandler = Util.createHandler(); + playerHandler = Util.createHandlerForCurrentLooper(); } /** Releases the component listener. */ @@ -365,20 +377,24 @@ public final class AdsMediaSource extends CompositeMediaSource { } } - private final class AdPrepareErrorListener implements MaskingMediaPeriod.PrepareErrorListener { + private final class AdPrepareListener implements MaskingMediaPeriod.PrepareListener { private final Uri adUri; - private final int adGroupIndex; - private final int adIndexInAdGroup; - public AdPrepareErrorListener(Uri adUri, int adGroupIndex, int adIndexInAdGroup) { + public AdPrepareListener(Uri adUri) { this.adUri = adUri; - this.adGroupIndex = adGroupIndex; - this.adIndexInAdGroup = adIndexInAdGroup; } @Override - public void onPrepareError(MediaPeriodId mediaPeriodId, final IOException exception) { + public void onPrepareComplete(MediaPeriodId mediaPeriodId) { + mainHandler.post( + () -> + adsLoader.handlePrepareComplete( + mediaPeriodId.adGroupIndex, mediaPeriodId.adIndexInAdGroup)); + } + + @Override + public void onPrepareError(MediaPeriodId mediaPeriodId, IOException exception) { createEventDispatcher(mediaPeriodId) .loadError( new LoadEventInfo( @@ -389,7 +405,9 @@ public final class AdsMediaSource extends CompositeMediaSource { AdLoadException.createForAd(exception), /* wasCanceled= */ true); mainHandler.post( - () -> adsLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception)); + () -> + adsLoader.handlePrepareError( + mediaPeriodId.adGroupIndex, mediaPeriodId.adIndexInAdGroup, exception)); } } @@ -409,8 +427,7 @@ public final class AdsMediaSource extends CompositeMediaSource { 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)); + maskingMediaPeriod.setPrepareListener(new AdPrepareListener(adUri)); activeMediaPeriods.add(maskingMediaPeriod); if (timeline != null) { Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java index b5167dc173..cc82510a29 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java @@ -44,23 +44,16 @@ public final class SinglePeriodAdTimeline extends ForwardingTimeline { @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { timeline.getPeriod(periodIndex, period, setIds); + long durationUs = + period.durationUs == C.TIME_UNSET ? adPlaybackState.contentDurationUs : period.durationUs; period.set( period.id, period.uid, period.windowIndex, - period.durationUs, + durationUs, period.getPositionInWindowUs(), adPlaybackState); return period; } - @Override - public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { - window = super.getWindow(windowIndex, window, defaultPositionProjectionUs); - if (window.durationUs == C.TIME_UNSET) { - window.durationUs = adPlaybackState.contentDurationUs; - } - return window; - } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java index 50c37f8b31..961d1f8db6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java @@ -18,7 +18,7 @@ package com.google.android.exoplayer2.source.chunk; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.source.SampleQueue; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.util.Log; /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java similarity index 72% rename from library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java rename to library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java index f2362f2eb1..f02329d5d5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/BundledChunkExtractor.java @@ -21,9 +21,12 @@ import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.upstream.DataReader; @@ -33,34 +36,14 @@ import java.io.IOException; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** - * An {@link Extractor} wrapper for loading chunks that contain a single primary track, and possibly - * additional embedded tracks. - *

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

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

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

      The same {@link TrackOutput} is returned if multiple calls are made with the same {@code + * id}. + * + * @param id A track identifier. + * @param type The type of the track. Typically one of the {@link C} {@code TRACK_TYPE_*} + * constants. + * @return The {@link TrackOutput} for the given track identifier. + */ + TrackOutput track(int id, int type); + } + + /** + * Returns the {@link ChunkIndex} most recently obtained from the chunks, or null if a {@link + * ChunkIndex} has not been obtained. + */ + @Nullable + ChunkIndex getChunkIndex(); + + /** + * Returns the sample {@link Format}s for the tracks identified by the extractor, or null if the + * extractor has not finished identifying tracks. + */ + @Nullable + Format[] getSampleFormats(); + + /** + * Initializes the wrapper to output to {@link TrackOutput}s provided by the specified {@link + * TrackOutputProvider}, and configures the extractor to receive data from a new chunk. + * + * @param trackOutputProvider The provider of {@link TrackOutput}s that will receive sample data. + * @param startTimeUs The start position in the new chunk, or {@link C#TIME_UNSET} to output + * samples from the start of the chunk. + * @param endTimeUs The end position in the new chunk, or {@link C#TIME_UNSET} to output samples + * to the end of the chunk. + */ + void init(@Nullable TrackOutputProvider trackOutputProvider, long startTimeUs, long endTimeUs); + + /** Releases any held resources. */ + void release(); + + /** + * Reads from the given {@link ExtractorInput}. + * + * @param input The input to read from. + * @return Whether there is any data left to extract. Returns false if the end of input has been + * reached. + * @throws IOException If an error occurred reading from or parsing the input. + */ + boolean read(ExtractorInput input) throws IOException; +} 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 c9a552a7cf..1a451cb0c3 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,10 @@ */ package com.google.android.exoplayer2.source.chunk; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -23,10 +27,11 @@ import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaLoadData; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.SampleQueue; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.SequenceableLoader; @@ -71,7 +76,7 @@ public class ChunkSampleStream implements SampleStream, S private final boolean[] embeddedTracksSelected; private final T chunkSource; private final SequenceableLoader.Callback> callback; - private final EventDispatcher eventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final Loader loader; private final ChunkHolder nextChunkHolder; @@ -81,13 +86,14 @@ public class ChunkSampleStream implements SampleStream, S private final SampleQueue[] embeddedSampleQueues; private final BaseMediaChunkOutput chunkOutput; + @Nullable private Chunk loadingChunk; private @MonotonicNonNull Format primaryDownstreamTrackFormat; @Nullable private ReleaseCallback releaseCallback; private long pendingResetPositionUs; private long lastSeekPositionUs; private int nextNotifyPrimaryFormatMediaChunkIndex; + @Nullable private BaseMediaChunk canceledMediaChunk; - /* package */ long decodeOnlyUntilPositionUs; /* package */ boolean loadingFinished; /** @@ -103,8 +109,10 @@ public class ChunkSampleStream implements SampleStream, S * @param positionUs The position from which to start loading media. * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} * from. + * @param drmEventDispatcher A dispatcher to notify of {@link DrmSessionEventListener} events. * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. - * @param eventDispatcher A dispatcher to notify of events. + * @param mediaSourceEventDispatcher A dispatcher to notify of {@link MediaSourceEventListener} + * events. */ public ChunkSampleStream( int primaryTrackType, @@ -115,14 +123,15 @@ public class ChunkSampleStream implements SampleStream, S Allocator allocator, long positionUs, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - EventDispatcher eventDispatcher) { + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher) { this.primaryTrackType = primaryTrackType; this.embeddedTrackTypes = embeddedTrackTypes == null ? new int[0] : embeddedTrackTypes; this.embeddedTrackFormats = embeddedTrackFormats == null ? new Format[0] : embeddedTrackFormats; this.chunkSource = chunkSource; this.callback = callback; - this.eventDispatcher = eventDispatcher; + this.mediaSourceEventDispatcher = mediaSourceEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; loader = new Loader("Loader:ChunkSampleStream"); nextChunkHolder = new ChunkHolder(); @@ -138,9 +147,9 @@ public class ChunkSampleStream implements SampleStream, S primarySampleQueue = new SampleQueue( allocator, - /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* playbackLooper= */ checkNotNull(Looper.myLooper()), drmSessionManager, - eventDispatcher); + drmEventDispatcher); trackTypes[0] = primaryTrackType; sampleQueues[0] = primarySampleQueue; @@ -148,9 +157,9 @@ public class ChunkSampleStream implements SampleStream, S SampleQueue sampleQueue = new SampleQueue( allocator, - /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* playbackLooper= */ checkNotNull(Looper.myLooper()), DrmSessionManager.getDummyDrmSessionManager(), - eventDispatcher); + drmEventDispatcher); embeddedSampleQueues[i] = sampleQueue; sampleQueues[i + 1] = sampleQueue; trackTypes[i + 1] = this.embeddedTrackTypes[i]; @@ -232,9 +241,9 @@ public class ChunkSampleStream implements SampleStream, S BaseMediaChunk lastCompletedMediaChunk = lastMediaChunk.isLoadCompleted() ? lastMediaChunk : mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null; if (lastCompletedMediaChunk != null) { - bufferedPositionUs = Math.max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); + bufferedPositionUs = max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); } - return Math.max(bufferedPositionUs, primarySampleQueue.getLargestQueuedTimestampUs()); + return max(bufferedPositionUs, primarySampleQueue.getLargestQueuedTimestampUs()); } } @@ -282,14 +291,12 @@ public class ChunkSampleStream implements SampleStream, S if (seekToMediaChunk != null) { // When seeking to the start of a chunk we use the index of the first sample in the chunk // rather than the seek position. This ensures we seek to the keyframe at the start of the - // chunk even if the sample timestamps are slightly offset from the chunk start times. + // chunk even if its timestamp is slightly earlier than the advertised chunk start time. seekInsideBuffer = primarySampleQueue.seekTo(seekToMediaChunk.getFirstSampleIndex(0)); - decodeOnlyUntilPositionUs = 0; } else { seekInsideBuffer = primarySampleQueue.seekTo( positionUs, /* allowTimeBeyondBuffer= */ positionUs < getNextLoadPositionUs()); - decodeOnlyUntilPositionUs = lastSeekPositionUs; } if (seekInsideBuffer) { @@ -311,10 +318,7 @@ public class ChunkSampleStream implements SampleStream, S loader.cancelLoading(); } else { loader.clearFatalError(); - primarySampleQueue.reset(); - for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { - embeddedSampleQueue.reset(); - } + resetSampleQueues(); } } } @@ -354,6 +358,7 @@ public class ChunkSampleStream implements SampleStream, S for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { embeddedSampleQueue.release(); } + chunkSource.release(); if (releaseCallback != null) { releaseCallback.onSampleStreamReleased(this); } @@ -381,10 +386,16 @@ public class ChunkSampleStream implements SampleStream, S if (isPendingReset()) { return C.RESULT_NOTHING_READ; } + if (canceledMediaChunk != null + && canceledMediaChunk.getFirstSampleIndex(/* trackIndex= */ 0) + <= primarySampleQueue.getReadIndex()) { + // Don't read into chunk that's going to be discarded. + // TODO: Support splicing to allow this. See [internal b/161130873]. + return C.RESULT_NOTHING_READ; + } maybeNotifyPrimaryTrackFormatChanged(); - return primarySampleQueue.read( - formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilPositionUs); + return primarySampleQueue.read(formatHolder, buffer, formatRequired, loadingFinished); } @Override @@ -392,12 +403,16 @@ public class ChunkSampleStream implements SampleStream, S if (isPendingReset()) { return 0; } - int skipCount; - if (loadingFinished && positionUs > primarySampleQueue.getLargestQueuedTimestampUs()) { - skipCount = primarySampleQueue.advanceToEnd(); - } else { - skipCount = primarySampleQueue.advanceTo(positionUs); + int skipCount = primarySampleQueue.getSkipCount(positionUs, loadingFinished); + if (canceledMediaChunk != null) { + // Don't skip into chunk that's going to be discarded. + // TODO: Support splicing to allow this. See [internal b/161130873]. + int maxSkipCount = + canceledMediaChunk.getFirstSampleIndex(/* trackIndex= */ 0) + - primarySampleQueue.getReadIndex(); + skipCount = min(skipCount, maxSkipCount); } + primarySampleQueue.skip(skipCount); maybeNotifyPrimaryTrackFormatChanged(); return skipCount; } @@ -406,6 +421,7 @@ public class ChunkSampleStream implements SampleStream, S @Override public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + loadingChunk = null; chunkSource.onChunkLoadCompleted(loadable); LoadEventInfo loadEventInfo = new LoadEventInfo( @@ -417,7 +433,7 @@ public class ChunkSampleStream implements SampleStream, S loadDurationMs, loadable.bytesLoaded()); loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); - eventDispatcher.loadCompleted( + mediaSourceEventDispatcher.loadCompleted( loadEventInfo, loadable.type, primaryTrackType, @@ -432,6 +448,8 @@ public class ChunkSampleStream implements SampleStream, S @Override public void onLoadCanceled( Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) { + loadingChunk = null; + canceledMediaChunk = null; LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -442,7 +460,7 @@ public class ChunkSampleStream implements SampleStream, S loadDurationMs, loadable.bytesLoaded()); loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); - eventDispatcher.loadCanceled( + mediaSourceEventDispatcher.loadCanceled( loadEventInfo, loadable.type, primaryTrackType, @@ -452,9 +470,14 @@ public class ChunkSampleStream implements SampleStream, S loadable.startTimeUs, loadable.endTimeUs); if (!released) { - primarySampleQueue.reset(); - for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { - embeddedSampleQueue.reset(); + if (isPendingReset()) { + resetSampleQueues(); + } else if (isMediaChunk(loadable)) { + // TODO: Support splicing to keep data from canceled chunk. See [internal b/161130873]. + discardUpstreamMediaChunksFromIndex(mediaChunks.size() - 1); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } } callback.onContinueLoadingRequested(this); } @@ -493,12 +516,12 @@ public class ChunkSampleStream implements SampleStream, S LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); - long blacklistDurationMs = + long exclusionDurationMs = cancelable ? loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo) : C.TIME_UNSET; @Nullable LoadErrorAction loadErrorAction = null; - if (chunkSource.onChunkLoadError(loadable, cancelable, error, blacklistDurationMs)) { + if (chunkSource.onChunkLoadError(loadable, cancelable, error, exclusionDurationMs)) { if (cancelable) { loadErrorAction = Loader.DONT_RETRY; if (isMediaChunk) { @@ -523,7 +546,7 @@ public class ChunkSampleStream implements SampleStream, S } boolean canceled = !loadErrorAction.isRetry(); - eventDispatcher.loadError( + mediaSourceEventDispatcher.loadError( loadEventInfo, loadable.type, primaryTrackType, @@ -535,6 +558,7 @@ public class ChunkSampleStream implements SampleStream, S error, canceled); if (canceled) { + loadingChunk = null; loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); callback.onContinueLoadingRequested(this); } @@ -574,12 +598,20 @@ public class ChunkSampleStream implements SampleStream, S return false; } + loadingChunk = loadable; if (isMediaChunk(loadable)) { BaseMediaChunk mediaChunk = (BaseMediaChunk) loadable; if (pendingReset) { - boolean resetToMediaChunk = mediaChunk.startTimeUs == pendingResetPositionUs; - // Only enable setting of the decode only flag if we're not resetting to a chunk boundary. - decodeOnlyUntilPositionUs = resetToMediaChunk ? 0 : pendingResetPositionUs; + // Only set the queue start times if we're not seeking to a chunk boundary. If we are + // seeking to a chunk boundary then we want the queue to pass through all of the samples in + // the chunk. Doing this ensures we'll always output the keyframe at the start of the chunk, + // even if its timestamp is slightly earlier than the advertised chunk start time. + if (mediaChunk.startTimeUs != pendingResetPositionUs) { + primarySampleQueue.setStartTimeUs(pendingResetPositionUs); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.setStartTimeUs(pendingResetPositionUs); + } + } pendingResetPositionUs = C.TIME_UNSET; } mediaChunk.init(chunkOutput); @@ -590,7 +622,7 @@ public class ChunkSampleStream implements SampleStream, S long elapsedRealtimeMs = loader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); - eventDispatcher.loadStarted( + mediaSourceEventDispatcher.loadStarted( new LoadEventInfo(loadable.loadTaskId, loadable.dataSpec, elapsedRealtimeMs), loadable.type, primaryTrackType, @@ -618,24 +650,46 @@ public class ChunkSampleStream implements SampleStream, S @Override public void reevaluateBuffer(long positionUs) { - if (loader.isLoading() || loader.hasFatalError() || isPendingReset()) { + if (loader.hasFatalError() || isPendingReset()) { return; } + if (loader.isLoading()) { + Chunk loadingChunk = checkNotNull(this.loadingChunk); + if (isMediaChunk(loadingChunk) + && haveReadFromMediaChunk(/* mediaChunkIndex= */ mediaChunks.size() - 1)) { + // Can't cancel anymore because the renderers have read from this chunk. + return; + } + if (chunkSource.shouldCancelLoad(positionUs, loadingChunk, readOnlyMediaChunks)) { + loader.cancelLoading(); + if (isMediaChunk(loadingChunk)) { + canceledMediaChunk = (BaseMediaChunk) loadingChunk; + } + } + return; + } + + int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + if (preferredQueueSize < mediaChunks.size()) { + discardUpstream(preferredQueueSize); + } + } + + private void discardUpstream(int preferredQueueSize) { + Assertions.checkState(!loader.isLoading()); + int currentQueueSize = mediaChunks.size(); - int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); - if (currentQueueSize <= preferredQueueSize) { - return; - } - - int newQueueSize = currentQueueSize; + int newQueueSize = C.LENGTH_UNSET; for (int i = preferredQueueSize; i < currentQueueSize; i++) { if (!haveReadFromMediaChunk(i)) { + // TODO: Sparse tracks (e.g. ESMG) may prevent discarding in almost all cases because it + // means that most chunks have been read from already. See [internal b/161126666]. newQueueSize = i; break; } } - if (newQueueSize == currentQueueSize) { + if (newQueueSize == C.LENGTH_UNSET) { return; } @@ -645,15 +699,21 @@ public class ChunkSampleStream implements SampleStream, S pendingResetPositionUs = lastSeekPositionUs; } loadingFinished = false; - eventDispatcher.upstreamDiscarded(primaryTrackType, firstRemovedChunk.startTimeUs, endTimeUs); + mediaSourceEventDispatcher.upstreamDiscarded( + primaryTrackType, firstRemovedChunk.startTimeUs, endTimeUs); } - // Internal methods - private boolean isMediaChunk(Chunk chunk) { return chunk instanceof BaseMediaChunk; } + private void resetSampleQueues() { + primarySampleQueue.reset(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.reset(); + } + } + /** Returns whether samples have been read from media chunk at given index. */ private boolean haveReadFromMediaChunk(int mediaChunkIndex) { BaseMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); @@ -677,7 +737,7 @@ public class ChunkSampleStream implements SampleStream, S primarySampleIndexToMediaChunkIndex(discardToSampleIndex, /* minChunkIndex= */ 0); // Don't discard any chunks that we haven't reported the primary format change for yet. discardToMediaChunkIndex = - Math.min(discardToMediaChunkIndex, nextNotifyPrimaryFormatMediaChunkIndex); + min(discardToMediaChunkIndex, nextNotifyPrimaryFormatMediaChunkIndex); if (discardToMediaChunkIndex > 0) { Util.removeRange(mediaChunks, /* fromIndex= */ 0, /* toIndex= */ discardToMediaChunkIndex); nextNotifyPrimaryFormatMediaChunkIndex -= discardToMediaChunkIndex; @@ -698,8 +758,11 @@ public class ChunkSampleStream implements SampleStream, S BaseMediaChunk currentChunk = mediaChunks.get(mediaChunkReadIndex); Format trackFormat = currentChunk.trackFormat; if (!trackFormat.equals(primaryDownstreamTrackFormat)) { - eventDispatcher.downstreamFormatChanged(primaryTrackType, trackFormat, - currentChunk.trackSelectionReason, currentChunk.trackSelectionData, + mediaSourceEventDispatcher.downstreamFormatChanged( + primaryTrackType, + trackFormat, + currentChunk.trackSelectionReason, + currentChunk.trackSelectionData, currentChunk.startTimeUs); } primaryDownstreamTrackFormat = trackFormat; @@ -741,7 +804,7 @@ public class ChunkSampleStream implements SampleStream, S BaseMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); nextNotifyPrimaryFormatMediaChunkIndex = - Math.max(nextNotifyPrimaryFormatMediaChunkIndex, mediaChunks.size()); + max(nextNotifyPrimaryFormatMediaChunkIndex, mediaChunks.size()); primarySampleQueue.discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(0)); for (int i = 0; i < embeddedSampleQueues.length; i++) { embeddedSampleQueues[i].discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(i + 1)); @@ -777,12 +840,18 @@ public class ChunkSampleStream implements SampleStream, S if (isPendingReset()) { return 0; } - maybeNotifyDownstreamFormat(); - int skipCount; - if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { - skipCount = sampleQueue.advanceToEnd(); - } else { - skipCount = sampleQueue.advanceTo(positionUs); + int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished); + if (canceledMediaChunk != null) { + // Don't skip into chunk that's going to be discarded. + // TODO: Support splicing to allow this. See [internal b/161130873]. + int maxSkipCount = + canceledMediaChunk.getFirstSampleIndex(/* trackIndex= */ 1 + index) + - sampleQueue.getReadIndex(); + skipCount = min(skipCount, maxSkipCount); + } + sampleQueue.skip(skipCount); + if (skipCount > 0) { + maybeNotifyDownstreamFormat(); } return skipCount; } @@ -798,13 +867,15 @@ public class ChunkSampleStream implements SampleStream, S if (isPendingReset()) { return C.RESULT_NOTHING_READ; } + if (canceledMediaChunk != null + && canceledMediaChunk.getFirstSampleIndex(/* trackIndex= */ 1 + index) + <= sampleQueue.getReadIndex()) { + // Don't read into chunk that's going to be discarded. + // TODO: Support splicing to allow this. See [internal b/161130873]. + return C.RESULT_NOTHING_READ; + } maybeNotifyDownstreamFormat(); - return sampleQueue.read( - formatHolder, - buffer, - formatRequired, - loadingFinished, - decodeOnlyUntilPositionUs); + return sampleQueue.read(formatHolder, buffer, formatRequired, loadingFinished); } public void release() { @@ -814,7 +885,7 @@ public class ChunkSampleStream implements SampleStream, S private void maybeNotifyDownstreamFormat() { if (!notifiedDownstreamFormat) { - eventDispatcher.downstreamFormatChanged( + mediaSourceEventDispatcher.downstreamFormatChanged( embeddedTrackTypes[index], embeddedTrackFormats[index], C.SELECTION_REASON_UNKNOWN, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java index b119cad5b0..52756b378f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSource.java @@ -38,8 +38,6 @@ public interface ChunkSource { /** * If the source is currently having difficulty providing chunks, then this method throws the * underlying error. Otherwise does nothing. - *

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

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

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

      Will only be called if no {@link MediaChunk} in the queue is currently loading. + * + * @param playbackPositionUs The current playback position, in microseconds. * @param queue The queue of buffered {@link MediaChunk}s. * @return The preferred queue size. */ int getPreferredQueueSize(long playbackPositionUs, List queue); + /** + * Returns whether an ongoing load of a chunk should be canceled. + * + * @param playbackPositionUs The current playback position, in microseconds. + * @param loadingChunk The currently loading {@link Chunk}. + * @param queue The queue of buffered {@link MediaChunk MediaChunks}. + * @return Whether the ongoing load of {@code loadingChunk} should be canceled. + */ + boolean shouldCancelLoad( + long playbackPositionUs, Chunk loadingChunk, List queue); + /** * Returns the next chunk to load. * @@ -85,8 +96,6 @@ public interface ChunkSource { * Called when the {@link ChunkSampleStream} has finished loading a chunk obtained from this * source. * - *

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

      This method should only be called when the source is enabled. - * * @param chunk The chunk whose load encountered the error. * @param cancelable Whether the load can be canceled. * @param e The error. - * @param blacklistDurationMs The duration for which the associated track may be blacklisted, or - * {@link C#TIME_UNSET} if the track may not be blacklisted. + * @param exclusionDurationMs The duration for which the associated track may be excluded, or + * {@link C#TIME_UNSET} if the track may not be excluded. * @return Whether the load should be canceled so that a replacement chunk can be loaded instead. * Must be {@code false} if {@code cancelable} is {@code false}. If {@code true}, {@link * #getNextChunk(long, long, List, ChunkHolder)} will be called to obtain the replacement * chunk. */ - boolean onChunkLoadError(Chunk chunk, boolean cancelable, Exception e, long blacklistDurationMs); + boolean onChunkLoadError(Chunk chunk, boolean cancelable, Exception e, long exclusionDurationMs); + + /** Releases any held resources. */ + void release(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java index 1b43af2084..b8938deac4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -21,11 +21,9 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.PositionHolder; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -34,11 +32,9 @@ import java.io.IOException; */ public class ContainerMediaChunk extends BaseMediaChunk { - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); - private final int chunkCount; private final long sampleOffsetUs; - private final ChunkExtractorWrapper extractorWrapper; + private final ChunkExtractor chunkExtractor; private long nextLoadPosition; private volatile boolean loadCanceled; @@ -61,7 +57,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { * instance. Normally equal to one, but may be larger if multiple chunks as defined by the * underlying media are being merged into a single load. * @param sampleOffsetUs An offset to add to the sample timestamps parsed by the extractor. - * @param extractorWrapper A wrapped extractor to use for parsing the data. + * @param chunkExtractor A wrapped extractor to use for parsing the data. */ public ContainerMediaChunk( DataSource dataSource, @@ -76,7 +72,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { long chunkIndex, int chunkCount, long sampleOffsetUs, - ChunkExtractorWrapper extractorWrapper) { + ChunkExtractor chunkExtractor) { super( dataSource, dataSpec, @@ -90,7 +86,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { chunkIndex); this.chunkCount = chunkCount; this.sampleOffsetUs = sampleOffsetUs; - this.extractorWrapper = extractorWrapper; + this.chunkExtractor = chunkExtractor; } @Override @@ -117,7 +113,7 @@ public class ContainerMediaChunk extends BaseMediaChunk { // Configure the output and set it as the target for the extractor wrapper. BaseMediaChunkOutput output = getOutput(); output.setSampleOffsetUs(sampleOffsetUs); - extractorWrapper.init( + chunkExtractor.init( getTrackOutputProvider(output), clippedStartTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedStartTimeUs - sampleOffsetUs), clippedEndTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedEndTimeUs - sampleOffsetUs)); @@ -130,19 +126,14 @@ public class ContainerMediaChunk extends BaseMediaChunk { dataSource, loadDataSpec.position, dataSource.open(loadDataSpec)); // Load and decode the sample data. try { - Extractor extractor = extractorWrapper.extractor; - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, DUMMY_POSITION_HOLDER); - } - Assertions.checkState(result != Extractor.RESULT_SEEK); + while (!loadCanceled && chunkExtractor.read(input)) {} } finally { nextLoadPosition = input.getPosition() - dataSpec.position; } } finally { Util.closeQuietly(dataSource); } - loadCompleted = true; + loadCompleted = !loadCanceled; } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java index 8b954af2f8..944b25395a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -21,11 +21,9 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.PositionHolder; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor.TrackOutputProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -35,9 +33,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public final class InitializationChunk extends Chunk { - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); - - private final ChunkExtractorWrapper extractorWrapper; + private final ChunkExtractor chunkExtractor; private @MonotonicNonNull TrackOutputProvider trackOutputProvider; private long nextLoadPosition; @@ -49,7 +45,7 @@ public final class InitializationChunk extends Chunk { * @param trackFormat See {@link #trackFormat}. * @param trackSelectionReason See {@link #trackSelectionReason}. * @param trackSelectionData See {@link #trackSelectionData}. - * @param extractorWrapper A wrapped extractor to use for parsing the initialization data. + * @param chunkExtractor A wrapped extractor to use for parsing the initialization data. */ public InitializationChunk( DataSource dataSource, @@ -57,10 +53,10 @@ public final class InitializationChunk extends Chunk { Format trackFormat, int trackSelectionReason, @Nullable Object trackSelectionData, - ChunkExtractorWrapper extractorWrapper) { + ChunkExtractor chunkExtractor) { super(dataSource, dataSpec, C.DATA_TYPE_MEDIA_INITIALIZATION, trackFormat, trackSelectionReason, trackSelectionData, C.TIME_UNSET, C.TIME_UNSET); - this.extractorWrapper = extractorWrapper; + this.chunkExtractor = chunkExtractor; } /** @@ -85,7 +81,7 @@ public final class InitializationChunk extends Chunk { @Override public void load() throws IOException { if (nextLoadPosition == 0) { - extractorWrapper.init( + chunkExtractor.init( trackOutputProvider, /* startTimeUs= */ C.TIME_UNSET, /* endTimeUs= */ C.TIME_UNSET); } try { @@ -96,12 +92,7 @@ public final class InitializationChunk extends Chunk { dataSource, loadDataSpec.position, dataSource.open(loadDataSpec)); // Load and decode the initialization data. try { - Extractor extractor = extractorWrapper.extractor; - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, DUMMY_POSITION_HOLDER); - } - Assertions.checkState(result != Extractor.RESULT_SEEK); + while (!loadCanceled && chunkExtractor.read(input)) {} } finally { nextLoadPosition = input.getPosition() - dataSpec.position; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java index 3b84761c28..268133ad40 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/Cue.java @@ -34,7 +34,7 @@ import java.lang.annotation.RetentionPolicy; public final class Cue { /** The empty cue. */ - public static final Cue EMPTY = new Cue(""); + public static final Cue EMPTY = new Cue.Builder().setText("").build(); /** An unset position, width or size. */ // Note: We deliberately don't use Float.MIN_VALUE because it's positive & very close to zero. @@ -144,10 +144,9 @@ public final class Cue { @Nullable public final Bitmap bitmap; /** - * The position of the {@link #lineAnchor} of the cue box within the viewport in the direction - * orthogonal to the writing direction (determined by {@link #verticalType}), or {@link - * #DIMEN_UNSET}. When set, the interpretation of the value depends on the value of {@link - * #lineType}. + * The position of the cue box within the viewport in the direction orthogonal to the writing + * direction (determined by {@link #verticalType}), or {@link #DIMEN_UNSET}. When set, the + * interpretation of the value depends on the value of {@link #lineType}. * *

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

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

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

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

      One of: + * + *

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

      For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link * #ANCHOR_TYPE_MIDDLE} and {@link #ANCHOR_TYPE_END} correspond to the top, middle and bottom of @@ -282,6 +276,7 @@ public final class Cue { * @param text See {@link #text}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue(CharSequence text) { this( @@ -308,6 +303,7 @@ public final class Cue { * @param size See {@link #size}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue( CharSequence text, @@ -346,6 +342,7 @@ public final class Cue { * @param textSize See {@link #textSize}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue( CharSequence text, @@ -584,41 +581,8 @@ public final class Cue { } /** - * Sets the position of the {@code lineAnchor} of the cue box within the viewport in the - * direction orthogonal to the writing direction. - * - *

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

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

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

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

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

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

      For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link - * #ANCHOR_TYPE_MIDDLE} and {@link #ANCHOR_TYPE_END} correspond to the left, middle and right of - * the cue box respectively. - * * @see Cue#positionAnchor */ public Builder setPositionAnchor(@AnchorType int positionAnchor) { @@ -807,6 +759,12 @@ public final class Cue { return this; } + /** Sets {@link Cue#windowColorSet} to false. */ + public Builder clearWindowColor() { + this.windowColorSet = false; + return this; + } + /** * Returns true if the fill color of the window is set. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java index b235706370..7de577f18c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderException.java @@ -15,10 +15,11 @@ */ package com.google.android.exoplayer2.text; -/** - * Thrown when an error occurs decoding subtitle data. - */ -public class SubtitleDecoderException extends Exception { +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.decoder.DecoderException; + +/** Thrown when an error occurs decoding subtitle data. */ +public class SubtitleDecoderException extends DecoderException { /** * @param message The detail message for this exception. @@ -27,17 +28,16 @@ public class SubtitleDecoderException extends Exception { super(message); } - /** @param cause The cause of this exception. */ - public SubtitleDecoderException(Exception cause) { + /** @param cause The cause of this exception, or {@code null}. */ + public SubtitleDecoderException(@Nullable Throwable cause) { super(cause); } /** * @param message The detail message for this exception. - * @param cause The cause of this exception. + * @param cause The cause of this exception, or {@code null}. */ - public SubtitleDecoderException(String message, Throwable cause) { + public SubtitleDecoderException(String message, @Nullable Throwable cause) { super(message, cause); } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java index b8b4d7de6e..6c140c74d1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/TextRenderer.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.text; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.os.Handler; import android.os.Handler.Callback; import android.os.Looper; @@ -27,7 +29,6 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.source.SampleStream; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; @@ -82,6 +83,7 @@ public final class TextRenderer extends BaseRenderer implements Callback { private boolean inputStreamEnded; private boolean outputStreamEnded; + private boolean waitingForKeyFrame; @ReplacementState private int decoderReplacementState; @Nullable private Format streamFormat; @Nullable private SubtitleDecoder decoder; @@ -114,7 +116,7 @@ public final class TextRenderer extends BaseRenderer implements Callback { public TextRenderer( TextOutput output, @Nullable Looper outputLooper, SubtitleDecoderFactory decoderFactory) { super(C.TRACK_TYPE_TEXT); - this.output = Assertions.checkNotNull(output); + this.output = checkNotNull(output); this.outputHandler = outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this); this.decoderFactory = decoderFactory; @@ -131,7 +133,7 @@ public final class TextRenderer extends BaseRenderer implements Callback { public int supportsFormat(Format format) { if (decoderFactory.supportsFormat(format)) { return RendererCapabilities.create( - format.drmInitData == null ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_DRM); + format.exoMediaCryptoType == null ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_DRM); } else if (MimeTypes.isText(format.sampleMimeType)) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); } else { @@ -140,20 +142,26 @@ public final class TextRenderer extends BaseRenderer implements Callback { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { streamFormat = formats[0]; if (decoder != null) { decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM; } else { - decoder = decoderFactory.createDecoder(streamFormat); + initDecoder(); } } @Override protected void onPositionReset(long positionUs, boolean joining) { + clearOutput(); inputStreamEnded = false; outputStreamEnded = false; - resetOutputAndDecoder(); + if (decoderReplacementState != REPLACEMENT_STATE_NONE) { + replaceDecoder(); + } else { + releaseBuffers(); + checkNotNull(decoder).flush(); + } } @Override @@ -163,9 +171,9 @@ public final class TextRenderer extends BaseRenderer implements Callback { } if (nextSubtitle == null) { - decoder.setPositionUs(positionUs); + checkNotNull(decoder).setPositionUs(positionUs); try { - nextSubtitle = decoder.dequeueOutputBuffer(); + nextSubtitle = checkNotNull(decoder).dequeueOutputBuffer(); } catch (SubtitleDecoderException e) { handleDecoderError(e); return; @@ -187,8 +195,8 @@ public final class TextRenderer extends BaseRenderer implements Callback { textRendererNeedsUpdate = true; } } - if (nextSubtitle != null) { + SubtitleOutputBuffer nextSubtitle = this.nextSubtitle; if (nextSubtitle.isEndOfStream()) { if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) { if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { @@ -203,14 +211,16 @@ public final class TextRenderer extends BaseRenderer implements Callback { if (subtitle != null) { subtitle.release(); } + nextSubtitleEventIndex = nextSubtitle.getNextEventTimeIndex(positionUs); subtitle = nextSubtitle; - nextSubtitle = null; - nextSubtitleEventIndex = subtitle.getNextEventTimeIndex(positionUs); + this.nextSubtitle = null; textRendererNeedsUpdate = true; } } if (textRendererNeedsUpdate) { + // If textRendererNeedsUpdate then subtitle must be non-null. + checkNotNull(subtitle); // textRendererNeedsUpdate is set and we're playing. Update the renderer. updateOutput(subtitle.getCues(positionUs)); } @@ -221,16 +231,18 @@ public final class TextRenderer extends BaseRenderer implements Callback { try { while (!inputStreamEnded) { + @Nullable SubtitleInputBuffer nextInputBuffer = this.nextInputBuffer; if (nextInputBuffer == null) { - nextInputBuffer = decoder.dequeueInputBuffer(); + nextInputBuffer = checkNotNull(decoder).dequeueInputBuffer(); if (nextInputBuffer == null) { return; } + this.nextInputBuffer = nextInputBuffer; } if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) { nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); - decoder.queueInputBuffer(nextInputBuffer); - nextInputBuffer = null; + checkNotNull(decoder).queueInputBuffer(nextInputBuffer); + this.nextInputBuffer = null; decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM; return; } @@ -239,19 +251,27 @@ public final class TextRenderer extends BaseRenderer implements Callback { if (result == C.RESULT_BUFFER_READ) { if (nextInputBuffer.isEndOfStream()) { inputStreamEnded = true; + waitingForKeyFrame = false; } else { - nextInputBuffer.subsampleOffsetUs = formatHolder.format.subsampleOffsetUs; + @Nullable Format format = formatHolder.format; + if (format == null) { + // We haven't received a format yet. + return; + } + nextInputBuffer.subsampleOffsetUs = format.subsampleOffsetUs; nextInputBuffer.flip(); + waitingForKeyFrame &= !nextInputBuffer.isKeyFrame(); + } + if (!waitingForKeyFrame) { + checkNotNull(decoder).queueInputBuffer(nextInputBuffer); + this.nextInputBuffer = null; } - decoder.queueInputBuffer(nextInputBuffer); - nextInputBuffer = null; } else if (result == C.RESULT_NOTHING_READ) { return; } } } catch (SubtitleDecoderException e) { handleDecoderError(e); - return; } } @@ -289,17 +309,23 @@ public final class TextRenderer extends BaseRenderer implements Callback { private void releaseDecoder() { releaseBuffers(); - decoder.release(); + checkNotNull(decoder).release(); decoder = null; decoderReplacementState = REPLACEMENT_STATE_NONE; } + private void initDecoder() { + waitingForKeyFrame = true; + decoder = decoderFactory.createDecoder(checkNotNull(streamFormat)); + } + private void replaceDecoder() { releaseDecoder(); - decoder = decoderFactory.createDecoder(streamFormat); + initDecoder(); } private long getNextEventTime() { + checkNotNull(subtitle); return nextSubtitleEventIndex == C.INDEX_UNSET || nextSubtitleEventIndex >= subtitle.getEventTimeCount() ? Long.MAX_VALUE : subtitle.getEventTime(nextSubtitleEventIndex); @@ -341,16 +367,7 @@ public final class TextRenderer extends BaseRenderer implements Callback { */ private void handleDecoderError(SubtitleDecoderException e) { Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e); - resetOutputAndDecoder(); - } - - private void resetOutputAndDecoder() { clearOutput(); - if (decoderReplacementState != REPLACEMENT_STATE_NONE) { - replaceDecoder(); - } else { - releaseBuffers(); - decoder.flush(); - } + replaceDecoder(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java index 75e86c4113..97890d7ee5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.text.cea; +import static java.lang.Math.min; + import android.graphics.Color; import android.graphics.Typeface; import android.text.Layout.Alignment; @@ -37,6 +39,7 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; @@ -628,7 +631,7 @@ public final class Cea608Decoder extends CeaDecoder { @Nullable Cue cue = cueBuilders.get(i).build(/* forcedPositionAnchor= */ Cue.TYPE_UNSET); cueBuilderCues.add(cue); if (cue != null) { - positionAnchor = Math.min(positionAnchor, cue.positionAnchor); + positionAnchor = min(positionAnchor, cue.positionAnchor); } } @@ -868,14 +871,18 @@ public final class Cea608Decoder extends CeaDecoder { } public void append(char text) { - captionStringBuilder.append(text); + // Don't accept more than 32 chars. We'll trim further, considering indent & tabOffset, in + // build(). + if (captionStringBuilder.length() < SCREEN_CHARWIDTH) { + captionStringBuilder.append(text); + } } public void rollUp() { rolledUpCaptions.add(buildCurrentLine()); captionStringBuilder.setLength(0); cueStyles.clear(); - int numRows = Math.min(captionRowCount, row); + int numRows = min(captionRowCount, row); while (rolledUpCaptions.size() >= numRows) { rolledUpCaptions.remove(0); } @@ -883,14 +890,17 @@ public final class Cea608Decoder extends CeaDecoder { @Nullable public Cue build(@Cue.AnchorType int forcedPositionAnchor) { + // The number of empty columns before the start of the text, in the range [0-31]. + int startPadding = indent + tabOffset; + int maxTextLength = SCREEN_CHARWIDTH - startPadding; SpannableStringBuilder cueString = new SpannableStringBuilder(); // Add any rolled up captions, separated by new lines. for (int i = 0; i < rolledUpCaptions.size(); i++) { - cueString.append(rolledUpCaptions.get(i)); + cueString.append(Util.truncateAscii(rolledUpCaptions.get(i), maxTextLength)); cueString.append('\n'); } // Add the current line. - cueString.append(buildCurrentLine()); + cueString.append(Util.truncateAscii(buildCurrentLine(), maxTextLength)); if (cueString.length() == 0) { // The cue is empty. @@ -898,8 +908,6 @@ public final class Cea608Decoder extends CeaDecoder { } int positionAnchor; - // The number of empty columns before the start of the text, in the range [0-31]. - int startPadding = indent + tabOffset; // The number of empty columns after the end of the text, in the same range. int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); int startEndPaddingDelta = startPadding - endPadding; @@ -937,31 +945,29 @@ public final class Cea608Decoder extends CeaDecoder { break; } - int lineAnchor; int line; - // Note: Row indices are in the range [1-15]. - if (captionMode == CC_MODE_ROLL_UP || row > (BASE_ROW / 2)) { - lineAnchor = Cue.ANCHOR_TYPE_END; + // Note: Row indices are in the range [1-15], Cue.line counts from 0 (top) and -1 (bottom). + if (row > (BASE_ROW / 2)) { line = row - BASE_ROW; // Two line adjustments. The first is because line indices from the bottom of the window // start from -1 rather than 0. The second is a blank row to act as the safe area. line -= 2; } else { - lineAnchor = Cue.ANCHOR_TYPE_START; - // Line indices from the top of the window start from 0, but we want a blank row to act as - // the safe area. As a result no adjustment is necessary. - line = row; + // The `row` of roll-up cues positions the bottom line (even for cues shown in the top + // half of the screen), so we need to consider the number of rows in this cue. In + // non-roll-up, we don't need any further adjustments because we leave the first line + // (cue.line=0) blank to act as the safe area, so positioning row=1 at Cue.line=1 is + // correct. + line = captionMode == CC_MODE_ROLL_UP ? row - (captionRowCount - 1) : row; } - return new Cue( - cueString, - Alignment.ALIGN_NORMAL, - line, - Cue.LINE_TYPE_NUMBER, - lineAnchor, - position, - positionAnchor, - Cue.DIMEN_UNSET); + return new Cue.Builder() + .setText(cueString) + .setTextAlignment(Alignment.ALIGN_NORMAL) + .setLine(line, Cue.LINE_TYPE_NUMBER) + .setPosition(position) + .setPositionAnchor(positionAnchor) + .build(); } private SpannableString buildCurrentLine() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java index 182fe7a2fe..8bd46fabdc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/Cea708Decoder.java @@ -1329,18 +1329,19 @@ public final class Cea708Decoder extends CeaDecoder { boolean windowColorSet, int windowColor, int priority) { - this.cue = - new Cue( - text, - textAlignment, - line, - lineType, - lineAnchor, - position, - positionAnchor, - size, - windowColorSet, - windowColor); + Cue.Builder cueBuilder = + new Cue.Builder() + .setText(text) + .setTextAlignment(textAlignment) + .setLine(line, lineType) + .setLineAnchor(lineAnchor) + .setPosition(position) + .setPositionAnchor(positionAnchor) + .setSize(size); + if (windowColorSet) { + cueBuilder.setWindowColor(windowColor); + } + this.cue = cueBuilder.build(); this.priority = priority; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java index f42b2a99cf..81ef58a712 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/cea/CeaDecoder.java @@ -81,8 +81,7 @@ import java.util.PriorityQueue; Assertions.checkArgument(inputBuffer == dequeuedInputBuffer); CeaInputBuffer ceaInputBuffer = (CeaInputBuffer) inputBuffer; if (ceaInputBuffer.isDecodeOnly()) { - // We can drop this buffer early (i.e. before it would be decoded) as the CEA formats allow - // for decoding to begin mid-stream. + // We can start decoding anywhere in CEA formats, so discarding on the input side is fine. releaseInputBuffer(ceaInputBuffer); } else { ceaInputBuffer.queuedInputBufferCount = queuedInputBufferCount++; @@ -97,15 +96,12 @@ import java.util.PriorityQueue; if (availableOutputBuffers.isEmpty()) { return null; } - // iterate through all available input buffers whose timestamps are less than or equal - // to the current playback position; processing input buffers for future content should - // be deferred until they would be applicable + // Process input buffers up to the current playback position. Processing of input buffers for + // future content is deferred. while (!queuedInputBuffers.isEmpty() && Util.castNonNull(queuedInputBuffers.peek()).timeUs <= playbackPositionUs) { CeaInputBuffer inputBuffer = Util.castNonNull(queuedInputBuffers.poll()); - // If the input buffer indicates we've reached the end of the stream, we can - // return immediately with an output buffer propagating that if (inputBuffer.isEndOfStream()) { // availableOutputBuffers.isEmpty() is checked at the top of the method, so this is safe. SubtitleOutputBuffer outputBuffer = Util.castNonNull(availableOutputBuffers.pollFirst()); @@ -116,18 +112,13 @@ import java.util.PriorityQueue; decode(inputBuffer); - // check if we have any caption updates to report if (isNewSubtitleDataAvailable()) { - // Even if the subtitle is decode-only; we need to generate it to consume the data so it - // isn't accidentally prepended to the next subtitle Subtitle subtitle = createSubtitle(); - if (!inputBuffer.isDecodeOnly()) { - // availableOutputBuffers.isEmpty() is checked at the top of the method, so this is safe. - SubtitleOutputBuffer outputBuffer = Util.castNonNull(availableOutputBuffers.pollFirst()); - outputBuffer.setContent(inputBuffer.timeUs, subtitle, Format.OFFSET_SAMPLE_RELATIVE); - releaseInputBuffer(inputBuffer); - return outputBuffer; - } + // availableOutputBuffers.isEmpty() is checked at the top of the method, so this is safe. + SubtitleOutputBuffer outputBuffer = Util.castNonNull(availableOutputBuffers.pollFirst()); + outputBuffer.setContent(inputBuffer.timeUs, subtitle, Format.OFFSET_SAMPLE_RELATIVE); + releaseInputBuffer(inputBuffer); + return outputBuffer; } releaseInputBuffer(inputBuffer); @@ -160,7 +151,7 @@ import java.util.PriorityQueue; @Override public void release() { - // Do nothing + // Do nothing. } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/dvb/DvbParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/dvb/DvbParser.java index 55666718da..5cdbfdf72e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/dvb/DvbParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/dvb/DvbParser.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.text.dvb; +import static java.lang.Math.min; + import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; @@ -163,10 +165,14 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + displayDefinition.horizontalPositionMinimum; int baseVerticalAddress = pageRegion.verticalAddress + displayDefinition.verticalPositionMinimum; - int clipRight = Math.min(baseHorizontalAddress + regionComposition.width, - displayDefinition.horizontalPositionMaximum); - int clipBottom = Math.min(baseVerticalAddress + regionComposition.height, - displayDefinition.verticalPositionMaximum); + int clipRight = + min( + baseHorizontalAddress + regionComposition.width, + displayDefinition.horizontalPositionMaximum); + int clipBottom = + min( + baseVerticalAddress + regionComposition.height, + displayDefinition.verticalPositionMaximum); canvas.clipRect(baseHorizontalAddress, baseVerticalAddress, clipRight, clipBottom); ClutDefinition clutDefinition = subtitleService.cluts.get(regionComposition.clutId); if (clutDefinition == null) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/pgs/PgsDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/pgs/PgsDecoder.java index fe8bf12d47..163cc6b12b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/pgs/PgsDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/pgs/PgsDecoder.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.text.pgs; +import static java.lang.Math.min; + import android.graphics.Bitmap; import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.Cue; @@ -72,7 +74,7 @@ public final class PgsDecoder extends SimpleSubtitleDecoder { inflater = new Inflater(); } if (Util.inflate(buffer, inflatedBuffer, inflater)) { - buffer.reset(inflatedBuffer.data, inflatedBuffer.limit()); + buffer.reset(inflatedBuffer.getData(), inflatedBuffer.limit()); } // else assume data is not compressed. } } @@ -182,8 +184,8 @@ public final class PgsDecoder extends SimpleSubtitleDecoder { int position = bitmapData.getPosition(); int limit = bitmapData.limit(); if (position < limit && sectionLength > 0) { - int bytesToRead = Math.min(sectionLength, limit - position); - buffer.readBytes(bitmapData.data, position, bytesToRead); + int bytesToRead = min(sectionLength, limit - position); + buffer.readBytes(bitmapData.getData(), position, bytesToRead); bitmapData.setPosition(position + bytesToRead); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java index b963b60479..3bb39aba9c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.text.ssa; +import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_FRACTION; import static com.google.android.exoplayer2.util.Util.castNonNull; import android.text.Layout; @@ -299,6 +300,8 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { SsaStyle.Overrides styleOverrides, float screenWidth, float screenHeight) { + Cue.Builder cue = new Cue.Builder().setText(text); + @SsaStyle.SsaAlignment int alignment; if (styleOverrides.alignment != SsaStyle.SSA_ALIGNMENT_UNKNOWN) { alignment = styleOverrides.alignment; @@ -307,31 +310,22 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { } else { alignment = SsaStyle.SSA_ALIGNMENT_UNKNOWN; } - @Cue.AnchorType int positionAnchor = toPositionAnchor(alignment); - @Cue.AnchorType int lineAnchor = toLineAnchor(alignment); + cue.setTextAlignment(toTextAlignment(alignment)) + .setPositionAnchor(toPositionAnchor(alignment)) + .setLineAnchor(toLineAnchor(alignment)); - float position; - float line; if (styleOverrides.position != null && screenHeight != Cue.DIMEN_UNSET && screenWidth != Cue.DIMEN_UNSET) { - position = styleOverrides.position.x / screenWidth; - line = styleOverrides.position.y / screenHeight; + cue.setPosition(styleOverrides.position.x / screenWidth); + cue.setLine(styleOverrides.position.y / screenHeight, LINE_TYPE_FRACTION); } else { // TODO: Read the MarginL, MarginR and MarginV values from the Style & Dialogue lines. - position = computeDefaultLineOrPosition(positionAnchor); - line = computeDefaultLineOrPosition(lineAnchor); + cue.setPosition(computeDefaultLineOrPosition(cue.getPositionAnchor())); + cue.setLine(computeDefaultLineOrPosition(cue.getLineAnchor()), LINE_TYPE_FRACTION); } - return new Cue( - text, - toTextAlignment(alignment), - line, - Cue.LINE_TYPE_FRACTION, - lineAnchor, - position, - positionAnchor, - /* size= */ Cue.DIMEN_UNSET); + return cue.build(); } @Nullable diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java index 6e25dfc52a..efbf3ab64f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/subrip/SubripDecoder.java @@ -84,7 +84,7 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { continue; } - // Parse the index line as a sanity check. + // Parse and check the index line. try { Integer.parseInt(currentLine); } catch (NumberFormatException e) { @@ -173,61 +173,54 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { * @return Built cue */ private Cue buildCue(Spanned text, @Nullable String alignmentTag) { + Cue.Builder cue = new Cue.Builder().setText(text); if (alignmentTag == null) { - return new Cue(text); + return cue.build(); } // Horizontal alignment. - @Cue.AnchorType int positionAnchor; switch (alignmentTag) { case ALIGN_BOTTOM_LEFT: case ALIGN_MID_LEFT: case ALIGN_TOP_LEFT: - positionAnchor = Cue.ANCHOR_TYPE_START; + cue.setPositionAnchor(Cue.ANCHOR_TYPE_START); break; case ALIGN_BOTTOM_RIGHT: case ALIGN_MID_RIGHT: case ALIGN_TOP_RIGHT: - positionAnchor = Cue.ANCHOR_TYPE_END; + cue.setPositionAnchor(Cue.ANCHOR_TYPE_END); break; case ALIGN_BOTTOM_MID: case ALIGN_MID_MID: case ALIGN_TOP_MID: default: - positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; + cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE); break; } // Vertical alignment. - @Cue.AnchorType int lineAnchor; switch (alignmentTag) { case ALIGN_BOTTOM_LEFT: case ALIGN_BOTTOM_MID: case ALIGN_BOTTOM_RIGHT: - lineAnchor = Cue.ANCHOR_TYPE_END; + cue.setLineAnchor(Cue.ANCHOR_TYPE_END); break; case ALIGN_TOP_LEFT: case ALIGN_TOP_MID: case ALIGN_TOP_RIGHT: - lineAnchor = Cue.ANCHOR_TYPE_START; + cue.setLineAnchor(Cue.ANCHOR_TYPE_START); break; case ALIGN_MID_LEFT: case ALIGN_MID_MID: case ALIGN_MID_RIGHT: default: - lineAnchor = Cue.ANCHOR_TYPE_MIDDLE; + cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE); break; } - return new Cue( - text, - /* textAlignment= */ null, - getFractionalPositionForAnchorType(lineAnchor), - Cue.LINE_TYPE_FRACTION, - lineAnchor, - getFractionalPositionForAnchorType(positionAnchor), - positionAnchor, - Cue.DIMEN_UNSET); + return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) + .setLine(getFractionalPositionForAnchorType(cue.getLineAnchor()), Cue.LINE_TYPE_FRACTION) + .build(); } private static long parseTimecode(Matcher matcher, int groupOffset) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java index e9d6d88c4a..611eb7ff2f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java @@ -444,6 +444,26 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { } float regionTextHeight = 1.0f / cellResolution.rows; + + @Cue.VerticalType int verticalType = Cue.TYPE_UNSET; + @Nullable + String writingDirection = + XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_WRITING_MODE); + if (writingDirection != null) { + switch (Util.toLowerInvariant(writingDirection)) { + // TODO: Support horizontal RTL modes. + case TtmlNode.VERTICAL: + case TtmlNode.VERTICAL_LR: + verticalType = Cue.VERTICAL_TYPE_LR; + break; + case TtmlNode.VERTICAL_RL: + verticalType = Cue.VERTICAL_TYPE_RL; + break; + default: + // ignore + break; + } + } return new TtmlRegion( regionId, position, @@ -453,7 +473,8 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { width, height, /* textSizeType= */ Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING, - /* textSize= */ regionTextHeight); + /* textSize= */ regionTextHeight, + verticalType); } private static String[] parseStyleIds(String parentStyleIds) { @@ -588,21 +609,6 @@ public final class TtmlDecoder extends SimpleSubtitleDecoder { break; } break; - case TtmlNode.ATTR_TTS_WRITING_MODE: - switch (Util.toLowerInvariant(attributeValue)) { - // TODO: Support horizontal RTL modes. - case TtmlNode.VERTICAL: - case TtmlNode.VERTICAL_LR: - style = createIfNull(style).setVerticalType(Cue.VERTICAL_TYPE_LR); - break; - case TtmlNode.VERTICAL_RL: - style = createIfNull(style).setVerticalType(Cue.VERTICAL_TYPE_RL); - break; - default: - // ignore - break; - } - break; default: // ignore break; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java index 7b1dda10fd..8e516dedf1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlNode.java @@ -268,6 +268,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; .setLineAnchor(region.lineAnchor) .setSize(region.width) .setBitmapHeight(region.height) + .setVerticalType(region.verticalType) .build()); } @@ -281,6 +282,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; regionOutput.setPosition(region.position); regionOutput.setSize(region.width); regionOutput.setTextSize(region.textSize, region.textSizeType); + regionOutput.setVerticalType(region.verticalType); cues.add(regionOutput.build()); } @@ -379,8 +381,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; regionOutput.setText(text); } if (resolvedStyle != null) { - TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent); - regionOutput.setVerticalType(resolvedStyle.getVerticalType()); + TtmlRenderUtil.applyStylesToSpan(text, start, end, resolvedStyle, parent, globalStyles); + regionOutput.setTextAlignment(resolvedStyle.getTextAlign()); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRegion.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRegion.java index 3cbc25d4b2..36c862568f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRegion.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRegion.java @@ -25,12 +25,13 @@ import com.google.android.exoplayer2.text.Cue; public final String id; public final float position; public final float line; - public final @Cue.LineType int lineType; - public final @Cue.AnchorType int lineAnchor; + @Cue.LineType public final int lineType; + @Cue.AnchorType public final int lineAnchor; public final float width; public final float height; - public final @Cue.TextSizeType int textSizeType; + @Cue.TextSizeType public final int textSizeType; public final float textSize; + @Cue.VerticalType public final int verticalType; public TtmlRegion(String id) { this( @@ -42,7 +43,8 @@ import com.google.android.exoplayer2.text.Cue; /* width= */ Cue.DIMEN_UNSET, /* height= */ Cue.DIMEN_UNSET, /* textSizeType= */ Cue.TYPE_UNSET, - /* textSize= */ Cue.DIMEN_UNSET); + /* textSize= */ Cue.DIMEN_UNSET, + /* verticalType= */ Cue.TYPE_UNSET); } public TtmlRegion( @@ -54,7 +56,8 @@ import com.google.android.exoplayer2.text.Cue; float width, float height, int textSizeType, - float textSize) { + float textSize, + @Cue.VerticalType int verticalType) { this.id = id; this.position = position; this.line = line; @@ -64,6 +67,7 @@ import com.google.android.exoplayer2.text.Cue; this.height = height; this.textSizeType = textSizeType; this.textSize = textSize; + this.verticalType = verticalType; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java index e5ba2c9c1c..13f3fe2b16 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java @@ -15,12 +15,10 @@ */ package com.google.android.exoplayer2.text.ttml; -import android.text.Layout.Alignment; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.style.AbsoluteSizeSpan; -import android.text.style.AlignmentSpan; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; @@ -80,7 +78,12 @@ import java.util.Map; } public static void applyStylesToSpan( - Spannable builder, int start, int end, TtmlStyle style, @Nullable TtmlNode parent) { + Spannable builder, + int start, + int end, + TtmlStyle style, + @Nullable TtmlNode parent, + Map globalStyles) { if (style.getStyle() != TtmlStyle.UNSPECIFIED) { builder.setSpan(new StyleSpan(style.getStyle()), start, end, @@ -119,12 +122,12 @@ import java.util.Map; switch (style.getRubyType()) { case TtmlStyle.RUBY_TYPE_BASE: // look for the sibling RUBY_TEXT and add it as span between start & end. - @Nullable TtmlNode containerNode = findRubyContainerNode(parent); + @Nullable TtmlNode containerNode = findRubyContainerNode(parent, globalStyles); if (containerNode == null) { // No matching container node break; } - @Nullable TtmlNode textNode = findRubyTextNode(containerNode); + @Nullable TtmlNode textNode = findRubyTextNode(containerNode, globalStyles); if (textNode == null) { // no matching text node break; @@ -162,16 +165,6 @@ import java.util.Map; // Do nothing break; } - - @Nullable Alignment textAlign = style.getTextAlign(); - if (textAlign != null) { - SpanUtil.addOrReplaceSpan( - builder, - new AlignmentSpan.Standard(textAlign), - start, - end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } if (style.getTextCombine()) { SpanUtil.addOrReplaceSpan( builder, @@ -212,12 +205,15 @@ import java.util.Map; } @Nullable - private static TtmlNode findRubyTextNode(TtmlNode rubyContainerNode) { + private static TtmlNode findRubyTextNode( + TtmlNode rubyContainerNode, Map globalStyles) { Deque childNodesStack = new ArrayDeque<>(); childNodesStack.push(rubyContainerNode); while (!childNodesStack.isEmpty()) { TtmlNode childNode = childNodesStack.pop(); - if (childNode.style != null && childNode.style.getRubyType() == TtmlStyle.RUBY_TYPE_TEXT) { + @Nullable + TtmlStyle style = resolveStyle(childNode.style, childNode.getStyleIds(), globalStyles); + if (style != null && style.getRubyType() == TtmlStyle.RUBY_TYPE_TEXT) { return childNode; } for (int i = childNode.getChildCount() - 1; i >= 0; i--) { @@ -229,9 +225,10 @@ import java.util.Map; } @Nullable - private static TtmlNode findRubyContainerNode(@Nullable TtmlNode node) { + private static TtmlNode findRubyContainerNode( + @Nullable TtmlNode node, Map globalStyles) { while (node != null) { - @Nullable TtmlStyle style = node.style; + @Nullable TtmlStyle style = resolveStyle(node.style, node.getStyleIds(), globalStyles); if (style != null && style.getRubyType() == TtmlStyle.RUBY_TYPE_CONTAINER) { return node; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java index 928af3620c..3ca519660d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ttml/TtmlStyle.java @@ -19,8 +19,6 @@ import android.graphics.Typeface; import android.text.Layout; import androidx.annotation.IntDef; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.text.Cue; -import com.google.android.exoplayer2.text.Cue.VerticalType; import com.google.android.exoplayer2.text.span.RubySpan; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -88,7 +86,6 @@ import java.lang.annotation.RetentionPolicy; @RubySpan.Position private int rubyPosition; @Nullable private Layout.Alignment textAlign; @OptionalBoolean private int textCombine; - @Cue.VerticalType private int verticalType; public TtmlStyle() { linethrough = UNSPECIFIED; @@ -99,7 +96,6 @@ import java.lang.annotation.RetentionPolicy; rubyType = UNSPECIFIED; rubyPosition = RubySpan.POSITION_UNKNOWN; textCombine = UNSPECIFIED; - verticalType = Cue.TYPE_UNSET; } /** @@ -249,9 +245,6 @@ import java.lang.annotation.RetentionPolicy; if (chaining && rubyType == UNSPECIFIED && ancestor.rubyType != UNSPECIFIED) { rubyType = ancestor.rubyType; } - if (chaining && verticalType == Cue.TYPE_UNSET && ancestor.verticalType != Cue.TYPE_UNSET) { - setVerticalType(ancestor.verticalType); - } } return this; } @@ -323,14 +316,4 @@ import java.lang.annotation.RetentionPolicy; public float getFontSize() { return fontSize; } - - public TtmlStyle setVerticalType(@VerticalType int verticalType) { - this.verticalType = verticalType; - return this; - } - - @VerticalType - public int getVerticalType() { - return verticalType; - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java index c8f2979c58..4ce0ea8df5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.text.tx3g; +import static com.google.android.exoplayer2.text.Cue.ANCHOR_TYPE_START; +import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_FRACTION; + import android.graphics.Color; import android.graphics.Typeface; import android.text.SpannableStringBuilder; @@ -30,7 +33,7 @@ import com.google.android.exoplayer2.text.Subtitle; import com.google.android.exoplayer2.text.SubtitleDecoderException; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; import java.util.List; /** @@ -150,15 +153,11 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { parsableByteArray.setPosition(position + atomSize); } return new Tx3gSubtitle( - new Cue( - cueText, - /* textAlignment= */ null, - verticalPlacement, - Cue.LINE_TYPE_FRACTION, - Cue.ANCHOR_TYPE_START, - Cue.DIMEN_UNSET, - Cue.TYPE_UNSET, - Cue.DIMEN_UNSET)); + new Cue.Builder() + .setText(cueText) + .setLine(verticalPlacement, LINE_TYPE_FRACTION) + .setLineAnchor(ANCHOR_TYPE_START) + .build()); } private static String readSubtitleText(ParsableByteArray parsableByteArray) @@ -171,10 +170,10 @@ public final class Tx3gDecoder extends SimpleSubtitleDecoder { if (parsableByteArray.bytesLeft() >= SIZE_BOM_UTF16) { char firstChar = parsableByteArray.peekChar(); if (firstChar == BOM_UTF16_BE || firstChar == BOM_UTF16_LE) { - return parsableByteArray.readString(textLength, Charset.forName(C.UTF16_NAME)); + return parsableByteArray.readString(textLength, Charsets.UTF_16); } } - return parsableByteArray.readString(textLength, Charset.forName(C.UTF8_NAME)); + return parsableByteArray.readString(textLength, Charsets.UTF_8); } private void applyStyleRecord(ParsableByteArray parsableByteArray, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java index 5efe378a9b..40fb1fcbb2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/CssParser.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.text.webvtt; import android.text.TextUtils; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -36,9 +37,13 @@ import java.util.regex.Pattern; private static final String RULE_START = "{"; private static final String RULE_END = "}"; + private static final String PROPERTY_COLOR = "color"; private static final String PROPERTY_BGCOLOR = "background-color"; private static final String PROPERTY_FONT_FAMILY = "font-family"; private static final String PROPERTY_FONT_WEIGHT = "font-weight"; + private static final String PROPERTY_RUBY_POSITION = "ruby-position"; + private static final String VALUE_OVER = "over"; + private static final String VALUE_UNDER = "under"; private static final String PROPERTY_TEXT_COMBINE_UPRIGHT = "text-combine-upright"; private static final String VALUE_ALL = "all"; private static final String VALUE_DIGITS = "digits"; @@ -73,7 +78,7 @@ import java.util.regex.Pattern; stringBuilder.setLength(0); int initialInputPosition = input.getPosition(); skipStyleBlock(input); - styleInput.reset(input.data, input.getPosition()); + styleInput.reset(input.getData(), input.getPosition()); styleInput.setPosition(initialInputPosition); List styles = new ArrayList<>(); @@ -149,7 +154,7 @@ import java.util.regex.Pattern; int limit = input.limit(); boolean cueTargetEndFound = false; while (position < limit && !cueTargetEndFound) { - char c = (char) input.data[position++]; + char c = (char) input.getData()[position++]; cueTargetEndFound = c == ')'; } return input.readString(--position - input.getPosition()).trim(); @@ -184,10 +189,16 @@ import java.util.regex.Pattern; return; } // At this point we have a presumably valid declaration, we need to parse it and fill the style. - if ("color".equals(property)) { + if (PROPERTY_COLOR.equals(property)) { style.setFontColor(ColorParser.parseCssColor(value)); } else if (PROPERTY_BGCOLOR.equals(property)) { style.setBackgroundColor(ColorParser.parseCssColor(value)); + } else if (PROPERTY_RUBY_POSITION.equals(property)) { + if (VALUE_OVER.equals(value)) { + style.setRubyPosition(RubySpan.POSITION_OVER); + } else if (VALUE_UNDER.equals(value)) { + style.setRubyPosition(RubySpan.POSITION_UNDER); + } } else if (PROPERTY_TEXT_COMBINE_UPRIGHT.equals(property)) { style.setCombineUpright(VALUE_ALL.equals(value) || value.startsWith(VALUE_DIGITS)); } else if (PROPERTY_TEXT_DECORATION.equals(property)) { @@ -256,7 +267,7 @@ import java.util.regex.Pattern; } private static char peekCharAtPosition(ParsableByteArray input, int position) { - return (char) input.data[position]; + return (char) input.getData()[position]; } @Nullable @@ -286,7 +297,7 @@ import java.util.regex.Pattern; private static boolean maybeSkipComment(ParsableByteArray input) { int position = input.getPosition(); int limit = input.limit(); - byte[] data = input.data; + byte[] data = input.getData(); if (position + 2 <= limit && data[position++] == '/' && data[position++] == '*') { while (position + 1 < limit) { char skippedChar = (char) data[position++]; @@ -309,7 +320,7 @@ import java.util.regex.Pattern; int limit = input.limit(); boolean identifierEndFound = false; while (position < limit && !identifierEndFound) { - char c = (char) input.data[position]; + char c = (char) input.getData()[position]; if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '#' || c == '-' || c == '.' || c == '_') { position++; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java index 82023e6c58..caaa7869ee 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java @@ -84,7 +84,7 @@ public final class Mp4WebvttDecoder extends SimpleSubtitleDecoder { remainingCueBoxBytes -= BOX_HEADER_SIZE; int payloadLength = boxSize - BOX_HEADER_SIZE; String boxPayload = - Util.fromUtf8Bytes(sampleData.data, sampleData.getPosition(), payloadLength); + Util.fromUtf8Bytes(sampleData.getData(), sampleData.getPosition(), payloadLength); sampleData.skipBytes(payloadLength); remainingCueBoxBytes -= payloadLength; if (boxType == TYPE_sttg) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java index cd08ad18cf..eeb3392e54 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java @@ -16,18 +16,19 @@ package com.google.android.exoplayer2.text.webvtt; import android.graphics.Typeface; -import android.text.Layout; import android.text.TextUtils; +import androidx.annotation.ColorInt; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.Arrays; import java.util.Collections; -import java.util.List; -import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import java.util.HashSet; +import java.util.Set; /** * Style object of a Css style block in a Webvtt file. @@ -79,12 +80,12 @@ public final class WebvttCssStyle { // Selector properties. private String targetId; private String targetTag; - private List targetClasses; + private Set targetClasses; private String targetVoice; // Style properties. @Nullable private String fontFamily; - private int fontColor; + @ColorInt private int fontColor; private boolean hasFontColor; private int backgroundColor; private boolean hasBackgroundColor; @@ -94,21 +95,13 @@ public final class WebvttCssStyle { @OptionalBoolean private int italic; @FontSizeUnit private int fontSizeUnit; private float fontSize; - @Nullable private Layout.Alignment textAlign; + @RubySpan.Position private int rubyPosition; private boolean combineUpright; - // Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed - // because reset() only assigns fields, it doesn't read any. - @SuppressWarnings("nullness:method.invocation.invalid") public WebvttCssStyle() { - reset(); - } - - @EnsuresNonNull({"targetId", "targetTag", "targetClasses", "targetVoice"}) - public void reset() { targetId = ""; targetTag = ""; - targetClasses = Collections.emptyList(); + targetClasses = Collections.emptySet(); targetVoice = ""; fontFamily = null; hasFontColor = false; @@ -118,7 +111,7 @@ public final class WebvttCssStyle { bold = UNSPECIFIED; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; - textAlign = null; + rubyPosition = RubySpan.POSITION_UNKNOWN; combineUpright = false; } @@ -131,7 +124,7 @@ public final class WebvttCssStyle { } public void setTargetClasses(String[] targetClasses) { - this.targetClasses = Arrays.asList(targetClasses); + this.targetClasses = new HashSet<>(Arrays.asList(targetClasses)); } public void setTargetVoice(String targetVoice) { @@ -157,7 +150,7 @@ public final class WebvttCssStyle { * @return The score of the match, zero if there is no match. */ public int getSpecificityScore( - @Nullable String id, @Nullable String tag, String[] classes, @Nullable String voice) { + @Nullable String id, @Nullable String tag, Set classes, @Nullable String voice) { if (targetId.isEmpty() && targetTag.isEmpty() && targetClasses.isEmpty() && targetVoice.isEmpty()) { // The selector is universal. It matches with the minimum score if and only if the given @@ -168,7 +161,7 @@ public final class WebvttCssStyle { score = updateScoreForMatch(score, targetId, id, 0x40000000); score = updateScoreForMatch(score, targetTag, tag, 2); score = updateScoreForMatch(score, targetVoice, voice, 4); - if (score == -1 || !Arrays.asList(classes).containsAll(targetClasses)) { + if (score == -1 || !classes.containsAll(targetClasses)) { return 0; } else { score += targetClasses.size() * 4; @@ -261,16 +254,6 @@ public final class WebvttCssStyle { return hasBackgroundColor; } - @Nullable - public Layout.Alignment getTextAlign() { - return textAlign; - } - - public WebvttCssStyle setTextAlign(@Nullable Layout.Alignment textAlign) { - this.textAlign = textAlign; - return this; - } - public WebvttCssStyle setFontSize(float fontSize) { this.fontSize = fontSize; return this; @@ -289,8 +272,19 @@ public final class WebvttCssStyle { return fontSize; } - public void setCombineUpright(boolean enabled) { + public WebvttCssStyle setRubyPosition(@RubySpan.Position int rubyPosition) { + this.rubyPosition = rubyPosition; + return this; + } + + @RubySpan.Position + public int getRubyPosition() { + return rubyPosition; + } + + public WebvttCssStyle setCombineUpright(boolean enabled) { this.combineUpright = enabled; + return this; } public boolean getCombineUpright() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java index 3c974d8a41..ed95f6b4e0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.text.webvtt; import static com.google.android.exoplayer2.text.span.SpanUtil.addOrReplaceSpan; +import static java.lang.Math.min; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.graphics.Color; @@ -26,7 +27,6 @@ import android.text.Spanned; import android.text.SpannedString; import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; -import android.text.style.AlignmentSpan; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; @@ -50,8 +50,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -240,7 +242,6 @@ public final class WebvttCueParser { @Nullable String id, String markup, List styles) { SpannableStringBuilder spannedText = new SpannableStringBuilder(); ArrayDeque startTagStack = new ArrayDeque<>(); - List scratchStyleMatches = new ArrayList<>(); int pos = 0; List nestedElements = new ArrayList<>(); while (pos < markup.length()) { @@ -271,8 +272,7 @@ public final class WebvttCueParser { break; } startTag = startTagStack.pop(); - applySpansForTag( - id, startTag, nestedElements, spannedText, styles, scratchStyleMatches); + applySpansForTag(id, startTag, nestedElements, spannedText, styles); if (!startTagStack.isEmpty()) { nestedElements.add(new Element(startTag, spannedText.length())); } else { @@ -286,9 +286,12 @@ public final class WebvttCueParser { case CHAR_AMPERSAND: int semiColonEndIndex = markup.indexOf(CHAR_SEMI_COLON, pos + 1); int spaceEndIndex = markup.indexOf(CHAR_SPACE, pos + 1); - int entityEndIndex = semiColonEndIndex == -1 ? spaceEndIndex - : (spaceEndIndex == -1 ? semiColonEndIndex - : Math.min(semiColonEndIndex, spaceEndIndex)); + int entityEndIndex = + semiColonEndIndex == -1 + ? spaceEndIndex + : (spaceEndIndex == -1 + ? semiColonEndIndex + : min(semiColonEndIndex, spaceEndIndex)); if (entityEndIndex != -1) { applyEntity(markup.substring(pos + 1, entityEndIndex), spannedText); if (entityEndIndex == spaceEndIndex) { @@ -308,16 +311,14 @@ public final class WebvttCueParser { } // apply unclosed tags while (!startTagStack.isEmpty()) { - applySpansForTag( - id, startTagStack.pop(), nestedElements, spannedText, styles, scratchStyleMatches); + applySpansForTag(id, startTagStack.pop(), nestedElements, spannedText, styles); } applySpansForTag( id, StartTag.buildWholeCueVirtualTag(), /* nestedElements= */ Collections.emptyList(), spannedText, - styles, - scratchStyleMatches); + styles); return SpannedString.valueOf(spannedText); } @@ -394,13 +395,7 @@ public final class WebvttCueParser { builder.line = WebvttParserUtil.parsePercentage(s); builder.lineType = Cue.LINE_TYPE_FRACTION; } else { - int lineNumber = Integer.parseInt(s); - if (lineNumber < 0) { - // WebVTT defines line -1 as last visible row when lineAnchor is ANCHOR_TYPE_START, where-as - // Cue defines it to be the first row that's not visible. - lineNumber--; - } - builder.line = lineNumber; + builder.line = Integer.parseInt(s); builder.lineType = Cue.LINE_TYPE_NUMBER; } } @@ -535,10 +530,10 @@ public final class WebvttCueParser { StartTag startTag, List nestedElements, SpannableStringBuilder text, - List styles, - List scratchStyleMatches) { + List styles) { int start = startTag.position; int end = text.length(); + switch(startTag.name) { case TAG_BOLD: text.setSpan(new StyleSpan(STYLE_BOLD), start, end, @@ -549,7 +544,7 @@ public final class WebvttCueParser { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); break; case TAG_RUBY: - applyRubySpans(nestedElements, text, start); + applyRubySpans(text, cueId, startTag, nestedElements, styles); break; case TAG_UNDERLINE: text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -564,33 +559,45 @@ public final class WebvttCueParser { default: return; } - scratchStyleMatches.clear(); - getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); - int styleMatchesCount = scratchStyleMatches.size(); - for (int i = 0; i < styleMatchesCount; i++) { - applyStyleToText(text, scratchStyleMatches.get(i).style, start, end); + + List applicableStyles = getApplicableStyles(styles, cueId, startTag); + for (int i = 0; i < applicableStyles.size(); i++) { + applyStyleToText(text, applicableStyles.get(i).style, start, end); } } private static void applyRubySpans( - List nestedElements, SpannableStringBuilder text, int startTagPosition) { + SpannableStringBuilder text, + @Nullable String cueId, + StartTag startTag, + List nestedElements, + List styles) { + @RubySpan.Position int rubyTagPosition = getRubyPosition(styles, cueId, startTag); List sortedNestedElements = new ArrayList<>(nestedElements.size()); sortedNestedElements.addAll(nestedElements); Collections.sort(sortedNestedElements, Element.BY_START_POSITION_ASC); int deletedCharCount = 0; - int lastRubyTextEnd = startTagPosition; + int lastRubyTextEnd = startTag.position; for (int i = 0; i < sortedNestedElements.size(); i++) { if (!TAG_RUBY_TEXT.equals(sortedNestedElements.get(i).startTag.name)) { continue; } Element rubyTextElement = sortedNestedElements.get(i); + // Use the element's ruby-position if set, otherwise the element's and otherwise + // default to OVER. + @RubySpan.Position + int rubyPosition = + firstKnownRubyPosition( + getRubyPosition(styles, cueId, rubyTextElement.startTag), + rubyTagPosition, + RubySpan.POSITION_OVER); // Move the rubyText from spannedText into the RubySpan. int adjustedRubyTextStart = rubyTextElement.startTag.position - deletedCharCount; int adjustedRubyTextEnd = rubyTextElement.endPosition - deletedCharCount; CharSequence rubyText = text.subSequence(adjustedRubyTextStart, adjustedRubyTextEnd); text.delete(adjustedRubyTextStart, adjustedRubyTextEnd); text.setSpan( - new RubySpan(rubyText.toString(), RubySpan.POSITION_OVER), + new RubySpan(rubyText.toString(), rubyPosition), lastRubyTextEnd, adjustedRubyTextStart, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -600,6 +607,36 @@ public final class WebvttCueParser { } } + @RubySpan.Position + private static int getRubyPosition( + List styles, @Nullable String cueId, StartTag startTag) { + List styleMatches = getApplicableStyles(styles, cueId, startTag); + for (int i = 0; i < styleMatches.size(); i++) { + WebvttCssStyle style = styleMatches.get(i).style; + if (style.getRubyPosition() != RubySpan.POSITION_UNKNOWN) { + return style.getRubyPosition(); + } + } + return RubySpan.POSITION_UNKNOWN; + } + + @RubySpan.Position + private static int firstKnownRubyPosition( + @RubySpan.Position int position1, + @RubySpan.Position int position2, + @RubySpan.Position int position3) { + if (position1 != RubySpan.POSITION_UNKNOWN) { + return position1; + } + if (position2 != RubySpan.POSITION_UNKNOWN) { + return position2; + } + if (position3 != RubySpan.POSITION_UNKNOWN) { + return position3; + } + throw new IllegalArgumentException(); + } + /** * Adds {@link ForegroundColorSpan}s and {@link BackgroundColorSpan}s to {@code text} for entries * in {@code classes} that match WebVTT's . */ private static void applyDefaultColors( - SpannableStringBuilder text, String[] classes, int start, int end) { + SpannableStringBuilder text, Set classes, int start, int end) { for (String className : classes) { if (DEFAULT_TEXT_COLORS.containsKey(className)) { int color = DEFAULT_TEXT_COLORS.get(className); @@ -663,15 +700,6 @@ public final class WebvttCueParser { end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } - Layout.Alignment textAlign = style.getTextAlign(); - if (textAlign != null) { - addOrReplaceSpan( - spannedText, - new AlignmentSpan.Standard(textAlign), - start, - end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } switch (style.getFontSizeUnit()) { case WebvttCssStyle.FONT_SIZE_UNIT_PIXEL: addOrReplaceSpan( @@ -719,20 +747,18 @@ public final class WebvttCueParser { return Util.splitAtFirst(tagExpression, "[ \\.]")[0]; } - private static void getApplicableStyles( - List declaredStyles, - @Nullable String id, - StartTag tag, - List output) { - int styleCount = declaredStyles.size(); - for (int i = 0; i < styleCount; i++) { + private static List getApplicableStyles( + List declaredStyles, @Nullable String id, StartTag tag) { + List applicableStyles = new ArrayList<>(); + for (int i = 0; i < declaredStyles.size(); i++) { WebvttCssStyle style = declaredStyles.get(i); int score = style.getSpecificityScore(id, tag.name, tag.classes, tag.voice); if (score > 0) { - output.add(new StyleMatch(score, style)); + applicableStyles.add(new StyleMatch(score, style)); } } - Collections.sort(output); + Collections.sort(applicableStyles); + return applicableStyles; } private static final class WebvttCueInfoBuilder { @@ -787,7 +813,7 @@ public final class WebvttCueParser { .setLineAnchor(lineAnchor) .setPosition(position) .setPositionAnchor(positionAnchor) - .setSize(Math.min(size, deriveMaxSize(positionAnchor, position))) + .setSize(min(size, deriveMaxSize(positionAnchor, position))) .setVerticalType(verticalType); if (text != null) { @@ -895,21 +921,19 @@ public final class WebvttCueParser { @Override public int compareTo(StyleMatch another) { - return this.score - another.score; + return Integer.compare(this.score, another.score); } } private static final class StartTag { - private static final String[] NO_CLASSES = new String[0]; - public final String name; public final int position; public final String voice; - public final String[] classes; + public final Set classes; - private StartTag(String name, int position, String voice, String[] classes) { + private StartTag(String name, int position, String voice, Set classes) { this.position = position; this.name = name; this.voice = voice; @@ -929,17 +953,19 @@ public final class WebvttCueParser { } String[] nameAndClasses = Util.split(fullTagExpression, "\\."); String name = nameAndClasses[0]; - String[] classes; - if (nameAndClasses.length > 1) { - classes = Util.nullSafeArrayCopyOfRange(nameAndClasses, 1, nameAndClasses.length); - } else { - classes = NO_CLASSES; + Set classes = new HashSet<>(); + for (int i = 1; i < nameAndClasses.length; i++) { + classes.add(nameAndClasses[i]); } return new StartTag(name, position, voice, classes); } public static StartTag buildWholeCueVirtualTag() { - return new StartTag("", 0, "", new String[0]); + return new StartTag( + /* name= */ "", + /* position= */ 0, + /* voice= */ "", + /* classes= */ Collections.emptySet()); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java index 6832033165..4a8f5a5471 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java @@ -85,16 +85,7 @@ import java.util.List; Collections.sort(cuesWithUnsetLine, (c1, c2) -> Long.compare(c1.startTimeUs, c2.startTimeUs)); for (int i = 0; i < cuesWithUnsetLine.size(); i++) { Cue cue = cuesWithUnsetLine.get(i).cue; - currentCues.add( - cue.buildUpon() - .setLine((float) (-1 - i), Cue.LINE_TYPE_NUMBER) - // WebVTT doesn't use 'line alignment' (i.e. Cue#lineAnchor) when computing position - // with snap-to-lines=true (i.e. Cue#LINE_TYPE_NUMBER) but Cue does use lineAnchor - // when describing how numeric cues should be displayed. So we have to manually set - // lineAnchor=ANCHOR_TYPE_END to avoid the bottom line of cues being off the screen. - // https://www.w3.org/TR/webvtt1/#processing-cue-settings - .setLineAnchor(Cue.ANCHOR_TYPE_END) - .build()); + currentCues.add(cue.buildUpon().setLine((float) (-1 - i), Cue.LINE_TYPE_NUMBER).build()); } return currentCues; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java index 9a599279ec..3173188cac 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java @@ -15,10 +15,12 @@ */ package com.google.android.exoplayer2.trackselection; +import static java.lang.Math.max; + +import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; @@ -26,6 +28,7 @@ import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.Iterables; import java.util.ArrayList; import java.util.List; import org.checkerframework.checker.nullness.compatqual.NullableType; @@ -39,13 +42,11 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { /** Factory for {@link AdaptiveTrackSelection} instances. */ public static class Factory implements TrackSelection.Factory { - @Nullable private final BandwidthMeter bandwidthMeter; private final int minDurationForQualityIncreaseMs; private final int maxDurationForQualityDecreaseMs; private final int minDurationToRetainAfterDiscardMs; private final float bandwidthFraction; private final float bufferedFractionToLiveEdgeForQualityIncrease; - private final long minTimeBetweenBufferReevaluationMs; private final Clock clock; /** Creates an adaptive track selection factory with default parameters. */ @@ -56,25 +57,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, DEFAULT_BANDWIDTH_FRACTION, DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, - Clock.DEFAULT); - } - - /** - * @deprecated Use {@link #Factory()} instead. Custom bandwidth meter should be directly passed - * to the player in {@link SimpleExoPlayer.Builder}. - */ - @Deprecated - @SuppressWarnings("deprecation") - public Factory(BandwidthMeter bandwidthMeter) { - this( - bandwidthMeter, - DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, - DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, - DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, - DEFAULT_BANDWIDTH_FRACTION, - DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, Clock.DEFAULT); } @@ -104,30 +86,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { minDurationToRetainAfterDiscardMs, bandwidthFraction, DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, - Clock.DEFAULT); - } - - /** - * @deprecated Use {@link #Factory(int, int, int, float)} instead. Custom bandwidth meter should - * be directly passed to the player in {@link SimpleExoPlayer.Builder}. - */ - @Deprecated - @SuppressWarnings("deprecation") - public Factory( - BandwidthMeter bandwidthMeter, - int minDurationForQualityIncreaseMs, - int maxDurationForQualityDecreaseMs, - int minDurationToRetainAfterDiscardMs, - float bandwidthFraction) { - this( - bandwidthMeter, - minDurationForQualityIncreaseMs, - maxDurationForQualityDecreaseMs, - minDurationToRetainAfterDiscardMs, - bandwidthFraction, - DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, Clock.DEFAULT); } @@ -151,64 +109,27 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { * applied when the playback position is closer to the live edge than {@code * minDurationForQualityIncreaseMs}, which would otherwise prevent switching to a higher * quality from happening. - * @param minTimeBetweenBufferReevaluationMs The track selection may periodically reevaluate its - * buffer and discard some chunks of lower quality to improve the playback quality if - * network conditions have changed. This is the minimum duration between 2 consecutive - * buffer reevaluation calls. * @param clock A {@link Clock}. */ - @SuppressWarnings("deprecation") public Factory( int minDurationForQualityIncreaseMs, int maxDurationForQualityDecreaseMs, int minDurationToRetainAfterDiscardMs, float bandwidthFraction, float bufferedFractionToLiveEdgeForQualityIncrease, - long minTimeBetweenBufferReevaluationMs, Clock clock) { - this( - /* bandwidthMeter= */ null, - minDurationForQualityIncreaseMs, - maxDurationForQualityDecreaseMs, - minDurationToRetainAfterDiscardMs, - bandwidthFraction, - bufferedFractionToLiveEdgeForQualityIncrease, - minTimeBetweenBufferReevaluationMs, - clock); - } - - /** - * @deprecated Use {@link #Factory(int, int, int, float, float, long, Clock)} instead. Custom - * bandwidth meter should be directly passed to the player in {@link - * SimpleExoPlayer.Builder}. - */ - @Deprecated - public Factory( - @Nullable BandwidthMeter bandwidthMeter, - int minDurationForQualityIncreaseMs, - int maxDurationForQualityDecreaseMs, - int minDurationToRetainAfterDiscardMs, - float bandwidthFraction, - float bufferedFractionToLiveEdgeForQualityIncrease, - long minTimeBetweenBufferReevaluationMs, - Clock clock) { - this.bandwidthMeter = bandwidthMeter; this.minDurationForQualityIncreaseMs = minDurationForQualityIncreaseMs; this.maxDurationForQualityDecreaseMs = maxDurationForQualityDecreaseMs; this.minDurationToRetainAfterDiscardMs = minDurationToRetainAfterDiscardMs; this.bandwidthFraction = bandwidthFraction; this.bufferedFractionToLiveEdgeForQualityIncrease = bufferedFractionToLiveEdgeForQualityIncrease; - this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; this.clock = clock; } @Override public final @NullableType TrackSelection[] createTrackSelections( @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { - if (this.bandwidthMeter != null) { - bandwidthMeter = this.bandwidthMeter; - } TrackSelection[] selections = new TrackSelection[definitions.length]; int totalFixedBandwidth = 0; for (int i = 0; i < definitions.length; i++) { @@ -249,7 +170,7 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { for (int i = 0; i < adaptiveSelections.size(); i++) { adaptiveSelections .get(i) - .experimental_setBandwidthAllocationCheckpoints(bandwidthCheckpoints[i]); + .experimentalSetBandwidthAllocationCheckpoints(bandwidthCheckpoints[i]); } } return selections; @@ -278,30 +199,30 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { maxDurationForQualityDecreaseMs, minDurationToRetainAfterDiscardMs, bufferedFractionToLiveEdgeForQualityIncrease, - minTimeBetweenBufferReevaluationMs, clock); } } - public static final int DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS = 10000; - public static final int DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS = 25000; - public static final int DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS = 25000; + public static final int DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS = 10_000; + public static final int DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS = 25_000; + public static final int DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS = 25_000; public static final float DEFAULT_BANDWIDTH_FRACTION = 0.7f; public static final float DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE = 0.75f; - public static final long DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS = 2000; + + private static final long MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS = 1000; private final BandwidthProvider bandwidthProvider; private final long minDurationForQualityIncreaseUs; private final long maxDurationForQualityDecreaseUs; private final long minDurationToRetainAfterDiscardUs; private final float bufferedFractionToLiveEdgeForQualityIncrease; - private final long minTimeBetweenBufferReevaluationMs; private final Clock clock; private float playbackSpeed; private int selectedIndex; private int reason; private long lastBufferEvaluationMs; + @Nullable private MediaChunk lastBufferEvaluationMediaChunk; /** * @param group The {@link TrackGroup}. @@ -321,7 +242,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, DEFAULT_BANDWIDTH_FRACTION, DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, Clock.DEFAULT); } @@ -349,10 +269,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { * when the playback position is closer to the live edge than {@code * minDurationForQualityIncreaseMs}, which would otherwise prevent switching to a higher * quality from happening. - * @param minTimeBetweenBufferReevaluationMs The track selection may periodically reevaluate its - * buffer and discard some chunks of lower quality to improve the playback quality if network - * condition has changed. This is the minimum duration between 2 consecutive buffer - * reevaluation calls. */ public AdaptiveTrackSelection( TrackGroup group, @@ -364,7 +280,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { long minDurationToRetainAfterDiscardMs, float bandwidthFraction, float bufferedFractionToLiveEdgeForQualityIncrease, - long minTimeBetweenBufferReevaluationMs, Clock clock) { this( group, @@ -374,7 +289,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { maxDurationForQualityDecreaseMs, minDurationToRetainAfterDiscardMs, bufferedFractionToLiveEdgeForQualityIncrease, - minTimeBetweenBufferReevaluationMs, clock); } @@ -386,7 +300,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { long maxDurationForQualityDecreaseMs, long minDurationToRetainAfterDiscardMs, float bufferedFractionToLiveEdgeForQualityIncrease, - long minTimeBetweenBufferReevaluationMs, Clock clock) { super(group, tracks); this.bandwidthProvider = bandwidthProvider; @@ -395,7 +308,6 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { this.minDurationToRetainAfterDiscardUs = minDurationToRetainAfterDiscardMs * 1000L; this.bufferedFractionToLiveEdgeForQualityIncrease = bufferedFractionToLiveEdgeForQualityIncrease; - this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; this.clock = clock; playbackSpeed = 1f; reason = C.SELECTION_REASON_UNKNOWN; @@ -408,14 +320,23 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { * @param allocationCheckpoints List of checkpoints. Each element must be a long[2], with [0] * being the total bandwidth and [1] being the allocated bandwidth. */ - public void experimental_setBandwidthAllocationCheckpoints(long[][] allocationCheckpoints) { + public void experimentalSetBandwidthAllocationCheckpoints(long[][] allocationCheckpoints) { ((DefaultBandwidthProvider) bandwidthProvider) - .experimental_setBandwidthAllocationCheckpoints(allocationCheckpoints); + .experimentalSetBandwidthAllocationCheckpoints(allocationCheckpoints); } + @CallSuper @Override public void enable() { lastBufferEvaluationMs = C.TIME_UNSET; + lastBufferEvaluationMediaChunk = null; + } + + @CallSuper + @Override + public void disable() { + // Avoid keeping a reference to a MediaChunk in case it prevents garbage collection. + lastBufferEvaluationMediaChunk = null; } @Override @@ -439,33 +360,35 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { return; } - // Stash the current selection, then make a new one. - int currentSelectedIndex = selectedIndex; - selectedIndex = determineIdealSelectedIndex(nowMs); - if (selectedIndex == currentSelectedIndex) { - return; + int previousSelectedIndex = selectedIndex; + int previousReason = reason; + int formatIndexOfPreviousChunk = + queue.isEmpty() ? C.INDEX_UNSET : indexOf(Iterables.getLast(queue).trackFormat); + if (formatIndexOfPreviousChunk != C.INDEX_UNSET) { + previousSelectedIndex = formatIndexOfPreviousChunk; + previousReason = Iterables.getLast(queue).trackSelectionReason; } - - if (!isBlacklisted(currentSelectedIndex, nowMs)) { - // Revert back to the current selection if conditions are not suitable for switching. - Format currentFormat = getFormat(currentSelectedIndex); - Format selectedFormat = getFormat(selectedIndex); + int newSelectedIndex = determineIdealSelectedIndex(nowMs); + if (!isBlacklisted(previousSelectedIndex, nowMs)) { + // Revert back to the previous selection if conditions are not suitable for switching. + Format currentFormat = getFormat(previousSelectedIndex); + Format selectedFormat = getFormat(newSelectedIndex); if (selectedFormat.bitrate > currentFormat.bitrate && bufferedDurationUs < minDurationForQualityIncreaseUs(availableDurationUs)) { // The selected track is a higher quality, but we have insufficient buffer to safely switch // up. Defer switching up for now. - selectedIndex = currentSelectedIndex; + newSelectedIndex = previousSelectedIndex; } else if (selectedFormat.bitrate < currentFormat.bitrate && bufferedDurationUs >= maxDurationForQualityDecreaseUs) { // The selected track is a lower quality, but we have sufficient buffer to defer switching // down for now. - selectedIndex = currentSelectedIndex; + newSelectedIndex = previousSelectedIndex; } } // If we adapted, update the trigger. - if (selectedIndex != currentSelectedIndex) { - reason = C.SELECTION_REASON_ADAPTIVE; - } + reason = + newSelectedIndex == previousSelectedIndex ? previousReason : C.SELECTION_REASON_ADAPTIVE; + selectedIndex = newSelectedIndex; } @Override @@ -487,15 +410,15 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { @Override public int evaluateQueueSize(long playbackPositionUs, List queue) { long nowMs = clock.elapsedRealtime(); - if (!shouldEvaluateQueueSize(nowMs)) { + if (!shouldEvaluateQueueSize(nowMs, queue)) { return queue.size(); } - lastBufferEvaluationMs = nowMs; + lastBufferEvaluationMediaChunk = queue.isEmpty() ? null : Iterables.getLast(queue); + if (queue.isEmpty()) { return 0; } - int queueSize = queue.size(); MediaChunk lastChunk = queue.get(queueSize - 1); long playoutBufferedDurationBeforeLastChunkUs = @@ -548,11 +471,13 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { * performed. * * @param nowMs The current value of {@link Clock#elapsedRealtime()}. + * @param queue The queue of buffered {@link MediaChunk MediaChunks}. Must not be modified. * @return Whether an evaluation should be performed. */ - protected boolean shouldEvaluateQueueSize(long nowMs) { + protected boolean shouldEvaluateQueueSize(long nowMs, List queue) { return lastBufferEvaluationMs == C.TIME_UNSET - || nowMs - lastBufferEvaluationMs >= minTimeBetweenBufferReevaluationMs; + || nowMs - lastBufferEvaluationMs >= MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS + || (!queue.isEmpty() && !Iterables.getLast(queue).equals(lastBufferEvaluationMediaChunk)); } /** @@ -569,22 +494,22 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { * Computes the ideal selected index ignoring buffer health. * * @param nowMs The current time in the timebase of {@link Clock#elapsedRealtime()}, or {@link - * Long#MIN_VALUE} to ignore blacklisting. + * Long#MIN_VALUE} to ignore track exclusion. */ private int determineIdealSelectedIndex(long nowMs) { long effectiveBitrate = bandwidthProvider.getAllocatedBandwidth(); - int lowestBitrateNonBlacklistedIndex = 0; + int lowestBitrateAllowedIndex = 0; for (int i = 0; i < length; i++) { if (nowMs == Long.MIN_VALUE || !isBlacklisted(i, nowMs)) { Format format = getFormat(i); if (canSelectFormat(format, format.bitrate, playbackSpeed, effectiveBitrate)) { return i; } else { - lowestBitrateNonBlacklistedIndex = i; + lowestBitrateAllowedIndex = i; } } } - return lowestBitrateNonBlacklistedIndex; + return lowestBitrateAllowedIndex; } private long minDurationForQualityIncreaseUs(long availableDurationUs) { @@ -620,7 +545,7 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { @Override public long getAllocatedBandwidth() { long totalBandwidth = (long) (bandwidthMeter.getBitrateEstimate() * bandwidthFraction); - long allocatableBandwidth = Math.max(0L, totalBandwidth - reservedBandwidth); + long allocatableBandwidth = max(0L, totalBandwidth - reservedBandwidth); if (allocationCheckpoints == null) { return allocatableBandwidth; } @@ -636,7 +561,7 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { return previous[1] + (long) (fractionBetweenCheckpoints * (next[1] - previous[1])); } - /* package */ void experimental_setBandwidthAllocationCheckpoints( + /* package */ void experimentalSetBandwidthAllocationCheckpoints( long[][] allocationCheckpoints) { Assertions.checkArgument(allocationCheckpoints.length >= 2); this.allocationCheckpoints = allocationCheckpoints; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BaseTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BaseTrackSelection.java index dc0b3f6747..4be4bf7075 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BaseTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/BaseTrackSelection.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.trackselection; +import static java.lang.Math.max; + import android.os.SystemClock; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -24,7 +26,6 @@ import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; -import java.util.Comparator; import java.util.List; /** @@ -49,10 +50,8 @@ public abstract class BaseTrackSelection implements TrackSelection { * The {@link Format}s of the selected tracks, in order of decreasing bandwidth. */ private final Format[] formats; - /** - * Selected track blacklist timestamps, in order of decreasing bandwidth. - */ - private final long[] blacklistUntilTimes; + /** Selected track exclusion timestamps, in order of decreasing bandwidth. */ + private final long[] excludeUntilTimes; // Lazily initialized hashcode. private int hashCode; @@ -71,13 +70,14 @@ public abstract class BaseTrackSelection implements TrackSelection { for (int i = 0; i < tracks.length; i++) { formats[i] = group.getFormat(tracks[i]); } - Arrays.sort(formats, new DecreasingBandwidthComparator()); + // Sort in order of decreasing bandwidth. + Arrays.sort(formats, (a, b) -> b.bitrate - a.bitrate); // Set the format indices in the same order. this.tracks = new int[length]; for (int i = 0; i < length; i++) { this.tracks[i] = group.indexOf(formats[i]); } - blacklistUntilTimes = new long[length]; + excludeUntilTimes = new long[length]; } @Override @@ -152,30 +152,30 @@ public abstract class BaseTrackSelection implements TrackSelection { } @Override - public final boolean blacklist(int index, long blacklistDurationMs) { + public final boolean blacklist(int index, long exclusionDurationMs) { long nowMs = SystemClock.elapsedRealtime(); - boolean canBlacklist = isBlacklisted(index, nowMs); - for (int i = 0; i < length && !canBlacklist; i++) { - canBlacklist = i != index && !isBlacklisted(i, nowMs); + boolean canExclude = isBlacklisted(index, nowMs); + for (int i = 0; i < length && !canExclude; i++) { + canExclude = i != index && !isBlacklisted(i, nowMs); } - if (!canBlacklist) { + if (!canExclude) { return false; } - blacklistUntilTimes[index] = - Math.max( - blacklistUntilTimes[index], - Util.addWithOverflowDefault(nowMs, blacklistDurationMs, Long.MAX_VALUE)); + excludeUntilTimes[index] = + max( + excludeUntilTimes[index], + Util.addWithOverflowDefault(nowMs, exclusionDurationMs, Long.MAX_VALUE)); return true; } /** - * Returns whether the track at the specified index in the selection is blacklisted. + * Returns whether the track at the specified index in the selection is excluded. * * @param index The index of the track in the selection. * @param nowMs The current time in the timebase of {@link SystemClock#elapsedRealtime()}. */ protected final boolean isBlacklisted(int index, long nowMs) { - return blacklistUntilTimes[index] > nowMs; + return excludeUntilTimes[index] > nowMs; } // Object overrides. @@ -201,17 +201,4 @@ public abstract class BaseTrackSelection implements TrackSelection { BaseTrackSelection other = (BaseTrackSelection) obj; return group == other.group && Arrays.equals(tracks, other.tracks); } - - /** - * Sorts {@link Format} objects in order of decreasing bandwidth. - */ - private static final class DecreasingBandwidthComparator implements Comparator { - - @Override - public int compare(Format a, Format b) { - return b.bitrate - a.bitrate; - } - - } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index 668202993a..c9f0e290c9 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 @@ -36,9 +36,12 @@ import com.google.android.exoplayer2.RendererCapabilities.FormatSupport; import com.google.android.exoplayer2.RendererConfiguration; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ComparisonChain; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Ordering; +import com.google.common.primitives.Ints; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -168,6 +171,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { private int maxVideoHeight; private int maxVideoFrameRate; private int maxVideoBitrate; + private int minVideoWidth; + private int minVideoHeight; + private int minVideoFrameRate; + private int minVideoBitrate; private boolean exceedVideoConstraintsIfNecessary; private boolean allowVideoMixedMimeTypeAdaptiveness; private boolean allowVideoNonSeamlessAdaptiveness; @@ -229,6 +236,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { maxVideoHeight = initialValues.maxVideoHeight; maxVideoFrameRate = initialValues.maxVideoFrameRate; maxVideoBitrate = initialValues.maxVideoBitrate; + minVideoWidth = initialValues.minVideoWidth; + minVideoHeight = initialValues.minVideoHeight; + minVideoFrameRate = initialValues.minVideoFrameRate; + minVideoBitrate = initialValues.minVideoBitrate; exceedVideoConstraintsIfNecessary = initialValues.exceedVideoConstraintsIfNecessary; allowVideoMixedMimeTypeAdaptiveness = initialValues.allowVideoMixedMimeTypeAdaptiveness; allowVideoNonSeamlessAdaptiveness = initialValues.allowVideoNonSeamlessAdaptiveness; @@ -309,8 +320,43 @@ public class DefaultTrackSelector extends MappingTrackSelector { } /** - * Sets whether to exceed the {@link #setMaxVideoSize(int, int)} and {@link - * #setMaxAudioBitrate(int)} constraints when no selection can be made otherwise. + * Sets the minimum allowed video width and height. + * + * @param minVideoWidth Minimum allowed video width in pixels. + * @param minVideoHeight Minimum allowed video height in pixels. + * @return This builder. + */ + public ParametersBuilder setMinVideoSize(int minVideoWidth, int minVideoHeight) { + this.minVideoWidth = minVideoWidth; + this.minVideoHeight = minVideoHeight; + return this; + } + + /** + * Sets the minimum allowed video frame rate. + * + * @param minVideoFrameRate Minimum allowed video frame rate in hertz. + * @return This builder. + */ + public ParametersBuilder setMinVideoFrameRate(int minVideoFrameRate) { + this.minVideoFrameRate = minVideoFrameRate; + return this; + } + + /** + * Sets the minimum allowed video bitrate. + * + * @param minVideoBitrate Minimum allowed video bitrate in bits per second. + * @return This builder. + */ + public ParametersBuilder setMinVideoBitrate(int minVideoBitrate) { + this.minVideoBitrate = minVideoBitrate; + return this; + } + + /** + * Sets whether to exceed the {@link #setMaxVideoBitrate}, {@link #setMaxVideoSize(int, int)} + * and {@link #setMaxVideoFrameRate} constraints when no selection can be made otherwise. * * @param exceedVideoConstraintsIfNecessary Whether to exceed video constraints when no * selection can be made otherwise. @@ -405,6 +451,12 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } + @Override + public ParametersBuilder setPreferredAudioLanguages(String... preferredAudioLanguages) { + super.setPreferredAudioLanguages(preferredAudioLanguages); + return this; + } + /** * Sets the maximum allowed audio channel count. * @@ -501,6 +553,12 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } + @Override + public ParametersBuilder setPreferredTextLanguages(String... preferredTextLanguages) { + super.setPreferredTextLanguages(preferredTextLanguages); + return this; + } + @Override public ParametersBuilder setPreferredTextRoleFlags(@C.RoleFlags int preferredTextRoleFlags) { super.setPreferredTextRoleFlags(preferredTextRoleFlags); @@ -711,6 +769,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { maxVideoHeight, maxVideoFrameRate, maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate, exceedVideoConstraintsIfNecessary, allowVideoMixedMimeTypeAdaptiveness, allowVideoNonSeamlessAdaptiveness, @@ -718,7 +780,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { viewportHeight, viewportOrientationMayChange, // Audio - preferredAudioLanguage, + preferredAudioLanguages, maxAudioChannelCount, maxAudioBitrate, exceedAudioConstraintsIfNecessary, @@ -726,7 +788,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { allowAudioMixedSampleRateAdaptiveness, allowAudioMixedChannelCountAdaptiveness, // Text - preferredTextLanguage, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags, @@ -836,6 +898,17 @@ public class DefaultTrackSelector extends MappingTrackSelector { * Integer#MAX_VALUE} (i.e. no constraint). */ public final int maxVideoBitrate; + /** Minimum allowed video width in pixels. The default value is 0 (i.e. no constraint). */ + public final int minVideoWidth; + /** Minimum allowed video height in pixels. The default value is 0 (i.e. no constraint). */ + public final int minVideoHeight; + /** Minimum allowed video frame rate in hertz. The default value is 0 (i.e. no constraint). */ + public final int minVideoFrameRate; + /** + * Minimum allowed video bitrate in bits per second. The default value is 0 (i.e. no + * constraint). + */ + public final int minVideoBitrate; /** * Whether to exceed the {@link #maxVideoWidth}, {@link #maxVideoHeight} and {@link * #maxVideoBitrate} constraints when no selection can be made otherwise. The default value is @@ -944,6 +1017,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maxVideoHeight, int maxVideoFrameRate, int maxVideoBitrate, + int minVideoWidth, + int minVideoHeight, + int minVideoFrameRate, + int minVideoBitrate, boolean exceedVideoConstraintsIfNecessary, boolean allowVideoMixedMimeTypeAdaptiveness, boolean allowVideoNonSeamlessAdaptiveness, @@ -951,7 +1028,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { int viewportHeight, boolean viewportOrientationMayChange, // Audio - @Nullable String preferredAudioLanguage, + ImmutableList preferredAudioLanguages, int maxAudioChannelCount, int maxAudioBitrate, boolean exceedAudioConstraintsIfNecessary, @@ -959,7 +1036,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { boolean allowAudioMixedSampleRateAdaptiveness, boolean allowAudioMixedChannelCountAdaptiveness, // Text - @Nullable String preferredTextLanguage, + ImmutableList preferredTextLanguages, @C.RoleFlags int preferredTextRoleFlags, boolean selectUndeterminedTextLanguage, @C.SelectionFlags int disabledTextTrackSelectionFlags, @@ -972,8 +1049,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { SparseArray> selectionOverrides, SparseBooleanArray rendererDisabledFlags) { super( - preferredAudioLanguage, - preferredTextLanguage, + preferredAudioLanguages, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags); @@ -982,6 +1059,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { this.maxVideoHeight = maxVideoHeight; this.maxVideoFrameRate = maxVideoFrameRate; this.maxVideoBitrate = maxVideoBitrate; + this.minVideoWidth = minVideoWidth; + this.minVideoHeight = minVideoHeight; + this.minVideoFrameRate = minVideoFrameRate; + this.minVideoBitrate = minVideoBitrate; this.exceedVideoConstraintsIfNecessary = exceedVideoConstraintsIfNecessary; this.allowVideoMixedMimeTypeAdaptiveness = allowVideoMixedMimeTypeAdaptiveness; this.allowVideoNonSeamlessAdaptiveness = allowVideoNonSeamlessAdaptiveness; @@ -1013,6 +1094,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { this.maxVideoHeight = in.readInt(); this.maxVideoFrameRate = in.readInt(); this.maxVideoBitrate = in.readInt(); + this.minVideoWidth = in.readInt(); + this.minVideoHeight = in.readInt(); + this.minVideoFrameRate = in.readInt(); + this.minVideoBitrate = in.readInt(); this.exceedVideoConstraintsIfNecessary = Util.readBoolean(in); this.allowVideoMixedMimeTypeAdaptiveness = Util.readBoolean(in); this.allowVideoNonSeamlessAdaptiveness = Util.readBoolean(in); @@ -1094,6 +1179,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { && maxVideoHeight == other.maxVideoHeight && maxVideoFrameRate == other.maxVideoFrameRate && maxVideoBitrate == other.maxVideoBitrate + && minVideoWidth == other.minVideoWidth + && minVideoHeight == other.minVideoHeight + && minVideoFrameRate == other.minVideoFrameRate + && minVideoBitrate == other.minVideoBitrate && exceedVideoConstraintsIfNecessary == other.exceedVideoConstraintsIfNecessary && allowVideoMixedMimeTypeAdaptiveness == other.allowVideoMixedMimeTypeAdaptiveness && allowVideoNonSeamlessAdaptiveness == other.allowVideoNonSeamlessAdaptiveness @@ -1126,6 +1215,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { result = 31 * result + maxVideoHeight; result = 31 * result + maxVideoFrameRate; result = 31 * result + maxVideoBitrate; + result = 31 * result + minVideoWidth; + result = 31 * result + minVideoHeight; + result = 31 * result + minVideoFrameRate; + result = 31 * result + minVideoBitrate; result = 31 * result + (exceedVideoConstraintsIfNecessary ? 1 : 0); result = 31 * result + (allowVideoMixedMimeTypeAdaptiveness ? 1 : 0); result = 31 * result + (allowVideoNonSeamlessAdaptiveness ? 1 : 0); @@ -1163,6 +1256,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { dest.writeInt(maxVideoHeight); dest.writeInt(maxVideoFrameRate); dest.writeInt(maxVideoBitrate); + dest.writeInt(minVideoWidth); + dest.writeInt(minVideoHeight); + dest.writeInt(minVideoFrameRate); + dest.writeInt(minVideoBitrate); Util.writeBoolean(dest, exceedVideoConstraintsIfNecessary); Util.writeBoolean(dest, allowVideoMixedMimeTypeAdaptiveness); Util.writeBoolean(dest, allowVideoNonSeamlessAdaptiveness); @@ -1406,7 +1503,15 @@ public class DefaultTrackSelector extends MappingTrackSelector { */ private static final float FRACTION_TO_CONSIDER_FULLSCREEN = 0.98f; private static final int[] NO_TRACKS = new int[0]; - private static final int WITHIN_RENDERER_CAPABILITIES_BONUS = 1000; + /** Ordering of two format values. A known value is considered greater than Format#NO_VALUE. */ + private static final Ordering FORMAT_VALUE_ORDERING = + Ordering.from( + (first, second) -> + first == Format.NO_VALUE + ? (second == Format.NO_VALUE ? 0 : -1) + : (second == Format.NO_VALUE ? 1 : (first - second))); + /** Ordering where all elements are equal. */ + private static final Ordering NO_ORDER = Ordering.from((first, second) -> 0); private final TrackSelection.Factory trackSelectionFactory; private final AtomicReference parametersReference; @@ -1415,20 +1520,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { /** @deprecated Use {@link #DefaultTrackSelector(Context)} instead. */ @Deprecated - @SuppressWarnings("deprecation") public DefaultTrackSelector() { - this(new AdaptiveTrackSelection.Factory()); - } - - /** - * @deprecated Use {@link #DefaultTrackSelector(Context)} instead. The bandwidth meter should be - * passed directly to the player in {@link - * com.google.android.exoplayer2.SimpleExoPlayer.Builder}. - */ - @Deprecated - @SuppressWarnings("deprecation") - public DefaultTrackSelector(BandwidthMeter bandwidthMeter) { - this(new AdaptiveTrackSelection.Factory(bandwidthMeter)); + this(Parameters.DEFAULT_WITHOUT_CONTEXT, new AdaptiveTrackSelection.Factory()); } /** @deprecated Use {@link #DefaultTrackSelector(Context, TrackSelection.Factory)}. */ @@ -1499,7 +1592,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { * *

      This method is experimental, and will be renamed or removed in a future release. */ - public void experimental_allowMultipleAdaptiveSelections() { + public void experimentalAllowMultipleAdaptiveSelections() { this.allowMultipleAdaptiveSelections = true; } @@ -1615,13 +1708,14 @@ public class DefaultTrackSelector extends MappingTrackSelector { } } - AudioTrackScore selectedAudioTrackScore = null; - String selectedAudioLanguage = null; + @Nullable AudioTrackScore selectedAudioTrackScore = null; + @Nullable String selectedAudioLanguage = null; int selectedAudioRendererIndex = C.INDEX_UNSET; for (int i = 0; i < rendererCount; i++) { if (C.TRACK_TYPE_AUDIO == mappedTrackInfo.getRendererType(i)) { boolean enableAdaptiveTrackSelection = allowMultipleAdaptiveSelections || !seenVideoRendererWithMappedTracks; + @Nullable Pair audioSelection = selectAudioTrack( mappedTrackInfo.getTrackGroups(i), @@ -1647,7 +1741,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { } } - TextTrackScore selectedTextTrackScore = null; + @Nullable TextTrackScore selectedTextTrackScore = null; int selectedTextRendererIndex = C.INDEX_UNSET; for (int i = 0; i < rendererCount; i++) { int trackType = mappedTrackInfo.getRendererType(i); @@ -1657,6 +1751,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { // Already done. Do nothing. break; case C.TRACK_TYPE_TEXT: + @Nullable Pair textSelection = selectTextTrack( mappedTrackInfo.getTrackGroups(i), @@ -1694,8 +1789,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { * {@link TrackSelection} for a video renderer. * * @param groups The {@link TrackGroupArray} mapped to the renderer. - * @param formatSupports The {@link Capabilities} for each mapped track, indexed by renderer, - * track group and track (in that order). + * @param formatSupport The {@link Capabilities} for each mapped track, indexed by track group and + * track (in that order). * @param mixedMimeTypeAdaptationSupports The {@link AdaptiveSupport} for mixed MIME type * adaptation for the renderer. * @param params The selector's current constraint parameters. @@ -1707,7 +1802,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { @Nullable protected TrackSelection.Definition selectVideoTrack( TrackGroupArray groups, - @Capabilities int[][] formatSupports, + @Capabilities int[][] formatSupport, @AdaptiveSupport int mixedMimeTypeAdaptationSupports, Parameters params, boolean enableAdaptiveTrackSelection) @@ -1717,10 +1812,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { && !params.forceLowestBitrate && enableAdaptiveTrackSelection) { definition = - selectAdaptiveVideoTrack(groups, formatSupports, mixedMimeTypeAdaptationSupports, params); + selectAdaptiveVideoTrack(groups, formatSupport, mixedMimeTypeAdaptationSupports, params); } if (definition == null) { - definition = selectFixedVideoTrack(groups, formatSupports, params); + definition = selectFixedVideoTrack(groups, formatSupport, params); } return definition; } @@ -1750,6 +1845,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { params.maxVideoHeight, params.maxVideoFrameRate, params.maxVideoBitrate, + params.minVideoWidth, + params.minVideoHeight, + params.minVideoFrameRate, + params.minVideoBitrate, params.viewportWidth, params.viewportHeight, params.viewportOrientationMayChange); @@ -1769,6 +1868,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maxVideoHeight, int maxVideoFrameRate, int maxVideoBitrate, + int minVideoWidth, + int minVideoHeight, + int minVideoFrameRate, + int minVideoBitrate, int viewportWidth, int viewportHeight, boolean viewportOrientationMayChange) { @@ -1801,6 +1904,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { maxVideoHeight, maxVideoFrameRate, maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate, selectedTrackIndices); if (countForMimeType > selectedMimeTypeTrackCount) { selectedMimeType = sampleMimeType; @@ -1820,9 +1927,13 @@ public class DefaultTrackSelector extends MappingTrackSelector { maxVideoHeight, maxVideoFrameRate, maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate, selectedTrackIndices); - return selectedTrackIndices.size() < 2 ? NO_TRACKS : Util.toArray(selectedTrackIndices); + return selectedTrackIndices.size() < 2 ? NO_TRACKS : Ints.toArray(selectedTrackIndices); } private static int getAdaptiveVideoTrackCountForMimeType( @@ -1834,6 +1945,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maxVideoHeight, int maxVideoFrameRate, int maxVideoBitrate, + int minVideoWidth, + int minVideoHeight, + int minVideoFrameRate, + int minVideoBitrate, List selectedTrackIndices) { int adaptiveTrackCount = 0; for (int i = 0; i < selectedTrackIndices.size(); i++) { @@ -1846,7 +1961,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { maxVideoWidth, maxVideoHeight, maxVideoFrameRate, - maxVideoBitrate)) { + maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate)) { adaptiveTrackCount++; } } @@ -1862,6 +1981,10 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maxVideoHeight, int maxVideoFrameRate, int maxVideoBitrate, + int minVideoWidth, + int minVideoHeight, + int minVideoFrameRate, + int minVideoBitrate, List selectedTrackIndices) { for (int i = selectedTrackIndices.size() - 1; i >= 0; i--) { int trackIndex = selectedTrackIndices.get(i); @@ -1873,7 +1996,11 @@ public class DefaultTrackSelector extends MappingTrackSelector { maxVideoWidth, maxVideoHeight, maxVideoFrameRate, - maxVideoBitrate)) { + maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate)) { selectedTrackIndices.remove(i); } } @@ -1887,33 +2014,43 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maxVideoWidth, int maxVideoHeight, int maxVideoFrameRate, - int maxVideoBitrate) { + int maxVideoBitrate, + int minVideoWidth, + int minVideoHeight, + int minVideoFrameRate, + int minVideoBitrate) { if ((format.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { // Ignore trick-play tracks for now. return false; } - return isSupported(formatSupport, false) + return isSupported(formatSupport, /* allowExceedsCapabilities= */ false) && ((formatSupport & requiredAdaptiveSupport) != 0) && (mimeType == null || Util.areEqual(format.sampleMimeType, mimeType)) - && (format.width == Format.NO_VALUE || format.width <= maxVideoWidth) - && (format.height == Format.NO_VALUE || format.height <= maxVideoHeight) - && (format.frameRate == Format.NO_VALUE || format.frameRate <= maxVideoFrameRate) - && (format.bitrate == Format.NO_VALUE || format.bitrate <= maxVideoBitrate); + && (format.width == Format.NO_VALUE + || (minVideoWidth <= format.width && format.width <= maxVideoWidth)) + && (format.height == Format.NO_VALUE + || (minVideoHeight <= format.height && format.height <= maxVideoHeight)) + && (format.frameRate == Format.NO_VALUE + || (minVideoFrameRate <= format.frameRate && format.frameRate <= maxVideoFrameRate)) + && (format.bitrate == Format.NO_VALUE + || (minVideoBitrate <= format.bitrate && format.bitrate <= maxVideoBitrate)); } @Nullable private static TrackSelection.Definition selectFixedVideoTrack( - TrackGroupArray groups, @Capabilities int[][] formatSupports, Parameters params) { - TrackGroup selectedGroup = null; - int selectedTrackIndex = 0; - int selectedTrackScore = 0; - int selectedBitrate = Format.NO_VALUE; - int selectedPixelCount = Format.NO_VALUE; + TrackGroupArray groups, @Capabilities int[][] formatSupport, Parameters params) { + int selectedTrackIndex = C.INDEX_UNSET; + @Nullable TrackGroup selectedGroup = null; + @Nullable VideoTrackScore selectedTrackScore = null; for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { TrackGroup trackGroup = groups.get(groupIndex); - List selectedTrackIndices = getViewportFilteredTrackIndices(trackGroup, - params.viewportWidth, params.viewportHeight, params.viewportOrientationMayChange); - @Capabilities int[] trackFormatSupport = formatSupports[groupIndex]; + List viewportFilteredTrackIndices = + getViewportFilteredTrackIndices( + trackGroup, + params.viewportWidth, + params.viewportHeight, + params.viewportOrientationMayChange); + @Capabilities int[] trackFormatSupport = formatSupport[groupIndex]; for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { Format format = trackGroup.getFormat(trackIndex); if ((format.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { @@ -1922,52 +2059,25 @@ public class DefaultTrackSelector extends MappingTrackSelector { } if (isSupported(trackFormatSupport[trackIndex], params.exceedRendererCapabilitiesIfNecessary)) { - boolean isWithinConstraints = - selectedTrackIndices.contains(trackIndex) - && (format.width == Format.NO_VALUE || format.width <= params.maxVideoWidth) - && (format.height == Format.NO_VALUE || format.height <= params.maxVideoHeight) - && (format.frameRate == Format.NO_VALUE - || format.frameRate <= params.maxVideoFrameRate) - && (format.bitrate == Format.NO_VALUE - || format.bitrate <= params.maxVideoBitrate); - if (!isWithinConstraints && !params.exceedVideoConstraintsIfNecessary) { + VideoTrackScore trackScore = + new VideoTrackScore( + format, + params, + trackFormatSupport[trackIndex], + viewportFilteredTrackIndices.contains(trackIndex)); + if (!trackScore.isWithinMaxConstraints && !params.exceedVideoConstraintsIfNecessary) { // Track should not be selected. continue; } - int trackScore = isWithinConstraints ? 2 : 1; - boolean isWithinCapabilities = isSupported(trackFormatSupport[trackIndex], false); - if (isWithinCapabilities) { - trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; - } - boolean selectTrack = trackScore > selectedTrackScore; - if (trackScore == selectedTrackScore) { - int bitrateComparison = compareFormatValues(format.bitrate, selectedBitrate); - if (params.forceLowestBitrate && bitrateComparison != 0) { - // Use bitrate as a tie breaker, preferring the lower bitrate. - selectTrack = bitrateComparison < 0; - } else { - // Use the pixel count as a tie breaker (or bitrate if pixel counts are tied). If - // we're within constraints prefer a higher pixel count (or bitrate), else prefer a - // lower count (or bitrate). If still tied then prefer the first track (i.e. the one - // that's already selected). - int formatPixelCount = format.getPixelCount(); - int comparisonResult = formatPixelCount != selectedPixelCount - ? compareFormatValues(formatPixelCount, selectedPixelCount) - : compareFormatValues(format.bitrate, selectedBitrate); - selectTrack = isWithinCapabilities && isWithinConstraints - ? comparisonResult > 0 : comparisonResult < 0; - } - } - if (selectTrack) { + if (selectedTrackScore == null || trackScore.compareTo(selectedTrackScore) > 0) { selectedGroup = trackGroup; selectedTrackIndex = trackIndex; selectedTrackScore = trackScore; - selectedBitrate = format.bitrate; - selectedPixelCount = format.getPixelCount(); } } } } + return selectedGroup == null ? null : new TrackSelection.Definition(selectedGroup, selectedTrackIndex); @@ -1980,8 +2090,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { * {@link TrackSelection} for an audio renderer. * * @param groups The {@link TrackGroupArray} mapped to the renderer. - * @param formatSupports The {@link Capabilities} for each mapped track, indexed by renderer, - * track group and track (in that order). + * @param formatSupport The {@link Capabilities} for each mapped track, indexed by track group and + * track (in that order). * @param mixedMimeTypeAdaptationSupports The {@link AdaptiveSupport} for mixed MIME type * adaptation for the renderer. * @param params The selector's current constraint parameters. @@ -1994,17 +2104,17 @@ public class DefaultTrackSelector extends MappingTrackSelector { @Nullable protected Pair selectAudioTrack( TrackGroupArray groups, - @Capabilities int[][] formatSupports, + @Capabilities int[][] formatSupport, @AdaptiveSupport int mixedMimeTypeAdaptationSupports, Parameters params, boolean enableAdaptiveTrackSelection) throws ExoPlaybackException { int selectedTrackIndex = C.INDEX_UNSET; int selectedGroupIndex = C.INDEX_UNSET; - AudioTrackScore selectedTrackScore = null; + @Nullable AudioTrackScore selectedTrackScore = null; for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { TrackGroup trackGroup = groups.get(groupIndex); - @Capabilities int[] trackFormatSupport = formatSupports[groupIndex]; + @Capabilities int[] trackFormatSupport = formatSupport[groupIndex]; for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { if (isSupported(trackFormatSupport[trackIndex], params.exceedRendererCapabilitiesIfNecessary)) { @@ -2038,7 +2148,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { int[] adaptiveTracks = getAdaptiveAudioTracks( selectedGroup, - formatSupports[selectedGroupIndex], + formatSupport[selectedGroupIndex], selectedTrackIndex, params.maxAudioBitrate, params.allowAudioMixedMimeTypeAdaptiveness, @@ -2111,8 +2221,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { * {@link TrackSelection} for a text renderer. * * @param groups The {@link TrackGroupArray} mapped to the renderer. - * @param formatSupport The {@link Capabilities} for each mapped track, indexed by renderer, track - * group and track (in that order). + * @param formatSupport The {@link Capabilities} for each mapped track, indexed by track group and + * track (in that order). * @param params The selector's current constraint parameters. * @param selectedAudioLanguage The language of the selected audio track. May be null if the * selected text track declares no language or no text track was selected. @@ -2127,9 +2237,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { Parameters params, @Nullable String selectedAudioLanguage) throws ExoPlaybackException { - TrackGroup selectedGroup = null; + @Nullable TrackGroup selectedGroup = null; int selectedTrackIndex = C.INDEX_UNSET; - TextTrackScore selectedTrackScore = null; + @Nullable TextTrackScore selectedTrackScore = null; for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { TrackGroup trackGroup = groups.get(groupIndex); @Capabilities int[] trackFormatSupport = formatSupport[groupIndex]; @@ -2164,8 +2274,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { * * @param trackType The type of the renderer. * @param groups The {@link TrackGroupArray} mapped to the renderer. - * @param formatSupport The {@link Capabilities} for each mapped track, indexed by renderer, track - * group and track (in that order). + * @param formatSupport The {@link Capabilities} for each mapped track, indexed by track group and + * track (in that order). * @param params The selector's current constraint parameters. * @return The {@link TrackSelection} for the renderer, or null if no selection was made. * @throws ExoPlaybackException If an error occurs while selecting the tracks. @@ -2174,9 +2284,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { protected TrackSelection.Definition selectOtherTrack( int trackType, TrackGroupArray groups, @Capabilities int[][] formatSupport, Parameters params) throws ExoPlaybackException { - TrackGroup selectedGroup = null; + @Nullable TrackGroup selectedGroup = null; int selectedTrackIndex = 0; - int selectedTrackScore = 0; + @Nullable OtherTrackScore selectedTrackScore = null; for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { TrackGroup trackGroup = groups.get(groupIndex); @Capabilities int[] trackFormatSupport = formatSupport[groupIndex]; @@ -2184,12 +2294,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { if (isSupported(trackFormatSupport[trackIndex], params.exceedRendererCapabilitiesIfNecessary)) { Format format = trackGroup.getFormat(trackIndex); - boolean isDefault = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; - int trackScore = isDefault ? 2 : 1; - if (isSupported(trackFormatSupport[trackIndex], false)) { - trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; - } - if (trackScore > selectedTrackScore) { + OtherTrackScore trackScore = new OtherTrackScore(format, trackFormatSupport[trackIndex]); + if (selectedTrackScore == null || trackScore.compareTo(selectedTrackScore) > 0) { selectedGroup = trackGroup; selectedTrackIndex = trackIndex; selectedTrackScore = trackScore; @@ -2269,21 +2375,21 @@ public class DefaultTrackSelector extends MappingTrackSelector { /** * Returns whether a renderer supports tunneling for a {@link TrackSelection}. * - * @param formatSupports The {@link Capabilities} for each track, indexed by group index and track + * @param formatSupport The {@link Capabilities} for each track, indexed by group index and track * index (in that order). * @param trackGroups The {@link TrackGroupArray}s for the renderer. * @param selection The track selection. * @return Whether the renderer supports tunneling for the {@link TrackSelection}. */ private static boolean rendererSupportsTunneling( - @Capabilities int[][] formatSupports, TrackGroupArray trackGroups, TrackSelection selection) { + @Capabilities int[][] formatSupport, TrackGroupArray trackGroups, TrackSelection selection) { if (selection == null) { return false; } int trackGroupIndex = trackGroups.indexOf(selection.getTrackGroup()); for (int i = 0; i < selection.length(); i++) { @Capabilities - int trackFormatSupport = formatSupports[trackGroupIndex][selection.getIndexInTrackGroup(i)]; + int trackFormatSupport = formatSupport[trackGroupIndex][selection.getIndexInTrackGroup(i)]; if (RendererCapabilities.getTunnelingSupport(trackFormatSupport) != RendererCapabilities.TUNNELING_SUPPORTED) { return false; @@ -2292,21 +2398,6 @@ public class DefaultTrackSelector extends MappingTrackSelector { return true; } - /** - * Compares two format values for order. A known value is considered greater than {@link - * Format#NO_VALUE}. - * - * @param first The first value. - * @param second The second value. - * @return A negative integer if the first value is less than the second. Zero if they are equal. - * A positive integer if the first value is greater than the second. - */ - private static int compareFormatValues(int first, int second) { - return first == Format.NO_VALUE - ? (second == Format.NO_VALUE ? 0 : -1) - : (second == Format.NO_VALUE ? 1 : (first - second)); - } - /** * Returns true if the {@link FormatSupport} in the given {@link Capabilities} is {@link * RendererCapabilities#FORMAT_HANDLED} or if {@code allowExceedsCapabilities} is set and the @@ -2445,16 +2536,75 @@ public class DefaultTrackSelector extends MappingTrackSelector { } } - /** - * Compares two integers in a safe way avoiding potential overflow. - * - * @param first The first value. - * @param second The second value. - * @return A negative integer if the first value is less than the second. Zero if they are equal. - * A positive integer if the first value is greater than the second. - */ - private static int compareInts(int first, int second) { - return first > second ? 1 : (second > first ? -1 : 0); + /** Represents how well a video track matches the selection {@link Parameters}. */ + protected static final class VideoTrackScore implements Comparable { + + /** + * Whether the provided format is within the parameter maximum constraints. If {@code false}, + * the format should not be selected. + */ + public final boolean isWithinMaxConstraints; + + private final Parameters parameters; + private final boolean isWithinMinConstraints; + private final boolean isWithinRendererCapabilities; + private final int bitrate; + private final int pixelCount; + + public VideoTrackScore( + Format format, + Parameters parameters, + @Capabilities int formatSupport, + boolean isSuitableForViewport) { + this.parameters = parameters; + isWithinMaxConstraints = + isSuitableForViewport + && (format.width == Format.NO_VALUE || format.width <= parameters.maxVideoWidth) + && (format.height == Format.NO_VALUE || format.height <= parameters.maxVideoHeight) + && (format.frameRate == Format.NO_VALUE + || format.frameRate <= parameters.maxVideoFrameRate) + && (format.bitrate == Format.NO_VALUE + || format.bitrate <= parameters.maxVideoBitrate); + isWithinMinConstraints = + isSuitableForViewport + && (format.width == Format.NO_VALUE || format.width >= parameters.minVideoWidth) + && (format.height == Format.NO_VALUE || format.height >= parameters.minVideoHeight) + && (format.frameRate == Format.NO_VALUE + || format.frameRate >= parameters.minVideoFrameRate) + && (format.bitrate == Format.NO_VALUE + || format.bitrate >= parameters.minVideoBitrate); + isWithinRendererCapabilities = + isSupported(formatSupport, /* allowExceedsCapabilities= */ false); + bitrate = format.bitrate; + pixelCount = format.getPixelCount(); + } + + @Override + public int compareTo(VideoTrackScore other) { + // The preferred ordering by video quality depends on the constraints: + // - Not within renderer capabilities: Prefer lower quality because it's more likely to play. + // - Within min and max constraints: Prefer higher quality. + // - Within max constraints only: Prefer higher quality because it gets us closest to + // satisfying the violated min constraints. + // - Within min constraints only: Prefer lower quality because it gets us closest to + // satisfying the violated max constraints. + // - Outside min and max constraints: Arbitrarily prefer lower quality. + Ordering qualityOrdering = + isWithinMaxConstraints && isWithinRendererCapabilities + ? FORMAT_VALUE_ORDERING + : FORMAT_VALUE_ORDERING.reverse(); + return ComparisonChain.start() + .compareFalseFirst(this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + .compareFalseFirst(this.isWithinMaxConstraints, other.isWithinMaxConstraints) + .compareFalseFirst(this.isWithinMinConstraints, other.isWithinMinConstraints) + .compare( + this.bitrate, + other.bitrate, + parameters.forceLowestBitrate ? FORMAT_VALUE_ORDERING.reverse() : NO_ORDER) + .compare(this.pixelCount, other.pixelCount, qualityOrdering) + .compare(this.bitrate, other.bitrate, qualityOrdering) + .result(); + } } /** Represents how well an audio track matches the selection {@link Parameters}. */ @@ -2470,6 +2620,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { private final Parameters parameters; private final boolean isWithinRendererCapabilities; private final int preferredLanguageScore; + private final int preferredLanguageIndex; private final int localeLanguageMatchIndex; private final int localeLanguageScore; private final boolean isDefaultSelectionFlag; @@ -2480,12 +2631,24 @@ public class DefaultTrackSelector extends MappingTrackSelector { public AudioTrackScore(Format format, Parameters parameters, @Capabilities int formatSupport) { this.parameters = parameters; this.language = normalizeUndeterminedLanguageToNull(format.language); - isWithinRendererCapabilities = isSupported(formatSupport, false); - preferredLanguageScore = - getFormatLanguageScore( - format, - parameters.preferredAudioLanguage, - /* allowUndeterminedFormatLanguage= */ false); + isWithinRendererCapabilities = + isSupported(formatSupport, /* allowExceedsCapabilities= */ false); + int bestLanguageScore = 0; + int bestLanguageIndex = Integer.MAX_VALUE; + for (int i = 0; i < parameters.preferredAudioLanguages.size(); i++) { + int score = + getFormatLanguageScore( + format, + parameters.preferredAudioLanguages.get(i), + /* allowUndeterminedFormatLanguage= */ false); + if (score > 0) { + bestLanguageIndex = i; + bestLanguageScore = score; + break; + } + } + preferredLanguageIndex = bestLanguageIndex; + preferredLanguageScore = bestLanguageScore; isDefaultSelectionFlag = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; channelCount = format.channelCount; sampleRate = format.sampleRate; @@ -2520,44 +2683,38 @@ public class DefaultTrackSelector extends MappingTrackSelector { */ @Override public int compareTo(AudioTrackScore other) { - if (this.isWithinRendererCapabilities != other.isWithinRendererCapabilities) { - return this.isWithinRendererCapabilities ? 1 : -1; - } - if (this.preferredLanguageScore != other.preferredLanguageScore) { - return compareInts(this.preferredLanguageScore, other.preferredLanguageScore); - } - if (this.isWithinConstraints != other.isWithinConstraints) { - return this.isWithinConstraints ? 1 : -1; - } - if (parameters.forceLowestBitrate) { - int bitrateComparison = compareFormatValues(bitrate, other.bitrate); - if (bitrateComparison != 0) { - return bitrateComparison > 0 ? -1 : 1; - } - } - if (this.isDefaultSelectionFlag != other.isDefaultSelectionFlag) { - return this.isDefaultSelectionFlag ? 1 : -1; - } - if (this.localeLanguageMatchIndex != other.localeLanguageMatchIndex) { - return -compareInts(this.localeLanguageMatchIndex, other.localeLanguageMatchIndex); - } - if (this.localeLanguageScore != other.localeLanguageScore) { - return compareInts(this.localeLanguageScore, other.localeLanguageScore); - } // If the formats are within constraints and renderer capabilities then prefer higher values // of channel count, sample rate and bit rate in that order. Otherwise, prefer lower values. - int resultSign = isWithinConstraints && isWithinRendererCapabilities ? 1 : -1; - if (this.channelCount != other.channelCount) { - return resultSign * compareInts(this.channelCount, other.channelCount); - } - if (this.sampleRate != other.sampleRate) { - return resultSign * compareInts(this.sampleRate, other.sampleRate); - } - if (Util.areEqual(this.language, other.language)) { - // Only compare bit rates of tracks with the same or unknown language. - return resultSign * compareInts(this.bitrate, other.bitrate); - } - return 0; + Ordering qualityOrdering = + isWithinConstraints && isWithinRendererCapabilities + ? FORMAT_VALUE_ORDERING + : FORMAT_VALUE_ORDERING.reverse(); + return ComparisonChain.start() + .compareFalseFirst(this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + .compare( + this.preferredLanguageIndex, + other.preferredLanguageIndex, + Ordering.natural().reverse()) + .compare(this.preferredLanguageScore, other.preferredLanguageScore) + .compareFalseFirst(this.isWithinConstraints, other.isWithinConstraints) + .compare( + this.bitrate, + other.bitrate, + parameters.forceLowestBitrate ? FORMAT_VALUE_ORDERING.reverse() : NO_ORDER) + .compareFalseFirst(this.isDefaultSelectionFlag, other.isDefaultSelectionFlag) + .compare( + this.localeLanguageMatchIndex, + other.localeLanguageMatchIndex, + Ordering.natural().reverse()) + .compare(this.localeLanguageScore, other.localeLanguageScore) + .compare(this.channelCount, other.channelCount, qualityOrdering) + .compare(this.sampleRate, other.sampleRate, qualityOrdering) + .compare( + this.bitrate, + other.bitrate, + // Only compare bit rates of tracks with matching language information. + Util.areEqual(this.language, other.language) ? qualityOrdering : NO_ORDER) + .result(); } } @@ -2572,7 +2729,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { private final boolean isWithinRendererCapabilities; private final boolean isDefault; - private final boolean hasPreferredIsForcedFlag; + private final boolean isForced; + private final int preferredLanguageIndex; private final int preferredLanguageScore; private final int preferredRoleFlagsScore; private final int selectedAudioLanguageScore; @@ -2588,26 +2746,38 @@ public class DefaultTrackSelector extends MappingTrackSelector { int maskedSelectionFlags = format.selectionFlags & ~parameters.disabledTextTrackSelectionFlags; isDefault = (maskedSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; - boolean isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; - preferredLanguageScore = - getFormatLanguageScore( - format, parameters.preferredTextLanguage, parameters.selectUndeterminedTextLanguage); + isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; + int bestLanguageIndex = Integer.MAX_VALUE; + int bestLanguageScore = 0; + // Compare against empty (unset) language if no preference is given to allow the selection of + // a text track with undetermined language. + ImmutableList preferredLanguages = + parameters.preferredTextLanguages.isEmpty() + ? ImmutableList.of("") + : parameters.preferredTextLanguages; + for (int i = 0; i < preferredLanguages.size(); i++) { + int score = + getFormatLanguageScore( + format, preferredLanguages.get(i), parameters.selectUndeterminedTextLanguage); + if (score > 0) { + bestLanguageIndex = i; + bestLanguageScore = score; + break; + } + } + preferredLanguageIndex = bestLanguageIndex; + preferredLanguageScore = bestLanguageScore; preferredRoleFlagsScore = Integer.bitCount(format.roleFlags & parameters.preferredTextRoleFlags); hasCaptionRoleFlags = (format.roleFlags & (C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND)) != 0; - // Prefer non-forced to forced if a preferred text language has been matched. Where both are - // provided the non-forced track will usually contain the forced subtitles as a subset. - // Otherwise, prefer a forced track. - hasPreferredIsForcedFlag = - (preferredLanguageScore > 0 && !isForced) || (preferredLanguageScore == 0 && isForced); boolean selectedAudioLanguageUndetermined = normalizeUndeterminedLanguageToNull(selectedAudioLanguage) == null; selectedAudioLanguageScore = getFormatLanguageScore(format, selectedAudioLanguage, selectedAudioLanguageUndetermined); isWithinConstraints = preferredLanguageScore > 0 - || (parameters.preferredTextLanguage == null && preferredRoleFlagsScore > 0) + || (parameters.preferredTextLanguages.isEmpty() && preferredRoleFlagsScore > 0) || isDefault || (isForced && selectedAudioLanguageScore > 0); } @@ -2621,28 +2791,53 @@ public class DefaultTrackSelector extends MappingTrackSelector { */ @Override public int compareTo(TextTrackScore other) { - if (this.isWithinRendererCapabilities != other.isWithinRendererCapabilities) { - return this.isWithinRendererCapabilities ? 1 : -1; + ComparisonChain chain = + ComparisonChain.start() + .compareFalseFirst( + this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + .compare( + this.preferredLanguageIndex, + other.preferredLanguageIndex, + Ordering.natural().reverse()) + .compare(this.preferredLanguageScore, other.preferredLanguageScore) + .compare(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore) + .compareFalseFirst(this.isDefault, other.isDefault) + .compare( + this.isForced, + other.isForced, + // Prefer non-forced to forced if a preferred text language has been matched. + // Where both are provided the non-forced track will usually contain the forced + // subtitles as a subset. Otherwise, prefer a forced track. + preferredLanguageScore == 0 ? Ordering.natural() : Ordering.natural().reverse()) + .compare(this.selectedAudioLanguageScore, other.selectedAudioLanguageScore); + if (preferredRoleFlagsScore == 0) { + chain = chain.compareTrueFirst(this.hasCaptionRoleFlags, other.hasCaptionRoleFlags); } - if (this.preferredLanguageScore != other.preferredLanguageScore) { - return compareInts(this.preferredLanguageScore, other.preferredLanguageScore); - } - if (this.preferredRoleFlagsScore != other.preferredRoleFlagsScore) { - return compareInts(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore); - } - if (this.isDefault != other.isDefault) { - return this.isDefault ? 1 : -1; - } - if (this.hasPreferredIsForcedFlag != other.hasPreferredIsForcedFlag) { - return this.hasPreferredIsForcedFlag ? 1 : -1; - } - if (this.selectedAudioLanguageScore != other.selectedAudioLanguageScore) { - return compareInts(this.selectedAudioLanguageScore, other.selectedAudioLanguageScore); - } - if (preferredRoleFlagsScore == 0 && this.hasCaptionRoleFlags != other.hasCaptionRoleFlags) { - return this.hasCaptionRoleFlags ? -1 : 1; - } - return 0; + return chain.result(); + } + } + + /** + * Represents how well any other track (non video, audio or text) matches the selection {@link + * Parameters}. + */ + protected static final class OtherTrackScore implements Comparable { + + private final boolean isDefault; + private final boolean isWithinRendererCapabilities; + + public OtherTrackScore(Format format, @Capabilities int trackFormatSupport) { + isDefault = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; + isWithinRendererCapabilities = + isSupported(trackFormatSupport, /* allowExceedsCapabilities= */ false); + } + + @Override + public int compareTo(OtherTrackScore other) { + return ComparisonChain.start() + .compareFalseFirst(this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + .compareFalseFirst(this.isDefault, other.isDefault) + .result(); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java index 59d50af405..9949a370ed 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.trackselection; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.util.Pair; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -191,7 +194,7 @@ public abstract class MappingTrackSelector extends TrackSelector { default: throw new IllegalStateException(); } - bestRendererSupport = Math.max(bestRendererSupport, trackRendererSupport); + bestRendererSupport = max(bestRendererSupport, trackRendererSupport); } } return bestRendererSupport; @@ -218,7 +221,7 @@ public abstract class MappingTrackSelector extends TrackSelector { @RendererSupport int bestRendererSupport = RENDERER_SUPPORT_NO_TRACKS; for (int i = 0; i < rendererCount; i++) { if (rendererTrackTypes[i] == trackType) { - bestRendererSupport = Math.max(bestRendererSupport, getRendererSupport(i)); + bestRendererSupport = max(bestRendererSupport, getRendererSupport(i)); } } return bestRendererSupport; @@ -307,13 +310,13 @@ public abstract class MappingTrackSelector extends TrackSelector { multipleMimeTypes |= !Util.areEqual(firstSampleMimeType, sampleMimeType); } adaptiveSupport = - Math.min( + min( adaptiveSupport, RendererCapabilities.getAdaptiveSupport( rendererFormatSupports[rendererIndex][groupIndex][i])); } return multipleMimeTypes - ? Math.min(adaptiveSupport, rendererMixedMimeTypeAdaptiveSupports[rendererIndex]) + ? min(adaptiveSupport, rendererMixedMimeTypeAdaptiveSupports[rendererIndex]) : adaptiveSupport; } @@ -502,7 +505,7 @@ public abstract class MappingTrackSelector extends TrackSelector { int trackFormatSupportLevel = RendererCapabilities.getFormatSupport( rendererCapability.supportsFormat(group.getFormat(trackIndex))); - formatSupportLevel = Math.max(formatSupportLevel, trackFormatSupportLevel); + formatSupportLevel = max(formatSupportLevel, trackFormatSupportLevel); } boolean rendererIsUnassociated = rendererTrackGroupCounts[rendererIndex] == 0; if (formatSupportLevel > bestFormatSupportLevel diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java index f35e7ec755..4b9b72715a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java @@ -102,21 +102,21 @@ public final class RandomTrackSelection extends BaseTrackSelection { long availableDurationUs, List queue, MediaChunkIterator[] mediaChunkIterators) { - // Count the number of non-blacklisted formats. + // Count the number of allowed formats. long nowMs = SystemClock.elapsedRealtime(); - int nonBlacklistedFormatCount = 0; + int allowedFormatCount = 0; for (int i = 0; i < length; i++) { if (!isBlacklisted(i, nowMs)) { - nonBlacklistedFormatCount++; + allowedFormatCount++; } } - selectedIndex = random.nextInt(nonBlacklistedFormatCount); - if (nonBlacklistedFormatCount != length) { - // Adjust the format index to account for blacklisted formats. - nonBlacklistedFormatCount = 0; + selectedIndex = random.nextInt(allowedFormatCount); + if (allowedFormatCount != length) { + // Adjust the format index to account for excluded formats. + allowedFormatCount = 0; for (int i = 0; i < length; i++) { - if (!isBlacklisted(i, nowMs) && selectedIndex == nonBlacklistedFormatCount++) { + if (!isBlacklisted(i, nowMs) && selectedIndex == allowedFormatCount++) { selectedIndex = i; return; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java index ad1a6ef1f2..5e703438f8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelection.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.upstream.BandwidthMeter; @@ -93,8 +94,8 @@ public interface TrackSelection { /** * Enables the track selection. Dynamic changes via {@link #updateSelectedTrack(long, long, long, - * List, MediaChunkIterator[])} or {@link #evaluateQueueSize(long, List)} will only happen after - * this call. + * List, MediaChunkIterator[])}, {@link #evaluateQueueSize(long, List)} or {@link + * #shouldCancelChunkLoad(long, Chunk, List)} will only happen after this call. * *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      This method will only be called when the selection is enabled. * * @param index The index of the track in the selection. - * @param blacklistDurationMs The duration of time for which the track should be blacklisted, in + * @param exclusionDurationMs The duration of time for which the track should be excluded, in * milliseconds. - * @return Whether blacklisting was successful. + * @return Whether exclusion was successful. */ - boolean blacklist(int index, long blacklistDurationMs); + boolean blacklist(int index, long exclusionDurationMs); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java index 3871a31a3b..be1f8f6733 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java @@ -15,16 +15,19 @@ */ package com.google.android.exoplayer2.trackselection; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.content.Context; import android.os.Looper; import android.os.Parcel; import android.os.Parcelable; -import android.text.TextUtils; import android.view.accessibility.CaptioningManager; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; import java.util.Locale; /** Constraint parameters for track selection. */ @@ -36,8 +39,8 @@ public class TrackSelectionParameters implements Parcelable { */ public static class Builder { - @Nullable /* package */ String preferredAudioLanguage; - @Nullable /* package */ String preferredTextLanguage; + /* package */ ImmutableList preferredAudioLanguages; + /* package */ ImmutableList preferredTextLanguages; @C.RoleFlags /* package */ int preferredTextRoleFlags; /* package */ boolean selectUndeterminedTextLanguage; @C.SelectionFlags /* package */ int disabledTextTrackSelectionFlags; @@ -59,8 +62,8 @@ public class TrackSelectionParameters implements Parcelable { */ @Deprecated public Builder() { - preferredAudioLanguage = null; - preferredTextLanguage = null; + preferredAudioLanguages = ImmutableList.of(); + preferredTextLanguages = ImmutableList.of(); preferredTextRoleFlags = 0; selectUndeterminedTextLanguage = false; disabledTextTrackSelectionFlags = 0; @@ -71,8 +74,8 @@ public class TrackSelectionParameters implements Parcelable { * the builder are obtained. */ /* package */ Builder(TrackSelectionParameters initialValues) { - preferredAudioLanguage = initialValues.preferredAudioLanguage; - preferredTextLanguage = initialValues.preferredTextLanguage; + preferredAudioLanguages = initialValues.preferredAudioLanguages; + preferredTextLanguages = initialValues.preferredTextLanguages; preferredTextRoleFlags = initialValues.preferredTextRoleFlags; selectUndeterminedTextLanguage = initialValues.selectUndeterminedTextLanguage; disabledTextTrackSelectionFlags = initialValues.disabledTextTrackSelectionFlags; @@ -86,7 +89,25 @@ public class TrackSelectionParameters implements Parcelable { * @return This builder. */ public Builder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { - this.preferredAudioLanguage = preferredAudioLanguage; + return preferredAudioLanguage == null + ? setPreferredAudioLanguages() + : setPreferredAudioLanguages(preferredAudioLanguage); + } + + /** + * Sets the preferred languages for audio and forced text tracks. + * + * @param preferredAudioLanguages Preferred audio languages as IETF BCP 47 conformant tags in + * order of preference, or an empty array to select the default track, or the first track if + * there's no default. + * @return This builder. + */ + public Builder setPreferredAudioLanguages(String... preferredAudioLanguages) { + ImmutableList.Builder listBuilder = ImmutableList.builder(); + for (String language : checkNotNull(preferredAudioLanguages)) { + listBuilder.add(Util.normalizeLanguageCode(checkNotNull(language))); + } + this.preferredAudioLanguages = listBuilder.build(); return this; } @@ -115,7 +136,25 @@ public class TrackSelectionParameters implements Parcelable { * @return This builder. */ public Builder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { - this.preferredTextLanguage = preferredTextLanguage; + return preferredTextLanguage == null + ? setPreferredTextLanguages() + : setPreferredTextLanguages(preferredTextLanguage); + } + + /** + * Sets the preferred languages for text tracks. + * + * @param preferredTextLanguages Preferred text languages as IETF BCP 47 conformant tags in + * order of preference, or an empty array to select the default track if there is one, or no + * track otherwise. + * @return This builder. + */ + public Builder setPreferredTextLanguages(String... preferredTextLanguages) { + ImmutableList.Builder listBuilder = ImmutableList.builder(); + for (String language : checkNotNull(preferredTextLanguages)) { + listBuilder.add(Util.normalizeLanguageCode(checkNotNull(language))); + } + this.preferredTextLanguages = listBuilder.build(); return this; } @@ -132,8 +171,8 @@ public class TrackSelectionParameters implements Parcelable { /** * Sets whether a text track with undetermined language should be selected if no track with - * {@link #setPreferredTextLanguage(String)} is available, or if the preferred language is - * unset. + * {@link #setPreferredTextLanguages(String...) a preferred language} is available, or if the + * preferred language is unset. * * @param selectUndeterminedTextLanguage Whether a text track with undetermined language should * be selected if no preferred language track is available. @@ -161,9 +200,9 @@ public class TrackSelectionParameters implements Parcelable { public TrackSelectionParameters build() { return new TrackSelectionParameters( // Audio - preferredAudioLanguage, + preferredAudioLanguages, // Text - preferredTextLanguage, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags); @@ -185,7 +224,7 @@ public class TrackSelectionParameters implements Parcelable { preferredTextRoleFlags = C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; Locale preferredLocale = captioningManager.getLocale(); if (preferredLocale != null) { - preferredTextLanguage = Util.getLocaleLanguageTag(preferredLocale); + preferredTextLanguages = ImmutableList.of(Util.getLocaleLanguageTag(preferredLocale)); } } } @@ -218,17 +257,18 @@ public class TrackSelectionParameters implements Parcelable { } /** - * The preferred language for audio and forced text tracks as an IETF BCP 47 conformant tag. - * {@code null} selects the default track, or the first track if there's no default. The default - * value is {@code null}. + * The preferred languages for audio and forced text tracks as IETF BCP 47 conformant tags in + * order of preference. An empty list selects the default track, or the first track if there's no + * default. The default value is an empty list. */ - @Nullable public final String preferredAudioLanguage; + public final ImmutableList preferredAudioLanguages; /** - * The preferred language for text tracks as an IETF BCP 47 conformant tag. {@code null} selects - * the default track if there is one, or no track otherwise. The default value is {@code null}, or - * the language of the accessibility {@link CaptioningManager} if enabled. + * The preferred languages for text tracks as IETF BCP 47 conformant tags in order of preference. + * An empty list selects the default track if there is one, or no track otherwise. The default + * value is an empty list, or the language of the accessibility {@link CaptioningManager} if + * enabled. */ - @Nullable public final String preferredTextLanguage; + public final ImmutableList preferredTextLanguages; /** * The preferred {@link C.RoleFlags} for text tracks. {@code 0} selects the default track if there * is one, or no track otherwise. The default value is {@code 0}, or {@link C#ROLE_FLAG_SUBTITLE} @@ -238,7 +278,7 @@ public class TrackSelectionParameters implements Parcelable { @C.RoleFlags public final int preferredTextRoleFlags; /** * Whether a text track with undetermined language should be selected if no track with {@link - * #preferredTextLanguage} is available, or if {@link #preferredTextLanguage} is unset. The + * #preferredTextLanguages} is available, or if {@link #preferredTextLanguages} is unset. The * default value is {@code false}. */ public final boolean selectUndeterminedTextLanguage; @@ -249,23 +289,27 @@ public class TrackSelectionParameters implements Parcelable { @C.SelectionFlags public final int disabledTextTrackSelectionFlags; /* package */ TrackSelectionParameters( - @Nullable String preferredAudioLanguage, - @Nullable String preferredTextLanguage, + ImmutableList preferredAudioLanguages, + ImmutableList preferredTextLanguages, @C.RoleFlags int preferredTextRoleFlags, boolean selectUndeterminedTextLanguage, @C.SelectionFlags int disabledTextTrackSelectionFlags) { // Audio - this.preferredAudioLanguage = Util.normalizeLanguageCode(preferredAudioLanguage); + this.preferredAudioLanguages = preferredAudioLanguages; // Text - this.preferredTextLanguage = Util.normalizeLanguageCode(preferredTextLanguage); + this.preferredTextLanguages = preferredTextLanguages; this.preferredTextRoleFlags = preferredTextRoleFlags; this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; } /* package */ TrackSelectionParameters(Parcel in) { - this.preferredAudioLanguage = in.readString(); - this.preferredTextLanguage = in.readString(); + ArrayList preferredAudioLanguages = new ArrayList<>(); + in.readList(preferredAudioLanguages, /* loader= */ null); + this.preferredAudioLanguages = ImmutableList.copyOf(preferredAudioLanguages); + ArrayList preferredTextLanguages = new ArrayList<>(); + in.readList(preferredTextLanguages, /* loader= */ null); + this.preferredTextLanguages = ImmutableList.copyOf(preferredTextLanguages); this.preferredTextRoleFlags = in.readInt(); this.selectUndeterminedTextLanguage = Util.readBoolean(in); this.disabledTextTrackSelectionFlags = in.readInt(); @@ -286,8 +330,8 @@ public class TrackSelectionParameters implements Parcelable { return false; } TrackSelectionParameters other = (TrackSelectionParameters) obj; - return TextUtils.equals(preferredAudioLanguage, other.preferredAudioLanguage) - && TextUtils.equals(preferredTextLanguage, other.preferredTextLanguage) + return preferredAudioLanguages.equals(other.preferredAudioLanguages) + && preferredTextLanguages.equals(other.preferredTextLanguages) && preferredTextRoleFlags == other.preferredTextRoleFlags && selectUndeterminedTextLanguage == other.selectUndeterminedTextLanguage && disabledTextTrackSelectionFlags == other.disabledTextTrackSelectionFlags; @@ -296,8 +340,8 @@ public class TrackSelectionParameters implements Parcelable { @Override public int hashCode() { int result = 1; - result = 31 * result + (preferredAudioLanguage == null ? 0 : preferredAudioLanguage.hashCode()); - result = 31 * result + (preferredTextLanguage == null ? 0 : preferredTextLanguage.hashCode()); + result = 31 * result + preferredAudioLanguages.hashCode(); + result = 31 * result + preferredTextLanguages.hashCode(); result = 31 * result + preferredTextRoleFlags; result = 31 * result + (selectUndeterminedTextLanguage ? 1 : 0); result = 31 * result + disabledTextTrackSelectionFlags; @@ -313,8 +357,8 @@ public class TrackSelectionParameters implements Parcelable { @Override public void writeToParcel(Parcel dest, int flags) { - dest.writeString(preferredAudioLanguage); - dest.writeString(preferredTextLanguage); + dest.writeList(preferredAudioLanguages); + dest.writeList(preferredTextLanguages); dest.writeInt(preferredTextRoleFlags); Util.writeBoolean(dest, selectUndeterminedTextLanguage); dest.writeInt(disabledTextTrackSelectionFlags); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java index d48c140ac8..8ee9d29d3d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelector.java @@ -61,6 +61,8 @@ import com.google.android.exoplayer2.util.Assertions; * prefer audio tracks in a particular language. This will trigger the player to make new * track selections. Note that the player will have to re-buffer in the case that the new * track selection for the currently playing period differs from the one that was invalidated. + * Implementing subclasses can trigger invalidation by calling {@link #invalidate()}, which + * will call {@link InvalidationListener#onTrackSelectionsInvalidated()}. * * *

      Renderer configuration

      diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/AssetDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/AssetDataSource.java index 3c92b039cc..e529e28846 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/AssetDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/AssetDataSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; import android.content.Context; import android.content.res.AssetManager; @@ -102,8 +103,8 @@ public final class AssetDataSource extends BaseDataSource { int bytesRead; try { - int bytesToRead = bytesRemaining == C.LENGTH_UNSET ? readLength - : (int) Math.min(bytesRemaining, readLength); + int bytesToRead = + bytesRemaining == C.LENGTH_UNSET ? readLength : (int) min(bytesRemaining, readLength); bytesRead = castNonNull(inputStream).read(buffer, offset, bytesToRead); } catch (IOException e) { throw new AssetDataSourceException(e); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java index 853a9af526..d520fcfa60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BandwidthMeter.java @@ -17,6 +17,8 @@ package com.google.android.exoplayer2.upstream; import android.os.Handler; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Assertions; +import java.util.concurrent.CopyOnWriteArrayList; /** * Provides estimates of the currently available bandwidth. @@ -42,6 +44,63 @@ public interface BandwidthMeter { * @param bitrateEstimate The estimated bitrate in bits/sec. */ void onBandwidthSample(int elapsedMs, long bytesTransferred, long bitrateEstimate); + + /** Event dispatcher which allows listener registration. */ + final class EventDispatcher { + + private final CopyOnWriteArrayList listeners; + + /** Creates an event dispatcher. */ + public EventDispatcher() { + listeners = new CopyOnWriteArrayList<>(); + } + + /** Adds a listener to the event dispatcher. */ + public void addListener(Handler eventHandler, BandwidthMeter.EventListener eventListener) { + Assertions.checkNotNull(eventHandler); + Assertions.checkNotNull(eventListener); + removeListener(eventListener); + listeners.add(new HandlerAndListener(eventHandler, eventListener)); + } + + /** Removes a listener from the event dispatcher. */ + public void removeListener(BandwidthMeter.EventListener eventListener) { + for (HandlerAndListener handlerAndListener : listeners) { + if (handlerAndListener.listener == eventListener) { + handlerAndListener.release(); + listeners.remove(handlerAndListener); + } + } + } + + public void bandwidthSample(int elapsedMs, long bytesTransferred, long bitrateEstimate) { + for (HandlerAndListener handlerAndListener : listeners) { + if (!handlerAndListener.released) { + handlerAndListener.handler.post( + () -> + handlerAndListener.listener.onBandwidthSample( + elapsedMs, bytesTransferred, bitrateEstimate)); + } + } + } + + private static final class HandlerAndListener { + + private final Handler handler; + private final BandwidthMeter.EventListener listener; + + private boolean released; + + public HandlerAndListener(Handler handler, BandwidthMeter.EventListener eventListener) { + this.handler = handler; + this.listener = eventListener; + } + + public void release() { + released = true; + } + } + } } /** Returns the estimated bitrate. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java index 80687db31f..ce6243eda0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/BaseDataSource.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; import androidx.annotation.Nullable; @@ -47,6 +48,7 @@ public abstract class BaseDataSource implements DataSource { @Override public final void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); if (!listeners.contains(transferListener)) { listeners.add(transferListener); listenerCount++; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ByteArrayDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ByteArrayDataSource.java index ed5ba9064b..17e9073128 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ByteArrayDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ByteArrayDataSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static java.lang.Math.min; + import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -65,7 +67,7 @@ public final class ByteArrayDataSource extends BaseDataSource { return C.RESULT_END_OF_INPUT; } - readLength = Math.min(readLength, bytesRemaining); + readLength = min(readLength, bytesRemaining); System.arraycopy(data, readPosition, buffer, offset, readLength); readPosition += readLength; bytesRemaining -= readLength; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ContentDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ContentDataSource.java index baaa677127..b659c5ca98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ContentDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ContentDataSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; import android.content.ContentResolver; import android.content.Context; @@ -90,9 +91,19 @@ public final class ContentDataSource extends BaseDataSource { // returns 0 then the remaining length cannot be determined. FileChannel channel = inputStream.getChannel(); long channelSize = channel.size(); - bytesRemaining = channelSize == 0 ? C.LENGTH_UNSET : channelSize - channel.position(); + if (channelSize == 0) { + bytesRemaining = C.LENGTH_UNSET; + } else { + bytesRemaining = channelSize - channel.position(); + if (bytesRemaining < 0) { + throw new EOFException(); + } + } } else { bytesRemaining = assetFileDescriptorLength - skipped; + if (bytesRemaining < 0) { + throw new EOFException(); + } } } } catch (IOException e) { @@ -115,8 +126,8 @@ public final class ContentDataSource extends BaseDataSource { int bytesRead; try { - int bytesToRead = bytesRemaining == C.LENGTH_UNSET ? readLength - : (int) Math.min(bytesRemaining, readLength); + int bytesToRead = + bytesRemaining == C.LENGTH_UNSET ? readLength : (int) min(bytesRemaining, readLength); bytesRead = castNonNull(inputStream).read(buffer, offset, bytesToRead); } catch (IOException e) { throw new ContentDataSourceException(e); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java index 55c580ead2..680ebbb2b1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; import android.net.Uri; import android.util.Base64; @@ -23,6 +24,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.io.IOException; import java.net.URLDecoder; @@ -63,7 +65,7 @@ public final class DataSchemeDataSource extends BaseDataSource { } } else { // TODO: Add support for other charsets. - data = Util.getUtf8Bytes(URLDecoder.decode(dataString, C.ASCII_NAME)); + data = Util.getUtf8Bytes(URLDecoder.decode(dataString, Charsets.US_ASCII.name())); } endPosition = dataSpec.length != C.LENGTH_UNSET ? (int) dataSpec.length + readPosition : data.length; @@ -84,7 +86,7 @@ public final class DataSchemeDataSource extends BaseDataSource { if (remainingBytes == 0) { return C.RESULT_END_OF_INPUT; } - readLength = Math.min(readLength, remainingBytes); + readLength = min(readLength, remainingBytes); System.arraycopy(castNonNull(data), readPosition, buffer, offset, readLength); readPosition += readLength; bytesTransferred(readLength); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultAllocator.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultAllocator.java index ca9cca255d..bb4ce1d0c1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultAllocator.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultAllocator.java @@ -15,10 +15,13 @@ */ package com.google.android.exoplayer2.upstream; +import static java.lang.Math.max; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * Default implementation of {@link Allocator}. @@ -35,7 +38,7 @@ public final class DefaultAllocator implements Allocator { private int targetBufferSize; private int allocatedCount; private int availableCount; - private Allocation[] availableAllocations; + private @NullableType Allocation[] availableAllocations; /** * Constructs an instance without creating any {@link Allocation}s up front. @@ -97,7 +100,7 @@ public final class DefaultAllocator implements Allocator { allocatedCount++; Allocation allocation; if (availableCount > 0) { - allocation = availableAllocations[--availableCount]; + allocation = Assertions.checkNotNull(availableAllocations[--availableCount]); availableAllocations[availableCount] = null; } else { allocation = new Allocation(new byte[individualAllocationSize], 0); @@ -114,8 +117,10 @@ public final class DefaultAllocator implements Allocator { @Override public synchronized void release(Allocation[] allocations) { if (availableCount + allocations.length >= availableAllocations.length) { - availableAllocations = Arrays.copyOf(availableAllocations, - Math.max(availableAllocations.length * 2, availableCount + allocations.length)); + availableAllocations = + Arrays.copyOf( + availableAllocations, + max(availableAllocations.length * 2, availableCount + allocations.length)); } for (Allocation allocation : allocations) { availableAllocations[availableCount++] = allocation; @@ -128,7 +133,7 @@ public final class DefaultAllocator implements Allocator { @Override public synchronized void trim() { int targetAllocationCount = Util.ceilDivide(targetBufferSize, individualAllocationSize); - int targetAvailableCount = Math.max(0, targetAllocationCount - allocatedCount); + int targetAvailableCount = max(0, targetAllocationCount - allocatedCount); if (targetAvailableCount >= availableCount) { // We're already at or below the target. return; @@ -141,11 +146,11 @@ public final class DefaultAllocator implements Allocator { int lowIndex = 0; int highIndex = availableCount - 1; while (lowIndex <= highIndex) { - Allocation lowAllocation = availableAllocations[lowIndex]; + Allocation lowAllocation = Assertions.checkNotNull(availableAllocations[lowIndex]); if (lowAllocation.data == initialAllocationBlock) { lowIndex++; } else { - Allocation highAllocation = availableAllocations[highIndex]; + Allocation highAllocation = Assertions.checkNotNull(availableAllocations[highIndex]); if (highAllocation.data != initialAllocationBlock) { highIndex--; } else { @@ -155,7 +160,7 @@ public final class DefaultAllocator implements Allocator { } } // lowIndex is the index of the first allocation not backed by an initial block. - targetAvailableCount = Math.max(targetAvailableCount, lowIndex); + targetAvailableCount = max(targetAvailableCount, lowIndex); if (targetAvailableCount >= availableCount) { // We're already at or below the target. return; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java index ceaefad0b9..8a5c4447c3 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 @@ -22,18 +22,20 @@ import android.content.IntentFilter; import android.net.ConnectivityManager; import android.os.Handler; import android.os.Looper; -import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.BandwidthMeter.EventListener.EventDispatcher; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; -import com.google.android.exoplayer2.util.EventDispatcher; import com.google.android.exoplayer2.util.SlidingPercentile; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; import java.lang.ref.WeakReference; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -49,26 +51,30 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList /** * Country groups used to determine the default initial bitrate estimate. The group assignment for - * each country is an array of group indices for [Wifi, 2G, 3G, 4G]. + * each country is a list for [Wifi, 2G, 3G, 4G, 5G_NSA]. */ - public static final Map DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS = - createInitialBitrateCountryGroupAssignment(); + public static final ImmutableListMultimap + DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS = createInitialBitrateCountryGroupAssignment(); /** Default initial Wifi bitrate estimate in bits per second. */ - public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI = - new long[] {5_800_000, 3_500_000, 1_900_000, 1_000_000, 520_000}; + public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI = + ImmutableList.of(6_100_000L, 3_800_000L, 2_100_000L, 1_300_000L, 590_000L); /** Default initial 2G bitrate estimates in bits per second. */ - public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_2G = - new long[] {204_000, 154_000, 139_000, 122_000, 102_000}; + public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_2G = + ImmutableList.of(218_000L, 159_000L, 145_000L, 130_000L, 112_000L); /** Default initial 3G bitrate estimates in bits per second. */ - public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_3G = - new long[] {2_200_000, 1_150_000, 810_000, 640_000, 450_000}; + public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_3G = + ImmutableList.of(2_200_000L, 1_300_000L, 930_000L, 730_000L, 530_000L); /** Default initial 4G bitrate estimates in bits per second. */ - public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_4G = - new long[] {4_900_000, 2_300_000, 1_500_000, 970_000, 540_000}; + public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_4G = + ImmutableList.of(4_800_000L, 2_700_000L, 1_800_000L, 1_200_000L, 630_000L); + + /** Default initial 5G-NSA bitrate estimates in bits per second. */ + public static final ImmutableList DEFAULT_INITIAL_BITRATE_ESTIMATES_5G_NSA = + ImmutableList.of(12_000_000L, 8_800_000L, 5_900_000L, 3_500_000L, 1_800_000L); /** * Default initial bitrate estimate used when the device is offline or the network type cannot be @@ -79,6 +85,17 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList /** Default maximum weight for the sliding window. */ public static final int DEFAULT_SLIDING_WINDOW_MAX_WEIGHT = 2000; + /** Index for the Wifi group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_WIFI = 0; + /** Index for the 2G group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_2G = 1; + /** Index for the 3G group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_3G = 2; + /** Index for the 4G group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_4G = 3; + /** Index for the 5G-NSA group index in {@link #DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS}. */ + private static final int COUNTRY_GROUP_INDEX_5G_NSA = 4; + @Nullable private static DefaultBandwidthMeter singletonInstance; /** Builder for a bandwidth meter. */ @@ -86,7 +103,7 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList @Nullable private final Context context; - private SparseArray initialBitrateEstimates; + private Map initialBitrateEstimates; private int slidingWindowMaxWeight; private Clock clock; private boolean resetOnNetworkTypeChange; @@ -124,8 +141,8 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList * @return This builder. */ public Builder setInitialBitrateEstimate(long initialBitrateEstimate) { - for (int i = 0; i < initialBitrateEstimates.size(); i++) { - initialBitrateEstimates.setValueAt(i, initialBitrateEstimate); + for (Integer networkType : initialBitrateEstimates.keySet()) { + setInitialBitrateEstimate(networkType, initialBitrateEstimate); } return this; } @@ -195,25 +212,37 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList resetOnNetworkTypeChange); } - private static SparseArray getInitialBitrateEstimatesForCountry(String countryCode) { - int[] groupIndices = getCountryGroupIndices(countryCode); - SparseArray result = new SparseArray<>(/* initialCapacity= */ 6); - result.append(C.NETWORK_TYPE_UNKNOWN, DEFAULT_INITIAL_BITRATE_ESTIMATE); - result.append(C.NETWORK_TYPE_WIFI, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); - result.append(C.NETWORK_TYPE_2G, DEFAULT_INITIAL_BITRATE_ESTIMATES_2G[groupIndices[1]]); - result.append(C.NETWORK_TYPE_3G, DEFAULT_INITIAL_BITRATE_ESTIMATES_3G[groupIndices[2]]); - result.append(C.NETWORK_TYPE_4G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); - // Assume default Wifi bitrate for Ethernet and 5G 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]]); + private static Map getInitialBitrateEstimatesForCountry(String countryCode) { + List groupIndices = getCountryGroupIndices(countryCode); + Map result = new HashMap<>(/* initialCapacity= */ 6); + result.put(C.NETWORK_TYPE_UNKNOWN, DEFAULT_INITIAL_BITRATE_ESTIMATE); + result.put( + C.NETWORK_TYPE_WIFI, + DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI.get(groupIndices.get(COUNTRY_GROUP_INDEX_WIFI))); + result.put( + C.NETWORK_TYPE_2G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_2G.get(groupIndices.get(COUNTRY_GROUP_INDEX_2G))); + result.put( + C.NETWORK_TYPE_3G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_3G.get(groupIndices.get(COUNTRY_GROUP_INDEX_3G))); + result.put( + C.NETWORK_TYPE_4G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_4G.get(groupIndices.get(COUNTRY_GROUP_INDEX_4G))); + result.put( + C.NETWORK_TYPE_5G, + DEFAULT_INITIAL_BITRATE_ESTIMATES_5G_NSA.get( + groupIndices.get(COUNTRY_GROUP_INDEX_5G_NSA))); + // Assume default Wifi speed for Ethernet to prevent using the slower fallback. + result.put( + C.NETWORK_TYPE_ETHERNET, + DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI.get(groupIndices.get(COUNTRY_GROUP_INDEX_WIFI))); return result; } - private static int[] getCountryGroupIndices(String countryCode) { - @Nullable int[] groupIndices = DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS.get(countryCode); + private static ImmutableList getCountryGroupIndices(String countryCode) { + ImmutableList groupIndices = DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS.get(countryCode); // Assume median group if not found. - return groupIndices == null ? new int[] {2, 2, 2, 2} : groupIndices; + return groupIndices.isEmpty() ? ImmutableList.of(2, 2, 2, 2, 2) : groupIndices; } } @@ -234,8 +263,8 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList private static final int BYTES_TRANSFERRED_FOR_ESTIMATE = 512 * 1024; @Nullable private final Context context; - private final SparseArray initialBitrateEstimates; - private final EventDispatcher eventDispatcher; + private final ImmutableMap initialBitrateEstimates; + private final EventDispatcher eventDispatcher; private final SlidingPercentile slidingPercentile; private final Clock clock; @@ -257,7 +286,7 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList public DefaultBandwidthMeter() { this( /* context= */ null, - /* initialBitrateEstimates= */ new SparseArray<>(), + /* initialBitrateEstimates= */ ImmutableMap.of(), DEFAULT_SLIDING_WINDOW_MAX_WEIGHT, Clock.DEFAULT, /* resetOnNetworkTypeChange= */ false); @@ -265,13 +294,13 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList private DefaultBandwidthMeter( @Nullable Context context, - SparseArray initialBitrateEstimates, + Map initialBitrateEstimates, int maxWeight, Clock clock, boolean resetOnNetworkTypeChange) { this.context = context == null ? null : context.getApplicationContext(); - this.initialBitrateEstimates = initialBitrateEstimates; - this.eventDispatcher = new EventDispatcher<>(); + this.initialBitrateEstimates = ImmutableMap.copyOf(initialBitrateEstimates); + this.eventDispatcher = new EventDispatcher(); this.slidingPercentile = new SlidingPercentile(maxWeight); this.clock = clock; // Set the initial network type and bitrate estimate @@ -311,6 +340,8 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList @Override public void addEventListener(Handler eventHandler, EventListener eventListener) { + Assertions.checkNotNull(eventHandler); + Assertions.checkNotNull(eventListener); eventDispatcher.addListener(eventHandler, eventListener); } @@ -406,8 +437,7 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList return; } lastReportedBitrateEstimate = bitrateEstimate; - eventDispatcher.dispatch( - listener -> listener.onBandwidthSample(elapsedMs, bytesTransferred, bitrateEstimate)); + eventDispatcher.bandwidthSample(elapsedMs, bytesTransferred, bitrateEstimate); } private long getInitialBitrateEstimateForNetworkType(@C.NetworkType int networkType) { @@ -489,247 +519,248 @@ public final class DefaultBandwidthMeter implements BandwidthMeter, TransferList } } - private static Map createInitialBitrateCountryGroupAssignment() { - HashMap countryGroupAssignment = new HashMap<>(); - 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[] {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, 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, 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, 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[] {1, 0, 3, 4}); - countryGroupAssignment.put("BI", new int[] {4, 4, 4, 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[] {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, 0, 1, 0}); - countryGroupAssignment.put("CL", new int[] {1, 2, 2, 3}); - 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, 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, 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, 1}); - countryGroupAssignment.put("FI", new int[] {0, 0, 1, 0}); - 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, 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, 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[] {2, 2, 2, 3}); - countryGroupAssignment.put("IE", new int[] {1, 0, 1, 1}); - 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, 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, 1}); - countryGroupAssignment.put("IS", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("IT", new int[] {1, 1, 1, 2}); - countryGroupAssignment.put("JE", new int[] {1, 0, 0, 1}); - 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, 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[] {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, 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, 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, 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, 2}); - countryGroupAssignment.put("NZ", new int[] {0, 0, 1, 2}); - 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, 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[] {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[] {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, 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, 4, 1}); - countryGroupAssignment.put("SC", new int[] {4, 2, 0, 1}); - countryGroupAssignment.put("SD", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("SE", new int[] {0, 1, 0, 0}); - 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, 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, 1, 1}); - countryGroupAssignment.put("SZ", new int[] {4, 4, 3, 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, 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, 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, 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); + private static ImmutableListMultimap + createInitialBitrateCountryGroupAssignment() { + ImmutableListMultimap.Builder countryGroupAssignment = + ImmutableListMultimap.builder(); + countryGroupAssignment.putAll("AD", 1, 2, 0, 0, 2); + countryGroupAssignment.putAll("AE", 1, 4, 4, 4, 1); + countryGroupAssignment.putAll("AF", 4, 4, 3, 4, 2); + countryGroupAssignment.putAll("AG", 2, 2, 1, 1, 2); + countryGroupAssignment.putAll("AI", 1, 2, 2, 2, 2); + countryGroupAssignment.putAll("AL", 1, 1, 0, 1, 2); + countryGroupAssignment.putAll("AM", 2, 2, 1, 2, 2); + countryGroupAssignment.putAll("AO", 3, 4, 4, 2, 2); + countryGroupAssignment.putAll("AR", 2, 4, 2, 2, 2); + countryGroupAssignment.putAll("AS", 2, 2, 4, 3, 2); + countryGroupAssignment.putAll("AT", 0, 3, 0, 0, 2); + countryGroupAssignment.putAll("AU", 0, 2, 0, 1, 1); + countryGroupAssignment.putAll("AW", 1, 2, 0, 4, 2); + countryGroupAssignment.putAll("AX", 0, 2, 2, 2, 2); + countryGroupAssignment.putAll("AZ", 3, 3, 3, 4, 2); + countryGroupAssignment.putAll("BA", 1, 1, 0, 1, 2); + countryGroupAssignment.putAll("BB", 0, 2, 0, 0, 2); + countryGroupAssignment.putAll("BD", 2, 0, 3, 3, 2); + countryGroupAssignment.putAll("BE", 0, 1, 2, 3, 2); + countryGroupAssignment.putAll("BF", 4, 4, 4, 2, 2); + countryGroupAssignment.putAll("BG", 0, 1, 0, 0, 2); + countryGroupAssignment.putAll("BH", 1, 0, 2, 4, 2); + countryGroupAssignment.putAll("BI", 4, 4, 4, 4, 2); + countryGroupAssignment.putAll("BJ", 4, 4, 3, 4, 2); + countryGroupAssignment.putAll("BL", 1, 2, 2, 2, 2); + countryGroupAssignment.putAll("BM", 1, 2, 0, 0, 2); + countryGroupAssignment.putAll("BN", 4, 0, 1, 1, 2); + countryGroupAssignment.putAll("BO", 2, 3, 3, 2, 2); + countryGroupAssignment.putAll("BQ", 1, 2, 1, 2, 2); + countryGroupAssignment.putAll("BR", 2, 4, 2, 1, 2); + countryGroupAssignment.putAll("BS", 3, 2, 2, 3, 2); + countryGroupAssignment.putAll("BT", 3, 0, 3, 2, 2); + countryGroupAssignment.putAll("BW", 3, 4, 2, 2, 2); + countryGroupAssignment.putAll("BY", 1, 0, 2, 1, 2); + countryGroupAssignment.putAll("BZ", 2, 2, 2, 1, 2); + countryGroupAssignment.putAll("CA", 0, 3, 1, 2, 3); + countryGroupAssignment.putAll("CD", 4, 3, 2, 2, 2); + countryGroupAssignment.putAll("CF", 4, 2, 2, 2, 2); + countryGroupAssignment.putAll("CG", 3, 4, 1, 1, 2); + countryGroupAssignment.putAll("CH", 0, 1, 0, 0, 0); + countryGroupAssignment.putAll("CI", 3, 3, 3, 3, 2); + countryGroupAssignment.putAll("CK", 3, 2, 1, 0, 2); + countryGroupAssignment.putAll("CL", 1, 1, 2, 3, 2); + countryGroupAssignment.putAll("CM", 3, 4, 3, 2, 2); + countryGroupAssignment.putAll("CN", 2, 2, 2, 1, 3); + countryGroupAssignment.putAll("CO", 2, 4, 3, 2, 2); + countryGroupAssignment.putAll("CR", 2, 3, 4, 4, 2); + countryGroupAssignment.putAll("CU", 4, 4, 2, 1, 2); + countryGroupAssignment.putAll("CV", 2, 3, 3, 3, 2); + countryGroupAssignment.putAll("CW", 1, 2, 0, 0, 2); + countryGroupAssignment.putAll("CY", 1, 2, 0, 0, 2); + countryGroupAssignment.putAll("CZ", 0, 1, 0, 0, 2); + countryGroupAssignment.putAll("DE", 0, 1, 1, 2, 0); + countryGroupAssignment.putAll("DJ", 4, 1, 4, 4, 2); + countryGroupAssignment.putAll("DK", 0, 0, 1, 0, 2); + countryGroupAssignment.putAll("DM", 1, 2, 2, 2, 2); + countryGroupAssignment.putAll("DO", 3, 4, 4, 4, 2); + countryGroupAssignment.putAll("DZ", 3, 2, 4, 4, 2); + countryGroupAssignment.putAll("EC", 2, 4, 3, 2, 2); + countryGroupAssignment.putAll("EE", 0, 0, 0, 0, 2); + countryGroupAssignment.putAll("EG", 3, 4, 2, 1, 2); + countryGroupAssignment.putAll("EH", 2, 2, 2, 2, 2); + countryGroupAssignment.putAll("ER", 4, 2, 2, 2, 2); + countryGroupAssignment.putAll("ES", 0, 1, 2, 1, 2); + countryGroupAssignment.putAll("ET", 4, 4, 4, 1, 2); + countryGroupAssignment.putAll("FI", 0, 0, 1, 0, 0); + countryGroupAssignment.putAll("FJ", 3, 0, 3, 3, 2); + countryGroupAssignment.putAll("FK", 2, 2, 2, 2, 2); + countryGroupAssignment.putAll("FM", 4, 2, 4, 3, 2); + countryGroupAssignment.putAll("FO", 0, 2, 0, 0, 2); + countryGroupAssignment.putAll("FR", 1, 0, 2, 1, 2); + countryGroupAssignment.putAll("GA", 3, 3, 1, 0, 2); + countryGroupAssignment.putAll("GB", 0, 0, 1, 2, 2); + countryGroupAssignment.putAll("GD", 1, 2, 2, 2, 2); + countryGroupAssignment.putAll("GE", 1, 0, 1, 3, 2); + countryGroupAssignment.putAll("GF", 2, 2, 2, 4, 2); + countryGroupAssignment.putAll("GG", 0, 2, 0, 0, 2); + countryGroupAssignment.putAll("GH", 3, 2, 3, 2, 2); + countryGroupAssignment.putAll("GI", 0, 2, 0, 0, 2); + countryGroupAssignment.putAll("GL", 1, 2, 2, 1, 2); + countryGroupAssignment.putAll("GM", 4, 3, 2, 4, 2); + countryGroupAssignment.putAll("GN", 4, 3, 4, 2, 2); + countryGroupAssignment.putAll("GP", 2, 2, 3, 4, 2); + countryGroupAssignment.putAll("GQ", 4, 2, 3, 4, 2); + countryGroupAssignment.putAll("GR", 1, 1, 0, 1, 2); + countryGroupAssignment.putAll("GT", 3, 2, 3, 2, 2); + countryGroupAssignment.putAll("GU", 1, 2, 4, 4, 2); + countryGroupAssignment.putAll("GW", 3, 4, 4, 3, 2); + countryGroupAssignment.putAll("GY", 3, 3, 1, 0, 2); + countryGroupAssignment.putAll("HK", 0, 2, 3, 4, 2); + countryGroupAssignment.putAll("HN", 3, 0, 3, 3, 2); + countryGroupAssignment.putAll("HR", 1, 1, 0, 1, 2); + countryGroupAssignment.putAll("HT", 4, 3, 4, 4, 2); + countryGroupAssignment.putAll("HU", 0, 1, 0, 0, 2); + countryGroupAssignment.putAll("ID", 3, 2, 2, 3, 2); + countryGroupAssignment.putAll("IE", 0, 0, 1, 1, 2); + countryGroupAssignment.putAll("IL", 1, 0, 2, 3, 2); + countryGroupAssignment.putAll("IM", 0, 2, 0, 1, 2); + countryGroupAssignment.putAll("IN", 2, 1, 3, 3, 2); + countryGroupAssignment.putAll("IO", 4, 2, 2, 4, 2); + countryGroupAssignment.putAll("IQ", 3, 2, 4, 3, 2); + countryGroupAssignment.putAll("IR", 4, 2, 3, 4, 2); + countryGroupAssignment.putAll("IS", 0, 2, 0, 0, 2); + countryGroupAssignment.putAll("IT", 0, 0, 1, 1, 2); + countryGroupAssignment.putAll("JE", 2, 2, 0, 2, 2); + countryGroupAssignment.putAll("JM", 3, 3, 4, 4, 2); + countryGroupAssignment.putAll("JO", 1, 2, 1, 1, 2); + countryGroupAssignment.putAll("JP", 0, 2, 0, 1, 3); + countryGroupAssignment.putAll("KE", 3, 4, 2, 2, 2); + countryGroupAssignment.putAll("KG", 1, 0, 2, 2, 2); + countryGroupAssignment.putAll("KH", 2, 0, 4, 3, 2); + countryGroupAssignment.putAll("KI", 4, 2, 3, 1, 2); + countryGroupAssignment.putAll("KM", 4, 2, 2, 3, 2); + countryGroupAssignment.putAll("KN", 1, 2, 2, 2, 2); + countryGroupAssignment.putAll("KP", 4, 2, 2, 2, 2); + countryGroupAssignment.putAll("KR", 0, 2, 1, 1, 1); + countryGroupAssignment.putAll("KW", 2, 3, 1, 1, 1); + countryGroupAssignment.putAll("KY", 1, 2, 0, 0, 2); + countryGroupAssignment.putAll("KZ", 1, 2, 2, 3, 2); + countryGroupAssignment.putAll("LA", 2, 2, 1, 1, 2); + countryGroupAssignment.putAll("LB", 3, 2, 0, 0, 2); + countryGroupAssignment.putAll("LC", 1, 1, 0, 0, 2); + countryGroupAssignment.putAll("LI", 0, 2, 2, 2, 2); + countryGroupAssignment.putAll("LK", 2, 0, 2, 3, 2); + countryGroupAssignment.putAll("LR", 3, 4, 3, 2, 2); + countryGroupAssignment.putAll("LS", 3, 3, 2, 3, 2); + countryGroupAssignment.putAll("LT", 0, 0, 0, 0, 2); + countryGroupAssignment.putAll("LU", 0, 0, 0, 0, 2); + countryGroupAssignment.putAll("LV", 0, 0, 0, 0, 2); + countryGroupAssignment.putAll("LY", 4, 2, 4, 3, 2); + countryGroupAssignment.putAll("MA", 2, 1, 2, 1, 2); + countryGroupAssignment.putAll("MC", 0, 2, 2, 2, 2); + countryGroupAssignment.putAll("MD", 1, 2, 0, 0, 2); + countryGroupAssignment.putAll("ME", 1, 2, 1, 2, 2); + countryGroupAssignment.putAll("MF", 1, 2, 1, 0, 2); + countryGroupAssignment.putAll("MG", 3, 4, 3, 3, 2); + countryGroupAssignment.putAll("MH", 4, 2, 2, 4, 2); + countryGroupAssignment.putAll("MK", 1, 0, 0, 0, 2); + countryGroupAssignment.putAll("ML", 4, 4, 1, 1, 2); + countryGroupAssignment.putAll("MM", 2, 3, 2, 2, 2); + countryGroupAssignment.putAll("MN", 2, 4, 1, 1, 2); + countryGroupAssignment.putAll("MO", 0, 2, 4, 4, 2); + countryGroupAssignment.putAll("MP", 0, 2, 2, 2, 2); + countryGroupAssignment.putAll("MQ", 2, 2, 2, 3, 2); + countryGroupAssignment.putAll("MR", 3, 0, 4, 2, 2); + countryGroupAssignment.putAll("MS", 1, 2, 2, 2, 2); + countryGroupAssignment.putAll("MT", 0, 2, 0, 1, 2); + countryGroupAssignment.putAll("MU", 3, 1, 2, 3, 2); + countryGroupAssignment.putAll("MV", 4, 3, 1, 4, 2); + countryGroupAssignment.putAll("MW", 4, 1, 1, 0, 2); + countryGroupAssignment.putAll("MX", 2, 4, 3, 3, 2); + countryGroupAssignment.putAll("MY", 2, 0, 3, 3, 2); + countryGroupAssignment.putAll("MZ", 3, 3, 2, 3, 2); + countryGroupAssignment.putAll("NA", 4, 3, 2, 2, 2); + countryGroupAssignment.putAll("NC", 2, 0, 4, 4, 2); + countryGroupAssignment.putAll("NE", 4, 4, 4, 4, 2); + countryGroupAssignment.putAll("NF", 2, 2, 2, 2, 2); + countryGroupAssignment.putAll("NG", 3, 3, 2, 2, 2); + countryGroupAssignment.putAll("NI", 3, 1, 4, 4, 2); + countryGroupAssignment.putAll("NL", 0, 2, 4, 2, 0); + countryGroupAssignment.putAll("NO", 0, 1, 1, 0, 2); + countryGroupAssignment.putAll("NP", 2, 0, 4, 3, 2); + countryGroupAssignment.putAll("NR", 4, 2, 3, 1, 2); + countryGroupAssignment.putAll("NU", 4, 2, 2, 2, 2); + countryGroupAssignment.putAll("NZ", 0, 2, 1, 2, 4); + countryGroupAssignment.putAll("OM", 2, 2, 0, 2, 2); + countryGroupAssignment.putAll("PA", 1, 3, 3, 4, 2); + countryGroupAssignment.putAll("PE", 2, 4, 4, 4, 2); + countryGroupAssignment.putAll("PF", 2, 2, 1, 1, 2); + countryGroupAssignment.putAll("PG", 4, 3, 3, 2, 2); + countryGroupAssignment.putAll("PH", 3, 0, 3, 4, 4); + countryGroupAssignment.putAll("PK", 3, 2, 3, 3, 2); + countryGroupAssignment.putAll("PL", 1, 0, 2, 2, 2); + countryGroupAssignment.putAll("PM", 0, 2, 2, 2, 2); + countryGroupAssignment.putAll("PR", 1, 2, 2, 3, 4); + countryGroupAssignment.putAll("PS", 3, 3, 2, 2, 2); + countryGroupAssignment.putAll("PT", 1, 1, 0, 0, 2); + countryGroupAssignment.putAll("PW", 1, 2, 3, 0, 2); + countryGroupAssignment.putAll("PY", 2, 0, 3, 3, 2); + countryGroupAssignment.putAll("QA", 2, 3, 1, 2, 2); + countryGroupAssignment.putAll("RE", 1, 0, 2, 1, 2); + countryGroupAssignment.putAll("RO", 1, 1, 1, 2, 2); + countryGroupAssignment.putAll("RS", 1, 2, 0, 0, 2); + countryGroupAssignment.putAll("RU", 0, 1, 0, 1, 2); + countryGroupAssignment.putAll("RW", 4, 3, 3, 4, 2); + countryGroupAssignment.putAll("SA", 2, 2, 2, 1, 2); + countryGroupAssignment.putAll("SB", 4, 2, 4, 2, 2); + countryGroupAssignment.putAll("SC", 4, 2, 0, 1, 2); + countryGroupAssignment.putAll("SD", 4, 4, 4, 3, 2); + countryGroupAssignment.putAll("SE", 0, 0, 0, 0, 2); + countryGroupAssignment.putAll("SG", 0, 0, 3, 3, 4); + countryGroupAssignment.putAll("SH", 4, 2, 2, 2, 2); + countryGroupAssignment.putAll("SI", 0, 1, 0, 0, 2); + countryGroupAssignment.putAll("SJ", 2, 2, 2, 2, 2); + countryGroupAssignment.putAll("SK", 0, 1, 0, 0, 2); + countryGroupAssignment.putAll("SL", 4, 3, 3, 1, 2); + countryGroupAssignment.putAll("SM", 0, 2, 2, 2, 2); + countryGroupAssignment.putAll("SN", 4, 4, 4, 3, 2); + countryGroupAssignment.putAll("SO", 3, 4, 4, 4, 2); + countryGroupAssignment.putAll("SR", 3, 2, 3, 1, 2); + countryGroupAssignment.putAll("SS", 4, 1, 4, 2, 2); + countryGroupAssignment.putAll("ST", 2, 2, 1, 2, 2); + countryGroupAssignment.putAll("SV", 2, 1, 4, 4, 2); + countryGroupAssignment.putAll("SX", 2, 2, 1, 0, 2); + countryGroupAssignment.putAll("SY", 4, 3, 2, 2, 2); + countryGroupAssignment.putAll("SZ", 3, 4, 3, 4, 2); + countryGroupAssignment.putAll("TC", 1, 2, 1, 0, 2); + countryGroupAssignment.putAll("TD", 4, 4, 4, 4, 2); + countryGroupAssignment.putAll("TG", 3, 2, 1, 0, 2); + countryGroupAssignment.putAll("TH", 1, 3, 4, 3, 0); + countryGroupAssignment.putAll("TJ", 4, 4, 4, 4, 2); + countryGroupAssignment.putAll("TL", 4, 1, 4, 4, 2); + countryGroupAssignment.putAll("TM", 4, 2, 1, 2, 2); + countryGroupAssignment.putAll("TN", 2, 1, 1, 1, 2); + countryGroupAssignment.putAll("TO", 3, 3, 4, 2, 2); + countryGroupAssignment.putAll("TR", 1, 2, 1, 1, 2); + countryGroupAssignment.putAll("TT", 1, 3, 1, 3, 2); + countryGroupAssignment.putAll("TV", 3, 2, 2, 4, 2); + countryGroupAssignment.putAll("TW", 0, 0, 0, 0, 1); + countryGroupAssignment.putAll("TZ", 3, 3, 3, 2, 2); + countryGroupAssignment.putAll("UA", 0, 3, 0, 0, 2); + countryGroupAssignment.putAll("UG", 3, 2, 2, 3, 2); + countryGroupAssignment.putAll("US", 0, 1, 3, 3, 3); + countryGroupAssignment.putAll("UY", 2, 1, 1, 1, 2); + countryGroupAssignment.putAll("UZ", 2, 0, 3, 2, 2); + countryGroupAssignment.putAll("VC", 2, 2, 2, 2, 2); + countryGroupAssignment.putAll("VE", 4, 4, 4, 4, 2); + countryGroupAssignment.putAll("VG", 2, 2, 1, 2, 2); + countryGroupAssignment.putAll("VI", 1, 2, 2, 4, 2); + countryGroupAssignment.putAll("VN", 0, 1, 4, 4, 2); + countryGroupAssignment.putAll("VU", 4, 1, 3, 1, 2); + countryGroupAssignment.putAll("WS", 3, 1, 4, 2, 2); + countryGroupAssignment.putAll("XK", 1, 1, 1, 0, 2); + countryGroupAssignment.putAll("YE", 4, 4, 4, 4, 2); + countryGroupAssignment.putAll("YT", 3, 2, 1, 3, 2); + countryGroupAssignment.putAll("ZA", 2, 3, 2, 2, 2); + countryGroupAssignment.putAll("ZM", 3, 2, 2, 3, 2); + countryGroupAssignment.putAll("ZW", 3, 3, 3, 3, 2); + return countryGroupAssignment.build(); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java index 98026c4677..12fea3898c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java @@ -15,9 +15,11 @@ */ package com.google.android.exoplayer2.upstream; +import android.content.ContentResolver; import android.content.Context; import android.net.Uri; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; @@ -38,6 +40,9 @@ import java.util.Map; *
    5. rawresource: For fetching data from a raw resource in the application's apk (e.g. * rawresource:///resourceId, where rawResourceId is the integer identifier of the raw * resource). + *
    6. android.resource: For fetching data in the application's apk (e.g. + * android.resource:///resourceId or android.resource://resourceType/resourceName). See {@link + * RawResourceDataSource} for more information about the URI form. *
    7. content: For fetching data from a content URI (e.g. content://authority/path/123). *
    8. rtmp: For fetching data over RTMP. Only supported if the project using ExoPlayer has an * explicit dependency on ExoPlayer's RTMP extension. @@ -57,7 +62,9 @@ public final class DefaultDataSource implements DataSource { private static final String SCHEME_CONTENT = "content"; private static final String SCHEME_RTMP = "rtmp"; private static final String SCHEME_UDP = "udp"; + private static final String SCHEME_DATA = DataSchemeDataSource.SCHEME_DATA; private static final String SCHEME_RAW = RawResourceDataSource.RAW_RESOURCE_SCHEME; + private static final String SCHEME_ANDROID_RESOURCE = ContentResolver.SCHEME_ANDROID_RESOURCE; private final Context context; private final List transferListeners; @@ -74,6 +81,20 @@ public final class DefaultDataSource implements DataSource { @Nullable private DataSource dataSource; + /** + * Constructs a new instance, optionally configured to follow cross-protocol redirects. + * + * @param context A context. + */ + public DefaultDataSource(Context context, boolean allowCrossProtocolRedirects) { + this( + context, + ExoPlayerLibraryInfo.DEFAULT_USER_AGENT, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + allowCrossProtocolRedirects); + } + /** * Constructs a new instance, optionally configured to follow cross-protocol redirects. * @@ -135,6 +156,7 @@ public final class DefaultDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); baseDataSource.addTransferListener(transferListener); transferListeners.add(transferListener); maybeAddListenerToDataSource(fileDataSource, transferListener); @@ -166,9 +188,9 @@ public final class DefaultDataSource implements DataSource { dataSource = getRtmpDataSource(); } else if (SCHEME_UDP.equals(scheme)) { dataSource = getUdpDataSource(); - } else if (DataSchemeDataSource.SCHEME_DATA.equals(scheme)) { + } else if (SCHEME_DATA.equals(scheme)) { dataSource = getDataSchemeDataSource(); - } else if (SCHEME_RAW.equals(scheme)) { + } else if (SCHEME_RAW.equals(scheme) || SCHEME_ANDROID_RESOURCE.equals(scheme)) { dataSource = getRawResourceDataSource(); } else { dataSource = baseDataSource; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java index 6b1131a3bd..68ce25c47f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import android.content.Context; import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.DataSource.Factory; @@ -30,6 +32,17 @@ public final class DefaultDataSourceFactory implements Factory { private final DataSource.Factory baseDataSourceFactory; /** + * Creates an instance. + * + * @param context A context. + */ + public DefaultDataSourceFactory(Context context) { + this(context, DEFAULT_USER_AGENT, /* listener= */ null); + } + + /** + * Creates an instance. + * * @param context A context. * @param userAgent The User-Agent string that should be used. */ @@ -38,6 +51,8 @@ public final class DefaultDataSourceFactory implements Factory { } /** + * Creates an instance. + * * @param context A context. * @param userAgent The User-Agent string that should be used. * @param listener An optional listener. @@ -48,6 +63,8 @@ public final class DefaultDataSourceFactory implements Factory { } /** + * Creates an instance. + * * @param context A context. * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} * for {@link DefaultDataSource}. @@ -58,6 +75,8 @@ public final class DefaultDataSourceFactory implements Factory { } /** + * Creates an instance. + * * @param context A context. * @param listener An optional listener. * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java index 17f8427dd1..d15804fd51 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java @@ -15,16 +15,20 @@ */ package com.google.android.exoplayer2.upstream; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.net.Uri; import android.text.TextUtils; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.Predicate; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; @@ -94,12 +98,26 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou private long bytesSkipped; private long bytesRead; - /** @param userAgent The User-Agent string that should be used. */ + /** Creates an instance. */ + public DefaultHttpDataSource() { + this( + ExoPlayerLibraryInfo.DEFAULT_USER_AGENT, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS); + } + + /** + * Creates an instance. + * + * @param userAgent The User-Agent string that should be used. + */ public DefaultHttpDataSource(String userAgent) { this(userAgent, DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS); } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is * interpreted as an infinite timeout. @@ -116,6 +134,8 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is * interpreted as an infinite timeout. Pass {@link #DEFAULT_CONNECT_TIMEOUT_MILLIS} to use the @@ -143,6 +163,8 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -150,6 +172,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou * @deprecated Use {@link #DefaultHttpDataSource(String)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public DefaultHttpDataSource(String userAgent, @Nullable Predicate contentTypePredicate) { this( @@ -160,6 +183,8 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -188,6 +213,8 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -296,9 +323,20 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou // Check for a valid response code. if (responseCode < 200 || responseCode > 299) { Map> headers = connection.getHeaderFields(); + @Nullable InputStream errorStream = connection.getErrorStream(); + byte[] errorResponseBody; + try { + errorResponseBody = + errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY; + } catch (IOException e) { + throw new HttpDataSourceException( + "Error reading non-2xx response body", e, dataSpec, HttpDataSourceException.TYPE_OPEN); + } closeConnectionQuietly(); InvalidResponseCodeException exception = - new InvalidResponseCodeException(responseCode, responseMessage, headers, dataSpec); + new InvalidResponseCodeException( + responseCode, responseMessage, headers, dataSpec, errorResponseBody); + if (responseCode == 416) { exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE)); } @@ -307,7 +345,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou // Check for a valid content type. String contentType = connection.getContentType(); - if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) { + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { closeConnectionQuietly(); throw new InvalidContentTypeException(contentType, dataSpec); } @@ -540,7 +578,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou connection.setInstanceFollowRedirects(followRedirects); connection.setDoOutput(httpBody != null); connection.setRequestMethod(DataSpec.getStringForHttpMethod(httpMethod)); - + if (httpBody != null) { connection.setFixedLengthStreamingMode(httpBody.length); connection.connect(); @@ -622,7 +660,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou // increase it. Log.w(TAG, "Inconsistent headers [" + contentLengthHeader + "] [" + contentRangeHeader + "]"); - contentLength = Math.max(contentLength, contentLengthFromRange); + contentLength = max(contentLength, contentLengthFromRange); } } catch (NumberFormatException e) { Log.e(TAG, "Unexpected Content-Range [" + contentRangeHeader + "]"); @@ -652,7 +690,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou } while (bytesSkipped != bytesToSkip) { - int readLength = (int) Math.min(bytesToSkip - bytesSkipped, skipBuffer.length); + int readLength = (int) min(bytesToSkip - bytesSkipped, skipBuffer.length); int read = inputStream.read(skipBuffer, 0, readLength); if (Thread.currentThread().isInterrupted()) { throw new InterruptedIOException(); @@ -691,7 +729,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou if (bytesRemaining == 0) { return C.RESULT_END_OF_INPUT; } - readLength = (int) Math.min(readLength, bytesRemaining); + readLength = (int) min(readLength, bytesRemaining); } int read = inputStream.read(buffer, offset, readLength); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java index f5d7dbd24c..0a0650a4b1 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; @@ -30,10 +32,18 @@ public final class DefaultHttpDataSourceFactory extends BaseFactory { private final boolean allowCrossProtocolRedirects; /** - * Constructs a DefaultHttpDataSourceFactory. Sets {@link - * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. + */ + public DefaultHttpDataSourceFactory() { + this(DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. * * @param userAgent The User-Agent string that should be used. */ @@ -42,10 +52,9 @@ public final class DefaultHttpDataSourceFactory extends BaseFactory { } /** - * Constructs a DefaultHttpDataSourceFactory. Sets {@link - * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. * * @param userAgent The User-Agent string that should be used. * @param listener An optional listener. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java index 435f4bf578..366bd6509e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; @@ -32,8 +34,8 @@ public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { * streams. */ public static final int DEFAULT_MIN_LOADABLE_RETRY_COUNT_PROGRESSIVE_LIVE = 6; - /** The default duration for which a track is blacklisted in milliseconds. */ - public static final long DEFAULT_TRACK_BLACKLIST_MS = 60000; + /** The default duration for which a track is excluded in milliseconds. */ + public static final long DEFAULT_TRACK_BLACKLIST_MS = 60_000; private static final int DEFAULT_BEHAVIOR_MIN_LOADABLE_RETRY_COUNT = -1; @@ -61,12 +63,13 @@ public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { } /** - * Blacklists resources whose load error was an {@link InvalidResponseCodeException} with response - * code HTTP 404 or 410. The duration of the blacklisting is {@link #DEFAULT_TRACK_BLACKLIST_MS}. + * Returns the exclusion duration, given by {@link #DEFAULT_TRACK_BLACKLIST_MS}, if the load error + * was an {@link InvalidResponseCodeException} with response code HTTP 404, 410 or 416, or {@link + * C#TIME_UNSET} otherwise. */ @Override - public long getBlacklistDurationMsFor( - int dataType, long loadDurationMs, IOException exception, int errorCount) { + public long getBlacklistDurationMsFor(LoadErrorInfo loadErrorInfo) { + IOException exception = loadErrorInfo.exception; if (exception instanceof InvalidResponseCodeException) { int responseCode = ((InvalidResponseCodeException) exception).responseCode; return responseCode == 404 // HTTP 404 Not Found. @@ -84,13 +87,13 @@ public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { * {@code Math.min((errorCount - 1) * 1000, 5000)}. */ @Override - public long getRetryDelayMsFor( - int dataType, long loadDurationMs, IOException exception, int errorCount) { + public long getRetryDelayMsFor(LoadErrorInfo loadErrorInfo) { + IOException exception = loadErrorInfo.exception; return exception instanceof ParserException || exception instanceof FileNotFoundException || exception instanceof UnexpectedLoaderException ? C.TIME_UNSET - : Math.min((errorCount - 1) * 1000, 5000); + : min((loadErrorInfo.errorCount - 1) * 1000, 5000); } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DummyDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DummyDataSource.java index 4124a2531f..5303e7f6eb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DummyDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DummyDataSource.java @@ -19,9 +19,7 @@ import android.net.Uri; import androidx.annotation.Nullable; import java.io.IOException; -/** - * A dummy DataSource which provides no data. {@link #open(DataSpec)} throws {@link IOException}. - */ +/** A DataSource which provides no data. {@link #open(DataSpec)} throws {@link IOException}. */ public final class DummyDataSource implements DataSource { public static final DummyDataSource INSTANCE = new DummyDataSource(); @@ -38,7 +36,7 @@ public final class DummyDataSource implements DataSource { @Override public long open(DataSpec dataSpec) throws IOException { - throw new IOException("Dummy source"); + throw new IOException("DummyDataSource cannot be opened"); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/FileDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/FileDataSource.java index 93c1ce9adf..d34e43eb46 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/FileDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/FileDataSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; import android.net.Uri; import android.text.TextUtils; @@ -111,8 +112,7 @@ public final class FileDataSource extends BaseDataSource { } else { int bytesRead; try { - bytesRead = - castNonNull(file).read(buffer, offset, (int) Math.min(bytesRemaining, readLength)); + bytesRead = castNonNull(file).read(buffer, offset, (int) min(bytesRemaining, readLength)); } catch (IOException e) { throw new FileDataSourceException(e); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java index 61e3b8309a..0102ea7871 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java @@ -23,18 +23,16 @@ import com.google.android.exoplayer2.upstream.Loader.Loadable; import java.io.IOException; /** - * Defines how errors encountered by {@link Loader Loaders} are handled. + * Defines how errors encountered by loaders are handled. * - *

      Loader clients may blacklist a resource when a load error occurs. Blacklisting works around - * load errors by loading an alternative resource. Clients do not try blacklisting when a resource - * does not have an alternative. When a resource does have valid alternatives, {@link - * #getBlacklistDurationMsFor(int, long, IOException, int)} defines whether the resource should be - * blacklisted. Blacklisting will succeed if any of the alternatives is not in the black list. + *

      A loader that can choose between one of a number of resources can exclude a resource when a + * load error occurs. In this case, {@link #getBlacklistDurationMsFor(int, long, IOException, int)} + * defines whether the resource should be excluded. Exclusion will succeed unless all of the + * alternatives are already excluded. * - *

      When blacklisting does not take place, {@link #getRetryDelayMsFor(int, long, IOException, - * int)} defines whether the load is retried. Errors whose load is not retried are propagated. Load - * errors whose load is retried are propagated according to {@link - * #getMinimumLoadableRetryCount(int)}. + *

      When exclusion does not take place, {@link #getRetryDelayMsFor(int, long, IOException, int)} + * defines whether the load is retried. An error that's not retried will always be propagated. An + * error that is retried will be propagated according to {@link #getMinimumLoadableRetryCount(int)}. * *

      Methods are invoked on the playback thread. */ @@ -65,30 +63,22 @@ public interface LoadErrorHandlingPolicy { } } - /** - * Returns the number of milliseconds for which a resource associated to a provided load error - * should be blacklisted, or {@link C#TIME_UNSET} if the resource should not be blacklisted. - * - * @param dataType One of the {@link C C.DATA_TYPE_*} constants indicating the type of data to - * load. - * @param loadDurationMs The duration in milliseconds of the load from the start of the first load - * attempt up to the point at which the error occurred. - * @param exception The load error. - * @param errorCount The number of errors this load has encountered, including this one. - * @return The blacklist duration in milliseconds, or {@link C#TIME_UNSET} if the resource should - * not be blacklisted. - */ - long getBlacklistDurationMsFor( - int dataType, long loadDurationMs, IOException exception, int errorCount); + /** @deprecated Implement {@link #getBlacklistDurationMsFor(LoadErrorInfo)} instead. */ + @Deprecated + default long getBlacklistDurationMsFor( + int dataType, long loadDurationMs, IOException exception, int errorCount) { + throw new UnsupportedOperationException(); + } /** * Returns the number of milliseconds for which a resource associated to a provided load error - * should be blacklisted, or {@link C#TIME_UNSET} if the resource should not be blacklisted. + * should be excluded, or {@link C#TIME_UNSET} if the resource should not be excluded. * * @param loadErrorInfo A {@link LoadErrorInfo} holding information about the load error. - * @return The blacklist duration in milliseconds, or {@link C#TIME_UNSET} if the resource should - * not be blacklisted. + * @return The exclusion duration in milliseconds, or {@link C#TIME_UNSET} if the resource should + * not be excluded. */ + @SuppressWarnings("deprecation") default long getBlacklistDurationMsFor(LoadErrorInfo loadErrorInfo) { return getBlacklistDurationMsFor( loadErrorInfo.mediaLoadData.dataType, @@ -97,37 +87,26 @@ public interface LoadErrorHandlingPolicy { loadErrorInfo.errorCount); } - /** - * Returns the number of milliseconds to wait before attempting the load again, or {@link - * C#TIME_UNSET} if the error is fatal and should not be retried. - * - *

      {@link Loader} clients may ignore the retry delay returned by this method in order to wait - * for a specific event before retrying. However, the load is retried if and only if this method - * does not return {@link C#TIME_UNSET}. - * - * @param dataType One of the {@link C C.DATA_TYPE_*} constants indicating the type of data to - * load. - * @param loadDurationMs The duration in milliseconds of the load from the start of the first load - * attempt up to the point at which the error occurred. - * @param exception The load error. - * @param errorCount The number of errors this load has encountered, including this one. - * @return The number of milliseconds to wait before attempting the load again, or {@link - * C#TIME_UNSET} if the error is fatal and should not be retried. - */ - long getRetryDelayMsFor(int dataType, long loadDurationMs, IOException exception, int errorCount); + /** @deprecated Implement {@link #getRetryDelayMsFor(LoadErrorInfo)} instead. */ + @Deprecated + default long getRetryDelayMsFor( + int dataType, long loadDurationMs, IOException exception, int errorCount) { + throw new UnsupportedOperationException(); + } /** * Returns the number of milliseconds to wait before attempting the load again, or {@link * C#TIME_UNSET} if the error is fatal and should not be retried. * - *

      {@link Loader} clients may ignore the retry delay returned by this method in order to wait - * for a specific event before retrying. However, the load is retried if and only if this method - * does not return {@link C#TIME_UNSET}. + *

      Loaders may ignore the retry delay returned by this method in order to wait for a specific + * event before retrying. However, the load is retried if and only if this method does not return + * {@link C#TIME_UNSET}. * * @param loadErrorInfo A {@link LoadErrorInfo} holding information about the load error. * @return The number of milliseconds to wait before attempting the load again, or {@link * C#TIME_UNSET} if the error is fatal and should not be retried. */ + @SuppressWarnings("deprecation") default long getRetryDelayMsFor(LoadErrorInfo loadErrorInfo) { return getRetryDelayMsFor( loadErrorInfo.mediaLoadData.dataType, 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 4ff58b108c..cab9d003c4 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static java.lang.Math.min; + import android.annotation.SuppressLint; import android.os.Handler; import android.os.Looper; @@ -32,6 +34,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 +59,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(); @@ -307,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; @@ -321,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, @@ -354,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(); - @Nullable 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) { @@ -382,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(); @@ -391,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) { @@ -445,10 +476,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) { @@ -490,7 +518,7 @@ public final class Loader implements LoaderErrorThrower { } private long getRetryDelayMillis() { - return Math.min((errorCount - 1) * 1000, 5000); + return min((errorCount - 1) * 1000, 5000); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java index 767b6d78a3..e52e1db376 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/PriorityDataSource.java @@ -55,6 +55,7 @@ public final class PriorityDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java index fbfd698610..7538cc67a4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java @@ -16,7 +16,9 @@ package com.google.android.exoplayer2.upstream; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; +import android.content.ContentResolver; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.content.res.Resources; @@ -33,9 +35,20 @@ import java.io.InputStream; /** * A {@link DataSource} for reading a raw resource inside the APK. * - *

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

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

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

      {@link #buildRawResourceUri(int)} can be used to build supported {@link Uri}s. */ public final class RawResourceDataSource extends BaseDataSource { @@ -66,6 +79,7 @@ public final class RawResourceDataSource extends BaseDataSource { public static final String RAW_RESOURCE_SCHEME = "rawresource"; private final Resources resources; + private final String packageName; @Nullable private Uri uri; @Nullable private AssetFileDescriptor assetFileDescriptor; @@ -79,33 +93,55 @@ public final class RawResourceDataSource extends BaseDataSource { public RawResourceDataSource(Context context) { super(/* isNetwork= */ false); this.resources = context.getResources(); + this.packageName = context.getPackageName(); } @Override public long open(DataSpec dataSpec) throws RawResourceDataSourceException { - try { - Uri uri = dataSpec.uri; - this.uri = uri; - if (!TextUtils.equals(RAW_RESOURCE_SCHEME, uri.getScheme())) { - throw new RawResourceDataSourceException("URI must use scheme " + RAW_RESOURCE_SCHEME); - } + Uri uri = dataSpec.uri; + this.uri = uri; - int resourceId; + int resourceId; + if (TextUtils.equals(RAW_RESOURCE_SCHEME, uri.getScheme()) + || (TextUtils.equals(ContentResolver.SCHEME_ANDROID_RESOURCE, uri.getScheme()) + && uri.getPathSegments().size() == 1 + && Assertions.checkNotNull(uri.getLastPathSegment()).matches("\\d+"))) { try { resourceId = Integer.parseInt(Assertions.checkNotNull(uri.getLastPathSegment())); } catch (NumberFormatException e) { throw new RawResourceDataSourceException("Resource identifier must be an integer."); } - - transferInitializing(dataSpec); - AssetFileDescriptor assetFileDescriptor = resources.openRawResourceFd(resourceId); - this.assetFileDescriptor = assetFileDescriptor; - if (assetFileDescriptor == null) { - throw new RawResourceDataSourceException("Resource is compressed: " + uri); + } else if (TextUtils.equals(ContentResolver.SCHEME_ANDROID_RESOURCE, uri.getScheme())) { + String path = Assertions.checkNotNull(uri.getPath()); + if (path.startsWith("/")) { + path = path.substring(1); } - FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); - this.inputStream = inputStream; + @Nullable String host = uri.getHost(); + String resourceName = (TextUtils.isEmpty(host) ? "" : (host + ":")) + path; + resourceId = + resources.getIdentifier( + resourceName, /* defType= */ "raw", /* defPackage= */ packageName); + if (resourceId == 0) { + throw new RawResourceDataSourceException("Resource not found."); + } + } else { + throw new RawResourceDataSourceException( + "URI must either use scheme " + + RAW_RESOURCE_SCHEME + + " or " + + ContentResolver.SCHEME_ANDROID_RESOURCE); + } + transferInitializing(dataSpec); + AssetFileDescriptor assetFileDescriptor = resources.openRawResourceFd(resourceId); + this.assetFileDescriptor = assetFileDescriptor; + if (assetFileDescriptor == null) { + throw new RawResourceDataSourceException("Resource is compressed: " + uri); + } + + FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); + this.inputStream = inputStream; + try { inputStream.skip(assetFileDescriptor.getStartOffset()); long skipped = inputStream.skip(dataSpec.position); if (skipped < dataSpec.position) { @@ -113,18 +149,21 @@ public final class RawResourceDataSource extends BaseDataSource { // skip beyond the end of the data. throw new EOFException(); } - if (dataSpec.length != C.LENGTH_UNSET) { - bytesRemaining = dataSpec.length; - } else { - long assetFileDescriptorLength = assetFileDescriptor.getLength(); - // If the length is UNKNOWN_LENGTH then the asset extends to the end of the file. - bytesRemaining = assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH - ? C.LENGTH_UNSET : (assetFileDescriptorLength - dataSpec.position); - } } catch (IOException e) { throw new RawResourceDataSourceException(e); } + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + long assetFileDescriptorLength = assetFileDescriptor.getLength(); + // If the length is UNKNOWN_LENGTH then the asset extends to the end of the file. + bytesRemaining = + assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH + ? C.LENGTH_UNSET + : (assetFileDescriptorLength - dataSpec.position); + } + opened = true; transferStarted(dataSpec); @@ -141,8 +180,8 @@ public final class RawResourceDataSource extends BaseDataSource { int bytesRead; try { - int bytesToRead = bytesRemaining == C.LENGTH_UNSET ? readLength - : (int) Math.min(bytesRemaining, readLength); + int bytesToRead = + bytesRemaining == C.LENGTH_UNSET ? readLength : (int) min(bytesRemaining, readLength); bytesRead = castNonNull(inputStream).read(buffer, offset, bytesToRead); } catch (IOException e) { throw new RawResourceDataSourceException(e); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java index f5fb67e40e..958780cbc3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ResolvingDataSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; import androidx.annotation.Nullable; import java.io.IOException; @@ -95,6 +97,7 @@ public final class ResolvingDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); upstreamDataSource.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java index 6cdc381ba2..4340169f45 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/StatsDataSource.java @@ -72,6 +72,7 @@ public final class StatsDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); dataSource.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java index f56f19a6ca..689273d388 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/TeeDataSource.java @@ -45,6 +45,7 @@ public final class TeeDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java index 4d9b375334..e2b8ba1b31 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/UdpDataSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static java.lang.Math.min; + import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -137,7 +139,7 @@ public final class UdpDataSource extends BaseDataSource { } int packetOffset = packet.getLength() - packetRemaining; - int bytesToRead = Math.min(packetRemaining, readLength); + int bytesToRead = min(packetRemaining, readLength); System.arraycopy(packetBuffer, packetOffset, buffer, offset, bytesToRead); packetRemaining -= bytesToRead; return bytesToRead; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java index 1d504159e6..c917929111 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -24,7 +24,20 @@ import java.util.NavigableSet; import java.util.Set; /** - * An interface for cache. + * A cache that supports partial caching of resources. + * + *

      Terminology

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

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

      If there is a cache entry that overlaps the position, then the returned {@link CacheSpan} * defines the file in which the data is stored. {@link CacheSpan#isCached} is true. The caller * may read from the cache file, but does not acquire any locks. * - *

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

      If there is no cache entry overlapping {@code position}, then the returned {@link CacheSpan} * defines a hole in the cache starting at {@code position} into which the caller may write as it * obtains the data from some other source. The returned {@link CacheSpan} serves as a lock. * Whilst the caller holds the lock it may write data into the hole. It may split data into @@ -168,38 +175,47 @@ public interface Cache { * *

      This method may be slow and shouldn't normally be called on the main thread. * - * @param key The key of the data being requested. - * @param position The position of the data being requested. + * @param key The cache key of the resource. + * @param position The starting position in the resource from which data is required. + * @param length The length of the data being requested, or {@link C#LENGTH_UNSET} if unbounded. + * The length is ignored in the case of a cache hit. In the case of a cache miss, it defines + * the maximum length of the hole {@link CacheSpan} that's returned. Cache implementations may + * support parallel writes into non-overlapping holes, and so passing the actual required + * length should be preferred to passing {@link C#LENGTH_UNSET} when possible. * @return The {@link CacheSpan}. * @throws InterruptedException If the thread was interrupted. * @throws CacheException If an error is encountered. */ @WorkerThread - CacheSpan startReadWrite(String key, long position) throws InterruptedException, CacheException; + CacheSpan startReadWrite(String key, long position, long length) + throws InterruptedException, CacheException; /** - * Same as {@link #startReadWrite(String, long)}. However, if the cache entry is locked, then - * instead of blocking, this method will return null as the {@link CacheSpan}. + * Same as {@link #startReadWrite(String, long, long)}. However, if the cache entry is locked, + * then instead of blocking, this method will return null as the {@link CacheSpan}. * *

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

      This method may be slow and shouldn't normally be called on the main thread. * - * @param key The cache key for the data. - * @param position The starting position of the data. + * @param key The cache key of the resource being written. + * @param position The starting position in the resource from which data will be written. * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown. Used * only to ensure that there is enough space in the cache. * @return The file into which data should be written. @@ -210,7 +226,7 @@ public interface Cache { /** * Commits a file into the cache. Must only be called when holding a corresponding hole {@link - * CacheSpan} obtained from {@link #startReadWrite(String, long)}. + * CacheSpan} obtained from {@link #startReadWrite(String, long, long)}. * *

      This method may be slow and shouldn't normally be called on the main thread. * @@ -222,53 +238,75 @@ public interface Cache { void commitFile(File file, long length) throws CacheException; /** - * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long)} which + * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long, long)} which * corresponded to a hole in the cache. * * @param holeSpan The {@link CacheSpan} being released. */ void releaseHoleSpan(CacheSpan holeSpan); + /** + * Removes all {@link CacheSpan CacheSpans} for a resource, deleting the underlying files. + * + * @param key The cache key of the resource being removed. + */ + @WorkerThread + void removeResource(String key); + /** * Removes a cached {@link CacheSpan} from the cache, deleting the underlying file. * *

      This method may be slow and shouldn't normally be called on the main thread. * * @param span The {@link CacheSpan} to remove. - * @throws CacheException If an error is encountered. */ @WorkerThread - void removeSpan(CacheSpan span) throws CacheException; + void removeSpan(CacheSpan span); /** - * Queries if a range is entirely available in the cache. + * Returns whether the specified range of data in a resource is fully cached. * - * @param key The cache key for the data. - * @param position The starting position of the data. + * @param key The cache key of the resource. + * @param position The starting position of the data in the resource. * @param length The length of the data. * @return true if the data is available in the Cache otherwise false; */ boolean isCached(String key, long position, long length); /** - * Returns the length of the cached data block starting from the {@code position} to the block end - * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap - * to the next cached data up to {@code length} bytes) is returned. + * Returns the length of continuously cached data starting from {@code position}, up to a maximum + * of {@code maxLength}, of a resource. If {@code position} isn't cached then {@code -holeLength} + * is returned, where {@code holeLength} is the length of continuously uncached data starting from + * {@code position}, up to a maximum of {@code maxLength}. * - * @param key The cache key for the data. - * @param position The starting position of the data. - * @param length The maximum length of the data to be returned. - * @return The length of the cached or not cached data block length. + * @param key The cache key of the resource. + * @param position The starting position of the data in the resource. + * @param length The maximum length of the data or hole to be returned. {@link C#LENGTH_UNSET} is + * permitted, and is equivalent to passing {@link Long#MAX_VALUE}. + * @return The length of the continuously cached data, or {@code -holeLength} if {@code position} + * isn't cached. */ long getCachedLength(String key, long position, long length); /** - * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link - * CachedContent} is added if there isn't one already with the given key. + * Returns the total number of cached bytes between {@code position} (inclusive) and {@code + * (position + length)} (exclusive) of a resource. + * + * @param key The cache key of the resource. + * @param position The starting position of the data in the resource. + * @param length The length of the data to check. {@link C#LENGTH_UNSET} is permitted, and is + * equivalent to passing {@link Long#MAX_VALUE}. + * @return The total number of cached bytes. + */ + long getCachedBytes(String key, long position, long length); + + /** + * Applies {@code mutations} to the {@link ContentMetadata} for the given resource. A new {@link + * CachedContent} is added if there isn't one already for the resource. * *

      This method may be slow and shouldn't normally be called on the main thread. * - * @param key The cache key for the data. + * @param key The cache key of the resource. * @param mutations Contains mutations to be applied to the metadata. * @throws CacheException If an error is encountered. */ @@ -277,10 +315,10 @@ public interface Cache { throws CacheException; /** - * Returns a {@link ContentMetadata} for the given key. + * Returns a {@link ContentMetadata} for the given resource. * - * @param key The cache key for the data. - * @return A {@link ContentMetadata} for the given key. + * @param key The cache key of the resource. + * @return The {@link ContentMetadata} for the resource. */ ContentMetadata getContentMetadata(String key); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java index 454674f665..76a833ddb5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.upstream.cache; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.DataSink; @@ -99,7 +103,7 @@ public final class CacheDataSink implements DataSink { @Override public DataSink createDataSink() { - return new CacheDataSink(Assertions.checkNotNull(cache), fragmentSize, bufferSize); + return new CacheDataSink(checkNotNull(cache), fragmentSize, bufferSize); } } @@ -166,13 +170,14 @@ public final class CacheDataSink implements DataSink { + MIN_RECOMMENDED_FRAGMENT_SIZE + ". This may cause poor cache performance."); } - this.cache = Assertions.checkNotNull(cache); + this.cache = checkNotNull(cache); this.fragmentSize = fragmentSize == C.LENGTH_UNSET ? Long.MAX_VALUE : fragmentSize; this.bufferSize = bufferSize; } @Override public void open(DataSpec dataSpec) throws CacheDataSinkException { + checkNotNull(dataSpec.key); if (dataSpec.length == C.LENGTH_UNSET && dataSpec.isFlagSet(DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN)) { this.dataSpec = null; @@ -183,7 +188,7 @@ public final class CacheDataSink implements DataSink { dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) ? fragmentSize : Long.MAX_VALUE; dataSpecBytesWritten = 0; try { - openNextOutputStream(); + openNextOutputStream(dataSpec); } catch (IOException e) { throw new CacheDataSinkException(e); } @@ -191,6 +196,7 @@ public final class CacheDataSink implements DataSink { @Override public void write(byte[] buffer, int offset, int length) throws CacheDataSinkException { + @Nullable DataSpec dataSpec = this.dataSpec; if (dataSpec == null) { return; } @@ -199,11 +205,11 @@ public final class CacheDataSink implements DataSink { while (bytesWritten < length) { if (outputStreamBytesWritten == dataSpecFragmentSize) { closeCurrentOutputStream(); - openNextOutputStream(); + openNextOutputStream(dataSpec); } int bytesToWrite = - (int) Math.min(length - bytesWritten, dataSpecFragmentSize - outputStreamBytesWritten); - outputStream.write(buffer, offset + bytesWritten, bytesToWrite); + (int) min(length - bytesWritten, dataSpecFragmentSize - outputStreamBytesWritten); + castNonNull(outputStream).write(buffer, offset + bytesWritten, bytesToWrite); bytesWritten += bytesToWrite; outputStreamBytesWritten += bytesToWrite; dataSpecBytesWritten += bytesToWrite; @@ -225,12 +231,14 @@ public final class CacheDataSink implements DataSink { } } - private void openNextOutputStream() throws IOException { + private void openNextOutputStream(DataSpec dataSpec) throws IOException { long length = dataSpec.length == C.LENGTH_UNSET ? C.LENGTH_UNSET - : Math.min(dataSpec.length - dataSpecBytesWritten, dataSpecFragmentSize); - file = cache.startFile(dataSpec.key, dataSpec.position + dataSpecBytesWritten, length); + : min(dataSpec.length - dataSpecBytesWritten, dataSpecFragmentSize); + file = + cache.startFile( + castNonNull(dataSpec.key), dataSpec.position + dataSpecBytesWritten, length); FileOutputStream underlyingFileOutputStream = new FileOutputStream(file); if (bufferSize > 0) { if (bufferedOutputStream == null) { @@ -258,7 +266,7 @@ public final class CacheDataSink implements DataSink { } finally { Util.closeQuietly(outputStream); outputStream = null; - File fileToCommit = file; + File fileToCommit = castNonNull(file); file = null; if (success) { cache.commitFile(fileToCommit, outputStreamBytesWritten); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java index 3f9010a609..c2bbdbb893 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.upstream.cache; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; + import android.net.Uri; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -64,7 +68,7 @@ public final class CacheDataSource implements DataSource { public Factory() { cacheReadDataSourceFactory = new FileDataSource.Factory(); - cacheKeyFactory = CacheUtil.DEFAULT_CACHE_KEY_FACTORY; + cacheKeyFactory = CacheKeyFactory.DEFAULT; } /** @@ -123,7 +127,7 @@ public final class CacheDataSource implements DataSource { /** * Sets the {@link CacheKeyFactory}. * - *

      The default is {@link CacheUtil#DEFAULT_CACHE_KEY_FACTORY}. + *

      The default is {@link CacheKeyFactory#DEFAULT}. * * @param cacheKeyFactory The {@link CacheKeyFactory}. * @return This factory. @@ -275,7 +279,7 @@ public final class CacheDataSource implements DataSource { private CacheDataSource createDataSourceInternal( @Nullable DataSource upstreamDataSource, @Flags int flags, int upstreamPriority) { - Cache cache = Assertions.checkNotNull(this.cache); + Cache cache = checkNotNull(this.cache); @Nullable DataSink cacheWriteDataSink; if (cacheIsReadOnly || upstreamDataSource == null) { cacheWriteDataSink = null; @@ -376,8 +380,6 @@ public final class CacheDataSource implements DataSource { @Nullable private final DataSource cacheWriteDataSource; private final DataSource upstreamDataSource; private final CacheKeyFactory cacheKeyFactory; - @Nullable private final PriorityTaskManager upstreamPriorityTaskManager; - private final int upstreamPriority; @Nullable private final EventListener eventListener; private final boolean blockOnCache; @@ -508,14 +510,11 @@ public final class CacheDataSource implements DataSource { @Nullable EventListener eventListener) { this.cache = cache; this.cacheReadDataSource = cacheReadDataSource; - this.cacheKeyFactory = - cacheKeyFactory != null ? cacheKeyFactory : CacheUtil.DEFAULT_CACHE_KEY_FACTORY; + this.cacheKeyFactory = cacheKeyFactory != null ? cacheKeyFactory : CacheKeyFactory.DEFAULT; this.blockOnCache = (flags & FLAG_BLOCK_ON_CACHE) != 0; this.ignoreCacheOnError = (flags & FLAG_IGNORE_CACHE_ON_ERROR) != 0; this.ignoreCacheForUnsetLengthRequests = (flags & FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS) != 0; - this.upstreamPriority = upstreamPriority; - this.upstreamPriorityTaskManager = upstreamPriorityTaskManager; if (upstreamDataSource != null) { if (upstreamPriorityTaskManager != null) { upstreamDataSource = @@ -544,26 +543,9 @@ public final class CacheDataSource implements DataSource { return cacheKeyFactory; } - /** - * Returns the {@link PriorityTaskManager} used when there's a cache miss and requests need to be - * made to the upstream {@link DataSource}, or {@code null} if there is none. - */ - @Nullable - public PriorityTaskManager getUpstreamPriorityTaskManager() { - return upstreamPriorityTaskManager; - } - - /** - * Returns the priority used when there's a cache miss and requests need to be made to the - * upstream {@link DataSource}. The priority is only used if the source has a {@link - * PriorityTaskManager}. - */ - public int getUpstreamPriority() { - return upstreamPriority; - } - @Override public void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); cacheReadDataSource.addTransferListener(transferListener); upstreamDataSource.addTransferListener(transferListener); } @@ -572,7 +554,8 @@ public final class CacheDataSource implements DataSource { public long open(DataSpec dataSpec) throws IOException { try { String key = cacheKeyFactory.buildCacheKey(dataSpec); - requestDataSpec = dataSpec.buildUpon().setKey(key).build(); + DataSpec requestDataSpec = dataSpec.buildUpon().setKey(key).build(); + this.requestDataSpec = requestDataSpec; actualUri = getRedirectedUriOrDefault(cache, key, /* defaultUri= */ requestDataSpec.uri); readPosition = dataSpec.position; @@ -593,7 +576,7 @@ public final class CacheDataSource implements DataSource { } } } - openNextSource(false); + openNextSource(requestDataSpec, false); return bytesRemaining; } catch (Throwable e) { handleBeforeThrow(e); @@ -603,6 +586,7 @@ public final class CacheDataSource implements DataSource { @Override public int read(byte[] buffer, int offset, int readLength) throws IOException { + DataSpec requestDataSpec = checkNotNull(this.requestDataSpec); if (readLength == 0) { return 0; } @@ -611,9 +595,9 @@ public final class CacheDataSource implements DataSource { } try { if (readPosition >= checkCachePosition) { - openNextSource(true); + openNextSource(requestDataSpec, true); } - int bytesRead = currentDataSource.read(buffer, offset, readLength); + int bytesRead = checkNotNull(currentDataSource).read(buffer, offset, readLength); if (bytesRead != C.RESULT_END_OF_INPUT) { if (isReadingFromCache()) { totalCachedBytesRead += bytesRead; @@ -623,16 +607,16 @@ public final class CacheDataSource implements DataSource { bytesRemaining -= bytesRead; } } else if (currentDataSpecLengthUnset) { - setNoBytesRemainingAndMaybeStoreLength(); + setNoBytesRemainingAndMaybeStoreLength(castNonNull(requestDataSpec.key)); } else if (bytesRemaining > 0 || bytesRemaining == C.LENGTH_UNSET) { closeCurrentSource(); - openNextSource(false); + openNextSource(requestDataSpec, false); return read(buffer, offset, readLength); } return bytesRead; } catch (IOException e) { - if (currentDataSpecLengthUnset && CacheUtil.isCausedByPositionOutOfRange(e)) { - setNoBytesRemainingAndMaybeStoreLength(); + if (currentDataSpecLengthUnset && DataSourceException.isCausedByPositionOutOfRange(e)) { + setNoBytesRemainingAndMaybeStoreLength(castNonNull(requestDataSpec.key)); return C.RESULT_END_OF_INPUT; } handleBeforeThrow(e); @@ -682,23 +666,24 @@ public final class CacheDataSource implements DataSource { * opened if it's possible to switch to reading from or writing to the cache. If a switch isn't * possible then the current source is left unchanged. * + * @param requestDataSpec The original {@link DataSpec} to build upon for the next source. * @param checkCache If true tries to switch to reading from or writing to cache instead of * reading from {@link #upstreamDataSource}, which is the currently open source. */ - private void openNextSource(boolean checkCache) throws IOException { + private void openNextSource(DataSpec requestDataSpec, boolean checkCache) throws IOException { @Nullable CacheSpan nextSpan; - String key = requestDataSpec.key; + String key = castNonNull(requestDataSpec.key); if (currentRequestIgnoresCache) { nextSpan = null; } else if (blockOnCache) { try { - nextSpan = cache.startReadWrite(key, readPosition); + nextSpan = cache.startReadWrite(key, readPosition, bytesRemaining); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new InterruptedIOException(); } } else { - nextSpan = cache.startReadWriteNonBlocking(key, readPosition); + nextSpan = cache.startReadWriteNonBlocking(key, readPosition, bytesRemaining); } DataSpec nextDataSpec; @@ -711,12 +696,12 @@ public final class CacheDataSource implements DataSource { requestDataSpec.buildUpon().setPosition(readPosition).setLength(bytesRemaining).build(); } else if (nextSpan.isCached) { // Data is cached in a span file starting at nextSpan.position. - Uri fileUri = Uri.fromFile(nextSpan.file); + Uri fileUri = Uri.fromFile(castNonNull(nextSpan.file)); long filePositionOffset = nextSpan.position; long positionInFile = readPosition - filePositionOffset; long length = nextSpan.length - positionInFile; if (bytesRemaining != C.LENGTH_UNSET) { - length = Math.min(length, bytesRemaining); + length = min(length, bytesRemaining); } nextDataSpec = requestDataSpec @@ -735,7 +720,7 @@ public final class CacheDataSource implements DataSource { } else { length = nextSpan.length; if (bytesRemaining != C.LENGTH_UNSET) { - length = Math.min(length, bytesRemaining); + length = min(length, bytesRemaining); } } nextDataSpec = @@ -763,7 +748,7 @@ public final class CacheDataSource implements DataSource { try { closeCurrentSource(); } catch (Throwable e) { - if (nextSpan.isHoleSpan()) { + if (castNonNull(nextSpan).isHoleSpan()) { // Release the hole span before throwing, else we'll hold it forever. cache.releaseHoleSpan(nextSpan); } @@ -785,7 +770,7 @@ public final class CacheDataSource implements DataSource { ContentMetadataMutations.setContentLength(mutations, readPosition + bytesRemaining); } if (isReadingFromUpstream()) { - actualUri = currentDataSource.getUri(); + actualUri = nextDataSource.getUri(); boolean isRedirected = !requestDataSpec.uri.equals(actualUri); ContentMetadataMutations.setRedirectedUri(mutations, isRedirected ? actualUri : null); } @@ -794,12 +779,12 @@ public final class CacheDataSource implements DataSource { } } - private void setNoBytesRemainingAndMaybeStoreLength() throws IOException { + private void setNoBytesRemainingAndMaybeStoreLength(String key) throws IOException { bytesRemaining = 0; if (isWritingToCache()) { ContentMetadataMutations mutations = new ContentMetadataMutations(); ContentMetadataMutations.setContentLength(mutations, readPosition); - cache.applyContentMetadataMutations(requestDataSpec.key, mutations); + cache.applyContentMetadataMutations(key, mutations); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java index a9348b7d3a..2c51da8a8d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java @@ -45,7 +45,6 @@ public final class CacheDataSourceFactory implements DataSource.Factory { } /** @see CacheDataSource#CacheDataSource(Cache, DataSource, int) */ - @SuppressWarnings("deprecation") public CacheDataSourceFactory( Cache cache, DataSource.Factory upstreamFactory, @CacheDataSource.Flags int flags) { this( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java index 3401d6f575..69e9b73fdd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java @@ -20,11 +20,20 @@ import com.google.android.exoplayer2.upstream.DataSpec; /** Factory for cache keys. */ public interface CacheKeyFactory { + /** Default {@link CacheKeyFactory}. */ + CacheKeyFactory DEFAULT = + (dataSpec) -> dataSpec.key != null ? dataSpec.key : dataSpec.uri.toString(); + /** - * Returns a cache key for the given {@link DataSpec}. + * Returns the cache key of the resource containing the data defined by a {@link DataSpec}. * - * @param dataSpec The data being cached. - * @return The cache key. + *

      Note that since the returned cache key corresponds to the whole resource, implementations + * must not return different cache keys for {@link DataSpec DataSpecs} that define different + * ranges of the same resource. As a result, implementations should not use fields such as {@link + * DataSpec#position} and {@link DataSpec#length}. + * + * @param dataSpec The {@link DataSpec}. + * @return The cache key of the resource. */ String buildCacheKey(DataSpec dataSpec); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java index bf51a69240..492681e7fc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheSpan.java @@ -24,21 +24,15 @@ import java.io.File; */ public class CacheSpan implements Comparable { - /** - * The cache key that uniquely identifies the original stream. - */ + /** The cache key that uniquely identifies the resource. */ public final String key; - /** - * The position of the {@link CacheSpan} in the original stream. - */ + /** The position of the {@link CacheSpan} in the resource. */ public final long position; /** * The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an open-ended hole. */ public final long length; - /** - * Whether the {@link CacheSpan} is cached. - */ + /** Whether the {@link CacheSpan} is cached. */ public final boolean isCached; /** The file corresponding to this {@link CacheSpan}, or null if {@link #isCached} is false. */ @Nullable public final File file; @@ -49,8 +43,8 @@ public class CacheSpan implements Comparable { * Creates a hole CacheSpan which isn't cached, has no last touch timestamp and no file * associated. * - * @param key The cache key that uniquely identifies the original stream. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key that uniquely identifies the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an * open-ended hole. */ @@ -61,8 +55,8 @@ public class CacheSpan implements Comparable { /** * Creates a CacheSpan. * - * @param key The cache key that uniquely identifies the original stream. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key that uniquely identifies the resource. + * @param position The position of the {@link CacheSpan} in the resource. * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an * open-ended hole. * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link @@ -102,4 +96,8 @@ public class CacheSpan implements Comparable { return startOffsetDiff == 0 ? 0 : ((startOffsetDiff < 0) ? -1 : 1); } + @Override + public String toString() { + return "[" + position + ", " + length + "]"; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java deleted file mode 100644 index f19b818e1a..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ /dev/null @@ -1,421 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.upstream.cache; - -import android.net.Uri; -import android.util.Pair; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSourceException; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.Assertions; -import com.google.android.exoplayer2.util.PriorityTaskManager; -import com.google.android.exoplayer2.util.Util; -import java.io.EOFException; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.util.NavigableSet; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Caching related utility methods. - */ -public final class CacheUtil { - - /** Receives progress updates during cache operations. */ - public interface ProgressListener { - - /** - * Called when progress is made during a cache operation. - * - * @param requestLength The length of the content being cached in bytes, or {@link - * C#LENGTH_UNSET} if unknown. - * @param bytesCached The number of bytes that are cached. - * @param newBytesCached The number of bytes that have been newly cached since the last progress - * update. - */ - void onProgress(long requestLength, long bytesCached, long newBytesCached); - } - - /** Default buffer size to be used while caching. */ - public static final int DEFAULT_BUFFER_SIZE_BYTES = 128 * 1024; - - /** Default {@link CacheKeyFactory}. */ - public static final CacheKeyFactory DEFAULT_CACHE_KEY_FACTORY = - (dataSpec) -> dataSpec.key != null ? dataSpec.key : generateKey(dataSpec.uri); - - /** - * Generates a cache key out of the given {@link Uri}. - * - * @param uri Uri of a content which the requested key is for. - */ - public static String generateKey(Uri uri) { - return uri.toString(); - } - - /** - * Queries the cache to obtain the request length and the number of bytes already cached for a - * given {@link DataSpec}. - * - * @param dataSpec Defines the data to be checked. - * @param cache A {@link Cache} which has the data. - * @param cacheKeyFactory An optional factory for cache keys. - * @return A pair containing the request length and the number of bytes that are already cached. - */ - public static Pair getCached( - DataSpec dataSpec, Cache cache, @Nullable CacheKeyFactory cacheKeyFactory) { - String key = buildCacheKey(dataSpec, cacheKeyFactory); - long position = dataSpec.position; - long requestLength = getRequestLength(dataSpec, cache, key); - long bytesAlreadyCached = 0; - long bytesLeft = requestLength; - while (bytesLeft != 0) { - long blockLength = - cache.getCachedLength( - key, position, bytesLeft != C.LENGTH_UNSET ? bytesLeft : Long.MAX_VALUE); - if (blockLength > 0) { - bytesAlreadyCached += blockLength; - } else { - blockLength = -blockLength; - if (blockLength == Long.MAX_VALUE) { - break; - } - } - position += blockLength; - bytesLeft -= bytesLeft == C.LENGTH_UNSET ? 0 : blockLength; - } - return Pair.create(requestLength, bytesAlreadyCached); - } - - /** - * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early - * if the end of the input is reached. - * - *

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

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

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

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

      This method may be slow and shouldn't normally be called on the main thread. - * - * @param dataSource A {@link CacheDataSource} to be used for caching the data. - * @param dataSpec Defines the data to be cached. - * @param progressListener A listener to receive progress updates, or {@code null}. - * @param isCanceled An optional flag that will cancel the operation if set to true. - * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been - * reached unexpectedly. - * @param temporaryBuffer A temporary buffer to be used during caching. - * @throws IOException If an error occurs caching the data, or if the operation was canceled. - */ - @WorkerThread - public static void cache( - CacheDataSource dataSource, - DataSpec dataSpec, - @Nullable ProgressListener progressListener, - @Nullable AtomicBoolean isCanceled, - boolean enableEOFException, - byte[] temporaryBuffer) - throws IOException { - Assertions.checkNotNull(dataSource); - Assertions.checkNotNull(temporaryBuffer); - - Cache cache = dataSource.getCache(); - CacheKeyFactory cacheKeyFactory = dataSource.getCacheKeyFactory(); - String key = buildCacheKey(dataSpec, cacheKeyFactory); - long bytesLeft; - @Nullable ProgressNotifier progressNotifier = null; - if (progressListener != null) { - progressNotifier = new ProgressNotifier(progressListener); - Pair lengthAndBytesAlreadyCached = getCached(dataSpec, cache, cacheKeyFactory); - progressNotifier.init(lengthAndBytesAlreadyCached.first, lengthAndBytesAlreadyCached.second); - bytesLeft = lengthAndBytesAlreadyCached.first; - } else { - bytesLeft = getRequestLength(dataSpec, cache, key); - } - - long position = dataSpec.position; - boolean lengthUnset = bytesLeft == C.LENGTH_UNSET; - while (bytesLeft != 0) { - throwExceptionIfCanceled(isCanceled); - long blockLength = - cache.getCachedLength(key, position, lengthUnset ? Long.MAX_VALUE : bytesLeft); - if (blockLength > 0) { - // Skip already cached data. - } else { - // There is a hole in the cache which is at least "-blockLength" long. - blockLength = -blockLength; - long length = blockLength == Long.MAX_VALUE ? C.LENGTH_UNSET : blockLength; - boolean isLastBlock = length == bytesLeft; - long read = - readAndDiscard( - dataSpec, - position, - length, - dataSource, - isCanceled, - progressNotifier, - isLastBlock, - temporaryBuffer); - if (read < blockLength) { - // Reached to the end of the data. - if (enableEOFException && !lengthUnset) { - throw new EOFException(); - } - break; - } - } - position += blockLength; - if (!lengthUnset) { - bytesLeft -= blockLength; - } - } - } - - private static long getRequestLength(DataSpec dataSpec, Cache cache, String key) { - if (dataSpec.length != C.LENGTH_UNSET) { - return dataSpec.length; - } else { - long contentLength = ContentMetadata.getContentLength(cache.getContentMetadata(key)); - return contentLength == C.LENGTH_UNSET ? C.LENGTH_UNSET : contentLength - dataSpec.position; - } - } - - /** - * Reads and discards all data specified by the {@code dataSpec}. - * - * @param dataSpec Defines the data to be read. The {@code position} and {@code length} fields are - * overwritten by the following parameters. - * @param position The position of the data to be read. - * @param length Length of the data to be read, or {@link C#LENGTH_UNSET} if it is unknown. - * @param dataSource The {@link CacheDataSource} to read the data from. - * @param isCanceled An optional flag that will cancel the operation if set to true. - * @param progressNotifier A notifier through which to report progress updates, or {@code null}. - * @param isLastBlock Whether this read block is the last block of the content. - * @param temporaryBuffer A temporary buffer to be used during caching. - * @return Number of read bytes, or 0 if no data is available because the end of the opened range - * has been reached. - * @param isCanceled An optional flag that will cancel the operation if set to true. - */ - private static long readAndDiscard( - DataSpec dataSpec, - long position, - long length, - CacheDataSource dataSource, - @Nullable AtomicBoolean isCanceled, - @Nullable ProgressNotifier progressNotifier, - boolean isLastBlock, - byte[] temporaryBuffer) - throws IOException { - long positionOffset = position - dataSpec.position; - long initialPositionOffset = positionOffset; - long endOffset = length != C.LENGTH_UNSET ? positionOffset + length : C.POSITION_UNSET; - @Nullable PriorityTaskManager priorityTaskManager = dataSource.getUpstreamPriorityTaskManager(); - while (true) { - if (priorityTaskManager != null) { - // Wait for any other thread with higher priority to finish its job. - try { - priorityTaskManager.proceed(dataSource.getUpstreamPriority()); - } catch (InterruptedException e) { - throw new InterruptedIOException(); - } - } - throwExceptionIfCanceled(isCanceled); - try { - long resolvedLength = C.LENGTH_UNSET; - boolean isDataSourceOpen = false; - if (endOffset != C.POSITION_UNSET) { - // If a specific length is given, first try to open the data source for that length to - // avoid more data then required to be requested. If the given length exceeds the end of - // input we will get a "position out of range" error. In that case try to open the source - // again with unset length. - try { - resolvedLength = - dataSource.open(dataSpec.subrange(positionOffset, endOffset - positionOffset)); - isDataSourceOpen = true; - } catch (IOException exception) { - if (!isLastBlock || !isCausedByPositionOutOfRange(exception)) { - throw exception; - } - Util.closeQuietly(dataSource); - } - } - if (!isDataSourceOpen) { - resolvedLength = dataSource.open(dataSpec.subrange(positionOffset, C.LENGTH_UNSET)); - } - if (isLastBlock && progressNotifier != null && resolvedLength != C.LENGTH_UNSET) { - progressNotifier.onRequestLengthResolved(positionOffset + resolvedLength); - } - while (positionOffset != endOffset) { - throwExceptionIfCanceled(isCanceled); - int bytesRead = - dataSource.read( - temporaryBuffer, - 0, - endOffset != C.POSITION_UNSET - ? (int) Math.min(temporaryBuffer.length, endOffset - positionOffset) - : temporaryBuffer.length); - if (bytesRead == C.RESULT_END_OF_INPUT) { - if (progressNotifier != null) { - progressNotifier.onRequestLengthResolved(positionOffset); - } - break; - } - positionOffset += bytesRead; - if (progressNotifier != null) { - progressNotifier.onBytesCached(bytesRead); - } - } - return positionOffset - initialPositionOffset; - } catch (PriorityTaskManager.PriorityTooLowException exception) { - // catch and try again - } finally { - Util.closeQuietly(dataSource); - } - } - } - - /** - * Removes all of the data specified by the {@code dataSpec}. - * - *

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

      This methods blocks until the operation is complete. - * - * @param cache A {@link Cache} to store the data. - * @param key The key whose data should be removed. - */ - @WorkerThread - public static void remove(Cache cache, String key) { - NavigableSet cachedSpans = cache.getCachedSpans(key); - for (CacheSpan cachedSpan : cachedSpans) { - try { - cache.removeSpan(cachedSpan); - } catch (Cache.CacheException e) { - // Do nothing. - } - } - } - - /* package */ static boolean isCausedByPositionOutOfRange(IOException e) { - @Nullable Throwable cause = e; - while (cause != null) { - if (cause instanceof DataSourceException) { - int reason = ((DataSourceException) cause).reason; - if (reason == DataSourceException.POSITION_OUT_OF_RANGE) { - return true; - } - } - cause = cause.getCause(); - } - return false; - } - - private static String buildCacheKey( - DataSpec dataSpec, @Nullable CacheKeyFactory cacheKeyFactory) { - return (cacheKeyFactory != null ? cacheKeyFactory : DEFAULT_CACHE_KEY_FACTORY) - .buildCacheKey(dataSpec); - } - - private static void throwExceptionIfCanceled(@Nullable AtomicBoolean isCanceled) - throws InterruptedIOException { - if (isCanceled != null && isCanceled.get()) { - throw new InterruptedIOException(); - } - } - - private CacheUtil() {} - - private static final class ProgressNotifier { - /** The listener to notify when progress is made. */ - private final ProgressListener listener; - /** The length of the content being cached in bytes, or {@link C#LENGTH_UNSET} if unknown. */ - private long requestLength; - /** The number of bytes that are cached. */ - private long bytesCached; - - public ProgressNotifier(ProgressListener listener) { - this.listener = listener; - } - - public void init(long requestLength, long bytesCached) { - this.requestLength = requestLength; - this.bytesCached = bytesCached; - listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0); - } - - public void onRequestLengthResolved(long requestLength) { - if (this.requestLength == C.LENGTH_UNSET && requestLength != C.LENGTH_UNSET) { - this.requestLength = requestLength; - listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0); - } - } - - public void onBytesCached(long newBytesCached) { - bytesCached += newBytesCached; - listener.onProgress(requestLength, bytesCached, newBytesCached); - } - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java new file mode 100644 index 0000000000..8ea2b4e280 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheWriter.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.util.PriorityTaskManager; +import com.google.android.exoplayer2.util.PriorityTaskManager.PriorityTooLowException; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.io.InterruptedIOException; + +/** Caching related utility methods. */ +public final class CacheWriter { + + /** Receives progress updates during cache operations. */ + public interface ProgressListener { + + /** + * Called when progress is made during a cache operation. + * + * @param requestLength The length of the content being cached in bytes, or {@link + * C#LENGTH_UNSET} if unknown. + * @param bytesCached The number of bytes that are cached. + * @param newBytesCached The number of bytes that have been newly cached since the last progress + * update. + */ + void onProgress(long requestLength, long bytesCached, long newBytesCached); + } + + /** Default buffer size to be used while caching. */ + public static final int DEFAULT_BUFFER_SIZE_BYTES = 128 * 1024; + + private final CacheDataSource dataSource; + private final Cache cache; + private final DataSpec dataSpec; + private final boolean allowShortContent; + private final String cacheKey; + private final byte[] temporaryBuffer; + @Nullable private final ProgressListener progressListener; + + private boolean initialized; + private long nextPosition; + private long endPosition; + private long bytesCached; + + private volatile boolean isCanceled; + + /** + * @param dataSource A {@link CacheDataSource} that writes to the target cache. + * @param dataSpec Defines the data to be written. + * @param allowShortContent Whether it's allowed for the content to end before the request as + * defined by the {@link DataSpec}. If {@code true} and the request exceeds the length of the + * content, then the content will be cached to the end. If {@code false} and the request + * exceeds the length of the content, {@link #cache} will throw an {@link IOException}. + * @param temporaryBuffer A temporary buffer to be used during caching, or {@code null} if the + * writer should instantiate its own internal temporary buffer. + * @param progressListener An optional progress listener. + */ + public CacheWriter( + CacheDataSource dataSource, + DataSpec dataSpec, + boolean allowShortContent, + @Nullable byte[] temporaryBuffer, + @Nullable ProgressListener progressListener) { + this.dataSource = dataSource; + this.cache = dataSource.getCache(); + this.dataSpec = dataSpec; + this.allowShortContent = allowShortContent; + this.temporaryBuffer = + temporaryBuffer == null ? new byte[DEFAULT_BUFFER_SIZE_BYTES] : temporaryBuffer; + this.progressListener = progressListener; + cacheKey = dataSource.getCacheKeyFactory().buildCacheKey(dataSpec); + nextPosition = dataSpec.position; + } + + /** + * Cancels this writer's caching operation. {@link #cache} checks for cancelation frequently + * during execution, and throws an {@link InterruptedIOException} if it sees that the caching + * operation has been canceled. + */ + public void cancel() { + isCanceled = true; + } + + /** + * Caches the requested data, skipping any that's already cached. + * + *

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

      This method may be slow and shouldn't normally be called on the main thread. + * + * @throws IOException If an error occurs reading the data, or writing the data into the cache, or + * if the operation is canceled. If canceled, an {@link InterruptedIOException} is thrown. The + * method may be called again to continue the operation from where the error occurred. + */ + @WorkerThread + public void cache() throws IOException { + throwIfCanceled(); + + if (!initialized) { + if (dataSpec.length != C.LENGTH_UNSET) { + endPosition = dataSpec.position + dataSpec.length; + } else { + long contentLength = ContentMetadata.getContentLength(cache.getContentMetadata(cacheKey)); + endPosition = contentLength == C.LENGTH_UNSET ? C.POSITION_UNSET : contentLength; + } + bytesCached = cache.getCachedBytes(cacheKey, dataSpec.position, dataSpec.length); + if (progressListener != null) { + progressListener.onProgress(getLength(), bytesCached, /* newBytesCached= */ 0); + } + initialized = true; + } + + while (endPosition == C.POSITION_UNSET || nextPosition < endPosition) { + throwIfCanceled(); + long maxRemainingLength = + endPosition == C.POSITION_UNSET ? Long.MAX_VALUE : endPosition - nextPosition; + long blockLength = cache.getCachedLength(cacheKey, nextPosition, maxRemainingLength); + if (blockLength > 0) { + nextPosition += blockLength; + } else { + // There's a hole of length -blockLength. + blockLength = -blockLength; + long nextRequestLength = blockLength == Long.MAX_VALUE ? C.LENGTH_UNSET : blockLength; + nextPosition += readBlockToCache(nextPosition, nextRequestLength); + } + } + } + + /** + * Reads the specified block of data, writing it into the cache. + * + * @param position The starting position of the block. + * @param length The length of the block, or {@link C#LENGTH_UNSET} if unbounded. + * @return The number of bytes read. + * @throws IOException If an error occurs reading the data or writing it to the cache. + */ + private long readBlockToCache(long position, long length) throws IOException { + boolean isLastBlock = position + length == endPosition || length == C.LENGTH_UNSET; + try { + long resolvedLength = C.LENGTH_UNSET; + boolean isDataSourceOpen = false; + if (length != C.LENGTH_UNSET) { + // If the length is specified, try to open the data source with a bounded request to avoid + // the underlying network stack requesting more data than required. + try { + DataSpec boundedDataSpec = + dataSpec.buildUpon().setPosition(position).setLength(length).build(); + resolvedLength = dataSource.open(boundedDataSpec); + isDataSourceOpen = true; + } catch (IOException exception) { + if (allowShortContent + && isLastBlock + && DataSourceException.isCausedByPositionOutOfRange(exception)) { + // The length of the request exceeds the length of the content. If we allow shorter + // content and are reading the last block, fall through and try again with an unbounded + // request to read up to the end of the content. + Util.closeQuietly(dataSource); + } else { + throw exception; + } + } + } + if (!isDataSourceOpen) { + // Either the length was unspecified, or we allow short content and our attempt to open the + // DataSource with the specified length failed. + throwIfCanceled(); + DataSpec unboundedDataSpec = + dataSpec.buildUpon().setPosition(position).setLength(C.LENGTH_UNSET).build(); + resolvedLength = dataSource.open(unboundedDataSpec); + } + if (isLastBlock && resolvedLength != C.LENGTH_UNSET) { + onRequestEndPosition(position + resolvedLength); + } + int totalBytesRead = 0; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT) { + throwIfCanceled(); + bytesRead = dataSource.read(temporaryBuffer, /* offset= */ 0, temporaryBuffer.length); + if (bytesRead != C.RESULT_END_OF_INPUT) { + onNewBytesCached(bytesRead); + totalBytesRead += bytesRead; + } + } + if (isLastBlock) { + onRequestEndPosition(position + totalBytesRead); + } + return totalBytesRead; + } finally { + Util.closeQuietly(dataSource); + } + } + + private void onRequestEndPosition(long endPosition) { + if (this.endPosition == endPosition) { + return; + } + this.endPosition = endPosition; + if (progressListener != null) { + progressListener.onProgress(getLength(), bytesCached, /* newBytesCached= */ 0); + } + } + + private void onNewBytesCached(long newBytesCached) { + bytesCached += newBytesCached; + if (progressListener != null) { + progressListener.onProgress(getLength(), bytesCached, newBytesCached); + } + } + + private long getLength() { + return endPosition == C.POSITION_UNSET ? C.LENGTH_UNSET : endPosition - dataSpec.position; + } + + private void throwIfCanceled() throws InterruptedIOException { + if (isCanceled) { + throw new InterruptedIOException(); + } + } +} 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..4c3f58f2c6 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,33 +15,41 @@ */ package com.google.android.exoplayer2.upstream.cache; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static java.lang.Math.max; +import static java.lang.Math.min; + import androidx.annotation.Nullable; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Log; import java.io.File; +import java.util.ArrayList; import java.util.TreeSet; -/** Defines the cached content for a single stream. */ +/** Defines the cached content for a single resource. */ /* package */ final class CachedContent { private static final String TAG = "CachedContent"; - /** The cache file id that uniquely identifies the original stream. */ + /** The cache id that uniquely identifies the resource. */ public final int id; - /** The cache key that uniquely identifies the original stream. */ + /** The cache key that uniquely identifies the resource. */ public final String key; /** The cached spans of this content. */ private final TreeSet cachedSpans; + /** Currently locked ranges. */ + private final ArrayList lockedRanges; + /** Metadata values. */ private DefaultContentMetadata metadata; - /** Whether the content is locked. */ - private boolean locked; /** * Creates a CachedContent. * - * @param id The cache file id. - * @param key The cache stream key. + * @param id The cache id of the resource. + * @param key The cache key of the resource. */ public CachedContent(int id, String key) { this(id, key, DefaultContentMetadata.EMPTY); @@ -51,7 +59,8 @@ import java.util.TreeSet; this.id = id; this.key = key; this.metadata = metadata; - this.cachedSpans = new TreeSet<>(); + cachedSpans = new TreeSet<>(); + lockedRanges = new ArrayList<>(); } /** Returns the metadata. */ @@ -70,14 +79,58 @@ import java.util.TreeSet; return !metadata.equals(oldMetadata); } - /** Returns whether the content is locked. */ - public boolean isLocked() { - return locked; + /** Returns whether the entire resource is fully unlocked. */ + public boolean isFullyUnlocked() { + return lockedRanges.isEmpty(); } - /** Sets the locked state of the content. */ - public void setLocked(boolean locked) { - this.locked = locked; + /** + * Returns whether the specified range of the resource is fully locked by a single lock. + * + * @param position The position of the range. + * @param length The length of the range, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether the range is fully locked by a single lock. + */ + public boolean isFullyLocked(long position, long length) { + for (int i = 0; i < lockedRanges.size(); i++) { + if (lockedRanges.get(i).contains(position, length)) { + return true; + } + } + return false; + } + + /** + * Attempts to lock the specified range of the resource. + * + * @param position The position of the range. + * @param length The length of the range, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether the range was successfully locked. + */ + public boolean lockRange(long position, long length) { + for (int i = 0; i < lockedRanges.size(); i++) { + if (lockedRanges.get(i).intersects(position, length)) { + return false; + } + } + lockedRanges.add(new Range(position, length)); + return true; + } + + /** + * Unlocks the currently locked range starting at the specified position. + * + * @param position The starting position of the locked range. + * @throws IllegalStateException If there was no locked range starting at the specified position. + */ + public void unlockRange(long position) { + for (int i = 0; i < lockedRanges.size(); i++) { + if (lockedRanges.get(i).position == position) { + lockedRanges.remove(i); + return; + } + } + throw new IllegalStateException(); } /** Adds the given {@link SimpleCacheSpan} which contains a part of the content. */ @@ -91,36 +144,51 @@ import java.util.TreeSet; } /** - * Returns the span containing the position. If there isn't one, it returns a hole span - * which defines the maximum extents of the hole in the cache. + * Returns the cache span corresponding to the provided range. See {@link + * Cache#startReadWrite(String, long, long)} for detailed descriptions of the returned spans. + * + * @param position The position of the span being requested. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded. + * @return The corresponding cache {@link SimpleCacheSpan}. */ - public SimpleCacheSpan getSpan(long position) { + public SimpleCacheSpan getSpan(long position, long length) { SimpleCacheSpan lookupSpan = SimpleCacheSpan.createLookup(key, position); SimpleCacheSpan floorSpan = cachedSpans.floor(lookupSpan); if (floorSpan != null && floorSpan.position + floorSpan.length > position) { return floorSpan; } SimpleCacheSpan ceilSpan = cachedSpans.ceiling(lookupSpan); - return ceilSpan == null ? SimpleCacheSpan.createOpenHole(key, position) - : SimpleCacheSpan.createClosedHole(key, position, ceilSpan.position - position); + if (ceilSpan != null) { + long holeLength = ceilSpan.position - position; + length = length == C.LENGTH_UNSET ? holeLength : min(holeLength, length); + } + return SimpleCacheSpan.createHole(key, position, length); } /** - * Returns the length of the cached data block starting from the {@code position} to the block end - * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap - * to the next cached data up to {@code length} bytes) is returned. + * Returns the length of continuously cached data starting from {@code position}, up to a maximum + * of {@code maxLength}. If {@code position} isn't cached, then {@code -holeLength} is returned, + * where {@code holeLength} is the length of continuously un-cached data starting from {@code + * position}, up to a maximum of {@code maxLength}. * * @param position The starting position of the data. - * @param length The maximum length of the data to be returned. - * @return the length of the cached or not cached data block length. + * @param length The maximum length of the data or hole to be returned. + * @return The length of continuously cached data, or {@code -holeLength} if {@code position} + * isn't cached. */ public long getCachedBytesLength(long position, long length) { - SimpleCacheSpan span = getSpan(position); + checkArgument(position >= 0); + checkArgument(length >= 0); + SimpleCacheSpan span = getSpan(position, length); if (span.isHoleSpan()) { // We don't have a span covering the start of the queried region. - return -Math.min(span.isOpenEnded() ? Long.MAX_VALUE : span.length, length); + return -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)) { @@ -130,14 +198,14 @@ import java.util.TreeSet; } // We expect currentEndPosition to always equal (next.position + next.length), but // perform a max check anyway to guard against the existence of overlapping spans. - currentEndPosition = Math.max(currentEndPosition, next.position + next.length); + currentEndPosition = max(currentEndPosition, next.position + next.length); if (currentEndPosition >= queryEndPosition) { // We've found spans covering the queried region. break; } } } - return Math.min(currentEndPosition - position, length); + return min(currentEndPosition - position, length); } /** @@ -151,10 +219,10 @@ import java.util.TreeSet; */ public SimpleCacheSpan setLastTouchTimestamp( SimpleCacheSpan cacheSpan, long lastTouchTimestamp, boolean updateFile) { - Assertions.checkState(cachedSpans.remove(cacheSpan)); - File file = cacheSpan.file; + checkState(cachedSpans.remove(cacheSpan)); + File file = checkNotNull(cacheSpan.file); if (updateFile) { - File directory = file.getParentFile(); + File directory = checkNotNull(file.getParentFile()); long position = cacheSpan.position; File newFile = SimpleCacheSpan.getCacheFile(directory, id, position, lastTouchTimestamp); if (file.renameTo(newFile)) { @@ -177,7 +245,9 @@ import java.util.TreeSet; /** Removes the given span from cache. */ public boolean removeSpan(CacheSpan span) { if (cachedSpans.remove(span)) { - span.file.delete(); + if (span.file != null) { + span.file.delete(); + } return true; } return false; @@ -205,4 +275,51 @@ import java.util.TreeSet; && cachedSpans.equals(that.cachedSpans) && metadata.equals(that.metadata); } + + private static final class Range { + + /** The starting position of the range. */ + public final long position; + /** The length of the range, or {@link C#LENGTH_UNSET} if unbounded. */ + public final long length; + + public Range(long position, long length) { + this.position = position; + this.length = length; + } + + /** + * Returns whether this range fully contains the range specified by {@code otherPosition} and + * {@code otherLength}. + * + * @param otherPosition The position of the range to check. + * @param otherLength The length of the range to check, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether this range fully contains the specified range. + */ + public boolean contains(long otherPosition, long otherLength) { + if (length == C.LENGTH_UNSET) { + return otherPosition >= position; + } else if (otherLength == C.LENGTH_UNSET) { + return false; + } else { + return position <= otherPosition && (otherPosition + otherLength) <= (position + length); + } + } + + /** + * Returns whether this range intersects with the range specified by {@code otherPosition} and + * {@code otherLength}. + * + * @param otherPosition The position of the range to check. + * @param otherLength The length of the range to check, or {@link C#LENGTH_UNSET} if unbounded. + * @return Whether this range intersects with the specified range. + */ + public boolean intersects(long otherPosition, long otherLength) { + if (position <= otherPosition) { + return length == C.LENGTH_UNSET || position + length > otherPosition; + } else { + return otherLength == C.LENGTH_UNSET || otherPosition + otherLength > position; + } + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java index 43bf691701..850ac59f04 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -15,6 +15,11 @@ */ package com.google.android.exoplayer2.upstream.cache; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; + import android.annotation.SuppressLint; import android.content.ContentValues; import android.database.Cursor; @@ -48,6 +53,7 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -58,6 +64,7 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** Maintains the index of cached content. */ /* package */ class CachedContentIndex { @@ -152,13 +159,15 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable byte[] legacyStorageSecretKey, boolean legacyStorageEncrypt, boolean preferLegacyStorage) { - Assertions.checkState(databaseProvider != null || legacyStorageDir != null); + checkState(databaseProvider != null || legacyStorageDir != null); keyToContent = new HashMap<>(); idToKey = new SparseArray<>(); removedIds = new SparseBooleanArray(); newIds = new SparseBooleanArray(); + @Nullable Storage databaseStorage = databaseProvider != null ? new DatabaseStorage(databaseProvider) : null; + @Nullable Storage legacyStorage = legacyStorageDir != null ? new LegacyStorage( @@ -167,7 +176,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; legacyStorageEncrypt) : null; if (databaseStorage == null || (legacyStorage != null && preferLegacyStorage)) { - storage = legacyStorage; + storage = castNonNull(legacyStorage); previousStorage = databaseStorage; } else { storage = databaseStorage; @@ -223,31 +232,35 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Adds the given key to the index if it isn't there already. + * Adds a resource to the index, if it's not there already. * - * @param key The cache key that uniquely identifies the original stream. - * @return A new or existing CachedContent instance with the given key. + * @param key The cache key of the resource. + * @return The new or existing {@link CachedContent} corresponding to the resource. */ public CachedContent getOrAdd(String key) { @Nullable CachedContent cachedContent = keyToContent.get(key); return cachedContent == null ? addNew(key) : cachedContent; } - /** Returns a CachedContent instance with the given key or null if there isn't one. */ + /** + * Returns the {@link CachedContent} for a resource, or {@code null} if the resource is not + * present in the index. + * + * @param key The cache key of the resource. + */ @Nullable public CachedContent get(String key) { return keyToContent.get(key); } /** - * Returns a Collection of all CachedContent instances in the index. The collection is backed by - * the {@code keyToContent} map, so changes to the map are reflected in the collection, and - * vice-versa. If the map is modified while an iteration over the collection is in progress - * (except through the iterator's own remove operation), the results of the iteration are - * undefined. + * Returns a read only collection of all {@link CachedContent CachedContents} in the index. + * + *

      Subsequent changes to the index are reflected in the returned collection. If the index is + * modified whilst iterating over the collection, the result of the iteration is undefined. */ public Collection getAll() { - return keyToContent.values(); + return Collections.unmodifiableCollection(keyToContent.values()); } /** Returns an existing or new id assigned to the given key. */ @@ -261,10 +274,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return idToKey.get(id); } - /** Removes {@link CachedContent} with the given key from index if it's empty and not locked. */ + /** + * Removes a resource if its {@link CachedContent} is both empty and unlocked. + * + * @param key The cache key of the resource. + */ public void maybeRemove(String key) { @Nullable CachedContent cachedContent = keyToContent.get(key); - if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) { + if (cachedContent != null && cachedContent.isEmpty() && cachedContent.isFullyUnlocked()) { keyToContent.remove(key); int id = cachedContent.id; boolean neverStored = newIds.get(id); @@ -282,7 +299,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } - /** Removes empty and not locked {@link CachedContent} instances from index. */ + /** Removes all resources whose {@link CachedContent CachedContents} are empty and unlocked. */ public void removeEmpty() { String[] keys = new String[keyToContent.size()]; keyToContent.keySet().toArray(keys); @@ -314,7 +331,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Returns a {@link ContentMetadata} for the given key. */ public ContentMetadata getContentMetadata(String key) { - CachedContent cachedContent = get(key); + @Nullable CachedContent cachedContent = get(key); return cachedContent != null ? cachedContent.getMetadata() : DefaultContentMetadata.EMPTY; } @@ -347,7 +364,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * returns the smallest unused non-negative integer. */ @VisibleForTesting - /* package */ static int getNewId(SparseArray idToKey) { + /* package */ static int getNewId(SparseArray<@NullableType String> idToKey) { int size = idToKey.size(); int id = size == 0 ? 0 : (idToKey.keyAt(size - 1) + 1); if (id < 0) { // In case if we pass max int value. @@ -382,13 +399,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // large) valueSize was read. In such cases the implementation below is expected to throw // IOException from one of the readFully calls, due to the end of the input being reached. int bytesRead = 0; - int nextBytesToRead = Math.min(valueSize, INCREMENTAL_METADATA_READ_LENGTH); + int nextBytesToRead = min(valueSize, INCREMENTAL_METADATA_READ_LENGTH); byte[] value = Util.EMPTY_BYTE_ARRAY; while (bytesRead != valueSize) { value = Arrays.copyOf(value, bytesRead + nextBytesToRead); input.readFully(value, bytesRead, nextBytesToRead); bytesRead += nextBytesToRead; - nextBytesToRead = Math.min(valueSize - bytesRead, INCREMENTAL_METADATA_READ_LENGTH); + nextBytesToRead = min(valueSize - bytesRead, INCREMENTAL_METADATA_READ_LENGTH); } metadata.put(name, value); } @@ -501,8 +518,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable private ReusableBufferedOutputStream bufferedOutputStream; public LegacyStorage(File file, @Nullable byte[] secretKey, boolean encrypt) { - Cipher cipher = null; - SecretKeySpec secretKeySpec = null; + checkState(secretKey != null || !encrypt); + @Nullable Cipher cipher = null; + @Nullable SecretKeySpec secretKeySpec = null; if (secretKey != null) { Assertions.checkArgument(secretKey.length == 16); try { @@ -539,7 +557,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Override public void load( HashMap content, SparseArray<@NullableType String> idToKey) { - Assertions.checkState(!changed); + checkState(!changed); if (!readFile(content, idToKey)) { content.clear(); idToKey.clear(); @@ -577,7 +595,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return true; } - DataInputStream input = null; + @Nullable DataInputStream input = null; try { InputStream inputStream = new BufferedInputStream(atomicFile.openRead()); input = new DataInputStream(inputStream); @@ -595,7 +613,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; input.readFully(initializationVector); IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); try { - cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); + cipher.init(Cipher.DECRYPT_MODE, castNonNull(secretKeySpec), ivParameterSpec); } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { throw new IllegalStateException(e); } @@ -636,6 +654,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } else { bufferedOutputStream.reset(outputStream); } + ReusableBufferedOutputStream bufferedOutputStream = this.bufferedOutputStream; output = new DataOutputStream(bufferedOutputStream); output.writeInt(VERSION); @@ -644,11 +663,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (encrypt) { byte[] initializationVector = new byte[16]; - random.nextBytes(initializationVector); + castNonNull(random).nextBytes(initializationVector); output.write(initializationVector); IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); try { - cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); + castNonNull(cipher) + .init(Cipher.ENCRYPT_MODE, castNonNull(secretKeySpec), ivParameterSpec); } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { throw new IllegalStateException(e); // Should never happen. } @@ -751,16 +771,17 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; + " BLOB NOT NULL)"; private final DatabaseProvider databaseProvider; - private final SparseArray pendingUpdates; + private final SparseArray<@NullableType CachedContent> pendingUpdates; - private String hexUid; - private String tableName; + private @MonotonicNonNull String hexUid; + private @MonotonicNonNull String tableName; public static void delete(DatabaseProvider databaseProvider, long uid) throws DatabaseIOException { delete(databaseProvider, Long.toHexString(uid)); } + @SuppressWarnings("nullness:initialization.fields.uninitialized") public DatabaseStorage(DatabaseProvider databaseProvider) { this.databaseProvider = databaseProvider; pendingUpdates = new SparseArray<>(); @@ -777,26 +798,26 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return VersionTable.getVersion( databaseProvider.getReadableDatabase(), VersionTable.FEATURE_CACHE_CONTENT_METADATA, - hexUid) + checkNotNull(hexUid)) != VersionTable.VERSION_UNSET; } @Override public void delete() throws DatabaseIOException { - delete(databaseProvider, hexUid); + delete(databaseProvider, checkNotNull(hexUid)); } @Override public void load( HashMap content, SparseArray<@NullableType String> idToKey) throws IOException { - Assertions.checkState(pendingUpdates.size() == 0); + checkState(pendingUpdates.size() == 0); try { int version = VersionTable.getVersion( databaseProvider.getReadableDatabase(), VersionTable.FEATURE_CACHE_CONTENT_METADATA, - hexUid); + checkNotNull(hexUid)); if (version != TABLE_VERSION) { SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); writableDatabase.beginTransactionNonExclusive(); @@ -860,7 +881,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; writableDatabase.beginTransactionNonExclusive(); try { for (int i = 0; i < pendingUpdates.size(); i++) { - CachedContent cachedContent = pendingUpdates.valueAt(i); + @Nullable CachedContent cachedContent = pendingUpdates.valueAt(i); if (cachedContent == null) { deleteRow(writableDatabase, pendingUpdates.keyAt(i)); } else { @@ -895,7 +916,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return databaseProvider .getReadableDatabase() .query( - tableName, + checkNotNull(tableName), COLUMNS, /* selection= */ null, /* selectionArgs= */ null, @@ -906,13 +927,17 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private void initializeTable(SQLiteDatabase writableDatabase) throws DatabaseIOException { VersionTable.setVersion( - writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, hexUid, TABLE_VERSION); - dropTable(writableDatabase, tableName); + writableDatabase, + VersionTable.FEATURE_CACHE_CONTENT_METADATA, + checkNotNull(hexUid), + TABLE_VERSION); + dropTable(writableDatabase, checkNotNull(tableName)); writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA); } private void deleteRow(SQLiteDatabase writableDatabase, int key) { - writableDatabase.delete(tableName, WHERE_ID_EQUALS, new String[] {Integer.toString(key)}); + writableDatabase.delete( + checkNotNull(tableName), WHERE_ID_EQUALS, new String[] {Integer.toString(key)}); } private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent) @@ -925,7 +950,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; values.put(COLUMN_ID, cachedContent.id); values.put(COLUMN_KEY, cachedContent.key); values.put(COLUMN_METADATA, data); - writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + writableDatabase.replaceOrThrow(checkNotNull(tableName), /* nullColumnHack= */ null, values); } private static void delete(DatabaseProvider databaseProvider, String hexUid) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java index c3f06252e4..706fa0d2c3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java @@ -16,9 +16,8 @@ package com.google.android.exoplayer2.upstream.cache; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; +import com.google.common.base.Charsets; import java.nio.ByteBuffer; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -80,7 +79,7 @@ public final class DefaultContentMetadata implements ContentMetadata { public final String get(String name, @Nullable String defaultValue) { @Nullable byte[] bytes = metadata.get(name); if (bytes != null) { - return new String(bytes, Charset.forName(C.UTF8_NAME)); + return new String(bytes, Charsets.UTF_8); } else { return defaultValue; } @@ -162,7 +161,7 @@ public final class DefaultContentMetadata implements ContentMetadata { if (value instanceof Long) { return ByteBuffer.allocate(8).putLong((Long) value).array(); } else if (value instanceof String) { - return ((String) value).getBytes(Charset.forName(C.UTF8_NAME)); + return ((String) value).getBytes(Charsets.UTF_8); } else if (value instanceof byte[]) { return (byte[]) value; } else { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java index c88e2643d8..fb461813ae 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.upstream.cache; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import java.util.TreeSet; /** Evicts least recently used cache files first. */ @@ -70,11 +69,7 @@ public final class LeastRecentlyUsedCacheEvictor implements CacheEvictor { private void evictCache(Cache cache, long requiredSpace) { while (currentSize + requiredSpace > maxBytes && !leastRecentlyUsed.isEmpty()) { - try { - cache.removeSpan(leastRecentlyUsed.first()); - } catch (CacheException e) { - // do nothing. - } + cache.removeSpan(leastRecentlyUsed.first()); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java index 721dac4d4e..29c09ff486 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -134,6 +134,7 @@ public final class SimpleCache implements Cache { * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance. */ @Deprecated + @SuppressWarnings("deprecation") public SimpleCache(File cacheDir, CacheEvictor evictor) { this(cacheDir, evictor, null, false); } @@ -308,6 +309,8 @@ public final class SimpleCache implements Cache { @Override public synchronized NavigableSet addListener(String key, Listener listener) { Assertions.checkState(!released); + Assertions.checkNotNull(key); + Assertions.checkNotNull(listener); ArrayList listenersForKey = listeners.get(key); if (listenersForKey == null) { listenersForKey = new ArrayList<>(); @@ -353,13 +356,13 @@ public final class SimpleCache implements Cache { } @Override - public synchronized CacheSpan startReadWrite(String key, long position) + public synchronized CacheSpan startReadWrite(String key, long position, long length) throws InterruptedException, CacheException { Assertions.checkState(!released); checkInitialization(); while (true) { - CacheSpan span = startReadWriteNonBlocking(key, position); + CacheSpan span = startReadWriteNonBlocking(key, position, length); if (span != null) { return span; } else { @@ -375,12 +378,12 @@ public final class SimpleCache implements Cache { @Override @Nullable - public synchronized CacheSpan startReadWriteNonBlocking(String key, long position) + public synchronized CacheSpan startReadWriteNonBlocking(String key, long position, long length) throws CacheException { Assertions.checkState(!released); checkInitialization(); - SimpleCacheSpan span = getSpan(key, position); + SimpleCacheSpan span = getSpan(key, position, length); if (span.isCached) { // Read case. @@ -388,9 +391,8 @@ public final class SimpleCache implements Cache { } CachedContent cachedContent = contentIndex.getOrAdd(key); - if (!cachedContent.isLocked()) { + if (cachedContent.lockRange(position, span.length)) { // Write case. - cachedContent.setLocked(true); return span; } @@ -405,7 +407,7 @@ public final class SimpleCache implements Cache { CachedContent cachedContent = contentIndex.get(key); Assertions.checkNotNull(cachedContent); - Assertions.checkState(cachedContent.isLocked()); + Assertions.checkState(cachedContent.isFullyLocked(position, length)); if (!cacheDir.exists()) { // For some reason the cache directory doesn't exist. Make a best effort to create it. cacheDir.mkdirs(); @@ -435,7 +437,7 @@ public final class SimpleCache implements Cache { SimpleCacheSpan span = Assertions.checkNotNull(SimpleCacheSpan.createCacheEntry(file, length, contentIndex)); CachedContent cachedContent = Assertions.checkNotNull(contentIndex.get(span.key)); - Assertions.checkState(cachedContent.isLocked()); + Assertions.checkState(cachedContent.isFullyLocked(span.position, span.length)); // Check if the span conflicts with the set content length long contentLength = ContentMetadata.getContentLength(cachedContent.getMetadata()); @@ -464,12 +466,19 @@ public final class SimpleCache implements Cache { public synchronized void releaseHoleSpan(CacheSpan holeSpan) { Assertions.checkState(!released); CachedContent cachedContent = Assertions.checkNotNull(contentIndex.get(holeSpan.key)); - Assertions.checkState(cachedContent.isLocked()); - cachedContent.setLocked(false); + cachedContent.unlockRange(holeSpan.position); contentIndex.maybeRemove(cachedContent.key); notifyAll(); } + @Override + public synchronized void removeResource(String key) { + Assertions.checkState(!released); + for (CacheSpan span : getCachedSpans(key)) { + removeSpanInternal(span); + } + } + @Override public synchronized void removeSpan(CacheSpan span) { Assertions.checkState(!released); @@ -486,10 +495,36 @@ public final class SimpleCache implements Cache { @Override public synchronized long getCachedLength(String key, long position, long length) { Assertions.checkState(!released); + if (length == C.LENGTH_UNSET) { + length = Long.MAX_VALUE; + } @Nullable CachedContent cachedContent = contentIndex.get(key); return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length; } + @Override + public synchronized long getCachedBytes(String key, long position, long length) { + long endPosition = length == C.LENGTH_UNSET ? Long.MAX_VALUE : position + length; + if (endPosition < 0) { + // The calculation rolled over (length is probably Long.MAX_VALUE). + endPosition = Long.MAX_VALUE; + } + long currentPosition = position; + long cachedBytes = 0; + while (currentPosition < endPosition) { + long maxRemainingLength = endPosition - currentPosition; + long blockLength = getCachedLength(key, currentPosition, maxRemainingLength); + if (blockLength > 0) { + cachedBytes += blockLength; + } else { + // There's a hole of length -blockLength. + blockLength = -blockLength; + } + currentPosition += blockLength; + } + return cachedBytes; + } + @Override public synchronized void applyContentMetadataMutations( String key, ContentMetadataMutations mutations) throws CacheException { @@ -654,23 +689,21 @@ public final class SimpleCache implements Cache { } /** - * Returns the cache span corresponding to the provided lookup span. - * - *

      If the lookup position is contained by an existing entry in the cache, then the returned - * span defines the file in which the data is stored. If the lookup position is not contained by - * an existing entry, then the returned span defines the maximum extents of the hole in the cache. + * Returns the cache span corresponding to the provided key and range. See {@link + * Cache#startReadWrite(String, long, long)} for detailed descriptions of the returned spans. * * @param key The key of the span being requested. * @param position The position of the span being requested. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded. * @return The corresponding cache {@link SimpleCacheSpan}. */ - private SimpleCacheSpan getSpan(String key, long position) { + private SimpleCacheSpan getSpan(String key, long position, long length) { @Nullable CachedContent cachedContent = contentIndex.get(key); if (cachedContent == null) { - return SimpleCacheSpan.createOpenHole(key, position); + return SimpleCacheSpan.createHole(key, position, length); } while (true) { - SimpleCacheSpan span = cachedContent.getSpan(position); + SimpleCacheSpan span = cachedContent.getSpan(position, length); if (span.isCached && span.file.length() != span.length) { // The file has been modified or deleted underneath us. It's likely that other files will // have been modified too, so scan the whole in-memory representation. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java index d8a0671469..d02f7c0988 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java @@ -23,7 +23,7 @@ import java.io.File; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** This class stores span metadata in filename. */ +/** A {@link CacheSpan} that encodes metadata into the names of the underlying cache files. */ /* package */ final class SimpleCacheSpan extends CacheSpan { /* package */ static final String COMMON_SUFFIX = ".exo"; @@ -42,7 +42,7 @@ import java.util.regex.Pattern; * * @param cacheDir The parent abstract pathname. * @param id The cache file id. - * @param position The position of the stored data in the original stream. + * @param position The position of the stored data in the resource. * @param timestamp The file timestamp. * @return The cache file. */ @@ -53,8 +53,8 @@ import java.util.regex.Pattern; /** * Creates a lookup span. * - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. + * @param key The cache key of the resource. + * @param position The position of the span in the resource. * @return The span. */ public static SimpleCacheSpan createLookup(String key, long position) { @@ -62,25 +62,14 @@ import java.util.regex.Pattern; } /** - * Creates an open hole span. + * Creates a hole span. * - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. - * @return The span. + * @param key The cache key of the resource. + * @param position The position of the span in the resource. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if unbounded. + * @return The hole span. */ - public static SimpleCacheSpan createOpenHole(String key, long position) { - return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null); - } - - /** - * Creates a closed hole span. - * - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. - * @param length The length of the {@link CacheSpan}. - * @return The span. - */ - public static SimpleCacheSpan createClosedHole(String key, long position, long length) { + public static SimpleCacheSpan createHole(String key, long position, long length) { return new SimpleCacheSpan(key, position, length, C.TIME_UNSET, null); } @@ -190,13 +179,12 @@ import java.util.regex.Pattern; } /** - * @param key The cache key. - * @param position The position of the {@link CacheSpan} in the original stream. - * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an - * open-ended hole. + * @param key The cache key of the resource. + * @param position The position of the span in the resource. + * @param length The length of the span, or {@link C#LENGTH_UNSET} if this is an open-ended hole. * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link * #isCached} is false. - * @param file The file corresponding to this {@link CacheSpan}, or null if it's a hole. + * @param file The file corresponding to this span, or null if it's a hole. */ private SimpleCacheSpan( String key, long position, long length, long lastTouchTimestamp, @Nullable File file) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java index 95295035e7..c1118c01a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream.crypto; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.min; import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.DataSink; @@ -83,7 +84,7 @@ public final class AesCipherDataSink implements DataSink { // Use scratch space. The original data remains intact. int bytesProcessed = 0; while (bytesProcessed < length) { - int bytesToProcess = Math.min(length - bytesProcessed, scratch.length); + int bytesToProcess = min(length - bytesProcessed, scratch.length); castNonNull(cipher) .update(data, offset + bytesProcessed, bytesToProcess, scratch, /* outOffset= */ 0); wrappedDataSink.write(scratch, /* offset= */ 0, bytesToProcess); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java index 665a47191e..5abe42b937 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.upstream.crypto; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; import android.net.Uri; @@ -45,6 +46,7 @@ public final class AesCipherDataSource implements DataSource { @Override public void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java index b7f0d04e23..bbdfd23d8c 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 @@ -40,7 +40,7 @@ public class ConditionVariable { } /** - * Creates an instance. + * Creates an instance, which starts closed. * * @param clock The {@link Clock} whose {@link Clock#elapsedRealtime()} method is used to * determine when {@link #block(long)} should time out. @@ -111,6 +111,26 @@ public class ConditionVariable { return isOpen; } + /** + * Blocks until the condition is open. Unlike {@link #block}, this method will continue to block + * if the calling thread is interrupted. If the calling thread was interrupted then its {@link + * Thread#isInterrupted() interrupted status} will be set when the method returns. + */ + public synchronized void blockUninterruptible() { + boolean wasInterrupted = false; + while (!isOpen) { + try { + wait(); + } catch (InterruptedException e) { + wasInterrupted = true; + } + } + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); + } + } + /** Returns whether the condition is opened. */ public synchronized boolean isOpen() { return isOpen; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventDispatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventDispatcher.java deleted file mode 100644 index 07f278c808..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventDispatcher.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.util; - -import android.os.Handler; -import java.util.concurrent.CopyOnWriteArrayList; - -/** - * Event dispatcher which allows listener registration. - * - * @param The type of listener. - */ -public final class EventDispatcher { - - /** Functional interface to send an event. */ - public interface Event { - - /** - * Sends the event to a listener. - * - * @param listener The listener to send the event to. - */ - void sendTo(T listener); - } - - /** The list of listeners and handlers. */ - private final CopyOnWriteArrayList> listeners; - - /** Creates an event dispatcher. */ - public EventDispatcher() { - listeners = new CopyOnWriteArrayList<>(); - } - - /** Adds a listener to the event dispatcher. */ - public void addListener(Handler handler, T eventListener) { - Assertions.checkArgument(handler != null && eventListener != null); - removeListener(eventListener); - listeners.add(new HandlerAndListener<>(handler, eventListener)); - } - - /** Removes a listener from the event dispatcher. */ - public void removeListener(T eventListener) { - for (HandlerAndListener handlerAndListener : listeners) { - if (handlerAndListener.listener == eventListener) { - handlerAndListener.release(); - listeners.remove(handlerAndListener); - } - } - } - - /** - * Dispatches an event to all registered listeners. - * - * @param event The {@link Event}. - */ - public void dispatch(Event event) { - for (HandlerAndListener handlerAndListener : listeners) { - handlerAndListener.dispatch(event); - } - } - - private static final class HandlerAndListener { - - private final Handler handler; - private final T listener; - - private boolean released; - - public HandlerAndListener(Handler handler, T eventListener) { - this.handler = handler; - this.listener = eventListener; - } - - public void release() { - released = true; - } - - public void dispatch(Event event) { - handler.post( - () -> { - if (!released) { - event.sendTo(listener); - } - }); - } - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java index 3136556f2c..a441e81bc4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.util; +import static java.lang.Math.min; + import android.os.SystemClock; import android.text.TextUtils; import android.view.Surface; @@ -22,6 +24,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; @@ -147,15 +150,7 @@ public class EventLogger implements AnalyticsListener { @Override public void onPlaybackParametersChanged( EventTime eventTime, PlaybackParameters playbackParameters) { - logd( - eventTime, - "playbackParameters", - Util.formatInvariant("speed=%.2f", playbackParameters.speed)); - } - - @Override - public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { - logd(eventTime, "playbackSpeed", Float.toString(playbackSpeed)); + logd(eventTime, "playbackParameters", playbackParameters.toString()); } @Override @@ -171,14 +166,14 @@ public class EventLogger implements AnalyticsListener { + windowCount + ", reason=" + getTimelineChangeReasonString(reason)); - for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) { + for (int i = 0; i < min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) { eventTime.timeline.getPeriod(i, period); logd(" " + "period [" + getTimeString(period.getDurationMs()) + "]"); } if (periodCount > MAX_TIMELINE_ITEM_LINES) { logd(" ..."); } - for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) { + for (int i = 0; i < min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) { eventTime.timeline.getWindow(i, window); logd( " " @@ -196,6 +191,17 @@ public class EventLogger implements AnalyticsListener { logd("]"); } + @Override + public void onMediaItemTransition( + EventTime eventTime, @Nullable MediaItem mediaItem, int reason) { + logd( + "mediaItem [" + + getEventTimeString(eventTime) + + ", reason=" + + getMediaItemTransitionReasonString(reason) + + "]"); + } + @Override public void onPlayerError(EventTime eventTime, ExoPlaybackException e) { loge(eventTime, "playerFailed", e); @@ -297,8 +303,34 @@ public class EventLogger implements AnalyticsListener { } @Override - public void onDecoderEnabled(EventTime eventTime, int trackType, DecoderCounters counters) { - logd(eventTime, "decoderEnabled", Util.getTrackTypeString(trackType)); + public void onAudioEnabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "audioEnabled"); + } + + @Override + public void onAudioDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + logd(eventTime, "audioDecoderInitialized", decoderName); + } + + @Override + public void onAudioInputFormatChanged(EventTime eventTime, Format format) { + logd(eventTime, "audioInputFormat", Format.toLogString(format)); + } + + @Override + public void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + loge( + eventTime, + "audioTrackUnderrun", + bufferSize + ", " + bufferSizeMs + ", " + elapsedSinceLastFeedMs, + /* throwable= */ null); + } + + @Override + public void onAudioDisabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "audioDisabled"); } @Override @@ -331,32 +363,19 @@ public class EventLogger implements AnalyticsListener { } @Override - public void onDecoderInitialized( - EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) { - logd(eventTime, "decoderInitialized", Util.getTrackTypeString(trackType) + ", " + decoderName); + public void onVideoEnabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "videoEnabled"); } @Override - public void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) { - logd( - eventTime, - "decoderInputFormat", - Util.getTrackTypeString(trackType) + ", " + Format.toLogString(format)); + public void onVideoDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + logd(eventTime, "videoDecoderInitialized", decoderName); } @Override - public void onDecoderDisabled(EventTime eventTime, int trackType, DecoderCounters counters) { - logd(eventTime, "decoderDisabled", Util.getTrackTypeString(trackType)); - } - - @Override - public void onAudioUnderrun( - EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - loge( - eventTime, - "audioTrackUnderrun", - bufferSize + ", " + bufferSizeMs + ", " + elapsedSinceLastFeedMs + "]", - null); + public void onVideoInputFormatChanged(EventTime eventTime, Format format) { + logd(eventTime, "videoInputFormat", Format.toLogString(format)); } @Override @@ -364,6 +383,16 @@ public class EventLogger implements AnalyticsListener { logd(eventTime, "droppedFrames", Integer.toString(count)); } + @Override + public void onVideoDisabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "videoDisabled"); + } + + @Override + public void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) { + logd(eventTime, "renderedFirstFrame", String.valueOf(surface)); + } + @Override public void onVideoSizeChanged( EventTime eventTime, @@ -374,21 +403,6 @@ public class EventLogger implements AnalyticsListener { logd(eventTime, "videoSize", width + ", " + height); } - @Override - public void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) { - logd(eventTime, "renderedFirstFrame", String.valueOf(surface)); - } - - @Override - public void onMediaPeriodCreated(EventTime eventTime) { - logd(eventTime, "mediaPeriodCreated"); - } - - @Override - public void onMediaPeriodReleased(EventTime eventTime) { - logd(eventTime, "mediaPeriodReleased"); - } - @Override public void onLoadStarted( EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { @@ -417,11 +431,6 @@ public class EventLogger implements AnalyticsListener { // Do nothing. } - @Override - public void onReadingStarted(EventTime eventTime) { - logd(eventTime, "mediaPeriodReadingStarted"); - } - @Override public void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { @@ -553,7 +562,7 @@ public class EventLogger implements AnalyticsListener { return "eventTime=" + getTimeString(eventTime.realtimeMs - startTimeMs) + ", mediaPos=" - + getTimeString(eventTime.currentPlaybackPositionMs) + + getTimeString(eventTime.eventPlaybackPositionMs) + ", " + windowPeriodString; } @@ -648,6 +657,22 @@ public class EventLogger implements AnalyticsListener { } } + private static String getMediaItemTransitionReasonString( + @Player.MediaItemTransitionReason int reason) { + switch (reason) { + case Player.MEDIA_ITEM_TRANSITION_REASON_AUTO: + return "AUTO"; + case Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED: + return "PLAYLIST_CHANGED"; + case Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT: + return "REPEAT"; + case Player.MEDIA_ITEM_TRANSITION_REASON_SEEK: + return "SEEK"; + default: + return "?"; + } + } + private static String getPlaybackSuppressionReasonString( @PlaybackSuppressionReason int playbackSuppressionReason) { switch (playbackSuppressionReason) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java index e90d133334..f38fd61caf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/GlUtil.java @@ -204,8 +204,8 @@ public final class GlUtil { private GlUtil() {} /** - * Returns whether creating a GL context with {@value EXTENSION_PROTECTED_CONTENT} is possible. If - * {@code true}, the device supports a protected output path for DRM content when using GL. + * Returns whether creating a GL context with {@value #EXTENSION_PROTECTED_CONTENT} is possible. + * If {@code true}, the device supports a protected output path for DRM content when using GL. */ public static boolean isProtectedContentExtensionSupported(Context context) { if (Util.SDK_INT < 24) { @@ -232,7 +232,7 @@ public final class GlUtil { } /** - * Returns whether creating a GL context with {@value EXTENSION_SURFACELESS_CONTEXT} is possible. + * Returns whether creating a GL context with {@value #EXTENSION_SURFACELESS_CONTEXT} is possible. */ public static boolean isSurfacelessContextExtensionSupported() { if (Util.SDK_INT < 17) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java index 3277d042ed..5deb5f2b60 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/IntArrayQueue.java @@ -26,7 +26,7 @@ import java.util.NoSuchElementException; public final class IntArrayQueue { /** Default capacity needs to be a power of 2. */ - private static int DEFAULT_INITIAL_CAPACITY = 16; + private static final int DEFAULT_INITIAL_CAPACITY = 16; private int headIndex; private int tailIndex; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/MediaClock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/MediaClock.java index 44c3c5e7fa..df335908c0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/MediaClock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/MediaClock.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.util; +import com.google.android.exoplayer2.PlaybackParameters; + /** * Tracks the progression of media time. */ @@ -26,13 +28,13 @@ public interface MediaClock { long getPositionUs(); /** - * Attempts to set the playback speed. The media clock may override the speed if changing the - * speed is not supported. + * Attempts to set the playback parameters. The media clock may override the speed if changing the + * playback parameters is not supported. * - * @param playbackSpeed The playback speed to attempt to set. + * @param playbackParameters The playback parameters to attempt to set. */ - void setPlaybackSpeed(float playbackSpeed); + void setPlaybackParameters(PlaybackParameters playbackParameters); - /** Returns the active playback speed. */ - float getPlaybackSpeed(); + /** Returns the active playback parameters. */ + PlaybackParameters getPlaybackParameters(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcher.java b/library/core/src/main/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcher.java deleted file mode 100644 index c58221a12c..0000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcher.java +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.util; - -import android.os.Handler; -import android.os.Looper; -import androidx.annotation.CheckResult; -import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; - -/** - * Event dispatcher which forwards events to a list of registered listeners. - * - *

      Adds the correct {@code windowIndex} and {@code mediaPeriodId} values (and {@code - * mediaTimeOffsetMs} if needed). - * - *

      Allows listeners of any type to be registered, calls to {@link #dispatch} then provide the - * type of listener to forward to, which is used to filter the registered listeners. - */ -// TODO: Make this final when MediaSourceEventListener.EventDispatcher is deleted. -public class MediaSourceEventDispatcher { - - /** - * Functional interface to send an event with {@code windowIndex} and {@code mediaPeriodId} - * attached. - */ - public interface EventWithPeriodId { - - /** Sends the event to a listener. */ - void sendTo(T listener, int windowIndex, @Nullable MediaPeriodId mediaPeriodId); - } - - /** The timeline window index reported with the events. */ - public final int windowIndex; - /** The {@link MediaPeriodId} reported with the events. */ - @Nullable public final MediaPeriodId mediaPeriodId; - - // TODO: Make these private when MediaSourceEventListener.EventDispatcher is deleted. - protected final CopyOnWriteMultiset listenerInfos; - // TODO: Define exactly what this means, and check it's always set correctly. - protected final long mediaTimeOffsetMs; - - /** Creates an event dispatcher. */ - public MediaSourceEventDispatcher() { - this( - /* listenerInfos= */ new CopyOnWriteMultiset<>(), - /* windowIndex= */ 0, - /* mediaPeriodId= */ null, - /* mediaTimeOffsetMs= */ 0); - } - - protected MediaSourceEventDispatcher( - CopyOnWriteMultiset listenerInfos, - int windowIndex, - @Nullable MediaPeriodId mediaPeriodId, - long mediaTimeOffsetMs) { - this.listenerInfos = listenerInfos; - this.windowIndex = windowIndex; - this.mediaPeriodId = mediaPeriodId; - this.mediaTimeOffsetMs = mediaTimeOffsetMs; - } - - /** - * Creates a view of the event dispatcher with pre-configured window index, media period id, and - * media time offset. - * - * @param windowIndex The timeline window index to be reported with the events. - * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. - * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. - * @return A view of the event dispatcher with the pre-configured parameters. - */ - @CheckResult - public MediaSourceEventDispatcher withParameters( - int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { - return new MediaSourceEventDispatcher( - listenerInfos, windowIndex, mediaPeriodId, mediaTimeOffsetMs); - } - - /** - * Adds a listener to the event dispatcher. - * - *

      Calls to {@link #dispatch(EventWithPeriodId, Class)} will propagate to {@code eventListener} - * if the {@code listenerClass} types are equal. - * - *

      The same listener instance can be added multiple times with different {@code listenerClass} - * values (i.e. if the instance implements multiple listener interfaces). - * - *

      Duplicate {@code {eventListener, listenerClass}} pairs are also permitted. In this case an - * event dispatched to {@code listenerClass} will only be passed to the {@code eventListener} - * once. - * - *

      NOTE: This doesn't interact well with hierarchies of listener interfaces. If a - * listener is registered with a super-class type then it will only receive events dispatched - * directly to that super-class type. Similarly, if a listener is registered with a sub-class type - * then it will only receive events dispatched directly to that sub-class. - * - * @param handler A handler on the which listener events will be posted. - * @param eventListener The listener to be added. - * @param listenerClass The type used to register the listener. Can be a superclass of {@code - * eventListener}. - */ - public void addEventListener(Handler handler, T eventListener, Class listenerClass) { - Assertions.checkNotNull(handler); - Assertions.checkNotNull(eventListener); - listenerInfos.add(new ListenerInfo(handler, eventListener, listenerClass)); - } - - /** - * Removes a listener from the event dispatcher. - * - *

      If there are duplicate registrations of {@code {eventListener, listenerClass}} this will - * only remove one (so events dispatched to {@code listenerClass} will still be passed to {@code - * eventListener}). - * - * @param eventListener The listener to be removed. - * @param listenerClass The listener type passed to {@link #addEventListener(Handler, Object, - * Class)}. - */ - public void removeEventListener(T eventListener, Class listenerClass) { - for (ListenerInfo listenerInfo : listenerInfos) { - if (listenerInfo.listener == eventListener - && listenerInfo.listenerClass.equals(listenerClass)) { - listenerInfos.remove(listenerInfo); - return; - } - } - } - - /** Dispatches {@code event} to all registered listeners of type {@code listenerClass}. */ - @SuppressWarnings("unchecked") // The cast is gated with listenerClass.isInstance() - public void dispatch(EventWithPeriodId event, Class listenerClass) { - for (ListenerInfo listenerInfo : listenerInfos.elementSet()) { - if (listenerInfo.listenerClass.equals(listenerClass)) { - postOrRun( - listenerInfo.handler, - () -> event.sendTo((T) listenerInfo.listener, windowIndex, mediaPeriodId)); - } - } - } - - private static void postOrRun(Handler handler, Runnable runnable) { - if (handler.getLooper() == Looper.myLooper()) { - runnable.run(); - } else { - handler.post(runnable); - } - } - - public static long adjustMediaTime(long mediaTimeUs, long mediaTimeOffsetMs) { - long mediaTimeMs = C.usToMs(mediaTimeUs); - return mediaTimeMs == C.TIME_UNSET ? C.TIME_UNSET : mediaTimeOffsetMs + mediaTimeMs; - } - - /** Container class for a {@link Handler}, {@code listener} and {@code listenerClass}. */ - protected static final class ListenerInfo { - - public final Handler handler; - public final Object listener; - public final Class listenerClass; - - public ListenerInfo(Handler handler, Object listener, Class listenerClass) { - this.handler = handler; - this.listener = listener; - this.listenerClass = listenerClass; - } - - @Override - public boolean equals(@Nullable Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ListenerInfo)) { - return false; - } - - ListenerInfo that = (ListenerInfo) o; - - // We deliberately only consider listener and listenerClass (and not handler) in equals() and - // hashcode() because the handler used to process the callbacks is an implementation detail. - return listener.equals(that.listener) && listenerClass.equals(that.listenerClass); - } - - @Override - public int hashCode() { - int result = 31 * listener.hashCode(); - return result + 31 * listenerClass.hashCode(); - } - } -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java index 756494f9d0..6c2b337344 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/NotificationUtil.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.util; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationChannel; @@ -99,7 +101,8 @@ public final class NotificationUtil { @Importance int importance) { if (Util.SDK_INT >= 26) { NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + checkNotNull( + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); NotificationChannel channel = new NotificationChannel(id, context.getString(nameResourceId), importance); if (descriptionResourceId != 0) { @@ -122,7 +125,7 @@ public final class NotificationUtil { */ public static void setNotification(Context context, int id, @Nullable Notification notification) { NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + checkNotNull((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); if (notification != null) { notificationManager.notify(id, notification); } else { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java b/library/core/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java index 2ebda60821..bf03c5f229 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/PriorityTaskManager.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.util; +import static java.lang.Math.max; + import java.io.IOException; import java.util.Collections; import java.util.PriorityQueue; @@ -59,7 +61,7 @@ public final class PriorityTaskManager { public void add(int priority) { synchronized (lock) { queue.add(priority); - highestPriority = Math.max(highestPriority, priority); + highestPriority = max(highestPriority, priority); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java b/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java new file mode 100644 index 0000000000..9da5f09629 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/RunnableFutureTask.java @@ -0,0 +1,174 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import androidx.annotation.Nullable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * A {@link RunnableFuture} that supports additional uninterruptible operations to query whether + * execution has started and finished. + * + * @param The type of the result. + * @param The type of any {@link ExecutionException} cause. + */ +public abstract class RunnableFutureTask implements RunnableFuture { + + private final ConditionVariable started; + private final ConditionVariable finished; + private final Object cancelLock; + + @Nullable private Exception exception; + @Nullable private R result; + + @Nullable private Thread workThread; + private boolean canceled; + + protected RunnableFutureTask() { + started = new ConditionVariable(); + finished = new ConditionVariable(); + cancelLock = new Object(); + } + + /** Blocks until the task has started, or has been canceled without having been started. */ + public final void blockUntilStarted() { + started.blockUninterruptible(); + } + + /** Blocks until the task has finished, or has been canceled without having been started. */ + public final void blockUntilFinished() { + finished.blockUninterruptible(); + } + + // Future implementation. + + @Override + @UnknownNull + public final R get() throws ExecutionException, InterruptedException { + finished.block(); + return getResult(); + } + + @Override + @UnknownNull + public final R get(long timeout, TimeUnit unit) + throws ExecutionException, InterruptedException, TimeoutException { + long timeoutMs = MILLISECONDS.convert(timeout, unit); + if (!finished.block(timeoutMs)) { + throw new TimeoutException(); + } + return getResult(); + } + + @Override + public final boolean cancel(boolean interruptIfRunning) { + synchronized (cancelLock) { + if (canceled || finished.isOpen()) { + return false; + } + canceled = true; + cancelWork(); + @Nullable Thread workThread = this.workThread; + if (workThread != null) { + if (interruptIfRunning) { + workThread.interrupt(); + } + } else { + started.open(); + finished.open(); + } + return true; + } + } + + @Override + public final boolean isDone() { + return finished.isOpen(); + } + + @Override + public final boolean isCancelled() { + return canceled; + } + + // Runnable implementation. + + @Override + public final void run() { + synchronized (cancelLock) { + if (canceled) { + return; + } + workThread = Thread.currentThread(); + } + started.open(); + try { + result = doWork(); + } catch (Exception e) { + // Must be an instance of E or RuntimeException. + exception = e; + } finally { + synchronized (cancelLock) { + finished.open(); + workThread = null; + // Clear the interrupted flag if set, to avoid it leaking into any subsequent tasks executed + // using the calling thread. + Thread.interrupted(); + } + } + } + + // Internal methods. + + /** + * Performs the work or computation. + * + * @return The computed result. + * @throws E If an error occurred. + */ + @UnknownNull + protected abstract R doWork() throws E; + + /** + * Cancels any work being done by {@link #doWork()}. If {@link #doWork()} is currently executing + * then the thread on which it's executing may be interrupted immediately after this method + * returns. + * + *

      The default implementation does nothing. + */ + protected void cancelWork() { + // Do nothing. + } + + // The return value is guaranteed to be non-null if and only if R is a non-null type, but there's + // no way to assert this. Suppress the warning instead. + @SuppressWarnings("return.type.incompatible") + @UnknownNull + private R getResult() throws ExecutionException { + if (canceled) { + throw new CancellationException(); + } else if (exception != null) { + throw new ExecutionException(exception); + } + return result; + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java b/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java index fa2edf253d..19159ede6e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java @@ -160,7 +160,7 @@ public final class SntpClient { final long receiveTime = readTimestamp(buffer, RECEIVE_TIME_OFFSET); final long transmitTime = readTimestamp(buffer, TRANSMIT_TIME_OFFSET); - // Do sanity check according to RFC. + // Check server reply validity according to RFC. checkValidServerReply(leap, mode, stratum, transmitTime); // receiveTime = originateTime + transit + skew diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/StandaloneMediaClock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/StandaloneMediaClock.java index e1df77a200..87970d3c00 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/StandaloneMediaClock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/StandaloneMediaClock.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2.util; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.PlaybackParameters; /** * A {@link MediaClock} whose position advances with real time based on the playback parameters when @@ -29,8 +29,7 @@ public final class StandaloneMediaClock implements MediaClock { private boolean started; private long baseUs; private long baseElapsedMs; - private float playbackSpeed; - private int scaledUsPerMs; + private PlaybackParameters playbackParameters; /** * Creates a new standalone media clock using the given {@link Clock} implementation. @@ -39,8 +38,7 @@ public final class StandaloneMediaClock implements MediaClock { */ public StandaloneMediaClock(Clock clock) { this.clock = clock; - playbackSpeed = Player.DEFAULT_PLAYBACK_SPEED; - scaledUsPerMs = getScaledUsPerMs(playbackSpeed); + playbackParameters = PlaybackParameters.DEFAULT; } /** @@ -80,33 +78,29 @@ public final class StandaloneMediaClock implements MediaClock { long positionUs = baseUs; if (started) { long elapsedSinceBaseMs = clock.elapsedRealtime() - baseElapsedMs; - if (playbackSpeed == 1f) { + if (playbackParameters.speed == 1f) { positionUs += C.msToUs(elapsedSinceBaseMs); } else { // Add the media time in microseconds that will elapse in elapsedSinceBaseMs milliseconds of // wallclock time - positionUs += elapsedSinceBaseMs * scaledUsPerMs; + positionUs += playbackParameters.getMediaTimeUsForPlayoutTimeMs(elapsedSinceBaseMs); } } return positionUs; } @Override - public void setPlaybackSpeed(float playbackSpeed) { + public void setPlaybackParameters(PlaybackParameters playbackParameters) { // Store the current position as the new base, in case the playback speed has changed. if (started) { resetPosition(getPositionUs()); } - this.playbackSpeed = playbackSpeed; - scaledUsPerMs = getScaledUsPerMs(playbackSpeed); + this.playbackParameters = playbackParameters; } @Override - public float getPlaybackSpeed() { - return playbackSpeed; + public PlaybackParameters getPlaybackParameters() { + return playbackParameters; } - private static int getScaledUsPerMs(float playbackSpeed) { - return Math.round(playbackSpeed * 1000f); - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/TimedValueQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/util/TimedValueQueue.java index da5d9bafeb..d49b37224c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/TimedValueQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/TimedValueQueue.java @@ -62,6 +62,12 @@ public final class TimedValueQueue { return size; } + /** Removes and returns the first value in the queue, or null if the queue is empty. */ + @Nullable + public synchronized V pollFirst() { + return size == 0 ? null : popFirst(); + } + /** * Returns the value with the greatest timestamp which is less than or equal to the given * timestamp. Removes all older values and the returned one from the buffer. @@ -71,7 +77,8 @@ public final class TimedValueQueue { * timestamp or null if there is no such value. * @see #poll(long) */ - public synchronized @Nullable V pollFloor(long timestamp) { + @Nullable + public synchronized V pollFloor(long timestamp) { return poll(timestamp, /* onlyOlder= */ true); } @@ -83,7 +90,8 @@ public final class TimedValueQueue { * @return The value with the closest timestamp or null if the buffer is empty. * @see #pollFloor(long) */ - public synchronized @Nullable V poll(long timestamp) { + @Nullable + public synchronized V poll(long timestamp) { return poll(timestamp, /* onlyOlder= */ false); } @@ -99,7 +107,7 @@ public final class TimedValueQueue { */ @Nullable private V poll(long timestamp, boolean onlyOlder) { - V value = null; + @Nullable V value = null; long previousTimeDiff = Long.MAX_VALUE; while (size > 0) { long timeDiff = timestamp - timestamps[first]; @@ -107,14 +115,21 @@ public final class TimedValueQueue { break; } previousTimeDiff = timeDiff; - value = values[first]; - values[first] = null; - first = (first + 1) % values.length; - size--; + value = popFirst(); } return value; } + @Nullable + private V popFirst() { + Assertions.checkState(size > 0); + @Nullable V value = values[first]; + values[first] = null; + first = (first + 1) % values.length; + size--; + return value; + } + private void clearBufferOnTimeDiscontinuity(long timestamp) { if (size > 0) { int last = (first + size - 1) % values.length; @@ -131,7 +146,7 @@ public final class TimedValueQueue { } int newCapacity = capacity * 2; long[] newTimestamps = new long[newCapacity]; - V[] newValues = newArray(newCapacity); + @NullableType V[] newValues = newArray(newCapacity); // Reset the loop starting index to 0 while coping to the new buffer. // First copy the values from 'first' index to the end of original array. int length = capacity - first; @@ -155,7 +170,7 @@ public final class TimedValueQueue { } @SuppressWarnings("unchecked") - private static V[] newArray(int length) { + private static @NullableType V[] newArray(int length) { return (V[]) new Object[length]; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java index 23cae05a03..6fda6d2e9c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/DecoderVideoRenderer.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.video; +import static java.lang.Math.max; + import android.os.Handler; import android.os.SystemClock; import android.view.Surface; @@ -116,7 +118,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { private boolean renderedFirstFrameAfterEnable; private long initialPositionUs; private long joiningDeadlineMs; - private boolean waitingForKeys; private boolean waitingForFirstSampleInFormat; private boolean inputStreamEnded; @@ -211,9 +212,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { @Override public boolean isReady() { - if (waitingForKeys) { - return false; - } if (inputFormat != null && (isSourceReady() || outputBuffer != null) && (renderedFirstFrameAfterReset || !hasOutput())) { @@ -293,7 +291,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { @Override protected void onDisabled() { inputFormat = null; - waitingForKeys = false; clearReportedVideoSize(); clearRenderedFirstFrame(); try { @@ -305,12 +302,13 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { // TODO: This shouldn't just update the output stream offset as long as there are still buffers // of the previous stream in the decoder. It should also make sure to render the first frame of // the next stream if the playback position reached the new stream. outputStreamOffsetUs = offsetUs; - super.onStreamChanged(formats, offsetUs); + super.onStreamChanged(formats, startPositionUs, offsetUs); } /** @@ -336,7 +334,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { */ @CallSuper protected void flushDecoder() throws ExoPlaybackException { - waitingForKeys = false; buffersInCodecCount = 0; if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { releaseDecoder(); @@ -510,7 +507,7 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { droppedFrames += droppedBufferCount; consecutiveDroppedFrameCount += droppedBufferCount; decoderCounters.maxConsecutiveDroppedBufferCount = - Math.max(consecutiveDroppedFrameCount, decoderCounters.maxConsecutiveDroppedBufferCount); + max(consecutiveDroppedFrameCount, decoderCounters.maxConsecutiveDroppedBufferCount); if (maxDroppedFramesToNotify > 0 && droppedFrames >= maxDroppedFramesToNotify) { maybeNotifyDroppedFrames(); } @@ -726,46 +723,36 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { return false; } - @SampleStream.ReadDataResult int result; FormatHolder formatHolder = getFormatHolder(); - if (waitingForKeys) { - // We've already read an encrypted sample into buffer, and are waiting for keys. - result = C.RESULT_BUFFER_READ; - } else { - result = readSource(formatHolder, inputBuffer, false); + switch (readSource(formatHolder, inputBuffer, /* formatRequired= */ false)) { + case C.RESULT_NOTHING_READ: + return false; + case C.RESULT_FORMAT_READ: + onInputFormatChanged(formatHolder); + return true; + case C.RESULT_BUFFER_READ: + if (inputBuffer.isEndOfStream()) { + inputStreamEnded = true; + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + return false; + } + if (waitingForFirstSampleInFormat) { + formatQueue.add(inputBuffer.timeUs, inputFormat); + waitingForFirstSampleInFormat = false; + } + inputBuffer.flip(); + inputBuffer.format = inputFormat; + onQueueInputBuffer(inputBuffer); + decoder.queueInputBuffer(inputBuffer); + buffersInCodecCount++; + decoderReceivedBuffers = true; + decoderCounters.inputBufferCount++; + inputBuffer = null; + return true; + default: + throw new IllegalStateException(); } - - if (result == C.RESULT_NOTHING_READ) { - return false; - } - if (result == C.RESULT_FORMAT_READ) { - onInputFormatChanged(formatHolder); - return true; - } - if (inputBuffer.isEndOfStream()) { - inputStreamEnded = true; - decoder.queueInputBuffer(inputBuffer); - inputBuffer = null; - return false; - } - boolean bufferEncrypted = inputBuffer.isEncrypted(); - waitingForKeys = shouldWaitForKeys(bufferEncrypted); - if (waitingForKeys) { - return false; - } - if (waitingForFirstSampleInFormat) { - formatQueue.add(inputBuffer.timeUs, inputFormat); - waitingForFirstSampleInFormat = false; - } - inputBuffer.flip(); - inputBuffer.colorInfo = inputFormat.colorInfo; - onQueueInputBuffer(inputBuffer); - decoder.queueInputBuffer(inputBuffer); - buffersInCodecCount++; - decoderReceivedBuffers = true; - decoderCounters.inputBufferCount++; - inputBuffer = null; - return true; } /** @@ -903,19 +890,6 @@ public abstract class DecoderVideoRenderer extends BaseRenderer { maybeRenotifyRenderedFirstFrame(); } - private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { - DrmSession decoderDrmSession = this.decoderDrmSession; - if (decoderDrmSession == null - || (!bufferEncrypted && decoderDrmSession.playClearSamplesWithoutKeys())) { - return false; - } - @DrmSession.State int drmSessionState = decoderDrmSession.getState(); - if (drmSessionState == DrmSession.STATE_ERROR) { - throw createRendererException(decoderDrmSession.getError(), inputFormat); - } - return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; - } - private void setJoiningDeadlineMs() { joiningDeadlineMs = allowedJoiningTimeMs > 0 diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/DummySurface.java b/library/core/src/main/java/com/google/android/exoplayer2/video/DummySurface.java index a5dd9cfefb..a68a64b28d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/DummySurface.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/DummySurface.java @@ -22,7 +22,6 @@ import static com.google.android.exoplayer2.util.EGLSurfaceTexture.SECURE_MODE_S import android.content.Context; import android.graphics.SurfaceTexture; import android.os.Handler; -import android.os.Handler.Callback; import android.os.HandlerThread; import android.os.Message; import android.view.Surface; @@ -70,14 +69,14 @@ public final class DummySurface extends Surface { /** * Returns a newly created dummy surface. The surface must be released by calling {@link #release} * when it's no longer required. - *

      - * Must only be called if {@link Util#SDK_INT} is 17 or higher. + * + *

      Must only be called if {@link Util#SDK_INT} is 17 or higher. * * @param context Any {@link Context}. - * @param secure Whether a secure surface is required. Must only be requested if - * {@link #isSecureSupported(Context)} returns {@code true}. - * @throws IllegalStateException If a secure surface is requested on a device for which - * {@link #isSecureSupported(Context)} returns {@code false}. + * @param secure Whether a secure surface is required. Must only be requested if {@link + * #isSecureSupported(Context)} returns {@code true}. + * @throws IllegalStateException If a secure surface is requested on a device for which {@link + * #isSecureSupported(Context)} returns {@code false}. */ public static DummySurface newInstanceV17(Context context, boolean secure) { Assertions.checkState(!secure || isSecureSupported(context)); @@ -123,7 +122,7 @@ public final class DummySurface extends Surface { } } - private static class DummySurfaceThread extends HandlerThread implements Callback { + private static class DummySurfaceThread extends HandlerThread implements Handler.Callback { private static final int MSG_INIT = 1; private static final int MSG_RELEASE = 2; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 9dc0c7230d..5b26588244 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.video; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; @@ -40,8 +43,10 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; @@ -134,6 +139,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private Surface surface; private float surfaceFrameRate; private Surface dummySurface; + private boolean haveReportedFirstFrameRenderedForCurrentSurface; @VideoScalingMode private int scalingMode; private boolean renderedFirstFrameAfterReset; private boolean mayRenderFirstFrameAfterEnableIfNotStarted; @@ -148,9 +154,6 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private long totalVideoFrameProcessingOffsetUs; private int videoFrameProcessingOffsetCount; - @Nullable private MediaFormat currentMediaFormat; - private int mediaFormatWidth; - private int mediaFormatHeight; private int currentWidth; private int currentHeight; private int currentUnappliedRotationDegrees; @@ -257,8 +260,6 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { currentHeight = Format.NO_VALUE; currentPixelWidthHeightRatio = Format.NO_VALUE; scalingMode = VIDEO_SCALING_MODE_DEFAULT; - mediaFormatWidth = Format.NO_VALUE; - mediaFormatHeight = Format.NO_VALUE; clearReportedVideoSize(); } @@ -444,9 +445,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @Override protected void onDisabled() { - currentMediaFormat = null; clearReportedVideoSize(); clearRenderedFirstFrame(); + haveReportedFirstFrameRenderedForCurrentSurface = false; frameReleaseTimeHelper.disable(); tunnelingOnFrameRenderedListener = null; try { @@ -505,6 +506,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { if (this.surface != surface) { clearSurfaceFrameRate(); this.surface = surface; + haveReportedFirstFrameRenderedForCurrentSurface = false; updateSurfaceFrameRate(/* isNewSurface= */ true); @State int state = getState(); @@ -514,7 +516,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { setOutputSurfaceV23(codec, surface); } else { releaseCodec(); - maybeInitCodecOrPassthrough(); + maybeInitCodecOrBypass(); } } if (surface != null && surface != dummySurface) { @@ -552,7 +554,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate) { @@ -575,9 +577,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } surface = dummySurface; } - codec.configure(mediaFormat, surface, crypto, 0); + codecAdapter.configure(mediaFormat, surface, crypto, 0); if (Util.SDK_INT >= 23 && tunneling) { - tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); + tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codecAdapter.getCodec()); } } @@ -618,7 +620,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { for (Format streamFormat : streamFormats) { float streamFrameRate = streamFormat.frameRate; if (streamFrameRate != Format.NO_VALUE) { - maxFrameRate = Math.max(maxFrameRate, streamFrameRate); + maxFrameRate = max(maxFrameRate, streamFrameRate); } } return maxFrameRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxFrameRate * operatingRate); @@ -642,11 +644,14 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { /** * Called immediately before an input buffer is queued into the codec. * + *

      In tunneling mode for pre Marshmallow, the buffer is treated as if immediately output. + * * @param buffer The buffer to be queued. + * @throws ExoPlaybackException Thrown if an error occurs handling the input buffer. */ @CallSuper @Override - protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + protected void onQueueInputBuffer(DecoderInputBuffer buffer) throws ExoPlaybackException { // In tunneling mode the device may do frame rate conversion, so in general we can't keep track // of the number of buffers in the codec. if (!tunneling) { @@ -660,51 +665,37 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } @Override - protected void onOutputMediaFormatChanged(MediaCodec codec, MediaFormat outputMediaFormat) { - currentMediaFormat = outputMediaFormat; - boolean hasCrop = - outputMediaFormat.containsKey(KEY_CROP_RIGHT) - && outputMediaFormat.containsKey(KEY_CROP_LEFT) - && outputMediaFormat.containsKey(KEY_CROP_BOTTOM) - && outputMediaFormat.containsKey(KEY_CROP_TOP); - mediaFormatWidth = - hasCrop - ? outputMediaFormat.getInteger(KEY_CROP_RIGHT) - - outputMediaFormat.getInteger(KEY_CROP_LEFT) - + 1 - : outputMediaFormat.getInteger(MediaFormat.KEY_WIDTH); - mediaFormatHeight = - hasCrop - ? outputMediaFormat.getInteger(KEY_CROP_BOTTOM) - - outputMediaFormat.getInteger(KEY_CROP_TOP) - + 1 - : outputMediaFormat.getInteger(MediaFormat.KEY_HEIGHT); - - // Must be applied each time the output MediaFormat changes. - codec.setVideoScalingMode(scalingMode); - maybeNotifyVideoFrameProcessingOffset(); - } - - @Override - protected void onOutputFormatChanged(Format outputFormat) { - configureOutput(outputFormat); - } - - @Override - protected void configureOutput(Format outputFormat) { - if (tunneling) { - currentWidth = outputFormat.width; - currentHeight = outputFormat.height; - } else { - currentWidth = mediaFormatWidth; - currentHeight = mediaFormatHeight; + protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaFormat) { + @Nullable MediaCodec codec = getCodec(); + if (codec != null) { + // Must be applied each time the output format changes. + codec.setVideoScalingMode(scalingMode); } - currentPixelWidthHeightRatio = outputFormat.pixelWidthHeightRatio; + if (tunneling) { + currentWidth = format.width; + currentHeight = format.height; + } else { + Assertions.checkNotNull(mediaFormat); + boolean hasCrop = + mediaFormat.containsKey(KEY_CROP_RIGHT) + && mediaFormat.containsKey(KEY_CROP_LEFT) + && mediaFormat.containsKey(KEY_CROP_BOTTOM) + && mediaFormat.containsKey(KEY_CROP_TOP); + currentWidth = + hasCrop + ? mediaFormat.getInteger(KEY_CROP_RIGHT) - mediaFormat.getInteger(KEY_CROP_LEFT) + 1 + : mediaFormat.getInteger(MediaFormat.KEY_WIDTH); + currentHeight = + hasCrop + ? mediaFormat.getInteger(KEY_CROP_BOTTOM) - mediaFormat.getInteger(KEY_CROP_TOP) + 1 + : mediaFormat.getInteger(MediaFormat.KEY_HEIGHT); + } + currentPixelWidthHeightRatio = format.pixelWidthHeightRatio; if (Util.SDK_INT >= 21) { // On API level 21 and above the decoder applies the rotation when rendering to the surface. // Hence currentUnappliedRotation should always be 0. For 90 and 270 degree rotations, we need // to flip the width, height and pixel aspect ratio to reflect the rotation that was applied. - if (outputFormat.rotationDegrees == 90 || outputFormat.rotationDegrees == 270) { + if (format.rotationDegrees == 90 || format.rotationDegrees == 270) { int rotatedHeight = currentWidth; currentWidth = currentHeight; currentHeight = rotatedHeight; @@ -712,9 +703,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } } else { // On API level 20 and below the decoder does not apply the rotation. - currentUnappliedRotationDegrees = outputFormat.rotationDegrees; + currentUnappliedRotationDegrees = format.rotationDegrees; } - currentFrameRate = outputFormat.frameRate; + currentFrameRate = format.frameRate; updateSurfaceFrameRate(/* isNewSurface= */ false); } @@ -754,7 +745,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { long positionUs, long elapsedRealtimeUs, @Nullable MediaCodec codec, - ByteBuffer buffer, + @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, int sampleCount, @@ -782,7 +773,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { // Skip frames in sync with playback, so we'll be at the right frame if the mode changes. if (isBufferLate(earlyUs)) { skipOutputBuffer(codec, bufferIndex, presentationTimeUs); - decoderCounters.addVideoFrameProcessingOffsetSample(earlyUs); + updateVideoFrameProcessingOffsetCounters(earlyUs); return true; } return false; @@ -803,13 +794,13 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { || (isStarted && shouldForceRenderOutputBuffer(earlyUs, elapsedSinceLastRenderUs))); if (forceRenderOutputBuffer) { long releaseTimeNs = System.nanoTime(); - notifyFrameMetadataListener(presentationTimeUs, releaseTimeNs, format, currentMediaFormat); + notifyFrameMetadataListener(presentationTimeUs, releaseTimeNs, format); if (Util.SDK_INT >= 21) { renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, releaseTimeNs); } else { renderOutputBuffer(codec, bufferIndex, presentationTimeUs); } - decoderCounters.addVideoFrameProcessingOffsetSample(earlyUs); + updateVideoFrameProcessingOffsetCounters(earlyUs); return true; } @@ -842,17 +833,16 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } else { dropOutputBuffer(codec, bufferIndex, presentationTimeUs); } - decoderCounters.addVideoFrameProcessingOffsetSample(earlyUs); + updateVideoFrameProcessingOffsetCounters(earlyUs); return true; } if (Util.SDK_INT >= 21) { // Let the underlying framework time the release. if (earlyUs < 50000) { - notifyFrameMetadataListener( - presentationTimeUs, adjustedReleaseTimeNs, format, currentMediaFormat); + notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format); renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, adjustedReleaseTimeNs); - decoderCounters.addVideoFrameProcessingOffsetSample(earlyUs); + updateVideoFrameProcessingOffsetCounters(earlyUs); return true; } } else { @@ -869,10 +859,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { return false; } } - notifyFrameMetadataListener( - presentationTimeUs, adjustedReleaseTimeNs, format, currentMediaFormat); + notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format); renderOutputBuffer(codec, bufferIndex, presentationTimeUs); - decoderCounters.addVideoFrameProcessingOffsetSample(earlyUs); + updateVideoFrameProcessingOffsetCounters(earlyUs); return true; } } @@ -882,15 +871,15 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } private void notifyFrameMetadataListener( - long presentationTimeUs, long releaseTimeNs, Format format, MediaFormat mediaFormat) { + long presentationTimeUs, long releaseTimeNs, Format format) { if (frameMetadataListener != null) { frameMetadataListener.onVideoFrameAboutToBeRendered( - presentationTimeUs, releaseTimeNs, format, mediaFormat); + presentationTimeUs, releaseTimeNs, format, getCodecOutputMediaFormat()); } } /** Called when a buffer was processed in tunneling mode. */ - protected void onProcessedTunneledBuffer(long presentationTimeUs) { + protected void onProcessedTunneledBuffer(long presentationTimeUs) throws ExoPlaybackException { updateOutputFormatForTime(presentationTimeUs); maybeNotifyVideoSizeChanged(); decoderCounters.renderedOutputBufferCount++; @@ -1028,8 +1017,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } /** - * Updates decoder counters to reflect that {@code droppedBufferCount} additional buffers were - * dropped. + * Updates local counters and {@link DecoderCounters} to reflect that {@code droppedBufferCount} + * additional buffers were dropped. * * @param droppedBufferCount The number of additional dropped buffers. */ @@ -1037,13 +1026,24 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { decoderCounters.droppedBufferCount += droppedBufferCount; droppedFrames += droppedBufferCount; consecutiveDroppedFrameCount += droppedBufferCount; - decoderCounters.maxConsecutiveDroppedBufferCount = Math.max(consecutiveDroppedFrameCount, - decoderCounters.maxConsecutiveDroppedBufferCount); + decoderCounters.maxConsecutiveDroppedBufferCount = + max(consecutiveDroppedFrameCount, decoderCounters.maxConsecutiveDroppedBufferCount); if (maxDroppedFramesToNotify > 0 && droppedFrames >= maxDroppedFramesToNotify) { maybeNotifyDroppedFrames(); } } + /** + * Updates local counters and {@link DecoderCounters} with a new video frame processing offset. + * + * @param processingOffsetUs The video frame processing offset. + */ + protected void updateVideoFrameProcessingOffsetCounters(long processingOffsetUs) { + decoderCounters.addVideoFrameProcessingOffset(processingOffsetUs); + totalVideoFrameProcessingOffsetUs += processingOffsetUs; + videoFrameProcessingOffsetCount++; + } + /** * Renders the output buffer with the specified index. This method is only called if the platform * API version of the device is less than 21. @@ -1163,11 +1163,12 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { if (!renderedFirstFrameAfterReset) { renderedFirstFrameAfterReset = true; eventDispatcher.renderedFirstFrame(surface); + haveReportedFirstFrameRenderedForCurrentSurface = true; } } private void maybeRenotifyRenderedFirstFrame() { - if (renderedFirstFrameAfterReset) { + if (haveReportedFirstFrameRenderedForCurrentSurface) { eventDispatcher.renderedFirstFrame(surface); } } @@ -1211,18 +1212,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } private void maybeNotifyVideoFrameProcessingOffset() { - Format outputFormat = getCurrentOutputFormat(); - if (outputFormat != null) { - long totalOffsetDelta = - decoderCounters.totalVideoFrameProcessingOffsetUs - totalVideoFrameProcessingOffsetUs; - int countDelta = - decoderCounters.videoFrameProcessingOffsetCount - videoFrameProcessingOffsetCount; - if (countDelta != 0) { - eventDispatcher.reportVideoFrameProcessingOffset( - totalOffsetDelta, countDelta, outputFormat); - totalVideoFrameProcessingOffsetUs = decoderCounters.totalVideoFrameProcessingOffsetUs; - videoFrameProcessingOffsetCount = decoderCounters.videoFrameProcessingOffsetCount; - } + if (videoFrameProcessingOffsetCount != 0) { + eventDispatcher.reportVideoFrameProcessingOffset( + totalVideoFrameProcessingOffsetUs, videoFrameProcessingOffsetCount); + totalVideoFrameProcessingOffsetUs = 0; + videoFrameProcessingOffsetCount = 0; } } @@ -1244,7 +1238,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { } @RequiresApi(23) - private static void setOutputSurfaceV23(MediaCodec codec, Surface surface) { + protected void setOutputSurfaceV23(MediaCodec codec, Surface surface) { codec.setOutputSurface(surface); } @@ -1345,7 +1339,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { int scaledMaxInputSize = (int) (maxInputSize * INITIAL_FORMAT_MAX_INPUT_SIZE_SCALE_FACTOR); // Avoid exceeding the maximum expected for the codec. - maxInputSize = Math.min(scaledMaxInputSize, codecMaxInputSize); + maxInputSize = min(scaledMaxInputSize, codecMaxInputSize); } } return new CodecMaxValues(maxWidth, maxHeight, maxInputSize); @@ -1356,19 +1350,19 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { format, streamFormat, /* isNewFormatComplete= */ false)) { haveUnknownDimensions |= (streamFormat.width == Format.NO_VALUE || streamFormat.height == Format.NO_VALUE); - maxWidth = Math.max(maxWidth, streamFormat.width); - maxHeight = Math.max(maxHeight, streamFormat.height); - maxInputSize = Math.max(maxInputSize, getMaxInputSize(codecInfo, streamFormat)); + maxWidth = max(maxWidth, streamFormat.width); + maxHeight = max(maxHeight, streamFormat.height); + maxInputSize = max(maxInputSize, getMaxInputSize(codecInfo, streamFormat)); } } if (haveUnknownDimensions) { Log.w(TAG, "Resolutions unknown. Codec max resolution: " + maxWidth + "x" + maxHeight); Point codecMaxSize = getCodecMaxSize(codecInfo, format); if (codecMaxSize != null) { - maxWidth = Math.max(maxWidth, codecMaxSize.x); - maxHeight = Math.max(maxHeight, codecMaxSize.y); + maxWidth = max(maxWidth, codecMaxSize.x); + maxHeight = max(maxHeight, codecMaxSize.y); maxInputSize = - Math.max( + max( maxInputSize, getCodecMaxInputSize(codecInfo, format.sampleMimeType, maxWidth, maxHeight)); Log.w(TAG, "Codec max resolution adjusted to: " + maxWidth + "x" + maxHeight); @@ -1436,7 +1430,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { * @return A maximum input buffer size in bytes, or {@link Format#NO_VALUE} if a maximum could not * be determined. */ - private static int getMaxInputSize(MediaCodecInfo codecInfo, Format format) { + protected static int getMaxInputSize(MediaCodecInfo codecInfo, Format format) { if (format.maxInputSize != Format.NO_VALUE) { // The format defines an explicit maximum input size. Add the total size of initialization // data buffers, as they may need to be queued in the same input buffer as the largest sample. @@ -1617,6 +1611,8 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { case "ELUGA_Prim": case "ELUGA_Ray_X": case "EverStar_S": + case "F02H": + case "F03H": case "F3111": case "F3113": case "F3116": @@ -1762,7 +1758,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private final Handler handler; public OnFrameRenderedListenerV23(MediaCodec codec) { - handler = Util.createHandler(/* callback= */ this); + handler = Util.createHandlerForCurrentLooper(/* callback= */ this); codec.setOnFrameRenderedListener(/* listener= */ this, handler); } @@ -1807,7 +1803,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { if (presentationTimeUs == TUNNELING_EOS_PRESENTATION_TIME_US) { onProcessedTunneledEndOfStream(); } else { - onProcessedTunneledBuffer(presentationTimeUs); + try { + onProcessedTunneledBuffer(presentationTimeUs); + } catch (ExoPlaybackException e) { + setPendingPlaybackException(e); + } } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderInputBuffer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderInputBuffer.java index 360279c11c..c496dbabde 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderInputBuffer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderInputBuffer.java @@ -16,15 +16,39 @@ package com.google.android.exoplayer2.video; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; /** Input buffer to a video decoder. */ public class VideoDecoderInputBuffer extends DecoderInputBuffer { - @Nullable public ColorInfo colorInfo; + @Nullable public Format format; - public VideoDecoderInputBuffer() { - super(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); + /** + * Creates a new instance. + * + * @param bufferReplacementMode Determines the behavior of {@link #ensureSpaceForWrite(int)}. One + * of {@link #BUFFER_REPLACEMENT_MODE_DISABLED}, {@link #BUFFER_REPLACEMENT_MODE_NORMAL} and + * {@link #BUFFER_REPLACEMENT_MODE_DIRECT}. + */ + public VideoDecoderInputBuffer(@BufferReplacementMode int bufferReplacementMode) { + super(bufferReplacementMode); } + /** + * Creates a new instance. + * + * @param bufferReplacementMode Determines the behavior of {@link #ensureSpaceForWrite(int)}. One + * of {@link #BUFFER_REPLACEMENT_MODE_DISABLED}, {@link #BUFFER_REPLACEMENT_MODE_NORMAL} and + * {@link #BUFFER_REPLACEMENT_MODE_DIRECT}. + * @param paddingSize If non-zero, {@link #ensureSpaceForWrite(int)} will ensure that the buffer + * is this number of bytes larger than the requested length. This can be useful for decoders + * that consume data in fixed size blocks, for efficiency. Setting the padding size to the + * decoder's fixed read size is necessary to prevent such a decoder from trying to read beyond + * the end of the buffer. + */ + public VideoDecoderInputBuffer( + @BufferReplacementMode int bufferReplacementMode, int paddingSize) { + super(bufferReplacementMode, paddingSize); + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java index 8f2e4122b3..899f1a8d47 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.video; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.decoder.OutputBuffer; import java.nio.ByteBuffer; @@ -43,7 +44,8 @@ public class VideoDecoderOutputBuffer extends OutputBuffer { public int width; public int height; - @Nullable public ColorInfo colorInfo; + /** The format of the input from which this output buffer was decoded. */ + @Nullable public Format format; /** YUV planes for YUV mode. */ @Nullable public ByteBuffer[] yuvPlanes; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java index 2134772d9c..01b296e747 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java @@ -36,7 +36,7 @@ import com.google.android.exoplayer2.util.Util; public final class VideoFrameReleaseTimeHelper { private static final long CHOREOGRAPHER_SAMPLE_DELAY_MILLIS = 500; - private static final long MAX_ALLOWED_DRIFT_NS = 20000000; + private static final long MAX_ALLOWED_DRIFT_NS = 20_000_000; private static final long VSYNC_OFFSET_PERCENTAGE = 80; private static final int MIN_FRAMES_FOR_ADJUSTMENT = 6; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java index 948c388c30..589371cde5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoListener.java @@ -51,8 +51,8 @@ public interface VideoListener { default void onSurfaceSizeChanged(int width, int height) {} /** - * Called when a frame is rendered for the first time since setting the surface, and when a frame - * is rendered for the first time since the renderer was reset. + * Called when a frame is rendered for the first time since setting the surface, or since the + * renderer was reset, or since the stream being rendered was changed. */ default void onRenderedFirstFrame() {} } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java index 671d66c31c..992a262dab 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoRendererEventListener.java @@ -88,10 +88,8 @@ public interface VideoRendererEventListener { * @param totalProcessingOffsetUs The sum of all video frame processing offset samples for the * video frames processed by the renderer in microseconds. * @param frameCount The number of samples included in the {@code totalProcessingOffsetUs}. - * @param format The {@link Format} that is currently output. */ - default void onVideoFrameProcessingOffset( - long totalProcessingOffsetUs, int frameCount, Format format) {} + default void onVideoFrameProcessingOffset(long totalProcessingOffsetUs, int frameCount) {} /** * Called before a frame is rendered for the first time since setting the surface, and each time @@ -114,8 +112,8 @@ public interface VideoRendererEventListener { int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {} /** - * Called when a frame is rendered for the first time since setting the surface, and when a frame - * is rendered for the first time since the renderer was reset. + * Called when a frame is rendered for the first time since setting the surface, or since the + * renderer was reset, or since the stream being rendered was changed. * * @param surface The {@link Surface} to which a first frame has been rendered, or {@code null} if * the renderer renders to something that isn't a {@link Surface}. @@ -138,12 +136,12 @@ public interface VideoRendererEventListener { @Nullable private final VideoRendererEventListener listener; /** - * @param handler A handler for dispatching events, or null if creating a dummy instance. - * @param listener The listener to which events should be dispatched, or null if creating a - * dummy instance. + * @param handler A handler for dispatching events, or null if events should not be dispatched. + * @param listener The listener to which events should be dispatched, or null if events should + * not be dispatched. */ - public EventDispatcher(@Nullable Handler handler, - @Nullable VideoRendererEventListener listener) { + public EventDispatcher( + @Nullable Handler handler, @Nullable VideoRendererEventListener listener) { this.handler = listener != null ? Assertions.checkNotNull(handler) : null; this.listener = listener; } @@ -182,13 +180,12 @@ public interface VideoRendererEventListener { } /** Invokes {@link VideoRendererEventListener#onVideoFrameProcessingOffset}. */ - public void reportVideoFrameProcessingOffset( - long totalProcessingOffsetUs, int frameCount, Format format) { + public void reportVideoFrameProcessingOffset(long totalProcessingOffsetUs, int frameCount) { if (handler != null) { handler.post( () -> castNonNull(listener) - .onVideoFrameProcessingOffset(totalProcessingOffsetUs, frameCount, format)); + .onVideoFrameProcessingOffset(totalProcessingOffsetUs, frameCount)); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java index abf08f3b4e..75902c0f14 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java @@ -31,11 +31,11 @@ import com.google.android.exoplayer2.util.Util; import java.nio.ByteBuffer; /** A {@link Renderer} that parses the camera motion track. */ -public class CameraMotionRenderer extends BaseRenderer { +public final class CameraMotionRenderer extends BaseRenderer { private static final String TAG = "CameraMotionRenderer"; // The amount of time to read samples ahead of the current time. - private static final int SAMPLE_WINDOW_DURATION_US = 100000; + private static final int SAMPLE_WINDOW_DURATION_US = 100_000; private final DecoderInputBuffer buffer; private final ParsableByteArray scratch; @@ -73,12 +73,13 @@ public class CameraMotionRenderer extends BaseRenderer { } @Override - protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { this.offsetUs = offsetUs; } @Override - protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + protected void onPositionReset(long positionUs, boolean joining) { + lastTimestampUs = Long.MIN_VALUE; resetListener(); } @@ -88,7 +89,7 @@ public class CameraMotionRenderer extends BaseRenderer { } @Override - public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + public void render(long positionUs, long elapsedRealtimeUs) { // Keep reading available samples as long as the sample time is not too far into the future. while (!hasReadStreamToEnd() && lastTimestampUs < positionUs + SAMPLE_WINDOW_DURATION_US) { buffer.clear(); @@ -99,14 +100,18 @@ public class CameraMotionRenderer extends BaseRenderer { return; } - buffer.flip(); lastTimestampUs = buffer.timeUs; - if (listener != null) { - float[] rotation = parseMetadata(Util.castNonNull(buffer.data)); - if (rotation != null) { - Util.castNonNull(listener).onCameraMotion(lastTimestampUs - offsetUs, rotation); - } + if (listener == null || buffer.isDecodeOnly()) { + continue; } + + buffer.flip(); + @Nullable float[] rotation = parseMetadata(Util.castNonNull(buffer.data)); + if (rotation == null) { + continue; + } + + Util.castNonNull(listener).onCameraMotion(lastTimestampUs - offsetUs, rotation); } } @@ -135,7 +140,6 @@ public class CameraMotionRenderer extends BaseRenderer { } private void resetListener() { - lastTimestampUs = 0; if (listener != null) { listener.onCameraMotionReset(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java index eadc617ea7..9f7f2362e5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java @@ -43,9 +43,9 @@ public final class ProjectionDecoder { private static final int TYPE_MESH = 0x6d657368; private static final int TYPE_PROJ = 0x70726f6a; - // Sanity limits to prevent a bad file from creating an OOM situation. We don't expect a mesh to + // Limits to prevent a bad file from creating an OOM situation. We don't expect a mesh to // exceed these limits. - private static final int MAX_COORDINATE_COUNT = 10000; + private static final int MAX_COORDINATE_COUNT = 10_000; private static final int MAX_VERTEX_COUNT = 32 * 1000; private static final int MAX_TRIANGLE_INDICES = 128 * 1000; @@ -179,7 +179,7 @@ public final class ProjectionDecoder { final double log2 = Math.log(2.0); int coordinateCountSizeBits = (int) Math.ceil(Math.log(2.0 * coordinateCount) / log2); - ParsableBitArray bitInput = new ParsableBitArray(input.data); + ParsableBitArray bitInput = new ParsableBitArray(input.getData()); bitInput.setPosition(input.getPosition() * 8); float[] vertices = new float[vertexCount * 5]; int[] coordinateIndices = new int[5]; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java index 2b9f476c61..b13b7fe5b1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/AudioFocusManagerTest.java @@ -20,8 +20,8 @@ import static com.google.android.exoplayer2.AudioFocusManager.PLAYER_COMMAND_PLA import static com.google.android.exoplayer2.AudioFocusManager.PLAYER_COMMAND_WAIT_FOR_CALLBACK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import static org.robolectric.Shadows.shadowOf; import static org.robolectric.annotation.Config.TARGET_SDK; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.content.Context; import android.media.AudioFocusRequest; @@ -37,11 +37,9 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Shadows; import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowAudioManager; /** Unit tests for {@link AudioFocusManager}. */ -@LooperMode(LEGACY) @RunWith(AndroidJUnit4.class) public class AudioFocusManagerTest { private static final int NO_COMMAND_RECEIVED = ~PLAYER_COMMAND_WAIT_FOR_CALLBACK; @@ -231,8 +229,9 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); - assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); // Focus should be re-requested, rather than staying in a state of transient ducking. This // should restore the volume to 1.0. See https://github.com/google/ExoPlayer/issues/7182 for // context. @@ -254,6 +253,8 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); // Configure the manager to no longer handle focus. @@ -354,6 +355,8 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); audioFocusManager.release(); @@ -374,10 +377,14 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); assertThat(testPlayerControl.lastVolumeMultiplier).isLessThan(1.0f); assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(NO_COMMAND_RECEIVED); + audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isEqualTo(1.0f); } @@ -399,9 +406,14 @@ public class AudioFocusManagerTest { audioFocusManager .getFocusListener() .onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_WAIT_FOR_CALLBACK); assertThat(testPlayerControl.lastVolumeMultiplier).isEqualTo(1.0f); + audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_PLAY_WHEN_READY); } @@ -415,6 +427,8 @@ public class AudioFocusManagerTest { .isEqualTo(PLAYER_COMMAND_PLAY_WHEN_READY); audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastVolumeMultiplier).isEqualTo(1.0f); assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_WAIT_FOR_CALLBACK); } @@ -433,6 +447,8 @@ public class AudioFocusManagerTest { ShadowAudioManager.AudioFocusRequest request = Shadows.shadowOf(audioManager).getLastAudioFocusRequest(); request.listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_DO_NOT_PLAY); assertThat(Shadows.shadowOf(audioManager).getLastAbandonedAudioFocusListener()) .isEqualTo(request.listener); @@ -450,6 +466,8 @@ public class AudioFocusManagerTest { assertThat(Shadows.shadowOf(audioManager).getLastAbandonedAudioFocusRequest()).isNull(); audioFocusManager.getFocusListener().onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(testPlayerControl.lastPlayerCommand).isEqualTo(PLAYER_COMMAND_DO_NOT_PLAY); assertThat(Shadows.shadowOf(audioManager).getLastAbandonedAudioFocusRequest()) .isEqualTo(Shadows.shadowOf(audioManager).getLastAudioFocusRequest().audioFocusRequest); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java index f7065fbbc5..b00da4390a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultLoadControlTest.java @@ -47,7 +47,7 @@ public class DefaultLoadControlTest { @Test public void shouldContinueLoading_untilMaxBufferExceeded() { - createDefaultLoadControl(); + build(); assertThat( loadControl.shouldContinueLoading( @@ -68,7 +68,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) .isFalse(); @@ -91,7 +91,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) .isFalse(); @@ -111,7 +111,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); makeSureTargetBufferBytesReached(); assertThat( @@ -132,7 +132,7 @@ public class DefaultLoadControlTest { public void shouldContinueLoading_withTargetBufferBytesReachedAndNotPrioritizeTimeOverSize_returnsTrueAsSoonAsTargetBufferReached() { builder.setPrioritizeTimeOverSizeThresholds(false); - createDefaultLoadControl(); + build(); // Put loadControl in buffering state. assertThat( @@ -162,7 +162,7 @@ public class DefaultLoadControlTest { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); // At normal playback speed, we stop buffering when the buffer reaches the minimum. assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED)) @@ -176,7 +176,7 @@ public class DefaultLoadControlTest { @Test public void shouldNotContinueLoadingWithMaxBufferReached_inFastPlayback() { - createDefaultLoadControl(); + build(); assertThat( loadControl.shouldContinueLoading( @@ -186,7 +186,7 @@ public class DefaultLoadControlTest { @Test public void startsPlayback_whenMinBufferSizeReached() { - createDefaultLoadControl(); + build(); assertThat(loadControl.shouldStartPlayback(MIN_BUFFER_US, SPEED, /* rebuffering= */ false)) .isTrue(); @@ -194,7 +194,7 @@ public class DefaultLoadControlTest { @Test public void shouldContinueLoading_withNoSelectedTracks_returnsTrue() { - loadControl = builder.createDefaultLoadControl(); + loadControl = builder.build(); loadControl.onTracksSelected(new Renderer[0], TrackGroupArray.EMPTY, new TrackSelectionArray()); assertThat( @@ -203,9 +203,9 @@ public class DefaultLoadControlTest { .isTrue(); } - private void createDefaultLoadControl() { + private void build() { builder.setAllocator(allocator).setTargetBufferBytes(TARGET_BUFFER_BYTES); - loadControl = builder.createDefaultLoadControl(); + loadControl = builder.build(); loadControl.onTracksSelected(new Renderer[0], null, null); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/DefaultMediaClockTest.java b/library/core/src/test/java/com/google/android/exoplayer2/DefaultMediaClockTest.java index 217df762f6..867857cbe5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/DefaultMediaClockTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/DefaultMediaClockTest.java @@ -22,7 +22,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.MockitoAnnotations.initMocks; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.DefaultMediaClock.PlaybackSpeedListener; +import com.google.android.exoplayer2.DefaultMediaClock.PlaybackParametersListener; import com.google.android.exoplayer2.testutil.FakeClock; import com.google.android.exoplayer2.testutil.FakeMediaClockRenderer; import org.junit.Before; @@ -36,9 +36,10 @@ public class DefaultMediaClockTest { private static final long TEST_POSITION_US = 123456789012345678L; private static final long SLEEP_TIME_MS = 1_000; - private static final float TEST_PLAYBACK_SPEED = 2f; + private static final PlaybackParameters TEST_PLAYBACK_PARAMETERS = + new PlaybackParameters(/* speed= */ 2f); - @Mock private PlaybackSpeedListener listener; + @Mock private PlaybackParametersListener listener; private FakeClock fakeClock; private DefaultMediaClock mediaClock; @@ -109,44 +110,44 @@ public class DefaultMediaClockTest { } @Test - public void standaloneGetPlaybackSpeed_initializedWithDefaultPlaybackSpeed() { - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(Player.DEFAULT_PLAYBACK_SPEED); + public void standaloneGetPlaybackParameters_initializedWithDefaultPlaybackParameters() { + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(PlaybackParameters.DEFAULT); } @Test - public void standaloneSetPlaybackSpeed_getPlaybackSpeedShouldReturnSameValue() { - mediaClock.setPlaybackSpeed(TEST_PLAYBACK_SPEED); - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(TEST_PLAYBACK_SPEED); + public void standaloneSetPlaybackParameters_getPlaybackParametersShouldReturnSameValue() { + mediaClock.setPlaybackParameters(TEST_PLAYBACK_PARAMETERS); + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(TEST_PLAYBACK_PARAMETERS); } @Test - public void standaloneSetPlaybackSpeed_shouldNotTriggerCallback() { - mediaClock.setPlaybackSpeed(TEST_PLAYBACK_SPEED); + public void standaloneSetPlaybackParameters_shouldNotTriggerCallback() { + mediaClock.setPlaybackParameters(TEST_PLAYBACK_PARAMETERS); verifyNoMoreInteractions(listener); } @Test - public void standaloneSetPlaybackSpeed_shouldApplyNewPlaybackSpeed() { - mediaClock.setPlaybackSpeed(TEST_PLAYBACK_SPEED); + public void standaloneSetPlaybackParameters_shouldApplyNewPlaybackParameters() { + mediaClock.setPlaybackParameters(TEST_PLAYBACK_PARAMETERS); mediaClock.start(); - // Asserts that clock is running with speed declared in getPlaybackSpeed(). + // Asserts that clock is running with speed declared in getPlaybackParameters(). assertClockIsRunning(/* isReadingAhead= */ false); } @Test - public void standaloneSetOtherPlaybackSpeed_getPlaybackSpeedShouldReturnSameValue() { - mediaClock.setPlaybackSpeed(TEST_PLAYBACK_SPEED); - mediaClock.setPlaybackSpeed(Player.DEFAULT_PLAYBACK_SPEED); - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(Player.DEFAULT_PLAYBACK_SPEED); + public void standaloneSetOtherPlaybackParameters_getPlaybackParametersShouldReturnSameValue() { + mediaClock.setPlaybackParameters(TEST_PLAYBACK_PARAMETERS); + mediaClock.setPlaybackParameters(PlaybackParameters.DEFAULT); + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(PlaybackParameters.DEFAULT); } @Test - public void enableRendererMediaClock_shouldOverwriteRendererPlaybackSpeedIfPossible() + public void enableRendererMediaClock_shouldOverwriteRendererPlaybackParametersIfPossible() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(TEST_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ true); + new MediaClockRenderer(TEST_PLAYBACK_PARAMETERS, /* playbackParametersAreMutable= */ true); mediaClock.onRendererEnabled(mediaClockRenderer); - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(Player.DEFAULT_PLAYBACK_SPEED); + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(PlaybackParameters.DEFAULT); verifyNoMoreInteractions(listener); } @@ -154,26 +155,27 @@ public class DefaultMediaClockTest { public void enableRendererMediaClockWithFixedPlaybackSpeed_usesRendererPlaybackSpeed() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(TEST_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ false); + new MediaClockRenderer(TEST_PLAYBACK_PARAMETERS, /* playbackParametersAreMutable= */ false); mediaClock.onRendererEnabled(mediaClockRenderer); - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(TEST_PLAYBACK_SPEED); + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(TEST_PLAYBACK_PARAMETERS); } @Test public void enableRendererMediaClockWithFixedPlaybackSpeed_shouldTriggerCallback() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(TEST_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ false); + new MediaClockRenderer(TEST_PLAYBACK_PARAMETERS, /* playbackParametersAreMutable= */ false); mediaClock.onRendererEnabled(mediaClockRenderer); mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); - verify(listener).onPlaybackSpeedChanged(TEST_PLAYBACK_SPEED); + verify(listener).onPlaybackParametersChanged(TEST_PLAYBACK_PARAMETERS); } @Test public void enableRendererMediaClockWithFixedButSamePlaybackSpeed_shouldNotTriggerCallback() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(Player.DEFAULT_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ false); + new MediaClockRenderer( + PlaybackParameters.DEFAULT, /* playbackParametersAreMutable= */ false); mediaClock.onRendererEnabled(mediaClockRenderer); mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); verifyNoMoreInteractions(listener); @@ -182,44 +184,47 @@ public class DefaultMediaClockTest { @Test public void disableRendererMediaClock_shouldKeepPlaybackSpeed() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(TEST_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ false); + new MediaClockRenderer(TEST_PLAYBACK_PARAMETERS, /* playbackParametersAreMutable= */ false); mediaClock.onRendererEnabled(mediaClockRenderer); mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); mediaClock.onRendererDisabled(mediaClockRenderer); mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(TEST_PLAYBACK_SPEED); + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(TEST_PLAYBACK_PARAMETERS); } @Test - public void rendererClockSetPlaybackSpeed_getPlaybackSpeedShouldReturnSameValue() + public void rendererClockSetPlaybackSpeed_getPlaybackParametersShouldReturnSameValue() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(Player.DEFAULT_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ true); + new MediaClockRenderer( + PlaybackParameters.DEFAULT, /* playbackParametersAreMutable= */ true); mediaClock.onRendererEnabled(mediaClockRenderer); mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); - mediaClock.setPlaybackSpeed(TEST_PLAYBACK_SPEED); - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(TEST_PLAYBACK_SPEED); + mediaClock.setPlaybackParameters(TEST_PLAYBACK_PARAMETERS); + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(TEST_PLAYBACK_PARAMETERS); } @Test public void rendererClockSetPlaybackSpeed_shouldNotTriggerCallback() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(Player.DEFAULT_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ true); + new MediaClockRenderer( + PlaybackParameters.DEFAULT, /* playbackParametersAreMutable= */ true); mediaClock.onRendererEnabled(mediaClockRenderer); mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); - mediaClock.setPlaybackSpeed(TEST_PLAYBACK_SPEED); + mediaClock.setPlaybackParameters(TEST_PLAYBACK_PARAMETERS); verifyNoMoreInteractions(listener); } @Test - public void rendererClockSetPlaybackSpeedOverwrite_getPlaybackSpeedShouldReturnSameValue() + public void rendererClockSetPlaybackSpeedOverwrite_getPlaybackParametersShouldReturnSameValue() throws ExoPlaybackException { FakeMediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(Player.DEFAULT_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ false); + new MediaClockRenderer( + PlaybackParameters.DEFAULT, /* playbackParametersAreMutable= */ false); mediaClock.onRendererEnabled(mediaClockRenderer); mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); - mediaClock.setPlaybackSpeed(TEST_PLAYBACK_SPEED); - assertThat(mediaClock.getPlaybackSpeed()).isEqualTo(Player.DEFAULT_PLAYBACK_SPEED); + mediaClock.setPlaybackParameters(TEST_PLAYBACK_PARAMETERS); + assertThat(mediaClock.getPlaybackParameters()).isEqualTo(PlaybackParameters.DEFAULT); } @Test @@ -266,12 +271,13 @@ public class DefaultMediaClockTest { public void getPositionWithPlaybackSpeedChange_shouldTriggerCallback() throws ExoPlaybackException { MediaClockRenderer mediaClockRenderer = - new MediaClockRenderer(Player.DEFAULT_PLAYBACK_SPEED, /* playbackSpeedIsMutable= */ true); + new MediaClockRenderer( + PlaybackParameters.DEFAULT, /* playbackParametersAreMutable= */ true); mediaClock.onRendererEnabled(mediaClockRenderer); // Silently change playback speed of renderer clock. - mediaClockRenderer.playbackSpeed = TEST_PLAYBACK_SPEED; + mediaClockRenderer.playbackParameters = TEST_PLAYBACK_PARAMETERS; mediaClock.syncAndGetPositionUs(/* isReadingAhead= */ false); - verify(listener).onPlaybackSpeedChanged(TEST_PLAYBACK_SPEED); + verify(listener).onPlaybackParametersChanged(TEST_PLAYBACK_PARAMETERS); } @Test @@ -356,7 +362,7 @@ public class DefaultMediaClockTest { private void assertClockIsRunning(boolean isReadingAhead) { long clockStartUs = mediaClock.syncAndGetPositionUs(isReadingAhead); fakeClock.advanceTime(SLEEP_TIME_MS); - int scaledUsPerMs = Math.round(mediaClock.getPlaybackSpeed() * 1000f); + int scaledUsPerMs = Math.round(mediaClock.getPlaybackParameters().speed * 1000f); assertThat(mediaClock.syncAndGetPositionUs(isReadingAhead)) .isEqualTo(clockStartUs + (SLEEP_TIME_MS * scaledUsPerMs)); } @@ -371,37 +377,53 @@ public class DefaultMediaClockTest { @SuppressWarnings("HidingField") private static class MediaClockRenderer extends FakeMediaClockRenderer { - private final boolean playbackSpeedIsMutable; + private final boolean playbackParametersAreMutable; private final boolean isReady; private final boolean isEnded; - public float playbackSpeed; + public PlaybackParameters playbackParameters; public long positionUs; public MediaClockRenderer() throws ExoPlaybackException { - this(Player.DEFAULT_PLAYBACK_SPEED, false, true, false, false); + this( + PlaybackParameters.DEFAULT, + /* playbackParametersAreMutable= */ false, + /* isReady= */ true, + /* isEnded= */ false, + /* hasReadStreamToEnd= */ false); } - public MediaClockRenderer(float playbackSpeed, boolean playbackSpeedIsMutable) + public MediaClockRenderer( + PlaybackParameters playbackParameters, boolean playbackParametersAreMutable) throws ExoPlaybackException { - this(playbackSpeed, playbackSpeedIsMutable, true, false, false); + this( + playbackParameters, + playbackParametersAreMutable, + /* isReady= */ true, + /* isEnded= */ false, + /* hasReadStreamToEnd= */ false); } public MediaClockRenderer(boolean isReady, boolean isEnded, boolean hasReadStreamToEnd) throws ExoPlaybackException { - this(Player.DEFAULT_PLAYBACK_SPEED, false, isReady, isEnded, hasReadStreamToEnd); + this( + PlaybackParameters.DEFAULT, + /* playbackParametersAreMutable= */ false, + isReady, + isEnded, + hasReadStreamToEnd); } private MediaClockRenderer( - float playbackSpeed, - boolean playbackSpeedIsMutable, + PlaybackParameters playbackParameters, + boolean playbackParametersAreMutable, boolean isReady, boolean isEnded, boolean hasReadStreamToEnd) throws ExoPlaybackException { super(C.TRACK_TYPE_UNKNOWN); - this.playbackSpeed = playbackSpeed; - this.playbackSpeedIsMutable = playbackSpeedIsMutable; + this.playbackParameters = playbackParameters; + this.playbackParametersAreMutable = playbackParametersAreMutable; this.isReady = isReady; this.isEnded = isEnded; this.positionUs = TEST_POSITION_US; @@ -416,15 +438,15 @@ public class DefaultMediaClockTest { } @Override - public void setPlaybackSpeed(float playbackSpeed) { - if (playbackSpeedIsMutable) { - this.playbackSpeed = playbackSpeed; + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + if (playbackParametersAreMutable) { + this.playbackParameters = playbackParameters; } } @Override - public float getPlaybackSpeed() { - return playbackSpeed; + public PlaybackParameters getPlaybackParameters() { + return playbackParameters; } @Override diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 770416bb4c..444640256f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -15,13 +15,24 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.playUntilStartOfWindow; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilPlaybackState; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilReceiveOffloadSchedulingEnabledNewState; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilTimelineChanged; +import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; 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 static org.robolectric.Shadows.shadowOf; @@ -30,9 +41,9 @@ import android.content.Context; import android.content.Intent; import android.graphics.SurfaceTexture; import android.media.AudioManager; +import android.net.Uri; import android.os.Looper; import android.view.Surface; -import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; @@ -42,6 +53,8 @@ import com.google.android.exoplayer2.Player.EventListener; import com.google.android.exoplayer2.Timeline.Window; import com.google.android.exoplayer2.analytics.AnalyticsListener; import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.ClippingMediaSource; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; @@ -50,13 +63,15 @@ import com.google.android.exoplayer2.source.MaskingMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.SilenceMediaSource; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource; +import com.google.android.exoplayer2.testutil.Action; import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerTarget; @@ -76,7 +91,9 @@ import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.testutil.FakeTrackSelection; import com.google.android.exoplayer2.testutil.FakeTrackSelector; +import com.google.android.exoplayer2.testutil.NoUidTimeline; import com.google.android.exoplayer2.testutil.TestExoPlayer; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocation; @@ -86,7 +103,7 @@ import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; -import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -94,20 +111,23 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatcher; +import org.mockito.InOrder; +import org.mockito.Mockito; import org.robolectric.shadows.ShadowAudioManager; /** Unit test for {@link ExoPlayer}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class ExoPlayerTest { private static final String TAG = "ExoPlayerTest"; @@ -117,15 +137,17 @@ public final class ExoPlayerTest { * milliseconds after starting the player before the test will time out. This is to catch cases * where the player under test is not making progress, in which case the test should fail. */ - private static final int TIMEOUT_MS = 10000; + private static final int TIMEOUT_MS = 10_000; private Context context; - private Timeline dummyTimeline; + private Timeline placeholderTimeline; @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); - dummyTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ 0); + placeholderTimeline = + new MaskingMediaSource.PlaceholderTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(0).build()); } /** @@ -135,20 +157,30 @@ public final class ExoPlayerTest { @Test public void playEmptyTimeline() throws Exception { Timeline timeline = Timeline.EMPTY; - Timeline expectedMaskingTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); + Timeline expectedMaskingTimeline = + new MaskingMediaSource.PlaceholderTimeline(FakeMediaSource.FAKE_MEDIA_ITEM); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_UNKNOWN); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setRenderers(renderer) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertNoPositionDiscontinuities(); - testRunner.assertTimelinesSame(expectedMaskingTimeline, Timeline.EMPTY); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + EventListener mockEventListener = mock(EventListener.class); + player.addListener(mockEventListener); + + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + InOrder inOrder = inOrder(mockEventListener); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(expectedMaskingTimeline)), + eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(timeline)), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); + inOrder.verify(mockEventListener, never()).onPositionDiscontinuity(anyInt()); assertThat(renderer.getFormatsRead()).isEmpty(); assertThat(renderer.sampleBufferReadCount).isEqualTo(0); assertThat(renderer.isEnded).isFalse(); @@ -157,24 +189,32 @@ public final class ExoPlayerTest { /** Tests playback of a source that exposes a single period. */ @Test public void playSinglePeriodTimeline() throws Exception { - Object manifest = new Object(); - Timeline timeline = new FakeTimeline(/* windowCount= */ 1, manifest); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setManifest(manifest) - .setRenderers(renderer) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertNoPositionDiscontinuities(); - testRunner.assertTimelinesSame(dummyTimeline, timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); - testRunner.assertTrackGroupsEqual( - new TrackGroupArray(new TrackGroup(ExoPlayerTestRunner.VIDEO_FORMAT))); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + EventListener mockEventListener = mock(EventListener.class); + player.addListener(mockEventListener); + + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + InOrder inOrder = Mockito.inOrder(mockEventListener); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(placeholderTimeline)), + eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(timeline)), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); + inOrder + .verify(mockEventListener) + .onTracksChanged( + eq(new TrackGroupArray(new TrackGroup(ExoPlayerTestRunner.VIDEO_FORMAT))), any()); + inOrder.verify(mockEventListener, never()).onPositionDiscontinuity(anyInt()); assertThat(renderer.getFormatsRead()).containsExactly(ExoPlayerTestRunner.VIDEO_FORMAT); assertThat(renderer.sampleBufferReadCount).isEqualTo(1); assertThat(renderer.isEnded).isTrue(); @@ -185,20 +225,28 @@ public final class ExoPlayerTest { public void playMultiPeriodTimeline() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 3); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setRenderers(renderer) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertPositionDiscontinuityReasonsEqual( - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesSame(new FakeMediaSource.InitialTimeline(timeline), timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + EventListener mockEventListener = mock(EventListener.class); + player.addListener(mockEventListener); + + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + InOrder inOrder = Mockito.inOrder(mockEventListener); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(new FakeMediaSource.InitialTimeline(timeline))), + eq(Player.DISCONTINUITY_REASON_PERIOD_TRANSITION)); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(timeline)), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); + inOrder + .verify(mockEventListener, times(2)) + .onPositionDiscontinuity(Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); assertThat(renderer.getFormatsRead()) .containsExactly( ExoPlayerTestRunner.VIDEO_FORMAT, @@ -215,20 +263,28 @@ public final class ExoPlayerTest { Timeline timeline = new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 100, /* id= */ 0)); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setRenderers(renderer) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - Integer[] expectedReasons = new Integer[99]; - Arrays.fill(expectedReasons, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertPositionDiscontinuityReasonsEqual(expectedReasons); - testRunner.assertTimelinesSame(dummyTimeline, timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + EventListener mockEventListener = mock(EventListener.class); + player.addListener(mockEventListener); + + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + InOrder inOrder = inOrder(mockEventListener); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(placeholderTimeline)), + eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(timeline)), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); + inOrder + .verify(mockEventListener, times(99)) + .onPositionDiscontinuity(Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); assertThat(renderer.getFormatsRead()).hasSize(100); assertThat(renderer.sampleBufferReadCount).isEqualTo(100); assertThat(renderer.isEnded).isTrue(); @@ -266,7 +322,6 @@ public final class ExoPlayerTest { final FakeRenderer videoRenderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); FakeMediaClockRenderer audioRenderer = new FakeMediaClockRenderer(C.TRACK_TYPE_AUDIO) { - @Override public long getPositionUs() { // Simulate the playback position lagging behind the reading position: the renderer @@ -277,11 +332,11 @@ public final class ExoPlayerTest { } @Override - public void setPlaybackSpeed(float playbackSpeed) {} + public void setPlaybackParameters(PlaybackParameters playbackParameters) {} @Override - public float getPlaybackSpeed() { - return Player.DEFAULT_PLAYBACK_SPEED; + public PlaybackParameters getPlaybackParameters() { + return PlaybackParameters.DEFAULT; } @Override @@ -289,21 +344,31 @@ public final class ExoPlayerTest { return videoRenderer.isEnded(); } }; - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setRenderers(videoRenderer, audioRenderer) - .setSupportedFormats(ExoPlayerTestRunner.VIDEO_FORMAT, ExoPlayerTestRunner.AUDIO_FORMAT) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertPositionDiscontinuityReasonsEqual( - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesSame(new FakeMediaSource.InitialTimeline(timeline), timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + SimpleExoPlayer player = + new TestExoPlayer.Builder(context).setRenderers(videoRenderer, audioRenderer).build(); + EventListener mockEventListener = mock(EventListener.class); + player.addListener(mockEventListener); + + player.setMediaSource( + new FakeMediaSource( + timeline, ExoPlayerTestRunner.VIDEO_FORMAT, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + InOrder inOrder = inOrder(mockEventListener); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(new FakeMediaSource.InitialTimeline(timeline))), + eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(timeline)), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); + inOrder + .verify(mockEventListener, times(2)) + .onPositionDiscontinuity(Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); assertThat(audioRenderer.positionResetCount).isEqualTo(1); assertThat(videoRenderer.isEnded).isTrue(); assertThat(audioRenderer.isEnded).isTrue(); @@ -315,77 +380,62 @@ public final class ExoPlayerTest { Timeline firstTimeline = new FakeTimeline( new TimelineWindowDefinition( - /* isSeekable= */ true, /* isDynamic= */ false, 1000_000_000)); + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 1_000_000_000)); MediaSource firstSource = new FakeMediaSource(firstTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); - final CountDownLatch queuedSourceInfoCountDownLatch = new CountDownLatch(1); - final CountDownLatch completePreparationCountDownLatch = new CountDownLatch(1); - - Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + AtomicBoolean secondSourcePrepared = new AtomicBoolean(); MediaSource secondSource = - new FakeMediaSource(secondTimeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) { @Override public synchronized void prepareSourceInternal( @Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); - // We've queued a source info refresh on the playback thread's event queue. Allow the - // test thread to set the third source to the playlist, and block this thread (the - // playback thread) until the test thread's call to setMediaSources() has returned. - queuedSourceInfoCountDownLatch.countDown(); - try { - completePreparationCountDownLatch.await(); - } catch (InterruptedException e) { - throw new IllegalStateException(e); - } + secondSourcePrepared.set(true); } }; - Object thirdSourceManifest = new Object(); - Timeline thirdTimeline = new FakeTimeline(/* windowCount= */ 1, thirdSourceManifest); + Timeline thirdTimeline = new FakeTimeline(/* windowCount= */ 1); MediaSource thirdSource = new FakeMediaSource(thirdTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + EventListener mockEventListener = mock(EventListener.class); + player.addListener(mockEventListener); + + player.setMediaSource(firstSource); + player.prepare(); + player.play(); + runUntilTimelineChanged(player); + player.setMediaSource(secondSource); + runMainLooperUntil(secondSourcePrepared::get); + player.setMediaSource(thirdSource); + runUntilPlaybackState(player, Player.STATE_ENDED); - // Prepare the player with a source with the first manifest and a non-empty timeline. Prepare - // the player again with a source and a new manifest, which will never be exposed. Allow the - // test thread to set a third source, and block the playback thread until the test thread's call - // to setMediaSources() has returned. - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .waitForTimelineChanged( - firstTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) - .setMediaSources(secondSource) - .executeRunnable( - () -> { - try { - queuedSourceInfoCountDownLatch.await(); - } catch (InterruptedException e) { - // Ignore. - } - }) - .setMediaSources(thirdSource) - .executeRunnable(completePreparationCountDownLatch::countDown) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(firstSource) - .setRenderers(renderer) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertNoPositionDiscontinuities(); // The first source's preparation completed with a real timeline. When the second source was - // prepared, it immediately exposed a dummy timeline, but the source info refresh from the + // prepared, it immediately exposed a placeholder timeline, but the source info refresh from the // second source was suppressed as we replace it with the third source before the update // arrives. - testRunner.assertTimelinesSame( - dummyTimeline, firstTimeline, dummyTimeline, dummyTimeline, thirdTimeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); - testRunner.assertTrackGroupsEqual( - new TrackGroupArray(new TrackGroup(ExoPlayerTestRunner.VIDEO_FORMAT))); + InOrder inOrder = inOrder(mockEventListener); + inOrder.verify(mockEventListener, never()).onPositionDiscontinuity(anyInt()); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(placeholderTimeline)), + eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(firstTimeline)), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); + inOrder + .verify(mockEventListener, times(2)) + .onTimelineChanged( + argThat(noUid(placeholderTimeline)), + eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockEventListener) + .onTimelineChanged( + argThat(noUid(thirdTimeline)), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); + inOrder + .verify(mockEventListener) + .onTracksChanged( + eq(new TrackGroupArray(new TrackGroup(ExoPlayerTestRunner.VIDEO_FORMAT))), any()); assertThat(renderer.isEnded).isTrue(); } @@ -393,49 +443,41 @@ public final class ExoPlayerTest { public void repeatModeChanges() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 3); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForTimelineChanged( - timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) - .playUntilStartOfWindow(/* windowIndex= */ 1) - .setRepeatMode(Player.REPEAT_MODE_ONE) - .playUntilStartOfWindow(/* windowIndex= */ 1) - .setRepeatMode(Player.REPEAT_MODE_OFF) - .playUntilStartOfWindow(/* windowIndex= */ 2) - .setRepeatMode(Player.REPEAT_MODE_ONE) - .playUntilStartOfWindow(/* windowIndex= */ 2) - .setRepeatMode(Player.REPEAT_MODE_ALL) - .playUntilStartOfWindow(/* windowIndex= */ 0) - .setRepeatMode(Player.REPEAT_MODE_ONE) - .playUntilStartOfWindow(/* windowIndex= */ 0) - .playUntilStartOfWindow(/* windowIndex= */ 0) - .setRepeatMode(Player.REPEAT_MODE_OFF) - .play() - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setRenderers(renderer) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertPlayedPeriodIndices(0, 1, 1, 2, 2, 0, 0, 0, 1, 2); - testRunner.assertPositionDiscontinuityReasonsEqual( - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesSame(new FakeMediaSource.InitialTimeline(timeline), timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + AnalyticsListener mockAnalyticsListener = mock(AnalyticsListener.class); + player.addAnalyticsListener(mockAnalyticsListener); + + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + runUntilTimelineChanged(player); + playUntilStartOfWindow(player, /* windowIndex= */ 1); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + playUntilStartOfWindow(player, /* windowIndex= */ 1); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + playUntilStartOfWindow(player, /* windowIndex= */ 2); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + playUntilStartOfWindow(player, /* windowIndex= */ 2); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + playUntilStartOfWindow(player, /* windowIndex= */ 0); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + playUntilStartOfWindow(player, /* windowIndex= */ 0); + playUntilStartOfWindow(player, /* windowIndex= */ 0); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + playUntilStartOfWindow(player, /* windowIndex= */ 1); + playUntilStartOfWindow(player, /* windowIndex= */ 2); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + ArgumentCaptor eventTimes = + ArgumentCaptor.forClass(AnalyticsListener.EventTime.class); + verify(mockAnalyticsListener, times(10)) + .onMediaItemTransition(eventTimes.capture(), any(), anyInt()); + assertThat( + eventTimes.getAllValues().stream() + .map(eventTime -> eventTime.currentWindowIndex) + .collect(Collectors.toList())) + .containsExactly(0, 1, 1, 2, 2, 0, 0, 0, 1, 2) + .inOrder(); assertThat(renderer.isEnded).isTrue(); } @@ -484,7 +526,7 @@ public final class ExoPlayerTest { public void adGroupWithLoadErrorIsSkipped() throws Exception { AdPlaybackState initialAdPlaybackState = FakeTimeline.createAdPlaybackState( - /* adsPerAdGroup= */ 1, /* adGroupTimesUs=... */ + /* adsPerAdGroup= */ 1, /* adGroupTimesUs...= */ TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + 5 * C.MICROS_PER_SECOND); Timeline fakeTimeline = @@ -494,7 +536,11 @@ public final class ExoPlayerTest { /* id= */ 0, /* isSeekable= */ true, /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, /* durationUs= */ C.MICROS_PER_SECOND, + /* defaultPositionUs= */ 0, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, initialAdPlaybackState)); AdPlaybackState errorAdPlaybackState = initialAdPlaybackState.withAdLoadError(0, 0); final Timeline adErrorTimeline = @@ -504,7 +550,11 @@ public final class ExoPlayerTest { /* id= */ 0, /* isSeekable= */ true, /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, /* durationUs= */ C.MICROS_PER_SECOND, + /* defaultPositionUs= */ 0, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, errorAdPlaybackState)); final FakeMediaSource fakeMediaSource = new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); @@ -600,9 +650,18 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false); mediaPeriod.setSeekToUsOffset(10); return mediaPeriod; } @@ -635,9 +694,15 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher); mediaPeriod.setDiscontinuityPositionUs(10); return mediaPeriod; } @@ -653,7 +718,7 @@ public final class ExoPlayerTest { @Test public void internalDiscontinuityAtInitialPosition() throws Exception { - FakeTimeline timeline = new FakeTimeline(1); + FakeTimeline timeline = new FakeTimeline(/* windowCount= */ 1); FakeMediaSource mediaSource = new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) { @Override @@ -661,11 +726,18 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - FakeMediaPeriod mediaPeriod = new FakeMediaPeriod(trackGroupArray, eventDispatcher); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher); + // Set a discontinuity at the position this period is supposed to start at anyway. mediaPeriod.setDiscontinuityPositionUs( - TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US); + timeline.getWindow(/* windowIndex= */ 0, new Window()).positionInFirstPeriodUs); return mediaPeriod; } }; @@ -842,7 +914,7 @@ public final class ExoPlayerTest { .build() .start() .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(dummyTimeline, timeline, timeline2); + testRunner.assertTimelinesSame(placeholderTimeline, timeline, timeline2); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, @@ -891,7 +963,7 @@ public final class ExoPlayerTest { } @Test - public void setPlaybackParametersBeforePreparationCompletesSucceeds() throws Exception { + public void setPlaybackSpeedBeforePreparationCompletesSucceeds() throws Exception { // Test that no exception is thrown when playback parameters are updated between creating a // period and preparation of the period completing. final CountDownLatch createPeriodCalledCountDownLatch = new CountDownLatch(1); @@ -904,11 +976,19 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { // Defer completing preparation of the period until playback parameters have been set. fakeMediaPeriodHolder[0] = - new FakeMediaPeriod(trackGroupArray, eventDispatcher, /* deferOnPrepared= */ true); + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ true); createPeriodCalledCountDownLatch.countDown(); return fakeMediaPeriodHolder[0]; } @@ -926,7 +1006,7 @@ public final class ExoPlayerTest { } }) // Set playback speed (while the fake media period is not yet prepared). - .setPlaybackSpeed(2f) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f)) // Complete preparation of the fake media period. .executeRunnable(() -> fakeMediaPeriodHolder[0].setPreparationComplete()) .build(); @@ -950,11 +1030,19 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { // Defer completing preparation of the period until seek has been sent. fakeMediaPeriodHolder[0] = - new FakeMediaPeriod(trackGroupArray, eventDispatcher, /* deferOnPrepared= */ true); + new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ true); createPeriodCalledCountDownLatch.countDown(); return fakeMediaPeriodHolder[0]; } @@ -1003,131 +1091,172 @@ public final class ExoPlayerTest { } @Test - public void stopDoesNotResetPosition() throws Exception { + public void stop_withoutReset_doesNotResetPosition_correctMasking() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final long[] positionHolder = new long[1]; + int[] currentWindowIndex = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDuration = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) .waitForPlaybackState(Player.STATE_READY) - .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 50) - .stop() .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - positionHolder[0] = player.getCurrentPosition(); + currentWindowIndex[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + player.stop(/* reset= */ false); + currentWindowIndex[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .waitForPlaybackState(Player.STATE_IDLE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[2] = player.getCurrentWindowIndex(); + currentPosition[2] = player.getCurrentPosition(); + bufferedPosition[2] = player.getBufferedPosition(); + totalBufferedDuration[2] = player.getTotalBufferedDuration(); } }) .build(); + ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) + .setMediaSources(mediaSource, mediaSource) .setActionSchedule(actionSchedule) .build() .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(dummyTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); - testRunner.assertNoPositionDiscontinuities(); - assertThat(positionHolder[0]).isAtLeast(50L); + testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); + + assertThat(currentWindowIndex[0]).isEqualTo(1); + assertThat(currentPosition[0]).isEqualTo(1000); + assertThat(bufferedPosition[0]).isEqualTo(10000); + assertThat(totalBufferedDuration[0]).isEqualTo(9000); + + assertThat(currentWindowIndex[1]).isEqualTo(1); + assertThat(currentPosition[1]).isEqualTo(1000); + assertThat(bufferedPosition[1]).isEqualTo(1000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + + assertThat(currentWindowIndex[2]).isEqualTo(1); + assertThat(currentPosition[2]).isEqualTo(1000); + assertThat(bufferedPosition[2]).isEqualTo(1000); + assertThat(totalBufferedDuration[2]).isEqualTo(0); } @Test - public void stopWithoutResetDoesNotResetPosition() throws Exception { + public void stop_withoutReset_releasesMediaSource() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final long[] positionHolder = new long[1]; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) - .pause() .waitForPlaybackState(Player.STATE_READY) - .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 50) .stop(/* reset= */ false) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - positionHolder[0] = player.getCurrentPosition(); - } - }) .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(dummyTimeline, timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); - testRunner.assertNoPositionDiscontinuities(); - assertThat(positionHolder[0]).isAtLeast(50L); + + new ExoPlayerTestRunner.Builder(context) + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + mediaSource.assertReleased(); } @Test - public void stopWithResetDoesResetPosition() throws Exception { + public void stop_withReset_doesResetPosition_correctMasking() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final long[] positionHolder = new long[1]; + int[] currentWindowIndex = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDuration = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) .waitForPlaybackState(Player.STATE_READY) - .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 50) - .stop(/* reset= */ true) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - positionHolder[0] = player.getCurrentPosition(); + currentWindowIndex[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + player.stop(/* reset= */ true); + currentWindowIndex[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .waitForPlaybackState(Player.STATE_IDLE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[2] = player.getCurrentWindowIndex(); + currentPosition[2] = player.getCurrentPosition(); + bufferedPosition[2] = player.getBufferedPosition(); + totalBufferedDuration[2] = player.getTotalBufferedDuration(); } }) .build(); + ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) + .setMediaSources(mediaSource, mediaSource) .setActionSchedule(actionSchedule) .build() .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(dummyTimeline, timeline, Timeline.EMPTY); + .blockUntilActionScheduleFinished(TIMEOUT_MS); + testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); - testRunner.assertNoPositionDiscontinuities(); - assertThat(positionHolder[0]).isEqualTo(0); + testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); + + assertThat(currentWindowIndex[0]).isEqualTo(1); + assertThat(currentPosition[0]).isGreaterThan(0); + assertThat(bufferedPosition[0]).isEqualTo(10000); + assertThat(totalBufferedDuration[0]).isEqualTo(10000 - currentPosition[0]); + + assertThat(currentWindowIndex[1]).isEqualTo(0); + assertThat(currentPosition[1]).isEqualTo(0); + assertThat(bufferedPosition[1]).isEqualTo(0); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + + assertThat(currentWindowIndex[2]).isEqualTo(0); + assertThat(currentPosition[2]).isEqualTo(0); + assertThat(bufferedPosition[2]).isEqualTo(0); + assertThat(totalBufferedDuration[2]).isEqualTo(0); } @Test - public void stopWithoutResetReleasesMediaSource() throws Exception { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final FakeMediaSource mediaSource = - new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .waitForPlaybackState(Player.STATE_READY) - .stop(/* reset= */ false) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS); - mediaSource.assertReleased(); - testRunner.blockUntilEnded(TIMEOUT_MS); - } - - @Test - public void stopWithResetReleasesMediaSource() throws Exception { + public void stop_withReset_releasesMediaSource() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); final FakeMediaSource mediaSource = new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); @@ -1136,15 +1265,81 @@ public final class ExoPlayerTest { .waitForPlaybackState(Player.STATE_READY) .stop(/* reset= */ true) .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS); + + new ExoPlayerTestRunner.Builder(context) + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + mediaSource.assertReleased(); - testRunner.blockUntilEnded(TIMEOUT_MS); + } + + @Test + public void release_correctMasking() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + int[] currentWindowIndex = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDuration = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final FakeMediaSource mediaSource = + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + player.release(); + currentWindowIndex[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .waitForPlaybackState(Player.STATE_IDLE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndex[2] = player.getCurrentWindowIndex(); + currentPosition[2] = player.getCurrentPosition(); + bufferedPosition[2] = player.getBufferedPosition(); + totalBufferedDuration[2] = player.getTotalBufferedDuration(); + } + }) + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource, mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS); + + assertThat(currentWindowIndex[0]).isEqualTo(1); + assertThat(currentPosition[0]).isGreaterThan(0); + assertThat(bufferedPosition[0]).isEqualTo(10000); + assertThat(totalBufferedDuration[0]).isEqualTo(10000 - currentPosition[0]); + + assertThat(currentWindowIndex[1]).isEqualTo(1); + assertThat(currentPosition[1]).isEqualTo(currentPosition[0]); + assertThat(bufferedPosition[1]).isEqualTo(1000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + + assertThat(currentWindowIndex[2]).isEqualTo(1); + assertThat(currentPosition[2]).isEqualTo(currentPosition[0]); + assertThat(bufferedPosition[2]).isEqualTo(1000); + assertThat(totalBufferedDuration[2]).isEqualTo(0); } @Test @@ -1189,7 +1384,7 @@ public final class ExoPlayerTest { Player.STATE_READY, Player.STATE_ENDED); testRunner.assertTimelinesSame( - dummyTimeline, + placeholderTimeline, timeline, Timeline.EMPTY, new FakeMediaSource.InitialTimeline(secondTimeline), @@ -1212,13 +1407,15 @@ public final class ExoPlayerTest { new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); Timeline firstExpectedMaskingTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + new MaskingMediaSource.PlaceholderTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(firstWindowId).build()); Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); Timeline secondExpectedMaskingTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + new MaskingMediaSource.PlaceholderTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(secondWindowId).build()); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = @@ -1263,14 +1460,16 @@ public final class ExoPlayerTest { Timeline timeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); - Timeline firstExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Timeline firstExpectedPlaceholderTimeline = + new MaskingMediaSource.PlaceholderTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(firstWindowId).build()); Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); - Timeline secondExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + Timeline secondExpectedPlaceholderTimeline = + new MaskingMediaSource.PlaceholderTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(secondWindowId).build()); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = @@ -1300,7 +1499,10 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); testRunner.assertTimelinesSame( - firstExpectedDummyTimeline, timeline, secondExpectedDummyTimeline, secondTimeline); + firstExpectedPlaceholderTimeline, + timeline, + secondExpectedPlaceholderTimeline, + secondTimeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, @@ -1315,14 +1517,16 @@ public final class ExoPlayerTest { Timeline timeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); - Timeline firstExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Timeline firstExpectedPlaceholderTimeline = + new MaskingMediaSource.PlaceholderTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(firstWindowId).build()); Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); - Timeline secondExpectedDummyTimeline = - new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + Timeline secondExpectedPlaceholderTimeline = + new MaskingMediaSource.PlaceholderTimeline( + FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(secondWindowId).build()); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = @@ -1352,7 +1556,10 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); testRunner.assertTimelinesSame( - firstExpectedDummyTimeline, timeline, secondExpectedDummyTimeline, secondTimeline); + firstExpectedPlaceholderTimeline, + timeline, + secondExpectedPlaceholderTimeline, + secondTimeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, @@ -1378,7 +1585,7 @@ public final class ExoPlayerTest { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(dummyTimeline, Timeline.EMPTY); + testRunner.assertTimelinesSame(placeholderTimeline, Timeline.EMPTY); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); @@ -1404,7 +1611,7 @@ public final class ExoPlayerTest { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelinesSame(placeholderTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); @@ -1432,7 +1639,7 @@ public final class ExoPlayerTest { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelinesSame(placeholderTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); @@ -1481,7 +1688,7 @@ public final class ExoPlayerTest { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS)); - testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelinesSame(placeholderTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); @@ -1710,8 +1917,17 @@ public final class ExoPlayerTest { AnalyticsListener listener = new AnalyticsListener() { @Override - public void onPlayerStateChanged( - EventTime eventTime, boolean playWhenReady, int playbackState) { + public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) { + if (eventTime.mediaPeriodId != null) { + reportedWindowSequenceNumbers.add(eventTime.mediaPeriodId.windowSequenceNumber); + } + } + + @Override + public void onPlayWhenReadyChanged( + EventTime eventTime, + boolean playWhenReady, + @Player.PlayWhenReadyChangeReason int reason) { if (eventTime.mediaPeriodId != null) { reportedWindowSequenceNumbers.add(eventTime.mediaPeriodId.windowSequenceNumber); } @@ -1760,7 +1976,7 @@ public final class ExoPlayerTest { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesSame(dummyTimeline, timeline, dummyTimeline, timeline); + testRunner.assertTimelinesSame(placeholderTimeline, timeline, placeholderTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, @@ -1842,9 +2058,7 @@ public final class ExoPlayerTest { .waitForTimelineChanged() .pause() .sendMessage( - (messageType, payload) -> { - counter.getAndIncrement(); - }, + (messageType, payload) -> counter.getAndIncrement(), /* windowIndex= */ 0, /* positionMs= */ 2000, /* deleteAfterDelivery= */ false) @@ -2340,7 +2554,7 @@ public final class ExoPlayerTest { // indices are non-zero. player.prepare(); player.play(); - TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + runUntilPlaybackState(player, Player.STATE_ENDED); verify(secondMediaItemTarget).handleMessage(anyInt(), any()); // Remove first item and play second item again to check if message is triggered again. @@ -2349,7 +2563,7 @@ public final class ExoPlayerTest { // See https://github.com/google/ExoPlayer/issues/7278. player.removeMediaItem(/* index= */ 0); player.seekTo(/* positionMs= */ 0); - TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + runUntilPlaybackState(player, Player.STATE_ENDED); assertThat(player.getPlayerError()).isNull(); verify(secondMediaItemTarget, times(2)).handleMessage(anyInt(), any()); @@ -2374,7 +2588,7 @@ public final class ExoPlayerTest { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - assertThat(Collections.frequency(rendererMessages, C.MSG_SET_SURFACE)).isEqualTo(2); + assertThat(Collections.frequency(rendererMessages, Renderer.MSG_SET_SURFACE)).isEqualTo(2); } @Test @@ -2880,87 +3094,6 @@ public final class ExoPlayerTest { .isEqualTo(ExoPlayerTestRunner.AUDIO_FORMAT); } - @Test - public void secondMediaSourceInPlaylistOnlyThrowsWhenPreviousPeriodIsFullyRead() - throws Exception { - Timeline fakeTimeline = - new FakeTimeline( - new TimelineWindowDefinition( - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs= */ 10 * C.MICROS_PER_SECOND)); - MediaSource workingMediaSource = - new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); - MediaSource failingMediaSource = - new FakeMediaSource(/* timeline= */ null, ExoPlayerTestRunner.VIDEO_FORMAT) { - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - throw new IOException(); - } - }; - ConcatenatingMediaSource concatenatingMediaSource = - new ConcatenatingMediaSource(workingMediaSource, failingMediaSource); - FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(concatenatingMediaSource) - .setRenderers(renderer) - .build(); - try { - testRunner.start().blockUntilEnded(TIMEOUT_MS); - fail(); - } catch (ExoPlaybackException e) { - // Expected exception. - } - assertThat(renderer.sampleBufferReadCount).isAtLeast(1); - assertThat(renderer.hasReadStreamToEnd()).isTrue(); - } - - @Test - public void - testDynamicallyAddedSecondMediaSourceInPlaylistOnlyThrowsWhenPreviousPeriodIsFullyRead() - throws Exception { - Timeline fakeTimeline = - new FakeTimeline( - new TimelineWindowDefinition( - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs= */ 10 * C.MICROS_PER_SECOND)); - MediaSource workingMediaSource = - new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); - MediaSource failingMediaSource = - new FakeMediaSource(/* timeline= */ null, ExoPlayerTestRunner.VIDEO_FORMAT) { - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - throw new IOException(); - } - }; - ConcatenatingMediaSource concatenatingMediaSource = - new ConcatenatingMediaSource(workingMediaSource); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForPlaybackState(Player.STATE_READY) - .executeRunnable(() -> concatenatingMediaSource.addMediaSource(failingMediaSource)) - .play() - .build(); - FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(concatenatingMediaSource) - .setActionSchedule(actionSchedule) - .setRenderers(renderer) - .build(); - try { - testRunner.start().blockUntilEnded(TIMEOUT_MS); - fail(); - } catch (ExoPlaybackException e) { - // Expected exception. - } - assertThat(renderer.sampleBufferReadCount).isAtLeast(1); - assertThat(renderer.hasReadStreamToEnd()).isTrue(); - } - @Test public void removingLoopingLastPeriodFromPlaylistDoesNotThrow() throws Exception { Timeline timeline = @@ -3231,23 +3364,37 @@ public final class ExoPlayerTest { } @Test - public void setPlaybackParametersConsecutivelyNotifiesListenerForEveryChangeOnce() + public void setPlaybackSpeedConsecutivelyNotifiesListenerForEveryChangeOnceAndIsMasked() throws Exception { + List maskedPlaybackSpeeds = new ArrayList<>(); + Action getPlaybackSpeedAction = + new Action("getPlaybackSpeed", /* description= */ null) { + @Override + protected void doActionImpl( + SimpleExoPlayer player, + DefaultTrackSelector trackSelector, + @Nullable Surface surface) { + maskedPlaybackSpeeds.add(player.getPlaybackParameters().speed); + } + }; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() .waitForPlaybackState(Player.STATE_READY) - .setPlaybackSpeed(1.1f) - .setPlaybackSpeed(1.2f) - .setPlaybackSpeed(1.3f) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.1f)) + .apply(getPlaybackSpeedAction) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.2f)) + .apply(getPlaybackSpeedAction) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.3f)) + .apply(getPlaybackSpeedAction) .play() .build(); List reportedPlaybackSpeeds = new ArrayList<>(); EventListener listener = new EventListener() { @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { - reportedPlaybackSpeeds.add(playbackSpeed); + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + reportedPlaybackSpeeds.add(playbackParameters.speed); } }; new ExoPlayerTestRunner.Builder(context) @@ -3258,11 +3405,12 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); assertThat(reportedPlaybackSpeeds).containsExactly(1.1f, 1.2f, 1.3f).inOrder(); + assertThat(maskedPlaybackSpeeds).isEqualTo(reportedPlaybackSpeeds); } @Test public void - setUnsupportedPlaybackParametersConsecutivelyNotifiesListenerForEveryChangeOnceAndResetsOnceHandled() + setUnsupportedPlaybackSpeedConsecutivelyNotifiesListenerForEveryChangeOnceAndResetsOnceHandled() throws Exception { Renderer renderer = new FakeMediaClockRenderer(C.TRACK_TYPE_AUDIO) { @@ -3272,28 +3420,28 @@ public final class ExoPlayerTest { } @Override - public void setPlaybackSpeed(float playbackSpeed) {} + public void setPlaybackParameters(PlaybackParameters playbackParameters) {} @Override - public float getPlaybackSpeed() { - return Player.DEFAULT_PLAYBACK_SPEED; + public PlaybackParameters getPlaybackParameters() { + return PlaybackParameters.DEFAULT; } }; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() .waitForPlaybackState(Player.STATE_READY) - .setPlaybackSpeed(1.1f) - .setPlaybackSpeed(1.2f) - .setPlaybackSpeed(1.3f) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.1f)) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.2f)) + .setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.3f)) .play() .build(); - List reportedPlaybackParameters = new ArrayList<>(); + List reportedPlaybackParameters = new ArrayList<>(); EventListener listener = new EventListener() { @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { - reportedPlaybackParameters.add(playbackSpeed); + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + reportedPlaybackParameters.add(playbackParameters); } }; new ExoPlayerTestRunner.Builder(context) @@ -3306,7 +3454,11 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); assertThat(reportedPlaybackParameters) - .containsExactly(1.1f, 1.2f, 1.3f, Player.DEFAULT_PLAYBACK_SPEED) + .containsExactly( + new PlaybackParameters(/* speed= */ 1.1f), + new PlaybackParameters(/* speed= */ 1.2f), + new PlaybackParameters(/* speed= */ 1.3f), + PlaybackParameters.DEFAULT) .inOrder(); } @@ -3412,6 +3564,11 @@ public final class ExoPlayerTest { return false; } + @Override + public MediaItem getMediaItem() { + return underlyingSource.getMediaItem(); + } + @Override @Nullable public Timeline getInitialTimeline() { @@ -3452,33 +3609,41 @@ public final class ExoPlayerTest { assertArrayEquals(new long[] {5_000}, currentPlaybackPositions); } + @SuppressWarnings("deprecation") @Test public void seekTo_windowIndexIsReset_deprecated() throws Exception { FakeTimeline fakeTimeline = new FakeTimeline(/* windowCount= */ 1); FakeMediaSource mediaSource = new FakeMediaSource(fakeTimeline); LoopingMediaSource loopingMediaSource = new LoopingMediaSource(mediaSource, 2); final int[] windowIndex = {C.INDEX_UNSET}; - final long[] positionMs = {C.TIME_UNSET}; + final long[] positionMs = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() .seek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 5000) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 3000) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { + positionMs[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); //noinspection deprecation player.prepare(mediaSource); - player.seekTo(/* positionMs= */ 5000); + player.seekTo(/* positionMs= */ 7000); + positionMs[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { windowIndex[0] = player.getCurrentWindowIndex(); - positionMs[0] = player.getCurrentPosition(); + positionMs[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); @@ -3490,7 +3655,13 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS); assertThat(windowIndex[0]).isEqualTo(0); - assertThat(positionMs[0]).isAtLeast(5000L); + assertThat(positionMs[0]).isAtLeast(3000L); + assertThat(positionMs[1]).isEqualTo(7000L); + assertThat(positionMs[2]).isEqualTo(7000L); + assertThat(bufferedPositions[0]).isAtLeast(3000L); + assertThat(bufferedPositions[1]).isEqualTo(7000L); + assertThat(bufferedPositions[2]) + .isEqualTo(fakeTimeline.getWindow(0, new Window()).getDurationMs()); } @Test @@ -3499,26 +3670,34 @@ public final class ExoPlayerTest { FakeMediaSource mediaSource = new FakeMediaSource(fakeTimeline); LoopingMediaSource loopingMediaSource = new LoopingMediaSource(mediaSource, 2); final int[] windowIndex = {C.INDEX_UNSET}; - final long[] positionMs = {C.TIME_UNSET}; + final long[] positionMs = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() .seek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 5000) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 3000) + .pause() .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - player.setMediaSource(mediaSource, /* startPositionMs= */ 5000); + positionMs[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); + player.setMediaSource(mediaSource, /* startPositionMs= */ 7000); player.prepare(); + positionMs[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { windowIndex[0] = player.getCurrentWindowIndex(); - positionMs[0] = player.getCurrentPosition(); + positionMs[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); @@ -3530,7 +3709,819 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS); assertThat(windowIndex[0]).isEqualTo(0); - assertThat(positionMs[0]).isAtLeast(5000L); + assertThat(positionMs[0]).isAtLeast(3000); + assertThat(positionMs[1]).isEqualTo(7000); + assertThat(positionMs[2]).isEqualTo(7000); + assertThat(bufferedPositions[0]).isAtLeast(3000); + assertThat(bufferedPositions[1]).isEqualTo(7000); + assertThat(bufferedPositions[2]) + .isEqualTo(fakeTimeline.getWindow(0, new Window()).getDurationMs()); + } + + @Test + public void seekTo_singlePeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(9000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(9000); + assertThat(bufferedPositions[0]).isEqualTo(9200); + assertThat(totalBufferedDuration[0]).isEqualTo(200); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(9200); + assertThat(totalBufferedDuration[1]).isEqualTo(200); + } + + @Test + public void seekTo_singlePeriod_beyondBufferedData_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(9200); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(9200); + assertThat(bufferedPositions[0]).isEqualTo(9200); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(9200); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + } + + @Test + public void seekTo_backwardsSinglePeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(1000); + assertThat(bufferedPositions[0]).isEqualTo(1000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + } + + @Test + public void seekTo_backwardsMultiplePeriods_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(0, 1000); + } + }, + /* pauseWindowIndex= */ 1, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(1000); + assertThat(bufferedPositions[0]).isEqualTo(1000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + } + + @Test + public void seekTo_toUnbufferedPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(2, 1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 0)); + + assertThat(windowIndex[0]).isEqualTo(2); + assertThat(positionMs[0]).isEqualTo(1000); + assertThat(bufferedPositions[0]).isEqualTo(1000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(2); + assertThat(positionMs[1]).isEqualTo(1000); + assertThat(bufferedPositions[1]).isEqualTo(1000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + } + + @Test + public void seekTo_toLoadingPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(1000); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(10_000); + // assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void seekTo_toLoadingPeriod_withinPartiallyBufferedData_correctMaskingPosition() + throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 1000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(1000); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(1000); + // assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(4000); + assertThat(totalBufferedDuration[1]).isEqualTo(3000); + } + + @Test + public void seekTo_toLoadingPeriod_beyondBufferedData_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 5000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(5000); + assertThat(bufferedPositions[0]).isEqualTo(5000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(1); + assertThat(positionMs[1]).isEqualTo(5000); + assertThat(bufferedPositions[1]).isEqualTo(5000); + assertThat(totalBufferedDuration[1]).isEqualTo(0); + } + + @Test + public void seekTo_toInnerFullyBufferedPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(1, 5000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(1); + assertThat(positionMs[0]).isEqualTo(5000); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(10_000); + // assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void addMediaSource_withinBufferedPeriods_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + /* index= */ 1, createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 0)); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void moveMediaItem_behindLoadingPeriod_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 2); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void moveMediaItem_undloadedBehindPlaying_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.moveMediaItem(/* currentIndex= */ 3, /* newIndex= */ 1); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 0)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void removeMediaItem_removePlayingWindow_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.removeMediaItem(/* index= */ 0); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(0); + // TODO(b/160450903): Verify masking of buffering properties when behaviour in EPII is fully + // covered. + // assertThat(bufferedPositions[0]).isEqualTo(4000); + // assertThat(totalBufferedDuration[0]).isEqualTo(4000); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(4000); + assertThat(totalBufferedDuration[1]).isEqualTo(4000); + } + + @Test + public void removeMediaItem_removeLoadingWindow_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.removeMediaItem(/* index= */ 2); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isAtLeast(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[1]); + } + + @Test + public void removeMediaItem_removeInnerFullyBufferedWindow_correctMaskingPosition() + throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.removeMediaItem(/* index= */ 1); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isGreaterThan(8000); + assertThat(bufferedPositions[0]).isEqualTo(10_000); + assertThat(totalBufferedDuration[0]).isEqualTo(10_000 - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(0); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(10_000); + assertThat(totalBufferedDuration[1]).isEqualTo(10_000 - positionMs[0]); + } + + @Test + public void clearMediaItems_correctMaskingPosition() throws Exception { + final int[] windowIndex = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] positionMs = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] bufferedPositions = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] totalBufferedDuration = {C.INDEX_UNSET, C.INDEX_UNSET}; + + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.clearMediaItems(); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 4000)); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(0); + assertThat(bufferedPositions[0]).isEqualTo(0); + assertThat(totalBufferedDuration[0]).isEqualTo(0); + + assertThat(windowIndex[1]).isEqualTo(windowIndex[0]); + assertThat(positionMs[1]).isEqualTo(positionMs[0]); + assertThat(bufferedPositions[1]).isEqualTo(bufferedPositions[0]); + assertThat(totalBufferedDuration[1]).isEqualTo(totalBufferedDuration[0]); + } + + private void runPositionMaskingCapturingActionSchedule( + PlayerRunnable actionRunnable, + int pauseWindowIndex, + int[] windowIndex, + long[] positionMs, + long[] bufferedPosition, + long[] totalBufferedDuration, + MediaSource... mediaSources) + throws Exception { + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .playUntilPosition(pauseWindowIndex, /* positionMs= */ 8000) + .executeRunnable(actionRunnable) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[0] = player.getCurrentWindowIndex(); + positionMs[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); + totalBufferedDuration[0] = player.getTotalBufferedDuration(); + } + }) + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[1] = player.getCurrentWindowIndex(); + positionMs[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); + totalBufferedDuration[1] = player.getTotalBufferedDuration(); + } + }) + .stop() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSources) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + } + + private static FakeMediaSource createPartiallyBufferedMediaSource(long maxBufferedPositionMs) { + int windowOffsetInFirstPeriodUs = 1_000_000; + FakeTimeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000L, + /* defaultPositionUs= */ 0, + windowOffsetInFirstPeriodUs, + AdPlaybackState.NONE)); + return new FakeMediaSource(fakeTimeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + @Override + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + FakeMediaPeriod fakeMediaPeriod = + new FakeMediaPeriod( + trackGroupArray, + FakeMediaPeriod.TrackDataFactory.singleSampleWithTimeUs(/* sampleTimeUs= */ 0), + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false); + fakeMediaPeriod.setBufferedPositionUs( + windowOffsetInFirstPeriodUs + C.msToUs(maxBufferedPositionMs)); + return fakeMediaPeriod; + } + }; + } + + @Test + public void addMediaSource_whilePlayingAd_correctMasking() throws Exception { + long contentDurationMs = 10_000; + long adDurationMs = 100_000; + AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0); + adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); + adPlaybackState = + adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY); + long[][] durationsUs = new long[1][]; + durationsUs[0] = new long[] {C.msToUs(adDurationMs)}; + adPlaybackState = adPlaybackState.withAdDurationsUs(durationsUs); + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(contentDurationMs), + adPlaybackState)); + FakeMediaSource adsMediaSource = new FakeMediaSource(adTimeline); + int[] windowIndex = new int[] {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + long[] positionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.INDEX_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.INDEX_UNSET}; + long[] totalBufferedDurationMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.INDEX_UNSET}; + boolean[] isPlayingAd = new boolean[3]; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .waitForIsLoading(true) + .waitForIsLoading(false) + .waitForIsLoading(true) + .waitForIsLoading(false) + .pause() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + /* index= */ 1, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + windowIndex[0] = player.getCurrentWindowIndex(); + isPlayingAd[0] = player.isPlayingAd(); + positionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + totalBufferedDurationMs[0] = player.getTotalBufferedDuration(); + } + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[1] = player.getCurrentWindowIndex(); + isPlayingAd[1] = player.isPlayingAd(); + positionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + totalBufferedDurationMs[1] = player.getTotalBufferedDuration(); + } + }) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 8000) + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + windowIndex[2] = player.getCurrentWindowIndex(); + isPlayingAd[2] = player.isPlayingAd(); + positionMs[2] = player.getCurrentPosition(); + bufferedPositionMs[2] = player.getBufferedPosition(); + totalBufferedDurationMs[2] = player.getTotalBufferedDuration(); + } + }) + .play() + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setMediaSources( + adsMediaSource, new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(isPlayingAd[0]).isTrue(); + assertThat(positionMs[0]).isAtMost(adDurationMs); + assertThat(bufferedPositionMs[0]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[0]).isEqualTo(adDurationMs - positionMs[0]); + + assertThat(windowIndex[1]).isEqualTo(0); + assertThat(isPlayingAd[1]).isTrue(); + assertThat(positionMs[1]).isAtMost(adDurationMs); + assertThat(bufferedPositionMs[1]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[1]).isEqualTo(adDurationMs - positionMs[1]); + + assertThat(windowIndex[2]).isEqualTo(0); + assertThat(isPlayingAd[2]).isFalse(); + assertThat(positionMs[2]).isGreaterThan(8000); + assertThat(bufferedPositionMs[2]).isEqualTo(contentDurationMs); + assertThat(totalBufferedDurationMs[2]).isEqualTo(contentDurationMs - positionMs[2]); + } + + @Test + public void seekTo_whilePlayingAd_correctMasking() throws Exception { + long contentDurationMs = 10_000; + long adDurationMs = 4_000; + AdPlaybackState adPlaybackState = new AdPlaybackState(/* adGroupTimesUs...= */ 0); + adPlaybackState = adPlaybackState.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1); + adPlaybackState = + adPlaybackState.withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY); + long[][] durationsUs = new long[1][]; + durationsUs[0] = new long[] {C.msToUs(adDurationMs)}; + adPlaybackState = adPlaybackState.withAdDurationsUs(durationsUs); + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(contentDurationMs), + adPlaybackState)); + FakeMediaSource adsMediaSource = new FakeMediaSource(adTimeline); + int[] windowIndex = new int[] {C.INDEX_UNSET, C.INDEX_UNSET}; + long[] positionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET}; + long[] totalBufferedDurationMs = new long[] {C.TIME_UNSET, C.TIME_UNSET}; + boolean[] isPlayingAd = new boolean[2]; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .waitForIsLoading(true) + .waitForIsLoading(false) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 8000); + windowIndex[0] = player.getCurrentWindowIndex(); + isPlayingAd[0] = player.isPlayingAd(); + positionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + totalBufferedDurationMs[0] = player.getTotalBufferedDuration(); + } + }) + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndex[1] = player.getCurrentWindowIndex(); + isPlayingAd[1] = player.isPlayingAd(); + positionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + totalBufferedDurationMs[1] = player.getTotalBufferedDuration(); + } + }) + .stop() + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(adsMediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(isPlayingAd[0]).isTrue(); + assertThat(positionMs[0]).isEqualTo(0); + assertThat(bufferedPositionMs[0]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[0]).isEqualTo(adDurationMs); + + assertThat(windowIndex[1]).isEqualTo(0); + assertThat(isPlayingAd[1]).isTrue(); + assertThat(positionMs[1]).isEqualTo(0); + assertThat(bufferedPositionMs[1]).isEqualTo(adDurationMs); + assertThat(totalBufferedDurationMs[1]).isEqualTo(adDurationMs); } @Test @@ -3596,8 +4587,6 @@ public final class ExoPlayerTest { testRunner.blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); } - // Disabled until the flag to throw exceptions for [internal: b/144538905] is enabled by default. - @Ignore @Test public void loadControlNeverWantsToLoad_throwsIllegalStateException() { LoadControl neverLoadingLoadControl = @@ -3666,9 +4655,14 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { + return new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher) { @Override public long getBufferedPositionUs() { // Pretend not to have buffered data yet. @@ -3700,7 +4694,6 @@ public final class ExoPlayerTest { new TestExoPlayer.Builder(context) .setRenderers(rendererWaitingForData) .setLoadControl(loadControlWithMaxBufferUs) - .experimental_setThrowWhenStuckBuffering(true) .build(); player.setMediaSource(mediaSourceWithLoadInProgress); player.prepare(); @@ -3769,7 +4762,7 @@ public final class ExoPlayerTest { Timeline timeline2 = new FakeTimeline(secondWindowDefinition); MediaSource mediaSource1 = new FakeMediaSource(timeline1); MediaSource mediaSource2 = new FakeMediaSource(timeline2); - Timeline expectedDummyTimeline = + Timeline expectedPlaceholderTimeline = new FakeTimeline( TimelineWindowDefinition.createDummy(/* tag= */ 1), TimelineWindowDefinition.createDummy(/* tag= */ 2)); @@ -3796,7 +4789,7 @@ public final class ExoPlayerTest { Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); exoPlayerTestRunner.assertTimelinesSame( - expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterMove); + expectedPlaceholderTimeline, expectedRealTimeline, expectedRealTimelineAfterMove); } @Test @@ -3842,7 +4835,7 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - Timeline expectedDummyTimeline = + Timeline expectedPlaceholderTimeline = new FakeTimeline( TimelineWindowDefinition.createDummy(/* tag= */ 1), TimelineWindowDefinition.createDummy(/* tag= */ 2), @@ -3856,7 +4849,7 @@ public final class ExoPlayerTest { Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); exoPlayerTestRunner.assertTimelinesSame( - expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + expectedPlaceholderTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); } @Test @@ -3902,7 +4895,7 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - Timeline expectedDummyTimeline = + Timeline expectedPlaceholderTimeline = new FakeTimeline( TimelineWindowDefinition.createDummy(/* tag= */ 1), TimelineWindowDefinition.createDummy(/* tag= */ 2), @@ -3915,7 +4908,7 @@ public final class ExoPlayerTest { Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); exoPlayerTestRunner.assertTimelinesSame( - expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + expectedPlaceholderTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); } @Test @@ -3939,7 +4932,7 @@ public final class ExoPlayerTest { exoPlayerTestRunner.assertPlaybackStatesEqual( Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); - exoPlayerTestRunner.assertTimelinesSame(dummyTimeline, timeline, Timeline.EMPTY); + exoPlayerTestRunner.assertTimelinesSame(placeholderTimeline, timeline, Timeline.EMPTY); exoPlayerTestRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */, @@ -3969,7 +4962,7 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); exoPlayerTestRunner.assertTimelinesSame( - dummyTimeline, + placeholderTimeline, timeline, Timeline.EMPTY, new FakeMediaSource.InitialTimeline(secondTimeline), @@ -3993,7 +4986,8 @@ public final class ExoPlayerTest { int[] maskingPlaybackState = {C.INDEX_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) - .waitForTimelineChanged(dummyTimeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) + .waitForTimelineChanged( + placeholderTimeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) .executeRunnable( new PlaybackStateCollector(/* index= */ 0, playbackStates, timelineWindowCounts)) .clearMediaItems() @@ -4042,7 +5036,7 @@ public final class ExoPlayerTest { Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* set media items */, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* add media items */, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source update after prepare */); - Timeline expectedSecondDummyTimeline = + Timeline expectedSecondPlaceholderTimeline = new FakeTimeline( TimelineWindowDefinition.createDummy(/* tag= */ 0), TimelineWindowDefinition.createDummy(/* tag= */ 0)); @@ -4061,10 +5055,10 @@ public final class ExoPlayerTest { /* isDynamic= */ false, /* durationUs= */ 10_000_000)); exoPlayerTestRunner.assertTimelinesSame( - dummyTimeline, + placeholderTimeline, Timeline.EMPTY, - dummyTimeline, - expectedSecondDummyTimeline, + placeholderTimeline, + expectedSecondPlaceholderTimeline, expectedSecondRealTimeline); assertArrayEquals(new int[] {Player.STATE_IDLE}, maskingPlaybackState); } @@ -4108,13 +5102,13 @@ public final class ExoPlayerTest { Player.STATE_READY, Player.STATE_ENDED); exoPlayerTestRunner.assertTimelinesSame( - dummyTimeline, + placeholderTimeline, timeline, Timeline.EMPTY, - dummyTimeline, + placeholderTimeline, timeline, Timeline.EMPTY, - dummyTimeline, + placeholderTimeline, timeline); exoPlayerTestRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, @@ -4171,7 +5165,7 @@ public final class ExoPlayerTest { Player.STATE_READY, Player.STATE_ENDED); exoPlayerTestRunner.assertTimelinesSame( - dummyTimeline, timeline, Timeline.EMPTY, dummyTimeline, timeline); + placeholderTimeline, timeline, Timeline.EMPTY, placeholderTimeline, timeline); exoPlayerTestRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, /* source prepared */ @@ -4227,7 +5221,7 @@ public final class ExoPlayerTest { exoPlayerTestRunner.assertPlaybackStatesEqual( Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); - exoPlayerTestRunner.assertTimelinesSame(dummyTimeline, timeline); + exoPlayerTestRunner.assertTimelinesSame(placeholderTimeline, timeline); exoPlayerTestRunner.assertTimelineChangeReasonsEqual( Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */); @@ -4455,6 +5449,80 @@ public final class ExoPlayerTest { assertThat(positionAfterSetPlayWhenReady.get()).isAtLeast(5000); } + @Test + public void setPlayWhenReady_correctPositionMasking() throws Exception { + long[] currentPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .playUntilPosition(0, 5000) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentPositionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + player.setPlayWhenReady(true); + currentPositionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + player.setPlayWhenReady(false); + currentPositionMs[2] = player.getCurrentPosition(); + bufferedPositionMs[2] = player.getBufferedPosition(); + } + }) + .play() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + assertThat(currentPositionMs[0]).isAtLeast(5000); + assertThat(currentPositionMs[1]).isEqualTo(currentPositionMs[0]); + assertThat(currentPositionMs[2]).isEqualTo(currentPositionMs[0]); + assertThat(bufferedPositionMs[0]).isGreaterThan(currentPositionMs[0]); + assertThat(bufferedPositionMs[1]).isEqualTo(bufferedPositionMs[0]); + assertThat(bufferedPositionMs[2]).isEqualTo(bufferedPositionMs[0]); + } + + @Test + public void setShuffleMode_correctPositionMasking() throws Exception { + long[] currentPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + long[] bufferedPositionMs = new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .playUntilPosition(0, 5000) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentPositionMs[0] = player.getCurrentPosition(); + bufferedPositionMs[0] = player.getBufferedPosition(); + player.setShuffleModeEnabled(true); + currentPositionMs[1] = player.getCurrentPosition(); + bufferedPositionMs[1] = player.getBufferedPosition(); + player.setShuffleModeEnabled(false); + currentPositionMs[2] = player.getCurrentPosition(); + bufferedPositionMs[2] = player.getBufferedPosition(); + } + }) + .play() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + assertThat(currentPositionMs[0]).isAtLeast(5000); + assertThat(currentPositionMs[1]).isEqualTo(currentPositionMs[0]); + assertThat(currentPositionMs[2]).isEqualTo(currentPositionMs[0]); + assertThat(bufferedPositionMs[0]).isGreaterThan(currentPositionMs[0]); + assertThat(bufferedPositionMs[1]).isEqualTo(bufferedPositionMs[0]); + assertThat(bufferedPositionMs[2]).isEqualTo(bufferedPositionMs[0]); + } + @Test public void setShuffleOrder_keepsCurrentPosition() throws Exception { AtomicLong positionAfterSetShuffleOrder = new AtomicLong(C.TIME_UNSET); @@ -4485,10 +5553,9 @@ public final class ExoPlayerTest { AdsMediaSource adsMediaSource = new AdsMediaSource( new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), - new DummyAdsLoader(), - new DummyAdViewProvider()); + new DefaultDataSourceFactory(context), + new FakeAdsLoader(), + new FakeAdViewProvider()); Exception[] exception = {null}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) @@ -4523,10 +5590,9 @@ public final class ExoPlayerTest { AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), - new DummyAdsLoader(), - new DummyAdViewProvider()); + new DefaultDataSourceFactory(context), + new FakeAdsLoader(), + new FakeAdViewProvider()); final Exception[] exception = {null}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) @@ -4563,10 +5629,9 @@ public final class ExoPlayerTest { AdsMediaSource adsMediaSource = new AdsMediaSource( mediaSource, - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)), - new DummyAdsLoader(), - new DummyAdViewProvider()); + new DefaultDataSourceFactory(context), + new FakeAdsLoader(), + new FakeAdViewProvider()); final Exception[] exception = {null}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) @@ -4786,13 +5851,14 @@ public final class ExoPlayerTest { } @Test - public void setMediaSources_whenEmpty_validInitialSeek_correctMaskingWindowIndex() - throws Exception { + public void setMediaSources_whenEmpty_validInitialSeek_correctMasking() throws Exception { Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 2); MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] currentPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) // Wait for initial seek to be fully handled by internal player. @@ -4803,9 +5869,13 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); // Increase current window index. player.addMediaSource(/* index= */ 0, secondMediaSource); currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .prepare() @@ -4815,11 +5885,13 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[2] = player.getCurrentWindowIndex(); + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); new ExoPlayerTestRunner.Builder(context) - .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .initialSeek(/* windowIndex= */ 1, 2000) .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build() @@ -4827,16 +5899,19 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 2, 2}, currentWindowIndices); + assertArrayEquals(new long[] {2000, 2000, 2000}, currentPositions); + assertArrayEquals(new long[] {2000, 2000, 2000}, bufferedPositions); } @Test - public void setMediaSources_whenEmpty_invalidInitialSeek_correctMaskingWindowIndex() - throws Exception { + public void setMediaSources_whenEmpty_invalidInitialSeek_correctMasking() throws Exception { Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] currentPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) // Wait for initial seek to be fully handled by internal player. @@ -4847,9 +5922,13 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); // Increase current window index. player.addMediaSource(/* index= */ 0, secondMediaSource); currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .prepare() @@ -4859,12 +5938,14 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[2] = player.getCurrentWindowIndex(); + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .waitForPlaybackState(Player.STATE_ENDED) .build(); new ExoPlayerTestRunner.Builder(context) - .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .initialSeek(/* windowIndex= */ 1, 2000) .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build() @@ -4872,6 +5953,8 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {0, 1, 1}, currentWindowIndices); + assertArrayEquals(new long[] {0, 0, 0}, currentPositions); + assertArrayEquals(new long[] {0, 0, 0}, bufferedPositions); } @Test @@ -5462,10 +6545,47 @@ public final class ExoPlayerTest { } @Test - public void addMediaSources_skipSettingMediaItems_validInitialSeek_correctMaskingWindowIndex() + public void addMediaSources_whenEmptyInitialSeek_correctPeriodMasking() throws Exception { + final long[] positions = new long[2]; + Arrays.fill(positions, C.TIME_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + // Wait for initial seek to be fully handled by internal player. + .waitForPositionDiscontinuity() + .waitForPendingPlayerCommands() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.addMediaSource( + /* index= */ 0, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + positions[0] = player.getCurrentPosition(); + positions[1] = player.getBufferedPosition(); + } + }) + .prepare() + .build(); + new ExoPlayerTestRunner.Builder(context) + .skipSettingMediaSources() + .initialSeek(/* windowIndex= */ 0, /* positionMs= */ 2000) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {2000, 2000}, positions); + } + + @Test + public void addMediaSources_skipSettingMediaItems_validInitialSeek_correctMasking() throws Exception { final int[] currentWindowIndices = new int[5]; Arrays.fill(currentWindowIndices, C.INDEX_UNSET); + final long[] currentPositions = new long[3]; + Arrays.fill(currentPositions, C.TIME_UNSET); + final long[] bufferedPositions = new long[3]; + Arrays.fill(bufferedPositions, C.TIME_UNSET); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) // Wait for initial seek to be fully handled by internal player. @@ -5476,6 +6596,9 @@ public final class ExoPlayerTest { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + // If the timeline is empty masking variables are used. + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); player.addMediaSource(/* index= */ 0, new ConcatenatingMediaSource()); currentWindowIndices[1] = player.getCurrentWindowIndex(); player.addMediaSource( @@ -5486,26 +6609,39 @@ public final class ExoPlayerTest { /* index= */ 0, new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); currentWindowIndices[3] = player.getCurrentWindowIndex(); + // With a non-empty timeline, we mask the periodId in the playback info. + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .prepare() + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[4] = player.getCurrentWindowIndex(); + // Finally original playbackInfo coming from EPII is used. + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .build(); new ExoPlayerTestRunner.Builder(context) .skipSettingMediaSources() - .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .initialSeek(/* windowIndex= */ 1, 2000) .setActionSchedule(actionSchedule) .build() .start(/* doPrepare= */ false) .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 1, 1, 2, 2}, currentWindowIndices); + assertThat(currentPositions[0]).isEqualTo(2000); + assertThat(currentPositions[1]).isEqualTo(2000); + assertThat(currentPositions[2]).isAtLeast(2000); + assertThat(bufferedPositions[0]).isEqualTo(2000); + assertThat(bufferedPositions[1]).isEqualTo(2000); + assertThat(bufferedPositions[2]).isAtLeast(2000); } @Test @@ -5704,13 +6840,14 @@ public final class ExoPlayerTest { } @Test - public void removeMediaItems_currentItemRemoved_correctMaskingWindowIndex() throws Exception { + public void removeMediaItems_currentItemRemoved_correctMasking() throws Exception { Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; final long[] currentPositions = {C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPositions = {C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .waitForPlaybackState(Player.STATE_BUFFERING) @@ -5721,9 +6858,11 @@ public final class ExoPlayerTest { // Remove the current item. currentWindowIndices[0] = player.getCurrentWindowIndex(); currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); player.removeMediaItem(/* index= */ 1); currentWindowIndices[1] = player.getCurrentWindowIndex(); currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .build(); @@ -5737,7 +6876,9 @@ public final class ExoPlayerTest { .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 1}, currentWindowIndices); assertThat(currentPositions[0]).isAtLeast(5000L); + assertThat(bufferedPositions[0]).isAtLeast(5000L); assertThat(currentPositions[1]).isEqualTo(0); + assertThat(bufferedPositions[1]).isAtLeast(0); } @Test @@ -5754,6 +6895,10 @@ public final class ExoPlayerTest { Arrays.fill(currentWindowIndices, C.INDEX_UNSET); final int[] maskingPlaybackStates = new int[4]; Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + final long[] currentPositions = new long[3]; + Arrays.fill(currentPositions, C.TIME_UNSET); + final long[] bufferedPositions = new long[3]; + Arrays.fill(bufferedPositions, C.TIME_UNSET); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .waitForPlaybackState(Player.STATE_READY) @@ -5763,12 +6908,16 @@ public final class ExoPlayerTest { public void run(SimpleExoPlayer player) { // Expect the current window index to be 2 after seek. currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + bufferedPositions[0] = player.getBufferedPosition(); player.removeMediaItem(/* index= */ 2); // Expect the current window index to be 0 // (default position of timeline after not finding subsequent period). currentWindowIndices[1] = player.getCurrentWindowIndex(); // Transition to ENDED. maskingPlaybackStates[0] = player.getPlaybackState(); + currentPositions[1] = player.getCurrentPosition(); + bufferedPositions[1] = player.getBufferedPosition(); } }) .waitForPlaybackState(Player.STATE_ENDED) @@ -5784,6 +6933,8 @@ public final class ExoPlayerTest { currentWindowIndices[3] = player.getCurrentWindowIndex(); // Remains in ENDED. maskingPlaybackStates[1] = player.getPlaybackState(); + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .waitForTimelineChanged() @@ -5852,6 +7003,12 @@ public final class ExoPlayerTest { }, // buffers after set items with seek maskingPlaybackStates); assertArrayEquals(new int[] {2, 0, 0, 1, 1, 0, 0, 0, 0}, currentWindowIndices); + assertThat(currentPositions[0]).isGreaterThan(0); + assertThat(currentPositions[1]).isEqualTo(0); + assertThat(currentPositions[2]).isEqualTo(0); + assertThat(bufferedPositions[0]).isGreaterThan(0); + assertThat(bufferedPositions[1]).isEqualTo(0); + assertThat(bufferedPositions[2]).isEqualTo(0); } @Test @@ -5889,16 +7046,24 @@ public final class ExoPlayerTest { MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; final int[] maskingPlaybackState = {C.INDEX_UNSET}; + final long[] currentPosition = {C.TIME_UNSET, C.TIME_UNSET}; + final long[] bufferedPosition = {C.TIME_UNSET, C.TIME_UNSET}; ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) + .pause() .waitForPlaybackState(Player.STATE_BUFFERING) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 150) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPosition[0] = player.getCurrentPosition(); + bufferedPosition[0] = player.getBufferedPosition(); player.clearMediaItems(); currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPosition[1] = player.getCurrentPosition(); + bufferedPosition[1] = player.getBufferedPosition(); maskingPlaybackState[0] = player.getPlaybackState(); } }) @@ -5912,6 +7077,11 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + assertThat(currentPosition[0]).isAtLeast(150); + assertThat(currentPosition[1]).isEqualTo(0); + assertThat(bufferedPosition[0]).isAtLeast(150); + assertThat(bufferedPosition[1]).isEqualTo(0); + assertArrayEquals(new int[] {1, 0}, currentWindowIndices); assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackState); } @@ -5962,203 +7132,6 @@ public final class ExoPlayerTest { assertArrayEquals(new int[] {1, 0}, currentWindowIndices); } - // TODO(b/150584930): Fix reporting of renderer errors. - @Ignore - @Test - public void errorThrownDuringRendererEnableAtPeriodTransition_isReportedForNewPeriod() { - FakeMediaSource source1 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); - FakeMediaSource source2 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); - FakeRenderer videoRenderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - FakeRenderer audioRenderer = - new FakeRenderer(C.TRACK_TYPE_AUDIO) { - @Override - protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) - throws ExoPlaybackException { - // Fail when enabling the renderer. This will happen during the period transition. - throw createRendererException( - new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); - } - }; - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); - } - }) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) - .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); - - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); - - assertThat(windowIndexAfterError.get()).isEqualTo(1); - assertThat(trackGroupsAfterError.get().length).isEqualTo(1); - assertThat(trackGroupsAfterError.get().get(0).getFormat(0)) - .isEqualTo(ExoPlayerTestRunner.AUDIO_FORMAT); - assertThat(trackSelectionsAfterError.get().get(0)).isNull(); // Video renderer. - assertThat(trackSelectionsAfterError.get().get(1)).isNotNull(); // Audio renderer. - } - - @Test - public void errorThrownDuringRendererDisableAtPeriodTransition_isReportedForCurrentPeriod() { - FakeMediaSource source1 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); - FakeMediaSource source2 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); - FakeRenderer videoRenderer = - new FakeRenderer(C.TRACK_TYPE_VIDEO) { - @Override - protected void onStopped() throws ExoPlaybackException { - // Fail when stopping the renderer. This will happen during the period transition. - throw createRendererException( - new IllegalStateException(), ExoPlayerTestRunner.VIDEO_FORMAT); - } - }; - FakeRenderer audioRenderer = new FakeRenderer(C.TRACK_TYPE_AUDIO); - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); - } - }) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) - .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); - - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); - - assertThat(windowIndexAfterError.get()).isEqualTo(0); - assertThat(trackGroupsAfterError.get().length).isEqualTo(1); - assertThat(trackGroupsAfterError.get().get(0).getFormat(0)) - .isEqualTo(ExoPlayerTestRunner.VIDEO_FORMAT); - assertThat(trackSelectionsAfterError.get().get(0)).isNotNull(); // Video renderer. - assertThat(trackSelectionsAfterError.get().get(1)).isNull(); // Audio renderer. - } - - // TODO(b/150584930): Fix reporting of renderer errors. - @Ignore - @Test - public void errorThrownDuringRendererReplaceStreamAtPeriodTransition_isReportedForNewPeriod() { - FakeMediaSource source1 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), - ExoPlayerTestRunner.VIDEO_FORMAT, - ExoPlayerTestRunner.AUDIO_FORMAT); - FakeMediaSource source2 = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); - FakeRenderer videoRenderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - FakeRenderer audioRenderer = - new FakeRenderer(C.TRACK_TYPE_AUDIO) { - @Override - protected void onStreamChanged(Format[] formats, long offsetUs) - throws ExoPlaybackException { - // Fail when changing streams. This will happen during the period transition. - throw createRendererException( - new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); - } - }; - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); - } - }) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) - .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); - - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); - - assertThat(windowIndexAfterError.get()).isEqualTo(1); - assertThat(trackGroupsAfterError.get().length).isEqualTo(1); - assertThat(trackGroupsAfterError.get().get(0).getFormat(0)) - .isEqualTo(ExoPlayerTestRunner.AUDIO_FORMAT); - assertThat(trackSelectionsAfterError.get().get(0)).isNull(); // Video renderer. - assertThat(trackSelectionsAfterError.get().get(1)).isNotNull(); // Audio renderer. - } - @Test public void errorThrownDuringPlaylistUpdate_keepsConsistentPlayerState() { FakeMediaSource source1 = @@ -6354,11 +7327,13 @@ public final class ExoPlayerTest { assertThat(positionAfterPause.get()).isEqualTo(10_000); } - // Disabled until the flag to throw exceptions for [internal: b/144538905] is enabled by default. - @Ignore @Test public void infiniteLoading_withSmallAllocations_oomIsPreventedByLoadControl_andThrowsStuckBufferingIllegalStateException() { + DefaultLoadControl loadControl = + new DefaultLoadControl.Builder() + .setTargetBufferBytes(10 * C.DEFAULT_BUFFER_SEGMENT_SIZE) + .build(); MediaSource continuouslyAllocatingMediaSource = new FakeMediaSource( new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) { @@ -6367,9 +7342,14 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { + return new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher) { private final List allocations = new ArrayList<>(); @@ -6411,6 +7391,7 @@ public final class ExoPlayerTest { new ExoPlayerTestRunner.Builder(context) .setActionSchedule(actionSchedule) .setMediaSources(continuouslyAllocatingMediaSource) + .setLoadControl(loadControl) .build(); ExoPlaybackException exception = @@ -6442,31 +7423,44 @@ public final class ExoPlayerTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { + return new FakeMediaPeriod( + trackGroupArray, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false) { private Loader loader = new Loader("oomLoader"); @Override public boolean continueLoading(long positionUs) { loader.startLoading( - loadable, new DummyLoaderCallback(), /* defaultMinRetryCount= */ 1); + loadable, new FakeLoaderCallback(), /* defaultMinRetryCount= */ 1); return true; } @Override protected SampleStream createSampleStream( - long positionUs, TrackSelection selection, EventDispatcher eventDispatcher) { + long positionUs, + TrackSelection selection, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher) { // Create 3 samples without end of stream signal to test that all 3 samples are // still played before the exception is thrown. return new FakeSampleStream( + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, selection.getSelectedFormat(), - eventDispatcher, - positionUs, - /* timeUsIncrement= */ 0, - new FakeSampleStream.FakeSampleStreamItem(new byte[] {0}), - new FakeSampleStream.FakeSampleStreamItem(new byte[] {0}), - new FakeSampleStream.FakeSampleStreamItem(new byte[] {0})) { + ImmutableList.of( + oneByteSample(positionUs), + oneByteSample(positionUs), + oneByteSample(positionUs))) { @Override public void maybeThrowError() throws IOException { @@ -6585,6 +7579,745 @@ public final class ExoPlayerTest { .inOrder(); } + /** + * This tests that renderer offsets and buffer times in the renderer are set correctly even when + * the sources have a window-to-period offset and a non-zero default start position. The start + * offset of the first source is also updated during preparation to make sure the player adapts + * everything accordingly. + */ + @Test + public void + playlistWithMediaWithStartOffsets_andStartOffsetChangesDuringPreparation_appliesCorrectRenderingOffsetToAllPeriods() + throws Exception { + List rendererStreamOffsetsUs = new ArrayList<>(); + List firstBufferTimesUsWithOffset = new ArrayList<>(); + FakeRenderer renderer = + new FakeRenderer(C.TRACK_TYPE_VIDEO) { + boolean pendingFirstBufferTime = false; + + @Override + protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { + rendererStreamOffsetsUs.add(offsetUs); + pendingFirstBufferTime = true; + } + + @Override + protected boolean shouldProcessBuffer(long bufferTimeUs, long playbackPositionUs) { + if (pendingFirstBufferTime) { + firstBufferTimesUsWithOffset.add(bufferTimeUs); + pendingFirstBufferTime = false; + } + return super.shouldProcessBuffer(bufferTimeUs, playbackPositionUs); + } + }; + Timeline timelineWithOffsets = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ new Object(), + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US, + /* defaultPositionUs= */ 4_567_890, + /* windowOffsetInFirstPeriodUs= */ 1_234_567, + AdPlaybackState.NONE)); + ExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + long firstSampleTimeUs = 4_567_890 + 1_234_567; + FakeMediaSource firstMediaSource = + new FakeMediaSource( + /* timeline= */ null, + DrmSessionManager.DUMMY, + (unusedFormat, unusedMediaPeriodId) -> + ImmutableList.of(oneByteSample(firstSampleTimeUs), END_OF_STREAM_ITEM), + ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource secondMediaSource = + new FakeMediaSource( + timelineWithOffsets, + DrmSessionManager.DUMMY, + (unusedFormat, unusedMediaPeriodId) -> + ImmutableList.of(oneByteSample(firstSampleTimeUs), END_OF_STREAM_ITEM), + ExoPlayerTestRunner.VIDEO_FORMAT); + player.setMediaSources(ImmutableList.of(firstMediaSource, secondMediaSource)); + + // Start playback and wait until player is idly waiting for an update of the first source. + player.prepare(); + player.play(); + TestExoPlayer.runUntilPendingCommandsAreFullyHandled(player); + // Update media with a non-zero default start position and window offset. + firstMediaSource.setNewSourceInfo(timelineWithOffsets); + // Wait until player transitions to second source (which also has non-zero offsets). + TestExoPlayer.runUntilPositionDiscontinuity( + player, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + player.release(); + + assertThat(rendererStreamOffsetsUs).hasSize(2); + assertThat(firstBufferTimesUsWithOffset).hasSize(2); + // Assert that the offsets and buffer times match the expected sample time. + assertThat(firstBufferTimesUsWithOffset.get(0)) + .isEqualTo(rendererStreamOffsetsUs.get(0) + firstSampleTimeUs); + assertThat(firstBufferTimesUsWithOffset.get(1)) + .isEqualTo(rendererStreamOffsetsUs.get(1) + firstSampleTimeUs); + // Assert that the second source continues rendering seamlessly at the point where the first one + // ended. + long periodDurationUs = + timelineWithOffsets.getPeriod(/* periodIndex= */ 0, new Timeline.Period()).durationUs; + assertThat(firstBufferTimesUsWithOffset.get(1)) + .isEqualTo(rendererStreamOffsetsUs.get(0) + periodDurationUs); + } + + @Test + public void mediaItemOfSources_correctInTimelineWindows() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + final Player[] playerHolder = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + playerHolder[0] = player; + } + }) + .waitForPlaybackState(Player.STATE_READY) + .seek(/* positionMs= */ 0) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + List currentMediaItems = new ArrayList<>(); + List initialMediaItems = new ArrayList<>(); + Player.EventListener eventListener = + new Player.EventListener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + if (reason != Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { + return; + } + Window window = new Window(); + for (int i = 0; i < timeline.getWindowCount(); i++) { + initialMediaItems.add(timeline.getWindow(i, window).mediaItem); + } + } + + @Override + public void onPositionDiscontinuity(int reason) { + currentMediaItems.add(playerHolder[0].getCurrentMediaItem()); + } + }; + new ExoPlayerTestRunner.Builder(context) + .setEventListener(eventListener) + .setActionSchedule(actionSchedule) + .setMediaSources( + factory.setTag("1").createMediaSource(), + factory.setTag("2").createMediaSource(), + factory.setTag("3").createMediaSource()) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(currentMediaItems.get(0).playbackProperties.tag).isEqualTo("1"); + assertThat(currentMediaItems.get(1).playbackProperties.tag).isEqualTo("2"); + assertThat(currentMediaItems.get(2).playbackProperties.tag).isEqualTo("3"); + assertThat(initialMediaItems).containsExactlyElementsIn(currentMediaItems); + } + + @Test + public void setMediaSources_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource = factory.setTag("1").createMediaSource(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void setMediaSources_replaceWithSameMediaItem_notifiesMediaItemTransition() + throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource = factory.setTag("1").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .setMediaSources(mediaSource) + .waitForPlaybackState(Player.STATE_READY) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource.getMediaItem(), mediaSource.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void automaticWindowTransition_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO); + } + + @Test + public void clearMediaItem_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 2000) + .clearMediaItems() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem(), null); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void seekTo_otherWindow_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .seek(/* windowIndex= */ 1, /* positionMs= */ 2000) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + } + + @Test + public void seekTo_sameWindow_doesNotNotifyMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .seek(/* windowIndex= */ 0, /* positionMs= */ 20_000) + .stop() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void repeat_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setRepeatMode(Player.REPEAT_MODE_ONE); + } + }) + .play() + .waitForPositionDiscontinuity() + .waitForPositionDiscontinuity() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setRepeatMode(Player.REPEAT_MODE_OFF); + } + }) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), + mediaSource1.getMediaItem(), + mediaSource1.getMediaItem(), + mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT, + Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO); + } + + @Test + public void stop_withReset_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ true) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem(), null); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void stop_withoutReset_doesNotNotifyMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ false) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void timelineRefresh_withModifiedMediaItem_doesNotNotifyMediaItemTransition() + throws Exception { + MediaItem initialMediaItem = FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(0).build(); + TimelineWindowDefinition initialWindow = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000, + /* defaultPositionUs= */ 0, + /* windowOffsetInFirstPeriodUs= */ 0, + AdPlaybackState.NONE, + initialMediaItem); + TimelineWindowDefinition secondWindow = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000, + /* defaultPositionUs= */ 0, + /* windowOffsetInFirstPeriodUs= */ 0, + AdPlaybackState.NONE, + initialMediaItem.buildUpon().setTag(1).build()); + FakeTimeline timeline = new FakeTimeline(initialWindow); + FakeTimeline newTimeline = new FakeTimeline(secondWindow); + FakeMediaSource mediaSource = new FakeMediaSource(timeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .waitForPlayWhenReady(false) + .executeRunnable( + () -> { + mediaSource.setNewSourceInfo(newTimeline); + }) + .play() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertTimelinesSame(placeholderTimeline, timeline, newTimeline); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertMediaItemsTransitionedSame(initialMediaItem); + } + + @Test + public void + mediaSourceMaybeThrowSourceInfoRefreshError_isNotThrownUntilPlaybackReachedFailingItem() + throws Exception { + ExoPlayer player = new TestExoPlayer.Builder(context).build(); + player.addMediaSource(new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + player.addMediaSource( + new FakeMediaSource(/* timeline= */ null) { + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + throw new IOException(); + } + }); + + player.prepare(); + player.play(); + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + } + + @Test + public void mediaPeriodMaybeThrowPrepareError_isNotThrownUntilPlaybackReachedFailingItem() + throws Exception { + ExoPlayer player = new TestExoPlayer.Builder(context).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.addMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.addMediaSource( + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + @Override + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + return new FakeMediaPeriod( + trackGroupArray, + /* singleSampleTimeUs= */ 0, + mediaSourceEventDispatcher, + DrmSessionManager.DUMMY, + drmEventDispatcher, + /* deferOnPrepared= */ true) { + @Override + public void maybeThrowPrepareError() throws IOException { + throw new IOException(); + } + }; + } + }); + + player.prepare(); + player.play(); + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + } + + @Test + public void sampleStreamMaybeThrowError_isNotThrownUntilPlaybackReachedFailingItem() + throws Exception { + ExoPlayer player = new TestExoPlayer.Builder(context).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.addMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.addMediaSource( + new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT) { + @Override + protected FakeMediaPeriod createFakeMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + return new FakeMediaPeriod( + trackGroupArray, /* singleSampleTimeUs= */ 0, mediaSourceEventDispatcher) { + @Override + protected SampleStream createSampleStream( + long positionUs, + TrackSelection selection, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher) { + return new FakeSampleStream( + mediaSourceEventDispatcher, + DrmSessionManager.DUMMY, + drmEventDispatcher, + selection.getSelectedFormat(), + /* fakeSampleStreamItems= */ ImmutableList.of()) { + @Override + public void maybeThrowError() throws IOException { + throw new IOException(); + } + }; + } + }; + } + }); + + player.prepare(); + player.play(); + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + } + + @Test + public void rendererError_isReportedWithReadingMediaPeriodId() throws Exception { + FakeMediaSource source0 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource source1 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_VIDEO), + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + @Override + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + // Fail when enabling the renderer. This will happen during the period + // transition while the reading and playing period are different. + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + }; + ExoPlayer player = + new TestExoPlayer.Builder(context).setRenderersFactory(renderersFactory).build(); + player.setMediaSources(ImmutableList.of(source0, source1)); + player.prepare(); + player.play(); + + ExoPlaybackException error = TestExoPlayer.runUntilError(player); + + Object period1Uid = + player + .getCurrentTimeline() + .getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true) + .uid; + assertThat(error.mediaPeriodId.periodUid).isEqualTo(period1Uid); + // Verify test setup by checking that playing period was indeed different. + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + } + + @Test + public void enableOffloadSchedulingWhileIdle_isToggled_isReported() throws Exception { + SimpleExoPlayer player = new TestExoPlayer.Builder(context).build(); + + player.experimentalSetOffloadSchedulingEnabled(true); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isTrue(); + + player.experimentalSetOffloadSchedulingEnabled(false); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } + + @Test + public void enableOffloadSchedulingWhilePlaying_isToggled_isReported() throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.prepare(); + player.play(); + + player.experimentalSetOffloadSchedulingEnabled(true); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isTrue(); + + player.experimentalSetOffloadSchedulingEnabled(false); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } + + @Test + public void enableOffloadSchedulingWhileSleepingForOffload_isDisabled_isReported() + throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + runUntilReceiveOffloadSchedulingEnabledNewState(player); + player.prepare(); + player.play(); + runMainLooperUntil(sleepRenderer::isSleeping); + + player.experimentalSetOffloadSchedulingEnabled(false); + + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } + + @Test + public void enableOffloadScheduling_isEnable_playerSleeps() throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + player.prepare(); + player.play(); + + sleepRenderer.sleepOnNextRender(); + + runMainLooperUntil(sleepRenderer::isSleeping); + // TODO(b/163303129): There is currently no way to check that the player is sleeping for + // offload, for now use a timeout to check that the renderer is never woken up. + final int renderTimeoutMs = 500; + assertThrows( + TimeoutException.class, + () -> + runMainLooperUntil(() -> !sleepRenderer.isSleeping(), renderTimeoutMs, Clock.DEFAULT)); + } + + @Test + public void + experimentalEnableOffloadSchedulingWhileSleepingForOffload_isDisabled_renderingResumes() + throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + player.prepare(); + player.play(); + runMainLooperUntil(sleepRenderer::isSleeping); + + player.experimentalSetOffloadSchedulingEnabled(false); // Force the player to exit offload sleep + + runMainLooperUntil(() -> !sleepRenderer.isSleeping()); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + + @Test + public void wakeupListenerWhileSleepingForOffload_isWokenUp_renderingResumes() throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(sleepRenderer).build(); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); + player.experimentalSetOffloadSchedulingEnabled(true); + player.prepare(); + player.play(); + runMainLooperUntil(sleepRenderer::isSleeping); + + sleepRenderer.wakeup(); + + runMainLooperUntil(() -> !sleepRenderer.isSleeping()); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { @@ -6614,6 +8347,63 @@ public final class ExoPlayerTest { // Internal classes. + /* {@link FakeRenderer} that can sleep and be woken-up. */ + private static class FakeSleepRenderer extends FakeRenderer { + private static final long WAKEUP_DEADLINE_MS = 60 * C.MICROS_PER_SECOND; + private final AtomicBoolean sleepOnNextRender; + private final AtomicBoolean isSleeping; + private final AtomicReference wakeupListenerReceiver; + + public FakeSleepRenderer(int trackType) { + super(trackType); + sleepOnNextRender = new AtomicBoolean(false); + isSleeping = new AtomicBoolean(false); + wakeupListenerReceiver = new AtomicReference<>(); + } + + public void wakeup() { + wakeupListenerReceiver.get().onWakeup(); + } + + /** + * Call {@link Renderer.WakeupListener#onSleep(long)} on the next {@link #render(long, long)} + */ + public FakeSleepRenderer sleepOnNextRender() { + sleepOnNextRender.set(true); + return this; + } + + /** + * Returns whether {@link Renderer.WakeupListener#onSleep(long)} was called on the last {@link + * #render(long, long)} + */ + public boolean isSleeping() { + return isSleeping.get(); + } + + @Override + public void handleMessage(int what, @Nullable Object object) throws ExoPlaybackException { + if (what == MSG_SET_WAKEUP_LISTENER) { + assertThat(object).isNotNull(); + wakeupListenerReceiver.set((WakeupListener) object); + } + super.handleMessage(what, object); + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + super.render(positionUs, elapsedRealtimeUs); + if (sleepOnNextRender.compareAndSet(/* expect= */ true, /* update= */ false)) { + wakeupListenerReceiver.get().onSleep(WAKEUP_DEADLINE_MS); + // TODO(b/163303129): Use an actual message from the player instead of guessing that the + // player will always sleep for offload after calling `onSleep`. + isSleeping.set(true); + } else { + isSleeping.set(false); + } + } + } + private static final class CountingMessageTarget implements PlayerMessage.Target { public int messageCount; @@ -6691,7 +8481,7 @@ public final class ExoPlayerTest { } } - private static final class DummyLoaderCallback implements Loader.Callback { + private static final class FakeLoaderCallback implements Loader.Callback { @Override public void onLoadCompleted( Loader.Loadable loadable, long elapsedRealtimeMs, long loadDurationMs) {} @@ -6711,7 +8501,7 @@ public final class ExoPlayerTest { } } - private static class DummyAdsLoader implements AdsLoader { + private static class FakeAdsLoader implements AdsLoader { @Override public void setPlayer(@Nullable Player player) {} @@ -6728,11 +8518,14 @@ public final class ExoPlayerTest { @Override public void stop() {} + @Override + public void handlePrepareComplete(int adGroupIndex, int adIndexInAdGroup) {} + @Override public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {} } - private static class DummyAdViewProvider implements AdsLoader.AdViewProvider { + private static class FakeAdViewProvider implements AdsLoader.AdViewProvider { @Override public ViewGroup getAdViewGroup() { @@ -6740,8 +8533,20 @@ public final class ExoPlayerTest { } @Override - public View[] getAdOverlayViews() { - return new View[0]; + public ImmutableList getAdOverlayInfos() { + return ImmutableList.of(); } } + + /** + * Returns an argument matcher for {@link Timeline} instances that ignores period and window uids. + */ + private static ArgumentMatcher noUid(Timeline timeline) { + return new ArgumentMatcher() { + @Override + public boolean matches(Timeline argument) { + return new NoUidTimeline(timeline).equals(new NoUidTimeline(argument)); + } + }; + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index ccc5156015..20be8fe12b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -18,30 +18,30 @@ package com.google.android.exoplayer2; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; +import static org.robolectric.Shadows.shadowOf; import android.net.Uri; +import android.os.Handler; +import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; -import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.testutil.FakeShuffleOrder; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.Allocator; -import java.util.Collections; +import com.google.common.collect.ImmutableList; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link MediaPeriodQueue}. */ -@LooperMode(LEGACY) @RunWith(AndroidJUnit4.class) public final class MediaPeriodQueueTest { @@ -52,7 +52,12 @@ public final class MediaPeriodQueueTest { private static final Timeline CONTENT_TIMELINE = new SinglePeriodTimeline( - CONTENT_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false); + CONTENT_DURATION_US, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); private static final Uri AD_URI = Uri.EMPTY; private MediaPeriodQueue mediaPeriodQueue; @@ -65,12 +70,16 @@ public final class MediaPeriodQueueTest { private Allocator allocator; private MediaSourceList mediaSourceList; private FakeMediaSource fakeMediaSource; - private MediaSourceList.MediaSourceHolder mediaSourceHolder; @Before public void setUp() { - mediaPeriodQueue = new MediaPeriodQueue(); - mediaSourceList = mock(MediaSourceList.class); + mediaPeriodQueue = + new MediaPeriodQueue(/* analyticsCollector= */ null, new Handler(Looper.getMainLooper())); + mediaSourceList = + new MediaSourceList( + mock(MediaSourceList.MediaSourceListInfoRefreshListener.class), + /* analyticsCollector= */ null, + new Handler(Looper.getMainLooper())); rendererCapabilities = new RendererCapabilities[0]; trackSelector = mock(TrackSelector.class); allocator = mock(Allocator.class); @@ -403,10 +412,13 @@ public final class MediaPeriodQueueTest { private void setupTimeline(Timeline timeline) { fakeMediaSource = new FakeMediaSource(timeline); - mediaSourceHolder = new MediaSourceList.MediaSourceHolder(fakeMediaSource, false); + MediaSourceList.MediaSourceHolder mediaSourceHolder = + new MediaSourceList.MediaSourceHolder(fakeMediaSource, /* useLazyPreparation= */ false); + mediaSourceList.setMediaSources( + ImmutableList.of(mediaSourceHolder), new FakeShuffleOrder(/* length= */ 1)); mediaSourceHolder.mediaSource.prepareSourceInternal(/* mediaTransferListener */ null); - Timeline playlistTimeline = createPlaylistTimeline(); + Timeline playlistTimeline = mediaSourceList.createTimeline(); firstPeriodUid = playlistTimeline.getUidOfPeriod(/* periodIndex= */ 0); playbackInfo = @@ -423,28 +435,11 @@ public final class MediaPeriodQueueTest { /* loadingMediaPeriodId= */ null, /* playWhenReady= */ false, Player.PLAYBACK_SUPPRESSION_REASON_NONE, + /* playbackParameters= */ PlaybackParameters.DEFAULT, /* bufferedPositionUs= */ 0, /* totalBufferedDurationUs= */ 0, - /* positionUs= */ 0); - } - - private void updateAdPlaybackStateAndTimeline(long... adGroupTimesUs) { - adPlaybackState = - new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); - updateTimeline(); - } - - private void updateTimeline() { - SinglePeriodAdTimeline adTimeline = - new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); - fakeMediaSource.setNewSourceInfo(adTimeline); - playbackInfo = playbackInfo.copyWithTimeline(createPlaylistTimeline()); - } - - private MediaSourceList.PlaylistTimeline createPlaylistTimeline() { - return new MediaSourceList.PlaylistTimeline( - Collections.singleton(mediaSourceHolder), - new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1)); + /* positionUs= */ 0, + /* offloadSchedulingEnabled= */ false); } private void advance() { @@ -499,6 +494,21 @@ public final class MediaPeriodQueueTest { updateTimeline(); } + private void updateAdPlaybackStateAndTimeline(long... adGroupTimesUs) { + adPlaybackState = + new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); + updateTimeline(); + } + + private void updateTimeline() { + SinglePeriodAdTimeline adTimeline = + new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + fakeMediaSource.setNewSourceInfo(adTimeline); + // Progress the looper so that the source info events have been executed. + shadowOf(Looper.getMainLooper()).idle(); + playbackInfo = playbackInfo.copyWithTimeline(mediaSourceList.createTimeline()); + } + private void assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( Object periodUid, long startPositionUs, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java index 7ece4f3259..b3ff5e5c55 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaSourceListTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.source.MediaSource; @@ -30,6 +31,7 @@ import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.testutil.FakeShuffleOrder; import com.google.android.exoplayer2.testutil.FakeTimeline; +import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -42,13 +44,18 @@ import org.junit.runner.RunWith; public class MediaSourceListTest { private static final int MEDIA_SOURCE_LIST_SIZE = 4; + private static final MediaItem MINIMAL_MEDIA_ITEM = + new MediaItem.Builder().setMediaId("").build(); private MediaSourceList mediaSourceList; @Before public void setUp() { mediaSourceList = - new MediaSourceList(mock(MediaSourceList.MediaSourceListInfoRefreshListener.class)); + new MediaSourceList( + mock(MediaSourceList.MediaSourceListInfoRefreshListener.class), + /* analyticsCollector= */ null, + Util.createHandlerForCurrentOrMainLooper()); } @Test @@ -76,7 +83,9 @@ public class MediaSourceListTest { @Test public void prepareAndReprepareAfterRelease_expectSourcePreparationAfterMediaSourceListPrepare() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); mediaSourceList.setMediaSources( createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2), @@ -115,7 +124,9 @@ public class MediaSourceListTest { ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2); MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List mediaSources = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); @@ -132,8 +143,10 @@ public class MediaSourceListTest { } // Set media items again. The second holder is re-used. + MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List moreMediaSources = - createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class)); + createFakeHoldersWithSources(/* useLazyPreparation= */ false, mockMediaSource3); moreMediaSources.add(mediaSources.get(1)); timeline = mediaSourceList.setMediaSources(moreMediaSources, shuffleOrder); @@ -157,7 +170,9 @@ public class MediaSourceListTest { ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2); MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List mediaSources = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); @@ -174,8 +189,10 @@ public class MediaSourceListTest { any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); // Set media items again. The second holder is re-used. + MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List moreMediaSources = - createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class)); + createFakeHoldersWithSources(/* useLazyPreparation= */ false, mockMediaSource3); moreMediaSources.add(mediaSources.get(1)); mediaSourceList.setMediaSources(moreMediaSources, shuffleOrder); @@ -193,7 +210,9 @@ public class MediaSourceListTest { @Test public void addMediaSources_mediaSourceListUnprepared_notUsingLazyPreparation_expectUnprepared() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List mediaSources = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); @@ -228,7 +247,9 @@ public class MediaSourceListTest { @Test public void addMediaSources_mediaSourceListPrepared_notUsingLazyPreparation_expectPrepared() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); mediaSourceList.prepare(/* mediaTransferListener= */ null); mediaSourceList.addMediaSources( /* index= */ 0, @@ -287,9 +308,13 @@ public class MediaSourceListTest { @Test public void removeMediaSources_whenUnprepared_expectNoRelease() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource4 = mock(MediaSource.class); + when(mockMediaSource4.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); @@ -319,9 +344,13 @@ public class MediaSourceListTest { @Test public void removeMediaSources_whenPrepared_expectRelease() { MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource3 = mock(MediaSource.class); + when(mockMediaSource3.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource4 = mock(MediaSource.class); + when(mockMediaSource4.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); @@ -350,6 +379,7 @@ public class MediaSourceListTest { @Test public void release_mediaSourceListUnprepared_expectSourcesNotReleased() { MediaSource mockMediaSource = mock(MediaSource.class); + when(mockMediaSource.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSourceList.MediaSourceHolder mediaSourceHolder = new MediaSourceList.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false); @@ -367,6 +397,7 @@ public class MediaSourceListTest { @Test public void release_mediaSourceListPrepared_expectSourcesReleasedNotRemoved() { MediaSource mockMediaSource = mock(MediaSource.class); + when(mockMediaSource.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSourceList.MediaSourceHolder mediaSourceHolder = new MediaSourceList.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false); @@ -387,7 +418,9 @@ public class MediaSourceListTest { ShuffleOrder.DefaultShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); MediaSource mockMediaSource1 = mock(MediaSource.class); + when(mockMediaSource1.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); MediaSource mockMediaSource2 = mock(MediaSource.class); + when(mockMediaSource2.getMediaItem()).thenReturn(MINIMAL_MEDIA_ITEM); List holders = createFakeHoldersWithSources( /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java new file mode 100644 index 0000000000..09b546e89e --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2; + +import static com.google.android.exoplayer2.MetadataRetriever.retrieveMetadata; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.content.Context; +import android.net.Uri; +import android.os.SystemClock; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.util.concurrent.ListenableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.LooperMode; + +/** Tests for {@link MetadataRetriever}. */ +@RunWith(AndroidJUnit4.class) +@LooperMode(LooperMode.Mode.PAUSED) +public class MetadataRetrieverTest { + + @Test + public void retrieveMetadata_singleMediaItem() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); + + ListenableFuture trackGroupsFuture = retrieveMetadata(context, mediaItem); + TrackGroupArray trackGroups = waitAndGetTrackGroups(trackGroupsFuture); + + assertThat(trackGroups.length).isEqualTo(2); + // Video group. + assertThat(trackGroups.get(0).length).isEqualTo(1); + assertThat(trackGroups.get(0).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.VIDEO_H264); + // Audio group. + assertThat(trackGroups.get(1).length).isEqualTo(1); + assertThat(trackGroups.get(1).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_AAC); + } + + @Test + public void retrieveMetadata_multipleMediaItems() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem1 = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); + MediaItem mediaItem2 = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp3/bear-id3.mp3")); + + ListenableFuture trackGroupsFuture1 = retrieveMetadata(context, mediaItem1); + ListenableFuture trackGroupsFuture2 = retrieveMetadata(context, mediaItem2); + TrackGroupArray trackGroups1 = waitAndGetTrackGroups(trackGroupsFuture1); + TrackGroupArray trackGroups2 = waitAndGetTrackGroups(trackGroupsFuture2); + + // First track group. + assertThat(trackGroups1.length).isEqualTo(2); + // First track group - Video group. + assertThat(trackGroups1.get(0).length).isEqualTo(1); + assertThat(trackGroups1.get(0).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.VIDEO_H264); + // First track group - Audio group. + assertThat(trackGroups1.get(1).length).isEqualTo(1); + assertThat(trackGroups1.get(1).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_AAC); + + // Second track group. + assertThat(trackGroups2.length).isEqualTo(1); + // Second track group - Audio group. + assertThat(trackGroups2.get(0).length).isEqualTo(1); + assertThat(trackGroups2.get(0).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_MPEG); + } + + @Test + public void retrieveMetadata_throwsErrorIfCannotLoad() { + Context context = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/does_not_exist")); + + ListenableFuture trackGroupsFuture = retrieveMetadata(context, mediaItem); + + assertThrows(ExecutionException.class, () -> waitAndGetTrackGroups(trackGroupsFuture)); + } + + private static TrackGroupArray waitAndGetTrackGroups( + ListenableFuture trackGroupsFuture) + throws InterruptedException, ExecutionException { + while (!trackGroupsFuture.isDone()) { + // Simulate advancing SystemClock so that delayed messages sent to handlers are received. + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100); + Thread.sleep(/* millis= */ 100); + } + return trackGroupsFuture.get(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java index 874a8c5a5a..490cc520fe 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.Assert.fail; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,7 +30,6 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import org.junit.After; import org.junit.Before; @@ -66,30 +66,30 @@ public class PlayerMessageTest { } @Test - public void experimental_blockUntilDelivered_timesOut() throws Exception { + public void experimentalBlockUntilDelivered_timesOut() throws Exception { when(clock.elapsedRealtime()).thenReturn(0L).thenReturn(TIMEOUT_MS * 2); try { - message.send().experimental_blockUntilDelivered(TIMEOUT_MS, clock); + message.send().experimentalBlockUntilDelivered(TIMEOUT_MS, clock); fail(); } catch (TimeoutException expected) { } - // Ensure experimental_blockUntilDelivered() entered the blocking loop + // Ensure experimentalBlockUntilDelivered() entered the blocking loop verify(clock, Mockito.times(2)).elapsedRealtime(); } @Test - public void experimental_blockUntilDelivered_onAlreadyProcessed_succeeds() throws Exception { + public void experimentalBlockUntilDelivered_onAlreadyProcessed_succeeds() throws Exception { when(clock.elapsedRealtime()).thenReturn(0L); message.send().markAsProcessed(/* isDelivered= */ true); - assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + assertThat(message.experimentalBlockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); } @Test - public void experimental_blockUntilDelivered_markAsProcessedWhileBlocked_succeeds() + public void experimentalBlockUntilDelivered_markAsProcessedWhileBlocked_succeeds() throws Exception { message.send(); @@ -114,10 +114,10 @@ public class PlayerMessageTest { }); try { - assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); - // Ensure experimental_blockUntilDelivered() entered the blocking loop. + assertThat(message.experimentalBlockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + // Ensure experimentalBlockUntilDelivered() entered the blocking loop. verify(clock, Mockito.atLeast(2)).elapsedRealtime(); - future.get(1, TimeUnit.SECONDS); + future.get(1, SECONDS); } finally { executorService.shutdown(); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/SimpleExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/SimpleExoPlayerTest.java new file mode 100644 index 0000000000..e3a625a3ce --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/SimpleExoPlayerTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** Unit test for {@link SimpleExoPlayer}. */ +@RunWith(AndroidJUnit4.class) +public class SimpleExoPlayerTest { + + // TODO(b/143232359): Revert to @Config(sdk = Config.ALL_SDKS) once b/143232359 is resolved + @Test + @Config(minSdk = Config.OLDEST_SDK, maxSdk = Config.TARGET_SDK) + public void builder_inBackgroundThread_doesNotThrow() throws Exception { + Thread builderThread = + new Thread( + () -> new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()).build()); + AtomicReference builderThrow = new AtomicReference<>(); + builderThread.setUncaughtExceptionHandler((thread, throwable) -> builderThrow.set(throwable)); + + builderThread.start(); + builderThread.join(); + + assertThat(builderThrow.get()).isNull(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java index a151507db4..65b0119354 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java @@ -62,7 +62,6 @@ public class TimelineTest { TimelineAsserts.assertNextWindowIndices(timeline, Player.REPEAT_MODE_ALL, false, 0); } - @SuppressWarnings("deprecation") // Tests the deprecated window.tag property. @Test public void windowEquals() { MediaItem mediaItem = new MediaItem.Builder().setUri("uri").setTag(new Object()).build(); @@ -73,10 +72,6 @@ public class TimelineTest { otherWindow.mediaItem = mediaItem; assertThat(window).isNotEqualTo(otherWindow); - otherWindow = new Timeline.Window(); - otherWindow.tag = mediaItem.playbackProperties.tag; - assertThat(window).isNotEqualTo(otherWindow); - otherWindow = new Timeline.Window(); otherWindow.manifest = new Object(); assertThat(window).isNotEqualTo(otherWindow); @@ -145,30 +140,6 @@ public class TimelineTest { assertThat(window).isEqualTo(otherWindow); } - @SuppressWarnings("deprecation") - @Test - public void windowSet_withTag() { - Timeline.Window window = populateWindow(/* mediaItem= */ null, new Object()); - Timeline.Window otherWindow = new Timeline.Window(); - otherWindow = - otherWindow.set( - window.uid, - window.tag, - window.manifest, - window.presentationStartTimeMs, - window.windowStartTimeMs, - window.elapsedRealtimeEpochOffsetMs, - window.isSeekable, - window.isDynamic, - window.isLive, - window.defaultPositionUs, - window.durationUs, - window.firstPeriodIndex, - window.lastPeriodIndex, - window.positionInFirstPeriodUs); - assertThat(window).isEqualTo(otherWindow); - } - @Test public void windowHashCode() { Timeline.Window window = new Timeline.Window(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 1d22984f84..1238831cbc 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.analytics; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.common.truth.Truth.assertThat; import android.view.Surface; @@ -24,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RenderersFactory; @@ -31,6 +34,12 @@ import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Window; import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.ExoMediaDrm; +import com.google.android.exoplayer2.drm.MediaDrmCallback; +import com.google.android.exoplayer2.drm.MediaDrmCallbackException; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.LoadEventInfo; @@ -43,26 +52,28 @@ import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner; import com.google.android.exoplayer2.testutil.FakeAudioRenderer; +import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.testutil.FakeRenderer; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; import com.google.android.exoplayer2.testutil.FakeVideoRenderer; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; /** Integration test for {@link AnalyticsCollector}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public final class AnalyticsCollectorTest { private static final String TAG = "AnalyticsCollectorTest"; @@ -72,7 +83,7 @@ public final class AnalyticsCollectorTest { private static final int EVENT_POSITION_DISCONTINUITY = 2; private static final int EVENT_SEEK_STARTED = 3; private static final int EVENT_SEEK_PROCESSED = 4; - private static final int EVENT_PLAYBACK_SPEED_CHANGED = 5; + private static final int EVENT_PLAYBACK_PARAMETERS_CHANGED = 5; private static final int EVENT_REPEAT_MODE_CHANGED = 6; private static final int EVENT_SHUFFLE_MODE_CHANGED = 7; private static final int EVENT_LOADING_CHANGED = 8; @@ -84,36 +95,66 @@ public final class AnalyticsCollectorTest { private static final int EVENT_LOAD_ERROR = 14; private static final int EVENT_DOWNSTREAM_FORMAT_CHANGED = 15; private static final int EVENT_UPSTREAM_DISCARDED = 16; - private static final int EVENT_MEDIA_PERIOD_CREATED = 17; - private static final int EVENT_MEDIA_PERIOD_RELEASED = 18; - private static final int EVENT_READING_STARTED = 19; - private static final int EVENT_BANDWIDTH_ESTIMATE = 20; - private static final int EVENT_SURFACE_SIZE_CHANGED = 21; - private static final int EVENT_METADATA = 23; - private static final int EVENT_DECODER_ENABLED = 24; - private static final int EVENT_DECODER_INIT = 25; - private static final int EVENT_DECODER_FORMAT_CHANGED = 26; - private static final int EVENT_DECODER_DISABLED = 27; + private static final int EVENT_BANDWIDTH_ESTIMATE = 17; + private static final int EVENT_SURFACE_SIZE_CHANGED = 18; + private static final int EVENT_METADATA = 19; + private static final int EVENT_DECODER_ENABLED = 20; + private static final int EVENT_DECODER_INIT = 21; + private static final int EVENT_DECODER_FORMAT_CHANGED = 22; + private static final int EVENT_DECODER_DISABLED = 23; + private static final int EVENT_AUDIO_ENABLED = 24; + private static final int EVENT_AUDIO_DECODER_INIT = 25; + private static final int EVENT_AUDIO_INPUT_FORMAT_CHANGED = 26; + private static final int EVENT_AUDIO_DISABLED = 27; private static final int EVENT_AUDIO_SESSION_ID = 28; - private static final int EVENT_AUDIO_UNDERRUN = 29; - private static final int EVENT_DROPPED_VIDEO_FRAMES = 30; - private static final int EVENT_VIDEO_SIZE_CHANGED = 31; - private static final int EVENT_RENDERED_FIRST_FRAME = 32; - private static final int EVENT_DRM_KEYS_LOADED = 33; - private static final int EVENT_DRM_ERROR = 34; - private static final int EVENT_DRM_KEYS_RESTORED = 35; - private static final int EVENT_DRM_KEYS_REMOVED = 36; - private static final int EVENT_DRM_SESSION_ACQUIRED = 37; - private static final int EVENT_DRM_SESSION_RELEASED = 38; - private static final int EVENT_VIDEO_FRAME_PROCESSING_OFFSET = 39; + private static final int EVENT_AUDIO_POSITION_ADVANCING = 29; + private static final int EVENT_AUDIO_UNDERRUN = 30; + private static final int EVENT_VIDEO_ENABLED = 31; + private static final int EVENT_VIDEO_DECODER_INIT = 32; + private static final int EVENT_VIDEO_INPUT_FORMAT_CHANGED = 33; + private static final int EVENT_DROPPED_FRAMES = 34; + private static final int EVENT_VIDEO_DISABLED = 35; + private static final int EVENT_RENDERED_FIRST_FRAME = 36; + private static final int EVENT_VIDEO_FRAME_PROCESSING_OFFSET = 37; + private static final int EVENT_VIDEO_SIZE_CHANGED = 38; + private static final int EVENT_DRM_KEYS_LOADED = 39; + private static final int EVENT_DRM_ERROR = 40; + private static final int EVENT_DRM_KEYS_RESTORED = 41; + private static final int EVENT_DRM_KEYS_REMOVED = 42; + private static final int EVENT_DRM_SESSION_ACQUIRED = 43; + private static final int EVENT_DRM_SESSION_RELEASED = 44; - private static final int TIMEOUT_MS = 10000; + private static final UUID DRM_SCHEME_UUID = + UUID.nameUUIDFromBytes(TestUtil.createByteArray(7, 8, 9)); + + public static final DrmInitData DRM_DATA_1 = + new DrmInitData( + new DrmInitData.SchemeData( + DRM_SCHEME_UUID, + ExoPlayerTestRunner.VIDEO_FORMAT.sampleMimeType, + /* data= */ TestUtil.createByteArray(1, 2, 3))); + public static final DrmInitData DRM_DATA_2 = + new DrmInitData( + new DrmInitData.SchemeData( + DRM_SCHEME_UUID, + ExoPlayerTestRunner.VIDEO_FORMAT.sampleMimeType, + /* data= */ TestUtil.createByteArray(4, 5, 6))); + private static final Format VIDEO_FORMAT_DRM_1 = + ExoPlayerTestRunner.VIDEO_FORMAT.buildUpon().setDrmInitData(DRM_DATA_1).build(); + + private static final int TIMEOUT_MS = 10_000; private static final Timeline SINGLE_PERIOD_TIMELINE = new FakeTimeline(/* windowCount= */ 1); private static final EventWindowAndPeriodId WINDOW_0 = new EventWindowAndPeriodId(/* windowIndex= */ 0, /* mediaPeriodId= */ null); private static final EventWindowAndPeriodId WINDOW_1 = new EventWindowAndPeriodId(/* windowIndex= */ 1, /* mediaPeriodId= */ null); + private final DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setMultiSession(true) + .build(new EmptyDrmCallback()); + private EventWindowAndPeriodId period0; private EventWindowAndPeriodId period1; private EventWindowAndPeriodId period0Seq0; @@ -133,9 +174,11 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( - WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, WINDOW_0 /* ENDED */); + WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, WINDOW_0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */) + .inOrder(); listener.assertNoMoreEvents(); } @@ -154,28 +197,42 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, - period0 /* ENDED */); + period0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0 /* started */, period0 /* stopped */); + .containsExactly(period0 /* started */, period0 /* stopped */) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) - .containsExactly(WINDOW_0 /* manifest */, period0 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) - .containsExactly(WINDOW_0 /* manifest */, period0 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0 /* audio */, period0 /* video */); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_POSITION_ADVANCING)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)).containsExactly(period0); @@ -202,43 +259,70 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, - period1 /* ENDED */); + period1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0, period0, period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1); + .containsExactly(period0, period0, period0, period0) + .inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) .containsExactly( - period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0, period1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0, period1); + period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* audio */, period0 /* video */); + .containsExactly(period0 /* audio */, period0 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) .containsExactly( - period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */); + period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly( - period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */); + period0 /* audio */, period0 /* video */, period1 /* audio */, period1 /* video */) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period1); - assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0, period1); - assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0, period1); + assertThat(listener.getEvents(EVENT_AUDIO_POSITION_ADVANCING)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period1); + assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)).containsExactly(period1); listener.assertNoMoreEvents(); } @@ -257,39 +341,55 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, - period1 /* ENDED */); + period1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0, period0, period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1); + .containsExactly(period0, period0, period0, period0) + .inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period1 /* audio */); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0, period1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0, period1); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* video */, period1 /* audio */); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0 /* video */, period1 /* audio */); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period1 /* audio */); - assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0); + .containsExactly(period0 /* video */, period1 /* audio */) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0 /* video */); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period1); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)).containsExactly(period1); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)).containsExactly(period1); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period1); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_AUDIO_POSITION_ADVANCING)).containsExactly(period1); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)).containsExactly(period0); @@ -328,42 +428,67 @@ public final class AnalyticsCollectorTest { period1 /* BUFFERING */, period1 /* setPlayWhenReady=true */, period1 /* READY */, - period1 /* ENDED */); + period1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period1); List loadingEvents = listener.getEvents(EVENT_LOADING_CHANGED); assertThat(loadingEvents).hasSize(4); - assertThat(loadingEvents).containsAtLeast(period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1); + assertThat(loadingEvents).containsAtLeast(period0, period0).inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1, period1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, - period1 /* media */); + period0 /* media */, + period1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)).containsExactly(period0, period1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0, period1); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */); + .containsExactly(period0 /* video */, period0 /* audio */, period1 /* audio */) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)) - .containsExactly(period0 /* video */, period0 /* audio */); - assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)).containsExactly(period0, period1); + .containsExactly(period0 /* video */, period0 /* audio */) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)).containsExactly(period0, period1).inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_POSITION_ADVANCING)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)).containsExactly(period0); listener.assertNoMoreEvents(); @@ -402,55 +527,87 @@ public final class AnalyticsCollectorTest { period0 /* BUFFERING */, period0 /* READY */, period0 /* setPlayWhenReady=true */, - period1Seq2 /* ENDED */); + period1Seq2 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */); + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* SOURCE_UPDATE */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) - .containsExactly(period0, period1Seq2); + .containsExactly(period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0, period0, period0, period0, period0, period0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0, period1Seq2); + .containsExactly(period0, period0, period0, period0, period0, period0) + .inOrder(); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, + period0 /* media */, period1Seq1 /* media */, - period1Seq2 /* media */); + period1Seq2 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, - period0 /* media */, WINDOW_1 /* manifest */, + period0 /* media */, period1Seq1 /* media */, - period1Seq2 /* media */); + period1Seq2 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(period0, period1Seq1, period1Seq2); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)) - .containsExactly(period0, period1Seq1); - assertThat(listener.getEvents(EVENT_READING_STARTED)) - .containsExactly(period0, period1Seq1, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0, period1, period0, period1Seq2); + .containsExactly(period0, period1, period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2); + .containsExactly(period0, period1Seq1, period1Seq1, period1Seq2, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0, period0); + assertThat(listener.getEvents(EVENT_AUDIO_ENABLED)) + .containsExactly(period1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_DECODER_INIT)) + .containsExactly(period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_INPUT_FORMAT_CHANGED)) + .containsExactly(period1Seq1, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_AUDIO_SESSION_ID)) - .containsExactly(period1Seq1, period1Seq2); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(period0, period1Seq2); + .containsExactly(period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_POSITION_ADVANCING)) + .containsExactly(period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_AUDIO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0, period0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0, period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0, period1Seq1, period1Seq2) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)) + .containsExactly(period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(period0, period1Seq1, period0, period1Seq2); + .containsExactly(period0, period1Seq1, period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(period0, period1Seq1, period0, period1Seq2); + .containsExactly(period0, period1Seq1, period0, period1Seq2) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) - .containsExactly(period0, period1Seq2); + .containsExactly(period0, period1Seq2) + .inOrder(); listener.assertNoMoreEvents(); } @@ -490,7 +647,8 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* BUFFERING */, period0Seq1 /* setPlayWhenReady=true */, period0Seq1 /* READY */, - period0Seq1 /* ENDED */); + period0Seq1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGE */, @@ -498,38 +656,56 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* PLAYLIST_CHANGE */, WINDOW_0 /* SOURCE_UPDATE */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) - .containsExactly(period0Seq0, period0Seq0, period0Seq1, period0Seq1); + .containsExactly(period0Seq0, period0Seq0, period0Seq1, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly( - period0Seq0 /* prepared */, WINDOW_0 /* setMediaSources */, period0Seq1 /* prepared */); + period0Seq0 /* prepared */, WINDOW_0 /* setMediaSources */, period0Seq1 /* prepared */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq1 /* media */); + period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq1 /* media */); + period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(period0Seq0, period0Seq1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0Seq0, period0Seq1); - assertThat(listener.getEvents(EVENT_DECODER_ENABLED)).containsExactly(period0Seq0, period0Seq1); - assertThat(listener.getEvents(EVENT_DECODER_INIT)).containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq1); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(period0Seq1); listener.assertNoMoreEvents(); @@ -565,7 +741,8 @@ public final class AnalyticsCollectorTest { period0Seq0 /* BUFFERING */, period0Seq0 /* setPlayWhenReady=true */, period0Seq0 /* READY */, - period0Seq0 /* ENDED */); + period0Seq0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly(WINDOW_0 /* prepared */, WINDOW_0 /* prepared */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period0Seq0); @@ -580,25 +757,29 @@ public final class AnalyticsCollectorTest { WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq0 /* media */); + period0Seq0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* manifest */, period0Seq0 /* media */, WINDOW_0 /* manifest */, - period0Seq0 /* media */); + period0Seq0 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) .containsExactly(period0Seq0, period0Seq0); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(period0Seq0, period0Seq0); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)).containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_DECODER_INIT)).containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0Seq0); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly(period0Seq0, period0Seq0); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) @@ -644,48 +825,61 @@ public final class AnalyticsCollectorTest { period1Seq0 /* setPlayWhenReady=true */, period1Seq0 /* BUFFERING */, period1Seq0 /* READY */, - period1Seq0 /* ENDED */); + period1Seq0 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, - window0Period1Seq0 /* SOURCE_UPDATE (concatenated timeline replaces dummy) */, - period1Seq0 /* SOURCE_UPDATE (child sources in concatenating source moved) */); + window0Period1Seq0 /* SOURCE_UPDATE (concatenated timeline replaces placeholder) */, + period1Seq0 /* SOURCE_UPDATE (child sources in concatenating source moved) */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly( window0Period1Seq0, window0Period1Seq0, window0Period1Seq0, window0Period1Seq0); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(window0Period1Seq0); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( - WINDOW_0 /* manifest */, - window0Period1Seq0 /* media */, - window1Period0Seq1 /* media */); + WINDOW_0 /* manifest */, window0Period1Seq0 /* media */, window1Period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( - WINDOW_0 /* manifest */, - window0Period1Seq0 /* media */, - window1Period0Seq1 /* media */); + WINDOW_0 /* manifest */, window0Period1Seq0 /* media */, window1Period0Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(window1Period0Seq1); - assertThat(listener.getEvents(EVENT_READING_STARTED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(window0Period1Seq0, window0Period1Seq0); + .containsExactly(window0Period1Seq0, window0Period1Seq0) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(window0Period1Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(window0Period1Seq0, period1Seq0); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(window0Period1Seq0, window0Period1Seq0) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(window0Period1Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)) + .containsExactly(window0Period1Seq0, period1Seq0) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(window0Period1Seq0, window1Period0Seq1, period1Seq0); + .containsExactly(window0Period1Seq0, window1Period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) - .containsExactly(window0Period1Seq0, period1Seq0); + .containsExactly(window0Period1Seq0, period1Seq0) + .inOrder(); listener.assertNoMoreEvents(); } @@ -727,37 +921,55 @@ public final class AnalyticsCollectorTest { period0Seq1 /* BUFFERING */, period0Seq1 /* READY */, period0Seq1 /* setPlayWhenReady=true */, - period0Seq1 /* ENDED */); + period0Seq1 /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE (first item) */, period0Seq0 /* PLAYLIST_CHANGED (add) */, period0Seq0 /* SOURCE_UPDATE (second item) */, - period0Seq1 /* PLAYLIST_CHANGED (remove) */); + period0Seq1 /* PLAYLIST_CHANGED (remove) */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0Seq0, period0Seq0, period0Seq0, period0Seq0); - assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0Seq0, period0Seq1); + assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) + .containsExactly(period0Seq0, period0Seq1, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) - .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) - .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */); + .containsExactly(WINDOW_0 /* manifest */, period0Seq0 /* media */, period1Seq1 /* media */) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(period0Seq0, period1Seq1); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)).containsExactly(period0Seq0); - assertThat(listener.getEvents(EVENT_READING_STARTED)).containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(period0Seq0, period0Seq1, period0Seq1); - assertThat(listener.getEvents(EVENT_DECODER_INIT)).containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)) .containsExactly(period0Seq0, period0Seq0); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(period0Seq1); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(period0Seq0, period0Seq1, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(period0Seq0, period0Seq0); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(period0Seq0, period0Seq1); + .containsExactly(period0Seq0, period0Seq1) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) .containsExactly(period0Seq0, period0Seq1); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) @@ -767,14 +979,15 @@ public final class AnalyticsCollectorTest { @Test public void adPlayback() throws Exception { - long contentDurationsUs = 10 * C.MICROS_PER_SECOND; + long contentDurationsUs = 11 * C.MICROS_PER_SECOND; + long windowOffsetInFirstPeriodUs = + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; AtomicReference adPlaybackState = new AtomicReference<>( FakeTimeline.createAdPlaybackState( /* adsPerAdGroup= */ 1, /* adGroupTimesUs...= */ - 0, - TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US - + 5 * C.MICROS_PER_SECOND, + windowOffsetInFirstPeriodUs, + windowOffsetInFirstPeriodUs + 5 * C.MICROS_PER_SECOND, C.TIME_END_OF_SOURCE)); AtomicInteger playedAdCount = new AtomicInteger(0); Timeline adTimeline = @@ -787,7 +1000,23 @@ public final class AnalyticsCollectorTest { contentDurationsUs, adPlaybackState.get())); FakeMediaSource fakeMediaSource = - new FakeMediaSource(adTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); + new FakeMediaSource( + adTimeline, + DrmSessionManager.DUMMY, + (unusedFormat, mediaPeriodId) -> { + if (mediaPeriodId.isAd()) { + return ImmutableList.of(oneByteSample(/* timeUs= */ 0), END_OF_STREAM_ITEM); + } else { + // Provide a single sample before and after the midroll ad and another after the + // postroll. + return ImmutableList.of( + oneByteSample(windowOffsetInFirstPeriodUs + C.MICROS_PER_SECOND), + oneByteSample(windowOffsetInFirstPeriodUs + 6 * C.MICROS_PER_SECOND), + oneByteSample(windowOffsetInFirstPeriodUs + contentDurationsUs), + END_OF_STREAM_ITEM); + } + }, + ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .executeRunnable( @@ -806,7 +1035,7 @@ public final class AnalyticsCollectorTest { adPlaybackState .get() .withPlayedAd( - playedAdCount.getAndIncrement(), + /* adGroupIndex= */ playedAdCount.getAndIncrement(), /* adIndexInAdGroup= */ 0)); fakeMediaSource.setNewSourceInfo( new FakeTimeline( @@ -815,8 +1044,9 @@ public final class AnalyticsCollectorTest { /* id= */ 0, /* isSeekable= */ true, /* isDynamic= */ false, - /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - adPlaybackState.get()))); + contentDurationsUs, + adPlaybackState.get())), + /* sendManifestLoadEvents= */ false); } } }); @@ -840,7 +1070,9 @@ public final class AnalyticsCollectorTest { // Wait in each content part to ensure previously triggered events get a chance to be // delivered. This prevents flakiness caused by playback progressing too fast. .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 3_000) + .waitForPendingPlayerCommands() .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 8_000) + .waitForPendingPlayerCommands() .play() .waitForPlaybackState(Player.STATE_ENDED) // Wait for final timeline change that marks post-roll played. @@ -897,21 +1129,25 @@ public final class AnalyticsCollectorTest { contentAfterPreroll /* setPlayWhenReady=true */, contentAfterMidroll /* setPlayWhenReady=false */, contentAfterMidroll /* setPlayWhenReady=true */, - contentAfterPostroll /* ENDED */); + contentAfterPostroll /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly( WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE (initial) */, contentAfterPreroll /* SOURCE_UPDATE (played preroll) */, contentAfterMidroll /* SOURCE_UPDATE (played midroll) */, - contentAfterPostroll /* SOURCE_UPDATE (played postroll) */); + contentAfterPostroll /* SOURCE_UPDATE (played postroll) */) + .inOrder(); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly( - contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd, contentAfterPostroll); + contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd, contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly( prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, prerollAd, - prerollAd, prerollAd, prerollAd, prerollAd); + prerollAd, prerollAd, prerollAd, prerollAd) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly( prerollAd, @@ -919,31 +1155,28 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* content manifest */, - WINDOW_0 /* preroll manifest */, prerollAd, contentAfterPreroll, - WINDOW_0 /* midroll manifest */, midrollAd, contentAfterMidroll, - WINDOW_0 /* postroll manifest */, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* content manifest */, - WINDOW_0 /* preroll manifest */, prerollAd, contentAfterPreroll, - WINDOW_0 /* midroll manifest */, midrollAd, contentAfterMidroll, - WINDOW_0 /* postroll manifest */, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) .containsExactly( prerollAd, @@ -951,26 +1184,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly( - prerollAd, - contentAfterPreroll, - midrollAd, - contentAfterMidroll, - postrollAd, - contentAfterPostroll); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)) - .containsExactly( - prerollAd, contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd); - assertThat(listener.getEvents(EVENT_READING_STARTED)) - .containsExactly( - prerollAd, - contentAfterPreroll, - midrollAd, - contentAfterMidroll, - postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)).containsExactly(prerollAd); assertThat(listener.getEvents(EVENT_DECODER_INIT)) .containsExactly( @@ -979,7 +1194,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly( prerollAd, @@ -987,9 +1203,30 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll); + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(prerollAd); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly( + prerollAd, + contentAfterPreroll, + midrollAd, + contentAfterMidroll, + postrollAd, + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly( + prerollAd, + contentAfterPreroll, + midrollAd, + contentAfterMidroll, + postrollAd, + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)) + .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly( prerollAd, @@ -997,7 +1234,8 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) .containsExactly( prerollAd, @@ -1005,14 +1243,18 @@ public final class AnalyticsCollectorTest { midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) - .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll); + .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll) + .inOrder(); listener.assertNoMoreEvents(); } @Test public void seekAfterMidroll() throws Exception { + long windowOffsetInFirstPeriodUs = + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; Timeline adTimeline = new FakeTimeline( new TimelineWindowDefinition( @@ -1023,10 +1265,23 @@ public final class AnalyticsCollectorTest { 10 * C.MICROS_PER_SECOND, FakeTimeline.createAdPlaybackState( /* adsPerAdGroup= */ 1, /* adGroupTimesUs...= */ - TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US - + 5 * C.MICROS_PER_SECOND))); + windowOffsetInFirstPeriodUs + 5 * C.MICROS_PER_SECOND))); FakeMediaSource fakeMediaSource = - new FakeMediaSource(adTimeline, ExoPlayerTestRunner.VIDEO_FORMAT); + new FakeMediaSource( + adTimeline, + DrmSessionManager.DUMMY, + (unusedFormat, mediaPeriodId) -> { + if (mediaPeriodId.isAd()) { + return ImmutableList.of(oneByteSample(/* timeUs= */ 0), END_OF_STREAM_ITEM); + } else { + // Provide a sample before the midroll and another after the seek point below (6s). + return ImmutableList.of( + oneByteSample(windowOffsetInFirstPeriodUs + C.MICROS_PER_SECOND), + oneByteSample(windowOffsetInFirstPeriodUs + 7 * C.MICROS_PER_SECOND), + END_OF_STREAM_ITEM); + } + }, + ExoPlayerTestRunner.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) .pause() @@ -1073,14 +1328,16 @@ public final class AnalyticsCollectorTest { contentAfterMidroll /* BUFFERING */, midrollAd /* setPlayWhenReady=true */, midrollAd /* READY */, - contentAfterMidroll /* ENDED */); + contentAfterMidroll /* ENDED */) + .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* SOURCE_UPDATE */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly( contentAfterMidroll /* seek */, midrollAd /* seek adjustment */, - contentAfterMidroll /* ad transition */); + contentAfterMidroll /* ad transition */) + .inOrder(); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(contentBeforeMidroll); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(contentAfterMidroll); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) @@ -1092,7 +1349,8 @@ public final class AnalyticsCollectorTest { contentBeforeMidroll, contentBeforeMidroll, midrollAd, - midrollAd); + midrollAd) + .inOrder(); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) @@ -1101,34 +1359,46 @@ public final class AnalyticsCollectorTest { contentBeforeMidroll, midrollAd, contentAfterMidroll, - contentAfterMidroll); + contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_LOAD_COMPLETED)) .containsExactly( WINDOW_0 /* content manifest */, contentBeforeMidroll, midrollAd, contentAfterMidroll, - contentAfterMidroll); + contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DOWNSTREAM_FORMAT_CHANGED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll, contentAfterMidroll); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_RELEASED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); - assertThat(listener.getEvents(EVENT_READING_STARTED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_ENABLED)) - .containsExactly(contentBeforeMidroll, midrollAd); + .containsExactly(contentBeforeMidroll, midrollAd) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_INIT)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_DECODER_DISABLED)).containsExactly(contentBeforeMidroll); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)).containsExactly(contentAfterMidroll); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)) + .containsExactly(contentBeforeMidroll, midrollAd) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_DISABLED)).containsExactly(contentBeforeMidroll); + assertThat(listener.getEvents(EVENT_DROPPED_FRAMES)).containsExactly(contentAfterMidroll); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) - .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll); + .containsExactly(contentBeforeMidroll, midrollAd, contentAfterMidroll) + .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) .containsExactly(contentAfterMidroll); listener.assertNoMoreEvents(); @@ -1158,6 +1428,182 @@ public final class AnalyticsCollectorTest { assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period0); } + @Test + public void drmEvents_singlePeriod() throws Exception { + MediaSource mediaSource = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)).containsExactly(period0); + // The release event is lost because it's posted to "ExoPlayerTest thread" after that thread + // has been quit during clean-up. + assertThat(listener.getEvents(EVENT_DRM_SESSION_RELEASED)).isEmpty(); + } + + @Test + public void drmEvents_periodWithSameDrmData_keysReused() throws Exception { + MediaSource mediaSource = + new ConcatenatingMediaSource( + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1)); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)).containsExactly(period0); + // The period1 release event is lost because it's posted to "ExoPlayerTest thread" after that + // thread has been quit during clean-up. + assertThat(listener.getEvents(EVENT_DRM_SESSION_RELEASED)).containsExactly(period0); + } + + @Test + public void drmEvents_periodWithDifferentDrmData_keysLoadedAgain() throws Exception { + MediaSource mediaSource = + new ConcatenatingMediaSource( + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, drmSessionManager, VIDEO_FORMAT_DRM_1), + new FakeMediaSource( + SINGLE_PERIOD_TIMELINE, + drmSessionManager, + VIDEO_FORMAT_DRM_1.buildUpon().setDrmInitData(DRM_DATA_2).build())); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).isEmpty(); + assertThat(listener.getEvents(EVENT_DRM_SESSION_ACQUIRED)) + .containsExactly(period0, period1) + .inOrder(); + assertThat(listener.getEvents(EVENT_DRM_KEYS_LOADED)) + .containsExactly(period0, period1) + .inOrder(); + // The period1 release event is lost because it's posted to "ExoPlayerTest thread" after that + // thread has been quit during clean-up. + assertThat(listener.getEvents(EVENT_DRM_SESSION_RELEASED)).containsExactly(period0); + } + + @Test + public void drmEvents_errorHandling() throws Exception { + DrmSessionManager failingDrmSessionManager = + new DefaultDrmSessionManager.Builder().build(new FailingDrmCallback()); + MediaSource mediaSource = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, failingDrmSessionManager, VIDEO_FORMAT_DRM_1); + TestAnalyticsListener listener = runAnalyticsTest(mediaSource); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_DRM_ERROR)).containsExactly(period0); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period0); + } + + @Test + public void onPlayerError_thrownDuringRendererEnableAtPeriodTransition_isReportedForNewPeriod() + throws Exception { + FakeMediaSource source0 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource source1 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_VIDEO), + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + @Override + protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) + throws ExoPlaybackException { + // Fail when enabling the renderer. This will happen during the period transition. + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + }; + + TestAnalyticsListener listener = + runAnalyticsTest( + new ConcatenatingMediaSource(source0, source1), + /* actionSchedule= */ null, + renderersFactory); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); + } + + @Test + public void onPlayerError_thrownDuringRenderAtPeriodTransition_isReportedForNewPeriod() + throws Exception { + FakeMediaSource source0 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT); + FakeMediaSource source1 = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_VIDEO), + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + @Override + public void render(long positionUs, long realtimeUs) throws ExoPlaybackException { + // Fail when rendering the audio stream. This will happen during the period + // transition. + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + }; + + TestAnalyticsListener listener = + runAnalyticsTest( + new ConcatenatingMediaSource(source0, source1), + /* actionSchedule= */ null, + renderersFactory); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); + } + + @Test + public void + onPlayerError_thrownDuringRendererReplaceStreamAtPeriodTransition_isReportedForNewPeriod() + throws Exception { + FakeMediaSource source = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.AUDIO_FORMAT); + RenderersFactory renderersFactory = + (eventHandler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeRenderer(C.TRACK_TYPE_AUDIO) { + private int streamChangeCount = 0; + + @Override + protected void onStreamChanged( + Format[] formats, long startPositionUs, long offsetUs) + throws ExoPlaybackException { + // Fail when changing streams for the second time. This will happen during the + // period transition (as the first time is when enabling the stream initially). + if (++streamChangeCount == 2) { + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + } + }; + + TestAnalyticsListener listener = + runAnalyticsTest( + new ConcatenatingMediaSource(source, source), + /* actionSchedule= */ null, + renderersFactory); + + populateEventIds(listener.lastReportedTimeline); + assertThat(listener.getEvents(EVENT_PLAYER_ERROR)).containsExactly(period1); + } + private void populateEventIds(Timeline timeline) { period0 = new EventWindowAndPeriodId( @@ -1216,6 +1662,14 @@ public final class AnalyticsCollectorTest { new FakeVideoRenderer(eventHandler, videoRendererEventListener), new FakeAudioRenderer(eventHandler, audioRendererEventListener) }; + return runAnalyticsTest(mediaSource, actionSchedule, renderersFactory); + } + + private static TestAnalyticsListener runAnalyticsTest( + MediaSource mediaSource, + @Nullable ActionSchedule actionSchedule, + RenderersFactory renderersFactory) + throws Exception { TestAnalyticsListener listener = new TestAnalyticsListener(); try { new ExoPlayerTestRunner.Builder(ApplicationProvider.getApplicationContext()) @@ -1255,15 +1709,24 @@ public final class AnalyticsCollectorTest { @Override public String toString() { return mediaPeriodId != null - ? "Event{" + ? "{" + "window=" + windowIndex - + ", period=" - + mediaPeriodId.periodUid + ", sequence=" + mediaPeriodId.windowSequenceNumber + + (mediaPeriodId.adGroupIndex != C.INDEX_UNSET + ? ", adGroup=" + + mediaPeriodId.adGroupIndex + + ", adIndexInGroup=" + + mediaPeriodId.adIndexInAdGroup + : "") + + ", period.hashCode=" + + mediaPeriodId.periodUid.hashCode() + + (mediaPeriodId.nextAdGroupIndex != C.INDEX_UNSET + ? ", nextAdGroup=" + mediaPeriodId.nextAdGroupIndex + : "") + '}' - : "Event{" + "window=" + windowIndex + ", period = null}"; + : "{" + "window=" + windowIndex + ", period = null}"; } @Override @@ -1302,6 +1765,7 @@ public final class AnalyticsCollectorTest { assertThat(reportedEvents).isEmpty(); } + @SuppressWarnings("deprecation") // Testing deprecated behaviour. @Override public void onPlayerStateChanged( EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) { @@ -1325,15 +1789,16 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_SEEK_STARTED, eventTime)); } + @SuppressWarnings("deprecation") // Testing deprecated behaviour. @Override public void onSeekProcessed(EventTime eventTime) { reportedEvents.add(new ReportedEvent(EVENT_SEEK_PROCESSED, eventTime)); } - @SuppressWarnings("deprecation") @Override - public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { - reportedEvents.add(new ReportedEvent(EVENT_PLAYBACK_SPEED_CHANGED, eventTime)); + public void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) { + reportedEvents.add(new ReportedEvent(EVENT_PLAYBACK_PARAMETERS_CHANGED, eventTime)); } @Override @@ -1400,21 +1865,6 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_UPSTREAM_DISCARDED, eventTime)); } - @Override - public void onMediaPeriodCreated(EventTime eventTime) { - reportedEvents.add(new ReportedEvent(EVENT_MEDIA_PERIOD_CREATED, eventTime)); - } - - @Override - public void onMediaPeriodReleased(EventTime eventTime) { - reportedEvents.add(new ReportedEvent(EVENT_MEDIA_PERIOD_RELEASED, eventTime)); - } - - @Override - public void onReadingStarted(EventTime eventTime) { - reportedEvents.add(new ReportedEvent(EVENT_READING_STARTED, eventTime)); - } - @Override public void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { @@ -1431,43 +1881,105 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_METADATA, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderEnabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_ENABLED, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderInitialized( EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_INIT, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_FORMAT_CHANGED, eventTime)); } + @SuppressWarnings("deprecation") @Override public void onDecoderDisabled( EventTime eventTime, int trackType, DecoderCounters decoderCounters) { reportedEvents.add(new ReportedEvent(EVENT_DECODER_DISABLED, eventTime)); } + @Override + public void onAudioEnabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_ENABLED, eventTime)); + } + + @Override + public void onAudioDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_DECODER_INIT, eventTime)); + } + + @Override + public void onAudioInputFormatChanged(EventTime eventTime, Format format) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_INPUT_FORMAT_CHANGED, eventTime)); + } + + @Override + public void onAudioDisabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_DISABLED, eventTime)); + } + @Override public void onAudioSessionId(EventTime eventTime, int audioSessionId) { reportedEvents.add(new ReportedEvent(EVENT_AUDIO_SESSION_ID, eventTime)); } + @Override + public void onAudioPositionAdvancing(EventTime eventTime, long playoutStartSystemTimeMs) { + reportedEvents.add(new ReportedEvent(EVENT_AUDIO_POSITION_ADVANCING, eventTime)); + } + @Override public void onAudioUnderrun( EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { reportedEvents.add(new ReportedEvent(EVENT_AUDIO_UNDERRUN, eventTime)); } + @Override + public void onVideoEnabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_ENABLED, eventTime)); + } + + @Override + public void onVideoDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_DECODER_INIT, eventTime)); + } + + @Override + public void onVideoInputFormatChanged(EventTime eventTime, Format format) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_INPUT_FORMAT_CHANGED, eventTime)); + } + @Override public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { - reportedEvents.add(new ReportedEvent(EVENT_DROPPED_VIDEO_FRAMES, eventTime)); + reportedEvents.add(new ReportedEvent(EVENT_DROPPED_FRAMES, eventTime)); + } + + @Override + public void onVideoDisabled(EventTime eventTime, DecoderCounters counters) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_DISABLED, eventTime)); + } + + @Override + public void onVideoFrameProcessingOffset( + EventTime eventTime, long totalProcessingOffsetUs, int frameCount) { + reportedEvents.add(new ReportedEvent(EVENT_VIDEO_FRAME_PROCESSING_OFFSET, eventTime)); + } + + @Override + public void onRenderedFirstFrame(EventTime eventTime, Surface surface) { + reportedEvents.add(new ReportedEvent(EVENT_RENDERED_FIRST_FRAME, eventTime)); } @Override @@ -1480,11 +1992,6 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_VIDEO_SIZE_CHANGED, eventTime)); } - @Override - public void onRenderedFirstFrame(EventTime eventTime, Surface surface) { - reportedEvents.add(new ReportedEvent(EVENT_RENDERED_FIRST_FRAME, eventTime)); - } - @Override public void onDrmSessionAcquired(EventTime eventTime) { reportedEvents.add(new ReportedEvent(EVENT_DRM_SESSION_ACQUIRED, eventTime)); @@ -1515,12 +2022,6 @@ public final class AnalyticsCollectorTest { reportedEvents.add(new ReportedEvent(EVENT_DRM_SESSION_RELEASED, eventTime)); } - @Override - public void onVideoFrameProcessingOffset( - EventTime eventTime, long totalProcessingOffsetUs, int frameCount, Format format) { - reportedEvents.add(new ReportedEvent(EVENT_VIDEO_FRAME_PROCESSING_OFFSET, eventTime)); - } - private static final class ReportedEvent { public final int eventType; @@ -1534,13 +2035,46 @@ public final class AnalyticsCollectorTest { @Override public String toString() { - return "ReportedEvent{" - + "type=" - + eventType - + ", windowAndPeriodId=" - + eventWindowAndPeriodId - + '}'; + return "{" + "type=" + eventType + ", windowAndPeriodId=" + eventWindowAndPeriodId + '}'; } } } + + /** + * A {@link MediaDrmCallback} that returns empty byte arrays for both {@link + * #executeProvisionRequest(UUID, ExoMediaDrm.ProvisionRequest)} and {@link + * #executeKeyRequest(UUID, ExoMediaDrm.KeyRequest)}. + */ + private static final class EmptyDrmCallback implements MediaDrmCallback { + @Override + public byte[] executeProvisionRequest(UUID uuid, ExoMediaDrm.ProvisionRequest request) + throws MediaDrmCallbackException { + return new byte[0]; + } + + @Override + public byte[] executeKeyRequest(UUID uuid, ExoMediaDrm.KeyRequest request) + throws MediaDrmCallbackException { + return new byte[0]; + } + } + + /** + * A {@link MediaDrmCallback} that throws exceptions for both {@link + * #executeProvisionRequest(UUID, ExoMediaDrm.ProvisionRequest)} and {@link + * #executeKeyRequest(UUID, ExoMediaDrm.KeyRequest)}. + */ + private static final class FailingDrmCallback implements MediaDrmCallback { + @Override + public byte[] executeProvisionRequest(UUID uuid, ExoMediaDrm.ProvisionRequest request) + throws MediaDrmCallbackException { + throw new RuntimeException("executeProvision failed"); + } + + @Override + public byte[] executeKeyRequest(UUID uuid, ExoMediaDrm.KeyRequest request) + throws MediaDrmCallbackException { + throw new RuntimeException("executeKey failed"); + } + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java index b24135152e..5f97ad78f2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -39,6 +40,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -160,26 +162,44 @@ public final class DefaultPlaybackSessionManagerTest { @Test public void updateSessions_ofSameWindow_withoutMediaPeriodId_afterAd_doesNotCreateNewSession() { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaPeriodId mediaPeriodId = + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ new Object(), + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000, + FakeTimeline.createAdPlaybackState( + /* adsPerAdGroup= */ 1, /* adGroupTimesUs... */ 0))); + MediaPeriodId adMediaPeriodId = new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 0), /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* windowSequenceNumber= */ 0); - EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); - EventTime eventTime2 = + MediaPeriodId contentMediaPeriodIdDuringAd = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 0); + EventTime adEventTime = createEventTime(timeline, /* windowIndex= */ 0, adMediaPeriodId); + EventTime contentEventTimeDuringAd = + createEventTime( + timeline, /* windowIndex= */ 0, contentMediaPeriodIdDuringAd, adMediaPeriodId); + EventTime contentEventTimeWithoutMediaPeriodId = createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); - sessionManager.updateSessions(eventTime1); - sessionManager.updateSessions(eventTime2); + sessionManager.updateSessions(adEventTime); + sessionManager.updateSessions(contentEventTimeWithoutMediaPeriodId); - ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); - verify(mockListener).onSessionCreated(eq(eventTime1), sessionId.capture()); - verify(mockListener).onSessionActive(eventTime1, sessionId.getValue()); + verify(mockListener).onSessionCreated(eq(contentEventTimeDuringAd), anyString()); + ArgumentCaptor adSessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(adEventTime), adSessionId.capture()); + verify(mockListener).onSessionActive(adEventTime, adSessionId.getValue()); verifyNoMoreInteractions(mockListener); - assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId)) - .isEqualTo(sessionId.getValue()); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, adMediaPeriodId)) + .isEqualTo(adSessionId.getValue()); } @Test @@ -350,18 +370,6 @@ public final class DefaultPlaybackSessionManagerTest { verifyNoMoreInteractions(mockListener); } - @Test - public void getSessionForMediaPeriodId_returnsValue_butDoesNotCreateSession() { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaPeriodId mediaPeriodId = - new MediaPeriodId( - timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); - String session = sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId); - - assertThat(session).isNotEmpty(); - verifyNoMoreInteractions(mockListener); - } - @Test public void updateSessions_afterSessionForMediaPeriodId_withSameMediaPeriodId_returnsSameValue() { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); @@ -399,6 +407,81 @@ public final class DefaultPlaybackSessionManagerTest { assertThat(sessionId.getValue()).isEqualTo(expectedSessionId); } + @Test + public void + updateSessions_withNewAd_afterDiscontinuitiesFromContentToAdAndBack_doesNotActivateNewAd() { + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs =*/ 10 * C.MICROS_PER_SECOND, + new AdPlaybackState( + /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); + EventTime adEventTime1 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime adEventTime2 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime contentEventTime1 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 0)); + EventTime contentEventTime2 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 1)); + sessionManager.updateSessionsWithTimelineChange(contentEventTime1); + sessionManager.updateSessions(adEventTime1); + sessionManager.updateSessionsWithDiscontinuity( + adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.updateSessionsWithDiscontinuity( + contentEventTime2, Player.DISCONTINUITY_REASON_AD_INSERTION); + String adSessionId2 = + sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); + + sessionManager.updateSessions(adEventTime2); + + verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2)); + } + + @Test + public void getSessionForMediaPeriodId_returnsValue_butDoesNotCreateSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + String session = sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId); + + assertThat(session).isNotEmpty(); + verifyNoMoreInteractions(mockListener); + } + @Test public void belongsToSession_withSameWindowIndex_returnsTrue() { EventTime eventTime = @@ -465,28 +548,38 @@ public final class DefaultPlaybackSessionManagerTest { @Test public void belongsToSession_withAd_returnsFalse() { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaPeriodId mediaPeriodId1 = + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ new Object(), + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000, + FakeTimeline.createAdPlaybackState( + /* adsPerAdGroup= */ 1, /* adGroupTimesUs... */ 0))); + MediaPeriodId contentMediaPeriodId = new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); - MediaPeriodId mediaPeriodId2 = + MediaPeriodId adMediaPeriodId = new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 0), /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* windowSequenceNumber= */ 1); - EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); - EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); - sessionManager.updateSessions(eventTime1); - sessionManager.updateSessions(eventTime2); + EventTime contentEventTime = + createEventTime(timeline, /* windowIndex= */ 0, contentMediaPeriodId); + EventTime adEventTime = createEventTime(timeline, /* windowIndex= */ 0, adMediaPeriodId); + sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessions(adEventTime); ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); - verify(mockListener).onSessionCreated(eq(eventTime1), sessionId1.capture()); - verify(mockListener).onSessionCreated(eq(eventTime2), sessionId2.capture()); - assertThat(sessionManager.belongsToSession(eventTime2, sessionId1.getValue())).isFalse(); - assertThat(sessionManager.belongsToSession(eventTime1, sessionId2.getValue())).isFalse(); - assertThat(sessionManager.belongsToSession(eventTime2, sessionId2.getValue())).isTrue(); + verify(mockListener).onSessionCreated(eq(contentEventTime), sessionId1.capture()); + verify(mockListener).onSessionCreated(eq(adEventTime), sessionId2.capture()); + assertThat(sessionManager.belongsToSession(adEventTime, sessionId1.getValue())).isFalse(); + assertThat(sessionManager.belongsToSession(contentEventTime, sessionId2.getValue())).isFalse(); + assertThat(sessionManager.belongsToSession(adEventTime, sessionId2.getValue())).isTrue(); } @Test @@ -501,8 +594,7 @@ public final class DefaultPlaybackSessionManagerTest { EventTime newTimelineEventTime = createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); - sessionManager.handleTimelineUpdate(newTimelineEventTime); - sessionManager.updateSessions(newTimelineEventTime); + sessionManager.updateSessionsWithTimelineChange(newTimelineEventTime); ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); @@ -545,8 +637,7 @@ public final class DefaultPlaybackSessionManagerTest { new MediaPeriodId( initialTimeline.getUidOfPeriod(/* periodIndex= */ 3), /* windowSequenceNumber= */ 2)); - sessionManager.handleTimelineUpdate(eventForInitialTimelineId100); - sessionManager.updateSessions(eventForInitialTimelineId100); + sessionManager.updateSessionsWithTimelineChange(eventForInitialTimelineId100); sessionManager.updateSessions(eventForInitialTimelineId200); sessionManager.updateSessions(eventForInitialTimelineId300); String sessionId100 = @@ -578,7 +669,7 @@ public final class DefaultPlaybackSessionManagerTest { timelineUpdate.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 2)); - sessionManager.handleTimelineUpdate(eventForTimelineUpdateId100); + sessionManager.updateSessionsWithTimelineChange(eventForTimelineUpdateId100); String updatedSessionId100 = sessionManager.getSessionForMediaPeriodId( timelineUpdate, eventForTimelineUpdateId100.mediaPeriodId); @@ -632,7 +723,7 @@ public final class DefaultPlaybackSessionManagerTest { sessionManager.updateSessions(contentEventTime); sessionManager.updateSessions(adEventTime); - sessionManager.handleTimelineUpdate(contentEventTime); + sessionManager.updateSessionsWithTimelineChange(contentEventTime); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -653,13 +744,11 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 0, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0)); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eq(eventTime1), anyString()); verify(mockListener).onSessionActive(eq(eventTime1), anyString()); @@ -681,17 +770,15 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 1, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); String sessionId1 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime1.mediaPeriodId); String sessionId2 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - sessionManager.updateSessions(eventTime2); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -717,16 +804,14 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 1, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); String sessionId1 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime1.mediaPeriodId); String sessionId2 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(eventTime2); + sessionManager.updateSessionsWithDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -748,12 +833,10 @@ public final class DefaultPlaybackSessionManagerTest { timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); - sessionManager.handleTimelineUpdate(eventTime1); - sessionManager.updateSessions(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime2); - sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(eventTime2); + sessionManager.updateSessionsWithDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -785,7 +868,7 @@ public final class DefaultPlaybackSessionManagerTest { /* windowIndex= */ 3, new MediaPeriodId( timeline.getUidOfPeriod(/* periodIndex= */ 3), /* windowSequenceNumber= */ 3)); - sessionManager.handleTimelineUpdate(eventTime1); + sessionManager.updateSessionsWithTimelineChange(eventTime1); sessionManager.updateSessions(eventTime1); sessionManager.updateSessions(eventTime2); sessionManager.updateSessions(eventTime3); @@ -795,8 +878,7 @@ public final class DefaultPlaybackSessionManagerTest { String sessionId2 = sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity(eventTime3, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(eventTime3); + sessionManager.updateSessionsWithDiscontinuity(eventTime3, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener).onSessionCreated(eventTime1, sessionId1); verify(mockListener).onSessionActive(eventTime1, sessionId1); @@ -842,7 +924,20 @@ public final class DefaultPlaybackSessionManagerTest { /* adGroupIndex= */ 1, /* adIndexInAdGroup= */ 0, /* windowSequenceNumber= */ 0)); - EventTime contentEventTime = + EventTime contentEventTimeDuringPreroll = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + /* eventMediaPeriodId= */ new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 0), + /* currentMediaPeriodId= */ new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime contentEventTimeBetweenAds = createEventTime( adTimeline, /* windowIndex= */ 0, @@ -850,25 +945,31 @@ public final class DefaultPlaybackSessionManagerTest { adTimeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ 1)); - sessionManager.handleTimelineUpdate(adEventTime1); - sessionManager.updateSessions(adEventTime1); + sessionManager.updateSessionsWithTimelineChange(adEventTime1); sessionManager.updateSessions(adEventTime2); String adSessionId1 = sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime1.mediaPeriodId); + String contentSessionId = + sessionManager.getSessionForMediaPeriodId( + adTimeline, contentEventTimeDuringPreroll.mediaPeriodId); - sessionManager.handlePositionDiscontinuity( - contentEventTime, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessionsWithDiscontinuity( + contentEventTimeBetweenAds, Player.DISCONTINUITY_REASON_AD_INSERTION); - verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); - verify(mockListener).onSessionActive(adEventTime1, adSessionId1); - verify(mockListener).onSessionCreated(eq(adEventTime2), anyString()); - verify(mockListener) + InOrder inOrder = inOrder(mockListener); + inOrder.verify(mockListener).onSessionCreated(contentEventTimeDuringPreroll, contentSessionId); + inOrder.verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); + inOrder.verify(mockListener).onSessionActive(adEventTime1, adSessionId1); + inOrder.verify(mockListener).onAdPlaybackStarted(adEventTime1, contentSessionId, adSessionId1); + inOrder.verify(mockListener).onSessionCreated(eq(adEventTime2), anyString()); + inOrder + .verify(mockListener) .onSessionFinished( - contentEventTime, adSessionId1, /* automaticTransitionToNextPlayback= */ true); - verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); - verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); - verifyNoMoreInteractions(mockListener); + contentEventTimeBetweenAds, + adSessionId1, + /* automaticTransitionToNextPlayback= */ true); + inOrder.verify(mockListener).onSessionActive(eq(contentEventTimeBetweenAds), anyString()); + inOrder.verifyNoMoreInteractions(); } @Test @@ -911,14 +1012,12 @@ public final class DefaultPlaybackSessionManagerTest { adTimeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ 0)); - sessionManager.handleTimelineUpdate(contentEventTime); - sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessionsWithTimelineChange(contentEventTime); sessionManager.updateSessions(adEventTime1); sessionManager.updateSessions(adEventTime2); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(adEventTime1); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -962,8 +1061,7 @@ public final class DefaultPlaybackSessionManagerTest { adTimeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0, /* nextAdGroupIndex= */ 1)); - sessionManager.handleTimelineUpdate(contentEventTime); - sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessionsWithTimelineChange(contentEventTime); sessionManager.updateSessions(adEventTime1); sessionManager.updateSessions(adEventTime2); String contentSessionId = @@ -973,11 +1071,9 @@ public final class DefaultPlaybackSessionManagerTest { String adSessionId2 = sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); - sessionManager.handlePositionDiscontinuity( + sessionManager.updateSessionsWithDiscontinuity( adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(adEventTime1); - sessionManager.handlePositionDiscontinuity(adEventTime2, Player.DISCONTINUITY_REASON_SEEK); - sessionManager.updateSessions(adEventTime2); + sessionManager.updateSessionsWithDiscontinuity(adEventTime2, Player.DISCONTINUITY_REASON_SEEK); verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); @@ -993,72 +1089,6 @@ public final class DefaultPlaybackSessionManagerTest { verifyNoMoreInteractions(mockListener); } - @Test - public void - updateSessions_withNewAd_afterDiscontinuitiesFromContentToAdAndBack_doesNotActivateNewAd() { - Timeline adTimeline = - new FakeTimeline( - new TimelineWindowDefinition( - /* periodCount= */ 1, - /* id= */ 0, - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - new AdPlaybackState( - /* adGroupTimesUs=... */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND) - .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) - .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); - EventTime adEventTime1 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* adGroupIndex= */ 0, - /* adIndexInAdGroup= */ 0, - /* windowSequenceNumber= */ 0)); - EventTime adEventTime2 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* adGroupIndex= */ 1, - /* adIndexInAdGroup= */ 0, - /* windowSequenceNumber= */ 0)); - EventTime contentEventTime1 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* windowSequenceNumber= */ 0, - /* nextAdGroupIndex= */ 0)); - EventTime contentEventTime2 = - createEventTime( - adTimeline, - /* windowIndex= */ 0, - new MediaPeriodId( - adTimeline.getUidOfPeriod(/* periodIndex= */ 0), - /* windowSequenceNumber= */ 0, - /* nextAdGroupIndex= */ 1)); - sessionManager.handleTimelineUpdate(contentEventTime1); - sessionManager.updateSessions(contentEventTime1); - sessionManager.updateSessions(adEventTime1); - sessionManager.handlePositionDiscontinuity( - adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(adEventTime1); - sessionManager.handlePositionDiscontinuity( - contentEventTime2, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(contentEventTime2); - String adSessionId2 = - sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); - - sessionManager.updateSessions(adEventTime2); - - verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2)); - } - @Test public void finishAllSessions_callsOnSessionFinishedForAllCreatedSessions() { Timeline timeline = new FakeTimeline(/* windowCount= */ 4); @@ -1092,6 +1122,27 @@ public final class DefaultPlaybackSessionManagerTest { windowIndex, mediaPeriodId, /* eventPlaybackPositionMs= */ 0, + timeline, + windowIndex, + mediaPeriodId, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + } + + private static EventTime createEventTime( + Timeline timeline, + int windowIndex, + @Nullable MediaPeriodId eventMediaPeriodId, + @Nullable MediaPeriodId currentMediaPeriodId) { + return new EventTime( + /* realtimeMs = */ 0, + timeline, + windowIndex, + eventMediaPeriodId, + /* eventPlaybackPositionMs= */ 0, + timeline, + windowIndex, + currentMediaPeriodId, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java index c6d4a597ed..1f19c2af58 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 @@ -26,6 +26,7 @@ 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.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaSource; @@ -44,20 +45,27 @@ public final class PlaybackStatsListenerTest { /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + /* currentTimeline= */ Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); private static final Timeline TEST_TIMELINE = new FakeTimeline(/* windowCount= */ 1); + private static final MediaSource.MediaPeriodId TEST_MEDIA_PERIOD_ID = + new MediaSource.MediaPeriodId( + TEST_TIMELINE.getPeriod(/* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true) + .uid, + /* windowSequenceNumber= */ 42); private static final AnalyticsListener.EventTime TEST_EVENT_TIME = new AnalyticsListener.EventTime( /* realtimeMs= */ 500, TEST_TIMELINE, /* windowIndex= */ 0, - new MediaSource.MediaPeriodId( - TEST_TIMELINE.getPeriod( - /* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true) - .uid, - /* windowSequenceNumber= */ 42), + TEST_MEDIA_PERIOD_ID, /* eventPlaybackPositionMs= */ 123, + TEST_TIMELINE, + /* currentWindowIndex= */ 0, + TEST_MEDIA_PERIOD_ID, /* currentPlaybackPositionMs= */ 123, /* totalBufferedDurationMs= */ 456); @@ -68,8 +76,8 @@ public final class PlaybackStatsListenerTest { playbackStatsListener.onPositionDiscontinuity( EMPTY_TIMELINE_EVENT_TIME, Player.DISCONTINUITY_REASON_SEEK); - playbackStatsListener.onPlaybackSpeedChanged( - EMPTY_TIMELINE_EVENT_TIME, /* playbackSpeed= */ 2.0f); + playbackStatsListener.onPlaybackParametersChanged( + EMPTY_TIMELINE_EVENT_TIME, new PlaybackParameters(/* speed= */ 2.0f)); playbackStatsListener.onPlayWhenReadyChanged( EMPTY_TIMELINE_EVENT_TIME, /* playWhenReady= */ true, @@ -151,6 +159,9 @@ public final class PlaybackStatsListenerTest { /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); AnalyticsListener.EventTime eventTimeWindow1 = @@ -160,6 +171,9 @@ public final class PlaybackStatsListenerTest { /* windowIndex= */ 1, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 1, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, /* totalBufferedDurationMs= */ 0); PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java index bfc657aaf4..7e9126be98 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DecoderAudioRendererTest.java @@ -19,6 +19,7 @@ import static com.google.android.exoplayer2.RendererCapabilities.ADAPTIVE_NOT_SE import static com.google.android.exoplayer2.RendererCapabilities.FORMAT_HANDLED; import static com.google.android.exoplayer2.RendererCapabilities.TUNNELING_NOT_SUPPORTED; import static com.google.android.exoplayer2.RendererCapabilities.TUNNELING_SUPPORTED; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -33,9 +34,12 @@ import com.google.android.exoplayer2.decoder.DecoderException; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -51,13 +55,13 @@ public class DecoderAudioRendererTest { new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_RAW).build(); @Mock private AudioSink mockAudioSink; - private DecoderAudioRenderer audioRenderer; + private DecoderAudioRenderer audioRenderer; @Before public void setUp() throws Exception { MockitoAnnotations.initMocks(this); audioRenderer = - new DecoderAudioRenderer(null, null, mockAudioSink) { + new DecoderAudioRenderer(null, null, mockAudioSink) { @Override public String getName() { return "TestAudioRenderer"; @@ -70,14 +74,12 @@ public class DecoderAudioRendererTest { } @Override - protected SimpleDecoder< - DecoderInputBuffer, ? extends SimpleOutputBuffer, ? extends DecoderException> - createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) { + protected FakeDecoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) { return new FakeDecoder(); } @Override - protected Format getOutputFormat() { + protected Format getOutputFormat(FakeDecoder decoder) { return FORMAT; } }; @@ -103,10 +105,16 @@ public class DecoderAudioRendererTest { audioRenderer.enable( RendererConfiguration.DEFAULT, new Format[] {FORMAT}, - new FakeSampleStream(FORMAT, /* eventDispatcher= */ null, /* shouldOutputSample= */ false), + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + FORMAT, + ImmutableList.of(END_OF_STREAM_ITEM)), /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs= */ 0); audioRenderer.setCurrentStreamFinal(); when(mockAudioSink.isEnded()).thenReturn(true); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java index 6fb27f46c9..2f86988d42 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/DefaultAudioSinkTest.java @@ -15,12 +15,18 @@ */ package com.google.android.exoplayer2.audio; +import static com.google.android.exoplayer2.audio.AudioSink.CURRENT_POSITION_NOT_SET; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY; +import static com.google.android.exoplayer2.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING; import static com.google.common.truth.Truth.assertThat; import static org.robolectric.annotation.Config.OLDEST_SDK; import static org.robolectric.annotation.Config.TARGET_SDK; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.util.MimeTypes; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; @@ -29,18 +35,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; -/** - * Unit tests for {@link DefaultAudioSink}. - * - *

      Note: the Robolectric-provided AudioTrack instantiated in the audio sink uses only the Java - * part of AudioTrack with a {@code ShadowPlayerBase} underneath. This means it will not consume - * data (i.e., the {@link android.media.AudioTrack#write} methods just return 0), so these tests are - * currently limited to verifying behavior that doesn't rely on consuming data, and the position - * will stay at its initial value. For example, we can't verify {@link - * AudioSink#handleBuffer(ByteBuffer, long, int)} handling a complete buffer, or queueing audio then - * draining to the end of the stream. This could be worked around by having a test-only mode where - * {@link DefaultAudioSink} automatically treats audio as consumed. - */ +/** Unit tests for {@link DefaultAudioSink}. */ @RunWith(AndroidJUnit4.class) public final class DefaultAudioSinkTest { @@ -50,6 +45,11 @@ public final class DefaultAudioSinkTest { private static final int SAMPLE_RATE_44_1 = 44100; private static final int TRIM_100_MS_FRAME_COUNT = 4410; private static final int TRIM_10_MS_FRAME_COUNT = 441; + private static final Format STEREO_44_1_FORMAT = + new Format.Builder() + .setChannelCount(CHANNEL_COUNT_STEREO) + .setSampleRate(SAMPLE_RATE_44_1) + .build(); private DefaultAudioSink defaultAudioSink; private ArrayAudioBufferSink arrayAudioBufferSink; @@ -63,7 +63,9 @@ public final class DefaultAudioSinkTest { new DefaultAudioSink( AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, new DefaultAudioSink.DefaultAudioProcessorChain(teeAudioProcessor), - /* enableConvertHighResIntPcmToFloat= */ false); + /* enableFloatOutput= */ false, + /* enableAudioTrackPlaybackParams= */ false, + /* enableOffload= */ false); } @Test @@ -87,8 +89,8 @@ public final class DefaultAudioSinkTest { } @Test - public void handlesBufferAfterReset_withPlaybackParameters() throws Exception { - defaultAudioSink.setPlaybackSpeed(/* playbackSpeed= */ 1.5f); + public void handlesBufferAfterReset_withPlaybackSpeed() throws Exception { + defaultAudioSink.setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.5f)); configureDefaultAudioSink(CHANNEL_COUNT_STEREO); defaultAudioSink.handleBuffer( createDefaultSilenceBuffer(), /* presentationTimeUs= */ 0, /* encodedAccessUnitCount= */ 1); @@ -98,7 +100,8 @@ public final class DefaultAudioSinkTest { configureDefaultAudioSink(CHANNEL_COUNT_STEREO); defaultAudioSink.handleBuffer( createDefaultSilenceBuffer(), /* presentationTimeUs= */ 0, /* encodedAccessUnitCount= */ 1); - assertThat(defaultAudioSink.getPlaybackSpeed()).isEqualTo(1.5f); + assertThat(defaultAudioSink.getPlaybackParameters()) + .isEqualTo(new PlaybackParameters(/* speed= */ 1.5f)); } @Test @@ -115,8 +118,8 @@ public final class DefaultAudioSinkTest { } @Test - public void handlesBufferAfterReset_withFormatChangeAndPlaybackParameters() throws Exception { - defaultAudioSink.setPlaybackSpeed(/* playbackSpeed= */ 1.5f); + public void handlesBufferAfterReset_withFormatChangeAndPlaybackSpeed() throws Exception { + defaultAudioSink.setPlaybackParameters(new PlaybackParameters(/* speed= */ 1.5f)); configureDefaultAudioSink(CHANNEL_COUNT_STEREO); defaultAudioSink.handleBuffer( createDefaultSilenceBuffer(), /* presentationTimeUs= */ 0, /* encodedAccessUnitCount= */ 1); @@ -126,7 +129,8 @@ public final class DefaultAudioSinkTest { configureDefaultAudioSink(CHANNEL_COUNT_MONO); defaultAudioSink.handleBuffer( createDefaultSilenceBuffer(), /* presentationTimeUs= */ 0, /* encodedAccessUnitCount= */ 1); - assertThat(defaultAudioSink.getPlaybackSpeed()).isEqualTo(1.5f); + assertThat(defaultAudioSink.getPlaybackParameters()) + .isEqualTo(new PlaybackParameters(/* speed= */ 1.5f)); } @Test @@ -197,22 +201,110 @@ public final class DefaultAudioSinkTest { .isEqualTo(8 * C.MICROS_PER_SECOND); } + @Test + public void floatPcmNeedsTranscodingIfFloatOutputDisabled() { + defaultAudioSink = + new DefaultAudioSink( + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + new AudioProcessor[0], + /* enableFloatOutput= */ false); + Format floatFormat = + STEREO_44_1_FORMAT + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_FLOAT) + .build(); + assertThat(defaultAudioSink.getFormatSupport(floatFormat)) + .isEqualTo(SINK_FORMAT_SUPPORTED_WITH_TRANSCODING); + } + @Config(minSdk = OLDEST_SDK, maxSdk = 20) @Test - public void doesNotSupportFloatOutputBeforeApi21() { - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) - .isFalse(); + public void floatPcmNeedsTranscodingIfFloatOutputEnabledBeforeApi21() { + defaultAudioSink = + new DefaultAudioSink( + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + new AudioProcessor[0], + /* enableFloatOutput= */ true); + Format floatFormat = + STEREO_44_1_FORMAT + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_FLOAT) + .build(); + assertThat(defaultAudioSink.getFormatSupport(floatFormat)) + .isEqualTo(SINK_FORMAT_SUPPORTED_WITH_TRANSCODING); } @Config(minSdk = 21, maxSdk = TARGET_SDK) @Test - public void supportsFloatOutputFromApi21() { - assertThat( - defaultAudioSink.supportsOutput( - CHANNEL_COUNT_STEREO, SAMPLE_RATE_44_1, C.ENCODING_PCM_FLOAT)) - .isTrue(); + public void floatOutputSupportedIfFloatOutputEnabledFromApi21() { + defaultAudioSink = + new DefaultAudioSink( + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + new AudioProcessor[0], + /* enableFloatOutput= */ true); + Format floatFormat = + STEREO_44_1_FORMAT + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_FLOAT) + .build(); + assertThat(defaultAudioSink.getFormatSupport(floatFormat)) + .isEqualTo(SINK_FORMAT_SUPPORTED_DIRECTLY); + } + + @Test + public void supportsFloatPcm() { + Format floatFormat = + STEREO_44_1_FORMAT + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_FLOAT) + .build(); + assertThat(defaultAudioSink.supportsFormat(floatFormat)).isTrue(); + } + + @Test + public void audioSinkWithAacAudioCapabilitiesWithoutOffload_doesNotSupportAac() { + DefaultAudioSink defaultAudioSink = + new DefaultAudioSink( + new AudioCapabilities(new int[] {C.ENCODING_AAC_LC}, 2), new AudioProcessor[0]); + Format aacLcFormat = + STEREO_44_1_FORMAT + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_AAC) + .setPcmEncoding(C.ENCODING_AAC_LC) + .build(); + assertThat(defaultAudioSink.supportsFormat(aacLcFormat)).isFalse(); + } + + @Test + public void handlesBufferAfterExperimentalFlush() throws Exception { + // This is demonstrating that no Exceptions are thrown as a result of handling a buffer after an + // experimental flush. + configureDefaultAudioSink(CHANNEL_COUNT_STEREO); + defaultAudioSink.handleBuffer( + createDefaultSilenceBuffer(), /* presentationTimeUs= */ 0, /* encodedAccessUnitCount= */ 1); + + // After the experimental flush we can successfully queue more input. + defaultAudioSink.experimentalFlushWithoutAudioTrackRelease(); + defaultAudioSink.handleBuffer( + createDefaultSilenceBuffer(), + /* presentationTimeUs= */ 5_000, + /* encodedAccessUnitCount= */ 1); + } + + @Test + public void getCurrentPosition_returnsUnset_afterExperimentalFlush() throws Exception { + configureDefaultAudioSink(CHANNEL_COUNT_STEREO); + defaultAudioSink.handleBuffer( + createDefaultSilenceBuffer(), + /* presentationTimeUs= */ 5 * C.MICROS_PER_SECOND, + /* encodedAccessUnitCount= */ 1); + defaultAudioSink.experimentalFlushWithoutAudioTrackRelease(); + assertThat(defaultAudioSink.getCurrentPositionUs(/* sourceEnded= */ false)) + .isEqualTo(CURRENT_POSITION_NOT_SET); } private void configureDefaultAudioSink(int channelCount) throws AudioSink.ConfigurationException { @@ -221,14 +313,16 @@ public final class DefaultAudioSinkTest { private void configureDefaultAudioSink(int channelCount, int trimStartFrames, int trimEndFrames) throws AudioSink.ConfigurationException { - defaultAudioSink.configure( - C.ENCODING_PCM_16BIT, - channelCount, - SAMPLE_RATE_44_1, - /* specifiedBufferSize= */ 0, - /* outputChannels= */ null, - /* trimStartFrames= */ trimStartFrames, - /* trimEndFrames= */ trimEndFrames); + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(channelCount) + .setSampleRate(SAMPLE_RATE_44_1) + .setEncoderDelay(trimStartFrames) + .setEncoderPadding(trimEndFrames) + .build(); + defaultAudioSink.configure(format, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); } /** Creates a one second silence buffer for 44.1 kHz stereo 16-bit audio. */ diff --git a/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java new file mode 100644 index 0000000000..922431d210 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -0,0 +1,292 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.format; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.media.MediaFormat; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.testutil.FakeSampleStream; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.util.Collections; +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; +import org.robolectric.annotation.Config; + +/** Unit tests for {@link MediaCodecAudioRenderer} */ +@Config(sdk = 29) +@RunWith(AndroidJUnit4.class) +public class MediaCodecAudioRendererTest { + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private static final Format AUDIO_AAC = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_AAC) + .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(2) + .setSampleRate(44100) + .setEncoderDelay(100) + .setEncoderPadding(150) + .build(); + + private MediaCodecAudioRenderer mediaCodecAudioRenderer; + private MediaCodecSelector mediaCodecSelector; + + @Mock private AudioSink audioSink; + + @Before + public void setUp() throws Exception { + // audioSink isEnded can always be true because the MediaCodecAudioRenderer isEnded = + // super.isEnded && audioSink.isEnded. + when(audioSink.isEnded()).thenReturn(true); + + when(audioSink.handleBuffer(any(), anyLong(), anyInt())).thenReturn(true); + + mediaCodecSelector = + (mimeType, requiresSecureDecoder, requiresTunnelingDecoder) -> + Collections.singletonList( + MediaCodecInfo.newInstance( + /* name= */ "name", + /* mimeType= */ mimeType, + /* codecMimeType= */ mimeType, + /* capabilities= */ null, + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false)); + + mediaCodecAudioRenderer = + new MediaCodecAudioRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* enableDecoderFallback= */ false, + /* eventHandler= */ null, + /* eventListener= */ null, + audioSink); + } + + @Test + public void render_configuresAudioSink_afterFormatChange() throws Exception { + Format changedFormat = AUDIO_AAC.buildUpon().setSampleRate(48_000).setEncoderDelay(400).build(); + + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ AUDIO_AAC, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 50, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 100, C.BUFFER_FLAG_KEY_FRAME), + format(changedFormat), + oneByteSample(/* timeUs= */ 150, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 200, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 250, C.BUFFER_FLAG_KEY_FRAME), + END_OF_STREAM_ITEM)); + + mediaCodecAudioRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {AUDIO_AAC, changedFormat}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + + mediaCodecAudioRenderer.start(); + mediaCodecAudioRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.setCurrentStreamFinal(); + + int positionUs = 500; + do { + mediaCodecAudioRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 250; + } while (!mediaCodecAudioRenderer.isEnded()); + + verify(audioSink) + .configure( + getAudioSinkFormat(AUDIO_AAC), + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null); + + verify(audioSink) + .configure( + getAudioSinkFormat(changedFormat), + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null); + } + + @Test + public void render_configuresAudioSink_afterGaplessFormatChange() throws Exception { + Format changedFormat = + AUDIO_AAC.buildUpon().setEncoderDelay(400).setEncoderPadding(232).build(); + + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ AUDIO_AAC, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 50, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 100, C.BUFFER_FLAG_KEY_FRAME), + format(changedFormat), + oneByteSample(/* timeUs= */ 150, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 200, C.BUFFER_FLAG_KEY_FRAME), + oneByteSample(/* timeUs= */ 250, C.BUFFER_FLAG_KEY_FRAME), + END_OF_STREAM_ITEM)); + + mediaCodecAudioRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {AUDIO_AAC, changedFormat}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + + mediaCodecAudioRenderer.start(); + mediaCodecAudioRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + mediaCodecAudioRenderer.setCurrentStreamFinal(); + + int positionUs = 500; + do { + mediaCodecAudioRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 250; + } while (!mediaCodecAudioRenderer.isEnded()); + + verify(audioSink) + .configure( + getAudioSinkFormat(AUDIO_AAC), + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null); + + verify(audioSink) + .configure( + getAudioSinkFormat(changedFormat), + /* specifiedBufferSize= */ 0, + /* outputChannels= */ null); + } + + @Test + public void render_throwsExoPlaybackExceptionJustOnce_whenSet() throws Exception { + MediaCodecAudioRenderer exceptionThrowingRenderer = + new MediaCodecAudioRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* eventHandler= */ null, + /* eventListener= */ null) { + @Override + protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaFormat) + throws ExoPlaybackException { + super.onOutputFormatChanged(format, mediaFormat); + if (!format.equals(AUDIO_AAC)) { + setPendingPlaybackException( + ExoPlaybackException.createForRenderer( + new AudioSink.ConfigurationException("Test"), + "rendererName", + /* rendererIndex= */ 0, + format, + FORMAT_HANDLED)); + } + } + }; + + Format changedFormat = AUDIO_AAC.buildUpon().setSampleRate(32_000).build(); + + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ AUDIO_AAC, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), END_OF_STREAM_ITEM)); + + exceptionThrowingRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {AUDIO_AAC, changedFormat}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + + exceptionThrowingRenderer.start(); + exceptionThrowingRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + exceptionThrowingRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + + MediaFormat mediaFormat = new MediaFormat(); + mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 2); + mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, 32_000); + // Simulating the exception being thrown when not traceable back to render. + exceptionThrowingRenderer.onOutputFormatChanged(changedFormat, mediaFormat); + + assertThrows( + ExoPlaybackException.class, + () -> + exceptionThrowingRenderer.render( + /* positionUs= */ 500, SystemClock.elapsedRealtime() * 1000)); + + // Doesn't throw an exception because it's cleared after being thrown in the previous call to + // render. + exceptionThrowingRenderer.render(/* positionUs= */ 750, SystemClock.elapsedRealtime() * 1000); + } + + private static Format getAudioSinkFormat(Format inputFormat) { + return new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(inputFormat.channelCount) + .setSampleRate(inputFormat.sampleRate) + .setEncoderDelay(inputFormat.encoderDelay) + .setEncoderPadding(inputFormat.encoderPadding) + .build(); + } +} 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 fac1c4e322..9c14c37587 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.audio; import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.min; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -37,7 +38,7 @@ public final class SilenceSkippingAudioProcessorTest { /* sampleRate= */ 1000, /* channelCount= */ 2, /* encoding= */ C.ENCODING_PCM_16BIT); private static final int TEST_SIGNAL_SILENCE_DURATION_MS = 1000; private static final int TEST_SIGNAL_NOISE_DURATION_MS = 1000; - private static final int TEST_SIGNAL_FRAME_COUNT = 100000; + private static final int TEST_SIGNAL_FRAME_COUNT = 100_000; private static final int INPUT_BUFFER_SIZE = 100; @@ -202,6 +203,33 @@ public final class SilenceSkippingAudioProcessorTest { assertThat(silenceSkippingAudioProcessor.getSkippedFrames()).isEqualTo(42020); } + @Test + 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. @@ -293,7 +321,7 @@ public final class SilenceSkippingAudioProcessorTest { ByteBuffer inputBuffer = ByteBuffer.allocate(sizeBytes).order(ByteOrder.nativeOrder()); ShortBuffer inputBufferAsShortBuffer = inputBuffer.asShortBuffer(); int limit = buffer.limit(); - buffer.limit(Math.min(buffer.position() + sizeBytes / 2, limit)); + buffer.limit(min(buffer.position() + sizeBytes / 2, limit)); inputBufferAsShortBuffer.put(buffer); buffer.limit(limit); inputBuffer.limit(inputBufferAsShortBuffer.position() * 2); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java index 2d74175265..f74b0ada91 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java @@ -85,16 +85,4 @@ public class VersionTableTest { .isEqualTo(VersionTable.VERSION_UNSET); assertThat(VersionTable.getVersion(database, FEATURE_1, INSTANCE_2)).isEqualTo(2); } - - @Test - public void doesTableExist_nonExistingTable_returnsFalse() { - assertThat(VersionTable.tableExists(database, "NonExistingTable")).isFalse(); - } - - @Test - public void doesTableExist_existingTable_returnsTrue() { - String table = "TestTable"; - databaseProvider.getWritableDatabase().execSQL("CREATE TABLE " + table + " (dummy INTEGER)"); - assertThat(VersionTable.tableExists(database, table)).isTrue(); - } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java new file mode 100644 index 0000000000..a700350b0b --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManagerTest.java @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.drm; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; + +import android.os.Looper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.testutil.FakeExoMediaDrm; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.util.UUID; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowLooper; + +/** Tests for {@link DefaultDrmSessionManager} and {@link DefaultDrmSession}. */ +// TODO: Test more branches: +// - Different sources for licenseServerUrl. +// - Multiple acquisitions & releases for same keys -> multiple requests. +// - Provisioning. +// - Key denial. +@RunWith(AndroidJUnit4.class) +public class DefaultDrmSessionManagerTest { + + private static final UUID DRM_SCHEME_UUID = + UUID.nameUUIDFromBytes(TestUtil.createByteArray(7, 8, 9)); + private static final ImmutableList DRM_SCHEME_DATAS = + ImmutableList.of( + new DrmInitData.SchemeData( + DRM_SCHEME_UUID, MimeTypes.VIDEO_MP4, /* data= */ TestUtil.createByteArray(1, 2, 3))); + private static final Format FORMAT_WITH_DRM_INIT_DATA = + new Format.Builder().setDrmInitData(new DrmInitData(DRM_SCHEME_DATAS)).build(); + + @Test(timeout = 10_000) + public void acquireSession_triggersKeyLoadAndSessionIsOpened() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + + DefaultDrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .build(/* mediaDrmCallback= */ licenseServer); + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + FORMAT_WITH_DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + assertThat(drmSession.queryKeyStatus()) + .containsExactly(FakeExoMediaDrm.KEY_STATUS_KEY, FakeExoMediaDrm.KEY_STATUS_AVAILABLE); + } + + @Test(timeout = 10_000) + public void keepaliveEnabled_sessionsKeptForRequestedTime() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + FORMAT_WITH_DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + drmSession.release(/* eventDispatcher= */ null); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + ShadowLooper.idleMainLooper(10, SECONDS); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void keepaliveDisabled_sessionsReleasedImmediately() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(C.TIME_UNSET) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + FORMAT_WITH_DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + drmSession.release(/* eventDispatcher= */ null); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void managerRelease_allKeepaliveSessionsImmediatelyReleased() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession drmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + FORMAT_WITH_DRM_INIT_DATA); + waitForOpenedWithKeys(drmSession); + drmSession.release(/* eventDispatcher= */ null); + + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + drmSessionManager.release(); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + } + + @Test(timeout = 10_000) + public void maxConcurrentSessionsExceeded_allKeepAliveSessionsEagerlyReleased() throws Exception { + ImmutableList secondSchemeDatas = + ImmutableList.of(DRM_SCHEME_DATAS.get(0).copyWithData(TestUtil.createByteArray(4, 5, 6))); + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS, secondSchemeDatas); + Format secondFormatWithDrmInitData = + new Format.Builder().setDrmInitData(new DrmInitData(secondSchemeDatas)).build(); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm(/* maxConcurrentSessions= */ 1)) + .setSessionKeepaliveMs(10_000) + .setMultiSession(true) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession firstDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + FORMAT_WITH_DRM_INIT_DATA); + waitForOpenedWithKeys(firstDrmSession); + firstDrmSession.release(/* eventDispatcher= */ null); + + // All external references to firstDrmSession have been released, it's being kept alive by + // drmSessionManager's internal reference. + assertThat(firstDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + DrmSession secondDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + secondFormatWithDrmInitData); + // The drmSessionManager had to release firstDrmSession in order to acquire secondDrmSession. + assertThat(firstDrmSession.getState()).isEqualTo(DrmSession.STATE_RELEASED); + + waitForOpenedWithKeys(secondDrmSession); + assertThat(secondDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + } + + @Test(timeout = 10_000) + public void sessionReacquired_keepaliveTimeOutCancelled() throws Exception { + FakeExoMediaDrm.LicenseServer licenseServer = + FakeExoMediaDrm.LicenseServer.allowingSchemeDatas(DRM_SCHEME_DATAS); + DrmSessionManager drmSessionManager = + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(DRM_SCHEME_UUID, uuid -> new FakeExoMediaDrm()) + .setSessionKeepaliveMs(10_000) + .build(/* mediaDrmCallback= */ licenseServer); + + drmSessionManager.prepare(); + DrmSession firstDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + FORMAT_WITH_DRM_INIT_DATA); + waitForOpenedWithKeys(firstDrmSession); + firstDrmSession.release(/* eventDispatcher= */ null); + + ShadowLooper.idleMainLooper(5, SECONDS); + + // Acquire a session for the same init data 5s in to the 10s timeout (so expect the same + // instance). + DrmSession secondDrmSession = + drmSessionManager.acquireSession( + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + /* eventDispatcher= */ null, + FORMAT_WITH_DRM_INIT_DATA); + assertThat(secondDrmSession).isSameInstanceAs(firstDrmSession); + + // Let the timeout definitely expire, and check the session didn't get released. + ShadowLooper.idleMainLooper(10, SECONDS); + assertThat(secondDrmSession.getState()).isEqualTo(DrmSession.STATE_OPENED_WITH_KEYS); + } + + private static void waitForOpenedWithKeys(DrmSession drmSession) { + // Check the error first, so we get a meaningful failure if there's been an error. + assertThat(drmSession.getError()).isNull(); + assertThat(drmSession.getState()).isEqualTo(DrmSession.STATE_OPENED); + while (drmSession.getState() != DrmSession.STATE_OPENED_WITH_KEYS) { + // Allow the key response to be handled. + ShadowLooper.idleMainLooper(); + } + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java index c36c6cff38..ae579b1b7c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/OfflineLicenseHelperTest.java @@ -24,8 +24,8 @@ import static org.mockito.Mockito.when; import android.util.Pair; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import java.util.HashMap; import org.junit.After; import org.junit.Before; @@ -33,11 +33,9 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.robolectric.annotation.LooperMode; /** Tests {@link OfflineLicenseHelper}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class OfflineLicenseHelperTest { private OfflineLicenseHelper offlineLicenseHelper; @@ -53,11 +51,11 @@ public class OfflineLicenseHelperTest { new ExoMediaDrm.KeyRequest(/* data= */ new byte[0], /* licenseServerUrl= */ "")); offlineLicenseHelper = new OfflineLicenseHelper( - C.WIDEVINE_UUID, - new ExoMediaDrm.AppManagedProvider(mediaDrm), - mediaDrmCallback, - /* optionalKeyRequestParameters= */ null, - new MediaSourceEventDispatcher()); + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + C.WIDEVINE_UUID, new ExoMediaDrm.AppManagedProvider(mediaDrm)) + .build(mediaDrmCallback), + new DrmSessionEventListener.EventDispatcher()); } @After @@ -73,7 +71,8 @@ public class OfflineLicenseHelperTest { byte[] keySetId = {2, 5, 8}; setStubKeySetId(keySetId); - byte[] offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(newDrmInitData()); + byte[] offlineLicenseKeySetId = + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); assertOfflineLicenseKeySetIdEqual(keySetId, offlineLicenseKeySetId); @@ -88,9 +87,9 @@ public class OfflineLicenseHelperTest { } @Test - public void downloadLicenseFailsIfNullInitData() throws Exception { + public void downloadLicenseFailsIfNullDrmInitData() throws Exception { try { - offlineLicenseHelper.downloadLicense(null); + offlineLicenseHelper.downloadLicense(new Format.Builder().build()); fail(); } catch (IllegalArgumentException e) { // Expected. @@ -102,7 +101,7 @@ public class OfflineLicenseHelperTest { setStubLicenseAndPlaybackDurationValues(1000, 200); try { - offlineLicenseHelper.downloadLicense(newDrmInitData()); + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); fail(); } catch (Exception e) { // Expected. @@ -113,7 +112,8 @@ public class OfflineLicenseHelperTest { public void downloadLicenseDoesNotFailIfDurationNotAvailable() throws Exception { setDefaultStubKeySetId(); - byte[] offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(newDrmInitData()); + byte[] offlineLicenseKeySetId = + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); assertThat(offlineLicenseKeySetId).isNotNull(); } @@ -125,7 +125,8 @@ public class OfflineLicenseHelperTest { setStubLicenseAndPlaybackDurationValues(licenseDuration, playbackDuration); setDefaultStubKeySetId(); - byte[] offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(newDrmInitData()); + byte[] offlineLicenseKeySetId = + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); Pair licenseDurationRemainingSec = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId); @@ -141,7 +142,8 @@ public class OfflineLicenseHelperTest { setStubLicenseAndPlaybackDurationValues(licenseDuration, playbackDuration); setDefaultStubKeySetId(); - byte[] offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(newDrmInitData()); + byte[] offlineLicenseKeySetId = + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); Pair licenseDurationRemainingSec = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId); @@ -176,8 +178,11 @@ public class OfflineLicenseHelperTest { when(mediaDrm.queryKeyStatus(any(byte[].class))).thenReturn(keyStatus); } - private static DrmInitData newDrmInitData() { - return new DrmInitData( - new SchemeData(C.WIDEVINE_UUID, "mimeType", new byte[] {1, 4, 7, 0, 3, 6})); + private static Format newFormatWithDrmInitData() { + return new Format.Builder() + .setDrmInitData( + new DrmInitData( + new SchemeData(C.WIDEVINE_UUID, "mimeType", new byte[] {1, 4, 7, 0, 3, 6}))) + .build(); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java new file mode 100644 index 0000000000..f37610d982 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/Mp4PlaybackTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.e2etest; + +import android.graphics.SurfaceTexture; +import android.view.Surface; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.e2etest.util.PlaybackOutput; +import com.google.android.exoplayer2.e2etest.util.ShadowMediaCodecConfig; +import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; +import com.google.android.exoplayer2.testutil.DumpFileAsserts; +import com.google.android.exoplayer2.testutil.TestExoPlayer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** End-to-end tests using MP4 samples. */ +// TODO(b/143232359): Remove once https://issuetracker.google.com/143232359 is resolved. +@Config(sdk = 29) +@RunWith(AndroidJUnit4.class) +public class Mp4PlaybackTest { + @Rule + public ShadowMediaCodecConfig mediaCodecConfig = + ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + + @Test + public void h264VideoAacAudio() throws Exception { + SimpleExoPlayer player = + new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) + .setClock(new AutoAdvancingFakeClock()) + .build(); + player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, mediaCodecConfig); + + player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4")); + player.prepare(); + player.play(); + TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + ApplicationProvider.getApplicationContext(), + playbackOutput, + "playbackdumps/mp4/sample.mp4.dump"); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java new file mode 100644 index 0000000000..d57f06ff52 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/TsPlaybackTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.e2etest; + +import android.graphics.SurfaceTexture; +import android.view.Surface; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.e2etest.util.PlaybackOutput; +import com.google.android.exoplayer2.e2etest.util.ShadowMediaCodecConfig; +import com.google.android.exoplayer2.testutil.AutoAdvancingFakeClock; +import com.google.android.exoplayer2.testutil.DumpFileAsserts; +import com.google.android.exoplayer2.testutil.TestExoPlayer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** End-to-end tests using TS samples. */ +// TODO(b/143232359): Remove once https://issuetracker.google.com/143232359 is resolved. +@Config(sdk = 29) +@RunWith(AndroidJUnit4.class) +public class TsPlaybackTest { + + @Rule + public ShadowMediaCodecConfig mediaCodecConfig = + ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + + @Test + public void mpegVideoMpegAudioScte35() throws Exception { + SimpleExoPlayer player = + new SimpleExoPlayer.Builder(ApplicationProvider.getApplicationContext()) + .setClock(new AutoAdvancingFakeClock()) + .build(); + player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, mediaCodecConfig); + + player.setMediaItem(MediaItem.fromUri("asset:///media/ts/sample_scte35.ts")); + player.prepare(); + player.play(); + TestExoPlayer.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + DumpFileAsserts.assertOutput( + ApplicationProvider.getApplicationContext(), + playbackOutput, + "playbackdumps/ts/sample_scte35.ts.dump"); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java new file mode 100644 index 0000000000..f9c32d34b5 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/PlaybackOutput.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.e2etest.util; + +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.testutil.Dumper; +import com.google.android.exoplayer2.util.Assertions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Class to capture output from a playback test. + * + *

      Implements {@link Dumper.Dumpable} so the output can be easily dumped to a string for + * comparison against previous test runs. + */ +public final class PlaybackOutput implements Dumper.Dumpable { + + private final ShadowMediaCodecConfig codecConfig; + + // TODO: Add support for subtitles too + private final List metadatas; + + private PlaybackOutput(SimpleExoPlayer player, ShadowMediaCodecConfig codecConfig) { + this.codecConfig = codecConfig; + + metadatas = Collections.synchronizedList(new ArrayList<>()); + // TODO: Consider passing playback position into MetadataOutput and TextOutput. Calling + // player.getCurrentPosition() inside onMetadata/Cues will likely be non-deterministic + // because renderer-thread != playback-thread. + player.addMetadataOutput(metadatas::add); + } + + /** + * Create an instance that captures the metadata and text output from {@code player} and the audio + * and video output via the {@link TeeCodec TeeCodecs} exposed by {@code mediaCodecConfig}. + * + *

      Must be called before playback to ensure metadata and text output is captured + * correctly. + * + * @param player The {@link SimpleExoPlayer} to capture metadata and text output from. + * @param mediaCodecConfig The {@link ShadowMediaCodecConfig} to capture audio and video output + * from. + * @return A new instance that can be used to dump the playback output. + */ + public static PlaybackOutput register( + SimpleExoPlayer player, ShadowMediaCodecConfig mediaCodecConfig) { + return new PlaybackOutput(player, mediaCodecConfig); + } + + @Override + public void dump(Dumper dumper) { + ImmutableMap codecs = codecConfig.getCodecs(); + ImmutableList mimeTypes = ImmutableList.sortedCopyOf(codecs.keySet()); + for (String mimeType : mimeTypes) { + dumper.add(Assertions.checkNotNull(codecs.get(mimeType))); + } + + dumpMetadata(dumper); + } + + private void dumpMetadata(Dumper dumper) { + if (metadatas.isEmpty()) { + return; + } + dumper.startBlock("MetadataOutput"); + for (int i = 0; i < metadatas.size(); i++) { + dumper.startBlock("Metadata[" + i + "]"); + Metadata metadata = metadatas.get(i); + for (int j = 0; j < metadata.length(); j++) { + dumper.add("entry[" + j + "]", metadata.get(j).getClass().getSimpleName()); + } + dumper.endBlock(); + } + dumper.endBlock(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java new file mode 100644 index 0000000000..6d7f23107e --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/ShadowMediaCodecConfig.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.e2etest.util; + +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.primitives.Ints; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.rules.ExternalResource; +import org.robolectric.shadows.MediaCodecInfoBuilder; +import org.robolectric.shadows.ShadowMediaCodec; +import org.robolectric.shadows.ShadowMediaCodecList; + +/** + * A JUnit @Rule to configure Roboelectric's {@link ShadowMediaCodec}. + * + *

      Registers a {@link org.robolectric.shadows.ShadowMediaCodec.CodecConfig} for each audio/video + * MIME type known by ExoPlayer, and provides access to the bytes passed to these via {@link + * TeeCodec}. + */ +public final class ShadowMediaCodecConfig extends ExternalResource { + + private final Map codecsByMimeType; + + private ShadowMediaCodecConfig() { + this.codecsByMimeType = new HashMap<>(); + } + + public static ShadowMediaCodecConfig forAllSupportedMimeTypes() { + return new ShadowMediaCodecConfig(); + } + + public ImmutableMap getCodecs() { + return ImmutableMap.copyOf(codecsByMimeType); + } + + @Override + protected void before() throws Throwable { + // Video codecs + MediaCodecInfo.CodecProfileLevel avcProfileLevel = + createProfileLevel( + MediaCodecInfo.CodecProfileLevel.AVCProfileHigh, + MediaCodecInfo.CodecProfileLevel.AVCLevel62); + configureCodec( + /* codecName= */ "exotest.video.avc", + MimeTypes.VIDEO_H264, + ImmutableList.of(avcProfileLevel), + ImmutableList.of(MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)); + MediaCodecInfo.CodecProfileLevel mpeg2ProfileLevel = + createProfileLevel( + MediaCodecInfo.CodecProfileLevel.MPEG2ProfileMain, + MediaCodecInfo.CodecProfileLevel.MPEG2LevelML); + configureCodec( + /* codecName= */ "exotest.video.mpeg2", + MimeTypes.VIDEO_MPEG2, + ImmutableList.of(mpeg2ProfileLevel), + ImmutableList.of(MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)); + + // Audio codecs + configureCodec("exotest.audio.aac", MimeTypes.AUDIO_AAC); + configureCodec("exotest.audio.mpegl2", MimeTypes.AUDIO_MPEG_L2); + } + + @Override + protected void after() { + codecsByMimeType.clear(); + ShadowMediaCodecList.reset(); + ShadowMediaCodec.clearCodecs(); + } + + private void configureCodec(String codecName, String mimeType) { + configureCodec( + codecName, + mimeType, + /* profileLevels= */ ImmutableList.of(), + /* colorFormats= */ ImmutableList.of()); + } + + private void configureCodec( + String codecName, + String mimeType, + List profileLevels, + List colorFormats) { + MediaFormat mediaFormat = new MediaFormat(); + mediaFormat.setString(MediaFormat.KEY_MIME, mimeType); + MediaCodecInfoBuilder.CodecCapabilitiesBuilder capabilities = + MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder().setMediaFormat(mediaFormat); + if (!profileLevels.isEmpty()) { + capabilities.setProfileLevels(profileLevels.toArray(new MediaCodecInfo.CodecProfileLevel[0])); + } + if (!colorFormats.isEmpty()) { + capabilities.setColorFormats(Ints.toArray(colorFormats)); + } + ShadowMediaCodecList.addCodec( + MediaCodecInfoBuilder.newBuilder() + .setName(codecName) + .setCapabilities(capabilities.build()) + .build()); + // TODO: Update ShadowMediaCodec to consider the MediaFormat.KEY_MAX_INPUT_SIZE value passed + // to configure() so we don't have to specify large buffers here. + TeeCodec codec = new TeeCodec(mimeType); + ShadowMediaCodec.addDecoder( + codecName, + new ShadowMediaCodec.CodecConfig( + /* inputBufferSize= */ 50_000, /* outputBufferSize= */ 50_000, codec)); + codecsByMimeType.put(mimeType, codec); + } + + private static MediaCodecInfo.CodecProfileLevel createProfileLevel(int profile, int level) { + MediaCodecInfo.CodecProfileLevel profileLevel = new MediaCodecInfo.CodecProfileLevel(); + profileLevel.profile = profile; + profileLevel.level = level; + return profileLevel; + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java new file mode 100644 index 0000000000..a14787e959 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/e2etest/util/TeeCodec.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.e2etest.util; + +import com.google.android.exoplayer2.testutil.Dumper; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.robolectric.shadows.ShadowMediaCodec; + +/** + * A {@link ShadowMediaCodec.CodecConfig.Codec} for Robolectric's {@link ShadowMediaCodec} that + * records the contents of buffers passed to it before copying the contents into the output buffer. + * + *

      This also implements {@link Dumper.Dumpable} so the recorded buffers can be written out to a + * dump file. + */ +public final class TeeCodec implements ShadowMediaCodec.CodecConfig.Codec, Dumper.Dumpable { + + private final String mimeType; + private final List receivedBuffers; + + public TeeCodec(String mimeType) { + this.mimeType = mimeType; + this.receivedBuffers = Collections.synchronizedList(new ArrayList<>()); + } + + @Override + public void process(ByteBuffer in, ByteBuffer out) { + byte[] bytes = new byte[in.remaining()]; + in.get(bytes); + receivedBuffers.add(bytes); + + if (!MimeTypes.isAudio(mimeType)) { + // Don't output audio bytes, because ShadowAudioTrack doesn't advance the playback position so + // playback never completes. + // TODO: Update ShadowAudioTrack to advance the playback position in a realistic way. + out.put(bytes); + } + } + + @Override + public void dump(Dumper dumper) { + if (receivedBuffers.isEmpty()) { + return; + } + dumper.startBlock("MediaCodec (" + mimeType + ")"); + dumper.add("buffers.length", receivedBuffers.size()); + for (int i = 0; i < receivedBuffers.size(); i++) { + dumper.add("buffers[" + i + "]", receivedBuffers.get(i)); + } + + dumper.endBlock(); + } + + /** + * Return the buffers received by this codec. + * + *

      The list is sorted in the order the buffers were passed to {@link #process(ByteBuffer, + * ByteBuffer)}. + */ + public ImmutableList getReceivedBuffers() { + return ImmutableList.copyOf(receivedBuffers); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java index f816d1d11b..dc32ce65a1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java @@ -16,55 +16,58 @@ package com.google.android.exoplayer2.mediacodec; -import static com.google.android.exoplayer2.testutil.TestUtil.assertBufferInfosEqual; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; +import static org.robolectric.Shadows.shadowOf; import android.media.MediaCodec; import android.media.MediaFormat; -import android.os.Handler; import android.os.HandlerThread; -import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; +import java.lang.reflect.Constructor; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.Shadows; -import org.robolectric.annotation.LooperMode; +import org.robolectric.shadows.ShadowLooper; /** Unit tests for {@link AsynchronousMediaCodecAdapter}. */ -@LooperMode(LEGACY) @RunWith(AndroidJUnit4.class) public class AsynchronousMediaCodecAdapterTest { private AsynchronousMediaCodecAdapter adapter; private MediaCodec codec; - private HandlerThread handlerThread; - private Looper looper; + private TestHandlerThread handlerThread; private MediaCodec.BufferInfo bufferInfo; @Before public void setUp() throws IOException { - handlerThread = new HandlerThread("TestHandler"); - handlerThread.start(); - looper = handlerThread.getLooper(); codec = MediaCodec.createByCodecName("h264"); - adapter = new AsynchronousMediaCodecAdapter(codec, looper); - adapter.setCodecStartRunnable(() -> {}); + handlerThread = new TestHandlerThread("TestHandlerThread"); + adapter = + new AsynchronousMediaCodecAdapter( + codec, + /* enableAsynchronousQueueing= */ false, + /* trackType= */ C.TRACK_TYPE_VIDEO, + handlerThread); bufferInfo = new MediaCodec.BufferInfo(); } @After public void tearDown() { adapter.shutdown(); - handlerThread.quit(); + + assertThat(handlerThread.hasQuit()).isTrue(); } @Test public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + // After adapter.start(), the ShadowMediaCodec offers one input buffer. We pause the looper so + // that the buffer is not propagated to the adapter. + shadowOf(handlerThread.getLooper()).pause(); adapter.start(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); @@ -72,171 +75,257 @@ public class AsynchronousMediaCodecAdapterTest { @Test public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 0); + // After start(), the ShadowMediaCodec offers input buffer 0. We advance the looper to make sure + // and messages have been propagated to the adapter. + shadowOf(handlerThread.getLooper()).idle(); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } @Test - public void dequeueInputBufferIndex_whileFlushing_returnsTryAgainLater() { + public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 0); + + // After adapter.start(), the ShadowMediaCodec offers input buffer 0. We run all currently + // enqueued messages and pause the looper so that flush is not completed. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); + shadowLooper.pause(); adapter.flush(); - adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 1); assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test - public void dequeueInputBufferIndex_afterFlushCompletes_returnsNextInputBuffer() { + public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInputBuffer() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - Handler handler = new Handler(looper); - handler.post( - () -> adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 0)); - adapter.flush(); // enqueues a flush event on the looper - handler.post( - () -> adapter.getMediaCodecCallback().onInputBufferAvailable(codec, /* index=*/ 1)); + // After adapter.start(), the ShadowMediaCodec offers input buffer 0. We advance the looper to + // make sure all messages have been propagated to the adapter. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(1); + adapter.flush(); + // Progress the looper to complete flush(): the adapter should call codec.start(), triggering + // the ShadowMediaCodec to offer input buffer 0. + shadowLooper.idle(); + + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); } @Test - public void dequeueInputBufferIndex_afterFlushCompletesWithError_throwsException() { - AtomicInteger calls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (calls.incrementAndGet() == 2) { - throw new IllegalStateException(); - } - }); + public void dequeueInputBufferIndex_withMediaCodecError_throwsException() throws Exception { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + // Pause the looper so that we interact with the adapter from this thread only. + shadowOf(handlerThread.getLooper()).pause(); adapter.start(); - adapter.flush(); - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThrows( - IllegalStateException.class, - () -> { - adapter.dequeueInputBufferIndex(); - }); + // Set an error directly on the adapter (not through the looper). + adapter.onError(codec, createCodecException()); + + assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); + } + + @Test + public void dequeueInputBufferIndex_afterShutdown_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + adapter.start(); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. We progress the looper so that we call shutdown() on a + // non-empty adapter. + shadowOf(handlerThread.getLooper()).idle(); + + adapter.shutdown(); + + assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test public void dequeueOutputBufferIndex_withoutOutputBuffer_returnsTryAgainLater() { - adapter.start(); + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + adapter.start(); + // After start(), the ShadowMediaCodec offers an output format change. We progress the looper + // so that the format change is propagated to the adapter. + shadowOf(handlerThread.getLooper()).idle(); + + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // Assert that output buffer is available. assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - outBufferInfo.presentationTimeUs = 10; - adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 0, outBufferInfo); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(0); - assertBufferInfosEqual(bufferInfo, outBufferInfo); + int index = adapter.dequeueInputBufferIndex(); + adapter.queueInputBuffer(index, 0, 0, 0, 0); + // Progress the looper so that the ShadowMediaCodec processes the input buffer. + shadowLooper.idle(); + + // The ShadowMediaCodec will first offer an output format and then the output buffer. + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // Assert it's the ShadowMediaCodec's output format + assertThat(adapter.getOutputFormat().getByteBuffer("csd-0")).isNotNull(); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(index); } @Test - public void dequeueOutputBufferIndex_whileFlushing_returnsTryAgainLater() { + public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 0, bufferInfo); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); + + // Flush enqueues a task in the looper, but we will pause the looper to leave flush() + // in an incomplete state. + shadowLooper.pause(); adapter.flush(); - adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 1, bufferInfo); assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test - public void dequeueOutputBufferIndex_afterFlushCompletes_returnsNextOutputBuffer() { + public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() throws Exception { + // Pause the looper so that we interact with the adapter from this thread only. + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + shadowOf(handlerThread.getLooper()).pause(); adapter.start(); - Handler handler = new Handler(looper); - MediaCodec.BufferInfo info0 = new MediaCodec.BufferInfo(); - handler.post( - () -> adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 0, info0)); - adapter.flush(); // enqueues a flush event on the looper - MediaCodec.BufferInfo info1 = new MediaCodec.BufferInfo(); - info1.presentationTimeUs = 1; - handler.post( - () -> adapter.getMediaCodecCallback().onOutputBufferAvailable(codec, /* index=*/ 1, info1)); - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(1); - assertBufferInfosEqual(info1, bufferInfo); - } + // Set an error directly on the adapter. + adapter.onError(codec, createCodecException()); - @Test - public void dequeueOutputBufferIndex_afterFlushCompletesWithError_throwsException() { - AtomicInteger calls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (calls.incrementAndGet() == 2) { - throw new RuntimeException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); } @Test - public void getOutputFormat_withMultipleFormats_returnsFormatsInCorrectOrder() { + public void dequeueOutputBufferIndex_afterShutdown_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - MediaFormat[] formats = new MediaFormat[10]; - MediaCodec.Callback mediaCodecCallback = adapter.getMediaCodecCallback(); - for (int i = 0; i < formats.length; i++) { - formats[i] = new MediaFormat(); - mediaCodecCallback.onOutputFormatChanged(codec, formats[i]); - } + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); + + int index = adapter.dequeueInputBufferIndex(); + adapter.queueInputBuffer(index, 0, 0, 0, 0); + // Progress the looper so that the ShadowMediaCodec processes the input buffer. + shadowLooper.idle(); + adapter.shutdown(); - for (MediaFormat format : formats) { - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - // Call it again to ensure same format is returned - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - // Obtain next output buffer assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - // Format should remain as is - assertThat(adapter.getOutputFormat()).isEqualTo(formats[formats.length - 1]); + } + + @Test + public void getOutputFormat_withoutFormatReceived_throwsException() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + // After start() the ShadowMediaCodec offers an output format change. Pause the looper so that + // the format change is not propagated to the adapter. + shadowOf(handlerThread.getLooper()).pause(); + adapter.start(); + + assertThrows(IllegalStateException.class, () -> adapter.getOutputFormat()); + } + + @Test + public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + adapter.start(); + // After start(), the ShadowMediaCodec offers an output format, which is available only if we + // progress the adapter's looper. + shadowOf(handlerThread.getLooper()).idle(); + + // Add another format directly on the adapter. + adapter.onOutputFormatChanged(codec, createMediaFormat("format2")); + + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // The first format is the ShadowMediaCodec's output format. + assertThat(adapter.getOutputFormat().getByteBuffer("csd-0")).isNotNull(); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + // The 2nd format is the format we enqueued 'manually' above. + assertThat(adapter.getOutputFormat().getString("name")).isEqualTo("format2"); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test public void getOutputFormat_afterFlush_returnsPreviousFormat() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - MediaFormat format = new MediaFormat(); - adapter.getMediaCodecCallback().onOutputFormatChanged(codec, format); - adapter.dequeueOutputBufferIndex(bufferInfo); - adapter.flush(); + // After start(), the ShadowMediaCodec offers an output format, which is available only if we + // progress the adapter's looper. + ShadowLooper shadowLooper = shadowOf(handlerThread.getLooper()); + shadowLooper.idle(); - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThat(adapter.getOutputFormat()).isEqualTo(format); + adapter.dequeueOutputBufferIndex(bufferInfo); + MediaFormat outputFormat = adapter.getOutputFormat(); + // Flush the adapter and progress the looper so that flush is completed. + adapter.flush(); + shadowLooper.idle(); + + assertThat(adapter.getOutputFormat()).isEqualTo(outputFormat); } - @Test - public void shutdown_withPendingFlush_cancelsFlush() { - AtomicInteger onCodecStartCalled = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> onCodecStartCalled.incrementAndGet()); - adapter.start(); - adapter.flush(); - adapter.shutdown(); + private static MediaFormat createMediaFormat(String name) { + MediaFormat format = new MediaFormat(); + format.setString("name", name); + return format; + } - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThat(onCodecStartCalled.get()).isEqualTo(1); + /** Reflectively create a {@link MediaCodec.CodecException}. */ + private static MediaCodec.CodecException createCodecException() throws Exception { + Constructor constructor = + MediaCodec.CodecException.class.getDeclaredConstructor( + Integer.TYPE, Integer.TYPE, String.class); + return constructor.newInstance( + /* errorCode= */ 0, /* actionCode= */ 0, /* detailMessage= */ "error from codec"); + } + + private static class TestHandlerThread extends HandlerThread { + private boolean quit; + + TestHandlerThread(String label) { + super(label); + } + + public boolean hasQuit() { + return quit; + } + + @Override + public boolean quit() { + quit = true; + return super.quit(); + } } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java index c7020b4169..37d31569c3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java @@ -21,8 +21,8 @@ import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doAnswer; import android.media.MediaCodec; +import android.media.MediaFormat; import android.os.HandlerThread; -import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.decoder.CryptoInfo; @@ -37,21 +37,22 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import org.robolectric.Shadows; -import org.robolectric.shadows.ShadowLooper; /** Unit tests for {@link AsynchronousMediaCodecBufferEnqueuer}. */ @RunWith(AndroidJUnit4.class) public class AsynchronousMediaCodecBufferEnqueuerTest { @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + private MediaCodec codec; private AsynchronousMediaCodecBufferEnqueuer enqueuer; private TestHandlerThread handlerThread; @Mock private ConditionVariable mockConditionVariable; @Before public void setUp() throws IOException { - MediaCodec codec = MediaCodec.createByCodecName("h264"); + codec = MediaCodec.createByCodecName("h264"); + codec.configure(new MediaFormat(), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + codec.start(); handlerThread = new TestHandlerThread("TestHandlerThread"); enqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, handlerThread, mockConditionVariable); @@ -60,7 +61,8 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { @After public void tearDown() { enqueuer.shutdown(); - + codec.stop(); + codec.release(); assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); } @@ -96,29 +98,6 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { /* flags= */ 0)); } - @Test - public void queueInputBuffer_multipleTimes_limitsObjectsAllocation() { - enqueuer.start(); - Looper looper = handlerThread.getLooper(); - ShadowLooper shadowLooper = Shadows.shadowOf(looper); - - for (int cycle = 0; cycle < 100; cycle++) { - // Enqueue 10 messages to looper. - for (int i = 0; i < 10; i++) { - enqueuer.queueInputBuffer( - /* index= */ i, - /* offset= */ 0, - /* size= */ 0, - /* presentationTimeUs= */ i, - /* flags= */ 0); - } - // Execute all messages. - shadowLooper.idle(); - } - - assertThat(AsynchronousMediaCodecBufferEnqueuer.getInstancePoolSize()).isEqualTo(10); - } - @Test public void queueSecureInputBuffer_withPendingCryptoException_throwsCryptoException() { enqueuer.setPendingRuntimeException( @@ -154,30 +133,6 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { /* flags= */ 0)); } - @Test - public void queueSecureInputBuffer_multipleTimes_limitsObjectsAllocation() { - enqueuer.start(); - Looper looper = handlerThread.getLooper(); - CryptoInfo info = createCryptoInfo(); - ShadowLooper shadowLooper = Shadows.shadowOf(looper); - - for (int cycle = 0; cycle < 100; cycle++) { - // Enqueue 10 messages to looper. - for (int i = 0; i < 10; i++) { - enqueuer.queueSecureInputBuffer( - /* index= */ i, - /* offset= */ 0, - /* info= */ info, - /* presentationTimeUs= */ i, - /* flags= */ 0); - } - // Execute all messages. - shadowLooper.idle(); - } - - assertThat(AsynchronousMediaCodecBufferEnqueuer.getInstancePoolSize()).isEqualTo(10); - } - @Test public void flush_withoutStart_works() { enqueuer.flush(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java index ac40b4b39a..6579e8ee06 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java @@ -23,6 +23,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.common.primitives.Bytes; import java.nio.ByteBuffer; import org.junit.Test; import org.junit.runner.RunWith; @@ -31,15 +32,13 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class BatchBufferTest { - /** Bigger than {@link BatchBuffer#BATCH_SIZE_BYTES} */ - private static final int BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES = 100 * 1000 * 1000; - /** Smaller than {@link BatchBuffer#BATCH_SIZE_BYTES} */ + /** Bigger than {@code BatchBuffer.BATCH_SIZE_BYTES} */ + private static final int BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES = 6 * 1000 * 1024; + /** Smaller than {@code BatchBuffer.BATCH_SIZE_BYTES} */ private static final int BUFFER_SIZE_MUCH_SMALLER_THAN_BATCH_SIZE_BYTES = 100; private static final byte[] TEST_ACCESS_UNIT = TestUtil.buildTestData(BUFFER_SIZE_MUCH_SMALLER_THAN_BATCH_SIZE_BYTES); - private static final byte[] TEST_HUGE_ACCESS_UNIT = - TestUtil.buildTestData(BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES); private final BatchBuffer batchBuffer = new BatchBuffer(); @@ -153,7 +152,7 @@ public final class BatchBufferTest { batchBuffer.commitNextAccessUnit(); batchBuffer.flip(); - byte[] expected = TestUtil.joinByteArrays(TEST_ACCESS_UNIT, TEST_ACCESS_UNIT); + byte[] expected = Bytes.concat(TEST_ACCESS_UNIT, TEST_ACCESS_UNIT); assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(expected)); } @@ -162,20 +161,21 @@ public final class BatchBufferTest { batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); batchBuffer.commitNextAccessUnit(); - batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_HUGE_ACCESS_UNIT.length); - batchBuffer.getNextAccessUnitBuffer().data.put(TEST_HUGE_ACCESS_UNIT); + byte[] hugeAccessUnit = TestUtil.buildTestData(BUFFER_SIZE_LARGER_THAN_BATCH_SIZE_BYTES); + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(hugeAccessUnit.length); + batchBuffer.getNextAccessUnitBuffer().data.put(hugeAccessUnit); batchBuffer.commitNextAccessUnit(); batchBuffer.batchWasConsumed(); batchBuffer.flip(); assertThat(batchBuffer.getAccessUnitCount()).isEqualTo(1); - assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(TEST_HUGE_ACCESS_UNIT)); + assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(hugeAccessUnit)); } @Test public void batchWasConsumed_whenNotEmpty_isEmpty() { - fillBatchBuffer(batchBuffer); + batchBuffer.commitNextAccessUnit(); batchBuffer.batchWasConsumed(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java new file mode 100644 index 0000000000..1108b882e4 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/C2Mp3TimestampTrackerTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.mediacodec; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.util.MimeTypes; +import java.nio.ByteBuffer; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link C2Mp3TimestampTracker}. */ +@RunWith(AndroidJUnit4.class) +public final class C2Mp3TimestampTrackerTest { + + private static final Format AUDIO_MP3 = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_MPEG) + .setChannelCount(2) + .setSampleRate(44_100) + .build(); + + private DecoderInputBuffer buffer; + private C2Mp3TimestampTracker timestampTracker; + + @Before + public void setUp() { + buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); + timestampTracker = new C2Mp3TimestampTracker(); + buffer.data = ByteBuffer.wrap(new byte[] {-1, -5, -24, 60}); + buffer.timeUs = 100_000; + } + + @Test + public void whenUpdateCalledMultipleTimes_timestampsIncrease() { + long first = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + long second = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + long third = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + + assertThat(second).isGreaterThan(first); + assertThat(third).isGreaterThan(second); + } + + @Test + public void whenResetCalled_timestampsDecrease() { + long first = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + long second = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + timestampTracker.reset(); + long third = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + + assertThat(second).isGreaterThan(first); + assertThat(third).isLessThan(second); + } + + @Test + public void whenBufferTimeIsNotZero_firstSampleIsOffset() { + long first = timestampTracker.updateAndGetPresentationTimeUs(AUDIO_MP3, buffer); + + assertThat(first).isEqualTo(buffer.timeUs); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapterTest.java deleted file mode 100644 index 7ea55b1d82..0000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/DedicatedThreadAsyncMediaCodecAdapterTest.java +++ /dev/null @@ -1,336 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import static com.google.android.exoplayer2.testutil.TestUtil.assertBufferInfosEqual; -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; -import static org.robolectric.Shadows.shadowOf; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Looper; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; -import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Shadows; -import org.robolectric.annotation.LooperMode; -import org.robolectric.shadows.ShadowLooper; - -/** Unit tests for {@link DedicatedThreadAsyncMediaCodecAdapter}. */ -@LooperMode(LEGACY) -@RunWith(AndroidJUnit4.class) -public class DedicatedThreadAsyncMediaCodecAdapterTest { - private DedicatedThreadAsyncMediaCodecAdapter adapter; - private MediaCodec codec; - private TestHandlerThread handlerThread; - private MediaCodec.BufferInfo bufferInfo; - - @Before - public void setUp() throws IOException { - codec = MediaCodec.createByCodecName("h264"); - handlerThread = new TestHandlerThread("TestHandlerThread"); - adapter = - new DedicatedThreadAsyncMediaCodecAdapter( - codec, - /* enableAsynchronousQueueing= */ false, - /* trackType= */ C.TRACK_TYPE_VIDEO, - handlerThread); - adapter.setCodecStartRunnable(() -> {}); - bufferInfo = new MediaCodec.BufferInfo(); - } - - @After - public void tearDown() { - adapter.shutdown(); - - assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); - } - - @Test - public void startAndShutdown_works() { - adapter.start(); - adapter.shutdown(); - } - - @Test - public void dequeueInputBufferIndex_withAfterFlushFailed_throwsException() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new IllegalStateException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); - } - - @Test - public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { - adapter.start(); - adapter.onInputBufferAvailable(codec, 0); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); - } - - @Test - public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { - adapter.start(); - adapter.onInputBufferAvailable(codec, 0); - adapter.flush(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInputBuffer() { - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - handler.post(() -> adapter.onInputBufferAvailable(codec, bufferIndex)); - } - adapter.flush(); // Enqueues a flush event after the onInputBufferAvailable callbacks - // Enqueue another onInputBufferAvailable after the flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 10)); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(10); - } - - @Test - public void dequeueInputBufferIndex_withMediaCodecError_throwsException() { - adapter.start(); - adapter.onMediaCodecError(new IllegalStateException("error from codec")); - - assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); - } - - @Test - public void dequeueOutputBufferIndex_withInternalException_throwsException() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new RuntimeException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); - } - - @Test - public void dequeueOutputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { - adapter.start(); - MediaCodec.BufferInfo enqueuedBufferInfo = new MediaCodec.BufferInfo(); - adapter.onOutputBufferAvailable(codec, 0, enqueuedBufferInfo); - - assertThat(adapter.dequeueOutputBufferIndex((bufferInfo))).isEqualTo(0); - assertBufferInfosEqual(enqueuedBufferInfo, bufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { - adapter.start(); - adapter.dequeueOutputBufferIndex(bufferInfo); - adapter.flush(); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_withFlushCompletedAndOutputBuffer_returnsOutputBuffer() { - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - outBufferInfo.presentationTimeUs = i; - handler.post(() -> adapter.onOutputBufferAvailable(codec, bufferIndex, outBufferInfo)); - } - adapter.flush(); // Enqueues a flush event after the onOutputBufferAvailable callbacks - // Enqueue another onOutputBufferAvailable after the flush event - MediaCodec.BufferInfo lastBufferInfo = new MediaCodec.BufferInfo(); - lastBufferInfo.presentationTimeUs = 10; - handler.post(() -> adapter.onOutputBufferAvailable(codec, 10, lastBufferInfo)); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(10); - assertBufferInfosEqual(lastBufferInfo, bufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() { - adapter.start(); - adapter.onMediaCodecError(new IllegalStateException("error from codec")); - - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); - } - - @Test - public void getOutputFormat_withoutFormatReceived_throwsException() { - adapter.start(); - - assertThrows(IllegalStateException.class, () -> adapter.getOutputFormat()); - } - - @Test - public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { - adapter.start(); - MediaFormat[] formats = new MediaFormat[10]; - for (int i = 0; i < formats.length; i++) { - formats[i] = new MediaFormat(); - adapter.onOutputFormatChanged(codec, formats[i]); - } - - for (int i = 0; i < 10; i++) { - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - // A subsequent call to getOutputFormat() should return the previously fetched format - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - } - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void getOutputFormat_afterFlush_returnsPreviousFormat() { - MediaFormat format = new MediaFormat(); - adapter.start(); - adapter.onOutputFormatChanged(codec, format); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - - adapter.flush(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - - @Test - public void flush_multipleTimes_onlyLastFlushExecutes() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> codecStartCalls.incrementAndGet()); - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - handler.post(() -> adapter.onInputBufferAvailable(codec, 0)); - adapter.flush(); // Enqueues a flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 2)); - AtomicInteger milestoneCount = new AtomicInteger(0); - handler.post(() -> milestoneCount.incrementAndGet()); - adapter.flush(); // Enqueues a second flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 3)); - - // Progress the looper until the milestoneCount is increased. - // adapter.start() will call codec.start(). First flush event should not call codec.start(). - ShadowLooper shadowLooper = shadowOf(looper); - while (milestoneCount.get() < 1) { - shadowLooper.runOneTask(); - } - assertThat(codecStartCalls.get()).isEqualTo(1); - - // Wait until all tasks have been handled. - shadowLooper.idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(3); - assertThat(codecStartCalls.get()).isEqualTo(2); - } - - @Test - public void flush_andImmediatelyShutdown_flushIsNoOp() { - AtomicInteger onCodecStartCount = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> onCodecStartCount.incrementAndGet()); - adapter.start(); - // Grab reference to Looper before shutting down the adapter otherwise handlerThread.getLooper() - // might return null. - Looper looper = handlerThread.getLooper(); - adapter.flush(); - adapter.shutdown(); - - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - // Only adapter.start() calls onCodecStart. - assertThat(onCodecStartCount.get()).isEqualTo(1); - } - - private static class TestHandlerThread extends HandlerThread { - private static final AtomicLong INSTANCES_STARTED = new AtomicLong(0); - - public TestHandlerThread(String name) { - super(name); - } - - @Override - public synchronized void start() { - super.start(); - INSTANCES_STARTED.incrementAndGet(); - } - - @Override - public boolean quit() { - boolean quit = super.quit(); - if (quit) { - INSTANCES_STARTED.decrementAndGet(); - } - return quit; - } - } -} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.java index 0161b541f1..7cf3f32391 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MediaCodecAsyncCallbackTest.java @@ -135,6 +135,47 @@ public class MediaCodecAsyncCallbackTest { assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(2); } + @Test + public void dequeOutputBufferIndex_withPendingOutputFormat_returnsPendingOutputFormat() { + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + + mediaCodecAsyncCallback.onOutputFormatChanged(codec, new MediaFormat()); + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 0, bufferInfo); + MediaFormat pendingMediaFormat = new MediaFormat(); + mediaCodecAsyncCallback.onOutputFormatChanged(codec, pendingMediaFormat); + // Flush should not discard the last format. + mediaCodecAsyncCallback.flush(); + // First callback after flush is an output buffer, pending output format should be pushed first. + mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 1, bufferInfo); + + assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(mediaCodecAsyncCallback.getOutputFormat()).isEqualTo(pendingMediaFormat); + assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); + } + + @Test + public void dequeOutputBufferIndex_withPendingOutputFormatAndNewFormat_returnsNewFormat() { + mediaCodecAsyncCallback.onOutputFormatChanged(codec, new MediaFormat()); + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 0, bufferInfo); + MediaFormat pendingMediaFormat = new MediaFormat(); + mediaCodecAsyncCallback.onOutputFormatChanged(codec, pendingMediaFormat); + // Flush should not discard the last format + mediaCodecAsyncCallback.flush(); + // The first callback after flush is a new MediaFormat, it should overwrite the pending format. + MediaFormat newFormat = new MediaFormat(); + mediaCodecAsyncCallback.onOutputFormatChanged(codec, newFormat); + mediaCodecAsyncCallback.onOutputBufferAvailable(codec, /* index= */ 1, bufferInfo); + MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); + + assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(mediaCodecAsyncCallback.getOutputFormat()).isEqualTo(newFormat); + assertThat(mediaCodecAsyncCallback.dequeueOutputBufferIndex(outBufferInfo)).isEqualTo(1); + } + @Test public void getOutputFormat_onNewInstance_raisesException() { try { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java deleted file mode 100644 index cfe9cf2900..0000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/MultiLockAsyncMediaCodecAdapterTest.java +++ /dev/null @@ -1,331 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import static com.google.android.exoplayer2.testutil.TestUtil.assertBufferInfosEqual; -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; -import static org.robolectric.Shadows.shadowOf; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Looper; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; -import java.io.IOException; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Shadows; -import org.robolectric.annotation.LooperMode; -import org.robolectric.shadows.ShadowLooper; - -/** Unit tests for {@link MultiLockAsyncMediaCodecAdapter}. */ -@LooperMode(LEGACY) -@RunWith(AndroidJUnit4.class) -public class MultiLockAsyncMediaCodecAdapterTest { - private MultiLockAsyncMediaCodecAdapter adapter; - private MediaCodec codec; - private MediaCodec.BufferInfo bufferInfo; - private TestHandlerThread handlerThread; - - @Before - public void setUp() throws IOException { - codec = MediaCodec.createByCodecName("h264"); - handlerThread = new TestHandlerThread("TestHandlerThread"); - adapter = - new MultiLockAsyncMediaCodecAdapter( - codec, /* enableAsynchronousQueueing= */ false, C.TRACK_TYPE_VIDEO, handlerThread); - adapter.setCodecStartRunnable(() -> {}); - bufferInfo = new MediaCodec.BufferInfo(); - } - - @After - public void tearDown() { - adapter.shutdown(); - - assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); - } - - @Test - public void startAndShutdown_works() { - adapter.start(); - adapter.shutdown(); - } - - @Test - public void dequeueInputBufferIndex_withAfterFlushFailed_throwsException() - throws InterruptedException { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new IllegalStateException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); - } - - @Test - public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withInputBuffer_returnsInputBuffer() { - adapter.start(); - adapter.onInputBufferAvailable(codec, 0); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(0); - } - - @Test - public void dequeueInputBufferIndex_withPendingFlush_returnsTryAgainLater() { - adapter.start(); - adapter.onInputBufferAvailable(codec, 0); - adapter.flush(); - - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueInputBufferIndex_withFlushCompletedAndInputBuffer_returnsInputBuffer() - throws InterruptedException { - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - handler.post(() -> adapter.onInputBufferAvailable(codec, bufferIndex)); - } - adapter.flush(); // Enqueues a flush event after the onInputBufferAvailable callbacks - // Enqueue another onInputBufferAvailable after the flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 10)); - - // Wait until all tasks have been handled - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(10); - } - - @Test - public void dequeueInputBufferIndex_withMediaCodecError_throwsException() { - adapter.start(); - adapter.onMediaCodecError(new IllegalStateException("error from codec")); - - assertThrows(IllegalStateException.class, () -> adapter.dequeueInputBufferIndex()); - } - - - @Test - public void dequeueOutputBufferIndex_withInternalException_throwsException() - throws InterruptedException { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (codecStartCalls.incrementAndGet() == 2) { - throw new RuntimeException("codec#start() exception"); - } - }); - adapter.start(); - adapter.flush(); - - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); - } - - @Test - public void dequeueOutputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { - adapter.start(); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_withOutputBuffer_returnsOutputBuffer() { - adapter.start(); - MediaCodec.BufferInfo enqueuedBufferInfo = new MediaCodec.BufferInfo(); - adapter.onOutputBufferAvailable(codec, 0, enqueuedBufferInfo); - - assertThat(adapter.dequeueOutputBufferIndex((bufferInfo))).isEqualTo(0); - assertBufferInfosEqual(enqueuedBufferInfo, bufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_withPendingFlush_returnsTryAgainLater() { - adapter.start(); - adapter.dequeueOutputBufferIndex(bufferInfo); - adapter.flush(); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void dequeueOutputBufferIndex_withFlushCompletedAndOutputBuffer_returnsOutputBuffer() - throws InterruptedException { - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - // Enqueue 10 callbacks from codec - for (int i = 0; i < 10; i++) { - int bufferIndex = i; - MediaCodec.BufferInfo outBufferInfo = new MediaCodec.BufferInfo(); - outBufferInfo.presentationTimeUs = i; - handler.post(() -> adapter.onOutputBufferAvailable(codec, bufferIndex, outBufferInfo)); - } - adapter.flush(); // Enqueues a flush event after the onOutputBufferAvailable callbacks - // Enqueue another onOutputBufferAvailable after the flush event - MediaCodec.BufferInfo lastBufferInfo = new MediaCodec.BufferInfo(); - lastBufferInfo.presentationTimeUs = 10; - handler.post(() -> adapter.onOutputBufferAvailable(codec, 10, lastBufferInfo)); - - // Wait until all tasks have been handled - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(10); - assertBufferInfosEqual(lastBufferInfo, bufferInfo); - } - - @Test - public void dequeueOutputBufferIndex_withMediaCodecError_throwsException() { - adapter.start(); - adapter.onMediaCodecError(new IllegalStateException("error from codec")); - - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); - } - - @Test - public void getOutputFormat_withoutFormatReceived_throwsException() { - adapter.start(); - - assertThrows(IllegalStateException.class, () -> adapter.getOutputFormat()); - } - - @Test - public void getOutputFormat_withMultipleFormats_returnsCorrectFormat() { - adapter.start(); - MediaFormat[] formats = new MediaFormat[10]; - for (int i = 0; i < formats.length; i++) { - formats[i] = new MediaFormat(); - adapter.onOutputFormatChanged(codec, formats[i]); - } - - for (int i = 0; i < 10; i++) { - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - // A subsequent call to getOutputFormat() should return the previously fetched format - assertThat(adapter.getOutputFormat()).isEqualTo(formats[i]); - } - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); - } - - @Test - public void getOutputFormat_afterFlush_returnsPreviousFormat() { - MediaFormat format = new MediaFormat(); - adapter.start(); - adapter.onOutputFormatChanged(codec, format); - - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - - adapter.flush(); - Shadows.shadowOf(handlerThread.getLooper()).idle(); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - - @Test - public void flush_multipleTimes_onlyLastFlushExecutes() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> codecStartCalls.incrementAndGet()); - adapter.start(); - Looper looper = handlerThread.getLooper(); - Handler handler = new Handler(looper); - handler.post(() -> adapter.onInputBufferAvailable(codec, 0)); - adapter.flush(); // Enqueues a flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 2)); - AtomicInteger milestoneCount = new AtomicInteger(0); - handler.post(() -> milestoneCount.incrementAndGet()); - adapter.flush(); // Enqueues a second flush event - handler.post(() -> adapter.onInputBufferAvailable(codec, 3)); - - // Progress the looper until the milestoneCount is increased: - // adapter.start() called codec.start() but first flush event should have been a no-op - ShadowLooper shadowLooper = shadowOf(looper); - while (milestoneCount.get() < 1) { - shadowLooper.runOneTask(); - } - assertThat(codecStartCalls.get()).isEqualTo(1); - - shadowLooper.idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(3); - assertThat(codecStartCalls.get()).isEqualTo(2); - } - - @Test - public void flush_andImmediatelyShutdown_flushIsNoOp() { - AtomicInteger codecStartCalls = new AtomicInteger(0); - adapter.setCodecStartRunnable(() -> codecStartCalls.incrementAndGet()); - adapter.start(); - // Grab reference to Looper before shutting down the adapter otherwise handlerThread.getLooper() - // might return null. - Looper looper = handlerThread.getLooper(); - adapter.flush(); - adapter.shutdown(); - - Shadows.shadowOf(looper).idle(); - // Only adapter.start() called codec#start() - assertThat(codecStartCalls.get()).isEqualTo(1); - } - - private static class TestHandlerThread extends HandlerThread { - - private static final AtomicLong INSTANCES_STARTED = new AtomicLong(0); - - public TestHandlerThread(String name) { - super(name); - } - - @Override - public synchronized void start() { - super.start(); - INSTANCES_STARTED.incrementAndGet(); - } - - @Override - public boolean quit() { - boolean quit = super.quit(); - INSTANCES_STARTED.decrementAndGet(); - return quit; - } - } -} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java index 4d1b4f601b..796f56becf 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/MetadataRendererTest.java @@ -22,6 +22,8 @@ import static java.nio.charset.StandardCharsets.UTF_8; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.metadata.emsg.EventMessage; import com.google.android.exoplayer2.metadata.emsg.EventMessageEncoder; import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; @@ -31,6 +33,8 @@ import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamI import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Bytes; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -42,7 +46,7 @@ import org.junit.runner.RunWith; public class MetadataRendererTest { private static final byte[] SCTE35_TIME_SIGNAL_BYTES = - TestUtil.joinByteArrays( + Bytes.concat( TestUtil.createByteArray( 0, // table_id. 0x80, // section_syntax_indicator, private_indicator, reserved, section_length(4). @@ -143,12 +147,14 @@ public class MetadataRendererTest { renderer.replaceStream( new Format[] {EMSG_FORMAT}, new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), EMSG_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 0, - new FakeSampleStreamItem(input), - FakeSampleStreamItem.END_OF_STREAM_ITEM), + ImmutableList.of( + FakeSampleStreamItem.sample(/* timeUs= */ 0, /* flags= */ 0, input), + FakeSampleStreamItem.END_OF_STREAM_ITEM)), + /* startPositionUs= */ 0L, /* offsetUs= */ 0L); renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); // Read the format renderer.render(/* positionUs= */ 0, /* elapsedRealtimeUs= */ 0); // Read the data @@ -168,7 +174,7 @@ public class MetadataRendererTest { */ private static byte[] encodeTxxxId3Frame(String description, String value) { byte[] id3FrameData = - TestUtil.joinByteArrays( + Bytes.concat( "TXXX".getBytes(ISO_8859_1), // ID for a 'user defined text information frame' TestUtil.createByteArray(0, 0, 0, 0), // Frame size (set later) TestUtil.createByteArray(0, 0), // Frame flags @@ -184,7 +190,7 @@ public class MetadataRendererTest { id3FrameData[frameSizeIndex] = (byte) frameSize; byte[] id3Bytes = - TestUtil.joinByteArrays( + Bytes.concat( "ID3".getBytes(ISO_8859_1), // identifier TestUtil.createByteArray(0x04, 0x00), // version TestUtil.createByteArray(0), // Tag flags diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoderTest.java index 39de14b893..f6256ef6ab 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/dvbsi/AppInfoTableDecoderTest.java @@ -33,9 +33,9 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class AppInfoTableDecoderTest { - private static final String TYPICAL_FILE = "dvbsi/ait_typical.bin"; - private static final String NO_URL_BASE_FILE = "dvbsi/ait_no_url_base.bin"; - private static final String NO_URL_PATH_FILE = "dvbsi/ait_no_url_path.bin"; + private static final String TYPICAL_FILE = "media/dvbsi/ait_typical.bin"; + private static final String NO_URL_BASE_FILE = "media/dvbsi/ait_no_url_base.bin"; + private static final String NO_URL_PATH_FILE = "media/dvbsi/ait_no_url_path.bin"; @Test public void decode_typical() throws Exception { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java index 49cca0367d..d16941b021 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/icy/IcyDecoderTest.java @@ -26,7 +26,7 @@ import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataInputBuffer; -import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.common.primitives.Bytes; import org.junit.Test; import org.junit.runner.RunWith; @@ -54,7 +54,7 @@ public final class IcyDecoderTest { public void decode_respectsLimit() { byte[] icyTitle = "StreamTitle='test title';".getBytes(UTF_8); byte[] icyUrl = "StreamURL='test_url';".getBytes(UTF_8); - byte[] paddedRawBytes = TestUtil.joinByteArrays(icyTitle, icyUrl); + byte[] paddedRawBytes = Bytes.concat(icyTitle, icyUrl); MetadataInputBuffer metadataBuffer = createMetadataInputBuffer(paddedRawBytes); // Stop before the stream URL. metadataBuffer.data.limit(icyTitle.length); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java index 90c2e7d386..dcb1f634c9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoderTest.java @@ -200,7 +200,8 @@ public final class SpliceInfoDecoderTest { } private static long removePtsConversionPrecisionError(long timeUs, long offsetUs) { - return TimestampAdjuster.ptsToUs(TimestampAdjuster.usToPts(timeUs - offsetUs)) + offsetUs; + return TimestampAdjuster.ptsToUs(TimestampAdjuster.usToNonWrappedPts(timeUs - offsetUs)) + + offsetUs; } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java index cec0d07688..02ff4bba5e 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileTest.java @@ -21,11 +21,11 @@ import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.util.Collections; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -57,7 +57,7 @@ public class ActionFileTest { @Test public void loadNoDataThrowsIOException() throws Exception { - ActionFile actionFile = getActionFile("offline/action_file_no_data.exi"); + ActionFile actionFile = getActionFile("media/offline/action_file_no_data.exi"); try { actionFile.load(); Assert.fail(); @@ -68,7 +68,7 @@ public class ActionFileTest { @Test public void loadIncompleteHeaderThrowsIOException() throws Exception { - ActionFile actionFile = getActionFile("offline/action_file_incomplete_header.exi"); + ActionFile actionFile = getActionFile("media/offline/action_file_incomplete_header.exi"); try { actionFile.load(); Assert.fail(); @@ -79,7 +79,7 @@ public class ActionFileTest { @Test public void loadZeroActions() throws Exception { - ActionFile actionFile = getActionFile("offline/action_file_zero_actions.exi"); + ActionFile actionFile = getActionFile("media/offline/action_file_zero_actions.exi"); DownloadRequest[] actions = actionFile.load(); assertThat(actions).isNotNull(); assertThat(actions).hasLength(0); @@ -87,7 +87,7 @@ public class ActionFileTest { @Test public void loadOneAction() throws Exception { - ActionFile actionFile = getActionFile("offline/action_file_one_action.exi"); + ActionFile actionFile = getActionFile("media/offline/action_file_one_action.exi"); DownloadRequest[] actions = actionFile.load(); assertThat(actions).hasLength(1); assertThat(actions[0]).isEqualTo(expectedAction1); @@ -95,7 +95,7 @@ public class ActionFileTest { @Test public void loadTwoActions() throws Exception { - ActionFile actionFile = getActionFile("offline/action_file_two_actions.exi"); + ActionFile actionFile = getActionFile("media/offline/action_file_two_actions.exi"); DownloadRequest[] actions = actionFile.load(); assertThat(actions).hasLength(2); assertThat(actions[0]).isEqualTo(expectedAction1); @@ -104,7 +104,7 @@ public class ActionFileTest { @Test public void loadUnsupportedVersion() throws Exception { - ActionFile actionFile = getActionFile("offline/action_file_unsupported_version.exi"); + ActionFile actionFile = getActionFile("media/offline/action_file_unsupported_version.exi"); try { actionFile.load(); Assert.fail(); @@ -125,12 +125,9 @@ public class ActionFileTest { } private static DownloadRequest buildExpectedRequest(Uri uri, byte[] data) { - return new DownloadRequest( - /* id= */ uri.toString(), - DownloadRequest.TYPE_PROGRESSIVE, - uri, - /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - data); + return new DownloadRequest.Builder(/* id= */ uri.toString(), uri) + .setMimeType(MimeTypes.VIDEO_UNKNOWN) + .setData(data) + .build(); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java index 17c1b57f37..05c0bcc780 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/ActionFileUpgradeUtilTest.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.offline; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_PROGRESSIVE; import static com.google.common.truth.Truth.assertThat; import android.net.Uri; @@ -23,12 +22,12 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.database.ExoDatabaseProvider; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.util.Arrays; -import java.util.List; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -58,36 +57,27 @@ public class ActionFileUpgradeUtilTest { } @Test - public void upgradeAndDelete_createsDownloads() throws IOException { - // Copy the test asset to a file. + public void upgradeAndDelete_progressiveActionFile_createsDownloads() throws IOException { byte[] actionFileBytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), - "offline/action_file_for_download_index_upgrade.exi"); + "media/offline/action_file_for_download_index_upgrade_progressive.exi"); try (FileOutputStream output = new FileOutputStream(tempFile)) { output.write(actionFileBytes); } - - StreamKey expectedStreamKey1 = - new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5); - StreamKey expectedStreamKey2 = - new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); DownloadRequest expectedRequest1 = - new DownloadRequest( - "key123", - /* type= */ "test", - Uri.parse("https://www.test.com/download1"), - asList(expectedStreamKey1), - /* customCacheKey= */ "key123", - new byte[] {1, 2, 3, 4}); + new DownloadRequest.Builder( + /* id= */ "http://www.test.com/1/video.mp4", + Uri.parse("http://www.test.com/1/video.mp4")) + .setMimeType(MimeTypes.VIDEO_UNKNOWN) + .build(); DownloadRequest expectedRequest2 = - new DownloadRequest( - "key234", - /* type= */ "test", - Uri.parse("https://www.test.com/download2"), - asList(expectedStreamKey2), - /* customCacheKey= */ "key234", - new byte[] {5, 4, 3, 2, 1}); + new DownloadRequest.Builder( + /* id= */ "customCacheKey", Uri.parse("http://www.test.com/2/video.mp4")) + .setMimeType(MimeTypes.VIDEO_UNKNOWN) + .setCustomCacheKey("customCacheKey") + .setData(new byte[] {0, 1, 2, 3}) + .build(); ActionFileUpgradeUtil.upgradeAndDelete( tempFile, @@ -96,23 +86,140 @@ public class ActionFileUpgradeUtilTest { /* deleteOnFailure= */ true, /* addNewDownloadsAsCompleted= */ false); + assertThat(tempFile.exists()).isFalse(); + assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); + assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); + } + + @Test + public void upgradeAndDelete_dashActionFile_createsDownloads() throws IOException { + byte[] actionFileBytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), + "media/offline/action_file_for_download_index_upgrade_dash.exi"); + try (FileOutputStream output = new FileOutputStream(tempFile)) { + output.write(actionFileBytes); + } + DownloadRequest expectedRequest1 = + new DownloadRequest.Builder( + /* id= */ "http://www.test.com/1/manifest.mpd", + Uri.parse("http://www.test.com/1/manifest.mpd")) + .setMimeType(MimeTypes.APPLICATION_MPD) + .build(); + DownloadRequest expectedRequest2 = + new DownloadRequest.Builder( + /* id= */ "http://www.test.com/2/manifest.mpd", + Uri.parse("http://www.test.com/2/manifest.mpd")) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setStreamKeys( + ImmutableList.of( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0), + new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1))) + .setData(new byte[] {0, 1, 2, 3}) + .build(); + + ActionFileUpgradeUtil.upgradeAndDelete( + tempFile, + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + /* addNewDownloadsAsCompleted= */ false); + + assertThat(tempFile.exists()).isFalse(); + assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); + assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); + } + + @Test + public void upgradeAndDelete_hlsActionFile_createsDownloads() throws IOException { + byte[] actionFileBytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), + "media/offline/action_file_for_download_index_upgrade_hls.exi"); + try (FileOutputStream output = new FileOutputStream(tempFile)) { + output.write(actionFileBytes); + } + DownloadRequest expectedRequest1 = + new DownloadRequest.Builder( + /* id= */ "http://www.test.com/1/manifest.m3u8", + Uri.parse("http://www.test.com/1/manifest.m3u8")) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(); + DownloadRequest expectedRequest2 = + new DownloadRequest.Builder( + /* id= */ "http://www.test.com/2/manifest.m3u8", + Uri.parse("http://www.test.com/2/manifest.m3u8")) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .setStreamKeys( + ImmutableList.of( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0), + new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1))) + .setData(new byte[] {0, 1, 2, 3}) + .build(); + + ActionFileUpgradeUtil.upgradeAndDelete( + tempFile, + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + /* addNewDownloadsAsCompleted= */ false); + + assertThat(tempFile.exists()).isFalse(); + assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); + assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); + } + + @Test + public void upgradeAndDelete_smoothStreamingActionFile_createsDownloads() throws IOException { + byte[] actionFileBytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), + "media/offline/action_file_for_download_index_upgrade_ss.exi"); + try (FileOutputStream output = new FileOutputStream(tempFile)) { + output.write(actionFileBytes); + } + DownloadRequest expectedRequest1 = + new DownloadRequest.Builder( + /* id= */ "http://www.test.com/1/video.ism/manifest", + Uri.parse("http://www.test.com/1/video.ism/manifest")) + .setMimeType(MimeTypes.APPLICATION_SS) + .build(); + DownloadRequest expectedRequest2 = + new DownloadRequest.Builder( + /* id= */ "http://www.test.com/2/video.ism/manifest", + Uri.parse("http://www.test.com/2/video.ism/manifest")) + .setMimeType(MimeTypes.APPLICATION_SS) + .setStreamKeys( + ImmutableList.of( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0), + new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1))) + .setData(new byte[] {0, 1, 2, 3}) + .build(); + + ActionFileUpgradeUtil.upgradeAndDelete( + tempFile, + /* downloadIdProvider= */ null, + downloadIndex, + /* deleteOnFailure= */ true, + /* addNewDownloadsAsCompleted= */ false); + + assertThat(tempFile.exists()).isFalse(); assertDownloadIndexContainsRequest(expectedRequest1, Download.STATE_QUEUED); assertDownloadIndexContainsRequest(expectedRequest2, Download.STATE_QUEUED); } @Test public void mergeRequest_nonExistingDownload_createsNewDownload() throws IOException { - byte[] data = new byte[] {1, 2, 3, 4}; DownloadRequest request = - new DownloadRequest( - "id", - TYPE_PROGRESSIVE, - Uri.parse("https://www.test.com/download"), - asList( - new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), - new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5)), - /* customCacheKey= */ "key123", - data); + new DownloadRequest.Builder(/* id= */ "id", Uri.parse("https://www.test.com/download")) + .setStreamKeys( + ImmutableList.of( + new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), + new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5))) + .setKeySetId(new byte[] {1, 2, 3, 4}) + .setCustomCacheKey("key123") + .setData(new byte[] {1, 2, 3, 4}) + .build(); ActionFileUpgradeUtil.mergeRequest( request, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); @@ -127,33 +234,34 @@ public class ActionFileUpgradeUtilTest { StreamKey streamKey2 = new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); DownloadRequest request1 = - new DownloadRequest( - "id", - TYPE_PROGRESSIVE, - Uri.parse("https://www.test.com/download1"), - asList(streamKey1), - /* customCacheKey= */ "key123", - new byte[] {1, 2, 3, 4}); + new DownloadRequest.Builder(/* id= */ "id", Uri.parse("https://www.test.com/download1")) + .setStreamKeys(ImmutableList.of(streamKey1)) + .setKeySetId(new byte[] {1, 2, 3, 4}) + .setCustomCacheKey("key123") + .setData(new byte[] {1, 2, 3, 4}) + .build(); DownloadRequest request2 = - new DownloadRequest( - "id", - TYPE_PROGRESSIVE, - Uri.parse("https://www.test.com/download2"), - asList(streamKey2), - /* customCacheKey= */ "key123", - new byte[] {5, 4, 3, 2, 1}); + new DownloadRequest.Builder(/* id= */ "id", Uri.parse("https://www.test.com/download2")) + .setMimeType(MimeTypes.APPLICATION_MP4) + .setStreamKeys(ImmutableList.of(streamKey2)) + .setKeySetId(new byte[] {5, 4, 3, 2, 1}) + .setCustomCacheKey("key345") + .setData(new byte[] {5, 4, 3, 2, 1}) + .build(); + ActionFileUpgradeUtil.mergeRequest( request1, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); ActionFileUpgradeUtil.mergeRequest( request2, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); - Download download = downloadIndex.getDownload(request2.id); + assertThat(download).isNotNull(); - assertThat(download.request.type).isEqualTo(request2.type); + assertThat(download.request.mimeType).isEqualTo(MimeTypes.APPLICATION_MP4); assertThat(download.request.customCacheKey).isEqualTo(request2.customCacheKey); assertThat(download.request.data).isEqualTo(request2.data); assertThat(download.request.uri).isEqualTo(request2.uri); assertThat(download.request.streamKeys).containsExactly(streamKey1, streamKey2); + assertThat(download.request.keySetId).isEqualTo(request2.keySetId); assertThat(download.state).isEqualTo(Download.STATE_QUEUED); } @@ -164,21 +272,19 @@ public class ActionFileUpgradeUtilTest { StreamKey streamKey2 = new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); DownloadRequest request1 = - new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, - Uri.parse("https://www.test.com/download1"), - asList(streamKey1), - /* customCacheKey= */ "key123", - new byte[] {1, 2, 3, 4}); + new DownloadRequest.Builder(/* id= */ "id1", Uri.parse("https://www.test.com/download1")) + .setStreamKeys(ImmutableList.of(streamKey1)) + .setKeySetId(new byte[] {1, 2, 3, 4}) + .setCustomCacheKey("key123") + .setData(new byte[] {1, 2, 3, 4}) + .build(); DownloadRequest request2 = - new DownloadRequest( - "id2", - TYPE_PROGRESSIVE, - Uri.parse("https://www.test.com/download2"), - asList(streamKey2), - /* customCacheKey= */ "key123", - new byte[] {5, 4, 3, 2, 1}); + new DownloadRequest.Builder(/* id= */ "id2", Uri.parse("https://www.test.com/download2")) + .setStreamKeys(ImmutableList.of(streamKey2)) + .setKeySetId(new byte[] {5, 4, 3, 2, 1}) + .setCustomCacheKey("key456") + .setData(new byte[] {5, 4, 3, 2, 1}) + .build(); ActionFileUpgradeUtil.mergeRequest( request1, downloadIndex, /* addNewDownloadAsCompleted= */ false, NOW_MS); @@ -199,8 +305,4 @@ public class ActionFileUpgradeUtilTest { assertThat(download.request).isEqualTo(request); assertThat(download.state).isEqualTo(state); } - - private static List asList(StreamKey... streamKeys) { - return Arrays.asList(streamKeys); - } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java index cc1ae4b71b..988b5127ec 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java @@ -15,15 +15,31 @@ */ package com.google.android.exoplayer2.offline; +import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_NONE; +import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_UNKNOWN; +import static com.google.android.exoplayer2.offline.Download.STATE_DOWNLOADING; +import static com.google.android.exoplayer2.offline.Download.STATE_STOPPED; +import static com.google.android.exoplayer2.offline.Download.STOP_REASON_NONE; import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.database.DatabaseIOException; import com.google.android.exoplayer2.database.ExoDatabaseProvider; import com.google.android.exoplayer2.database.VersionTable; import com.google.android.exoplayer2.testutil.DownloadBuilder; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -73,14 +89,14 @@ public class DefaultDownloadIndexTest { Download download = downloadBuilder - .setType("different type") .setUri("different uri") + .setMimeType(MimeTypes.APPLICATION_MP4) .setCacheKey("different cacheKey") .setState(Download.STATE_FAILED) .setPercentDownloaded(50) .setBytesDownloaded(200) .setContentLength(400) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN) + .setFailureReason(FAILURE_REASON_UNKNOWN) .setStopReason(0x12345678) .setStartTimeMs(10) .setUpdateTimeMs(20) @@ -88,6 +104,7 @@ public class DefaultDownloadIndexTest { new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2), new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5)) .setCustomMetadata(new byte[] {0, 1, 2, 3, 7, 8, 9, 10}) + .setKeySetId(new byte[] {0, 1, 2, 3}) .build(); downloadIndex.putDownload(download); Download readDownload = downloadIndex.getDownload(id); @@ -153,7 +170,7 @@ public class DefaultDownloadIndexTest { new DownloadBuilder("id1").setStartTimeMs(0).setState(Download.STATE_REMOVING).build(); downloadIndex.putDownload(download1); Download download2 = - new DownloadBuilder("id2").setStartTimeMs(1).setState(Download.STATE_STOPPED).build(); + new DownloadBuilder("id2").setStartTimeMs(1).setState(STATE_STOPPED).build(); downloadIndex.putDownload(download2); Download download3 = new DownloadBuilder("id3").setStartTimeMs(2).setState(Download.STATE_COMPLETED).build(); @@ -202,6 +219,47 @@ public class DefaultDownloadIndexTest { .isEqualTo(DefaultDownloadIndex.TABLE_VERSION); } + @Test + public void downloadIndex_upgradesFromVersion2() throws IOException { + Context context = ApplicationProvider.getApplicationContext(); + File databaseFile = context.getDatabasePath(ExoDatabaseProvider.DATABASE_NAME); + try (FileOutputStream output = new FileOutputStream(databaseFile)) { + output.write(TestUtil.getByteArray(context, "media/offline/exoplayer_internal_v2.db")); + } + Download dashDownload = + createDownload( + /* uri= */ "http://www.test.com/manifest.mpd", + /* mimeType= */ MimeTypes.APPLICATION_MPD, + ImmutableList.of(), + /* customCacheKey= */ null); + Download hlsDownload = + createDownload( + /* uri= */ "http://www.test.com/manifest.m3u8", + /* mimeType= */ MimeTypes.APPLICATION_M3U8, + ImmutableList.of(), + /* customCacheKey= */ null); + Download ssDownload = + createDownload( + /* uri= */ "http://www.test.com/video.ism/manifest", + /* mimeType= */ MimeTypes.APPLICATION_SS, + Arrays.asList(new StreamKey(0, 0), new StreamKey(1, 1)), + /* customCacheKey= */ null); + Download progressiveDownload = + createDownload( + /* uri= */ "http://www.test.com/video.mp4", + /* mimeType= */ MimeTypes.VIDEO_UNKNOWN, + ImmutableList.of(), + /* customCacheKey= */ "customCacheKey"); + + databaseProvider = new ExoDatabaseProvider(context); + downloadIndex = new DefaultDownloadIndex(databaseProvider); + + assertEqual(downloadIndex.getDownload("http://www.test.com/manifest.mpd"), dashDownload); + assertEqual(downloadIndex.getDownload("http://www.test.com/manifest.m3u8"), hlsDownload); + assertEqual(downloadIndex.getDownload("http://www.test.com/video.ism/manifest"), ssDownload); + assertEqual(downloadIndex.getDownload("http://www.test.com/video.mp4"), progressiveDownload); + } + @Test public void setStopReason_setReasonToNone() throws Exception { String id = "id"; @@ -210,10 +268,10 @@ public class DefaultDownloadIndexTest { Download download = downloadBuilder.build(); downloadIndex.putDownload(download); - downloadIndex.setStopReason(Download.STOP_REASON_NONE); + downloadIndex.setStopReason(STOP_REASON_NONE); Download readDownload = downloadIndex.getDownload(id); - Download expectedDownload = downloadBuilder.setStopReason(Download.STOP_REASON_NONE).build(); + Download expectedDownload = downloadBuilder.setStopReason(STOP_REASON_NONE).build(); assertEqual(readDownload, expectedDownload); } @@ -223,7 +281,7 @@ public class DefaultDownloadIndexTest { DownloadBuilder downloadBuilder = new DownloadBuilder(id) .setState(Download.STATE_FAILED) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN); + .setFailureReason(FAILURE_REASON_UNKNOWN); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int stopReason = 0x12345678; @@ -238,7 +296,7 @@ public class DefaultDownloadIndexTest { @Test public void setStopReason_notTerminalState_doesNotSetStopReason() throws Exception { String id = "id"; - DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(Download.STATE_DOWNLOADING); + DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(STATE_DOWNLOADING); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int notMetRequirements = 0x12345678; @@ -255,7 +313,7 @@ public class DefaultDownloadIndexTest { DownloadBuilder downloadBuilder = new DownloadBuilder(id) .setState(Download.STATE_FAILED) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN); + .setFailureReason(FAILURE_REASON_UNKNOWN); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); @@ -263,7 +321,7 @@ public class DefaultDownloadIndexTest { download = downloadIndex.getDownload(id); assertThat(download.state).isEqualTo(Download.STATE_REMOVING); - assertThat(download.failureReason).isEqualTo(Download.FAILURE_REASON_NONE); + assertThat(download.failureReason).isEqualTo(FAILURE_REASON_NONE); } @Test @@ -274,10 +332,10 @@ public class DefaultDownloadIndexTest { Download download = downloadBuilder.build(); downloadIndex.putDownload(download); - downloadIndex.setStopReason(id, Download.STOP_REASON_NONE); + downloadIndex.setStopReason(id, STOP_REASON_NONE); Download readDownload = downloadIndex.getDownload(id); - Download expectedDownload = downloadBuilder.setStopReason(Download.STOP_REASON_NONE).build(); + Download expectedDownload = downloadBuilder.setStopReason(STOP_REASON_NONE).build(); assertEqual(readDownload, expectedDownload); } @@ -287,7 +345,7 @@ public class DefaultDownloadIndexTest { DownloadBuilder downloadBuilder = new DownloadBuilder(id) .setState(Download.STATE_FAILED) - .setFailureReason(Download.FAILURE_REASON_UNKNOWN); + .setFailureReason(FAILURE_REASON_UNKNOWN); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int stopReason = 0x12345678; @@ -302,7 +360,7 @@ public class DefaultDownloadIndexTest { @Test public void setSingleDownloadStopReason_notTerminalState_doesNotSetStopReason() throws Exception { String id = "id"; - DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(Download.STATE_DOWNLOADING); + DownloadBuilder downloadBuilder = new DownloadBuilder(id).setState(STATE_DOWNLOADING); Download download = downloadBuilder.build(); downloadIndex.putDownload(download); int notMetRequirements = 0x12345678; @@ -324,4 +382,23 @@ public class DefaultDownloadIndexTest { assertThat(download.getPercentDownloaded()).isEqualTo(that.getPercentDownloaded()); assertThat(download.getBytesDownloaded()).isEqualTo(that.getBytesDownloaded()); } + + private static Download createDownload( + String uri, String mimeType, List streamKeys, @Nullable String customCacheKey) { + DownloadRequest downloadRequest = + new DownloadRequest.Builder(uri, Uri.parse(uri)) + .setMimeType(mimeType) + .setStreamKeys(streamKeys) + .setCustomCacheKey(customCacheKey) + .setData(new byte[] {0, 1, 2, 3}) + .build(); + return new Download( + downloadRequest, + /* state= */ STATE_STOPPED, + /* startTimeMs= */ 1, + /* updateTimeMs= */ 2, + /* contentLength= */ 3, + /* stopReason= */ 4, + /* failureReason= */ FAILURE_REASON_NONE); + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java index 5955a9491e..9cf52ce568 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloaderFactoryTest.java @@ -22,7 +22,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; @@ -37,17 +36,13 @@ public final class DefaultDownloaderFactoryTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( - new DownloadRequest( - "id", - DownloadRequest.TYPE_PROGRESSIVE, - Uri.parse("https://www.test.com/download"), - /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null)); + new DownloadRequest.Builder(/* id= */ "id", Uri.parse("https://www.test.com/download")) + .build()); assertThat(downloader).isInstanceOf(ProgressiveDownloader.class); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java index 5fa9ae082f..76f9267430 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadHelperTest.java @@ -16,13 +16,14 @@ package com.google.android.exoplayer2.offline; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.robolectric.shadows.ShadowBaseLooper.shadowMainLooper; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.Timeline; @@ -46,21 +47,16 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link DownloadHelper}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class DownloadHelperTest { - private static final String TEST_DOWNLOAD_TYPE = "downloadType"; - private static final String TEST_CACHE_KEY = "cacheKey"; private static final Object TEST_MANIFEST = new Object(); private static final Timeline TEST_TIMELINE = new FakeTimeline( @@ -69,10 +65,6 @@ public class DownloadHelperTest { private static final Format VIDEO_FORMAT_LOW = createVideoFormat(/* bitrate= */ 200_000); private static final Format VIDEO_FORMAT_HIGH = createVideoFormat(/* bitrate= */ 800_000); - private static Format audioFormatUs; - private static Format audioFormatZh; - private static Format textFormatUs; - private static Format textFormatZh; private static final TrackGroup TRACK_GROUP_VIDEO_BOTH = new TrackGroup(VIDEO_FORMAT_LOW, VIDEO_FORMAT_HIGH); @@ -81,40 +73,37 @@ public class DownloadHelperTest { private static TrackGroup trackGroupAudioZh; private static TrackGroup trackGroupTextUs; private static TrackGroup trackGroupTextZh; - - private static TrackGroupArray trackGroupArrayAll; - private static TrackGroupArray trackGroupArraySingle; private static TrackGroupArray[] trackGroupArrays; - - private static Uri testUri; + private static MediaItem testMediaItem; private DownloadHelper downloadHelper; @BeforeClass public static void staticSetUp() { - audioFormatUs = createAudioFormat(/* language= */ "US"); - audioFormatZh = createAudioFormat(/* language= */ "ZH"); - textFormatUs = createTextFormat(/* language= */ "US"); - textFormatZh = createTextFormat(/* language= */ "ZH"); + Format audioFormatUs = createAudioFormat(/* language= */ "US"); + Format audioFormatZh = createAudioFormat(/* language= */ "ZH"); + Format textFormatUs = createTextFormat(/* language= */ "US"); + Format textFormatZh = createTextFormat(/* language= */ "ZH"); trackGroupAudioUs = new TrackGroup(audioFormatUs); trackGroupAudioZh = new TrackGroup(audioFormatZh); trackGroupTextUs = new TrackGroup(textFormatUs); trackGroupTextZh = new TrackGroup(textFormatZh); - trackGroupArrayAll = + TrackGroupArray trackGroupArrayAll = new TrackGroupArray( TRACK_GROUP_VIDEO_BOTH, trackGroupAudioUs, trackGroupAudioZh, trackGroupTextUs, trackGroupTextZh); - trackGroupArraySingle = + TrackGroupArray trackGroupArraySingle = new TrackGroupArray(TRACK_GROUP_VIDEO_SINGLE, trackGroupAudioUs); trackGroupArrays = new TrackGroupArray[] {trackGroupArrayAll, trackGroupArraySingle}; - testUri = Uri.parse("http://test.uri"); + testMediaItem = + new MediaItem.Builder().setUri("http://test.uri").setCustomCacheKey("cacheKey").build(); } @Before @@ -128,11 +117,9 @@ public class DownloadHelperTest { downloadHelper = new DownloadHelper( - TEST_DOWNLOAD_TYPE, - testUri, - TEST_CACHE_KEY, + testMediaItem, new TestMediaSource(), - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, DownloadHelper.getRendererCapabilities(renderersFactory)); } @@ -414,9 +401,10 @@ public class DownloadHelperTest { DownloadRequest downloadRequest = downloadHelper.getDownloadRequest(data); - assertThat(downloadRequest.type).isEqualTo(TEST_DOWNLOAD_TYPE); - assertThat(downloadRequest.uri).isEqualTo(testUri); - assertThat(downloadRequest.customCacheKey).isEqualTo(TEST_CACHE_KEY); + assertThat(downloadRequest.uri).isEqualTo(testMediaItem.playbackProperties.uri); + assertThat(downloadRequest.mimeType).isEqualTo(testMediaItem.playbackProperties.mimeType); + assertThat(downloadRequest.customCacheKey) + .isEqualTo(testMediaItem.playbackProperties.customCacheKey); assertThat(downloadRequest.data).isEqualTo(data); assertThat(downloadRequest.streamKeys) .containsExactly( @@ -445,7 +433,7 @@ public class DownloadHelperTest { preparedLatch.countDown(); } }); - while (!preparedLatch.await(0, TimeUnit.MILLISECONDS)) { + while (!preparedLatch.await(0, MILLISECONDS)) { shadowMainLooper().idleFor(shadowMainLooper().getNextScheduledTaskTime()); } if (prepareException.get() != null) { @@ -505,14 +493,14 @@ public class DownloadHelperTest { int periodIndex = TEST_TIMELINE.getIndexOfPeriod(id.periodUid); return new FakeMediaPeriod( trackGroupArrays[periodIndex], + TEST_TIMELINE.getWindow(0, new Timeline.Window()).positionInFirstPeriodUs, new EventDispatcher() .withParameters(/* windowIndex= */ 0, id, /* mediaTimeOffsetMs= */ 0)) { @Override public List getStreamKeys(List trackSelections) { List result = new ArrayList<>(); for (TrackSelection trackSelection : trackSelections) { - int groupIndex = - trackGroupArrays[periodIndex].indexOf(trackSelection.getTrackGroup()); + int groupIndex = trackGroupArrays[periodIndex].indexOf(trackSelection.getTrackGroup()); for (int i = 0; i < trackSelection.length(); i++) { result.add( new StreamKey(periodIndex, groupIndex, trackSelection.getIndexInTrackGroup(i))); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java index 0f4abf2f89..d5959584ad 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadManagerTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.offline; import static com.google.common.truth.Truth.assertThat; +import static java.util.Arrays.asList; import android.net.Uri; import androidx.annotation.GuardedBy; @@ -33,7 +34,6 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ConditionVariable; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -41,13 +41,10 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; import org.robolectric.shadows.ShadowLog; /** Tests {@link DownloadManager}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public class DownloadManagerTest { /** Timeout to use when blocking on conditions that we expect to become unblocked. */ @@ -56,7 +53,7 @@ public class DownloadManagerTest { private static final int APP_STOP_REASON = 1; /** The minimum number of times a download must be retried before failing. */ private static final int MIN_RETRY_COUNT = 3; - /** Dummy value for the current time. */ + /** Test value for the current time. */ private static final long NOW_MS = 1234; private static final String ID1 = "id1"; @@ -68,19 +65,19 @@ public class DownloadManagerTest { private DownloadManager downloadManager; private TestDownloadManagerListener downloadManagerListener; - private DummyMainThread dummyMainThread; + private DummyMainThread testThread; @Before public void setUp() throws Exception { ShadowLog.stream = System.out; - dummyMainThread = new DummyMainThread(); + testThread = new DummyMainThread(); setupDownloadManager(/* maxParallelDownloads= */ 100); } @After public void tearDown() throws Exception { releaseDownloadManager(); - dummyMainThread.release(); + testThread.release(); } @Test @@ -711,24 +708,18 @@ public class DownloadManagerTest { private List postGetCurrentDownloads() { AtomicReference> currentDownloadsReference = new AtomicReference<>(); - runOnMainThread( - () -> { - currentDownloadsReference.set(downloadManager.getCurrentDownloads()); - }); + runOnMainThread(() -> currentDownloadsReference.set(downloadManager.getCurrentDownloads())); return currentDownloadsReference.get(); } private DownloadIndex postGetDownloadIndex() { AtomicReference downloadIndexReference = new AtomicReference<>(); - runOnMainThread( - () -> { - downloadIndexReference.set(downloadManager.getDownloadIndex()); - }); + runOnMainThread(() -> downloadIndexReference.set(downloadManager.getDownloadIndex())); return downloadIndexReference.get(); } private void runOnMainThread(TestRunnable r) { - dummyMainThread.runTestOnMainThread(r); + testThread.runTestOnMainThread(r); } private FakeDownloader getDownloaderAt(int index) throws InterruptedException { @@ -794,13 +785,9 @@ public class DownloadManagerTest { } private static DownloadRequest createDownloadRequest(String id, StreamKey... keys) { - return new DownloadRequest( - id, - DownloadRequest.TYPE_DASH, - Uri.parse("http://abc.com/ " + id), - Arrays.asList(keys), - /* customCacheKey= */ null, - /* data= */ null); + return new DownloadRequest.Builder(id, Uri.parse("http://abc.com/ " + id)) + .setStreamKeys(asList(keys)) + .build(); } // Internal methods. @@ -917,7 +904,7 @@ public class DownloadManagerTest { } public void assertStreamKeys(StreamKey... streamKeys) { - assertThat(request.streamKeys).containsExactly(streamKeys); + assertThat(request.streamKeys).containsExactlyElementsIn(streamKeys); } public void assertDownloadStarted() throws InterruptedException { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java index c5b00b02d6..c4a101e946 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DownloadRequestTest.java @@ -15,18 +15,14 @@ */ package com.google.android.exoplayer2.offline; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_DASH; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_HLS; -import static com.google.android.exoplayer2.offline.DownloadRequest.TYPE_PROGRESSIVE; import static com.google.common.truth.Truth.assertThat; +import static java.util.Arrays.asList; import static org.junit.Assert.fail; import android.net.Uri; import android.os.Parcel; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.ArrayList; -import java.util.Collections; -import java.util.List; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -46,48 +42,10 @@ public class DownloadRequestTest { @Test public void mergeRequests_withDifferentIds_fails() { - DownloadRequest request1 = - new DownloadRequest( - "id1", - TYPE_DASH, - uri1, - /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null); - DownloadRequest request2 = - new DownloadRequest( - "id2", - TYPE_DASH, - uri2, - /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null); - try { - request1.copyWithMergedRequest(request2); - fail(); - } catch (IllegalArgumentException e) { - // Expected. - } - } - @Test - public void mergeRequests_withDifferentTypes_fails() { - DownloadRequest request1 = - new DownloadRequest( - "id1", - TYPE_DASH, - uri1, - /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null); - DownloadRequest request2 = - new DownloadRequest( - "id1", - TYPE_HLS, - uri1, - /* streamKeys= */ Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null); + DownloadRequest request1 = new DownloadRequest.Builder(/* id= */ "id1", uri1).build(); + DownloadRequest request2 = new DownloadRequest.Builder(/* id= */ "id2", uri2).build(); + try { request1.copyWithMergedRequest(request2); fail(); @@ -135,33 +93,34 @@ public class DownloadRequestTest { @Test public void mergeRequests_withDifferentFields() { - byte[] data1 = new byte[] {0, 1, 2}; - byte[] data2 = new byte[] {3, 4, 5}; - DownloadRequest request1 = - new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, - uri1, - /* streamKeys= */ Collections.emptyList(), - "key1", - /* data= */ data1); - DownloadRequest request2 = - new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, - uri2, - /* streamKeys= */ Collections.emptyList(), - "key2", - /* data= */ data2); + byte[] keySetId1 = new byte[] {0, 1, 2}; + byte[] keySetId2 = new byte[] {3, 4, 5}; + byte[] data1 = new byte[] {6, 7, 8}; + byte[] data2 = new byte[] {9, 10, 11}; - // uri, customCacheKey and data should be from the request being merged. + DownloadRequest request1 = + new DownloadRequest.Builder(/* id= */ "id1", uri1) + .setKeySetId(keySetId1) + .setCustomCacheKey("key1") + .setData(data1) + .build(); + DownloadRequest request2 = + new DownloadRequest.Builder(/* id= */ "id1", uri2) + .setKeySetId(keySetId2) + .setCustomCacheKey("key2") + .setData(data2) + .build(); + + // uri, keySetId, customCacheKey and data should be from the request being merged. DownloadRequest mergedRequest = request1.copyWithMergedRequest(request2); assertThat(mergedRequest.uri).isEqualTo(uri2); + assertThat(mergedRequest.keySetId).isEqualTo(keySetId2); assertThat(mergedRequest.customCacheKey).isEqualTo("key2"); assertThat(mergedRequest.data).isEqualTo(data2); mergedRequest = request2.copyWithMergedRequest(request1); assertThat(mergedRequest.uri).isEqualTo(uri1); + assertThat(mergedRequest.keySetId).isEqualTo(keySetId1); assertThat(mergedRequest.customCacheKey).isEqualTo("key1"); assertThat(mergedRequest.data).isEqualTo(data1); } @@ -172,13 +131,12 @@ public class DownloadRequestTest { streamKeys.add(new StreamKey(1, 2, 3)); streamKeys.add(new StreamKey(4, 5, 6)); DownloadRequest requestToParcel = - new DownloadRequest( - "id", - "type", - Uri.parse("https://abc.def/ghi"), - streamKeys, - "key", - new byte[] {1, 2, 3, 4, 5}); + new DownloadRequest.Builder("id", Uri.parse("https://abc.def/ghi")) + .setStreamKeys(streamKeys) + .setKeySetId(new byte[] {1, 2, 3, 4, 5}) + .setCustomCacheKey("key") + .setData(new byte[] {1, 2, 3, 4, 5}) + .build(); Parcel parcel = Parcel.obtain(); requestToParcel.writeToParcel(parcel, 0); parcel.setDataPosition(0); @@ -232,16 +190,10 @@ public class DownloadRequestTest { private static void assertEqual(DownloadRequest request1, DownloadRequest request2) { assertThat(request1).isEqualTo(request2); assertThat(request2).isEqualTo(request1); + assertThat(request1.hashCode()).isEqualTo(request2.hashCode()); } private static DownloadRequest createRequest(Uri uri, StreamKey... keys) { - return new DownloadRequest( - uri.toString(), TYPE_DASH, uri, toList(keys), /* customCacheKey= */ null, /* data= */ null); - } - - private static List toList(StreamKey... keys) { - ArrayList keysList = new ArrayList<>(); - Collections.addAll(keysList, keys); - return keysList; + return new DownloadRequest.Builder(uri.toString(), uri).setStreamKeys(asList(keys)).build(); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java index ae0c431bd3..8fce3b25ac 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ClippingMediaSourceTest.java @@ -18,17 +18,20 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import android.net.Uri; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.Timeline.Window; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.source.ClippingMediaSource.IllegalClippingException; -import com.google.android.exoplayer2.source.MaskingMediaSource.DummyTimeline; +import com.google.android.exoplayer2.source.MaskingMediaSource.PlaceholderTimeline; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.testutil.FakeMediaPeriod; import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.testutil.FakeTimeline; @@ -42,16 +45,13 @@ import java.io.IOException; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; -import org.robolectric.annotation.LooperMode.Mode; /** Unit tests for {@link ClippingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(Mode.PAUSED) public final class ClippingMediaSourceTest { - private static final long TEST_PERIOD_DURATION_US = 1000000; - private static final long TEST_CLIP_AMOUNT_US = 300000; + private static final long TEST_PERIOD_DURATION_US = 1_000_000; + private static final long TEST_CLIP_AMOUNT_US = 300_000; private Window window; private Period period; @@ -69,7 +69,9 @@ public final class ClippingMediaSourceTest { TEST_PERIOD_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US); @@ -88,7 +90,9 @@ public final class ClippingMediaSourceTest { TEST_PERIOD_DURATION_US, /* isSeekable= */ false, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); // If the unseekable window isn't clipped, clipping succeeds. getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US); @@ -108,7 +112,9 @@ public final class ClippingMediaSourceTest { /* durationUs= */ C.TIME_UNSET, /* isSeekable= */ false, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); // If the unseekable window isn't clipped, clipping succeeds. getClippedTimeline(timeline, /* startUs= */ 0, TEST_PERIOD_DURATION_US); @@ -128,7 +134,9 @@ public final class ClippingMediaSourceTest { TEST_PERIOD_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, TEST_CLIP_AMOUNT_US, TEST_PERIOD_DURATION_US); @@ -145,7 +153,9 @@ public final class ClippingMediaSourceTest { TEST_PERIOD_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, 0, TEST_PERIOD_DURATION_US - TEST_CLIP_AMOUNT_US); @@ -160,7 +170,7 @@ public final class ClippingMediaSourceTest { // Timeline that's dynamic and not seekable. A child source might report such a timeline prior // to it having loaded sufficient data to establish its duration and seekability. Such timelines // should not result in clipping failure. - Timeline timeline = new DummyTimeline(/* tag= */ null); + Timeline timeline = new PlaceholderTimeline(MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline( @@ -179,7 +189,9 @@ public final class ClippingMediaSourceTest { /* durationUs= */ TEST_PERIOD_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); // When clipping to the end, the clipped timeline should also have a duration. Timeline clippedTimeline = @@ -196,7 +208,9 @@ public final class ClippingMediaSourceTest { /* durationUs= */ C.TIME_UNSET, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); // When clipping to the end, the clipped timeline should also have an unset duration. Timeline clippedTimeline = @@ -212,7 +226,9 @@ public final class ClippingMediaSourceTest { TEST_PERIOD_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline( @@ -235,7 +251,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline clippedTimeline = getClippedTimeline(timeline, /* durationUs= */ TEST_CLIP_AMOUNT_US); assertThat(clippedTimeline.getWindow(0, window).getDurationUs()).isEqualTo(TEST_CLIP_AMOUNT_US); @@ -258,7 +274,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 3 * TEST_PERIOD_DURATION_US, @@ -269,7 +285,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -309,7 +325,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 4 * TEST_PERIOD_DURATION_US, @@ -320,7 +336,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -360,7 +376,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 3 * TEST_PERIOD_DURATION_US, @@ -371,7 +387,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -412,7 +428,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline timeline2 = new SinglePeriodTimeline( /* periodDurationUs= */ 4 * TEST_PERIOD_DURATION_US, @@ -423,7 +439,7 @@ public final class ClippingMediaSourceTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -540,7 +556,9 @@ public final class ClippingMediaSourceTest { TEST_PERIOD_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); FakeMediaSource fakeMediaSource = new FakeMediaSource(timeline) { @Override @@ -548,9 +566,11 @@ public final class ClippingMediaSourceTest { MediaPeriodId id, TrackGroupArray trackGroupArray, Allocator allocator, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - eventDispatcher.downstreamFormatChanged( + mediaSourceEventDispatcher.downstreamFormatChanged( new MediaLoadData( C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, @@ -560,7 +580,13 @@ public final class ClippingMediaSourceTest { C.usToMs(eventStartUs), C.usToMs(eventEndUs))); return super.createFakeMediaPeriod( - id, trackGroupArray, allocator, eventDispatcher, transferListener); + id, + trackGroupArray, + allocator, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + transferListener); } }; final ClippingMediaSource clippingMediaSource = @@ -572,7 +598,7 @@ public final class ClippingMediaSourceTest { testRunner.runOnPlaybackThread( () -> clippingMediaSource.addEventListener( - Util.createHandler(), + Util.createHandlerForCurrentLooper(), new MediaSourceEventListener() { @Override public void onDownstreamFormatChanged( diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java index cf2e3e879d..7ba0cc02e5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSourceTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; @@ -38,16 +39,13 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link ConcatenatingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class ConcatenatingMediaSourceTest { private ConcatenatingMediaSource mediaSource; @@ -408,13 +406,15 @@ public final class ConcatenatingMediaSourceTest { public void customCallbackBeforePreparationAddSingle() throws Exception { CountDownLatch runnableInvoked = new CountDownLatch(1); - DummyMainThread dummyMainThread = new DummyMainThread(); - dummyMainThread.runOnMainThread( + DummyMainThread testThread = new DummyMainThread(); + testThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), Util.createHandler(), runnableInvoked::countDown)); - runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); - dummyMainThread.release(); + createFakeMediaSource(), + Util.createHandlerForCurrentLooper(), + runnableInvoked::countDown)); + runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS); + testThread.release(); assertThat(runnableInvoked.getCount()).isEqualTo(0); } @@ -423,15 +423,15 @@ public final class ConcatenatingMediaSourceTest { public void customCallbackBeforePreparationAddMultiple() throws Exception { CountDownLatch runnableInvoked = new CountDownLatch(1); - DummyMainThread dummyMainThread = new DummyMainThread(); - dummyMainThread.runOnMainThread( + DummyMainThread testThread = new DummyMainThread(); + testThread.runOnMainThread( () -> mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); - runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); - dummyMainThread.release(); + runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS); + testThread.release(); assertThat(runnableInvoked.getCount()).isEqualTo(0); } @@ -440,16 +440,16 @@ public final class ConcatenatingMediaSourceTest { public void customCallbackBeforePreparationAddSingleWithIndex() throws Exception { CountDownLatch runnableInvoked = new CountDownLatch(1); - DummyMainThread dummyMainThread = new DummyMainThread(); - dummyMainThread.runOnMainThread( + DummyMainThread testThread = new DummyMainThread(); + testThread.runOnMainThread( () -> mediaSource.addMediaSource( /* index */ 0, createFakeMediaSource(), - Util.createHandler(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); - runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); - dummyMainThread.release(); + runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS); + testThread.release(); assertThat(runnableInvoked.getCount()).isEqualTo(0); } @@ -458,16 +458,16 @@ public final class ConcatenatingMediaSourceTest { public void customCallbackBeforePreparationAddMultipleWithIndex() throws Exception { CountDownLatch runnableInvoked = new CountDownLatch(1); - DummyMainThread dummyMainThread = new DummyMainThread(); - dummyMainThread.runOnMainThread( + DummyMainThread testThread = new DummyMainThread(); + testThread.runOnMainThread( () -> mediaSource.addMediaSources( /* index */ 0, Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); - runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); - dummyMainThread.release(); + runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS); + testThread.release(); assertThat(runnableInvoked.getCount()).isEqualTo(0); } @@ -476,15 +476,15 @@ public final class ConcatenatingMediaSourceTest { public void customCallbackBeforePreparationRemove() throws Exception { CountDownLatch runnableInvoked = new CountDownLatch(1); - DummyMainThread dummyMainThread = new DummyMainThread(); - dummyMainThread.runOnMainThread( + DummyMainThread testThread = new DummyMainThread(); + testThread.runOnMainThread( () -> { mediaSource.addMediaSource(createFakeMediaSource()); mediaSource.removeMediaSource( - /* index */ 0, Util.createHandler(), runnableInvoked::countDown); + /* index */ 0, Util.createHandlerForCurrentLooper(), runnableInvoked::countDown); }); - runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); - dummyMainThread.release(); + runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS); + testThread.release(); assertThat(runnableInvoked.getCount()).isEqualTo(0); } @@ -493,120 +493,127 @@ public final class ConcatenatingMediaSourceTest { public void customCallbackBeforePreparationMove() throws Exception { CountDownLatch runnableInvoked = new CountDownLatch(1); - DummyMainThread dummyMainThread = new DummyMainThread(); - dummyMainThread.runOnMainThread( + DummyMainThread testThread = new DummyMainThread(); + testThread.runOnMainThread( () -> { mediaSource.addMediaSources( Arrays.asList(new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()})); mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), runnableInvoked::countDown); + /* fromIndex */ 1, /* toIndex */ + 0, + Util.createHandlerForCurrentLooper(), + runnableInvoked::countDown); }); - runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); - dummyMainThread.release(); + runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS); + testThread.release(); assertThat(runnableInvoked.getCount()).isEqualTo(0); } @Test public void customCallbackAfterPreparationAddSingle() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); try { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaSource.addMediaSource( - createFakeMediaSource(), Util.createHandler(), timelineGrabber)); + createFakeMediaSource(), Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { - dummyMainThread.release(); + testThread.release(); } } @Test public void customCallbackAfterPreparationAddMultiple() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); try { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaSource.addMediaSources( Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); } finally { - dummyMainThread.release(); + testThread.release(); } } @Test public void customCallbackAfterPreparationAddSingleWithIndex() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); try { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaSource.addMediaSource( - /* index */ 0, createFakeMediaSource(), Util.createHandler(), timelineGrabber)); + /* index */ 0, + createFakeMediaSource(), + Util.createHandlerForCurrentLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(1); } finally { - dummyMainThread.release(); + testThread.release(); } } @Test public void customCallbackAfterPreparationAddMultipleWithIndex() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); try { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaSource.addMediaSources( /* index */ 0, Arrays.asList( new MediaSource[] {createFakeMediaSource(), createFakeMediaSource()}), - Util.createHandler(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); } finally { - dummyMainThread.release(); + testThread.release(); } } @Test public void customCallbackAfterPreparationRemove() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); try { testRunner.prepareSource(); - dummyMainThread.runOnMainThread(() -> mediaSource.addMediaSource(createFakeMediaSource())); + testThread.runOnMainThread(() -> mediaSource.addMediaSource(createFakeMediaSource())); testRunner.assertTimelineChangeBlocking(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> - mediaSource.removeMediaSource(/* index */ 0, Util.createHandler(), timelineGrabber)); + mediaSource.removeMediaSource( + /* index */ 0, Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(0); } finally { - dummyMainThread.release(); + testThread.release(); } } @Test public void customCallbackAfterPreparationMove() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); try { testRunner.prepareSource(); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaSource.addMediaSources( Arrays.asList( @@ -614,23 +621,26 @@ public final class ConcatenatingMediaSourceTest { testRunner.assertTimelineChangeBlocking(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaSource.moveMediaSource( - /* fromIndex */ 1, /* toIndex */ 0, Util.createHandler(), timelineGrabber)); + /* fromIndex */ 1, /* toIndex */ + 0, + Util.createHandlerForCurrentLooper(), + timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getWindowCount()).isEqualTo(2); } finally { - dummyMainThread.release(); + testThread.release(); } } @Test public void customCallbackIsCalledAfterRelease() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); CountDownLatch callbackCalledCondition = new CountDownLatch(1); try { - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> { MediaSourceCaller caller = mock(MediaSourceCaller.class); mediaSource.addMediaSources(Arrays.asList(createMediaSources(2))); @@ -638,16 +648,14 @@ public final class ConcatenatingMediaSourceTest { mediaSource.moveMediaSource( /* currentIndex= */ 0, /* newIndex= */ 1, - Util.createHandler(), + Util.createHandlerForCurrentLooper(), callbackCalledCondition::countDown); mediaSource.releaseSource(caller); }); - assertThat( - callbackCalledCondition.await( - MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS)) + assertThat(callbackCalledCondition.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS)) .isTrue(); } finally { - dummyMainThread.release(); + testThread.release(); } } @@ -879,10 +887,10 @@ public final class ConcatenatingMediaSourceTest { @Test public void clear() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); final FakeMediaSource preparedChildSource = createFakeMediaSource(); final FakeMediaSource unpreparedChildSource = new FakeMediaSource(/* timeline= */ null); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> { mediaSource.addMediaSource(preparedChildSource); mediaSource.addMediaSource(unpreparedChildSource); @@ -890,7 +898,8 @@ public final class ConcatenatingMediaSourceTest { testRunner.prepareSource(); final TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread(() -> mediaSource.clear(Util.createHandler(), timelineGrabber)); + testThread.runOnMainThread( + () -> mediaSource.clear(Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.isEmpty()).isTrue(); @@ -1037,37 +1046,37 @@ public final class ConcatenatingMediaSourceTest { public void customCallbackBeforePreparationSetShuffleOrder() throws Exception { CountDownLatch runnableInvoked = new CountDownLatch(1); - DummyMainThread dummyMainThread = new DummyMainThread(); - dummyMainThread.runOnMainThread( + DummyMainThread testThread = new DummyMainThread(); + testThread.runOnMainThread( () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 0), - Util.createHandler(), + Util.createHandlerForCurrentLooper(), runnableInvoked::countDown)); - runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS); - dummyMainThread.release(); + runnableInvoked.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS); + testThread.release(); assertThat(runnableInvoked.getCount()).isEqualTo(0); } @Test public void customCallbackAfterPreparationSetShuffleOrder() throws Exception { - DummyMainThread dummyMainThread = new DummyMainThread(); + DummyMainThread testThread = new DummyMainThread(); try { mediaSource.addMediaSources( Arrays.asList(createFakeMediaSource(), createFakeMediaSource(), createFakeMediaSource())); testRunner.prepareSource(); TimelineGrabber timelineGrabber = new TimelineGrabber(testRunner); - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> mediaSource.setShuffleOrder( new ShuffleOrder.UnshuffledShuffleOrder(/* length= */ 3), - Util.createHandler(), + Util.createHandlerForCurrentLooper(), timelineGrabber)); Timeline timeline = timelineGrabber.assertTimelineChangeBlocking(); assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(0); } finally { - dummyMainThread.release(); + testThread.release(); } } @@ -1135,8 +1144,7 @@ public final class ConcatenatingMediaSourceTest { } public Timeline assertTimelineChangeBlocking() throws InterruptedException { - assertThat(finishedLatch.await(MediaSourceTestRunner.TIMEOUT_MS, TimeUnit.MILLISECONDS)) - .isTrue(); + assertThat(finishedLatch.await(MediaSourceTestRunner.TIMEOUT_MS, MILLISECONDS)).isTrue(); if (error != null) { throw error; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java index 0e723a0263..d02f04d097 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java @@ -20,14 +20,12 @@ import static org.mockito.Mockito.mock; import android.content.Context; import android.net.Uri; -import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; import java.util.Collections; @@ -42,10 +40,21 @@ public final class DefaultMediaSourceFactoryTest { private static final String URI_MEDIA = "http://exoplayer.dev/video"; private static final String URI_TEXT = "http://exoplayer.dev/text"; + @Test + public void createMediaSource_fromMediaItem_returnsSameMediaItemInstance() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource.getMediaItem()).isSameInstanceAs(mediaItem); + } + @Test public void createMediaSource_withoutMimeType_progressiveSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -54,10 +63,11 @@ public final class DefaultMediaSourceFactoryTest { } @Test - public void createMediaSource_withTag_tagInSource() { + @SuppressWarnings("deprecation") // Testing deprecated MediaSource.getTag() still works. + public void createMediaSource_withTag_tagInSource_deprecated() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setTag(tag).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -68,7 +78,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withPath_progressiveSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mp3").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -79,7 +89,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); MediaSource mediaSource = @@ -95,7 +105,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withSubtitle_isMergingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); List subtitles = Arrays.asList( new MediaItem.Subtitle(Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "en"), @@ -109,9 +119,10 @@ public final class DefaultMediaSourceFactoryTest { } @Test - public void createMediaSource_withSubtitle_hasTag() { + @SuppressWarnings("deprecation") // Testing deprecated MediaSource.getTag() still works. + public void createMediaSource_withSubtitle_hasTag_deprecated() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); Object tag = new Object(); MediaItem mediaItem = new MediaItem.Builder() @@ -130,7 +141,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withStartPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setClipStartPositionMs(1000L).build(); @@ -142,7 +153,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withEndPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setClipEndPositionMs(1000L).build(); @@ -154,7 +165,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_relativeToDefaultPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setClipRelativeToDefaultPosition(true).build(); @@ -166,7 +177,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_defaultToEnd_isNotClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -181,7 +192,7 @@ public final class DefaultMediaSourceFactoryTest { @Test public void getSupportedTypes_coreModule_onlyOther() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER); @@ -189,14 +200,12 @@ public final class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withAdTagUri_callsAdsLoader() { - Context applicationContext = ApplicationProvider.getApplicationContext(); Uri adTagUri = Uri.parse(URI_MEDIA); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(adTagUri).build(); DefaultMediaSourceFactory defaultMediaSourceFactory = - new DefaultMediaSourceFactory( - applicationContext, - new DefaultDataSourceFactory(applicationContext, "userAgent"), - createAdSupportProvider(mock(AdsLoader.class), mock(AdsLoader.AdViewProvider.class))); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) + .setAdsLoaderProvider(ignoredAdTagUri -> mock(AdsLoader.class)) + .setAdViewProvider(mock(AdsLoader.AdViewProvider.class)); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -204,15 +213,11 @@ public final class DefaultMediaSourceFactoryTest { } @Test - public void createMediaSource_withAdTagUriAdsLoaderNull_playsWithoutAdNoException() { - Context applicationContext = ApplicationProvider.getApplicationContext(); + public void createMediaSource_withAdTagUri_adProvidersNotSet_playsWithoutAdNoException() { MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); DefaultMediaSourceFactory defaultMediaSourceFactory = - new DefaultMediaSourceFactory( - applicationContext, - new DefaultDataSourceFactory(applicationContext, "userAgent"), - createAdSupportProvider(/* adsLoader= */ null, mock(AdsLoader.AdViewProvider.class))); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -226,24 +231,8 @@ public final class DefaultMediaSourceFactoryTest { new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); MediaSource mediaSource = - DefaultMediaSourceFactory.newInstance(applicationContext).createMediaSource(mediaItem); + new DefaultMediaSourceFactory(applicationContext).createMediaSource(mediaItem); assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class); } - - private static DefaultMediaSourceFactory.AdSupportProvider createAdSupportProvider( - @Nullable AdsLoader adsLoader, AdsLoader.AdViewProvider adViewProvider) { - return new DefaultMediaSourceFactory.AdSupportProvider() { - @Nullable - @Override - public AdsLoader getAdsLoader(Uri adTagUri) { - return adsLoader; - } - - @Override - public AdsLoader.AdViewProvider getAdViewProvider() { - return adViewProvider; - } - }; - } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java index f938ffe370..9c883a149a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/LoopingMediaSourceTest.java @@ -28,11 +28,9 @@ import java.io.IOException; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link LoopingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class LoopingMediaSourceTest { private FakeTimeline multiWindowTimeline; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/MediaSourceDrmHelperTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/MediaSourceDrmHelperTest.java new file mode 100644 index 0000000000..45384f05ec --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/MediaSourceDrmHelperTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link MediaSourceDrmHelper}. */ +@RunWith(AndroidJUnit4.class) +public class MediaSourceDrmHelperTest { + + @Test + public void create_noDrmProperties_createsNoopManager() { + DrmSessionManager drmSessionManager = + new MediaSourceDrmHelper().create(MediaItem.fromUri(Uri.EMPTY)); + + assertThat(drmSessionManager).isEqualTo(DrmSessionManager.DUMMY); + } + + @Test + public void create_createsManager() { + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setDrmLicenseUri(Uri.EMPTY) + .setDrmUuid(C.WIDEVINE_UUID) + .build(); + + DrmSessionManager drmSessionManager = new MediaSourceDrmHelper().create(mediaItem); + + assertThat(drmSessionManager).isNotEqualTo(DrmSessionManager.DUMMY); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java index d201782b53..e28af160c3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaPeriodTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -22,19 +24,21 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.testutil.FakeMediaPeriod; import com.google.android.exoplayer2.trackselection.FixedTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.common.collect.ImmutableList; import java.util.concurrent.CountDownLatch; import org.checkerframework.checker.nullness.compatqual.NullableType; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit test for {@link MergingMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class MergingMediaPeriodTest { private static final Format childFormat11 = new Format.Builder().setId("1_1").build(); @@ -46,8 +50,10 @@ public final class MergingMediaPeriodTest { public void getTrackGroups_returnsAllChildTrackGroups() throws Exception { MergingMediaPeriod mergingMediaPeriod = prepareMergingPeriod( - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat11, childFormat12), - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat21, childFormat22)); + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat11, childFormat12), + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat21, childFormat22)); assertThat(mergingMediaPeriod.getTrackGroups().length).isEqualTo(4); assertThat(mergingMediaPeriod.getTrackGroups().get(0).getFormat(0)).isEqualTo(childFormat11); @@ -60,8 +66,10 @@ public final class MergingMediaPeriodTest { public void selectTracks_createsSampleStreamsFromChildPeriods() throws Exception { MergingMediaPeriod mergingMediaPeriod = prepareMergingPeriod( - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat11, childFormat12), - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat21, childFormat22)); + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat11, childFormat12), + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, /* singleSampleTimeUs= */ 0, childFormat21, childFormat22)); TrackSelection selectionForChild1 = new FixedTrackSelection(mergingMediaPeriod.getTrackGroups().get(1), /* track= */ 0); @@ -96,8 +104,16 @@ public final class MergingMediaPeriodTest { throws Exception { MergingMediaPeriod mergingMediaPeriod = prepareMergingPeriod( - new MergingPeriodDefinition(/* timeOffsetUs= */ 0, childFormat11, childFormat12), - new MergingPeriodDefinition(/* timeOffsetUs= */ -3000, childFormat21, childFormat22)); + new MergingPeriodDefinition( + /* timeOffsetUs= */ 0, + /* singleSampleTimeUs= */ 123_000, + childFormat11, + childFormat12), + new MergingPeriodDefinition( + /* timeOffsetUs= */ -3000, + /* singleSampleTimeUs= */ 456_000, + childFormat21, + childFormat22)); TrackSelection selectionForChild1 = new FixedTrackSelection(mergingMediaPeriod.getTrackGroups().get(0), /* track= */ 0); @@ -121,14 +137,14 @@ public final class MergingMediaPeriodTest { assertThat(childMediaPeriod1.selectTracksPositionUs).isEqualTo(0); assertThat(streams[0].readData(formatHolder, inputBuffer, /* formatRequired= */ false)) .isEqualTo(C.RESULT_BUFFER_READ); - assertThat(inputBuffer.timeUs).isEqualTo(0L); + assertThat(inputBuffer.timeUs).isEqualTo(123_000L); FakeMediaPeriodWithSelectTracksPosition childMediaPeriod2 = (FakeMediaPeriodWithSelectTracksPosition) mergingMediaPeriod.getChildPeriod(1); assertThat(childMediaPeriod2.selectTracksPositionUs).isEqualTo(3000L); assertThat(streams[1].readData(formatHolder, inputBuffer, /* formatRequired= */ false)) .isEqualTo(C.RESULT_BUFFER_READ); - assertThat(inputBuffer.timeUs).isEqualTo(0L); + assertThat(inputBuffer.timeUs).isEqualTo(456_000 - 3000); } private MergingMediaPeriod prepareMergingPeriod(MergingPeriodDefinition... definitions) @@ -136,14 +152,23 @@ public final class MergingMediaPeriodTest { MediaPeriod[] mediaPeriods = new MediaPeriod[definitions.length]; long[] timeOffsetsUs = new long[definitions.length]; for (int i = 0; i < definitions.length; i++) { - timeOffsetsUs[i] = definitions[i].timeOffsetUs; - TrackGroup[] trackGroups = new TrackGroup[definitions[i].formats.length]; - for (int j = 0; j < definitions[i].formats.length; j++) { - trackGroups[j] = new TrackGroup(definitions[i].formats[j]); + MergingPeriodDefinition definition = definitions[i]; + timeOffsetsUs[i] = definition.timeOffsetUs; + TrackGroup[] trackGroups = new TrackGroup[definition.formats.length]; + for (int j = 0; j < definition.formats.length; j++) { + trackGroups[j] = new TrackGroup(definition.formats[j]); } mediaPeriods[i] = new FakeMediaPeriodWithSelectTracksPosition( - new TrackGroupArray(trackGroups), new EventDispatcher()); + new TrackGroupArray(trackGroups), + new EventDispatcher() + .withParameters( + /* windowIndex= */ i, + new MediaPeriodId(/* periodUid= */ i), + /* mediaTimeOffsetMs= */ 0), + /* trackDataFactory= */ (unusedFormat, unusedMediaPeriodId) -> + ImmutableList.of( + oneByteSample(definition.singleSampleTimeUs), END_OF_STREAM_ITEM)); } MergingMediaPeriod mergingMediaPeriod = new MergingMediaPeriod( @@ -173,8 +198,16 @@ public final class MergingMediaPeriodTest { public long selectTracksPositionUs; public FakeMediaPeriodWithSelectTracksPosition( - TrackGroupArray trackGroupArray, EventDispatcher eventDispatcher) { - super(trackGroupArray, eventDispatcher); + TrackGroupArray trackGroupArray, + EventDispatcher mediaSourceEventDispatcher, + TrackDataFactory trackDataFactory) { + super( + trackGroupArray, + trackDataFactory, + mediaSourceEventDispatcher, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* deferOnPrepared= */ false); selectTracksPositionUs = C.TIME_UNSET; } @@ -193,11 +226,13 @@ public final class MergingMediaPeriodTest { private static final class MergingPeriodDefinition { - public long timeOffsetUs; - public Format[] formats; + public final long timeOffsetUs; + public final long singleSampleTimeUs; + public final Format[] formats; - public MergingPeriodDefinition(long timeOffsetUs, Format... formats) { + public MergingPeriodDefinition(long timeOffsetUs, long singleSampleTimeUs, Format... formats) { this.timeOffsetUs = timeOffsetUs; + this.singleSampleTimeUs = singleSampleTimeUs; this.formats = formats; } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java index 4d91b7a34c..c66a5cff74 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/MergingMediaSourceTest.java @@ -29,11 +29,9 @@ import com.google.android.exoplayer2.testutil.MediaSourceTestRunner; import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link MergingMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class MergingMediaSourceTest { @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java new file mode 100644 index 0000000000..ecdb43f150 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ProgressiveMediaPeriodTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.upstream.AssetDataSource; +import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ProgressiveMediaPeriod}. */ +@RunWith(AndroidJUnit4.class) +public final class ProgressiveMediaPeriodTest { + + @Test + public void prepare_updatesSourceInfoBeforeOnPreparedCallback() throws Exception { + AtomicBoolean sourceInfoRefreshCalled = new AtomicBoolean(false); + ProgressiveMediaPeriod.Listener sourceInfoRefreshListener = + (durationUs, isSeekable, isLive) -> sourceInfoRefreshCalled.set(true); + MediaPeriodId mediaPeriodId = new MediaPeriodId(/* periodUid= */ new Object()); + ProgressiveMediaPeriod mediaPeriod = + new ProgressiveMediaPeriod( + Uri.parse("asset://android_asset/media/mp4/sample.mp4"), + new AssetDataSource(ApplicationProvider.getApplicationContext()), + () -> new Extractor[] {new Mp4Extractor()}, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId), + new DefaultLoadErrorHandlingPolicy(), + new MediaSourceEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId, /* mediaTimeOffsetMs= */ 0), + sourceInfoRefreshListener, + new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + /* customCacheKey= */ null, + ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES); + + AtomicBoolean prepareCallbackCalled = new AtomicBoolean(false); + AtomicBoolean sourceInfoRefreshCalledBeforeOnPrepared = new AtomicBoolean(false); + mediaPeriod.prepare( + new MediaPeriod.Callback() { + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + sourceInfoRefreshCalledBeforeOnPrepared.set(sourceInfoRefreshCalled.get()); + prepareCallbackCalled.set(true); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + source.continueLoading(/* positionUs= */ 0); + } + }, + /* positionUs= */ 0); + runMainLooperUntil(prepareCallbackCalled::get); + mediaPeriod.release(); + + assertThat(sourceInfoRefreshCalledBeforeOnPrepared.get()).isTrue(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index 41b953a0d2..241834fab5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -21,6 +21,7 @@ import static com.google.android.exoplayer2.C.RESULT_BUFFER_READ; import static com.google.android.exoplayer2.C.RESULT_FORMAT_READ; import static com.google.android.exoplayer2.C.RESULT_NOTHING_READ; import static com.google.common.truth.Truth.assertThat; +import static java.lang.Long.MAX_VALUE; import static java.lang.Long.MIN_VALUE; import static java.util.Arrays.copyOfRange; import static org.junit.Assert.assertArrayEquals; @@ -35,14 +36,17 @@ import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.ExoMediaCrypto; 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.MediaSourceEventDispatcher; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.primitives.Bytes; import java.io.IOException; import java.util.Arrays; import java.util.concurrent.atomic.AtomicReference; @@ -51,7 +55,6 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentMatchers; import org.mockito.Mockito; /** Test for {@link SampleQueue}. */ @@ -66,6 +69,8 @@ public final class SampleQueueTest { private static final Format FORMAT_SPLICED = buildFormat(/* id= */ "spliced"); private static final Format FORMAT_ENCRYPTED = new Format.Builder().setId(/* id= */ "encrypted").setDrmInitData(new DrmInitData()).build(); + private static final Format FORMAT_ENCRYPTED_WITH_EXO_MEDIA_CRYPTO_TYPE = + FORMAT_ENCRYPTED.copyWithExoMediaCryptoType(MockExoMediaCrypto.class); private static final byte[] DATA = TestUtil.buildTestData(ALLOCATION_SIZE * 10); /* @@ -121,13 +126,13 @@ public final class SampleQueueTest { private static final int[] ENCRYPTED_SAMPLE_OFFSETS = new int[] {7, 4, 3, 0}; private static final byte[] ENCRYPTED_SAMPLE_DATA = new byte[] {1, 1, 1, 1, 1, 1, 1, 1}; - private static final TrackOutput.CryptoData DUMMY_CRYPTO_DATA = + private static final TrackOutput.CryptoData CRYPTO_DATA = new TrackOutput.CryptoData(C.CRYPTO_MODE_AES_CTR, new byte[16], 0, 0); private Allocator allocator; - private DrmSessionManager mockDrmSessionManager; + private MockDrmSessionManager mockDrmSessionManager; private DrmSession mockDrmSession; - private MediaSourceEventDispatcher eventDispatcher; + private DrmSessionEventListener.EventDispatcher eventDispatcher; private SampleQueue sampleQueue; private FormatHolder formatHolder; private DecoderInputBuffer inputBuffer; @@ -135,12 +140,9 @@ public final class SampleQueueTest { @Before public void setUp() { allocator = new DefaultAllocator(false, ALLOCATION_SIZE); - mockDrmSessionManager = Mockito.mock(DrmSessionManager.class); mockDrmSession = Mockito.mock(DrmSession.class); - when(mockDrmSessionManager.acquireSession( - ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) - .thenReturn(mockDrmSession); - eventDispatcher = new MediaSourceEventDispatcher(); + mockDrmSessionManager = new MockDrmSessionManager(mockDrmSession); + eventDispatcher = new DrmSessionEventListener.EventDispatcher(); sampleQueue = new SampleQueue( allocator, @@ -179,6 +181,7 @@ public final class SampleQueueTest { assertReadSample( /* timeUs= */ i * 1000, /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, /* sampleData= */ new byte[1], /* offset= */ 0, @@ -225,9 +228,23 @@ public final class SampleQueueTest { sampleQueue.sampleMetadata(1000, C.BUFFER_FLAG_KEY_FRAME, ALLOCATION_SIZE, 0, null); assertReadFormat(false, FORMAT_1); - assertReadSample(0, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 0, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); // Assert the second sample is read without a format change. - assertReadSample(1000, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 1000, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); // The same applies if the queue is empty when the formats are written. sampleQueue.format(FORMAT_2); @@ -236,7 +253,14 @@ public final class SampleQueueTest { sampleQueue.sampleMetadata(2000, C.BUFFER_FLAG_KEY_FRAME, ALLOCATION_SIZE, 0, null); // Assert the third sample is read without a format change. - assertReadSample(2000, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 2000, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); } @Test @@ -259,7 +283,14 @@ public final class SampleQueueTest { // If formatRequired, should read the format rather than the sample. assertReadFormat(true, FORMAT_1); // Otherwise should read the sample. - assertReadSample(1000, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + 1000, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); // Allocation should still be held. assertAllocationCount(1); sampleQueue.discardToRead(); @@ -276,7 +307,14 @@ public final class SampleQueueTest { // If formatRequired, should read the format rather than the sample. assertReadFormat(true, FORMAT_1); // Read the sample. - assertReadSample(2000, false, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE - 1); + assertReadSample( + 2000, + /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE - 1); // Allocation should still be held. assertAllocationCount(1); sampleQueue.discardToRead(); @@ -290,7 +328,14 @@ public final class SampleQueueTest { // If formatRequired, should read the format rather than the sample. assertReadFormat(true, FORMAT_1); // Read the sample. - assertReadSample(3000, false, /* isEncrypted= */ false, DATA, ALLOCATION_SIZE - 1, 1); + assertReadSample( + 3000, + /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + ALLOCATION_SIZE - 1, + 1); // Allocation should still be held. assertAllocationCount(1); sampleQueue.discardToRead(); @@ -353,7 +398,7 @@ public final class SampleQueueTest { @Test public void isReadyReturnsTrueForValidDrmSession() { writeTestDataWithEncryptedSections(); - assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED); + assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED_WITH_EXO_MEDIA_CRYPTO_TYPE); assertThat(sampleQueue.isReady(/* loadingFinished= */ false)).isFalse(); when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); assertThat(sampleQueue.isReady(/* loadingFinished= */ false)).isTrue(); @@ -378,7 +423,7 @@ public final class SampleQueueTest { when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED); writeTestDataWithEncryptedSections(); - assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED); + assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED_WITH_EXO_MEDIA_CRYPTO_TYPE); assertReadNothing(/* formatRequired= */ false); assertThat(inputBuffer.waitingForKeys).isTrue(); when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); @@ -393,11 +438,7 @@ public final class SampleQueueTest { int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 0); @@ -406,21 +447,13 @@ public final class SampleQueueTest { assertThat(formatHolder.drmSession).isNull(); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isNull(); assertReadEncryptedSample(/* sampleIndex= */ 2); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); } @@ -430,18 +463,12 @@ public final class SampleQueueTest { when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); DrmSession mockPlaceholderDrmSession = Mockito.mock(DrmSession.class); when(mockPlaceholderDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); - when(mockDrmSessionManager.acquirePlaceholderSession( - ArgumentMatchers.any(), ArgumentMatchers.anyInt())) - .thenReturn(mockPlaceholderDrmSession); + mockDrmSessionManager.mockPlaceholderDrmSession = mockPlaceholderDrmSession; writeTestDataWithEncryptedSections(); int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 0); @@ -450,21 +477,13 @@ public final class SampleQueueTest { assertThat(formatHolder.drmSession).isNull(); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockPlaceholderDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 2); result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); assertThat(formatHolder.drmSession).isSameInstanceAs(mockDrmSession); assertReadEncryptedSample(/* sampleIndex= */ 3); @@ -475,15 +494,13 @@ public final class SampleQueueTest { when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); DrmSession mockPlaceholderDrmSession = Mockito.mock(DrmSession.class); when(mockPlaceholderDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED_WITH_KEYS); - when(mockDrmSessionManager.acquirePlaceholderSession( - ArgumentMatchers.any(), ArgumentMatchers.anyInt())) - .thenReturn(mockPlaceholderDrmSession); + mockDrmSessionManager.mockPlaceholderDrmSession = mockPlaceholderDrmSession; writeFormat(ENCRYPTED_SAMPLE_FORMATS[0]); byte[] sampleData = new byte[] {0, 1, 2}; byte[] initializationVector = new byte[] {7, 6, 5, 4, 3, 2, 1, 0}; byte[] encryptedSampleData = - TestUtil.joinByteArrays( + Bytes.concat( new byte[] { 0x08, // subsampleEncryption = false (1 bit), ivSize = 8 (7 bits). }, @@ -494,11 +511,7 @@ public final class SampleQueueTest { int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); // Fill cryptoInfo.iv with non-zero data. When the 8 byte initialization vector is written into @@ -508,11 +521,7 @@ public final class SampleQueueTest { result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_BUFFER_READ); // Assert cryptoInfo.iv contains the 8-byte initialization vector and that the trailing 8 bytes @@ -526,7 +535,7 @@ public final class SampleQueueTest { when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED); writeTestDataWithEncryptedSections(); - assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED); + assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED_WITH_EXO_MEDIA_CRYPTO_TYPE); assertReadNothing(/* formatRequired= */ false); sampleQueue.maybeThrowError(); when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_ERROR); @@ -555,7 +564,7 @@ public final class SampleQueueTest { when(mockDrmSession.getState()).thenReturn(DrmSession.STATE_OPENED); writeTestDataWithEncryptedSections(); - assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED); + assertReadFormat(/* formatRequired= */ false, FORMAT_ENCRYPTED_WITH_EXO_MEDIA_CRYPTO_TYPE); assertReadEncryptedSample(/* sampleIndex= */ 0); } @@ -577,9 +586,10 @@ public final class SampleQueueTest { } @Test - public void advanceToEnd() { + public void skipToEnd() { writeTestData(); - sampleQueue.advanceToEnd(); + sampleQueue.skip( + sampleQueue.getSkipCount(/* timeUs= */ MAX_VALUE, /* allowEndOfQueue= */ true)); assertAllocationCount(10); sampleQueue.discardToRead(); assertAllocationCount(0); @@ -591,10 +601,11 @@ public final class SampleQueueTest { } @Test - public void advanceToEndRetainsUnassignedData() { + public void skipToEndRetainsUnassignedData() { sampleQueue.format(FORMAT_1); sampleQueue.sampleData(new ParsableByteArray(DATA), ALLOCATION_SIZE); - sampleQueue.advanceToEnd(); + sampleQueue.skip( + sampleQueue.getSkipCount(/* timeUs= */ MAX_VALUE, /* allowEndOfQueue= */ true)); assertAllocationCount(1); sampleQueue.discardToRead(); // Skipping shouldn't discard data that may belong to a sample whose metadata has yet to be @@ -607,7 +618,14 @@ public final class SampleQueueTest { sampleQueue.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, ALLOCATION_SIZE, 0, null); // Once the metadata has been written, check the sample can be read as expected. - assertReadSample(0, true, /* isEncrypted= */ false, DATA, 0, ALLOCATION_SIZE); + assertReadSample( + /* timeUs= */ 0, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + ALLOCATION_SIZE); assertNoSamplesToRead(FORMAT_1); assertAllocationCount(1); sampleQueue.discardToRead(); @@ -615,42 +633,48 @@ public final class SampleQueueTest { } @Test - public void advanceToBeforeBuffer() { + public void skipToBeforeBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(SAMPLE_TIMESTAMPS[0] - 1); + int skipCount = + sampleQueue.getSkipCount(SAMPLE_TIMESTAMPS[0] - 1, /* allowEndOfQueue= */ false); // Should have no effect (we're already at the first frame). assertThat(skipCount).isEqualTo(0); + sampleQueue.skip(skipCount); assertReadTestData(); assertNoSamplesToRead(FORMAT_2); } @Test - public void advanceToStartOfBuffer() { + public void skipToStartOfBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(SAMPLE_TIMESTAMPS[0]); + int skipCount = sampleQueue.getSkipCount(SAMPLE_TIMESTAMPS[0], /* allowEndOfQueue= */ false); // Should have no effect (we're already at the first frame). assertThat(skipCount).isEqualTo(0); + sampleQueue.skip(skipCount); assertReadTestData(); assertNoSamplesToRead(FORMAT_2); } @Test - public void advanceToEndOfBuffer() { + public void skipToEndOfBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(LAST_SAMPLE_TIMESTAMP); + int skipCount = sampleQueue.getSkipCount(LAST_SAMPLE_TIMESTAMP, /* allowEndOfQueue= */ false); // Should advance to 2nd keyframe (the 4th frame). assertThat(skipCount).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + sampleQueue.skip(skipCount); + assertReadTestData(/* startFormat= */ null, DATA_SECOND_KEYFRAME_INDEX); assertNoSamplesToRead(FORMAT_2); } @Test - public void advanceToAfterBuffer() { + public void skipToAfterBuffer() { writeTestData(); - int skipCount = sampleQueue.advanceTo(LAST_SAMPLE_TIMESTAMP + 1); + int skipCount = + sampleQueue.getSkipCount(LAST_SAMPLE_TIMESTAMP + 1, /* allowEndOfQueue= */ false); // Should advance to 2nd keyframe (the 4th frame). assertThat(skipCount).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + sampleQueue.skip(skipCount); + assertReadTestData(/* startFormat= */ null, DATA_SECOND_KEYFRAME_INDEX); assertNoSamplesToRead(FORMAT_2); } @@ -680,7 +704,12 @@ public final class SampleQueueTest { boolean success = sampleQueue.seekTo(LAST_SAMPLE_TIMESTAMP, false); assertThat(success).isTrue(); assertThat(sampleQueue.getReadIndex()).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData( + /* startFormat= */ null, + DATA_SECOND_KEYFRAME_INDEX, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length - DATA_SECOND_KEYFRAME_INDEX, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP); assertNoSamplesToRead(FORMAT_2); } @@ -700,7 +729,12 @@ public final class SampleQueueTest { boolean success = sampleQueue.seekTo(LAST_SAMPLE_TIMESTAMP + 1, true); assertThat(success).isTrue(); assertThat(sampleQueue.getReadIndex()).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData( + /* startFormat= */ null, + DATA_SECOND_KEYFRAME_INDEX, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length - DATA_SECOND_KEYFRAME_INDEX, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP + 1); assertNoSamplesToRead(FORMAT_2); } @@ -710,7 +744,13 @@ public final class SampleQueueTest { boolean success = sampleQueue.seekTo(LAST_SAMPLE_TIMESTAMP, false); assertThat(success).isTrue(); assertThat(sampleQueue.getReadIndex()).isEqualTo(4); - assertReadTestData(null, DATA_SECOND_KEYFRAME_INDEX); + assertReadTestData( + /* startFormat= */ null, + DATA_SECOND_KEYFRAME_INDEX, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length - DATA_SECOND_KEYFRAME_INDEX, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP); + assertNoSamplesToRead(FORMAT_2); // Seek back to the start. success = sampleQueue.seekTo(SAMPLE_TIMESTAMPS[0], false); @@ -720,6 +760,51 @@ public final class SampleQueueTest { assertNoSamplesToRead(FORMAT_2); } + @Test + public void setStartTimeUs_allSamplesAreSyncSamples_discardsOnWriteSide() { + // The format uses a MIME type for which MimeTypes.allSamplesAreSyncSamples() is true. + Format format = new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_RAW).build(); + Format[] sampleFormats = new Format[SAMPLE_SIZES.length]; + Arrays.fill(sampleFormats, format); + int[] sampleFlags = new int[SAMPLE_SIZES.length]; + Arrays.fill(sampleFlags, BUFFER_FLAG_KEY_FRAME); + + sampleQueue.setStartTimeUs(LAST_SAMPLE_TIMESTAMP); + writeTestData( + DATA, SAMPLE_SIZES, SAMPLE_OFFSETS, SAMPLE_TIMESTAMPS, sampleFormats, sampleFlags); + + assertThat(sampleQueue.getReadIndex()).isEqualTo(0); + + assertReadFormat(/* formatRequired= */ false, format); + assertReadSample( + SAMPLE_TIMESTAMPS[7], + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + DATA.length - SAMPLE_OFFSETS[7] - SAMPLE_SIZES[7], + SAMPLE_SIZES[7]); + } + + @Test + public void setStartTimeUs_notAllSamplesAreSyncSamples_discardsOnReadSide() { + // The format uses a MIME type for which MimeTypes.allSamplesAreSyncSamples() is false. + Format format = new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build(); + Format[] sampleFormats = new Format[SAMPLE_SIZES.length]; + Arrays.fill(sampleFormats, format); + + sampleQueue.setStartTimeUs(LAST_SAMPLE_TIMESTAMP); + writeTestData(); + + assertThat(sampleQueue.getReadIndex()).isEqualTo(0); + assertReadTestData( + /* startFormat= */ null, + /* firstSampleIndex= */ 0, + /* sampleCount= */ SAMPLE_TIMESTAMPS.length, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ LAST_SAMPLE_TIMESTAMP); + } + @Test public void discardToEnd() { writeTestData(); @@ -744,7 +829,7 @@ public final class SampleQueueTest { assertThat(sampleQueue.getReadIndex()).isEqualTo(0); assertAllocationCount(10); // Read the first sample. - assertReadTestData(null, 0, 1); + assertReadTestData(/* startFormat= */ null, 0, 1); // Shouldn't discard anything. sampleQueue.discardTo(SAMPLE_TIMESTAMPS[1] - 1, false, true); assertThat(sampleQueue.getFirstIndex()).isEqualTo(0); @@ -793,6 +878,118 @@ public final class SampleQueueTest { assertReadTestData(FORMAT_1, 1, 7); } + @Test + public void discardUpstreamFrom() { + writeTestData(); + sampleQueue.discardUpstreamFrom(8000); + assertAllocationCount(10); + sampleQueue.discardUpstreamFrom(7000); + assertAllocationCount(9); + sampleQueue.discardUpstreamFrom(6000); + assertAllocationCount(7); + sampleQueue.discardUpstreamFrom(5000); + assertAllocationCount(5); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(3000); + assertAllocationCount(3); + sampleQueue.discardUpstreamFrom(2000); + assertAllocationCount(2); + sampleQueue.discardUpstreamFrom(1000); + assertAllocationCount(1); + sampleQueue.discardUpstreamFrom(0); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromMulti() { + writeTestData(); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(0); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromNonSampleTimestamps() { + writeTestData(); + sampleQueue.discardUpstreamFrom(3500); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(500); + assertAllocationCount(1); + sampleQueue.discardUpstreamFrom(0); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromBeforeRead() { + writeTestData(); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(4); + assertReadTestData(null, 0, 4); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromAfterRead() { + writeTestData(); + assertReadTestData(null, 0, 3); + sampleQueue.discardUpstreamFrom(8000); + assertAllocationCount(10); + sampleQueue.discardToRead(); + assertAllocationCount(7); + sampleQueue.discardUpstreamFrom(7000); + assertAllocationCount(6); + sampleQueue.discardUpstreamFrom(6000); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(5000); + assertAllocationCount(2); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(1); + sampleQueue.discardUpstreamFrom(3000); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void largestQueuedTimestampWithDiscardUpstreamFrom() { + writeTestData(); + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(LAST_SAMPLE_TIMESTAMP); + sampleQueue.discardUpstreamFrom(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 1]); + // Discarding from upstream should reduce the largest timestamp. + assertThat(sampleQueue.getLargestQueuedTimestampUs()) + .isEqualTo(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 2]); + sampleQueue.discardUpstreamFrom(0); + // Discarding everything from upstream without reading should unset the largest timestamp. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(MIN_VALUE); + } + + @Test + public void largestQueuedTimestampWithDiscardUpstreamFromDecodeOrder() { + long[] decodeOrderTimestamps = new long[] {0, 3000, 2000, 1000, 4000, 7000, 6000, 5000}; + writeTestData( + DATA, SAMPLE_SIZES, SAMPLE_OFFSETS, decodeOrderTimestamps, SAMPLE_FORMATS, SAMPLE_FLAGS); + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(7000); + sampleQueue.discardUpstreamFrom(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 2]); + // Discarding the last two samples should not change the largest timestamp, due to the decode + // ordering of the timestamps. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(7000); + sampleQueue.discardUpstreamFrom(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 3]); + // Once a third sample is discarded, the largest timestamp should have changed. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(4000); + sampleQueue.discardUpstreamFrom(0); + // Discarding everything from upstream without reading should unset the largest timestamp. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(MIN_VALUE); + } + @Test public void discardUpstream() { writeTestData(); @@ -834,7 +1031,7 @@ public final class SampleQueueTest { writeTestData(); sampleQueue.discardUpstreamSamples(4); assertAllocationCount(4); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); assertReadFormat(false, FORMAT_2); assertNoSamplesToRead(FORMAT_2); } @@ -842,7 +1039,7 @@ public final class SampleQueueTest { @Test public void discardUpstreamAfterRead() { writeTestData(); - assertReadTestData(null, 0, 3); + assertReadTestData(/* startFormat= */ null, 0, 3); sampleQueue.discardUpstreamSamples(8); assertAllocationCount(10); sampleQueue.discardToRead(); @@ -901,13 +1098,54 @@ public final class SampleQueueTest { assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(LAST_SAMPLE_TIMESTAMP); } + @Test + public void largestReadTimestampWithReadAll() { + writeTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + assertReadTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(LAST_SAMPLE_TIMESTAMP); + } + + @Test + public void largestReadTimestampWithReads() { + writeTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + + assertReadTestData(/* startFormat= */ null, 0, 2); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[1]); + + assertReadTestData(SAMPLE_FORMATS[1], 2, 3); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[4]); + } + + @Test + public void largestReadTimestampWithDiscard() { + // Discarding shouldn't change the read timestamp. + writeTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + sampleQueue.discardUpstreamSamples(5); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + + assertReadTestData(/* startFormat= */ null, 0, 3); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[2]); + + sampleQueue.discardUpstreamSamples(3); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[2]); + sampleQueue.discardToRead(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[2]); + } + @Test public void setSampleOffsetBeforeData() { long sampleOffsetUs = 1000; sampleQueue.setSampleOffsetUs(sampleOffsetUs); writeTestData(); assertReadTestData( - /* startFormat= */ null, /* firstSampleIndex= */ 0, /* sampleCount= */ 8, sampleOffsetUs); + /* startFormat= */ null, + /* firstSampleIndex= */ 0, + /* sampleCount= */ 8, + sampleOffsetUs, + /* decodeOnlyUntilUs= */ 0); assertReadEndOfStream(/* formatRequired= */ false); } @@ -930,6 +1168,7 @@ public final class SampleQueueTest { assertReadSample( unadjustedTimestampUs + sampleOffsetUs, /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -985,6 +1224,7 @@ public final class SampleQueueTest { assertReadSample( /* timeUs= */ 0, /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -993,6 +1233,7 @@ public final class SampleQueueTest { assertReadSample( /* timeUs= */ 1, /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -1008,16 +1249,23 @@ public final class SampleQueueTest { long spliceSampleTimeUs = SAMPLE_TIMESTAMPS[4]; writeFormat(FORMAT_SPLICED); writeSample(DATA, spliceSampleTimeUs, C.BUFFER_FLAG_KEY_FRAME); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); assertReadFormat(false, FORMAT_SPLICED); - assertReadSample(spliceSampleTimeUs, true, /* isEncrypted= */ false, DATA, 0, DATA.length); + assertReadSample( + spliceSampleTimeUs, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + DATA.length); assertReadEndOfStream(false); } @Test public void spliceAfterRead() { writeTestData(); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); sampleQueue.splice(); // Splice should fail, leaving the last 4 samples unchanged. long spliceSampleTimeUs = SAMPLE_TIMESTAMPS[3]; @@ -1027,14 +1275,21 @@ public final class SampleQueueTest { assertReadEndOfStream(false); sampleQueue.seekTo(0); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); sampleQueue.splice(); // Splice should succeed, replacing the last 4 samples with the sample being written spliceSampleTimeUs = SAMPLE_TIMESTAMPS[3] + 1; writeFormat(FORMAT_SPLICED); writeSample(DATA, spliceSampleTimeUs, C.BUFFER_FLAG_KEY_FRAME); assertReadFormat(false, FORMAT_SPLICED); - assertReadSample(spliceSampleTimeUs, true, /* isEncrypted= */ false, DATA, 0, DATA.length); + assertReadSample( + spliceSampleTimeUs, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + DATA.length); assertReadEndOfStream(false); } @@ -1048,14 +1303,23 @@ public final class SampleQueueTest { long spliceSampleTimeUs = SAMPLE_TIMESTAMPS[4]; writeFormat(FORMAT_SPLICED); writeSample(DATA, spliceSampleTimeUs, C.BUFFER_FLAG_KEY_FRAME); - assertReadTestData(null, 0, 4, sampleOffsetUs); + assertReadTestData(/* startFormat= */ null, 0, 4, sampleOffsetUs, /* decodeOnlyUntilUs= */ 0); assertReadFormat( false, FORMAT_SPLICED.buildUpon().setSubsampleOffsetUs(sampleOffsetUs).build()); assertReadSample( - spliceSampleTimeUs + sampleOffsetUs, true, /* isEncrypted= */ false, DATA, 0, DATA.length); + spliceSampleTimeUs + sampleOffsetUs, + /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, + /* isEncrypted= */ false, + DATA, + /* offset= */ 0, + DATA.length); assertReadEndOfStream(false); } + @Test + public void setStartTime() {} + // Internal methods. /** @@ -1094,7 +1358,7 @@ public final class SampleQueueTest { sampleFlags[i], sampleSizes[i], sampleOffsets[i], - (sampleFlags[i] & C.BUFFER_FLAG_ENCRYPTED) != 0 ? DUMMY_CRYPTO_DATA : null); + (sampleFlags[i] & C.BUFFER_FLAG_ENCRYPTED) != 0 ? CRYPTO_DATA : null); } } @@ -1111,14 +1375,14 @@ public final class SampleQueueTest { sampleFlags, data.length, /* offset= */ 0, - (sampleFlags & C.BUFFER_FLAG_ENCRYPTED) != 0 ? DUMMY_CRYPTO_DATA : null); + (sampleFlags & C.BUFFER_FLAG_ENCRYPTED) != 0 ? CRYPTO_DATA : null); } /** * Asserts correct reading of standard test data from {@code sampleQueue}. */ private void assertReadTestData() { - assertReadTestData(null, 0); + assertReadTestData(/* startFormat= */ null, 0); } /** @@ -1148,7 +1412,12 @@ public final class SampleQueueTest { * @param sampleCount The number of samples to read. */ private void assertReadTestData(Format startFormat, int firstSampleIndex, int sampleCount) { - assertReadTestData(startFormat, firstSampleIndex, sampleCount, 0); + assertReadTestData( + startFormat, + firstSampleIndex, + sampleCount, + /* sampleOffsetUs= */ 0, + /* decodeOnlyUntilUs= */ 0); } /** @@ -1160,7 +1429,11 @@ public final class SampleQueueTest { * @param sampleOffsetUs The expected sample offset. */ private void assertReadTestData( - Format startFormat, int firstSampleIndex, int sampleCount, long sampleOffsetUs) { + Format startFormat, + int firstSampleIndex, + int sampleCount, + long sampleOffsetUs, + long decodeOnlyUntilUs) { Format format = adjustFormat(startFormat, sampleOffsetUs); for (int i = firstSampleIndex; i < firstSampleIndex + sampleCount; i++) { // Use equals() on the read side despite using referential equality on the write side, since @@ -1174,9 +1447,11 @@ public final class SampleQueueTest { // If we require the format, we should always read it. assertReadFormat(true, testSampleFormat); // Assert the sample is as expected. + long expectedTimeUs = SAMPLE_TIMESTAMPS[i] + sampleOffsetUs; assertReadSample( - SAMPLE_TIMESTAMPS[i] + sampleOffsetUs, + expectedTimeUs, (SAMPLE_FLAGS[i] & C.BUFFER_FLAG_KEY_FRAME) != 0, + /* isDecodeOnly= */ expectedTimeUs < decodeOnlyUntilUs, /* isEncrypted= */ false, DATA, DATA.length - SAMPLE_OFFSETS[i] - SAMPLE_SIZES[i], @@ -1220,12 +1495,7 @@ public final class SampleQueueTest { private void assertReadNothing(boolean formatRequired) { clearFormatHolderAndInputBuffer(); int result = - sampleQueue.read( - formatHolder, - inputBuffer, - formatRequired, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + sampleQueue.read(formatHolder, inputBuffer, formatRequired, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_NOTHING_READ); // formatHolder should not be populated. assertThat(formatHolder.format).isNull(); @@ -1243,12 +1513,7 @@ public final class SampleQueueTest { private void assertReadEndOfStream(boolean formatRequired) { clearFormatHolderAndInputBuffer(); int result = - sampleQueue.read( - formatHolder, - inputBuffer, - formatRequired, - /* loadingFinished= */ true, - /* decodeOnlyUntilUs= */ 0); + sampleQueue.read(formatHolder, inputBuffer, formatRequired, /* loadingFinished= */ true); assertThat(result).isEqualTo(RESULT_BUFFER_READ); // formatHolder should not be populated. assertThat(formatHolder.format).isNull(); @@ -1269,12 +1534,7 @@ public final class SampleQueueTest { private void assertReadFormat(boolean formatRequired, Format format) { clearFormatHolderAndInputBuffer(); int result = - sampleQueue.read( - formatHolder, - inputBuffer, - formatRequired, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + sampleQueue.read(formatHolder, inputBuffer, formatRequired, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_FORMAT_READ); // formatHolder should be populated. assertThat(formatHolder.format).isEqualTo(format); @@ -1291,6 +1551,7 @@ public final class SampleQueueTest { assertReadSample( ENCRYPTED_SAMPLE_TIMESTAMPS[sampleIndex], isKeyFrame, + /* isDecodeOnly= */ false, isEncrypted, sampleData, /* offset= */ 0, @@ -1303,6 +1564,7 @@ public final class SampleQueueTest { * * @param timeUs The expected buffer timestamp. * @param isKeyFrame The expected keyframe flag. + * @param isDecodeOnly The expected decodeOnly flag. * @param isEncrypted The expected encrypted flag. * @param sampleData An array containing the expected sample data. * @param offset The offset in {@code sampleData} of the expected sample data. @@ -1311,6 +1573,7 @@ public final class SampleQueueTest { private void assertReadSample( long timeUs, boolean isKeyFrame, + boolean isDecodeOnly, boolean isEncrypted, byte[] sampleData, int offset, @@ -1318,18 +1581,14 @@ public final class SampleQueueTest { clearFormatHolderAndInputBuffer(); int result = sampleQueue.read( - formatHolder, - inputBuffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, inputBuffer, /* formatRequired= */ false, /* loadingFinished= */ false); assertThat(result).isEqualTo(RESULT_BUFFER_READ); // formatHolder should not be populated. assertThat(formatHolder.format).isNull(); // inputBuffer should be populated. assertThat(inputBuffer.timeUs).isEqualTo(timeUs); assertThat(inputBuffer.isKeyFrame()).isEqualTo(isKeyFrame); - assertThat(inputBuffer.isDecodeOnly()).isFalse(); + assertThat(inputBuffer.isDecodeOnly()).isEqualTo(isDecodeOnly); assertThat(inputBuffer.isEncrypted()).isEqualTo(isEncrypted); inputBuffer.flip(); assertThat(inputBuffer.data.limit()).isEqualTo(length); @@ -1382,4 +1641,33 @@ public final class SampleQueueTest { private static Format copyWithLabel(Format format, String label) { return format.buildUpon().setLabel(label).build(); } + + private static final class MockExoMediaCrypto implements ExoMediaCrypto {} + + private static final class MockDrmSessionManager implements DrmSessionManager { + + private final DrmSession mockDrmSession; + @Nullable private DrmSession mockPlaceholderDrmSession; + + private MockDrmSessionManager(DrmSession mockDrmSession) { + this.mockDrmSession = mockDrmSession; + } + + @Nullable + @Override + public DrmSession acquireSession( + Looper playbackLooper, + @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, + Format format) { + return format.drmInitData != null ? mockDrmSession : mockPlaceholderDrmSession; + } + + @Nullable + @Override + public Class getExoMediaCryptoType(Format format) { + return mockPlaceholderDrmSession != null || format.drmInitData != null + ? MockExoMediaCrypto.class + : null; + } + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SilenceMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SilenceMediaSourceTest.java new file mode 100644 index 0000000000..d8a7727953 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SilenceMediaSourceTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.util.MimeTypes; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link SilenceMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class SilenceMediaSourceTest { + + @Test + public void builder_setsMediaItem() { + SilenceMediaSource mediaSource = + new SilenceMediaSource.Factory().setDurationUs(1_000_000).createMediaSource(); + + MediaItem mediaItem = mediaSource.getMediaItem(); + + assertThat(mediaItem).isNotNull(); + assertThat(mediaItem.mediaId).isEqualTo(SilenceMediaSource.MEDIA_ID); + assertThat(mediaItem.playbackProperties.uri).isEqualTo(Uri.EMPTY); + assertThat(mediaItem.playbackProperties.mimeType).isEqualTo(MimeTypes.AUDIO_RAW); + } + + @Test + public void builderSetTag_setsTagOfMediaItem() { + Object tag = new Object(); + + SilenceMediaSource mediaSource = + new SilenceMediaSource.Factory().setTag(tag).setDurationUs(1_000_000).createMediaSource(); + + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); + } + + @Test + public void builderSetTag_setsTagOfMediaSource() { + Object tag = new Object(); + + SilenceMediaSource mediaSource = + new SilenceMediaSource.Factory().setTag(tag).setDurationUs(1_000_000).createMediaSource(); + + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); + } + + @Test + public void builder_setDurationUsNotCalled_throwsIllegalStateException() { + assertThrows(IllegalStateException.class, new SilenceMediaSource.Factory()::createMediaSource); + } + + @Test + public void builderSetDurationUs_nonPositiveValue_throwsIllegalStateException() { + SilenceMediaSource.Factory factory = new SilenceMediaSource.Factory().setDurationUs(-1); + + assertThrows(IllegalStateException.class, factory::createMediaSource); + } + + @Test + public void newInstance_setsMediaItem() { + SilenceMediaSource mediaSource = new SilenceMediaSource(1_000_000); + + MediaItem mediaItem = mediaSource.getMediaItem(); + + assertThat(mediaItem).isNotNull(); + assertThat(mediaItem.mediaId).isEqualTo(SilenceMediaSource.MEDIA_ID); + assertThat(mediaSource.getMediaItem().playbackProperties.uri).isEqualTo(Uri.EMPTY); + assertThat(mediaItem.playbackProperties.mimeType).isEqualTo(MimeTypes.AUDIO_RAW); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java index fe4255c631..4fce17e336 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SinglePeriodTimelineTest.java @@ -17,9 +17,11 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; +import android.net.Uri; import android.util.Pair; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.Timeline.Window; import org.junit.Before; @@ -43,7 +45,12 @@ public final class SinglePeriodTimelineTest { public void getPeriodPositionDynamicWindowUnknownDuration() { SinglePeriodTimeline timeline = new SinglePeriodTimeline( - C.TIME_UNSET, /* isSeekable= */ false, /* isDynamic= */ true, /* isLive= */ true); + C.TIME_UNSET, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ true, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); // Should return null with any positive position projection. Pair position = timeline.getPeriodPosition(window, period, 0, C.TIME_UNSET, 1); assertThat(position).isNull(); @@ -66,7 +73,7 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); // Should return null with a positive position projection beyond window duration. Pair position = timeline.getPeriodPosition(window, period, 0, C.TIME_UNSET, windowDurationUs + 1); @@ -82,6 +89,7 @@ public final class SinglePeriodTimelineTest { } @Test + @SuppressWarnings("deprecation") // Testing deprecated Window.tag is still populated correctly. public void setNullTag_returnsNullTag_butUsesDefaultUid() { SinglePeriodTimeline timeline = new SinglePeriodTimeline( @@ -90,9 +98,11 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* tag= */ null); + new MediaItem.Builder().setUri(Uri.EMPTY).setTag(null).build()); assertThat(timeline.getWindow(/* windowIndex= */ 0, window).tag).isNull(); + assertThat(timeline.getWindow(/* windowIndex= */ 0, window).mediaItem.playbackProperties.tag) + .isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).id).isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).id).isNull(); assertThat(timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ false).uid).isNull(); @@ -101,6 +111,7 @@ public final class SinglePeriodTimelineTest { } @Test + @SuppressWarnings("deprecation") // Testing deprecated Window.tag is still populated correctly. public void getWindow_setsTag() { Object tag = new Object(); SinglePeriodTimeline timeline = @@ -110,11 +121,31 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - tag); + new MediaItem.Builder().setUri(Uri.EMPTY).setTag(tag).build()); assertThat(timeline.getWindow(/* windowIndex= */ 0, window).tag).isEqualTo(tag); } + // Tests backward compatibility. + @SuppressWarnings("deprecation") + @Test + public void getWindow_setsMediaItemAndTag() { + MediaItem mediaItem = new MediaItem.Builder().setUri(Uri.EMPTY).setTag(new Object()).build(); + SinglePeriodTimeline timeline = + new SinglePeriodTimeline( + /* durationUs= */ C.TIME_UNSET, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* isLive= */ false, + /* manifest= */ null, + mediaItem); + + Window window = timeline.getWindow(/* windowIndex= */ 0, this.window); + + assertThat(window.mediaItem).isEqualTo(mediaItem); + assertThat(window.tag).isEqualTo(mediaItem.playbackProperties.tag); + } + @Test public void getIndexOfPeriod_returnsPeriod() { SinglePeriodTimeline timeline = @@ -124,7 +155,7 @@ public final class SinglePeriodTimelineTest { /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Object uid = timeline.getPeriod(/* periodIndex= */ 0, period, /* setIds= */ true).uid; assertThat(timeline.getIndexOfPeriod(uid)).isEqualTo(0); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java index 5b7713a835..3a253b2976 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdPlaybackStateTest.java @@ -64,7 +64,9 @@ public final class AdPlaybackStateTest { assertThat(state.adGroups[0].uris[0]).isNull(); assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_ERROR); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)).isTrue(); assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)).isFalse(); } @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java index 255d1298b7..8395fcb1f4 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java @@ -22,12 +22,12 @@ 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.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; @@ -46,11 +46,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link AdsMediaSource}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(PAUSED) public final class AdsMediaSourceTest { private static final long PREROLL_AD_DURATION_US = 10 * C.MICROS_PER_SECOND; @@ -59,14 +57,21 @@ public final class AdsMediaSourceTest { PREROLL_AD_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, - /* isLive= */ false); + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); 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); + CONTENT_DURATION_US, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); private static final Object CONTENT_PERIOD_UID = CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); @@ -92,7 +97,8 @@ public final class AdsMediaSourceTest { contentMediaSource = new FakeMediaSource(/* timeline= */ null); prerollAdMediaSource = new FakeMediaSource(/* timeline= */ null); MediaSourceFactory adMediaSourceFactory = mock(MediaSourceFactory.class); - when(adMediaSourceFactory.createMediaSource(any(Uri.class))).thenReturn(prerollAdMediaSource); + when(adMediaSourceFactory.createMediaSource(any(MediaItem.class))) + .thenReturn(prerollAdMediaSource); // Prepare the AdsMediaSource and capture its ads loader listener. AdsLoader mockAdsLoader = mock(AdsLoader.class); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/CueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/CueTest.java index 8c66d35253..c16cb928b1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/CueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/CueTest.java @@ -76,6 +76,14 @@ public class CueTest { assertThat(modifiedCue.verticalType).isEqualTo(cue.verticalType); } + @Test + public void clearWindowColor() { + Cue cue = + new Cue.Builder().setText(SpannedString.valueOf("text")).setWindowColor(Color.CYAN).build(); + + assertThat(cue.buildUpon().clearWindowColor().build().windowColorSet).isFalse(); + } + @Test public void buildWithNoTextOrBitmapFails() { assertThrows(RuntimeException.class, () -> new Cue.Builder().build()); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java index 379e189db9..c7833fab04 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java @@ -34,16 +34,16 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class SsaDecoderTest { - private static final String EMPTY = "ssa/empty"; - private static final String TYPICAL = "ssa/typical"; - private static final String TYPICAL_HEADER_ONLY = "ssa/typical_header"; - private static final String TYPICAL_DIALOGUE_ONLY = "ssa/typical_dialogue"; - private static final String TYPICAL_FORMAT_ONLY = "ssa/typical_format"; - private static final String OVERLAPPING_TIMECODES = "ssa/overlapping_timecodes"; - private static final String POSITIONS = "ssa/positioning"; - private static final String INVALID_TIMECODES = "ssa/invalid_timecodes"; - private static final String INVALID_POSITIONS = "ssa/invalid_positioning"; - private static final String POSITIONS_WITHOUT_PLAYRES = "ssa/positioning_without_playres"; + private static final String EMPTY = "media/ssa/empty"; + private static final String TYPICAL = "media/ssa/typical"; + private static final String TYPICAL_HEADER_ONLY = "media/ssa/typical_header"; + private static final String TYPICAL_DIALOGUE_ONLY = "media/ssa/typical_dialogue"; + private static final String TYPICAL_FORMAT_ONLY = "media/ssa/typical_format"; + private static final String OVERLAPPING_TIMECODES = "media/ssa/overlapping_timecodes"; + private static final String POSITIONS = "media/ssa/positioning"; + private static final String INVALID_TIMECODES = "media/ssa/invalid_timecodes"; + private static final String INVALID_POSITIONS = "media/ssa/invalid_positioning"; + private static final String POSITIONS_WITHOUT_PLAYRES = "media/ssa/positioning_without_playres"; @Test public void decodeEmpty() throws IOException { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java index e233d8d1b5..c868cc9a70 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java @@ -30,16 +30,19 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class SubripDecoderTest { - private static final String EMPTY_FILE = "subrip/empty"; - private static final String TYPICAL_FILE = "subrip/typical"; - private static final String TYPICAL_WITH_BYTE_ORDER_MARK = "subrip/typical_with_byte_order_mark"; - private static final String TYPICAL_EXTRA_BLANK_LINE = "subrip/typical_extra_blank_line"; - private static final String TYPICAL_MISSING_TIMECODE = "subrip/typical_missing_timecode"; - private static final String TYPICAL_MISSING_SEQUENCE = "subrip/typical_missing_sequence"; - private static final String TYPICAL_NEGATIVE_TIMESTAMPS = "subrip/typical_negative_timestamps"; - private static final String TYPICAL_UNEXPECTED_END = "subrip/typical_unexpected_end"; - private static final String TYPICAL_WITH_TAGS = "subrip/typical_with_tags"; - private static final String TYPICAL_NO_HOURS_AND_MILLIS = "subrip/typical_no_hours_and_millis"; + private static final String EMPTY_FILE = "media/subrip/empty"; + private static final String TYPICAL_FILE = "media/subrip/typical"; + private static final String TYPICAL_WITH_BYTE_ORDER_MARK = + "media/subrip/typical_with_byte_order_mark"; + private static final String TYPICAL_EXTRA_BLANK_LINE = "media/subrip/typical_extra_blank_line"; + private static final String TYPICAL_MISSING_TIMECODE = "media/subrip/typical_missing_timecode"; + private static final String TYPICAL_MISSING_SEQUENCE = "media/subrip/typical_missing_sequence"; + private static final String TYPICAL_NEGATIVE_TIMESTAMPS = + "media/subrip/typical_negative_timestamps"; + private static final String TYPICAL_UNEXPECTED_END = "media/subrip/typical_unexpected_end"; + private static final String TYPICAL_WITH_TAGS = "media/subrip/typical_with_tags"; + private static final String TYPICAL_NO_HOURS_AND_MILLIS = + "media/subrip/typical_no_hours_and_millis"; @Test public void decodeEmpty() throws IOException { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java index 071d34e5d0..dac21f3628 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlDecoderTest.java @@ -40,29 +40,33 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class TtmlDecoderTest { - private static final String INLINE_ATTRIBUTES_TTML_FILE = "ttml/inline_style_attributes.xml"; - private static final String INHERIT_STYLE_TTML_FILE = "ttml/inherit_style.xml"; + private static final String INLINE_ATTRIBUTES_TTML_FILE = + "media/ttml/inline_style_attributes.xml"; + private static final String INHERIT_STYLE_TTML_FILE = "media/ttml/inherit_style.xml"; private static final String INHERIT_STYLE_OVERRIDE_TTML_FILE = - "ttml/inherit_and_override_style.xml"; + "media/ttml/inherit_and_override_style.xml"; private static final String INHERIT_GLOBAL_AND_PARENT_TTML_FILE = - "ttml/inherit_global_and_parent.xml"; + "media/ttml/inherit_global_and_parent.xml"; private static final String INHERIT_MULTIPLE_STYLES_TTML_FILE = - "ttml/inherit_multiple_styles.xml"; - private static final String CHAIN_MULTIPLE_STYLES_TTML_FILE = "ttml/chain_multiple_styles.xml"; - private static final String MULTIPLE_REGIONS_TTML_FILE = "ttml/multiple_regions.xml"; + "media/ttml/inherit_multiple_styles.xml"; + private static final String CHAIN_MULTIPLE_STYLES_TTML_FILE = + "media/ttml/chain_multiple_styles.xml"; + private static final String MULTIPLE_REGIONS_TTML_FILE = "media/ttml/multiple_regions.xml"; private static final String NO_UNDERLINE_LINETHROUGH_TTML_FILE = - "ttml/no_underline_linethrough.xml"; - private static final String FONT_SIZE_TTML_FILE = "ttml/font_size.xml"; - private static final String FONT_SIZE_MISSING_UNIT_TTML_FILE = "ttml/font_size_no_unit.xml"; - private static final String FONT_SIZE_INVALID_TTML_FILE = "ttml/font_size_invalid.xml"; - private static final String FONT_SIZE_EMPTY_TTML_FILE = "ttml/font_size_empty.xml"; - private static final String FRAME_RATE_TTML_FILE = "ttml/frame_rate.xml"; - private static final String BITMAP_REGION_FILE = "ttml/bitmap_percentage_region.xml"; - private static final String BITMAP_PIXEL_REGION_FILE = "ttml/bitmap_pixel_region.xml"; - private static final String BITMAP_UNSUPPORTED_REGION_FILE = "ttml/bitmap_unsupported_region.xml"; - private static final String VERTICAL_TEXT_FILE = "ttml/vertical_text.xml"; - private static final String TEXT_COMBINE_FILE = "ttml/text_combine.xml"; - private static final String RUBIES_FILE = "ttml/rubies.xml"; + "media/ttml/no_underline_linethrough.xml"; + private static final String FONT_SIZE_TTML_FILE = "media/ttml/font_size.xml"; + private static final String FONT_SIZE_MISSING_UNIT_TTML_FILE = "media/ttml/font_size_no_unit.xml"; + private static final String FONT_SIZE_INVALID_TTML_FILE = "media/ttml/font_size_invalid.xml"; + private static final String FONT_SIZE_EMPTY_TTML_FILE = "media/ttml/font_size_empty.xml"; + private static final String FRAME_RATE_TTML_FILE = "media/ttml/frame_rate.xml"; + private static final String BITMAP_REGION_FILE = "media/ttml/bitmap_percentage_region.xml"; + private static final String BITMAP_PIXEL_REGION_FILE = "media/ttml/bitmap_pixel_region.xml"; + private static final String BITMAP_UNSUPPORTED_REGION_FILE = + "media/ttml/bitmap_unsupported_region.xml"; + private static final String TEXT_ALIGN_FILE = "media/ttml/text_align.xml"; + private static final String VERTICAL_TEXT_FILE = "media/ttml/vertical_text.xml"; + private static final String TEXT_COMBINE_FILE = "media/ttml/text_combine.xml"; + private static final String RUBIES_FILE = "media/ttml/rubies.xml"; @Test public void inlineAttributes() throws IOException, SubtitleDecoderException { @@ -194,9 +198,6 @@ public final class TtmlDecoderTest { assertThat(firstCueText) .hasForegroundColorSpanBetween(0, firstCueText.length()) .withColor(ColorParser.parseTtmlColor("lime")); - assertThat(firstCueText) - .hasAlignmentSpanBetween(0, firstCueText.length()) - .withAlignment(Layout.Alignment.ALIGN_CENTER); Spanned secondCueText = getOnlyCueTextAtTimeUs(subtitle, 20_000_000); assertThat(secondCueText.toString()).isEqualTo("text 2"); @@ -210,9 +211,6 @@ public final class TtmlDecoderTest { assertThat(secondCueText) .hasForegroundColorSpanBetween(0, secondCueText.length()) .withColor(0xFFFFFF00); - assertThat(secondCueText) - .hasAlignmentSpanBetween(0, secondCueText.length()) - .withAlignment(Layout.Alignment.ALIGN_CENTER); } @Test @@ -309,16 +307,16 @@ public final class TtmlDecoderTest { // assertEquals(1f, cue.size); cue = getOnlyCueAtTimeUs(subtitle, 21_000_000); - assertThat(cue.text.toString()).isEqualTo("She first said this"); + assertThat(cue.text.toString()).isEqualTo("They first said this"); assertThat(cue.position).isEqualTo(45f / 100f); assertThat(cue.line).isEqualTo(45f / 100f); assertThat(cue.size).isEqualTo(35f / 100f); cue = getOnlyCueAtTimeUs(subtitle, 25_000_000); - assertThat(cue.text.toString()).isEqualTo("She first said this\nThen this"); + assertThat(cue.text.toString()).isEqualTo("They first said this\nThen this"); cue = getOnlyCueAtTimeUs(subtitle, 29_000_000); - assertThat(cue.text.toString()).isEqualTo("She first said this\nThen this\nFinally this"); + assertThat(cue.text.toString()).isEqualTo("They first said this\nThen this\nFinally this"); assertThat(cue.position).isEqualTo(45f / 100f); assertThat(cue.line).isEqualTo(45f / 100f); } @@ -575,6 +573,39 @@ public final class TtmlDecoderTest { assertThat(cue.bitmapHeight).isEqualTo(Cue.DIMEN_UNSET); } + @Test + public void textAlign() throws IOException, SubtitleDecoderException { + TtmlSubtitle subtitle = getSubtitle(TEXT_ALIGN_FILE); + + Cue firstCue = getOnlyCueAtTimeUs(subtitle, 10_000_000); + assertThat(firstCue.text.toString()).isEqualTo("Start alignment"); + assertThat(firstCue.textAlignment).isEqualTo(Layout.Alignment.ALIGN_NORMAL); + + Cue secondCue = getOnlyCueAtTimeUs(subtitle, 20_000_000); + assertThat(secondCue.text.toString()).isEqualTo("Left alignment"); + assertThat(secondCue.textAlignment).isEqualTo(Layout.Alignment.ALIGN_NORMAL); + + Cue thirdCue = getOnlyCueAtTimeUs(subtitle, 30_000_000); + assertThat(thirdCue.text.toString()).isEqualTo("Center alignment"); + assertThat(thirdCue.textAlignment).isEqualTo(Layout.Alignment.ALIGN_CENTER); + + Cue fourthCue = getOnlyCueAtTimeUs(subtitle, 40_000_000); + assertThat(fourthCue.text.toString()).isEqualTo("Right alignment"); + assertThat(fourthCue.textAlignment).isEqualTo(Layout.Alignment.ALIGN_OPPOSITE); + + Cue fifthCue = getOnlyCueAtTimeUs(subtitle, 50_000_000); + assertThat(fifthCue.text.toString()).isEqualTo("End alignment"); + assertThat(fifthCue.textAlignment).isEqualTo(Layout.Alignment.ALIGN_OPPOSITE); + + Cue sixthCue = getOnlyCueAtTimeUs(subtitle, 60_000_000); + assertThat(sixthCue.text.toString()).isEqualTo("Justify alignment (unsupported)"); + assertThat(sixthCue.textAlignment).isNull(); + + Cue seventhCue = getOnlyCueAtTimeUs(subtitle, 70_000_000); + assertThat(seventhCue.text.toString()).isEqualTo("No textAlign property"); + assertThat(seventhCue.textAlignment).isNull(); + } + @Test public void verticalText() throws IOException, SubtitleDecoderException { TtmlSubtitle subtitle = getSubtitle(VERTICAL_TEXT_FILE); @@ -628,15 +659,19 @@ public final class TtmlDecoderTest { Spanned thirdCue = getOnlyCueTextAtTimeUs(subtitle, 30_000_000); assertThat(thirdCue.toString()).isEqualTo("Cue with annotated text."); - assertThat(thirdCue).hasNoRubySpanBetween(0, thirdCue.length()); + assertThat(thirdCue).hasRubySpanBetween("Cue with ".length(), "Cue with annotated".length()); Spanned fourthCue = getOnlyCueTextAtTimeUs(subtitle, 40_000_000); - assertThat(fourthCue.toString()).isEqualTo("Cue with text."); + assertThat(fourthCue.toString()).isEqualTo("Cue with annotated text."); assertThat(fourthCue).hasNoRubySpanBetween(0, fourthCue.length()); Spanned fifthCue = getOnlyCueTextAtTimeUs(subtitle, 50_000_000); - assertThat(fifthCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(fifthCue.toString()).isEqualTo("Cue with text."); assertThat(fifthCue).hasNoRubySpanBetween(0, fifthCue.length()); + + Spanned sixthCue = getOnlyCueTextAtTimeUs(subtitle, 60_000_000); + assertThat(sixthCue.toString()).isEqualTo("Cue with annotated text."); + assertThat(sixthCue).hasNoRubySpanBetween(0, sixthCue.length()); } private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java index 4f75c50b12..a3ad1ba599 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ttml/TtmlStyleTest.java @@ -28,7 +28,6 @@ import android.graphics.Color; import android.text.Layout; import androidx.annotation.ColorInt; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.span.RubySpan; import org.junit.Test; import org.junit.runner.RunWith; @@ -47,7 +46,6 @@ public final class TtmlStyleTest { private static final int RUBY_POSITION = RubySpan.POSITION_UNDER; private static final Layout.Alignment TEXT_ALIGN = Layout.Alignment.ALIGN_CENTER; private static final boolean TEXT_COMBINE = true; - @Cue.VerticalType private static final int VERTICAL_TYPE = Cue.VERTICAL_TYPE_RL; private final TtmlStyle populatedStyle = new TtmlStyle() @@ -64,8 +62,7 @@ public final class TtmlStyleTest { .setRubyType(RUBY_TYPE) .setRubyPosition(RUBY_POSITION) .setTextAlign(TEXT_ALIGN) - .setTextCombine(TEXT_COMBINE) - .setVerticalType(VERTICAL_TYPE); + .setTextCombine(TEXT_COMBINE); @Test public void inheritStyle() { @@ -89,9 +86,6 @@ public final class TtmlStyleTest { assertWithMessage("backgroundColor should not be inherited") .that(style.hasBackgroundColor()) .isFalse(); - assertWithMessage("verticalType should not be inherited") - .that(style.getVerticalType()) - .isEqualTo(Cue.TYPE_UNSET); } @Test @@ -115,9 +109,6 @@ public final class TtmlStyleTest { .that(style.getBackgroundColor()) .isEqualTo(BACKGROUND_COLOR); assertWithMessage("rubyType should be chained").that(style.getRubyType()).isEqualTo(RUBY_TYPE); - assertWithMessage("verticalType should be chained") - .that(style.getVerticalType()) - .isEqualTo(VERTICAL_TYPE); } @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java index 143326583c..58b9a853e7 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/tx3g/Tx3gDecoderTest.java @@ -41,17 +41,20 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class Tx3gDecoderTest { - private static final String NO_SUBTITLE = "tx3g/no_subtitle"; - private static final String SAMPLE_JUST_TEXT = "tx3g/sample_just_text"; - private static final String SAMPLE_WITH_STYL = "tx3g/sample_with_styl"; - private static final String SAMPLE_WITH_STYL_ALL_DEFAULTS = "tx3g/sample_with_styl_all_defaults"; - private static final String SAMPLE_UTF16_BE_NO_STYL = "tx3g/sample_utf16_be_no_styl"; - private static final String SAMPLE_UTF16_LE_NO_STYL = "tx3g/sample_utf16_le_no_styl"; - private static final String SAMPLE_WITH_MULTIPLE_STYL = "tx3g/sample_with_multiple_styl"; - private static final String SAMPLE_WITH_OTHER_EXTENSION = "tx3g/sample_with_other_extension"; - private static final String SAMPLE_WITH_TBOX = "tx3g/sample_with_tbox"; - private static final String INITIALIZATION = "tx3g/initialization"; - private static final String INITIALIZATION_ALL_DEFAULTS = "tx3g/initialization_all_defaults"; + private static final String NO_SUBTITLE = "media/tx3g/no_subtitle"; + private static final String SAMPLE_JUST_TEXT = "media/tx3g/sample_just_text"; + private static final String SAMPLE_WITH_STYL = "media/tx3g/sample_with_styl"; + private static final String SAMPLE_WITH_STYL_ALL_DEFAULTS = + "media/tx3g/sample_with_styl_all_defaults"; + private static final String SAMPLE_UTF16_BE_NO_STYL = "media/tx3g/sample_utf16_be_no_styl"; + private static final String SAMPLE_UTF16_LE_NO_STYL = "media/tx3g/sample_utf16_le_no_styl"; + private static final String SAMPLE_WITH_MULTIPLE_STYL = "media/tx3g/sample_with_multiple_styl"; + private static final String SAMPLE_WITH_OTHER_EXTENSION = + "media/tx3g/sample_with_other_extension"; + private static final String SAMPLE_WITH_TBOX = "media/tx3g/sample_with_tbox"; + private static final String INITIALIZATION = "media/tx3g/initialization"; + private static final String INITIALIZATION_ALL_DEFAULTS = + "media/tx3g/initialization_all_defaults"; @Test public void decodeNoSubtitle() throws IOException, SubtitleDecoderException { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java index 7dc41eda82..797a0b5d94 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/CssParserTest.java @@ -21,6 +21,9 @@ import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import org.junit.Before; import org.junit.Test; @@ -118,8 +121,9 @@ public final class CssParserTest { @Test public void multiplePropertiesInBlock() { - String styleBlock = "::cue(#id){text-decoration:underline; background-color:green;" - + "color:red; font-family:Courier; font-weight:bold}"; + String styleBlock = + "::cue(#id){text-decoration:underline; background-color:green;" + + "color:red; font-family:Courier; font-weight:bold}"; WebvttCssStyle expectedStyle = new WebvttCssStyle(); expectedStyle.setTargetId("id"); expectedStyle.setUnderline(true); @@ -133,8 +137,9 @@ public final class CssParserTest { @Test public void rgbaColorExpression() { - String styleBlock = "::cue(#rgb){background-color: rgba(\n10/* Ugly color */,11\t, 12\n,.1);" - + "color:rgb(1,1,\n1)}"; + String styleBlock = + "::cue(#rgb){background-color: rgba(\n10/* Ugly color */,11\t, 12\n,.1);" + + "color:rgb(1,1,\n1)}"; WebvttCssStyle expectedStyle = new WebvttCssStyle(); expectedStyle.setTargetId("rgb"); expectedStyle.setBackgroundColor(0x190A0B0C); @@ -173,32 +178,54 @@ public final class CssParserTest { public void styleScoreSystem() { WebvttCssStyle style = new WebvttCssStyle(); // Universal selector. - assertThat(style.getSpecificityScore("", "", new String[0], "")).isEqualTo(1); + assertThat(style.getSpecificityScore("", "", Collections.emptySet(), "")).isEqualTo(1); // Class match without tag match. style.setTargetClasses(new String[] { "class1", "class2"}); - assertThat(style.getSpecificityScore("", "", new String[]{"class1", "class2", "class3"}, - "")).isEqualTo(8); + assertThat( + style.getSpecificityScore( + "", "", new HashSet<>(Arrays.asList("class1", "class2", "class3")), "")) + .isEqualTo(8); // Class and tag match style.setTargetTagName("b"); - assertThat(style.getSpecificityScore("", "b", - new String[]{"class1", "class2", "class3"}, "")).isEqualTo(10); + assertThat( + style.getSpecificityScore( + "", "b", new HashSet<>(Arrays.asList("class1", "class2", "class3")), "")) + .isEqualTo(10); // Class insufficiency. - assertThat(style.getSpecificityScore("", "b", new String[]{"class1", "class"}, "")) + assertThat( + style.getSpecificityScore("", "b", new HashSet<>(Arrays.asList("class1", "class")), "")) .isEqualTo(0); // Voice, classes and tag match. style.setTargetVoice("Manuel Cráneo"); - assertThat(style.getSpecificityScore("", "b", - new String[]{"class1", "class2", "class3"}, "Manuel Cráneo")).isEqualTo(14); + assertThat( + style.getSpecificityScore( + "", + "b", + new HashSet<>(Arrays.asList("class1", "class2", "class3")), + "Manuel Cráneo")) + .isEqualTo(14); // Voice mismatch. - assertThat(style.getSpecificityScore(null, "b", - new String[]{"class1", "class2", "class3"}, "Manuel Craneo")).isEqualTo(0); + assertThat( + style.getSpecificityScore( + null, + "b", + new HashSet<>(Arrays.asList("class1", "class2", "class3")), + "Manuel Craneo")) + .isEqualTo(0); // Id, voice, classes and tag match. style.setTargetId("id"); - assertThat(style.getSpecificityScore("id", "b", - new String[]{"class1", "class2", "class3"}, "Manuel Cráneo")).isEqualTo(0x40000000 + 14); + assertThat( + style.getSpecificityScore( + "id", + "b", + new HashSet<>(Arrays.asList("class1", "class2", "class3")), + "Manuel Cráneo")) + .isEqualTo(0x40000000 + 14); // Id mismatch. - assertThat(style.getSpecificityScore("id1", "b", - new String[]{"class1", "class2", "class3"}, "")).isEqualTo(0); + assertThat( + style.getSpecificityScore( + "id1", "b", new HashSet<>(Arrays.asList("class1", "class2", "class3")), "")) + .isEqualTo(0); } // Utility methods. @@ -236,7 +263,6 @@ public final class CssParserTest { assertThat(actualElem.getStyle()).isEqualTo(expected.getStyle()); assertThat(actualElem.isLinethrough()).isEqualTo(expected.isLinethrough()); assertThat(actualElem.isUnderline()).isEqualTo(expected.isUnderline()); - assertThat(actualElem.getTextAlign()).isEqualTo(expected.getTextAlign()); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java index f500029885..778820b451 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttCueParserTest.java @@ -21,7 +21,6 @@ import static com.google.common.truth.Truth.assertThat; import android.graphics.Color; import android.text.Spanned; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.text.span.RubySpan; import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -50,59 +49,6 @@ public final class WebvttCueParserTest { assertThat(text).hasNoSpans(); } - @Test - public void parseRubyTag() throws Exception { - Spanned text = - parseCueText("Some base textwith ruby and undecorated text"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("Some base text and undecorated text"); - assertThat(text) - .hasRubySpanBetween("Some ".length(), "Some base text".length()) - .withTextAndPosition("with ruby", RubySpan.POSITION_OVER); - } - - @Test - public void parseSingleRubyTagWithMultipleRts() throws Exception { - Spanned text = parseCueText("A1B2C3"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("ABC"); - assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER); - } - - @Test - public void parseMultipleRubyTagsWithSingleRtEach() throws Exception { - Spanned text = - parseCueText("A1B2C3"); - - // The text between the tags is stripped from Cue.text and only present on the RubySpan. - assertThat(text.toString()).isEqualTo("ABC"); - assertThat(text).hasRubySpanBetween(0, 1).withTextAndPosition("1", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(1, 2).withTextAndPosition("2", RubySpan.POSITION_OVER); - assertThat(text).hasRubySpanBetween(2, 3).withTextAndPosition("3", RubySpan.POSITION_OVER); - } - - @Test - public void parseRubyTagWithNoTextTag() throws Exception { - Spanned text = parseCueText("Some base text with no ruby text"); - - assertThat(text.toString()).isEqualTo("Some base text with no ruby text"); - assertThat(text).hasNoSpans(); - } - - @Test - public void parseRubyTagWithEmptyTextTag() throws Exception { - Spanned text = parseCueText("Some base text with empty ruby text"); - - assertThat(text.toString()).isEqualTo("Some base text with empty ruby text"); - assertThat(text) - .hasRubySpanBetween("Some ".length(), "Some base text with".length()) - .withTextAndPosition("", RubySpan.POSITION_OVER); - } - @Test public void parseDefaultTextColor() throws Exception { Spanned text = parseCueText("In this sentence this text is red"); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java index 3de75a249f..9b7db097a7 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttDecoderTest.java @@ -26,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.SubtitleDecoderException; +import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ColorParser; import com.google.common.collect.Iterables; @@ -40,22 +41,25 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class WebvttDecoderTest { - private static final String TYPICAL_FILE = "webvtt/typical"; - private static final String TYPICAL_WITH_BAD_TIMESTAMPS = "webvtt/typical_with_bad_timestamps"; - private static final String TYPICAL_WITH_IDS_FILE = "webvtt/typical_with_identifiers"; - private static final String TYPICAL_WITH_COMMENTS_FILE = "webvtt/typical_with_comments"; - private static final String WITH_POSITIONING_FILE = "webvtt/with_positioning"; + private static final String TYPICAL_FILE = "media/webvtt/typical"; + private static final String TYPICAL_WITH_BAD_TIMESTAMPS = + "media/webvtt/typical_with_bad_timestamps"; + private static final String TYPICAL_WITH_IDS_FILE = "media/webvtt/typical_with_identifiers"; + private static final String TYPICAL_WITH_COMMENTS_FILE = "media/webvtt/typical_with_comments"; + private static final String WITH_POSITIONING_FILE = "media/webvtt/with_positioning"; private static final String WITH_OVERLAPPING_TIMESTAMPS_FILE = - "webvtt/with_overlapping_timestamps"; - private static final String WITH_VERTICAL_FILE = "webvtt/with_vertical"; - private static final String WITH_BAD_CUE_HEADER_FILE = "webvtt/with_bad_cue_header"; - private static final String WITH_TAGS_FILE = "webvtt/with_tags"; - private static final String WITH_CSS_STYLES = "webvtt/with_css_styles"; - private static final String WITH_CSS_COMPLEX_SELECTORS = "webvtt/with_css_complex_selectors"; + "media/webvtt/with_overlapping_timestamps"; + private static final String WITH_VERTICAL_FILE = "media/webvtt/with_vertical"; + private static final String WITH_RUBIES_FILE = "media/webvtt/with_rubies"; + private static final String WITH_BAD_CUE_HEADER_FILE = "media/webvtt/with_bad_cue_header"; + private static final String WITH_TAGS_FILE = "media/webvtt/with_tags"; + private static final String WITH_CSS_STYLES = "media/webvtt/with_css_styles"; + private static final String WITH_CSS_COMPLEX_SELECTORS = + "media/webvtt/with_css_complex_selectors"; private static final String WITH_CSS_TEXT_COMBINE_UPRIGHT = - "webvtt/with_css_text_combine_upright"; - private static final String WITH_BOM = "webvtt/with_bom"; - private static final String EMPTY_FILE = "webvtt/empty"; + "media/webvtt/with_css_text_combine_upright"; + private static final String WITH_BOM = "media/webvtt/with_bom"; + private static final String EMPTY_FILE = "media/webvtt/empty"; @Rule public final Expect expect = Expect.create(); @@ -201,10 +205,6 @@ public class WebvttDecoderTest { // Unspecified values should use WebVTT defaults assertThat(firstCue.line).isEqualTo(-1f); assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - // WebVTT specifies START as the default, but it doesn't expect this to be used if - // lineType=NUMBER so we have to override it to END in this case, otherwise the Cue will be - // displayed off the bottom of the screen. - assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); assertThat(firstCue.verticalType).isEqualTo(Cue.TYPE_UNSET); assertThat(subtitle.getEventTime(2)).isEqualTo(2_345_000L); @@ -230,7 +230,7 @@ public class WebvttDecoderTest { assertThat(subtitle.getEventTime(7)).isEqualTo(7_000_000L); Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); assertThat(fourthCue.text.toString()).isEqualTo("This is the fourth subtitle."); - assertThat(fourthCue.line).isEqualTo(-11f); + assertThat(fourthCue.line).isEqualTo(-10f); assertThat(fourthCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_START); assertThat(fourthCue.textAlignment).isEqualTo(Alignment.ALIGN_CENTER); // Derived from `align:middle`: @@ -278,7 +278,6 @@ public class WebvttDecoderTest { assertThat(firstCue.text.toString()).isEqualTo("Displayed at the bottom for 3 seconds."); assertThat(firstCue.line).isEqualTo(-1f); assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); List firstAndSecondCue = subtitle.getCues(subtitle.getEventTime(1)); assertThat(firstAndSecondCue).hasSize(2); @@ -286,18 +285,15 @@ public class WebvttDecoderTest { .isEqualTo("Displayed at the bottom for 3 seconds."); assertThat(firstAndSecondCue.get(0).line).isEqualTo(-1f); assertThat(firstAndSecondCue.get(0).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(firstAndSecondCue.get(0).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); assertThat(firstAndSecondCue.get(1).text.toString()) .isEqualTo("Appears directly above for 1 second."); assertThat(firstAndSecondCue.get(1).line).isEqualTo(-2f); assertThat(firstAndSecondCue.get(1).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(firstAndSecondCue.get(1).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))); assertThat(thirdCue.text.toString()).isEqualTo("Displayed at the bottom for 2 seconds."); assertThat(thirdCue.line).isEqualTo(-1f); assertThat(thirdCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(thirdCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); List thirdAndFourthCue = subtitle.getCues(subtitle.getEventTime(5)); assertThat(thirdAndFourthCue).hasSize(2); @@ -305,19 +301,16 @@ public class WebvttDecoderTest { .isEqualTo("Displayed at the bottom for 2 seconds."); assertThat(thirdAndFourthCue.get(0).line).isEqualTo(-1f); assertThat(thirdAndFourthCue.get(0).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(thirdAndFourthCue.get(0).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); assertThat(thirdAndFourthCue.get(1).text.toString()) .isEqualTo("Appears directly above the previous cue, then replaces it after 1 second."); assertThat(thirdAndFourthCue.get(1).line).isEqualTo(-2f); assertThat(thirdAndFourthCue.get(1).lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(thirdAndFourthCue.get(1).lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); assertThat(fourthCue.text.toString()) .isEqualTo("Appears directly above the previous cue, then replaces it after 1 second."); assertThat(fourthCue.line).isEqualTo(-1f); assertThat(fourthCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); - assertThat(fourthCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); } @Test @@ -345,6 +338,51 @@ public class WebvttDecoderTest { assertThat(thirdCue.verticalType).isEqualTo(Cue.TYPE_UNSET); } + @Test + public void decodeWithRubies() throws Exception { + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_RUBIES_FILE); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(8); + + // Check that an explicit `over` position is read from CSS. + Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0))); + assertThat(firstCue.text.toString()).isEqualTo("Some text with over-ruby."); + assertThat((Spanned) firstCue.text) + .hasRubySpanBetween("Some ".length(), "Some text with over-ruby".length()) + .withTextAndPosition("over", RubySpan.POSITION_OVER); + + // Check that `under` is read from CSS and unspecified defaults to `over`. + Cue secondCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2))); + assertThat(secondCue.text.toString()) + .isEqualTo("Some text with under-ruby and over-ruby (default)."); + assertThat((Spanned) secondCue.text) + .hasRubySpanBetween("Some ".length(), "Some text with under-ruby".length()) + .withTextAndPosition("under", RubySpan.POSITION_UNDER); + assertThat((Spanned) secondCue.text) + .hasRubySpanBetween( + "Some text with under-ruby and ".length(), + "Some text with under-ruby and over-ruby (default)".length()) + .withTextAndPosition("over", RubySpan.POSITION_OVER); + + // Check many tags with different positions nested in a single span. + Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))); + assertThat(thirdCue.text.toString()).isEqualTo("base1base2base3."); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween(/* start= */ 0, "base1".length()) + .withTextAndPosition("over1", RubySpan.POSITION_OVER); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween("base1".length(), "base1base2".length()) + .withTextAndPosition("under2", RubySpan.POSITION_UNDER); + assertThat((Spanned) thirdCue.text) + .hasRubySpanBetween("base1base2".length(), "base1base2base3".length()) + .withTextAndPosition("under3", RubySpan.POSITION_UNDER); + + // Check a span with no tags. + Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))); + assertThat(fourthCue.text.toString()).isEqualTo("Some text with no ruby text."); + assertThat((Spanned) fourthCue.text).hasNoSpans(); + } + @Test public void decodeWithBadCueHeader() throws Exception { WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_BAD_CUE_HEADER_FILE); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java index b14e4b123e..a7a8e5a4c1 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java @@ -16,10 +16,6 @@ package com.google.android.exoplayer2.trackselection; import static com.google.common.truth.Truth.assertThat; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -30,9 +26,9 @@ import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.testutil.FakeClock; import com.google.android.exoplayer2.testutil.FakeMediaChunk; -import com.google.android.exoplayer2.trackselection.TrackSelection.Definition; import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -53,39 +49,12 @@ public final class AdaptiveTrackSelectionTest { @Mock private BandwidthMeter mockBandwidthMeter; private FakeClock fakeClock; - private AdaptiveTrackSelection adaptiveTrackSelection; - @Before public void setUp() { initMocks(this); fakeClock = new FakeClock(0); } - @Test - @SuppressWarnings("deprecation") - public void factoryUsesInitiallyProvidedBandwidthMeter() { - BandwidthMeter initialBandwidthMeter = mock(BandwidthMeter.class); - BandwidthMeter injectedBandwidthMeter = mock(BandwidthMeter.class); - Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); - Format format2 = videoFormat(/* bitrate= */ 1000, /* width= */ 640, /* height= */ 480); - TrackSelection[] trackSelections = - new AdaptiveTrackSelection.Factory(initialBandwidthMeter) - .createTrackSelections( - new Definition[] { - new Definition(new TrackGroup(format1, format2), /* tracks=... */ 0, 1) - }, - injectedBandwidthMeter); - trackSelections[0].updateSelectedTrack( - /* playbackPositionUs= */ 0, - /* bufferedDurationUs= */ 0, - /* availableDurationUs= */ C.TIME_UNSET, - /* queue= */ Collections.emptyList(), - /* mediaChunkIterators= */ new MediaChunkIterator[] {MediaChunkIterator.EMPTY}); - - verify(initialBandwidthMeter, atLeastOnce()).getBitrateEstimate(); - verifyZeroInteractions(injectedBandwidthMeter); - } - @Test public void selectInitialIndexUseMaxInitialBitrateIfNoBandwidthEstimate() { Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); @@ -94,7 +63,7 @@ public final class AdaptiveTrackSelectionTest { TrackGroup trackGroup = new TrackGroup(format1, format2, format3); when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); - adaptiveTrackSelection = adaptiveTrackSelection(trackGroup); + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelection(trackGroup); assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); @@ -108,7 +77,7 @@ public final class AdaptiveTrackSelectionTest { TrackGroup trackGroup = new TrackGroup(format1, format2, format3); when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); - adaptiveTrackSelection = adaptiveTrackSelection(trackGroup); + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelection(trackGroup); assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); @@ -124,7 +93,7 @@ public final class AdaptiveTrackSelectionTest { // The second measurement onward returns 2000L, which prompts the track selection to switch up // if possible. when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L, 2000L); - adaptiveTrackSelection = + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelectionWithMinDurationForQualityIncreaseMs( trackGroup, /* minDurationForQualityIncreaseMs= */ 10_000); @@ -152,7 +121,7 @@ public final class AdaptiveTrackSelectionTest { // The second measurement onward returns 2000L, which prompts the track selection to switch up // if possible. when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L, 2000L); - adaptiveTrackSelection = + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelectionWithMinDurationForQualityIncreaseMs( trackGroup, /* minDurationForQualityIncreaseMs= */ 10_000); @@ -180,7 +149,7 @@ public final class AdaptiveTrackSelectionTest { // The second measurement onward returns 500L, which prompts the track selection to switch down // if necessary. when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L, 500L); - adaptiveTrackSelection = + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelectionWithMaxDurationForQualityDecreaseMs( trackGroup, /* maxDurationForQualityDecreaseMs= */ 25_000); @@ -208,7 +177,7 @@ public final class AdaptiveTrackSelectionTest { // The second measurement onward returns 500L, which prompts the track selection to switch down // if necessary. when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L, 500L); - adaptiveTrackSelection = + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelectionWithMaxDurationForQualityDecreaseMs( trackGroup, /* maxDurationForQualityDecreaseMs= */ 25_000); @@ -245,7 +214,7 @@ public final class AdaptiveTrackSelectionTest { queue.add(chunk3); when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); - adaptiveTrackSelection = adaptiveTrackSelection(trackGroup); + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelection(trackGroup); int size = adaptiveTrackSelection.evaluateQueueSize(0, queue); assertThat(size).isEqualTo(3); @@ -270,22 +239,25 @@ public final class AdaptiveTrackSelectionTest { queue.add(chunk3); when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); - adaptiveTrackSelection = + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelectionWithMinTimeBetweenBufferReevaluationMs( - trackGroup, - /* durationToRetainAfterDiscardMs= */ 15_000, - /* minTimeBetweenBufferReevaluationMs= */ 2000); + trackGroup, /* durationToRetainAfterDiscardMs= */ 15_000); int initialQueueSize = adaptiveTrackSelection.evaluateQueueSize(0, queue); - fakeClock.advanceTime(1999); + fakeClock.advanceTime(999); when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); - // When bandwidth estimation is updated, we can discard chunks at the end of the queue now. - // However, since min duration between buffer reevaluation = 2000, we will not reevaluate - // queue size if time now is only 1999 ms after last buffer reevaluation. - int newSize = adaptiveTrackSelection.evaluateQueueSize(0, queue); + // When the bandwidth estimation is updated, we should be able to discard chunks from the end of + // the queue. However, since the duration since the last evaluation (999ms) is less than 1000ms, + // we will not reevaluate the queue size and should not discard chunks. + int newSize = adaptiveTrackSelection.evaluateQueueSize(/* playbackPositionUs= */ 0, queue); assertThat(newSize).isEqualTo(initialQueueSize); + + // Verify that the comment above is correct. + fakeClock.advanceTime(1); + newSize = adaptiveTrackSelection.evaluateQueueSize(/* playbackPositionUs= */ 0, queue); + assertThat(newSize).isLessThan(initialQueueSize); } @Test @@ -307,11 +279,9 @@ public final class AdaptiveTrackSelectionTest { queue.add(chunk3); when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); - adaptiveTrackSelection = + AdaptiveTrackSelection adaptiveTrackSelection = adaptiveTrackSelectionWithMinTimeBetweenBufferReevaluationMs( - trackGroup, - /* durationToRetainAfterDiscardMs= */ 15_000, - /* minTimeBetweenBufferReevaluationMs= */ 2000); + trackGroup, /* durationToRetainAfterDiscardMs= */ 15_000); int initialQueueSize = adaptiveTrackSelection.evaluateQueueSize(0, queue); assertThat(initialQueueSize).isEqualTo(3); @@ -327,6 +297,89 @@ public final class AdaptiveTrackSelectionTest { assertThat(newSize).isEqualTo(2); } + @Test + public void updateSelectedTrack_usesFormatOfLastChunkInTheQueueForSelection() { + Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); + Format format2 = videoFormat(/* bitrate= */ 1000, /* width= */ 640, /* height= */ 480); + TrackGroup trackGroup = new TrackGroup(format1, format2); + AdaptiveTrackSelection adaptiveTrackSelection = + new AdaptiveTrackSelection.Factory( + /* minDurationForQualityIncreaseMs= */ 10_000, + /* maxDurationForQualityDecreaseMs= */ 10_000, + /* minDurationToRetainAfterDiscardMs= */ 25_000, + /* bandwidthFraction= */ 1f) + .createAdaptiveTrackSelection( + trackGroup, + mockBandwidthMeter, + /* tracks= */ new int[] {0, 1}, + /* totalFixedTrackBandwidth= */ 0); + + // Make initial selection. + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); + prepareTrackSelection(adaptiveTrackSelection); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + + // Ensure that track selection wants to switch down due to low bandwidth. + FakeMediaChunk chunk1 = + new FakeMediaChunk( + format2, /* startTimeUs= */ 0, /* endTimeUs= */ 2_000_000, C.SELECTION_REASON_INITIAL); + FakeMediaChunk chunk2 = + new FakeMediaChunk( + format2, + /* startTimeUs= */ 2_000_000, + /* endTimeUs= */ 4_000_000, + C.SELECTION_REASON_INITIAL); + List queue = ImmutableList.of(chunk1, chunk2); + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 4_000_000, + /* availableDurationUs= */ C.TIME_UNSET, + queue, + /* mediaChunkIterators= */ THREE_EMPTY_MEDIA_CHUNK_ITERATORS); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_ADAPTIVE); + + // Assert that an improved bandwidth selects the last chunk's format and ignores the previous + // decision. Switching up from the previous decision wouldn't be possible yet because the + // buffered duration is less than minDurationForQualityIncreaseMs. + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 4_000_000, + /* availableDurationUs= */ C.TIME_UNSET, + queue, + /* mediaChunkIterators= */ THREE_EMPTY_MEDIA_CHUNK_ITERATORS); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void updateSelectedTrack_withQueueOfUnknownFormats_doesntThrow() { + Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); + Format format2 = videoFormat(/* bitrate= */ 1000, /* width= */ 640, /* height= */ 480); + TrackGroup trackGroup = new TrackGroup(format1, format2); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareTrackSelection(adaptiveTrackSelection(trackGroup)); + Format unknownFormat = videoFormat(/* bitrate= */ 42, /* width= */ 300, /* height= */ 123); + FakeMediaChunk chunk = + new FakeMediaChunk(unknownFormat, /* startTimeUs= */ 0, /* endTimeUs= */ 2_000_000); + List queue = ImmutableList.of(chunk); + + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 2_000_000, + /* availableDurationUs= */ C.TIME_UNSET, + queue, + /* mediaChunkIterators= */ THREE_EMPTY_MEDIA_CHUNK_ITERATORS); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isAnyOf(format1, format2); + } + private AdaptiveTrackSelection adaptiveTrackSelection(TrackGroup trackGroup) { return adaptiveTrackSelectionWithMinDurationForQualityIncreaseMs( trackGroup, AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS); @@ -345,7 +398,6 @@ public final class AdaptiveTrackSelectionTest { AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, /* bandwidthFraction= */ 1.0f, AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, fakeClock)); } @@ -362,14 +414,11 @@ public final class AdaptiveTrackSelectionTest { AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, /* bandwidthFraction= */ 1.0f, AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, fakeClock)); } private AdaptiveTrackSelection adaptiveTrackSelectionWithMinTimeBetweenBufferReevaluationMs( - TrackGroup trackGroup, - long durationToRetainAfterDiscardMs, - long minTimeBetweenBufferReevaluationMs) { + TrackGroup trackGroup, long durationToRetainAfterDiscardMs) { return prepareTrackSelection( new AdaptiveTrackSelection( trackGroup, @@ -381,7 +430,6 @@ public final class AdaptiveTrackSelectionTest { durationToRetainAfterDiscardMs, /* bandwidthFraction= */ 1.0f, AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, - minTimeBetweenBufferReevaluationMs, fakeClock)); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java index 4304c9af9a..ef58cb2801 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java @@ -19,6 +19,7 @@ import static com.google.android.exoplayer2.RendererCapabilities.ADAPTIVE_NOT_SE import static com.google.android.exoplayer2.RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES; import static com.google.android.exoplayer2.RendererCapabilities.FORMAT_HANDLED; import static com.google.android.exoplayer2.RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE; +import static com.google.android.exoplayer2.RendererCapabilities.TUNNELING_NOT_SUPPORTED; import static com.google.android.exoplayer2.RendererConfiguration.DEFAULT; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.never; @@ -37,6 +38,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.RendererCapabilities.Capabilities; import com.google.android.exoplayer2.RendererConfiguration; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; @@ -50,6 +52,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelector.InvalidationLi import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.util.HashMap; import java.util.Map; import org.junit.Before; @@ -67,7 +70,8 @@ public final class DefaultTrackSelectorTest { private static final RendererCapabilities ALL_TEXT_FORMAT_SUPPORTED_RENDERER_CAPABILITIES = new FakeRendererCapabilities(C.TRACK_TYPE_TEXT); private static final RendererCapabilities ALL_AUDIO_FORMAT_EXCEEDED_RENDERER_CAPABILITIES = - new FakeRendererCapabilities(C.TRACK_TYPE_AUDIO, FORMAT_EXCEEDS_CAPABILITIES); + new FakeRendererCapabilities( + C.TRACK_TYPE_AUDIO, RendererCapabilities.create(FORMAT_EXCEEDS_CAPABILITIES)); private static final RendererCapabilities VIDEO_CAPABILITIES = new FakeRendererCapabilities(C.TRACK_TYPE_VIDEO); @@ -131,51 +135,16 @@ public final class DefaultTrackSelectorTest { trackSelector.init(invalidationListener, bandwidthMeter); } + @Test + public void parameters_buildUponThenBuild_isEqual() { + Parameters parameters = buildParametersForEqualsTest(); + assertThat(parameters.buildUpon().build()).isEqualTo(parameters); + } + /** Tests {@link Parameters} {@link android.os.Parcelable} implementation. */ @Test - public void parametersParcelable() { - SparseArray> selectionOverrides = new SparseArray<>(); - Map videoOverrides = new HashMap<>(); - videoOverrides.put(new TrackGroupArray(VIDEO_TRACK_GROUP), new SelectionOverride(0, 1)); - selectionOverrides.put(2, videoOverrides); - - SparseBooleanArray rendererDisabledFlags = new SparseBooleanArray(); - rendererDisabledFlags.put(3, true); - - Parameters parametersToParcel = - new Parameters( - // Video - /* maxVideoWidth= */ 0, - /* maxVideoHeight= */ 1, - /* maxVideoFrameRate= */ 2, - /* maxVideoBitrate= */ 3, - /* exceedVideoConstraintsIfNecessary= */ false, - /* allowVideoMixedMimeTypeAdaptiveness= */ true, - /* allowVideoNonSeamlessAdaptiveness= */ false, - /* viewportWidth= */ 4, - /* viewportHeight= */ 5, - /* viewportOrientationMayChange= */ true, - // Audio - /* preferredAudioLanguage= */ "en", - /* maxAudioChannelCount= */ 6, - /* maxAudioBitrate= */ 7, - /* exceedAudioConstraintsIfNecessary= */ false, - /* allowAudioMixedMimeTypeAdaptiveness= */ true, - /* allowAudioMixedSampleRateAdaptiveness= */ false, - /* allowAudioMixedChannelCountAdaptiveness= */ true, - // Text - /* preferredTextLanguage= */ "de", - /* preferredTextRoleFlags= */ C.ROLE_FLAG_CAPTION, - /* selectUndeterminedTextLanguage= */ true, - /* disabledTextTrackSelectionFlags= */ 8, - // General - /* forceLowestBitrate= */ false, - /* forceHighestSupportedBitrate= */ true, - /* exceedRendererCapabilitiesIfNecessary= */ false, - /* tunnelingAudioSessionId= */ C.AUDIO_SESSION_ID_UNSET, - // Overrides - selectionOverrides, - rendererDisabledFlags); + public void parameters_parcelAndUnParcelable() { + Parameters parametersToParcel = buildParametersForEqualsTest(); Parcel parcel = Parcel.obtain(); parametersToParcel.writeToParcel(parcel, 0); @@ -1353,7 +1322,10 @@ public final class DefaultTrackSelectorTest { @Test public void selectTracksWithMultipleVideoTracksWithNonSeamlessAdaptiveness() throws Exception { FakeRendererCapabilities nonSeamlessVideoCapabilities = - new FakeRendererCapabilities(C.TRACK_TYPE_VIDEO, FORMAT_HANDLED | ADAPTIVE_NOT_SEAMLESS); + new FakeRendererCapabilities( + C.TRACK_TYPE_VIDEO, + RendererCapabilities.create( + FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, TUNNELING_NOT_SUPPORTED)); // Should do non-seamless adaptiveness by default, so expect an adaptive selection. Format.Builder formatBuilder = VIDEO_FORMAT.buildUpon(); @@ -1510,6 +1482,61 @@ public final class DefaultTrackSelectorTest { .build(); } + /** + * Returns {@link Parameters} suitable for simple round trip equality tests. + * + *

      Primitive variables are set to different values (to the extent that this is possible), to + * increase the probability of such tests failing if they accidentally compare mismatched + * variables. + */ + private static Parameters buildParametersForEqualsTest() { + SparseArray> selectionOverrides = new SparseArray<>(); + Map videoOverrides = new HashMap<>(); + videoOverrides.put(new TrackGroupArray(VIDEO_TRACK_GROUP), new SelectionOverride(0, 1)); + selectionOverrides.put(2, videoOverrides); + + SparseBooleanArray rendererDisabledFlags = new SparseBooleanArray(); + rendererDisabledFlags.put(3, true); + + return new Parameters( + // Video + /* maxVideoWidth= */ 0, + /* maxVideoHeight= */ 1, + /* maxVideoFrameRate= */ 2, + /* maxVideoBitrate= */ 3, + /* minVideoWidth= */ 4, + /* minVideoHeight= */ 5, + /* minVideoFrameRate= */ 6, + /* minVideoBitrate= */ 7, + /* exceedVideoConstraintsIfNecessary= */ false, + /* allowVideoMixedMimeTypeAdaptiveness= */ true, + /* allowVideoNonSeamlessAdaptiveness= */ false, + /* viewportWidth= */ 8, + /* viewportHeight= */ 9, + /* viewportOrientationMayChange= */ true, + // Audio + /* preferredAudioLanguages= */ ImmutableList.of("zh", "jp"), + /* maxAudioChannelCount= */ 10, + /* maxAudioBitrate= */ 11, + /* exceedAudioConstraintsIfNecessary= */ false, + /* allowAudioMixedMimeTypeAdaptiveness= */ true, + /* allowAudioMixedSampleRateAdaptiveness= */ false, + /* allowAudioMixedChannelCountAdaptiveness= */ true, + // Text + /* preferredTextLanguages= */ ImmutableList.of("de", "en"), + /* preferredTextRoleFlags= */ C.ROLE_FLAG_CAPTION, + /* selectUndeterminedTextLanguage= */ true, + /* disabledTextTrackSelectionFlags= */ C.SELECTION_FLAG_AUTOSELECT, + // General + /* forceLowestBitrate= */ false, + /* forceHighestSupportedBitrate= */ true, + /* exceedRendererCapabilitiesIfNecessary= */ false, + /* tunnelingAudioSessionId= */ 13, + // Overrides + selectionOverrides, + rendererDisabledFlags); + } + /** * A {@link RendererCapabilities} that advertises support for all formats of a given type using * a provided support value. For any format that does not have the given track type, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/AssetDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/AssetDataSourceTest.java index 1f4790b8c5..67ca415a53 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/AssetDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/AssetDataSourceTest.java @@ -26,7 +26,7 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class AssetDataSourceTest { - private static final String DATA_PATH = "mp3/1024_incrementing_bytes.mp3"; + private static final String DATA_PATH = "media/mp3/1024_incrementing_bytes.mp3"; @Test public void readFileUri() throws Exception { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/ByteArrayDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/ByteArrayDataSourceTest.java index 27cf243030..564973f51c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/ByteArrayDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/ByteArrayDataSourceTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream; import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.min; import static org.junit.Assert.fail; import android.net.Uri; @@ -125,7 +126,7 @@ public final class ByteArrayDataSourceTest { while (true) { // Calculate a valid length for the next read, constraining by the specified output buffer // length, write offset and maximum write length input parameters. - int requestedReadLength = Math.min(maxReadLength, outputBufferLength - writeOffset); + int requestedReadLength = min(maxReadLength, outputBufferLength - writeOffset); assertThat(requestedReadLength).isGreaterThan(0); int bytesRead = dataSource.read(outputBuffer, writeOffset, requestedReadLength); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java index d8d22a7b2f..23f5a17e93 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultBandwidthMeterTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static android.net.NetworkInfo.State.CONNECTED; +import static android.net.NetworkInfo.State.DISCONNECTED; import static com.google.common.truth.Truth.assertThat; import android.content.Context; @@ -68,42 +70,42 @@ public final class DefaultBandwidthMeterTest { ConnectivityManager.TYPE_WIFI, /* subType= */ 0, /* isAvailable= */ false, - /* isConnected= */ false); + DISCONNECTED); networkInfoWifi = ShadowNetworkInfo.newInstance( DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, /* subType= */ 0, /* isAvailable= */ true, - /* isConnected= */ true); + CONNECTED); networkInfo2g = ShadowNetworkInfo.newInstance( DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, TelephonyManager.NETWORK_TYPE_GPRS, /* isAvailable= */ true, - /* isConnected= */ true); + CONNECTED); networkInfo3g = ShadowNetworkInfo.newInstance( DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, TelephonyManager.NETWORK_TYPE_HSDPA, /* isAvailable= */ true, - /* isConnected= */ true); + CONNECTED); networkInfo4g = ShadowNetworkInfo.newInstance( DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, TelephonyManager.NETWORK_TYPE_LTE, /* isAvailable= */ true, - /* isConnected= */ true); + CONNECTED); networkInfoEthernet = ShadowNetworkInfo.newInstance( DetailedState.CONNECTED, ConnectivityManager.TYPE_ETHERNET, /* subType= */ 0, /* isAvailable= */ true, - /* isConnected= */ true); + CONNECTED); } @Test @@ -569,7 +571,7 @@ public final class DefaultBandwidthMeterTest { long[] bitrateEstimates = new long[SIMULATED_TRANSFER_COUNT]; Random random = new Random(/* seed= */ 0); DataSource dataSource = new FakeDataSource(); - DataSpec dataSpec = new DataSpec(Uri.parse("https://dummy.com")); + DataSpec dataSpec = new DataSpec(Uri.parse("https://test.com")); for (int i = 0; i < SIMULATED_TRANSFER_COUNT; i++) { bandwidthMeter.onTransferStart(dataSource, dataSpec, /* isNetwork= */ true); clock.advanceTime(random.nextInt(/* bound= */ 5000)); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java index 405fe6c5ee..8d5a7479e5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceTest.java @@ -17,122 +17,115 @@ package com.google.android.exoplayer2.upstream; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.Assert.assertThrows; import androidx.test.ext.junit.runners.AndroidJUnit4; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.net.HttpURLConnection; +import com.google.android.exoplayer2.testutil.TestUtil; import java.util.HashMap; import java.util.Map; +import okhttp3.Headers; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; /** Unit tests for {@link DefaultHttpDataSource}. */ @RunWith(AndroidJUnit4.class) public class DefaultHttpDataSourceTest { + /** + * This test will set HTTP default request parameters (1) in the DefaultHttpDataSource, (2) via + * DefaultHttpDataSource.setRequestProperty() and (3) in the DataSpec instance according to the + * table below. Values wrapped in '*' are the ones that should be set in the connection request. + * + *

      {@code
      +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
      +   * |               |               Header Key                |
      +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
      +   * |   Location    |  0  |  1  |  2  |  3  |  4  |  5  |  6  |
      +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
      +   * | Constructor   | *Y* |  Y  |  Y  |     |  Y  |     |     |
      +   * | Setter        |     | *Y* |  Y  |  Y  |     | *Y* |     |
      +   * | DataSpec      |     |     | *Y* | *Y* | *Y* |     | *Y* |
      +   * +---------------+-----+-----+-----+-----+-----+-----+-----+
      +   * }
      + */ @Test - public void open_withSpecifiedRequestParameters_usesCorrectParameters() throws IOException { + public void open_withSpecifiedRequestParameters_usesCorrectParameters() throws Exception { + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse()); - /* - * This test will set HTTP default request parameters (1) in the DefaultHttpDataSource, (2) via - * DefaultHttpDataSource.setRequestProperty() and (3) in the DataSpec instance according to the - * table below. Values wrapped in '*' are the ones that should be set in the connection request. - * - * +-----------------------+---+-----+-----+-----+-----+-----+ - * | | Header Key | - * +-----------------------+---+-----+-----+-----+-----+-----+ - * | Location | 0 | 1 | 2 | 3 | 4 | 5 | - * +-----------------------+---+-----+-----+-----+-----+-----+ - * | Default |*Y*| Y | Y | | | | - * | DefaultHttpDataSource | | *Y* | Y | Y | *Y* | | - * | DataSpec | | | *Y* | *Y* | | *Y* | - * +-----------------------+---+-----+-----+-----+-----+-----+ - */ + String propertyFromConstructor = "fromConstructor"; + HttpDataSource.RequestProperties constructorProperties = new HttpDataSource.RequestProperties(); + constructorProperties.set("0", propertyFromConstructor); + constructorProperties.set("1", propertyFromConstructor); + constructorProperties.set("2", propertyFromConstructor); + constructorProperties.set("4", propertyFromConstructor); + DefaultHttpDataSource dataSource = + new DefaultHttpDataSource( + /* userAgent= */ "testAgent", + /* connectTimeoutMillis= */ 1000, + /* readTimeoutMillis= */ 1000, + /* allowCrossProtocolRedirects= */ false, + constructorProperties); - String defaultParameter = "Default"; - String dataSourceInstanceParameter = "DefaultHttpDataSource"; - String dataSpecParameter = "Dataspec"; - - HttpDataSource.RequestProperties defaultParameters = new HttpDataSource.RequestProperties(); - defaultParameters.set("0", defaultParameter); - defaultParameters.set("1", defaultParameter); - defaultParameters.set("2", defaultParameter); - - DefaultHttpDataSource defaultHttpDataSource = - Mockito.spy( - new DefaultHttpDataSource( - /* userAgent= */ "testAgent", - /* connectTimeoutMillis= */ 1000, - /* readTimeoutMillis= */ 1000, - /* allowCrossProtocolRedirects= */ false, - defaultParameters)); - - Map sentRequestProperties = new HashMap<>(); - HttpURLConnection mockHttpUrlConnection = makeMockHttpUrlConnection(sentRequestProperties); - Mockito.doReturn(mockHttpUrlConnection) - .when(defaultHttpDataSource) - .openConnection(ArgumentMatchers.any()); - - defaultHttpDataSource.setRequestProperty("1", dataSourceInstanceParameter); - defaultHttpDataSource.setRequestProperty("2", dataSourceInstanceParameter); - defaultHttpDataSource.setRequestProperty("3", dataSourceInstanceParameter); - defaultHttpDataSource.setRequestProperty("4", dataSourceInstanceParameter); + String propertyFromSetter = "fromSetter"; + dataSource.setRequestProperty("1", propertyFromSetter); + dataSource.setRequestProperty("2", propertyFromSetter); + dataSource.setRequestProperty("3", propertyFromSetter); + dataSource.setRequestProperty("5", propertyFromSetter); + String propertyFromDataSpec = "fromDataSpec"; Map dataSpecRequestProperties = new HashMap<>(); - dataSpecRequestProperties.put("2", dataSpecParameter); - dataSpecRequestProperties.put("3", dataSpecParameter); - dataSpecRequestProperties.put("5", dataSpecParameter); - + dataSpecRequestProperties.put("2", propertyFromDataSpec); + dataSpecRequestProperties.put("3", propertyFromDataSpec); + dataSpecRequestProperties.put("4", propertyFromDataSpec); + dataSpecRequestProperties.put("6", propertyFromDataSpec); DataSpec dataSpec = new DataSpec.Builder() - .setUri("http://www.google.com") - .setHttpBody(new byte[] {0, 0, 0, 0}) - .setLength(1) - .setKey("key") + .setUri(mockWebServer.url("/test-path").toString()) .setHttpRequestHeaders(dataSpecRequestProperties) .build(); - defaultHttpDataSource.open(dataSpec); + dataSource.open(dataSpec); - assertThat(sentRequestProperties.get("0")).isEqualTo(defaultParameter); - assertThat(sentRequestProperties.get("1")).isEqualTo(dataSourceInstanceParameter); - assertThat(sentRequestProperties.get("2")).isEqualTo(dataSpecParameter); - assertThat(sentRequestProperties.get("3")).isEqualTo(dataSpecParameter); - assertThat(sentRequestProperties.get("4")).isEqualTo(dataSourceInstanceParameter); - assertThat(sentRequestProperties.get("5")).isEqualTo(dataSpecParameter); + Headers headers = mockWebServer.takeRequest(10, SECONDS).getHeaders(); + assertThat(headers.get("0")).isEqualTo(propertyFromConstructor); + assertThat(headers.get("1")).isEqualTo(propertyFromSetter); + assertThat(headers.get("2")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("3")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("4")).isEqualTo(propertyFromDataSpec); + assertThat(headers.get("5")).isEqualTo(propertyFromSetter); + assertThat(headers.get("6")).isEqualTo(propertyFromDataSpec); } - /** - * Creates a mock {@link HttpURLConnection} that stores all request parameters inside {@code - * requestProperties}. - */ - private static HttpURLConnection makeMockHttpUrlConnection(Map requestProperties) - throws IOException { - HttpURLConnection mockHttpUrlConnection = Mockito.mock(HttpURLConnection.class); - Mockito.when(mockHttpUrlConnection.usingProxy()).thenReturn(false); + @Test + public void open_invalidResponseCode() throws Exception { + DefaultHttpDataSource defaultHttpDataSource = + new DefaultHttpDataSource( + /* userAgent= */ "testAgent", + /* connectTimeoutMillis= */ 1000, + /* readTimeoutMillis= */ 1000, + /* allowCrossProtocolRedirects= */ false, + /* defaultRequestProperties= */ null); - Mockito.when(mockHttpUrlConnection.getInputStream()) - .thenReturn(new ByteArrayInputStream(new byte[128])); + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(404) + .setBody(new Buffer().write(TestUtil.createByteArray(1, 2, 3)))); - Mockito.when(mockHttpUrlConnection.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + DataSpec dataSpec = + new DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build(); - Mockito.when(mockHttpUrlConnection.getResponseCode()).thenReturn(200); - Mockito.when(mockHttpUrlConnection.getResponseMessage()).thenReturn("OK"); + HttpDataSource.InvalidResponseCodeException exception = + assertThrows( + HttpDataSource.InvalidResponseCodeException.class, + () -> defaultHttpDataSource.open(dataSpec)); - Mockito.doAnswer( - (invocation) -> { - String key = invocation.getArgument(0); - String value = invocation.getArgument(1); - requestProperties.put(key, value); - return null; - }) - .when(mockHttpUrlConnection) - .setRequestProperty(ArgumentMatchers.anyString(), ArgumentMatchers.anyString()); - - return mockHttpUrlConnection; + assertThat(exception.responseCode).isEqualTo(404); + assertThat(exception.responseBody).isEqualTo(TestUtil.createByteArray(1, 2, 3)); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java index 8840abfcdc..50b06c14db 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicyTest.java @@ -21,7 +21,11 @@ import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.Collections; import org.junit.Test; @@ -31,36 +35,60 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class DefaultLoadErrorHandlingPolicyTest { + private static final LoadEventInfo PLACEHOLDER_LOAD_EVENT_INFO = + new LoadEventInfo( + LoadEventInfo.getNewId(), + new DataSpec(Uri.EMPTY), + Uri.EMPTY, + /* responseHeaders= */ Collections.emptyMap(), + /* elapsedRealtimeMs= */ 5000, + /* loadDurationMs= */ 1000, + /* bytesLoaded= */ 0); + private static final MediaLoadData PLACEHOLDER_MEDIA_LOAD_DATA = + new MediaLoadData(/* dataType= */ C.DATA_TYPE_UNKNOWN); + @Test - public void getBlacklistDurationMsFor_blacklist404() { + public void getExclusionDurationMsFor_responseCode404() { InvalidResponseCodeException exception = new InvalidResponseCodeException( - 404, "Not Found", Collections.emptyMap(), new DataSpec(Uri.EMPTY)); - assertThat(getDefaultPolicyBlacklistOutputFor(exception)) + 404, + "Not Found", + Collections.emptyMap(), + new DataSpec(Uri.EMPTY), + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); } @Test - public void getBlacklistDurationMsFor_blacklist410() { + public void getExclusionDurationMsFor_responseCode410() { InvalidResponseCodeException exception = new InvalidResponseCodeException( - 410, "Gone", Collections.emptyMap(), new DataSpec(Uri.EMPTY)); - assertThat(getDefaultPolicyBlacklistOutputFor(exception)) + 410, + "Gone", + Collections.emptyMap(), + new DataSpec(Uri.EMPTY), + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + assertThat(getDefaultPolicyExclusionDurationMsFor(exception)) .isEqualTo(DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_BLACKLIST_MS); } @Test - public void getBlacklistDurationMsFor_dontBlacklistUnexpectedHttpCodes() { + public void getExclusionDurationMsFor_dontExcludeUnexpectedHttpCodes() { InvalidResponseCodeException exception = new InvalidResponseCodeException( - 500, "Internal Server Error", Collections.emptyMap(), new DataSpec(Uri.EMPTY)); - assertThat(getDefaultPolicyBlacklistOutputFor(exception)).isEqualTo(C.TIME_UNSET); + 500, + "Internal Server Error", + Collections.emptyMap(), + new DataSpec(Uri.EMPTY), + /* responseBody= */ Util.EMPTY_BYTE_ARRAY); + assertThat(getDefaultPolicyExclusionDurationMsFor(exception)).isEqualTo(C.TIME_UNSET); } @Test - public void getBlacklistDurationMsFor_dontBlacklistUnexpectedExceptions() { + public void getExclusionDurationMsFor_dontExcludeUnexpectedExceptions() { IOException exception = new IOException(); - assertThat(getDefaultPolicyBlacklistOutputFor(exception)).isEqualTo(C.TIME_UNSET); + assertThat(getDefaultPolicyExclusionDurationMsFor(exception)).isEqualTo(C.TIME_UNSET); } @Test @@ -76,14 +104,20 @@ public final class DefaultLoadErrorHandlingPolicyTest { assertThat(getDefaultPolicyRetryDelayOutputFor(new IOException(), 9)).isEqualTo(5000); } - private static long getDefaultPolicyBlacklistOutputFor(IOException exception) { - return new DefaultLoadErrorHandlingPolicy() - .getBlacklistDurationMsFor( - C.DATA_TYPE_MEDIA, /* loadDurationMs= */ 1000, exception, /* errorCount= */ 1); + private static long getDefaultPolicyExclusionDurationMsFor(IOException exception) { + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo( + PLACEHOLDER_LOAD_EVENT_INFO, + PLACEHOLDER_MEDIA_LOAD_DATA, + exception, + /* errorCount= */ 1); + return new DefaultLoadErrorHandlingPolicy().getBlacklistDurationMsFor(loadErrorInfo); } private static long getDefaultPolicyRetryDelayOutputFor(IOException exception, int errorCount) { - return new DefaultLoadErrorHandlingPolicy() - .getRetryDelayMsFor(C.DATA_TYPE_MEDIA, /* loadDurationMs= */ 1000, exception, errorCount); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo( + PLACEHOLDER_LOAD_EVENT_INFO, PLACEHOLDER_MEDIA_LOAD_DATA, exception, errorCount); + return new DefaultLoadErrorHandlingPolicy().getRetryDelayMsFor(loadErrorInfo); } } diff --git a/library/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 6562c17183..cadd9e43ab 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 @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.upstream.cache; import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCacheEmpty; import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.min; import static org.junit.Assert.fail; import android.net.Uri; @@ -75,13 +76,14 @@ public final class CacheDataSourceTest { boundedDataSpec = buildDataSpec(/* unbounded= */ false, /* key= */ null); unboundedDataSpecWithKey = buildDataSpec(/* unbounded= */ true, DATASPEC_KEY); boundedDataSpecWithKey = buildDataSpec(/* unbounded= */ false, DATASPEC_KEY); - defaultCacheKey = CacheUtil.DEFAULT_CACHE_KEY_FACTORY.buildCacheKey(unboundedDataSpec); + defaultCacheKey = CacheKeyFactory.DEFAULT.buildCacheKey(unboundedDataSpec); customCacheKey = "customKey." + defaultCacheKey; cacheKeyFactory = dataSpec -> customCacheKey; tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); upstreamDataSource = new FakeDataSource(); } @@ -357,8 +359,14 @@ public final class CacheDataSourceTest { .newDefaultData() .appendReadData(1024 * 1024) .endData()); - CacheUtil.cache( - cache, unboundedDataSpec, upstream2, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream2), + unboundedDataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Read the rest of the data. TestUtil.readToEnd(cacheDataSource); @@ -377,7 +385,7 @@ public final class CacheDataSourceTest { .appendReadData(1); // Lock the content on the cache. - CacheSpan cacheSpan = cache.startReadWriteNonBlocking(defaultCacheKey, 0); + CacheSpan cacheSpan = cache.startReadWriteNonBlocking(defaultCacheKey, 0, C.LENGTH_UNSET); assertThat(cacheSpan).isNotNull(); assertThat(cacheSpan.isHoleSpan()).isTrue(); @@ -401,8 +409,14 @@ public final class CacheDataSourceTest { .newDefaultData() .appendReadData(1024 * 1024) .endData()); - CacheUtil.cache( - cache, unboundedDataSpec, upstream2, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream2), + unboundedDataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Read the rest of the data. TestUtil.readToEnd(cacheDataSource); @@ -420,8 +434,14 @@ public final class CacheDataSourceTest { // Cache the latter half of the data. int halfDataLength = 512; DataSpec dataSpec = buildDataSpec(halfDataLength, C.LENGTH_UNSET); - CacheUtil.cache( - cache, dataSpec, upstream, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream), + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Create cache read-only CacheDataSource. CacheDataSource cacheDataSource = @@ -432,7 +452,7 @@ public final class CacheDataSourceTest { TestUtil.readExactly(cacheDataSource, 100); // Delete cached data. - CacheUtil.remove(unboundedDataSpec, cache, /* cacheKeyFactory= */ null); + cache.removeResource(cacheDataSource.getCacheKeyFactory().buildCacheKey(unboundedDataSpec)); assertCacheEmpty(cache); // Read the rest of the data. @@ -451,8 +471,14 @@ public final class CacheDataSourceTest { // Cache the latter half of the data. int halfDataLength = 512; DataSpec dataSpec = buildDataSpec(/* position= */ 0, halfDataLength); - CacheUtil.cache( - cache, dataSpec, upstream, /* progressListener= */ null, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, upstream), + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + /* progressListener= */ null); + cacheWriter.cache(); // Create blocking CacheDataSource. CacheDataSource cacheDataSource = @@ -525,7 +551,7 @@ public final class CacheDataSourceTest { int requestLength = (int) dataSpec.length; int readLength = TEST_DATA.length - position; if (requestLength != C.LENGTH_UNSET) { - readLength = Math.min(readLength, requestLength); + readLength = min(readLength, requestLength); } assertThat(cacheDataSource.open(dataSpec)) .isEqualTo(unknownLength ? requestLength : readLength); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java index 8702e887f8..e6b44e9aa8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheDataSourceTest2.java @@ -18,7 +18,6 @@ package com.google.android.exoplayer2.upstream.cache; import static com.google.common.truth.Truth.assertThat; import static java.util.Arrays.copyOf; import static java.util.Arrays.copyOfRange; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.content.Context; import android.net.Uri; @@ -40,10 +39,8 @@ import java.io.IOException; import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Additional tests for {@link CacheDataSource}. */ -@LooperMode(LEGACY) @RunWith(AndroidJUnit4.class) public final class CacheDataSourceTest2 { @@ -156,7 +153,11 @@ public final class CacheDataSourceTest2 { private static CacheDataSource buildCacheDataSource(Context context, DataSource upstreamSource, boolean useAesEncryption) throws CacheException { File cacheDir = context.getExternalCacheDir(); - Cache cache = new SimpleCache(new File(cacheDir, EXO_CACHE_DIR), new NoOpCacheEvictor()); + Cache cache = + new SimpleCache( + new File(cacheDir, EXO_CACHE_DIR), + new NoOpCacheEvictor(), + TestUtil.getInMemoryDatabaseProvider()); emptyCache(cache); // Source and cipher @@ -179,13 +180,13 @@ public final class CacheDataSourceTest2 { null); // eventListener } - private static void emptyCache(Cache cache) throws CacheException { + private static void emptyCache(Cache cache) { for (String key : cache.getKeys()) { for (CacheSpan span : cache.getCachedSpans(key)) { cache.removeSpan(span); } } - // Sanity check that the cache really is empty now. + // Check that the cache really is empty now. assertThat(cache.getKeys().isEmpty()).isTrue(); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactoryTest.java new file mode 100644 index 0000000000..3c6542b90f --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheKeyFactoryTest.java @@ -0,0 +1,45 @@ +/* + * 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.upstream.cache; + +import static com.google.android.exoplayer2.upstream.cache.CacheKeyFactory.DEFAULT; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.upstream.DataSpec; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests {@link CacheKeyFactoryTest}. */ +@RunWith(AndroidJUnit4.class) +public class CacheKeyFactoryTest { + + @Test + public void default_dataSpecWithKey_returnsKey() { + Uri testUri = Uri.parse("test"); + String key = "key"; + DataSpec dataSpec = new DataSpec.Builder().setUri(testUri).setKey(key).build(); + assertThat(DEFAULT.buildCacheKey(dataSpec)).isEqualTo(key); + } + + @Test + public void default_dataSpecWithoutKey_returnsUri() { + Uri testUri = Uri.parse("test"); + DataSpec dataSpec = new DataSpec.Builder().setUri(testUri).build(); + assertThat(DEFAULT.buildCacheKey(dataSpec)).isEqualTo(testUri.toString()); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java similarity index 56% rename from library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java rename to library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java index d0a4da4f8c..6064783e08 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CacheWriterTest.java @@ -15,25 +15,23 @@ */ package com.google.android.exoplayer2.upstream.cache; -import static com.google.android.exoplayer2.C.LENGTH_UNSET; -import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCacheEmpty; import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCachedData; import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; +import static java.lang.Math.min; +import static org.junit.Assert.assertThrows; import android.net.Uri; -import android.util.Pair; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.testutil.FakeDataSet; import com.google.android.exoplayer2.testutil.FakeDataSource; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.util.Util; -import java.io.EOFException; import java.io.File; +import java.io.IOException; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -42,9 +40,9 @@ import org.mockito.Answers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -/** Tests {@link CacheUtil}. */ +/** Unit tests for {@link CacheWriter}. */ @RunWith(AndroidJUnit4.class) -public final class CacheUtilTest { +public final class CacheWriterTest { /** * Abstract fake Cache implementation used by the test. This class must be public so Mockito can @@ -66,10 +64,13 @@ public final class CacheUtilTest { @Override public long getCachedLength(String key, long position, long length) { + if (length == C.LENGTH_UNSET) { + length = Long.MAX_VALUE; + } for (int i = 0; i < spansAndGaps.length; i++) { int spanOrGap = spansAndGaps[i]; if (position < spanOrGap) { - long left = Math.min(spanOrGap - position, length); + long left = min(spanOrGap - position, length); return (i & 1) == 1 ? -left : left; } position -= spanOrGap; @@ -96,7 +97,8 @@ public final class CacheUtilTest { mockCache.init(); tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); } @After @@ -104,104 +106,21 @@ public final class CacheUtilTest { Util.recursiveDelete(tempFolder); } - @Test - public void generateKey() { - assertThat(CacheUtil.generateKey(Uri.EMPTY)).isNotNull(); - - Uri testUri = Uri.parse("test"); - String key = CacheUtil.generateKey(testUri); - assertThat(key).isNotNull(); - - // Should generate the same key for the same input. - assertThat(CacheUtil.generateKey(testUri)).isEqualTo(key); - - // Should generate different key for different input. - assertThat(key.equals(CacheUtil.generateKey(Uri.parse("test2")))).isFalse(); - } - - @Test - public void defaultCacheKeyFactory_buildCacheKey() { - Uri testUri = Uri.parse("test"); - String key = "key"; - // If DataSpec.key is present, returns it. - assertThat( - CacheUtil.DEFAULT_CACHE_KEY_FACTORY.buildCacheKey( - new DataSpec.Builder().setUri(testUri).setKey(key).build())) - .isEqualTo(key); - // If not generates a new one using DataSpec.uri. - assertThat( - CacheUtil.DEFAULT_CACHE_KEY_FACTORY.buildCacheKey( - new DataSpec(testUri, /* position= */ 0, /* length= */ LENGTH_UNSET))) - .isEqualTo(testUri.toString()); - } - - @Test - public void getCachedNoData() { - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(C.LENGTH_UNSET); - assertThat(contentLengthAndBytesCached.second).isEqualTo(0); - } - - @Test - public void getCachedDataUnknownLength() { - // Mock there is 100 bytes cached at the beginning - mockCache.spansAndGaps = new int[] {100}; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(C.LENGTH_UNSET); - assertThat(contentLengthAndBytesCached.second).isEqualTo(100); - } - - @Test - public void getCachedNoDataKnownLength() { - mockCache.contentLength = 1000; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(1000); - assertThat(contentLengthAndBytesCached.second).isEqualTo(0); - } - - @Test - public void getCached() { - mockCache.contentLength = 1000; - mockCache.spansAndGaps = new int[] {100, 100, 200}; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test")), mockCache, /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(1000); - assertThat(contentLengthAndBytesCached.second).isEqualTo(300); - } - - @Test - public void getCachedFromNonZeroPosition() { - mockCache.contentLength = 1000; - mockCache.spansAndGaps = new int[] {100, 100, 200}; - Pair contentLengthAndBytesCached = - CacheUtil.getCached( - new DataSpec(Uri.parse("test"), /* position= */ 100, /* length= */ C.LENGTH_UNSET), - mockCache, - /* cacheKeyFactory= */ null); - - assertThat(contentLengthAndBytesCached.first).isEqualTo(900); - assertThat(contentLengthAndBytesCached.second).isEqualTo(200); - } - @Test public void cache() throws Exception { FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); FakeDataSource dataSource = new FakeDataSource(fakeDataSet); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - cache, new DataSpec(Uri.parse("test_data")), dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(Uri.parse("test_data")), + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); @@ -215,12 +134,27 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 20, 20); counters.reset(); - CacheUtil.cache(cache, new DataSpec(testUri), dataSource, counters, /* isCanceled= */ null); + cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(testUri), + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(20, 80, 100); assertCachedData(cache, fakeDataSet); @@ -235,7 +169,15 @@ public final class CacheUtilTest { DataSpec dataSpec = new DataSpec(Uri.parse("test_data")); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); @@ -251,12 +193,27 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); + + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 20, 20); counters.reset(); - CacheUtil.cache(cache, new DataSpec(testUri), dataSource, counters, /* isCanceled= */ null); + cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(testUri), + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(20, 80, 100); assertCachedData(cache, fakeDataSet); @@ -270,9 +227,17 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000); CachingCounters counters = new CachingCounters(); - CacheUtil.cache(cache, dataSpec, dataSource, counters, /* isCanceled= */ null); - counters.assertValues(0, 100, 1000); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ true, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); + + counters.assertValues(0, 100, 100); assertCachedData(cache, fakeDataSet); } @@ -284,18 +249,18 @@ public final class CacheUtilTest { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000); - try { - CacheUtil.cache( - new CacheDataSource(cache, dataSource), - dataSpec, - /* progressListener= */ null, - /* isCanceled= */ null, - /* enableEOFException= */ true, - /* temporaryBuffer= */ new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES]); - fail(); - } catch (EOFException e) { - // Do nothing. - } + IOException exception = + assertThrows( + IOException.class, + () -> + new CacheWriter( + new CacheDataSource(cache, dataSource), + dataSpec, + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + /* progressListener= */ null) + .cache()); + assertThat(DataSourceException.isCausedByPositionOutOfRange(exception)).isTrue(); } @Test @@ -312,43 +277,20 @@ public final class CacheUtilTest { .endData(); FakeDataSource dataSource = new FakeDataSource(fakeDataSet); - CacheUtil.cache( - cache, new DataSpec(Uri.parse("test_data")), dataSource, counters, /* isCanceled= */ null); + CacheWriter cacheWriter = + new CacheWriter( + new CacheDataSource(cache, dataSource), + new DataSpec(Uri.parse("test_data")), + /* allowShortContent= */ false, + /* temporaryBuffer= */ null, + counters); + cacheWriter.cache(); counters.assertValues(0, 300, 300); assertCachedData(cache, fakeDataSet); } - @Test - public void remove() throws Exception { - FakeDataSet fakeDataSet = new FakeDataSet().setRandomData("test_data", 100); - FakeDataSource dataSource = new FakeDataSource(fakeDataSet); - - DataSpec dataSpec = - new DataSpec.Builder() - .setUri("test_data") - .setFlags(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) - .build(); - CacheUtil.cache( - // Set fragmentSize to 10 to make sure there are multiple spans. - new CacheDataSource( - cache, - dataSource, - new FileDataSource(), - new CacheDataSink(cache, /* fragmentSize= */ 10), - /* flags= */ 0, - /* eventListener= */ null), - dataSpec, - /* progressListener= */ null, - /* isCanceled= */ null, - /* enableEOFException= */ true, - /* temporaryBuffer= */ new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES]); - CacheUtil.remove(dataSpec, cache, /* cacheKeyFactory= */ null); - - assertCacheEmpty(cache); - } - - private static final class CachingCounters implements CacheUtil.ProgressListener { + private static final class CachingCounters implements CacheWriter.ProgressListener { private long contentLength = C.LENGTH_UNSET; private long bytesAlreadyCached; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java index bbb372b5e2..1237d3a312 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/CachedContentIndexTest.java @@ -301,7 +301,7 @@ public class CachedContentIndexTest { public void cantRemoveLockedCachedContent() { CachedContentIndex index = newInstance(); CachedContent cachedContent = index.getOrAdd("key1"); - cachedContent.setLocked(true); + cachedContent.lockRange(0, 1); index.maybeRemove(cachedContent.key); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java index 14222f144d..482c95bfd2 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/cache/SimpleCacheTest.java @@ -18,11 +18,13 @@ package com.google.android.exoplayer2.upstream.cache; import static com.google.android.exoplayer2.C.LENGTH_UNSET; import static com.google.android.exoplayer2.util.Util.toByteArray; import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doAnswer; +import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.database.DatabaseProvider; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Util; @@ -32,38 +34,44 @@ import java.io.FileOutputStream; import java.io.IOException; import java.util.NavigableSet; import java.util.Random; -import java.util.Set; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; /** Unit tests for {@link SimpleCache}. */ @RunWith(AndroidJUnit4.class) public class SimpleCacheTest { + private static final byte[] ENCRYPTED_INDEX_KEY = Util.getUtf8Bytes("Bar12345Bar12345"); private static final String KEY_1 = "key1"; private static final String KEY_2 = "key2"; + private File testDir; private File cacheDir; + private DatabaseProvider databaseProvider; @Before - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - cacheDir = Util.createTempFile(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - // Delete the file. SimpleCache initialization should create a directory with the same name. - assertThat(cacheDir.delete()).isTrue(); + public void createTestDir() throws Exception { + testDir = Util.createTempFile(ApplicationProvider.getApplicationContext(), "SimpleCacheTest"); + assertThat(testDir.delete()).isTrue(); + assertThat(testDir.mkdirs()).isTrue(); + cacheDir = new File(testDir, "cache"); + } + + @Before + public void createDatabaseProvider() { + databaseProvider = TestUtil.getInMemoryDatabaseProvider(); } @After - public void tearDown() { - Util.recursiveDelete(cacheDir); + public void deleteTestDir() { + Util.recursiveDelete(testDir); } @Test - public void cacheInitialization() { + public void newInstance_withEmptyDirectory() { SimpleCache cache = getSimpleCache(); // Cache initialization should have created a non-negative UID. @@ -76,10 +84,13 @@ public class SimpleCacheTest { cache.release(); cache = getSimpleCache(); assertThat(cache.getUid()).isEqualTo(uid); + + // Cache should be empty. + assertThat(cache.getKeys()).isEmpty(); } @Test - public void cacheInitializationError() throws IOException { + public void newInstance_withConflictingFile_fails() throws IOException { // Creating a file where the cache should be will cause an error during initialization. assertThat(cacheDir.createNewFile()).isTrue(); @@ -90,52 +101,346 @@ public class SimpleCacheTest { } @Test - public void committingOneFile() throws Exception { + @SuppressWarnings("deprecation") // Testing deprecated behaviour + public void newInstance_withExistingCacheDirectory_withoutDatabase_loadsCachedData() + throws Exception { + SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + + // Write some data and metadata to the cache. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setRedirectedUri(mutations, Uri.parse("https://redirect.google.com")); + simpleCache.applyContentMetadataMutations(KEY_1, mutations); + simpleCache.release(); + + // Create a new instance pointing to the same directory. + simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + + // Read the cached data and metadata back. + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertCachedDataReadCorrect(fileSpan); + assertThat(ContentMetadata.getRedirectedUri(simpleCache.getContentMetadata(KEY_1))) + .isEqualTo(Uri.parse("https://redirect.google.com")); + } + + @Test + public void newInstance_withExistingCacheDirectory_withDatabase_loadsCachedData() + throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - assertThat(cacheSpan1.isCached).isFalse(); - assertThat(cacheSpan1.isOpenEnded()).isTrue(); + // Write some data and metadata to the cache. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setRedirectedUri(mutations, Uri.parse("https://redirect.google.com")); + simpleCache.applyContentMetadataMutations(KEY_1, mutations); + simpleCache.release(); - assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 0)).isNull(); + // Create a new instance pointing to the same directory. + simpleCache = getSimpleCache(); - NavigableSet cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans.isEmpty()).isTrue(); - assertThat(simpleCache.getCacheSpace()).isEqualTo(0); + // Read the cached data and metadata back. + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertCachedDataReadCorrect(fileSpan); + assertThat(ContentMetadata.getRedirectedUri(simpleCache.getContentMetadata(KEY_1))) + .isEqualTo(Uri.parse("https://redirect.google.com")); + } + + @Test + public void newInstance_withExistingCacheInstance_fails() { + getSimpleCache(); + + // Instantiation should fail because the directory is locked by the first instance. + assertThrows(IllegalStateException.class, this::getSimpleCache); + } + + @Test + @SuppressWarnings("deprecation") // Testing deprecated behaviour + public void newInstance_withExistingCacheDirectory_withoutDatabase_resolvesInconsistentState() + throws Exception { + SimpleCache simpleCache = new SimpleCache(testDir, new NoOpCacheEvictor()); + + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.removeSpan(simpleCache.getCachedSpans(KEY_1).first()); + + // Don't release the cache. This means the index file won't have been written to disk after the + // span was removed. Move the cache directory instead, so we can reload it without failing the + // folder locking check. + File cacheDir2 = new File(testDir, "cache2"); + cacheDir.renameTo(cacheDir2); + + // Create a new instance pointing to the new directory. + simpleCache = new SimpleCache(cacheDir2, new NoOpCacheEvictor()); + + // The entry for KEY_1 should have been removed when the cache was reloaded. + assertThat(simpleCache.getCachedSpans(KEY_1)).isEmpty(); + } + + @Test + public void newInstance_withExistingCacheDirectory_withDatabase_resolvesInconsistentState() + throws Exception { + SimpleCache simpleCache = new SimpleCache(testDir, new NoOpCacheEvictor(), databaseProvider); + + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.removeSpan(simpleCache.getCachedSpans(KEY_1).first()); + + // Don't release the cache. This means the index file won't have been written to disk after the + // span was removed. Move the cache directory instead, so we can reload it without failing the + // folder locking check. + File cacheDir2 = new File(testDir, "cache2"); + cacheDir.renameTo(cacheDir2); + + // Create a new instance pointing to the new directory. + simpleCache = new SimpleCache(cacheDir2, new NoOpCacheEvictor(), databaseProvider); + + // The entry for KEY_1 should have been removed when the cache was reloaded. + assertThat(simpleCache.getCachedSpans(KEY_1)).isEmpty(); + } + + @Test + @SuppressWarnings("deprecation") // Encrypted index is deprecated + public void newInstance_withEncryptedIndex() throws Exception { + SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.release(); + + // Create a new instance pointing to the same directory. + simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + + // Read the cached data back. + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertCachedDataReadCorrect(fileSpan); + } + + @Test + @SuppressWarnings("deprecation") // Encrypted index is deprecated + public void newInstance_withEncryptedIndexAndWrongKey_clearsCache() throws Exception { + SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + + // Write data. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.release(); + + // Create a new instance pointing to the same directory, with a different key. + simpleCache = getEncryptedSimpleCache(Util.getUtf8Bytes("Foo12345Foo12345")); + + // Cache should be cleared. + assertThat(simpleCache.getKeys()).isEmpty(); assertNoCacheFiles(cacheDir); + } + @Test + @SuppressWarnings("deprecation") // Encrypted index is deprecated + public void newInstance_withEncryptedIndexAndNoKey_clearsCache() throws Exception { + SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); + + // Write data. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 15); + simpleCache.releaseHoleSpan(holeSpan); + simpleCache.release(); + + // Create a new instance pointing to the same directory, with no key. + simpleCache = getSimpleCache(); + + // Cache should be cleared. + assertThat(simpleCache.getKeys()).isEmpty(); + assertNoCacheFiles(cacheDir); + } + + @Test + public void write_oneLock_oneFile_thenRead() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(holeSpan.isCached).isFalse(); + assertThat(holeSpan.isOpenEnded()).isTrue(); addCache(simpleCache, KEY_1, 0, 15); - Set cachedKeys = simpleCache.getKeys(); - assertThat(cachedKeys).containsExactly(KEY_1); - cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans).contains(cacheSpan1); + CacheSpan readSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(readSpan.position).isEqualTo(0); + assertThat(readSpan.length).isEqualTo(15); + assertCachedDataReadCorrect(readSpan); assertThat(simpleCache.getCacheSpace()).isEqualTo(15); - simpleCache.releaseHoleSpan(cacheSpan1); - - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertThat(cacheSpan2.isCached).isTrue(); - assertThat(cacheSpan2.isOpenEnded()).isFalse(); - assertThat(cacheSpan2.length).isEqualTo(15); - assertCachedDataReadCorrect(cacheSpan2); + simpleCache.releaseHoleSpan(holeSpan); } @Test - public void readCacheWithoutReleasingWriteCacheSpan() throws Exception { + public void write_oneLock_twoFiles_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); - simpleCache.releaseHoleSpan(cacheSpan1); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 7); + addCache(simpleCache, KEY_1, 7, 8); + + CacheSpan readSpan1 = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(readSpan1.position).isEqualTo(0); + assertThat(readSpan1.length).isEqualTo(7); + assertCachedDataReadCorrect(readSpan1); + CacheSpan readSpan2 = simpleCache.startReadWrite(KEY_1, 7, LENGTH_UNSET); + assertThat(readSpan2.position).isEqualTo(7); + assertThat(readSpan2.length).isEqualTo(8); + assertCachedDataReadCorrect(readSpan2); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan); } @Test - public void setGetContentMetadata() throws Exception { + public void write_twoLocks_twoFiles_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0, 7); + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_1, 7, 8); + + addCache(simpleCache, KEY_1, 0, 7); + addCache(simpleCache, KEY_1, 7, 8); + + CacheSpan readSpan1 = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(readSpan1.position).isEqualTo(0); + assertThat(readSpan1.length).isEqualTo(7); + assertCachedDataReadCorrect(readSpan1); + CacheSpan readSpan2 = simpleCache.startReadWrite(KEY_1, 7, LENGTH_UNSET); + assertThat(readSpan2.position).isEqualTo(7); + assertThat(readSpan2.length).isEqualTo(8); + assertCachedDataReadCorrect(readSpan2); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); + } + + @Test + public void write_differentKeyLocked_thenRead() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_2, 0, LENGTH_UNSET); + assertThat(holeSpan2.isCached).isFalse(); + assertThat(holeSpan2.isOpenEnded()).isTrue(); + addCache(simpleCache, KEY_2, 0, 15); + + CacheSpan readSpan = simpleCache.startReadWrite(KEY_2, 0, LENGTH_UNSET); + assertThat(readSpan.length).isEqualTo(15); + assertCachedDataReadCorrect(readSpan); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); + } + + @Test + public void write_oneLock_fileExceedsLock_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, 10); + + assertThrows(IllegalStateException.class, () -> addCache(simpleCache, KEY_1, 0, 11)); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_twoLocks_oneFileSpanningBothLocks_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0, 7); + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_1, 7, 8); + + assertThrows(IllegalStateException.class, () -> addCache(simpleCache, KEY_1, 0, 15)); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); + } + + @Test + public void write_unboundedRangeLocked_lockingOverlappingRange_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 50, LENGTH_UNSET); + + // Overlapping cannot be locked. + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 49, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 99, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 0, LENGTH_UNSET)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 9, LENGTH_UNSET)).isNull(); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_unboundedRangeLocked_lockingNonOverlappingRange_succeeds() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 50, LENGTH_UNSET); + + // Non-overlapping range can be locked. + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_1, 0, 50); + assertThat(holeSpan2.isCached).isFalse(); + assertThat(holeSpan2.position).isEqualTo(0); + assertThat(holeSpan2.length).isEqualTo(50); + + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); + } + + @Test + public void write_boundedRangeLocked_lockingOverlappingRange_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 50, 50); + + // Overlapping cannot be locked. + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 49, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 99, 2)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 0, LENGTH_UNSET)).isNull(); + assertThat(simpleCache.startReadWriteNonBlocking(KEY_1, 99, LENGTH_UNSET)).isNull(); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_boundedRangeLocked_lockingNonOverlappingRange_succeeds() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 50, 50); + assertThat(holeSpan1.isCached).isFalse(); + assertThat(holeSpan1.length).isEqualTo(50); + + // Non-overlapping range can be locked. + CacheSpan holeSpan2 = simpleCache.startReadWriteNonBlocking(KEY_1, 49, 1); + assertThat(holeSpan2.isCached).isFalse(); + assertThat(holeSpan2.position).isEqualTo(49); + assertThat(holeSpan2.length).isEqualTo(1); + simpleCache.releaseHoleSpan(holeSpan2); + + CacheSpan holeSpan3 = simpleCache.startReadWriteNonBlocking(KEY_1, 100, 1); + assertThat(holeSpan3.isCached).isFalse(); + assertThat(holeSpan3.position).isEqualTo(100); + assertThat(holeSpan3.length).isEqualTo(1); + simpleCache.releaseHoleSpan(holeSpan3); + + CacheSpan holeSpan4 = simpleCache.startReadWriteNonBlocking(KEY_1, 100, LENGTH_UNSET); + assertThat(holeSpan4.isCached).isFalse(); + assertThat(holeSpan4.position).isEqualTo(100); + assertThat(holeSpan4.isOpenEnded()).isTrue(); + simpleCache.releaseHoleSpan(holeSpan4); + + simpleCache.releaseHoleSpan(holeSpan1); + } + + @Test + public void applyContentMetadataMutations_setsContentLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) .isEqualTo(LENGTH_UNSET); @@ -144,183 +449,203 @@ public class SimpleCacheTest { simpleCache.applyContentMetadataMutations(KEY_1, mutations); assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) .isEqualTo(15); - - simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - - mutations = new ContentMetadataMutations(); - ContentMetadataMutations.setContentLength(mutations, 150); - simpleCache.applyContentMetadataMutations(KEY_1, mutations); - assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) - .isEqualTo(150); - - addCache(simpleCache, KEY_1, 140, 10); - - simpleCache.release(); - - // Check if values are kept after cache is reloaded. - SimpleCache simpleCache2 = getSimpleCache(); - assertThat(ContentMetadata.getContentLength(simpleCache2.getContentMetadata(KEY_1))) - .isEqualTo(150); - - // Removing the last span shouldn't cause the length be change next time cache loaded - CacheSpan lastSpan = simpleCache2.startReadWrite(KEY_1, 145); - simpleCache2.removeSpan(lastSpan); - simpleCache2.release(); - simpleCache2 = getSimpleCache(); - assertThat(ContentMetadata.getContentLength(simpleCache2.getContentMetadata(KEY_1))) - .isEqualTo(150); } @Test - public void reloadCache() throws Exception { + public void removeSpans_removesSpansWithSameKey() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, 0, 10); + addCache(simpleCache, KEY_1, 20, 10); + simpleCache.releaseHoleSpan(holeSpan); + holeSpan = simpleCache.startReadWrite(KEY_2, 20, LENGTH_UNSET); + addCache(simpleCache, KEY_2, 20, 10); + simpleCache.releaseHoleSpan(holeSpan); + + simpleCache.removeResource(KEY_1); + assertThat(simpleCache.getCachedSpans(KEY_1)).isEmpty(); + assertThat(simpleCache.getCachedSpans(KEY_2)).hasSize(1); + } + + @Test + public void getCachedLength_noCachedContent_returnsNegativeMaxHoleLength() { SimpleCache simpleCache = getSimpleCache(); - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - simpleCache = getSimpleCache(); - - // read data back - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); + 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= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(-Long.MAX_VALUE); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-100); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-Long.MAX_VALUE); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(-Long.MAX_VALUE); } @Test - public void reloadCacheWithoutRelease() throws Exception { + public void getCachedLength_returnsNegativeHoleLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, /* position= */ 50, /* length= */ 50); + simpleCache.releaseHoleSpan(holeSpan); + + 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= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(-50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(-30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(-30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(-30); + } + + @Test + public void getCachedLength_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 50); + simpleCache.releaseHoleSpan(holeSpan); + + 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= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); + addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); + simpleCache.releaseHoleSpan(holeSpan); + + 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= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(50); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedLength_withMultipleNonAdjacentSpans_returnsCachedLength() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); + addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); + simpleCache.releaseHoleSpan(holeSpan); + + 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= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(10); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); + assertThat(simpleCache.getCachedLength(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } + + @Test + public void getCachedBytes_noCachedContent_returnsZero() { SimpleCache simpleCache = getSimpleCache(); - // Write data for KEY_1. - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - // Write and remove data for KEY_2. - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_2, 0); - addCache(simpleCache, KEY_2, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan2); - simpleCache.removeSpan(simpleCache.getCachedSpans(KEY_2).first()); - - // Don't release the cache. This means the index file won't have been written to disk after the - // data for KEY_2 was removed. Move the cache instead, so we can reload it without failing the - // folder locking check. - File cacheDir2 = - Util.createTempFile(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cacheDir2.delete(); - cacheDir.renameTo(cacheDir2); - - // Reload the cache from its new location. - simpleCache = new SimpleCache(cacheDir2, new NoOpCacheEvictor()); - - // Read data back for KEY_1. - CacheSpan cacheSpan3 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan3); - - // Check the entry for KEY_2 was removed when the cache was reloaded. - assertThat(simpleCache.getCachedSpans(KEY_2)).isEmpty(); - - Util.recursiveDelete(cacheDir2); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(0); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(0); } @Test - public void encryptedIndex() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); - - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - simpleCache = getEncryptedSimpleCache(key); - - // read data back - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); - } - - @Test - public void encryptedIndexWrongKey() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); - - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - byte[] key2 = Util.getUtf8Bytes("Foo12345Foo12345"); // 128 bit key - simpleCache = getEncryptedSimpleCache(key2); - - // Cache should be cleared - assertThat(simpleCache.getKeys()).isEmpty(); - assertNoCacheFiles(cacheDir); - } - - @Test - public void encryptedIndexLostKey() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); - - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); - - // Reload cache - simpleCache = getSimpleCache(); - - // Cache should be cleared - assertThat(simpleCache.getKeys()).isEmpty(); - assertNoCacheFiles(cacheDir); - } - - @Test - public void getCachedLength() throws Exception { + public void getCachedBytes_withMultipleAdjacentSpans_returnsCachedBytes() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 25); + addCache(simpleCache, KEY_1, /* position= */ 25, /* length= */ 25); + simpleCache.releaseHoleSpan(holeSpan); - // No cached bytes, returns -'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(-100); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(50); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(50); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(50); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 15)) + .isEqualTo(15); + } - // Position value doesn't affect the return value - assertThat(simpleCache.getCachedLength(KEY_1, 20, 100)).isEqualTo(-100); + @Test + public void getCachedBytes_withMultipleNonAdjacentSpans_returnsCachedBytes() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, /* position= */ 0, LENGTH_UNSET); + addCache(simpleCache, KEY_1, /* position= */ 0, /* length= */ 10); + addCache(simpleCache, KEY_1, /* position= */ 15, /* length= */ 35); + simpleCache.releaseHoleSpan(holeSpan); - 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); - - simpleCache.releaseHoleSpan(cacheSpan); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ 100)) + .isEqualTo(45); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ Long.MAX_VALUE)) + .isEqualTo(45); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 0, /* length= */ LENGTH_UNSET)) + .isEqualTo(45); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 100)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ Long.MAX_VALUE)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ LENGTH_UNSET)) + .isEqualTo(30); + assertThat(simpleCache.getCachedBytes(KEY_1, /* position= */ 20, /* length= */ 10)) + .isEqualTo(10); } /* Tests https://github.com/google/ExoPlayer/issues/3260 case. */ @Test - public void exceptionDuringEvictionByLeastRecentlyUsedCacheEvictorNotHang() throws Exception { + public void exceptionDuringIndexStore_doesNotPreventEviction() throws Exception { CachedContentIndex contentIndex = Mockito.spy(new CachedContentIndex(TestUtil.getInMemoryDatabaseProvider())); SimpleCache simpleCache = @@ -328,7 +653,7 @@ public class SimpleCacheTest { cacheDir, new LeastRecentlyUsedCacheEvictor(20), contentIndex, /* fileIndex= */ null); // Add some content. - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); // Make index.store() throw exception from now on. @@ -339,61 +664,33 @@ public class SimpleCacheTest { .when(contentIndex) .store(); - // Adding more content will make LeastRecentlyUsedCacheEvictor evict previous content. - try { - addCache(simpleCache, KEY_1, 15, 15); - assertWithMessage("Exception was expected").fail(); - } catch (CacheException e) { - // do nothing. - } + // Adding more content should evict previous content. + assertThrows(CacheException.class, () -> addCache(simpleCache, KEY_1, 15, 15)); + simpleCache.releaseHoleSpan(holeSpan); - simpleCache.releaseHoleSpan(cacheSpan); - - // Although store() has failed, it should remove the first span and add the new one. + // Although store() failed, the first span should have been removed and the new one added. NavigableSet cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans).isNotEmpty(); assertThat(cachedSpans).hasSize(1); - assertThat(cachedSpans.pollFirst().position).isEqualTo(15); + CacheSpan fileSpan = cachedSpans.first(); + assertThat(fileSpan.position).isEqualTo(15); + assertThat(fileSpan.length).isEqualTo(15); } @Test - public void usingReleasedSimpleCacheThrowsException() throws Exception { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + public void usingReleasedCache_throwsException() { + SimpleCache simpleCache = getSimpleCache(); simpleCache.release(); - - try { - simpleCache.startReadWriteNonBlocking(KEY_1, 0); - assertWithMessage("Exception was expected").fail(); - } catch (RuntimeException e) { - // Expected. Do nothing. - } - } - - @Test - public void multipleSimpleCacheWithSameCacheDirThrowsException() throws Exception { - new SimpleCache(cacheDir, new NoOpCacheEvictor()); - - try { - new SimpleCache(cacheDir, new NoOpCacheEvictor()); - assertWithMessage("Exception was expected").fail(); - } catch (IllegalStateException e) { - // Expected. Do nothing. - } - } - - @Test - public void multipleSimpleCacheWithSameCacheDirDoesNotThrowsExceptionAfterRelease() - throws Exception { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); - simpleCache.release(); - - new SimpleCache(cacheDir, new NoOpCacheEvictor()); + assertThrows( + IllegalStateException.class, + () -> simpleCache.startReadWriteNonBlocking(KEY_1, 0, LENGTH_UNSET)); } private SimpleCache getSimpleCache() { - return new SimpleCache(cacheDir, new NoOpCacheEvictor()); + return new SimpleCache(cacheDir, new NoOpCacheEvictor(), databaseProvider); } + @Deprecated + @SuppressWarnings("deprecation") // Testing deprecated behaviour. private SimpleCache getEncryptedSimpleCache(byte[] secretKey) { return new SimpleCache(cacheDir, new NoOpCacheEvictor(), secretKey); } @@ -431,8 +728,7 @@ public class SimpleCacheTest { private static byte[] generateData(String key, int position, int length) { byte[] bytes = new byte[length]; - new Random((long) (key.hashCode() ^ position)).nextBytes(bytes); + new Random(key.hashCode() ^ position).nextBytes(bytes); return bytes; } - } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java b/library/core/src/test/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java index 17e69db26b..9af0710e9b 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipherTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.upstream.crypto; import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.min; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.TestUtil; @@ -100,7 +101,7 @@ public class AesFlushingCipherTest { int offset = 0; while (offset < data.length) { int bytes = (1 + random.nextInt(50)) * 16; - bytes = Math.min(bytes, data.length - offset); + bytes = min(bytes, data.length - offset); assertThat(bytes % 16).isEqualTo(0); encryptCipher.updateInPlace(data, offset, bytes); offset += bytes; @@ -113,7 +114,7 @@ public class AesFlushingCipherTest { offset = 0; while (offset < data.length) { int bytes = (1 + random.nextInt(50)) * 16; - bytes = Math.min(bytes, data.length - offset); + bytes = min(bytes, data.length - offset); assertThat(bytes % 16).isEqualTo(0); decryptCipher.updateInPlace(data, offset, bytes); offset += bytes; @@ -134,7 +135,7 @@ public class AesFlushingCipherTest { int offset = 0; while (offset < data.length) { int bytes = 1 + random.nextInt(4095); - bytes = Math.min(bytes, data.length - offset); + bytes = min(bytes, data.length - offset); encryptCipher.updateInPlace(data, offset, bytes); offset += bytes; } @@ -146,7 +147,7 @@ public class AesFlushingCipherTest { offset = 0; while (offset < data.length) { int bytes = 1 + random.nextInt(4095); - bytes = Math.min(bytes, data.length - offset); + bytes = min(bytes, data.length - offset); decryptCipher.updateInPlace(data, offset, bytes); offset += bytes; } @@ -166,7 +167,7 @@ public class AesFlushingCipherTest { int offset = 0; while (offset < data.length) { int bytes = 1 + random.nextInt(4095); - bytes = Math.min(bytes, data.length - offset); + bytes = min(bytes, data.length - offset); encryptCipher.updateInPlace(data, offset, bytes); offset += bytes; } @@ -185,7 +186,7 @@ public class AesFlushingCipherTest { // Decrypt while (remainingLength > 0) { int bytes = 1 + random.nextInt(4095); - bytes = Math.min(bytes, remainingLength); + bytes = min(bytes, remainingLength); decryptCipher.updateInPlace(data, offset, bytes); offset += bytes; remainingLength -= bytes; diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java index 8f2fb2ed14..e7e0d8911a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java @@ -49,34 +49,7 @@ public class ConditionVariableTest { } @Test - public void blockWithoutTimeout_blocks() throws InterruptedException { - ConditionVariable conditionVariable = buildTestConditionVariable(); - - AtomicBoolean blockReturned = new AtomicBoolean(); - AtomicBoolean blockWasInterrupted = new AtomicBoolean(); - Thread blockingThread = - new Thread( - () -> { - try { - conditionVariable.block(); - blockReturned.set(true); - } catch (InterruptedException e) { - blockWasInterrupted.set(true); - } - }); - - blockingThread.start(); - Thread.sleep(500); - assertThat(blockReturned.get()).isFalse(); - - blockingThread.interrupt(); - blockingThread.join(); - assertThat(blockWasInterrupted.get()).isTrue(); - assertThat(conditionVariable.isOpen()).isFalse(); - } - - @Test - public void blockWithMaxTimeout_blocks() throws InterruptedException { + public void blockWithMaxTimeout_blocks_thenThrowsWhenInterrupted() throws InterruptedException { ConditionVariable conditionVariable = buildTestConditionVariable(); AtomicBoolean blockReturned = new AtomicBoolean(); @@ -103,7 +76,34 @@ public class ConditionVariableTest { } @Test - public void open_unblocksBlock() throws InterruptedException { + public void block_blocks_thenThrowsWhenInterrupted() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + blockingThread.join(); + assertThat(blockWasInterrupted.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void block_blocks_thenReturnsWhenOpened() throws InterruptedException { ConditionVariable conditionVariable = buildTestConditionVariable(); AtomicBoolean blockReturned = new AtomicBoolean(); @@ -129,6 +129,37 @@ public class ConditionVariableTest { assertThat(conditionVariable.isOpen()).isTrue(); } + @Test + public void blockUnterruptible_blocksIfInterrupted_thenUnblocksWhenOpened() + throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean interruptedStatusSet = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + conditionVariable.blockUninterruptible(); + blockReturned.set(true); + interruptedStatusSet.set(Thread.currentThread().isInterrupted()); + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + Thread.sleep(500); + // blockUninterruptible should still be blocked. + assertThat(blockReturned.get()).isFalse(); + + conditionVariable.open(); + blockingThread.join(); + // blockUninterruptible should have set the thread's interrupted status on exit. + assertThat(interruptedStatusSet.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isTrue(); + } + private static ConditionVariable buildTestConditionVariable() { return new ConditionVariable( new SystemClock() { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java deleted file mode 100644 index 8d110a8776..0000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.google.android.exoplayer2.util; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; - -import android.os.Handler; -import android.os.Looper; -import androidx.annotation.Nullable; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.drm.DrmSessionEventListener; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; -import com.google.android.exoplayer2.source.MediaSourceEventListener; -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; - -/** Tests for {@link MediaSourceEventDispatcher}. */ -@RunWith(AndroidJUnit4.class) -public class MediaSourceEventDispatcherTest { - - private static final MediaSource.MediaPeriodId MEDIA_PERIOD_ID = - new MediaSource.MediaPeriodId("test uid"); - private static final int WINDOW_INDEX = 200; - private static final int MEDIA_TIME_OFFSET_MS = 1_000; - - @Rule public final MockitoRule mockito = MockitoJUnit.rule(); - - @Mock private MediaSourceEventListener mediaSourceEventListener; - @Mock private MediaAndDrmEventListener mediaAndDrmEventListener; - - private MediaSourceEventDispatcher eventDispatcher; - - @Before - public void setupEventDispatcher() { - eventDispatcher = new MediaSourceEventDispatcher(); - eventDispatcher = - eventDispatcher.withParameters(WINDOW_INDEX, MEDIA_PERIOD_ID, MEDIA_TIME_OFFSET_MS); - } - - @Test - public void listenerReceivesEventPopulatedWithMediaPeriodInfo() { - eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - - verify(mediaSourceEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - } - - @Test - public void sameListenerObjectRegisteredTwiceOnlyReceivesEventsOnce() { - eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); - eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - - verify(mediaSourceEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - } - - @Test - public void sameListenerInstanceCanBeRegisteredWithTwoTypes() { - eventDispatcher.addEventListener( - new Handler(Looper.getMainLooper()), - mediaAndDrmEventListener, - MediaSourceEventListener.class); - eventDispatcher.addEventListener( - new Handler(Looper.getMainLooper()), - mediaAndDrmEventListener, - DrmSessionEventListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysLoaded, DrmSessionEventListener.class); - - verify(mediaAndDrmEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - verify(mediaAndDrmEventListener).onDrmKeysLoaded(WINDOW_INDEX, MEDIA_PERIOD_ID); - } - - // If a listener is added that implements multiple types, it should only receive events for the - // type specified at registration time. - @Test - public void listenerOnlyReceivesEventsForRegisteredType() { - eventDispatcher.addEventListener( - new Handler(Looper.getMainLooper()), - mediaAndDrmEventListener, - MediaSourceEventListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - eventDispatcher.dispatch( - DrmSessionEventListener::onDrmKeysLoaded, DrmSessionEventListener.class); - - verify(mediaAndDrmEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - verify(mediaAndDrmEventListener, never()).onDrmKeysLoaded(WINDOW_INDEX, MEDIA_PERIOD_ID); - } - - @Test - public void listenerDoesntReceiveEventsDispatchedToSubclass() { - SubclassListener subclassListener = mock(SubclassListener.class); - eventDispatcher.addEventListener( - new Handler(Looper.getMainLooper()), subclassListener, MediaSourceEventListener.class); - - eventDispatcher.dispatch(SubclassListener::subclassMethod, SubclassListener.class); - - // subclassListener can handle the call to subclassMethod, but it isn't called because - // it was registered 'as-a' MediaSourceEventListener, not SubclassListener. - verify(subclassListener, never()).subclassMethod(anyInt(), any()); - } - - @Test - public void listenerDoesntReceiveEventsDispatchedToSuperclass() { - SubclassListener subclassListener = mock(SubclassListener.class); - eventDispatcher.addEventListener( - new Handler(Looper.getMainLooper()), subclassListener, SubclassListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - - // subclassListener 'is-a' a MediaSourceEventListener, but it isn't called because the event - // is dispatched specifically to listeners registered as MediaSourceEventListener. - verify(subclassListener, never()).onMediaPeriodCreated(anyInt(), any()); - } - - @Test - public void listenersAreCopiedToNewDispatcher() { - eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); - - MediaSource.MediaPeriodId newPeriodId = new MediaSource.MediaPeriodId("different uid"); - MediaSourceEventDispatcher newEventDispatcher = - this.eventDispatcher.withParameters( - /* windowIndex= */ 250, newPeriodId, /* mediaTimeOffsetMs= */ 500); - - newEventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - - verify(mediaSourceEventListener).onMediaPeriodCreated(250, newPeriodId); - } - - @Test - public void removingListenerStopsEventDispatch() { - eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); - eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - - verify(mediaSourceEventListener, never()).onMediaPeriodCreated(anyInt(), any()); - } - - @Test - public void removingListenerWithDifferentTypeToRegistrationDoesntRemove() { - eventDispatcher.addEventListener( - Util.createHandler(), mediaAndDrmEventListener, MediaSourceEventListener.class); - eventDispatcher.removeEventListener(mediaAndDrmEventListener, DrmSessionEventListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - - verify(mediaAndDrmEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - } - - @Test - public void listenersAreCountedBasedOnListenerAndType() { - // Add the listener twice and remove it once. - eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); - eventDispatcher.addEventListener( - Util.createHandler(), mediaSourceEventListener, MediaSourceEventListener.class); - eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); - - eventDispatcher.dispatch( - MediaSourceEventListener::onMediaPeriodCreated, MediaSourceEventListener.class); - - verify(mediaSourceEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - - // Remove it a second time and confirm the events stop being propagated. - eventDispatcher.removeEventListener(mediaSourceEventListener, MediaSourceEventListener.class); - - verifyNoMoreInteractions(mediaSourceEventListener); - } - - private interface MediaAndDrmEventListener - extends MediaSourceEventListener, DrmSessionEventListener {} - - private interface SubclassListener extends MediaSourceEventListener { - void subclassMethod(int windowIndex, @Nullable MediaPeriodId mediaPeriodId); - } -} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/RunnableFutureTaskTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/RunnableFutureTaskTest.java new file mode 100644 index 0000000000..9a8aac3020 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/RunnableFutureTaskTest.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link RunnableFutureTask}. */ +@RunWith(AndroidJUnit4.class) +public class RunnableFutureTaskTest { + + @Test + public void blockUntilStarted_ifNotStarted_blocks() throws InterruptedException { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + + AtomicBoolean blockUntilStartedReturned = new AtomicBoolean(); + Thread testThread = + new Thread() { + @Override + public void run() { + task.blockUntilStarted(); + blockUntilStartedReturned.set(true); + } + }; + testThread.start(); + + Thread.sleep(1000); + assertThat(blockUntilStartedReturned.get()).isFalse(); + + // Thread cleanup. + task.run(); + testThread.join(); + } + + @Test(timeout = 1000) + public void blockUntilStarted_ifStarted_unblocks() throws InterruptedException { + ConditionVariable finish = new ConditionVariable(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + finish.blockUninterruptible(); + return null; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + task.blockUntilStarted(); // Should unblock. + + // Thread cleanup. + finish.open(); + testThread.join(); + } + + @Test(timeout = 1000) + public void blockUntilStarted_ifCanceled_unblocks() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + + task.cancel(/* interruptIfRunning= */ false); + + // Should not block. + task.blockUntilStarted(); + } + + @Test + public void blockUntilFinished_ifNotFinished_blocks() throws InterruptedException { + ConditionVariable finish = new ConditionVariable(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + finish.blockUninterruptible(); + return null; + } + }; + Thread testThread1 = new Thread(task); + testThread1.start(); + + AtomicBoolean blockUntilFinishedReturned = new AtomicBoolean(); + Thread testThread2 = + new Thread() { + @Override + public void run() { + task.blockUntilFinished(); + blockUntilFinishedReturned.set(true); + } + }; + testThread2.start(); + + Thread.sleep(1000); + assertThat(blockUntilFinishedReturned.get()).isFalse(); + + // Thread cleanup. + finish.open(); + testThread1.join(); + testThread2.join(); + } + + @Test(timeout = 1000) + public void blockUntilFinished_ifFinished_unblocks() throws InterruptedException { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + task.blockUntilFinished(); + assertThat(task.isDone()).isTrue(); + + // Thread cleanup. + testThread.join(); + } + + @Test(timeout = 1000) + public void blockUntilFinished_ifCanceled_unblocks() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + + task.cancel(/* interruptIfRunning= */ false); + + // Should not block. + task.blockUntilFinished(); + } + + @Test + public void get_ifNotFinished_blocks() throws InterruptedException { + ConditionVariable finish = new ConditionVariable(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + finish.blockUninterruptible(); + return null; + } + }; + Thread testThread1 = new Thread(task); + testThread1.start(); + + AtomicBoolean blockUntilGetResultReturned = new AtomicBoolean(); + Thread testThread2 = + new Thread() { + @Override + public void run() { + try { + task.get(); + } catch (ExecutionException | InterruptedException e) { + // Do nothing. + } finally { + blockUntilGetResultReturned.set(true); + } + } + }; + testThread2.start(); + + Thread.sleep(1000); + assertThat(blockUntilGetResultReturned.get()).isFalse(); + + // Thread cleanup. + finish.open(); + testThread1.join(); + testThread2.join(); + } + + @Test(timeout = 1000) + public void get_returnsResult() throws ExecutionException, InterruptedException { + Object result = new Object(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() { + return result; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + assertThat(task.get()).isSameInstanceAs(result); + + // Thread cleanup. + testThread.join(); + } + + @Test(timeout = 1000) + public void get_throwsExecutionException_containsIOException() throws InterruptedException { + IOException exception = new IOException(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() throws IOException { + throw exception; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + ExecutionException executionException = assertThrows(ExecutionException.class, task::get); + assertThat(executionException).hasCauseThat().isSameInstanceAs(exception); + + // Thread cleanup. + testThread.join(); + } + + @Test(timeout = 1000) + public void get_throwsExecutionException_containsRuntimeException() throws InterruptedException { + RuntimeException exception = new RuntimeException(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() { + throw exception; + } + }; + Thread testThread = new Thread(task); + testThread.start(); + + ExecutionException executionException = assertThrows(ExecutionException.class, task::get); + assertThat(executionException).hasCauseThat().isSameInstanceAs(exception); + + // Thread cleanup. + testThread.join(); + } + + @Test + public void run_throwsError() { + Error error = new Error(); + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Object doWork() { + throw error; + } + }; + Error thrownError = assertThrows(Error.class, task::run); + assertThat(thrownError).isSameInstanceAs(error); + } + + @Test + public void cancel_whenNotStarted_returnsTrue() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + assertThat(task.cancel(/* interruptIfRunning= */ false)).isTrue(); + } + + @Test + public void cancel_whenCanceled_returnsFalse() { + RunnableFutureTask task = + new RunnableFutureTask() { + @Override + protected Void doWork() { + return null; + } + }; + task.cancel(/* interruptIfRunning= */ false); + assertThat(task.cancel(/* interruptIfRunning= */ false)).isFalse(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/TimedValueQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/TimedValueQueueTest.java index 8f1949f96e..0334027234 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/TimedValueQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/TimedValueQueueTest.java @@ -33,23 +33,13 @@ public class TimedValueQueueTest { queue = new TimedValueQueue<>(); } - @Test - public void addAndPollValues() { - queue.add(0, "a"); - queue.add(1, "b"); - queue.add(2, "c"); - assertThat(queue.poll(0)).isEqualTo("a"); - assertThat(queue.poll(1)).isEqualTo("b"); - assertThat(queue.poll(2)).isEqualTo("c"); - } - @Test public void bufferCapacityIncreasesAutomatically() { queue = new TimedValueQueue<>(1); for (int i = 0; i < 20; i++) { queue.add(i, "" + i); if ((i & 1) == 1) { - assertThat(queue.poll(0)).isEqualTo("" + (i / 2)); + assertThat(queue.pollFirst()).isEqualTo("" + (i / 2)); } } assertThat(queue.size()).isEqualTo(10); @@ -61,7 +51,7 @@ public class TimedValueQueueTest { queue.add(2, "c"); queue.add(0, "a"); assertThat(queue.size()).isEqualTo(1); - assertThat(queue.poll(0)).isEqualTo("a"); + assertThat(queue.pollFirst()).isEqualTo("a"); } @Test @@ -71,7 +61,37 @@ public class TimedValueQueueTest { queue.add(3, "c"); queue.add(2, "a"); assertThat(queue.size()).isEqualTo(1); - assertThat(queue.poll(2)).isEqualTo("a"); + assertThat(queue.pollFirst()).isEqualTo("a"); + } + + @Test + public void pollFirstReturnsValues() { + queue.add(0, "a"); + queue.add(1, "b"); + queue.add(2, "c"); + assertThat(queue.pollFirst()).isEqualTo("a"); + assertThat(queue.size()).isEqualTo(2); + assertThat(queue.pollFirst()).isEqualTo("b"); + assertThat(queue.size()).isEqualTo(1); + assertThat(queue.pollFirst()).isEqualTo("c"); + assertThat(queue.size()).isEqualTo(0); + assertThat(queue.pollFirst()).isEqualTo(null); + assertThat(queue.size()).isEqualTo(0); + } + + @Test + public void pollReturnsValues() { + queue.add(0, "a"); + queue.add(1, "b"); + queue.add(2, "c"); + assertThat(queue.poll(0)).isEqualTo("a"); + assertThat(queue.size()).isEqualTo(2); + assertThat(queue.poll(1)).isEqualTo("b"); + assertThat(queue.size()).isEqualTo(1); + assertThat(queue.poll(2)).isEqualTo("c"); + assertThat(queue.size()).isEqualTo(0); + assertThat(queue.pollFirst()).isEqualTo(null); + assertThat(queue.size()).isEqualTo(0); } @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java index f4aee42f25..57cc7cb9b0 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/DecoderVideoRendererTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.video; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -24,19 +25,25 @@ import android.graphics.SurfaceTexture; import android.os.Handler; import android.os.SystemClock; import android.view.Surface; -import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RendererConfiguration; import com.google.android.exoplayer2.decoder.DecoderException; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.decoder.SimpleDecoder; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.ExoMediaCrypto; import com.google.android.exoplayer2.testutil.FakeSampleStream; import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.util.concurrent.Phaser; +import org.junit.After; import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -45,11 +52,9 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowLooper; /** Unit test for {@link DecoderVideoRenderer}. */ -@LooperMode(LooperMode.Mode.PAUSED) @RunWith(AndroidJUnit4.class) public final class DecoderVideoRendererTest { @Rule public final MockitoRule mockito = MockitoJUnit.rule(); @@ -73,11 +78,7 @@ public final class DecoderVideoRendererTest { eventListener, /* maxDroppedFramesToNotify= */ -1) { - private final Object pendingDecodeCallLock = new Object(); - - @GuardedBy("pendingDecodeCallLock") - private int pendingDecodeCalls; - + private final Phaser inputBuffersInCodecPhaser = new Phaser(); @C.VideoOutputMode private int outputMode; @Override @@ -104,29 +105,17 @@ public final class DecoderVideoRendererTest { @Override protected void onQueueInputBuffer(VideoDecoderInputBuffer buffer) { - // SimpleDecoder.decode() is called on a background thread we have no control about from - // the test. Ensure the background calls are predictably serialized by waiting for them - // to finish: - // 1. Mark decode calls as "pending" here. - // 2. Send a message on the test thread to wait for all pending decode calls. - // 3. Decrement the pending counter in decode calls and wake up the waiting test. - // 4. The tests need to call ShadowLooper.idleMainThread() to wait for pending calls. - synchronized (pendingDecodeCallLock) { - pendingDecodeCalls++; - } - new Handler() - .post( - () -> { - synchronized (pendingDecodeCallLock) { - while (pendingDecodeCalls > 0) { - try { - pendingDecodeCallLock.wait(); - } catch (InterruptedException e) { - // Ignore. - } - } - } - }); + // Decoding is done on a background thread we have no control about from the test. + // Ensure the background calls are predictably serialized by waiting for them to finish: + // 1. Register queued input buffers here. + // 2. Deregister the input buffer when it's cleared. If an input buffer is cleared it + // will have been fully handled by the decoder. + // 3. Send a message on the test thread to wait for all currently pending input buffers + // to be cleared. + // 4. The tests need to call ShadowLooper.idleMainThread() to execute the wait message + // sent in step (3). + int currentPhase = inputBuffersInCodecPhaser.register(); + new Handler().post(() -> inputBuffersInCodecPhaser.awaitAdvance(currentPhase)); super.onQueueInputBuffer(buffer); } @@ -141,7 +130,14 @@ public final class DecoderVideoRendererTest { new VideoDecoderInputBuffer[10], new VideoDecoderOutputBuffer[10]) { @Override protected VideoDecoderInputBuffer createInputBuffer() { - return new VideoDecoderInputBuffer(); + return new VideoDecoderInputBuffer( + DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT) { + @Override + public void clear() { + super.clear(); + inputBuffersInCodecPhaser.arriveAndDeregister(); + } + }; } @Override @@ -161,10 +157,6 @@ public final class DecoderVideoRendererTest { VideoDecoderOutputBuffer outputBuffer, boolean reset) { outputBuffer.init(inputBuffer.timeUs, outputMode, /* supplementalData= */ null); - synchronized (pendingDecodeCallLock) { - pendingDecodeCalls--; - pendingDecodeCallLock.notify(); - } return null; } @@ -178,15 +170,25 @@ public final class DecoderVideoRendererTest { renderer.setOutputSurface(new Surface(new SurfaceTexture(/* texName= */ 0))); } + @After + public void shutDown() throws Exception { + if (renderer.getState() == Renderer.STATE_STARTED) { + renderer.stop(); + } + if (renderer.getState() == Renderer.STATE_ENABLED) { + renderer.disable(); + } + } + @Test public void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ H264_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ H264_FORMAT, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); renderer.enable( RendererConfiguration.DEFAULT, @@ -195,6 +197,7 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0L, /* offsetUs */ 0); for (int i = 0; i < 10; i++) { renderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); @@ -210,11 +213,11 @@ public final class DecoderVideoRendererTest { throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ H264_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ H264_FORMAT, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); renderer.enable( RendererConfiguration.DEFAULT, @@ -223,6 +226,7 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); for (int i = 0; i < 10; i++) { renderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); @@ -237,11 +241,11 @@ public final class DecoderVideoRendererTest { public void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() throws Exception { FakeSampleStream fakeSampleStream = new FakeSampleStream( - /* format= */ H264_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME)); + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ H264_FORMAT, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); renderer.enable( RendererConfiguration.DEFAULT, @@ -250,6 +254,7 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); renderer.start(); for (int i = 0; i < 10; i++) { @@ -267,20 +272,20 @@ public final class DecoderVideoRendererTest { public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( - /* format= */ H264_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); FakeSampleStream fakeSampleStream2 = new FakeSampleStream( - /* format= */ H264_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); renderer.enable( RendererConfiguration.DEFAULT, new Format[] {H264_FORMAT}, @@ -288,6 +293,7 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); renderer.start(); @@ -295,7 +301,11 @@ public final class DecoderVideoRendererTest { for (int i = 0; i <= 10; i++) { renderer.render(/* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); if (!replacedStream && renderer.hasReadStreamToEnd()) { - renderer.replaceStream(new Format[] {H264_FORMAT}, fakeSampleStream2, /* offsetUs= */ 100); + renderer.replaceStream( + new Format[] {H264_FORMAT}, + fakeSampleStream2, + /* startPositionUs= */ 100, + /* offsetUs= */ 100); replacedStream = true; } // Ensure pending messages are delivered. @@ -311,20 +321,20 @@ public final class DecoderVideoRendererTest { public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { FakeSampleStream fakeSampleStream1 = new FakeSampleStream( - /* format= */ H264_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); FakeSampleStream fakeSampleStream2 = new FakeSampleStream( - /* format= */ H264_FORMAT, - /* eventDispatcher= */ null, - /* firstSampleTimeUs= */ 0, - /* timeUsIncrement= */ 50, - new FakeSampleStreamItem(new byte[] {0}, C.BUFFER_FLAG_KEY_FRAME), - FakeSampleStreamItem.END_OF_STREAM_ITEM); + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ H264_FORMAT, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0), FakeSampleStreamItem.END_OF_STREAM_ITEM)); renderer.enable( RendererConfiguration.DEFAULT, new Format[] {H264_FORMAT}, @@ -332,13 +342,18 @@ public final class DecoderVideoRendererTest { /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); boolean replacedStream = false; for (int i = 0; i < 10; i++) { renderer.render(/* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); if (!replacedStream && renderer.hasReadStreamToEnd()) { - renderer.replaceStream(new Format[] {H264_FORMAT}, fakeSampleStream2, /* offsetUs= */ 100); + renderer.replaceStream( + new Format[] {H264_FORMAT}, + fakeSampleStream2, + /* startPositionUs= */ 100, + /* offsetUs= */ 100); replacedStream = true; } // Ensure pending messages are delivered. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java new file mode 100644 index 0000000000..4ba5eb34b1 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/MediaCodecVideoRendererTest.java @@ -0,0 +1,476 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.video; + +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.format; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.robolectric.Shadows.shadowOf; + +import android.graphics.SurfaceTexture; +import android.media.MediaFormat; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.view.Surface; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import com.google.android.exoplayer2.testutil.FakeSampleStream; +import com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.util.Collections; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.shadows.ShadowLooper; + +/** Unit test for {@link MediaCodecVideoRenderer}. */ +@RunWith(AndroidJUnit4.class) +public class MediaCodecVideoRendererTest { + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private static final Format VIDEO_H264 = + new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setWidth(1920) + .setHeight(1080) + .build(); + + private Looper testMainLooper; + private MediaCodecVideoRenderer mediaCodecVideoRenderer; + @Nullable private Format currentOutputFormat; + + @Mock private VideoRendererEventListener eventListener; + + @Before + public void setUp() throws Exception { + testMainLooper = Looper.getMainLooper(); + MediaCodecSelector mediaCodecSelector = + (mimeType, requiresSecureDecoder, requiresTunnelingDecoder) -> + Collections.singletonList( + MediaCodecInfo.newInstance( + /* name= */ "name", + /* mimeType= */ mimeType, + /* codecMimeType= */ mimeType, + /* capabilities= */ null, + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false)); + + mediaCodecVideoRenderer = + new MediaCodecVideoRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* allowedJoiningTimeMs= */ 0, + /* eventHandler= */ new Handler(testMainLooper), + /* eventListener= */ eventListener, + /* maxDroppedFramesToNotify= */ 1) { + @Override + @Capabilities + protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) + throws DecoderQueryException { + return RendererCapabilities.create(FORMAT_HANDLED); + } + + @Override + protected void onOutputFormatChanged(Format format, @Nullable MediaFormat mediaFormat) { + super.onOutputFormatChanged(format, mediaFormat); + currentOutputFormat = format; + } + }; + + mediaCodecVideoRenderer.handleMessage( + Renderer.MSG_SET_SURFACE, new Surface(new SurfaceTexture(/* texName= */ 0))); + } + + @Test + public void render_dropsLateBuffer() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), // First buffer. + oneByteSample(/* timeUs= */ 50_000), // Late buffer. + oneByteSample(/* timeUs= */ 100_000), // Last buffer. + FakeSampleStreamItem.END_OF_STREAM_ITEM)); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + + mediaCodecVideoRenderer.start(); + mediaCodecVideoRenderer.render(0, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.render(40_000, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + int posUs = 80_001; // Ensures buffer will be 30_001us late. + while (!mediaCodecVideoRenderer.isEnded()) { + mediaCodecVideoRenderer.render(posUs, SystemClock.elapsedRealtime() * 1000); + posUs += 40_000; + } + shadowOf(testMainLooper).idle(); + + verify(eventListener).onDroppedFrames(eq(1), anyLong()); + } + + @Test + public void render_sendsVideoSizeChangeWithCurrentFormatValues() throws Exception { + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)), + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + mediaCodecVideoRenderer.start(); + + int positionUs = 0; + do { + mediaCodecVideoRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 10; + } while (!mediaCodecVideoRenderer.isEnded()); + shadowOf(testMainLooper).idle(); + + verify(eventListener) + .onVideoSizeChanged( + VIDEO_H264.width, + VIDEO_H264.height, + VIDEO_H264.rotationDegrees, + VIDEO_H264.pixelWidthHeightRatio); + } + + @Test + public void + render_withMultipleQueued_sendsVideoSizeChangedWithCorrectPixelAspectRatioWhenMultipleQueued() + throws Exception { + Format pAsp1 = VIDEO_H264.buildUpon().setPixelWidthHeightRatio(1f).build(); + Format pAsp2 = VIDEO_H264.buildUpon().setPixelWidthHeightRatio(2f).build(); + Format pAsp3 = VIDEO_H264.buildUpon().setPixelWidthHeightRatio(3f).build(); + + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ pAsp1, + ImmutableList.of(oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME))); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {pAsp1, pAsp2, pAsp3}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + mediaCodecVideoRenderer.start(); + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.render(/* positionUs= */ 250, SystemClock.elapsedRealtime() * 1000); + + fakeSampleStream.addFakeSampleStreamItem(format(pAsp2)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 5_000)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 10_000)); + fakeSampleStream.addFakeSampleStreamItem(format(pAsp3)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 15_000)); + fakeSampleStream.addFakeSampleStreamItem(oneByteSample(/* timeUs= */ 20_000)); + fakeSampleStream.addFakeSampleStreamItem(FakeSampleStreamItem.END_OF_STREAM_ITEM); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + + int pos = 500; + do { + mediaCodecVideoRenderer.render(/* positionUs= */ pos, SystemClock.elapsedRealtime() * 1000); + pos += 250; + } while (!mediaCodecVideoRenderer.isEnded()); + shadowOf(testMainLooper).idle(); + + InOrder orderVerifier = inOrder(eventListener); + orderVerifier.verify(eventListener).onVideoSizeChanged(anyInt(), anyInt(), anyInt(), eq(1f)); + orderVerifier.verify(eventListener).onVideoSizeChanged(anyInt(), anyInt(), anyInt(), eq(2f)); + orderVerifier.verify(eventListener).onVideoSizeChanged(anyInt(), anyInt(), anyInt(), eq(3f)); + orderVerifier.verifyNoMoreInteractions(); + } + + @Test + public void render_includingResetPosition_keepsOutputFormatInVideoFrameMetadataListener() + throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME))); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + + mediaCodecVideoRenderer.start(); + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + mediaCodecVideoRenderer.resetPosition(0); + mediaCodecVideoRenderer.setCurrentStreamFinal(); + fakeSampleStream.addFakeSampleStreamItem( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME)); + fakeSampleStream.addFakeSampleStreamItem(FakeSampleStreamItem.END_OF_STREAM_ITEM); + int positionUs = 10; + do { + mediaCodecVideoRenderer.render(positionUs, SystemClock.elapsedRealtime() * 1000); + positionUs += 10; + } while (!mediaCodecVideoRenderer.isEnded()); + shadowOf(testMainLooper).idle(); + + assertThat(currentOutputFormat).isEqualTo(VIDEO_H264); + } + + @Test + public void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME))); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + shadowOf(testMainLooper).idle(); + + verify(eventListener).onRenderedFirstFrame(any()); + } + + @Test + public void enable_withoutMayRenderStartOfStream_doesNotRenderFirstFrameBeforeStart() + throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0))); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + shadowOf(testMainLooper).idle(); + + verify(eventListener, never()).onRenderedFirstFrame(any()); + } + + @Test + public void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() throws Exception { + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of(oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME))); + + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + mediaCodecVideoRenderer.start(); + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render(/* positionUs= */ 0, SystemClock.elapsedRealtime() * 1000); + } + shadowOf(testMainLooper).idle(); + + verify(eventListener).onRenderedFirstFrame(any()); + } + + @Test + public void replaceStream_rendersFirstFrameOnlyAfterStartPosition() throws Exception { + ShadowLooper shadowLooper = shadowOf(testMainLooper); + FakeSampleStream fakeSampleStream1 = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); + FakeSampleStream fakeSampleStream2 = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 1_000_000, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + mediaCodecVideoRenderer.start(); + + boolean replacedStream = false; + for (int i = 0; i <= 10; i++) { + mediaCodecVideoRenderer.render( + /* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); + if (!replacedStream && mediaCodecVideoRenderer.hasReadStreamToEnd()) { + mediaCodecVideoRenderer.replaceStream( + new Format[] {VIDEO_H264}, + fakeSampleStream2, + /* startPositionUs= */ 100, + /* offsetUs= */ 100); + replacedStream = true; + } + } + + // Expect only the first frame of the first stream to have been rendered. + shadowLooper.idle(); + verify(eventListener, times(2)).onRenderedFirstFrame(any()); + } + + @Test + public void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() throws Exception { + ShadowLooper shadowLooper = shadowOf(testMainLooper); + FakeSampleStream fakeSampleStream1 = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); + FakeSampleStream fakeSampleStream2 = + new FakeSampleStream( + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DUMMY, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ VIDEO_H264, + ImmutableList.of( + oneByteSample(/* timeUs= */ 0, C.BUFFER_FLAG_KEY_FRAME), + FakeSampleStreamItem.END_OF_STREAM_ITEM)); + mediaCodecVideoRenderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {VIDEO_H264}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs */ 0); + + boolean replacedStream = false; + for (int i = 0; i < 10; i++) { + mediaCodecVideoRenderer.render( + /* positionUs= */ i * 10, SystemClock.elapsedRealtime() * 1000); + if (!replacedStream && mediaCodecVideoRenderer.hasReadStreamToEnd()) { + mediaCodecVideoRenderer.replaceStream( + new Format[] {VIDEO_H264}, + fakeSampleStream2, + /* startPositionUs= */ 100, + /* offsetUs= */ 100); + replacedStream = true; + } + } + + shadowLooper.idle(); + verify(eventListener).onRenderedFirstFrame(any()); + + // Render to streamOffsetUs and verify the new first frame gets rendered. + mediaCodecVideoRenderer.render(/* positionUs= */ 100, SystemClock.elapsedRealtime() * 1000); + + shadowLooper.idle(); + verify(eventListener, times(2)).onRenderedFirstFrame(any()); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java index b9559816d7..8cabd85fad 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/spherical/ProjectionDecoderTest.java @@ -21,7 +21,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; -import junit.framework.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -76,20 +75,19 @@ public final class ProjectionDecoderTest { assertThat(subMesh.textureCoords.length).isEqualTo(VERTEX_COUNT * 2); // Test first vertex - testCoordinate(FIRST_VERTEX, vertices, 0, 3); + testCoordinate(FIRST_VERTEX, vertices, /* offset= */ 0); // Test last vertex - testCoordinate(LAST_VERTEX, vertices, VERTEX_COUNT * 3 - 3, 3); + testCoordinate(LAST_VERTEX, vertices, /* offset= */ VERTEX_COUNT * 3 - 3); // Test first uv - testCoordinate(FIRST_UV, uv, 0, 2); + testCoordinate(FIRST_UV, uv, /* offset= */ 0); // Test last uv - testCoordinate(LAST_UV, uv, VERTEX_COUNT * 2 - 2, 2); + testCoordinate(LAST_UV, uv, /* offset= */ VERTEX_COUNT * 2 - 2); } /** Tests that the output coordinates match the expected. */ - private static void testCoordinate(float[] expected, float[] output, int offset, int count) { - for (int i = 0; i < count; i++) { - Assert.assertEquals(expected[i], output[i + offset]); - } + private static void testCoordinate(float[] expected, float[] output, int offset) { + float[] adjustedOutput = Arrays.copyOfRange(output, offset, offset + expected.length); + assertThat(adjustedOutput).isEqualTo(expected); } } diff --git a/library/dash/README.md b/library/dash/README.md index 1076716684..2ae77c41aa 100644 --- a/library/dash/README.md +++ b/library/dash/README.md @@ -1,8 +1,20 @@ # ExoPlayer DASH library module # -Provides support for Dynamic Adaptive Streaming over HTTP (DASH) content. To -play DASH content, instantiate a `DashMediaSource` and pass it to -`ExoPlayer.prepare`. +Provides support for Dynamic Adaptive Streaming over HTTP (DASH) content. + +Adding a dependency to this module is all that's required to enable playback of +DASH `MediaItem`s added to an `ExoPlayer` or `SimpleExoPlayer` in their default +configurations. Internally, `DefaultMediaSourceFactory` will automatically +detect the presence of the module and convert DASH `MediaItem`s into +`DashMediaSource` instances for playback. + +Similarly, a `DownloadManager` in its default configuration will use +`DefaultDownloaderFactory`, which will automatically detect the presence of +the module and build `DashDownloader` instances to download DASH content. + +For advanced playback use cases, applications can build `DashMediaSource` +instances and pass them directly to the player. For advanced download use cases, +`DashDownloader` can be used directly. ## Links ## diff --git a/library/dash/build.gradle b/library/dash/build.gradle index 0ffbc718f0..e6cb20d933 100644 --- a/library/dash/build.gradle +++ b/library/dash/build.gradle @@ -11,22 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true @@ -34,14 +21,19 @@ android { } sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' - - testOptions.unitTests.includeAndroidResources = true } dependencies { implementation project(modulePrefix + 'library-core') + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion testImplementation project(modulePrefix + 'testutils') diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index e1a441f36f..81d72b61f3 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.dash; +import static java.lang.Math.min; + import android.util.Pair; import android.util.SparseArray; import android.util.SparseIntArray; @@ -23,12 +25,13 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; -import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.EmptySampleStream; import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.SequenceableLoader; @@ -51,6 +54,7 @@ import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -69,7 +73,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; SequenceableLoader.Callback>, ChunkSampleStream.ReleaseCallback { + // Defined by ANSI/SCTE 214-1 2016 7.2.3. private static final Pattern CEA608_SERVICE_DESCRIPTOR_REGEX = Pattern.compile("CC([1-4])=(.+)"); + // Defined by ANSI/SCTE 214-1 2016 7.2.2. + private static final Pattern CEA708_SERVICE_DESCRIPTOR_REGEX = + Pattern.compile("([1-4])=lang:(\\w+)(,.+)?"); /* package */ final int id; private final DashChunkSource.Factory chunkSourceFactory; @@ -85,7 +93,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private final PlayerEmsgHandler playerEmsgHandler; private final IdentityHashMap, PlayerTrackEmsgHandler> trackEmsgHandlerBySampleStream; - private final EventDispatcher eventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; @Nullable private Callback callback; private ChunkSampleStream[] sampleStreams; @@ -94,7 +103,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private DashManifest manifest; private int periodIndex; private List eventStreams; - private boolean notifiedReadingStarted; public DashMediaPeriod( int id, @@ -103,8 +111,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; DashChunkSource.Factory chunkSourceFactory, @Nullable TransferListener transferListener, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - EventDispatcher eventDispatcher, + EventDispatcher mediaSourceEventDispatcher, long elapsedRealtimeOffsetMs, LoaderErrorThrower manifestLoaderErrorThrower, Allocator allocator, @@ -116,8 +125,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; this.chunkSourceFactory = chunkSourceFactory; this.transferListener = transferListener; this.drmSessionManager = drmSessionManager; + this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; - this.eventDispatcher = eventDispatcher; + this.mediaSourceEventDispatcher = mediaSourceEventDispatcher; this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs; this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; this.allocator = allocator; @@ -134,7 +144,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; buildTrackGroups(drmSessionManager, period.adaptationSets, eventStreams); trackGroups = result.first; trackGroupInfos = result.second; - eventDispatcher.mediaPeriodCreated(); } /** @@ -173,7 +182,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; sampleStream.release(this); } callback = null; - eventDispatcher.mediaPeriodReleased(); } // ChunkSampleStream.ReleaseCallback implementation. @@ -310,10 +318,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } @@ -488,14 +492,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int primaryGroupCount = groupedAdaptationSetIndices.length; boolean[] primaryGroupHasEventMessageTrackFlags = new boolean[primaryGroupCount]; - Format[][] primaryGroupCea608TrackFormats = new Format[primaryGroupCount][]; + Format[][] primaryGroupClosedCaptionTrackFormats = new Format[primaryGroupCount][]; int totalEmbeddedTrackGroupCount = identifyEmbeddedTracks( primaryGroupCount, adaptationSets, groupedAdaptationSetIndices, primaryGroupHasEventMessageTrackFlags, - primaryGroupCea608TrackFormats); + primaryGroupClosedCaptionTrackFormats); int totalGroupCount = primaryGroupCount + totalEmbeddedTrackGroupCount + eventStreams.size(); TrackGroup[] trackGroups = new TrackGroup[totalGroupCount]; @@ -508,7 +512,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; groupedAdaptationSetIndices, primaryGroupCount, primaryGroupHasEventMessageTrackFlags, - primaryGroupCea608TrackFormats, + primaryGroupClosedCaptionTrackFormats, trackGroups, trackGroupInfos); @@ -582,7 +586,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; adaptationSetIdToIndex.get( Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1); if (otherAdaptationSetId != -1) { - mergedGroupIndex = Math.min(mergedGroupIndex, otherAdaptationSetId); + mergedGroupIndex = min(mergedGroupIndex, otherAdaptationSetId); } } } @@ -600,7 +604,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int[][] groupedAdaptationSetIndices = new int[adaptationSetGroupedIndices.size()][]; for (int i = 0; i < groupedAdaptationSetIndices.length; i++) { - groupedAdaptationSetIndices[i] = Util.toArray(adaptationSetGroupedIndices.get(i)); + groupedAdaptationSetIndices[i] = Ints.toArray(adaptationSetGroupedIndices.get(i)); // Restore the original adaptation set order within each group. Arrays.sort(groupedAdaptationSetIndices[i]); } @@ -616,8 +620,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * same primary group, grouped in primary track groups order. * @param primaryGroupHasEventMessageTrackFlags An output array to be filled with flags indicating * whether each of the primary track groups contains an embedded event message track. - * @param primaryGroupCea608TrackFormats An output array to be filled with track formats for - * CEA-608 tracks embedded in each of the primary track groups. + * @param primaryGroupClosedCaptionTrackFormats An output array to be filled with track formats + * for closed caption tracks embedded in each of the primary track groups. * @return Total number of embedded track groups. */ private static int identifyEmbeddedTracks( @@ -625,16 +629,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; List adaptationSets, int[][] groupedAdaptationSetIndices, boolean[] primaryGroupHasEventMessageTrackFlags, - Format[][] primaryGroupCea608TrackFormats) { + Format[][] primaryGroupClosedCaptionTrackFormats) { int numEmbeddedTrackGroups = 0; for (int i = 0; i < primaryGroupCount; i++) { if (hasEventMessageTrack(adaptationSets, groupedAdaptationSetIndices[i])) { primaryGroupHasEventMessageTrackFlags[i] = true; numEmbeddedTrackGroups++; } - primaryGroupCea608TrackFormats[i] = - getCea608TrackFormats(adaptationSets, groupedAdaptationSetIndices[i]); - if (primaryGroupCea608TrackFormats[i].length != 0) { + primaryGroupClosedCaptionTrackFormats[i] = + getClosedCaptionTrackFormats(adaptationSets, groupedAdaptationSetIndices[i]); + if (primaryGroupClosedCaptionTrackFormats[i].length != 0) { numEmbeddedTrackGroups++; } } @@ -647,7 +651,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int[][] groupedAdaptationSetIndices, int primaryGroupCount, boolean[] primaryGroupHasEventMessageTrackFlags, - Format[][] primaryGroupCea608TrackFormats, + Format[][] primaryGroupClosedCaptionTrackFormats, TrackGroup[] trackGroups, TrackGroupInfo[] trackGroupInfos) { int trackGroupCount = 0; @@ -660,21 +664,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; Format[] formats = new Format[representations.size()]; for (int j = 0; j < formats.length; j++) { Format format = representations.get(j).format; - DrmInitData drmInitData = format.drmInitData; - if (drmInitData != null) { - format = - format.copyWithExoMediaCryptoType( - drmSessionManager.getExoMediaCryptoType(drmInitData)); - } - formats[j] = format; + formats[j] = + format.copyWithExoMediaCryptoType(drmSessionManager.getExoMediaCryptoType(format)); } AdaptationSet firstAdaptationSet = adaptationSets.get(adaptationSetIndices[0]); int primaryTrackGroupIndex = trackGroupCount++; int eventMessageTrackGroupIndex = primaryGroupHasEventMessageTrackFlags[i] ? trackGroupCount++ : C.INDEX_UNSET; - int cea608TrackGroupIndex = - primaryGroupCea608TrackFormats[i].length != 0 ? trackGroupCount++ : C.INDEX_UNSET; + int closedCaptionTrackGroupIndex = + primaryGroupClosedCaptionTrackFormats[i].length != 0 ? trackGroupCount++ : C.INDEX_UNSET; trackGroups[primaryTrackGroupIndex] = new TrackGroup(formats); trackGroupInfos[primaryTrackGroupIndex] = @@ -683,7 +682,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; adaptationSetIndices, primaryTrackGroupIndex, eventMessageTrackGroupIndex, - cea608TrackGroupIndex); + closedCaptionTrackGroupIndex); if (eventMessageTrackGroupIndex != C.INDEX_UNSET) { Format format = new Format.Builder() @@ -694,10 +693,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackGroupInfos[eventMessageTrackGroupIndex] = TrackGroupInfo.embeddedEmsgTrack(adaptationSetIndices, primaryTrackGroupIndex); } - if (cea608TrackGroupIndex != C.INDEX_UNSET) { - trackGroups[cea608TrackGroupIndex] = new TrackGroup(primaryGroupCea608TrackFormats[i]); - trackGroupInfos[cea608TrackGroupIndex] = - TrackGroupInfo.embeddedCea608Track(adaptationSetIndices, primaryTrackGroupIndex); + if (closedCaptionTrackGroupIndex != C.INDEX_UNSET) { + trackGroups[closedCaptionTrackGroupIndex] = + new TrackGroup(primaryGroupClosedCaptionTrackFormats[i]); + trackGroupInfos[closedCaptionTrackGroupIndex] = + TrackGroupInfo.embeddedClosedCaptionTrack(adaptationSetIndices, primaryTrackGroupIndex); } } return trackGroupCount; @@ -728,11 +728,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackGroups.get(trackGroupInfo.embeddedEventMessageTrackGroupIndex); embeddedTrackCount++; } - boolean enableCea608Tracks = trackGroupInfo.embeddedCea608TrackGroupIndex != C.INDEX_UNSET; - TrackGroup embeddedCea608TrackGroup = null; - if (enableCea608Tracks) { - embeddedCea608TrackGroup = trackGroups.get(trackGroupInfo.embeddedCea608TrackGroupIndex); - embeddedTrackCount += embeddedCea608TrackGroup.length; + boolean enableClosedCaptionTrack = + trackGroupInfo.embeddedClosedCaptionTrackGroupIndex != C.INDEX_UNSET; + TrackGroup embeddedClosedCaptionTrackGroup = null; + if (enableClosedCaptionTrack) { + embeddedClosedCaptionTrackGroup = + trackGroups.get(trackGroupInfo.embeddedClosedCaptionTrackGroupIndex); + embeddedTrackCount += embeddedClosedCaptionTrackGroup.length; } Format[] embeddedTrackFormats = new Format[embeddedTrackCount]; @@ -743,12 +745,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_METADATA; embeddedTrackCount++; } - List embeddedCea608TrackFormats = new ArrayList<>(); - if (enableCea608Tracks) { - for (int i = 0; i < embeddedCea608TrackGroup.length; i++) { - embeddedTrackFormats[embeddedTrackCount] = embeddedCea608TrackGroup.getFormat(i); + List embeddedClosedCaptionTrackFormats = new ArrayList<>(); + if (enableClosedCaptionTrack) { + for (int i = 0; i < embeddedClosedCaptionTrackGroup.length; i++) { + embeddedTrackFormats[embeddedTrackCount] = embeddedClosedCaptionTrackGroup.getFormat(i); embeddedTrackTypes[embeddedTrackCount] = C.TRACK_TYPE_TEXT; - embeddedCea608TrackFormats.add(embeddedTrackFormats[embeddedTrackCount]); + embeddedClosedCaptionTrackFormats.add(embeddedTrackFormats[embeddedTrackCount]); embeddedTrackCount++; } } @@ -767,7 +769,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; trackGroupInfo.trackType, elapsedRealtimeOffsetMs, enableEventMessageTrack, - embeddedCea608TrackFormats, + embeddedClosedCaptionTrackFormats, trackPlayerEmsgHandler, transferListener); ChunkSampleStream stream = @@ -780,8 +782,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; allocator, positionUs, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, - eventDispatcher); + mediaSourceEventDispatcher); synchronized (this) { // The map is also accessed on the loading thread so synchronize access. trackEmsgHandlerBySampleStream.put(stream, trackPlayerEmsgHandler); @@ -824,7 +827,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; return false; } - private static Format[] getCea608TrackFormats( + private static Format[] getClosedCaptionTrackFormats( List adaptationSets, int[] adaptationSetIndices) { for (int i : adaptationSetIndices) { AdaptationSet adaptationSet = adaptationSets.get(i); @@ -832,49 +835,52 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; for (int j = 0; j < descriptors.size(); j++) { Descriptor descriptor = descriptors.get(j); if ("urn:scte:dash:cc:cea-608:2015".equals(descriptor.schemeIdUri)) { - @Nullable String value = descriptor.value; - if (value == null) { - // There are embedded CEA-608 tracks, but service information is not declared. - return new Format[] {buildCea608TrackFormat(adaptationSet.id)}; - } - String[] services = Util.split(value, ";"); - Format[] formats = new Format[services.length]; - for (int k = 0; k < services.length; k++) { - Matcher matcher = CEA608_SERVICE_DESCRIPTOR_REGEX.matcher(services[k]); - if (!matcher.matches()) { - // If we can't parse service information for all services, assume a single track. - return new Format[] {buildCea608TrackFormat(adaptationSet.id)}; - } - formats[k] = - buildCea608TrackFormat( - adaptationSet.id, - /* language= */ matcher.group(2), - /* accessibilityChannel= */ Integer.parseInt(matcher.group(1))); - } - return formats; + Format cea608Format = + new Format.Builder() + .setSampleMimeType(MimeTypes.APPLICATION_CEA608) + .setId(adaptationSet.id + ":cea608") + .build(); + return parseClosedCaptionDescriptor( + descriptor, CEA608_SERVICE_DESCRIPTOR_REGEX, cea608Format); + } else if ("urn:scte:dash:cc:cea-708:2015".equals(descriptor.schemeIdUri)) { + Format cea708Format = + new Format.Builder() + .setSampleMimeType(MimeTypes.APPLICATION_CEA708) + .setId(adaptationSet.id + ":cea708") + .build(); + return parseClosedCaptionDescriptor( + descriptor, CEA708_SERVICE_DESCRIPTOR_REGEX, cea708Format); } } } return new Format[0]; } - private static Format buildCea608TrackFormat(int adaptationSetId) { - return buildCea608TrackFormat( - adaptationSetId, /* language= */ null, /* accessibilityChannel= */ Format.NO_VALUE); - } - - private static Format buildCea608TrackFormat( - int adaptationSetId, @Nullable String language, int accessibilityChannel) { - String id = - adaptationSetId - + ":cea608" - + (accessibilityChannel != Format.NO_VALUE ? ":" + accessibilityChannel : ""); - return new Format.Builder() - .setId(id) - .setSampleMimeType(MimeTypes.APPLICATION_CEA608) - .setLanguage(language) - .setAccessibilityChannel(accessibilityChannel) - .build(); + private static Format[] parseClosedCaptionDescriptor( + Descriptor descriptor, Pattern serviceDescriptorRegex, Format baseFormat) { + @Nullable String value = descriptor.value; + if (value == null) { + // There are embedded closed caption tracks, but service information is not declared. + return new Format[] {baseFormat}; + } + String[] services = Util.split(value, ";"); + Format[] formats = new Format[services.length]; + for (int i = 0; i < services.length; i++) { + Matcher matcher = serviceDescriptorRegex.matcher(services[i]); + if (!matcher.matches()) { + // If we can't parse service information for all services, assume a single track. + return new Format[] {baseFormat}; + } + int accessibilityChannel = Integer.parseInt(matcher.group(1)); + formats[i] = + baseFormat + .buildUpon() + .setId(baseFormat.id + ":" + accessibilityChannel) + .setAccessibilityChannel(accessibilityChannel) + .setLanguage(matcher.group(2)) + .build(); + } + return formats; } // We won't assign the array to a variable that erases the generic type, and then write into it. @@ -916,21 +922,21 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; public final int eventStreamGroupIndex; public final int primaryTrackGroupIndex; public final int embeddedEventMessageTrackGroupIndex; - public final int embeddedCea608TrackGroupIndex; + public final int embeddedClosedCaptionTrackGroupIndex; public static TrackGroupInfo primaryTrack( int trackType, int[] adaptationSetIndices, int primaryTrackGroupIndex, int embeddedEventMessageTrackGroupIndex, - int embeddedCea608TrackGroupIndex) { + int embeddedClosedCaptionTrackGroupIndex) { return new TrackGroupInfo( trackType, CATEGORY_PRIMARY, adaptationSetIndices, primaryTrackGroupIndex, embeddedEventMessageTrackGroupIndex, - embeddedCea608TrackGroupIndex, + embeddedClosedCaptionTrackGroupIndex, /* eventStreamGroupIndex= */ -1); } @@ -946,8 +952,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /* eventStreamGroupIndex= */ -1); } - public static TrackGroupInfo embeddedCea608Track(int[] adaptationSetIndices, - int primaryTrackGroupIndex) { + public static TrackGroupInfo embeddedClosedCaptionTrack( + int[] adaptationSetIndices, int primaryTrackGroupIndex) { return new TrackGroupInfo( C.TRACK_TYPE_TEXT, CATEGORY_EMBEDDED, @@ -975,14 +981,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int[] adaptationSetIndices, int primaryTrackGroupIndex, int embeddedEventMessageTrackGroupIndex, - int embeddedCea608TrackGroupIndex, + int embeddedClosedCaptionTrackGroupIndex, int eventStreamGroupIndex) { this.trackType = trackType; this.adaptationSetIndices = adaptationSetIndices; this.trackGroupCategory = trackGroupCategory; this.primaryTrackGroupIndex = primaryTrackGroupIndex; this.embeddedEventMessageTrackGroupIndex = embeddedEventMessageTrackGroupIndex; - this.embeddedCea608TrackGroupIndex = embeddedCea608TrackGroupIndex; + this.embeddedClosedCaptionTrackGroupIndex = embeddedClosedCaptionTrackGroupIndex; this.eventStreamGroupIndex = eventStreamGroupIndex; } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 4b74956816..2f5b169e30 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.source.dash; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.net.Uri; import android.os.Handler; import android.os.SystemClock; @@ -26,7 +30,7 @@ import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.FilteringManifestParser; import com.google.android.exoplayer2.offline.StreamKey; @@ -37,6 +41,7 @@ import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceDrmHelper; import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.MediaSourceFactory; @@ -49,6 +54,7 @@ import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.upstream.Loader; @@ -58,13 +64,14 @@ import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.SntpClient; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.nio.charset.Charset; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Collections; @@ -85,9 +92,10 @@ public final class DashMediaSource extends BaseMediaSource { public static final class Factory implements MediaSourceFactory { private final DashChunkSource.Factory chunkSourceFactory; + private final MediaSourceDrmHelper mediaSourceDrmHelper; @Nullable private final DataSource.Factory manifestDataSourceFactory; - private DrmSessionManager drmSessionManager; + @Nullable private DrmSessionManager drmSessionManager; private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private long livePresentationDelayMs; @@ -118,9 +126,9 @@ public final class DashMediaSource extends BaseMediaSource { public Factory( DashChunkSource.Factory chunkSourceFactory, @Nullable DataSource.Factory manifestDataSourceFactory) { - this.chunkSourceFactory = Assertions.checkNotNull(chunkSourceFactory); + this.chunkSourceFactory = checkNotNull(chunkSourceFactory); this.manifestDataSourceFactory = manifestDataSourceFactory; - drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); + mediaSourceDrmHelper = new MediaSourceDrmHelper(); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); livePresentationDelayMs = DEFAULT_LIVE_PRESENTATION_DELAY_MS; compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); @@ -149,19 +157,22 @@ public final class DashMediaSource extends BaseMediaSource { return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - */ @Override public Factory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManager) { - this.drmSessionManager = - drmSessionManager != null - ? drmSessionManager - : DrmSessionManager.getDummyDrmSessionManager(); + this.drmSessionManager = drmSessionManager; + return this; + } + + @Override + public Factory setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { + mediaSourceDrmHelper.setDrmHttpDataSourceFactory(drmHttpDataSourceFactory); + return this; + } + + @Override + public Factory setDrmUserAgent(@Nullable String userAgent) { + mediaSourceDrmHelper.setDrmUserAgent(userAgent); return this; } @@ -260,22 +271,56 @@ public final class DashMediaSource extends BaseMediaSource { * @throws IllegalArgumentException If {@link DashManifest#dynamic} is true. */ public DashMediaSource createMediaSource(DashManifest manifest) { + return createMediaSource( + manifest, + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setMediaId(DUMMY_MEDIA_ID) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setStreamKeys(streamKeys) + .setTag(tag) + .build()); + } + + /** + * Returns a new {@link DashMediaSource} using the current parameters and the specified + * sideloaded manifest. + * + * @param manifest The manifest. {@link DashManifest#dynamic} must be false. + * @param mediaItem The {@link MediaItem} to be included in the timeline. + * @return The new {@link DashMediaSource}. + * @throws IllegalArgumentException If {@link DashManifest#dynamic} is true. + */ + public DashMediaSource createMediaSource(DashManifest manifest, MediaItem mediaItem) { Assertions.checkArgument(!manifest.dynamic); + List streamKeys = + mediaItem.playbackProperties != null && !mediaItem.playbackProperties.streamKeys.isEmpty() + ? mediaItem.playbackProperties.streamKeys + : this.streamKeys; if (!streamKeys.isEmpty()) { manifest = manifest.copy(streamKeys); } + boolean hasUri = mediaItem.playbackProperties != null; + boolean hasTag = hasUri && mediaItem.playbackProperties.tag != null; + mediaItem = + mediaItem + .buildUpon() + .setMimeType(MimeTypes.APPLICATION_MPD) + .setUri(hasUri ? mediaItem.playbackProperties.uri : Uri.EMPTY) + .setTag(hasTag ? mediaItem.playbackProperties.tag : tag) + .setStreamKeys(streamKeys) + .build(); return new DashMediaSource( + mediaItem, manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, compositeSequenceableLoaderFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, livePresentationDelayMs, - livePresentationDelayOverridesManifest, - tag); + livePresentationDelayOverridesManifest); } /** @@ -295,9 +340,10 @@ public final class DashMediaSource extends BaseMediaSource { } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ + @SuppressWarnings("deprecation") @Deprecated public DashMediaSource createMediaSource( Uri manifestUri, @@ -315,7 +361,12 @@ public final class DashMediaSource extends BaseMediaSource { @Deprecated @Override public DashMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setUri(uri).build()); + return createMediaSource( + new MediaItem.Builder() + .setUri(uri) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(tag) + .build()); } /** @@ -327,30 +378,40 @@ public final class DashMediaSource extends BaseMediaSource { */ @Override public DashMediaSource createMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); + checkNotNull(mediaItem.playbackProperties); @Nullable ParsingLoadable.Parser manifestParser = this.manifestParser; if (manifestParser == null) { manifestParser = new DashManifestParser(); } List streamKeys = - !mediaItem.playbackProperties.streamKeys.isEmpty() - ? mediaItem.playbackProperties.streamKeys - : this.streamKeys; + mediaItem.playbackProperties.streamKeys.isEmpty() + ? this.streamKeys + : mediaItem.playbackProperties.streamKeys; if (!streamKeys.isEmpty()) { manifestParser = new FilteringManifestParser<>(manifestParser, streamKeys); } + + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsStreamKeys = + mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty(); + if (needsTag && needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setTag(tag).setStreamKeys(streamKeys).build(); + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setStreamKeys(streamKeys).build(); + } return new DashMediaSource( + mediaItem, /* manifest= */ null, - mediaItem.playbackProperties.uri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, compositeSequenceableLoaderFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, livePresentationDelayMs, - livePresentationDelayOverridesManifest, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + livePresentationDelayOverridesManifest); } @Override @@ -363,7 +424,7 @@ public final class DashMediaSource extends BaseMediaSource { * The default presentation delay for live streams. The presentation delay is the duration by * which the default start position precedes the end of the live window. */ - public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30000; + public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30_000; /** @deprecated Use {@link #DEFAULT_LIVE_PRESENTATION_DELAY_MS}. */ @Deprecated public static final long DEFAULT_LIVE_PRESENTATION_DELAY_FIXED_MS = @@ -371,6 +432,10 @@ public final class DashMediaSource extends BaseMediaSource { /** @deprecated Use of this parameter is no longer necessary. */ @Deprecated public static final long DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS = -1; + /** The media id used by media items of dash media sources without a manifest URI. */ + public static final String DUMMY_MEDIA_ID = + "com.google.android.exoplayer2.source.dash.DashMediaSource"; + /** * The interval in milliseconds between invocations of {@link * MediaSourceCaller#onSourceInfoRefreshed(MediaSource, Timeline)} when the source's {@link @@ -380,7 +445,7 @@ public final class DashMediaSource extends BaseMediaSource { /** * The minimum default start position for live streams, relative to the start of the live window. */ - private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5000000; + private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5_000_000; private static final String TAG = "DashMediaSource"; @@ -401,7 +466,8 @@ public final class DashMediaSource extends BaseMediaSource { private final Runnable simulateManifestRefreshRunnable; private final PlayerEmsgCallback playerEmsgCallback; private final LoaderErrorThrower manifestLoadErrorThrower; - @Nullable private final Object tag; + private final MediaItem mediaItem; + private final MediaItem.PlaybackProperties playbackProperties; private DataSource dataSource; private Loader loader; @@ -410,8 +476,8 @@ public final class DashMediaSource extends BaseMediaSource { private IOException manifestFatalError; private Handler handler; - private Uri initialManifestUri; private Uri manifestUri; + private Uri initialManifestUri; private DashManifest manifest; private boolean manifestLoadPending; private long manifestLoadStartTimestampMs; @@ -423,15 +489,7 @@ public final class DashMediaSource extends BaseMediaSource { private int firstPeriodId; - /** - * Constructs an instance to play a given {@link DashManifest}, which must be static. - * - * @param manifest The manifest. {@link DashManifest#dynamic} must be false. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -447,16 +505,7 @@ public final class DashMediaSource extends BaseMediaSource { eventListener); } - /** - * Constructs an instance to play a given {@link DashManifest}, which must be static. - * - * @param manifest The manifest. {@link DashManifest#dynamic} must be false. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated public DashMediaSource( DashManifest manifest, @@ -465,8 +514,12 @@ public final class DashMediaSource extends BaseMediaSource { @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder() + .setMediaId(DUMMY_MEDIA_ID) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setUri(Uri.EMPTY) + .build(), manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, @@ -474,25 +527,13 @@ public final class DashMediaSource extends BaseMediaSource { DrmSessionManager.getDummyDrmSessionManager(), new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), DEFAULT_LIVE_PRESENTATION_DELAY_MS, - /* livePresentationDelayOverridesManifest= */ false, - /* tag= */ null); + /* livePresentationDelayOverridesManifest= */ false); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } } - /** - * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or - * static. - * - * @param manifestUri The manifest {@link Uri}. - * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used - * to load (and refresh) the manifest. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -511,23 +552,7 @@ public final class DashMediaSource extends BaseMediaSource { eventListener); } - /** - * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or - * static. - * - * @param manifestUri The manifest {@link Uri}. - * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used - * to load (and refresh) the manifest. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the - * default start position should precede the end of the live window. Use {@link - * #DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS} to use the value specified by the - * manifest, if present. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -549,24 +574,7 @@ public final class DashMediaSource extends BaseMediaSource { eventListener); } - /** - * Constructs an instance to play the manifest at a given {@link Uri}, which may be dynamic or - * static. - * - * @param manifestUri The manifest {@link Uri}. - * @param manifestDataSourceFactory A factory for {@link DataSource} instances that will be used - * to load (and refresh) the manifest. - * @param manifestParser A parser for loaded manifest data. - * @param chunkSourceFactory A factory for {@link DashChunkSource} instances. - * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. - * @param livePresentationDelayMs For live playbacks, the duration in milliseconds by which the - * default start position should precede the end of the live window. Use {@link - * #DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS} to use the value specified by the - * manifest, if present. - * @param eventHandler A handler for events. May be null if delivery of events is not required. - * @param eventListener A listener of events. May be null if delivery of events is not required. - * @deprecated Use {@link Factory} instead. - */ + /** @deprecated Use {@link Factory} instead. */ @Deprecated @SuppressWarnings("deprecation") public DashMediaSource( @@ -579,8 +587,8 @@ public final class DashMediaSource extends BaseMediaSource { @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder().setUri(manifestUri).setMimeType(MimeTypes.APPLICATION_MPD).build(), /* manifest= */ null, - manifestUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, @@ -590,16 +598,15 @@ public final class DashMediaSource extends BaseMediaSource { livePresentationDelayMs == DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS ? DEFAULT_LIVE_PRESENTATION_DELAY_MS : livePresentationDelayMs, - livePresentationDelayMs != DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS, - /* tag= */ null); + livePresentationDelayMs != DEFAULT_LIVE_PRESENTATION_DELAY_PREFER_MANIFEST_MS); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } } private DashMediaSource( + MediaItem mediaItem, @Nullable DashManifest manifest, - @Nullable Uri manifestUri, @Nullable DataSource.Factory manifestDataSourceFactory, @Nullable ParsingLoadable.Parser manifestParser, DashChunkSource.Factory chunkSourceFactory, @@ -607,11 +614,12 @@ public final class DashMediaSource extends BaseMediaSource { DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, long livePresentationDelayMs, - boolean livePresentationDelayOverridesManifest, - @Nullable Object tag) { - this.initialManifestUri = manifestUri; + boolean livePresentationDelayOverridesManifest) { + this.mediaItem = mediaItem; + this.playbackProperties = checkNotNull(mediaItem.playbackProperties); + this.manifestUri = playbackProperties.uri; + this.initialManifestUri = playbackProperties.uri; this.manifest = manifest; - this.manifestUri = manifestUri; this.manifestDataSourceFactory = manifestDataSourceFactory; this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; @@ -620,7 +628,6 @@ public final class DashMediaSource extends BaseMediaSource { this.livePresentationDelayMs = livePresentationDelayMs; this.livePresentationDelayOverridesManifest = livePresentationDelayOverridesManifest; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; - this.tag = tag; sideloadedManifest = manifest != null; manifestEventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); manifestUriLock = new Object(); @@ -656,10 +663,20 @@ public final class DashMediaSource extends BaseMediaSource { // MediaSource implementation. + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { - return tag; + return playbackProperties.tag; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -671,7 +688,7 @@ public final class DashMediaSource extends BaseMediaSource { } else { dataSource = manifestDataSourceFactory.createDataSource(); loader = new Loader("Loader:DashMediaSource"); - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentLooper(); startLoadingManifest(); } } @@ -685,8 +702,9 @@ public final class DashMediaSource extends BaseMediaSource { public MediaPeriod createPeriod( MediaPeriodId periodId, Allocator allocator, long startPositionUs) { int periodIndex = (Integer) periodId.periodUid - firstPeriodId; - EventDispatcher periodEventDispatcher = + MediaSourceEventListener.EventDispatcher periodEventDispatcher = createEventDispatcher(periodId, manifest.getPeriod(periodIndex).startMs); + DrmSessionEventListener.EventDispatcher drmEventDispatcher = createDrmEventDispatcher(periodId); DashMediaPeriod mediaPeriod = new DashMediaPeriod( firstPeriodId + periodIndex, @@ -695,6 +713,7 @@ public final class DashMediaSource extends BaseMediaSource { chunkSourceFactory, mediaTransferListener, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, periodEventDispatcher, elapsedRealtimeOffsetMs, @@ -1023,7 +1042,7 @@ public final class DashMediaSource extends BaseMediaSource { long liveStreamDurationUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs); long liveStreamEndPositionInLastPeriodUs = liveStreamDurationUs - C.msToUs(manifest.getPeriod(lastPeriodIndex).startMs); - currentEndTimeUs = Math.min(liveStreamEndPositionInLastPeriodUs, currentEndTimeUs); + currentEndTimeUs = min(liveStreamEndPositionInLastPeriodUs, currentEndTimeUs); if (manifest.timeShiftBufferDepthMs != C.TIME_UNSET) { long timeShiftBufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs); long offsetInPeriodUs = currentEndTimeUs - timeShiftBufferDepthUs; @@ -1032,7 +1051,7 @@ public final class DashMediaSource extends BaseMediaSource { offsetInPeriodUs += manifest.getPeriodDurationUs(--periodIndex); } if (periodIndex == 0) { - currentStartTimeUs = Math.max(currentStartTimeUs, offsetInPeriodUs); + currentStartTimeUs = max(currentStartTimeUs, offsetInPeriodUs); } else { // The time shift buffer starts after the earliest period. // TODO: Does this ever happen? @@ -1058,8 +1077,8 @@ public final class DashMediaSource extends BaseMediaSource { // The default start position is too close to the start of the live window. Set it to the // minimum default start position provided the window is at least twice as big. Else set // it to the middle of the window. - windowDefaultStartPositionUs = Math.min(MIN_LIVE_DEFAULT_START_POSITION_US, - windowDurationUs / 2); + windowDefaultStartPositionUs = + min(MIN_LIVE_DEFAULT_START_POSITION_US, windowDurationUs / 2); } } long windowStartTimeMs = C.TIME_UNSET; @@ -1079,7 +1098,7 @@ public final class DashMediaSource extends BaseMediaSource { windowDurationUs, windowDefaultStartPositionUs, manifest, - tag); + mediaItem); refreshSourceInfo(timeline); if (!sideloadedManifest) { @@ -1104,8 +1123,7 @@ public final class DashMediaSource extends BaseMediaSource { minUpdatePeriodMs = 5000; } long nextLoadTimestampMs = manifestLoadStartTimestampMs + minUpdatePeriodMs; - long delayUntilNextLoadMs = - Math.max(0, nextLoadTimestampMs - SystemClock.elapsedRealtime()); + long delayUntilNextLoadMs = max(0, nextLoadTimestampMs - SystemClock.elapsedRealtime()); scheduleManifestRefresh(delayUntilNextLoadMs); } } @@ -1136,7 +1154,7 @@ public final class DashMediaSource extends BaseMediaSource { } private long getManifestLoadRetryDelayMillis() { - return Math.min((staleManifestReloadAttempt - 1) * 1000, 5000); + return min((staleManifestReloadAttempt - 1) * 1000, 5000); } private void startLoading(ParsingLoadable loadable, @@ -1187,12 +1205,12 @@ public final class DashMediaSource extends BaseMediaSource { } else if (!seenEmptyIndex) { long firstSegmentNum = index.getFirstSegmentNum(); long adaptationSetAvailableStartTimeUs = index.getTimeUs(firstSegmentNum); - availableStartTimeUs = Math.max(availableStartTimeUs, adaptationSetAvailableStartTimeUs); + availableStartTimeUs = max(availableStartTimeUs, adaptationSetAvailableStartTimeUs); if (segmentCount != DashSegmentIndex.INDEX_UNBOUNDED) { long lastSegmentNum = firstSegmentNum + segmentCount - 1; long adaptationSetAvailableEndTimeUs = index.getTimeUs(lastSegmentNum) + index.getDurationUs(lastSegmentNum, durationUs); - availableEndTimeUs = Math.min(availableEndTimeUs, adaptationSetAvailableEndTimeUs); + availableEndTimeUs = min(availableEndTimeUs, adaptationSetAvailableEndTimeUs); } } } @@ -1223,7 +1241,7 @@ public final class DashMediaSource extends BaseMediaSource { private final long windowDurationUs; private final long windowDefaultStartPositionUs; private final DashManifest manifest; - @Nullable private final Object windowTag; + private final MediaItem mediaItem; public DashTimeline( long presentationStartTimeMs, @@ -1234,7 +1252,7 @@ public final class DashMediaSource extends BaseMediaSource { long windowDurationUs, long windowDefaultStartPositionUs, DashManifest manifest, - @Nullable Object windowTag) { + MediaItem mediaItem) { this.presentationStartTimeMs = presentationStartTimeMs; this.windowStartTimeMs = windowStartTimeMs; this.elapsedRealtimeEpochOffsetMs = elapsedRealtimeEpochOffsetMs; @@ -1243,7 +1261,7 @@ public final class DashMediaSource extends BaseMediaSource { this.windowDurationUs = windowDurationUs; this.windowDefaultStartPositionUs = windowDefaultStartPositionUs; this.manifest = manifest; - this.windowTag = windowTag; + this.mediaItem = mediaItem; } @Override @@ -1273,7 +1291,7 @@ public final class DashMediaSource extends BaseMediaSource { defaultPositionProjectionUs); return window.set( Window.SINGLE_WINDOW_UID, - windowTag, + mediaItem, manifest, presentationStartTimeMs, windowStartTimeMs, @@ -1442,8 +1460,7 @@ public final class DashMediaSource extends BaseMediaSource { @Override public Long parse(Uri uri, InputStream inputStream) throws IOException { String firstLine = - new BufferedReader(new InputStreamReader(inputStream, Charset.forName(C.UTF8_NAME))) - .readLine(); + new BufferedReader(new InputStreamReader(inputStream, Charsets.UTF_8)).readLine(); try { Matcher matcher = TIMESTAMP_WITH_TIMEZONE_PATTERN.matcher(firstLine); if (!matcher.matches()) { diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java index 6d440b96df..5dc6662d4f 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java @@ -19,12 +19,12 @@ import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper; +import com.google.android.exoplayer2.source.chunk.BundledChunkExtractor; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor; import com.google.android.exoplayer2.source.chunk.InitializationChunk; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; @@ -74,15 +74,15 @@ public final class DashUtil { } /** - * Loads {@link DrmInitData} for a given period in a DASH manifest. + * Loads a {@link Format} for acquiring keys for a given period in a DASH manifest. * * @param dataSource The {@link HttpDataSource} from which data should be loaded. * @param period The {@link Period}. - * @return The loaded {@link DrmInitData}, or null if none is defined. + * @return The loaded {@link Format}, or null if none is defined. * @throws IOException Thrown when there is an error while loading. */ @Nullable - public static DrmInitData loadDrmInitData(DataSource dataSource, Period period) + public static Format loadFormatWithDrmInitData(DataSource dataSource, Period period) throws IOException { int primaryTrackType = C.TRACK_TYPE_VIDEO; Representation representation = getFirstRepresentation(period, primaryTrackType); @@ -96,8 +96,8 @@ public final class DashUtil { Format manifestFormat = representation.format; Format sampleFormat = DashUtil.loadSampleFormat(dataSource, primaryTrackType, representation); return sampleFormat == null - ? manifestFormat.drmInitData - : sampleFormat.withManifestFormatInfo(manifestFormat).drmInitData; + ? manifestFormat + : sampleFormat.withManifestFormatInfo(manifestFormat); } /** @@ -113,11 +113,16 @@ public final class DashUtil { @Nullable public static Format loadSampleFormat( DataSource dataSource, int trackType, Representation representation) throws IOException { - ChunkExtractorWrapper extractorWrapper = loadInitializationData(dataSource, trackType, - representation, false); - return extractorWrapper == null - ? null - : Assertions.checkStateNotNull(extractorWrapper.getSampleFormats())[0]; + if (representation.getInitializationUri() == null) { + return null; + } + ChunkExtractor chunkExtractor = newChunkExtractor(trackType, representation.format); + try { + loadInitializationData(chunkExtractor, dataSource, representation, /* loadIndex= */ false); + } finally { + chunkExtractor.release(); + } + return Assertions.checkStateNotNull(chunkExtractor.getSampleFormats())[0]; } /** @@ -135,74 +140,80 @@ public final class DashUtil { @Nullable public static ChunkIndex loadChunkIndex( DataSource dataSource, int trackType, Representation representation) throws IOException { - ChunkExtractorWrapper extractorWrapper = loadInitializationData(dataSource, trackType, - representation, true); - return extractorWrapper == null ? null : (ChunkIndex) extractorWrapper.getSeekMap(); + if (representation.getInitializationUri() == null) { + return null; + } + ChunkExtractor chunkExtractor = newChunkExtractor(trackType, representation.format); + try { + loadInitializationData(chunkExtractor, dataSource, representation, /* loadIndex= */ true); + } finally { + chunkExtractor.release(); + } + return chunkExtractor.getChunkIndex(); } /** * Loads initialization data for the {@code representation} and optionally index data then returns - * a {@link ChunkExtractorWrapper} which contains the output. + * a {@link BundledChunkExtractor} which contains the output. * + * @param chunkExtractor The {@link ChunkExtractor} to use. * @param dataSource The source from which the data should be loaded. - * @param trackType The type of the representation. Typically one of the {@link - * com.google.android.exoplayer2.C} {@code TRACK_TYPE_*} constants. * @param representation The representation which initialization chunk belongs to. * @param loadIndex Whether to load index data too. - * @return A {@link ChunkExtractorWrapper} for the {@code representation}, or null if no - * initialization or (if requested) index data exists. * @throws IOException Thrown when there is an error while loading. */ - @Nullable - private static ChunkExtractorWrapper loadInitializationData( - DataSource dataSource, int trackType, Representation representation, boolean loadIndex) + private static void loadInitializationData( + ChunkExtractor chunkExtractor, + DataSource dataSource, + Representation representation, + boolean loadIndex) throws IOException { - RangedUri initializationUri = representation.getInitializationUri(); - if (initializationUri == null) { - return null; - } - ChunkExtractorWrapper extractorWrapper = newWrappedExtractor(trackType, representation.format); + RangedUri initializationUri = Assertions.checkNotNull(representation.getInitializationUri()); RangedUri requestUri; if (loadIndex) { RangedUri indexUri = representation.getIndexUri(); if (indexUri == null) { - return null; + return; } // It's common for initialization and index data to be stored adjacently. Attempt to merge // the two requests together to request both at once. requestUri = initializationUri.attemptMerge(indexUri, representation.baseUrl); if (requestUri == null) { - loadInitializationData(dataSource, representation, extractorWrapper, initializationUri); + loadInitializationData(dataSource, representation, chunkExtractor, initializationUri); requestUri = indexUri; } } else { requestUri = initializationUri; } - loadInitializationData(dataSource, representation, extractorWrapper, requestUri); - return extractorWrapper; + loadInitializationData(dataSource, representation, chunkExtractor, requestUri); } private static void loadInitializationData( DataSource dataSource, Representation representation, - ChunkExtractorWrapper extractorWrapper, + ChunkExtractor chunkExtractor, RangedUri requestUri) throws IOException { DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri); - InitializationChunk initializationChunk = new InitializationChunk(dataSource, dataSpec, - representation.format, C.SELECTION_REASON_UNKNOWN, null /* trackSelectionData */, - extractorWrapper); + InitializationChunk initializationChunk = + new InitializationChunk( + dataSource, + dataSpec, + representation.format, + C.SELECTION_REASON_UNKNOWN, + null /* trackSelectionData */, + chunkExtractor); initializationChunk.load(); } - private static ChunkExtractorWrapper newWrappedExtractor(int trackType, Format format) { + private static ChunkExtractor newChunkExtractor(int trackType, Format format) { String mimeType = format.containerMimeType; boolean isWebm = mimeType != null && (mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM)); Extractor extractor = isWebm ? new MatroskaExtractor() : new FragmentedMp4Extractor(); - return new ChunkExtractorWrapper(extractor, trackType, format); + return new BundledChunkExtractor(extractor, trackType, format); } @Nullable diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index e03ade2d48..01e51c3f6c 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.source.dash; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.net.Uri; import android.os.SystemClock; import androidx.annotation.CheckResult; @@ -24,15 +27,15 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.extractor.Extractor; -import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; import com.google.android.exoplayer2.extractor.rawcc.RawCcExtractor; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import com.google.android.exoplayer2.source.chunk.BundledChunkExtractor; import com.google.android.exoplayer2.source.chunk.Chunk; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor; import com.google.android.exoplayer2.source.chunk.ChunkHolder; import com.google.android.exoplayer2.source.chunk.ContainerMediaChunk; import com.google.android.exoplayer2.source.chunk.InitializationChunk; @@ -244,6 +247,15 @@ public class DefaultDashChunkSource implements DashChunkSource { return trackSelection.evaluateQueueSize(playbackPositionUs, queue); } + @Override + public boolean shouldCancelLoad( + long playbackPositionUs, Chunk loadingChunk, List queue) { + if (fatalError != null) { + return false; + } + return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, queue); + } + @Override public void getNextChunk( long playbackPositionUs, @@ -302,11 +314,11 @@ public class DefaultDashChunkSource implements DashChunkSource { RepresentationHolder representationHolder = representationHolders[trackSelection.getSelectedIndex()]; - if (representationHolder.extractorWrapper != null) { + if (representationHolder.chunkExtractor != null) { Representation selectedRepresentation = representationHolder.representation; RangedUri pendingInitializationUri = null; RangedUri pendingIndexUri = null; - if (representationHolder.extractorWrapper.getSampleFormats() == null) { + if (representationHolder.chunkExtractor.getSampleFormats() == null) { pendingInitializationUri = selectedRepresentation.getInitializationUri(); } if (representationHolder.segmentIndex == null) { @@ -363,8 +375,7 @@ public class DefaultDashChunkSource implements DashChunkSource { return; } - int maxSegmentCount = - (int) Math.min(maxSegmentsPerLoad, lastAvailableSegmentNum - segmentNum + 1); + int maxSegmentCount = (int) min(maxSegmentsPerLoad, lastAvailableSegmentNum - segmentNum + 1); if (periodDurationUs != C.TIME_UNSET) { while (maxSegmentCount > 1 && representationHolder.getSegmentStartTimeUs(segmentNum + maxSegmentCount - 1) @@ -399,13 +410,12 @@ public class DefaultDashChunkSource implements DashChunkSource { // from the stream. If the manifest defines an index then the stream shouldn't, but in cases // where it does we should ignore it. if (representationHolder.segmentIndex == null) { - SeekMap seekMap = representationHolder.extractorWrapper.getSeekMap(); - if (seekMap != null) { + @Nullable ChunkIndex chunkIndex = representationHolder.chunkExtractor.getChunkIndex(); + if (chunkIndex != null) { representationHolders[trackIndex] = representationHolder.copyWithNewSegmentIndex( new DashWrappingSegmentIndex( - (ChunkIndex) seekMap, - representationHolder.representation.presentationTimeOffsetUs)); + chunkIndex, representationHolder.representation.presentationTimeOffsetUs)); } } } @@ -416,7 +426,7 @@ public class DefaultDashChunkSource implements DashChunkSource { @Override public boolean onChunkLoadError( - Chunk chunk, boolean cancelable, Exception e, long blacklistDurationMs) { + Chunk chunk, boolean cancelable, Exception e, long exclusionDurationMs) { if (!cancelable) { return false; } @@ -439,8 +449,18 @@ public class DefaultDashChunkSource implements DashChunkSource { } } } - return blacklistDurationMs != C.TIME_UNSET - && trackSelection.blacklist(trackSelection.indexOf(chunk.trackFormat), blacklistDurationMs); + return exclusionDurationMs != C.TIME_UNSET + && trackSelection.blacklist(trackSelection.indexOf(chunk.trackFormat), exclusionDurationMs); + } + + @Override + public void release() { + for (RepresentationHolder representationHolder : representationHolders) { + @Nullable ChunkExtractor chunkExtractor = representationHolder.chunkExtractor; + if (chunkExtractor != null) { + chunkExtractor.release(); + } + } } // Internal methods. @@ -500,8 +520,13 @@ public class DefaultDashChunkSource implements DashChunkSource { requestUri = indexUri; } DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri); - return new InitializationChunk(dataSource, dataSpec, trackFormat, - trackSelectionReason, trackSelectionData, representationHolder.extractorWrapper); + return new InitializationChunk( + dataSource, + dataSpec, + trackFormat, + trackSelectionReason, + trackSelectionData, + representationHolder.chunkExtractor); } protected Chunk newMediaChunk( @@ -518,7 +543,7 @@ public class DefaultDashChunkSource implements DashChunkSource { long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); String baseUrl = representation.baseUrl; - if (representationHolder.extractorWrapper == null) { + if (representationHolder.chunkExtractor == null) { long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum); DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri); return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason, @@ -556,7 +581,7 @@ public class DefaultDashChunkSource implements DashChunkSource { firstSegmentNum, segmentCount, sampleOffsetUs, - representationHolder.extractorWrapper); + representationHolder.chunkExtractor); } } @@ -605,7 +630,7 @@ public class DefaultDashChunkSource implements DashChunkSource { /** Holds information about a snapshot of a single {@link Representation}. */ protected static final class RepresentationHolder { - /* package */ final @Nullable ChunkExtractorWrapper extractorWrapper; + @Nullable /* package */ final ChunkExtractor chunkExtractor; public final Representation representation; @Nullable public final DashSegmentIndex segmentIndex; @@ -623,7 +648,7 @@ public class DefaultDashChunkSource implements DashChunkSource { this( periodDurationUs, representation, - createExtractorWrapper( + createChunkExtractor( trackType, representation, enableEventMessageTrack, @@ -636,13 +661,13 @@ public class DefaultDashChunkSource implements DashChunkSource { private RepresentationHolder( long periodDurationUs, Representation representation, - @Nullable ChunkExtractorWrapper extractorWrapper, + @Nullable ChunkExtractor chunkExtractor, long segmentNumShift, @Nullable DashSegmentIndex segmentIndex) { this.periodDurationUs = periodDurationUs; this.representation = representation; this.segmentNumShift = segmentNumShift; - this.extractorWrapper = extractorWrapper; + this.chunkExtractor = chunkExtractor; this.segmentIndex = segmentIndex; } @@ -656,20 +681,20 @@ public class DefaultDashChunkSource implements DashChunkSource { if (oldIndex == null) { // Segment numbers cannot shift if the index isn't defined by the manifest. return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, oldIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, segmentNumShift, oldIndex); } if (!oldIndex.isExplicit()) { // Segment numbers cannot shift if the index isn't explicit. return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, segmentNumShift, newIndex); } int oldIndexSegmentCount = oldIndex.getSegmentCount(newPeriodDurationUs); if (oldIndexSegmentCount == 0) { // Segment numbers cannot shift if the old index was empty. return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, segmentNumShift, newIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, segmentNumShift, newIndex); } long oldIndexFirstSegmentNum = oldIndex.getFirstSegmentNum(); @@ -701,13 +726,13 @@ public class DefaultDashChunkSource implements DashChunkSource { - newIndexFirstSegmentNum; } return new RepresentationHolder( - newPeriodDurationUs, newRepresentation, extractorWrapper, newSegmentNumShift, newIndex); + newPeriodDurationUs, newRepresentation, chunkExtractor, newSegmentNumShift, newIndex); } @CheckResult /* package */ RepresentationHolder copyWithNewSegmentIndex(DashSegmentIndex segmentIndex) { return new RepresentationHolder( - periodDurationUs, representation, extractorWrapper, segmentNumShift, segmentIndex); + periodDurationUs, representation, chunkExtractor, segmentNumShift, segmentIndex); } public long getFirstSegmentNum() { @@ -745,8 +770,7 @@ public class DefaultDashChunkSource implements DashChunkSource { long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs); long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs; long bufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs); - return Math.max( - getFirstSegmentNum(), getSegmentNum(liveEdgeTimeInPeriodUs - bufferDepthUs)); + return max(getFirstSegmentNum(), getSegmentNum(liveEdgeTimeInPeriodUs - bufferDepthUs)); } return getFirstSegmentNum(); } @@ -767,18 +791,15 @@ public class DefaultDashChunkSource implements DashChunkSource { return getFirstSegmentNum() + availableSegmentCount - 1; } - private static boolean mimeTypeIsWebm(String mimeType) { - return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM) - || mimeType.startsWith(MimeTypes.APPLICATION_WEBM); - } - - private static @Nullable ChunkExtractorWrapper createExtractorWrapper( + @Nullable + private static ChunkExtractor createChunkExtractor( int trackType, Representation representation, boolean enableEventMessageTrack, List closedCaptionFormats, @Nullable TrackOutput playerEmsgTrackOutput) { String containerMimeType = representation.format.containerMimeType; + Extractor extractor; if (MimeTypes.isText(containerMimeType)) { if (MimeTypes.APPLICATION_RAWCC.equals(containerMimeType)) { @@ -788,7 +809,7 @@ public class DefaultDashChunkSource implements DashChunkSource { // All other text types are raw formats that do not need an extractor. return null; } - } else if (mimeTypeIsWebm(containerMimeType)) { + } else if (MimeTypes.isMatroska(containerMimeType)) { extractor = new MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES); } else { int flags = 0; @@ -803,7 +824,7 @@ public class DefaultDashChunkSource implements DashChunkSource { closedCaptionFormats, playerEmsgTrackOutput); } - return new ChunkExtractorWrapper(extractor, trackType, representation.format); + return new BundledChunkExtractor(extractor, trackType, representation.format); } } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java index 6e67be6ec5..66fcd280c6 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/EventSampleStream.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.dash; +import static java.lang.Math.max; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; @@ -113,21 +115,16 @@ import java.io.IOException; } int sampleIndex = currentIndex++; byte[] serializedEvent = eventMessageEncoder.encode(eventStream.events[sampleIndex]); - if (serializedEvent != null) { - buffer.ensureSpaceForWrite(serializedEvent.length); - buffer.data.put(serializedEvent); - buffer.timeUs = eventTimesUs[sampleIndex]; - buffer.setFlags(C.BUFFER_FLAG_KEY_FRAME); - return C.RESULT_BUFFER_READ; - } else { - return C.RESULT_NOTHING_READ; - } + buffer.ensureSpaceForWrite(serializedEvent.length); + buffer.data.put(serializedEvent); + buffer.timeUs = eventTimesUs[sampleIndex]; + buffer.setFlags(C.BUFFER_FLAG_KEY_FRAME); + return C.RESULT_BUFFER_READ; } @Override public int skipData(long positionUs) { - int newIndex = - Math.max(currentIndex, Util.binarySearchCeil(eventTimesUs, positionUs, true, false)); + int newIndex = max(currentIndex, Util.binarySearchCeil(eventTimesUs, positionUs, true, false)); int skipped = newIndex - currentIndex; currentIndex = newIndex; return skipped; diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/PlayerEmsgHandler.java index 7888841e23..2185b52f93 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 @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.metadata.Metadata; @@ -35,7 +36,6 @@ import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataReader; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; @@ -105,7 +105,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { this.allocator = allocator; manifestPublishTimeToExpiryTimeUs = new TreeMap<>(); - handler = Util.createHandler(/* callback= */ this); + handler = Util.createHandlerForCurrentLooper(/* callback= */ this); decoder = new EventMessageDecoder(); lastLoadedChunkEndTimeUs = C.TIME_UNSET; lastLoadedChunkEndTimeBeforeRefreshUs = C.TIME_UNSET; @@ -290,7 +290,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { allocator, /* playbackLooper= */ handler.getLooper(), DrmSessionManager.getDummyDrmSessionManager(), - new MediaSourceEventDispatcher()); + new DrmSessionEventListener.EventDispatcher()); formatHolder = new FormatHolder(); buffer = new MetadataInputBuffer(); } @@ -361,12 +361,15 @@ public final class PlayerEmsgHandler implements Handler.Callback { private void parseAndDiscardSamples() { while (sampleQueue.isReady(/* loadingFinished= */ false)) { - MetadataInputBuffer inputBuffer = dequeueSample(); + @Nullable MetadataInputBuffer inputBuffer = dequeueSample(); if (inputBuffer == null) { continue; } long eventTimeUs = inputBuffer.timeUs; - Metadata metadata = decoder.decode(inputBuffer); + @Nullable Metadata metadata = decoder.decode(inputBuffer); + if (metadata == null) { + continue; + } EventMessage eventMessage = (EventMessage) metadata.get(0); if (isPlayerEmsgEvent(eventMessage.schemeIdUri, eventMessage.value)) { parsePlayerEmsgEvent(eventTimeUs, eventMessage); @@ -380,11 +383,7 @@ public final class PlayerEmsgHandler implements Handler.Callback { buffer.clear(); int result = sampleQueue.read( - formatHolder, - buffer, - /* formatRequired= */ false, - /* loadingFinished= */ false, - /* decodeOnlyUntilUs= */ 0); + formatHolder, buffer, /* formatRequired= */ false, /* loadingFinished= */ false); if (result == C.RESULT_BUFFER_READ) { buffer.flip(); return buffer; diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index 23f264e64b..ede5df90c4 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -39,11 +39,12 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.XmlPullParserUtil; +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableList; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.regex.Matcher; @@ -114,6 +115,7 @@ public class DashManifestParser extends DefaultHandler ProgramInformation programInformation = null; UtcTimingElement utcTiming = null; Uri location = null; + long baseUrlAvailabilityTimeOffsetUs = dynamic ? 0 : C.TIME_UNSET; List periods = new ArrayList<>(); long nextPeriodStartMs = dynamic ? C.TIME_UNSET : 0; @@ -123,6 +125,8 @@ public class DashManifestParser extends DefaultHandler xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -133,7 +137,8 @@ public class DashManifestParser extends DefaultHandler } else if (XmlPullParserUtil.isStartTag(xpp, "Location")) { location = Uri.parse(xpp.nextText()); } else if (XmlPullParserUtil.isStartTag(xpp, "Period") && !seenEarlyAccessPeriod) { - Pair periodWithDurationMs = parsePeriod(xpp, baseUrl, nextPeriodStartMs); + Pair periodWithDurationMs = + parsePeriod(xpp, baseUrl, nextPeriodStartMs, baseUrlAvailabilityTimeOffsetUs); Period period = periodWithDurationMs.first; if (period.startMs == C.TIME_UNSET) { if (dynamic) { @@ -220,7 +225,8 @@ public class DashManifestParser extends DefaultHandler return new UtcTimingElement(schemeIdUri, value); } - protected Pair parsePeriod(XmlPullParser xpp, String baseUrl, long defaultStartMs) + protected Pair parsePeriod( + XmlPullParser xpp, String baseUrl, long defaultStartMs, long baseUrlAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { @Nullable String id = xpp.getAttributeValue(null, "id"); long startMs = parseDuration(xpp, "start", defaultStartMs); @@ -230,23 +236,50 @@ public class DashManifestParser extends DefaultHandler List adaptationSets = new ArrayList<>(); List eventStreams = new ArrayList<>(); boolean seenFirstBaseUrl = false; + long segmentBaseAvailabilityTimeOffsetUs = C.TIME_UNSET; do { xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } } else if (XmlPullParserUtil.isStartTag(xpp, "AdaptationSet")) { - adaptationSets.add(parseAdaptationSet(xpp, baseUrl, segmentBase, durationMs)); + adaptationSets.add( + parseAdaptationSet( + xpp, + baseUrl, + segmentBase, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs)); } else if (XmlPullParserUtil.isStartTag(xpp, "EventStream")) { eventStreams.add(parseEventStream(xpp)); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { - segmentBase = parseSegmentBase(xpp, null); + segmentBase = parseSegmentBase(xpp, /* parent= */ null); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, null, durationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentList( + xpp, + /* parent= */ null, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { - segmentBase = parseSegmentTemplate(xpp, null, Collections.emptyList(), durationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentTemplate( + xpp, + /* parent= */ null, + ImmutableList.of(), + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "AssetIdentifier")) { assetIdentifier = parseDescriptor(xpp, "AssetIdentifier"); } else { @@ -270,7 +303,12 @@ public class DashManifestParser extends DefaultHandler // AdaptationSet parsing. protected AdaptationSet parseAdaptationSet( - XmlPullParser xpp, String baseUrl, @Nullable SegmentBase segmentBase, long periodDurationMs) + XmlPullParser xpp, + String baseUrl, + @Nullable SegmentBase segmentBase, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { int id = parseInt(xpp, "id", AdaptationSet.ID_UNSET); int contentType = parseContentType(xpp); @@ -298,6 +336,8 @@ public class DashManifestParser extends DefaultHandler xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -340,7 +380,9 @@ public class DashManifestParser extends DefaultHandler essentialProperties, supplementalProperties, segmentBase, - periodDurationMs); + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); contentType = checkContentTypeConsistency( contentType, MimeTypes.getTrackType(representationInfo.format.sampleMimeType)); @@ -348,11 +390,26 @@ public class DashManifestParser extends DefaultHandler } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase, periodDurationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); segmentBase = parseSegmentTemplate( - xpp, (SegmentTemplate) segmentBase, supplementalProperties, periodDurationMs); + xpp, + (SegmentTemplate) segmentBase, + supplementalProperties, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); } else if (XmlPullParserUtil.isStartTag(xpp, "Label")) { @@ -513,7 +570,9 @@ public class DashManifestParser extends DefaultHandler List adaptationSetEssentialProperties, List adaptationSetSupplementalProperties, @Nullable SegmentBase segmentBase, - long periodDurationMs) + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); @@ -537,6 +596,8 @@ public class DashManifestParser extends DefaultHandler xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -545,14 +606,26 @@ public class DashManifestParser extends DefaultHandler } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase, periodDurationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); segmentBase = parseSegmentTemplate( xpp, (SegmentTemplate) segmentBase, adaptationSetSupplementalProperties, - periodDurationMs); + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs); } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { Pair contentProtection = parseContentProtection(xpp); if (contentProtection.first != null) { @@ -717,7 +790,11 @@ public class DashManifestParser extends DefaultHandler } protected SegmentList parseSegmentList( - XmlPullParser xpp, @Nullable SegmentList parent, long periodDurationMs) + XmlPullParser xpp, + @Nullable SegmentList parent, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -725,6 +802,9 @@ public class DashManifestParser extends DefaultHandler parent != null ? parent.presentationTimeOffset : 0); long duration = parseLong(xpp, "duration", parent != null ? parent.duration : C.TIME_UNSET); long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); RangedUri initialization = null; List timeline = null; @@ -752,8 +832,15 @@ public class DashManifestParser extends DefaultHandler segments = segments != null ? segments : parent.mediaSegments; } - return buildSegmentList(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, segments); + return buildSegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments); } protected SegmentList buildSegmentList( @@ -763,16 +850,26 @@ public class DashManifestParser extends DefaultHandler long startNumber, long duration, @Nullable List timeline, + long availabilityTimeOffsetUs, @Nullable List segments) { - return new SegmentList(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, segments); + return new SegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments); } protected SegmentTemplate parseSegmentTemplate( XmlPullParser xpp, @Nullable SegmentTemplate parent, List adaptationSetSupplementalProperties, - long periodDurationMs) + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", @@ -781,6 +878,9 @@ public class DashManifestParser extends DefaultHandler long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); long endNumber = parseLastSegmentNumberSupplementalProperty(adaptationSetSupplementalProperties); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); UrlTemplate mediaTemplate = parseUrlTemplate(xpp, "media", parent != null ? parent.mediaTemplate : null); @@ -814,6 +914,7 @@ public class DashManifestParser extends DefaultHandler endNumber, duration, timeline, + availabilityTimeOffsetUs, initializationTemplate, mediaTemplate); } @@ -826,6 +927,7 @@ public class DashManifestParser extends DefaultHandler long endNumber, long duration, List timeline, + long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, @Nullable UrlTemplate mediaTemplate) { return new SegmentTemplate( @@ -836,14 +938,14 @@ public class DashManifestParser extends DefaultHandler endNumber, duration, timeline, + availabilityTimeOffsetUs, initializationTemplate, mediaTemplate); } /** - * /** * Parses a single EventStream node in the manifest. - *

      + * * @param xpp The current xml parser. * @return The {@link EventStream} parsed from this EventStream node. * @throws XmlPullParserException If there is any error parsing this node. @@ -934,7 +1036,7 @@ public class DashManifestParser extends DefaultHandler throws XmlPullParserException, IOException { scratchOutputStream.reset(); XmlSerializer xmlSerializer = Xml.newSerializer(); - xmlSerializer.setOutput(scratchOutputStream, C.UTF8_NAME); + xmlSerializer.setOutput(scratchOutputStream, Charsets.UTF_8.name()); // Start reading everything between and , and serialize them into an Xml // byte array. xpp.nextToken(); @@ -1151,6 +1253,27 @@ public class DashManifestParser extends DefaultHandler return UriUtil.resolve(parentBaseUrl, parseText(xpp, "BaseURL")); } + /** + * Parses the availabilityTimeOffset value and returns the parsed value or the parent value if it + * doesn't exist. + * + * @param xpp The parser from which to read. + * @param parentAvailabilityTimeOffsetUs The availability time offset of a parent element in + * microseconds. + * @return The parsed availabilityTimeOffset in microseconds. + */ + protected long parseAvailabilityTimeOffsetUs( + XmlPullParser xpp, long parentAvailabilityTimeOffsetUs) { + String value = xpp.getAttributeValue(/* namespace= */ null, "availabilityTimeOffset"); + if (value == null) { + return parentAvailabilityTimeOffsetUs; + } + if ("INF".equals(value)) { + return Long.MAX_VALUE; + } + return (long) (Float.parseFloat(value) * C.MICROS_PER_SECOND); + } + // AudioChannelConfiguration parsing. protected int parseAudioChannelConfiguration(XmlPullParser xpp) @@ -1569,6 +1692,20 @@ public class DashManifestParser extends DefaultHandler return C.INDEX_UNSET; } + private static long getFinalAvailabilityTimeOffset( + long baseUrlAvailabilityTimeOffsetUs, long segmentBaseAvailabilityTimeOffsetUs) { + long availabilityTimeOffsetUs = segmentBaseAvailabilityTimeOffsetUs; + if (availabilityTimeOffsetUs == C.TIME_UNSET) { + // Fall back to BaseURL values if no SegmentBase specifies an offset. + availabilityTimeOffsetUs = baseUrlAvailabilityTimeOffsetUs; + } + if (availabilityTimeOffsetUs == Long.MAX_VALUE) { + // Replace INF value with TIME_UNSET to specify that all segments are available immediately. + availabilityTimeOffsetUs = C.TIME_UNSET; + } + return availabilityTimeOffsetUs; + } + /** A parsed Representation element. */ protected static final class RepresentationInfo { diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index 80ad15cd8f..03151631d3 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.dash.manifest; import android.net.Uri; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; @@ -275,7 +276,7 @@ public abstract class Representation { public static class MultiSegmentRepresentation extends Representation implements DashSegmentIndex { - private final MultiSegmentBase segmentBase; + @VisibleForTesting /* package */ final MultiSegmentBase segmentBase; /** * @param revisionId Identifies the revision of the content. diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java index b5ca31c151..5de2814b29 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.dash.manifest; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; @@ -118,6 +120,15 @@ public abstract class SegmentBase { /* package */ final long duration; @Nullable /* package */ final List segmentTimeline; + /** + * Offset to the current realtime at which segments become available, in microseconds, or {@link + * C#TIME_UNSET} if all segments are available immediately. + * + *

      Segments will be available once their end time ≤ currentRealTime + + * availabilityTimeOffset. + */ + /* package */ final long availabilityTimeOffsetUs; + /** * @param initialization A {@link RangedUri} corresponding to initialization data, if such data * exists. @@ -131,6 +142,8 @@ public abstract class SegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. */ public MultiSegmentBase( @Nullable RangedUri initialization, @@ -138,11 +151,13 @@ public abstract class SegmentBase { long presentationTimeOffset, long startNumber, long duration, - @Nullable List segmentTimeline) { + @Nullable List segmentTimeline, + long availabilityTimeOffsetUs) { super(initialization, timescale, presentationTimeOffset); this.startNumber = startNumber; this.duration = duration; this.segmentTimeline = segmentTimeline; + this.availabilityTimeOffsetUs = availabilityTimeOffsetUs; } /** @see DashSegmentIndex#getSegmentNum(long, long) */ @@ -157,9 +172,11 @@ public abstract class SegmentBase { long durationUs = (duration * C.MICROS_PER_SECOND) / timescale; long segmentNum = startNumber + timeUs / durationUs; // Ensure we stay within bounds. - return segmentNum < firstSegmentNum ? firstSegmentNum - : segmentCount == DashSegmentIndex.INDEX_UNBOUNDED ? segmentNum - : Math.min(segmentNum, firstSegmentNum + segmentCount - 1); + return segmentNum < firstSegmentNum + ? firstSegmentNum + : segmentCount == DashSegmentIndex.INDEX_UNBOUNDED + ? segmentNum + : min(segmentNum, firstSegmentNum + segmentCount - 1); } else { // The index cannot be unbounded. Identify the segment using binary search. long lowIndex = firstSegmentNum; @@ -251,6 +268,8 @@ public abstract class SegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. * @param mediaSegments A list of {@link RangedUri}s indicating the locations of the segments. */ public SegmentList( @@ -260,9 +279,16 @@ public abstract class SegmentBase { long startNumber, long duration, @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, @Nullable List mediaSegments) { - super(initialization, timescale, presentationTimeOffset, startNumber, duration, - segmentTimeline); + super( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + segmentTimeline, + availabilityTimeOffsetUs); this.mediaSegments = mediaSegments; } @@ -307,6 +333,8 @@ public abstract class SegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. * @param initializationTemplate A template defining the location of initialization data, if * such data exists. If non-null then the {@code initialization} parameter is ignored. If * null then {@code initialization} will be used. @@ -320,6 +348,7 @@ public abstract class SegmentBase { long endNumber, long duration, @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, @Nullable UrlTemplate mediaTemplate) { super( @@ -328,7 +357,8 @@ public abstract class SegmentBase { presentationTimeOffset, startNumber, duration, - segmentTimeline); + segmentTimeline, + availabilityTimeOffsetUs); this.initializationTemplate = initializationTemplate; this.mediaTemplate = mediaTemplate; this.endNumber = endNumber; diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java index 7b85d46f66..7b99d55fd9 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/offline/DashDownloader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.source.dash.offline; import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.offline.DownloadException; import com.google.android.exoplayer2.offline.SegmentDownloader; @@ -33,11 +34,14 @@ import com.google.android.exoplayer2.source.dash.manifest.RangedUri; import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.RunnableFutureTask; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executor; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** * A downloader for DASH streams. @@ -54,7 +58,11 @@ import java.util.concurrent.Executor; * // period. * DashDownloader dashDownloader = * new DashDownloader( - * manifestUrl, Collections.singletonList(new StreamKey(0, 0, 0)), cacheDataSourceFactory); + * new MediaItem.Builder() + * .setUri(manifestUrl) + * .setStreamKeys(Collections.singletonList(new StreamKey(0, 0, 0))) + * .build(), + * cacheDataSourceFactory); * // Perform the download. * dashDownloader.download(progressListener); * // Use the downloaded data for playback. @@ -64,22 +72,44 @@ import java.util.concurrent.Executor; */ public final class DashDownloader extends SegmentDownloader { - /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which representations in the manifest should be selected for - * download. If empty, all representations are downloaded. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. - */ + /** @deprecated Use {@link #DashDownloader(MediaItem, CacheDataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public DashDownloader( Uri manifestUri, List streamKeys, CacheDataSource.Factory cacheDataSourceFactory) { this(manifestUri, streamKeys, cacheDataSourceFactory, Runnable::run); } /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which representations in the manifest should be selected for - * download. If empty, all representations are downloaded. + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public DashDownloader(MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #DashDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. + */ + @Deprecated + public DashDownloader( + Uri manifestUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(manifestUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -87,17 +117,33 @@ public final class DashDownloader extends SegmentDownloader { * allowing parts of it to be executed in parallel. */ public DashDownloader( - Uri manifestUri, - List streamKeys, + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + this(mediaItem, new DashManifestParser(), cacheDataSourceFactory, executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param manifestParser A parser for DASH manifests. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + * @param executor An {@link Executor} used to make requests for the media being downloaded. + * Providing an {@link Executor} that uses multiple threads will speed up the download by + * allowing parts of it to be executed in parallel. + */ + public DashDownloader( + MediaItem mediaItem, + Parser manifestParser, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - super(manifestUri, new DashManifestParser(), streamKeys, cacheDataSourceFactory, executor); + super(mediaItem, manifestParser, cacheDataSourceFactory, executor); } @Override protected List getSegments( - DataSource dataSource, DashManifest manifest, boolean allowIncompleteList) - throws IOException { + DataSource dataSource, DashManifest manifest, boolean removing) + throws IOException, InterruptedException { ArrayList segments = new ArrayList<>(); for (int i = 0; i < manifest.getPeriodCount(); i++) { Period period = manifest.getPeriod(i); @@ -106,36 +152,31 @@ public final class DashDownloader extends SegmentDownloader { List adaptationSets = period.adaptationSets; for (int j = 0; j < adaptationSets.size(); j++) { addSegmentsForAdaptationSet( - dataSource, - adaptationSets.get(j), - periodStartUs, - periodDurationUs, - allowIncompleteList, - segments); + dataSource, adaptationSets.get(j), periodStartUs, periodDurationUs, removing, segments); } } return segments; } - private static void addSegmentsForAdaptationSet( + private void addSegmentsForAdaptationSet( DataSource dataSource, AdaptationSet adaptationSet, long periodStartUs, long periodDurationUs, - boolean allowIncompleteList, + boolean removing, ArrayList out) - throws IOException { + throws IOException, InterruptedException { for (int i = 0; i < adaptationSet.representations.size(); i++) { Representation representation = adaptationSet.representations.get(i); DashSegmentIndex index; try { - index = getSegmentIndex(dataSource, adaptationSet.type, representation); + index = getSegmentIndex(dataSource, adaptationSet.type, representation, removing); if (index == null) { // Loading succeeded but there was no index. throw new DownloadException("Missing segment index"); } } catch (IOException e) { - if (!allowIncompleteList) { + if (!removing) { throw e; } // Generating an incomplete segment list is allowed. Advance to the next representation. @@ -171,16 +212,24 @@ public final class DashDownloader extends SegmentDownloader { out.add(new Segment(startTimeUs, dataSpec)); } - private static @Nullable DashSegmentIndex getSegmentIndex( - DataSource dataSource, int trackType, Representation representation) throws IOException { + @Nullable + private DashSegmentIndex getSegmentIndex( + DataSource dataSource, int trackType, Representation representation, boolean removing) + throws IOException, InterruptedException { DashSegmentIndex index = representation.getIndex(); if (index != null) { return index; } - ChunkIndex seekMap = DashUtil.loadChunkIndex(dataSource, trackType, representation); + RunnableFutureTask<@NullableType ChunkIndex, IOException> runnable = + new RunnableFutureTask<@NullableType ChunkIndex, IOException>() { + @Override + protected @NullableType ChunkIndex doWork() throws IOException { + return DashUtil.loadChunkIndex(dataSource, trackType, representation); + } + }; + @Nullable ChunkIndex seekMap = execute(runnable, removing); return seekMap == null ? null : new DashWrappingSegmentIndex(seekMap, representation.presentationTimeOffsetUs); } - } diff --git a/library/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 5a5318c670..a21e73b0ab 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 @@ -18,44 +18,39 @@ package com.google.android.exoplayer2.source.dash; import static org.mockito.Mockito.mock; import android.net.Uri; -import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; 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.MediaSourceEventListener; 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; -import com.google.android.exoplayer2.source.dash.manifest.Descriptor; -import com.google.android.exoplayer2.source.dash.manifest.Period; -import com.google.android.exoplayer2.source.dash.manifest.Representation; -import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SingleSegmentBase; -import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; +import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; import com.google.android.exoplayer2.testutil.MediaPeriodAsserts; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; -import java.util.Arrays; -import java.util.Collections; +import java.io.IOException; +import java.io.InputStream; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link DashMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class DashMediaPeriodTest { @Test - public void getStreamKeys_isCompatibleWithDashManifestFilter() { + public void getStreamKeys_isCompatibleWithDashManifestFilter() throws IOException { // Test manifest which covers various edge cases: // - Multiple periods. // - Single and multiple representations per adaptation set. @@ -63,54 +58,7 @@ 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 manifest = - createDashManifest( - createPeriod( - createAdaptationSet( - /* id= */ 0, - C.TRACK_TYPE_VIDEO, - /* descriptor= */ null, - createVideoRepresentation(/* bitrate= */ 1000000))), - createPeriod( - createAdaptationSet( - /* id= */ 100, - C.TRACK_TYPE_VIDEO, - createSwitchDescriptor(/* ids...= */ 103, 104), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 200000), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 400000), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 600000)), - createAdaptationSet( - /* id= */ 101, - C.TRACK_TYPE_AUDIO, - createSwitchDescriptor(/* ids...= */ 102), - createAudioRepresentation(/* bitrate= */ 48000), - createAudioRepresentation(/* bitrate= */ 96000)), - createAdaptationSet( - /* id= */ 102, - C.TRACK_TYPE_AUDIO, - createSwitchDescriptor(/* ids...= */ 101), - createAudioRepresentation(/* bitrate= */ 256000)), - createAdaptationSet( - /* id= */ 103, - C.TRACK_TYPE_VIDEO, - createSwitchDescriptor(/* ids...= */ 100, 104), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 800000), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 1000000)), - createAdaptationSet( - /* id= */ 104, - C.TRACK_TYPE_VIDEO, - createSwitchDescriptor(/* ids...= */ 100, 103), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 2000000)), - createAdaptationSet( - /* id= */ 105, - C.TRACK_TYPE_TEXT, - /* descriptor= */ null, - createTextRepresentation(/* language= */ "eng")), - createAdaptationSet( - /* id= */ 105, - C.TRACK_TYPE_TEXT, - /* descriptor= */ null, - createTextRepresentation(/* language= */ "ger")))); + DashManifest manifest = parseManifest("media/mpd/sample_mpd_stream_keys"); // Ignore embedded metadata as we don't want to select primary group just to get embedded track. MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration( @@ -121,32 +69,8 @@ public final class DashMediaPeriodTest { } @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)))); + public void adaptationSetSwitchingProperty_mergesTrackGroups() throws IOException { + DashManifest manifest = parseManifest("media/mpd/sample_mpd_switching_property"); DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); List adaptationSets = manifest.getPeriod(0).adaptationSets; @@ -166,32 +90,8 @@ public final class DashMediaPeriodTest { } @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)))); + public void trickPlayProperty_mergesTrackGroups() throws IOException { + DashManifest manifest = parseManifest("media/mpd/sample_mpd_trick_play_property"); DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); List adaptationSets = manifest.getPeriod(0).adaptationSets; @@ -212,32 +112,9 @@ public final class DashMediaPeriodTest { } @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)))); + public void adaptationSetSwitchingProperty_andTrickPlayProperty_mergesTrackGroups() + throws IOException { + DashManifest manifest = parseManifest("media/mpd/sample_mpd_switching_and_trick_play_property"); DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); List adaptationSets = manifest.getPeriod(0).adaptationSets; @@ -256,7 +133,68 @@ public final class DashMediaPeriodTest { MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); } + @Test + public void cea608AccessibilityDescriptor_createsCea608TrackGroup() throws IOException { + DashManifest manifest = parseManifest("media/mpd/sample_mpd_cea_608_accessibility"); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect two adaptation sets. The first containing the video representations, and the second + // containing the embedded CEA-608 tracks. + Format.Builder cea608FormatBuilder = + new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608); + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format), + new TrackGroup( + cea608FormatBuilder + .setId("123:cea608:1") + .setLanguage("eng") + .setAccessibilityChannel(1) + .build(), + cea608FormatBuilder + .setId("123:cea608:3") + .setLanguage("deu") + .setAccessibilityChannel(3) + .build())); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + + @Test + public void cea708AccessibilityDescriptor_createsCea708TrackGroup() throws IOException { + DashManifest manifest = parseManifest("media/mpd/sample_mpd_cea_708_accessibility"); + DashMediaPeriod dashMediaPeriod = createDashMediaPeriod(manifest, 0); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + + // We expect two adaptation sets. The first containing the video representations, and the second + // containing the embedded CEA-708 tracks. + Format.Builder cea608FormatBuilder = + new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA708); + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format), + new TrackGroup( + cea608FormatBuilder + .setId("123:cea708:1") + .setLanguage("eng") + .setAccessibilityChannel(1) + .build(), + cea608FormatBuilder + .setId("123:cea708:2") + .setLanguage("deu") + .setAccessibilityChannel(2) + .build())); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); + } + private static DashMediaPeriod createDashMediaPeriod(DashManifest manifest, int periodIndex) { + MediaPeriodId mediaPeriodId = new MediaPeriodId(/* periodUid= */ new Object()); return new DashMediaPeriod( /* id= */ periodIndex, manifest, @@ -264,12 +202,11 @@ public final class DashMediaPeriodTest { mock(DashChunkSource.Factory.class), mock(TransferListener.class), DrmSessionManager.getDummyDrmSessionManager(), + new DrmSessionEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId), mock(LoadErrorHandlingPolicy.class), - new EventDispatcher() - .withParameters( - /* windowIndex= */ 0, - /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), - /* mediaTimeOffsetMs= */ 0), + new MediaSourceEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId, /* mediaTimeOffsetMs= */ 0), /* elapsedRealtimeOffsetMs= */ 0, mock(LoaderErrorThrower.class), mock(Allocator.class), @@ -277,105 +214,9 @@ public final class DashMediaPeriodTest { mock(PlayerEmsgCallback.class)); } - private static DashManifest createDashManifest(Period... periods) { - return new DashManifest( - /* availabilityStartTimeMs= */ 0, - /* durationMs= */ 5000, - /* minBufferTimeMs= */ 1, - /* dynamic= */ false, - /* minUpdatePeriodMs= */ 2, - /* timeShiftBufferDepthMs= */ 3, - /* suggestedPresentationDelayMs= */ 4, - /* publishTimeMs= */ 12345, - /* programInformation= */ null, - new UtcTimingElement("", ""), - Uri.EMPTY, - Arrays.asList(periods)); - } - - private static Period createPeriod(AdaptationSet... adaptationSets) { - return new Period(/* id= */ null, /* startMs= */ 0, Arrays.asList(adaptationSets)); - } - - private static AdaptationSet createAdaptationSet( - int id, int trackType, @Nullable Descriptor descriptor, Representation... representations) { - return new AdaptationSet( - id, - trackType, - Arrays.asList(representations), - /* accessibilityDescriptors= */ Collections.emptyList(), - /* essentialProperties= */ Collections.emptyList(), - descriptor == null ? Collections.emptyList() : Collections.singletonList(descriptor)); - } - - private static Representation createVideoRepresentation(int bitrate) { - return Representation.newInstance( - /* revisionId= */ 0, - createVideoFormat(bitrate), - /* baseUrl= */ "", - new SingleSegmentBase()); - } - - private static Representation createVideoRepresentationWithInbandEventStream(int bitrate) { - return Representation.newInstance( - /* revisionId= */ 0, - createVideoFormat(bitrate), - /* baseUrl= */ "", - new SingleSegmentBase(), - Collections.singletonList(getInbandEventDescriptor())); - } - - private static Format createVideoFormat(int bitrate) { - return new Format.Builder() - .setContainerMimeType(MimeTypes.VIDEO_MP4) - .setSampleMimeType(MimeTypes.VIDEO_H264) - .setPeakBitrate(bitrate) - .build(); - } - - private static Representation createAudioRepresentation(int bitrate) { - Format format = - new Format.Builder() - .setContainerMimeType(MimeTypes.AUDIO_MP4) - .setSampleMimeType(MimeTypes.AUDIO_AAC) - .setPeakBitrate(bitrate) - .build(); - return Representation.newInstance( - /* revisionId= */ 0, format, /* baseUrl= */ "", new SingleSegmentBase()); - } - - private static Representation createTextRepresentation(String language) { - Format format = - new Format.Builder() - .setContainerMimeType(MimeTypes.APPLICATION_MP4) - .setSampleMimeType(MimeTypes.TEXT_VTT) - .setLanguage(language) - .build(); - return Representation.newInstance( - /* revisionId= */ 0, format, /* baseUrl= */ "", new SingleSegmentBase()); - } - - private static Descriptor createSwitchDescriptor(int... ids) { - StringBuilder idString = new StringBuilder(); - idString.append(ids[0]); - for (int i = 1; i < ids.length; i++) { - idString.append(",").append(ids[i]); - } - return new Descriptor( - /* schemeIdUri= */ "urn:mpeg:dash:adaptation-set-switching:2016", - /* value= */ idString.toString(), - /* 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"); + private static DashManifest parseManifest(String fileName) throws IOException { + InputStream inputStream = + TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), fileName); + return new DashManifestParser().parse(Uri.EMPTY, inputStream); } } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java index 3c8952fd62..aa65237095 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaSourceTest.java @@ -18,10 +18,16 @@ package com.google.android.exoplayer2.source.dash; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; +import android.net.Uri; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.ByteArrayInputStream; import java.io.IOException; import org.junit.Test; @@ -68,6 +74,119 @@ public final class DashMediaSourceTest { } } + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nullMediaItemTag_setsMediaItemTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nonNullMediaItemTag_doesNotOverrideMediaItemTag() { + Object factoryTag = new Object(); + Object mediaItemTag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(mediaItemTag).build(); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(factoryTag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factoryCreateMediaSource_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(tag).build(); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(new FileDataSource.Factory()).setTag(new Object()); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_emptyMediaItemStreamKeys_setsMediaItemStreamKeys() { + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + StreamKey streamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(new FileDataSource.Factory()) + .setStreamKeys(ImmutableList.of(streamKey)); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotsOverrideMediaItemStreamKeys() { + StreamKey mediaItemStreamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("http://www.google.com") + .setStreamKeys(ImmutableList.of(mediaItemStreamKey)) + .build(); + DashMediaSource.Factory factory = + new DashMediaSource.Factory(new FileDataSource.Factory()) + .setStreamKeys( + ImmutableList.of(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); + } + + @Test + public void replaceManifestUri_doesNotChangeMediaItem() { + DashMediaSource.Factory factory = new DashMediaSource.Factory(new FileDataSource.Factory()); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + DashMediaSource mediaSource = factory.createMediaSource(mediaItem); + + mediaSource.replaceManifestUri(Uri.EMPTY); + + assertThat(mediaSource.getMediaItem()).isEqualTo(mediaItem); + } + private static void assertParseStringToLong( long expected, ParsingLoadable.Parser parser, String data) throws IOException { long actual = parser.parse(null, new ByteArrayInputStream(Util.getUtf8Bytes(data))); 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 3176b06865..188d1b2a18 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 @@ -40,29 +40,29 @@ public final class DashUtilTest { @Test public void loadDrmInitDataFromManifest() throws Exception { Period period = newPeriod(newAdaptationSet(newRepresentation(newDrmInitData()))); - DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); - assertThat(drmInitData).isEqualTo(newDrmInitData()); + Format format = DashUtil.loadFormatWithDrmInitData(DummyDataSource.INSTANCE, period); + assertThat(format.drmInitData).isEqualTo(newDrmInitData()); } @Test public void loadDrmInitDataMissing() throws Exception { Period period = newPeriod(newAdaptationSet(newRepresentation(null /* no init data */))); - DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); - assertThat(drmInitData).isNull(); + Format format = DashUtil.loadFormatWithDrmInitData(DummyDataSource.INSTANCE, period); + assertThat(format.drmInitData).isNull(); } @Test public void loadDrmInitDataNoRepresentations() throws Exception { Period period = newPeriod(newAdaptationSet(/* no representation */ )); - DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); - assertThat(drmInitData).isNull(); + Format format = DashUtil.loadFormatWithDrmInitData(DummyDataSource.INSTANCE, period); + assertThat(format).isNull(); } @Test public void loadDrmInitDataNoAdaptationSets() throws Exception { Period period = newPeriod(/* no adaptation set */ ); - DrmInitData drmInitData = DashUtil.loadDrmInitData(DummyDataSource.INSTANCE, period); - assertThat(drmInitData).isNull(); + Format format = DashUtil.loadFormatWithDrmInitData(DummyDataSource.INSTANCE, period); + assertThat(format).isNull(); } private static Period newPeriod(AdaptationSet... adaptationSets) { diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java index 7c3fdfc5ac..ab7f456c55 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.dash; import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -36,7 +37,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withMimeType_dashSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_MPD).build(); @@ -49,7 +50,7 @@ public class DefaultMediaSourceFactoryTest { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -59,13 +60,13 @@ public class DefaultMediaSourceFactoryTest { MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - assertThat(mediaSource.getTag()).isEqualTo(tag); + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); } @Test public void createMediaSource_withPath_dashSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mpd").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -76,7 +77,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mpd").build(); MediaSource mediaSource = @@ -92,7 +93,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void getSupportedTypes_dashModule_containsTypeDash() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_DASH); diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java index 47087472ae..496dd9575d 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java @@ -28,9 +28,9 @@ import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SegmentTim import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; import java.io.IOException; import java.io.StringReader; -import java.nio.charset.Charset; import java.util.Collections; import java.util.List; import org.junit.Test; @@ -42,14 +42,21 @@ import org.xmlpull.v1.XmlPullParserFactory; @RunWith(AndroidJUnit4.class) public class DashManifestParserTest { - private static final String SAMPLE_MPD = "mpd/sample_mpd"; - private static final String SAMPLE_MPD_UNKNOWN_MIME_TYPE = "mpd/sample_mpd_unknown_mime_type"; - private static final String SAMPLE_MPD_SEGMENT_TEMPLATE = "mpd/sample_mpd_segment_template"; - private static final String SAMPLE_MPD_EVENT_STREAM = "mpd/sample_mpd_event_stream"; - private static final String SAMPLE_MPD_LABELS = "mpd/sample_mpd_labels"; - private static final String SAMPLE_MPD_ASSET_IDENTIFIER = "mpd/sample_mpd_asset_identifier"; - private static final String SAMPLE_MPD_TEXT = "mpd/sample_mpd_text"; - private static final String SAMPLE_MPD_TRICK_PLAY = "mpd/sample_mpd_trick_play"; + private static final String SAMPLE_MPD = "media/mpd/sample_mpd"; + private static final String SAMPLE_MPD_UNKNOWN_MIME_TYPE = + "media/mpd/sample_mpd_unknown_mime_type"; + private static final String SAMPLE_MPD_SEGMENT_TEMPLATE = "media/mpd/sample_mpd_segment_template"; + private static final String SAMPLE_MPD_EVENT_STREAM = "media/mpd/sample_mpd_event_stream"; + private static final String SAMPLE_MPD_LABELS = "media/mpd/sample_mpd_labels"; + private static final String SAMPLE_MPD_ASSET_IDENTIFIER = "media/mpd/sample_mpd_asset_identifier"; + private static final String SAMPLE_MPD_TEXT = "media/mpd/sample_mpd_text"; + private static final String SAMPLE_MPD_TRICK_PLAY = "media/mpd/sample_mpd_trick_play"; + private static final String SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_BASE_URL = + "media/mpd/sample_mpd_availabilityTimeOffset_baseUrl"; + private static final String SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_TEMPLATE = + "media/mpd/sample_mpd_availabilityTimeOffset_segmentTemplate"; + private static final String SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_LIST = + "media/mpd/sample_mpd_availabilityTimeOffset_segmentList"; private static final String NEXT_TAG_NAME = "Next"; private static final String NEXT_TAG = "<" + NEXT_TAG_NAME + "/>"; @@ -116,11 +123,7 @@ public class DashManifestParserTest { assertThat(eventStream1.events.length).isEqualTo(1); EventMessage expectedEvent1 = new EventMessage( - "urn:uuid:XYZY", - "call", - 10000, - 0, - "+ 1 800 10101010".getBytes(Charset.forName(C.UTF8_NAME))); + "urn:uuid:XYZY", "call", 10000, 0, "+ 1 800 10101010".getBytes(Charsets.UTF_8)); assertThat(eventStream1.events[0]).isEqualTo(expectedEvent1); assertThat(eventStream1.presentationTimesUs[0]).isEqualTo(0); @@ -472,6 +475,91 @@ public class DashManifestParserTest { assertThat(assetIdentifier.id).isEqualTo("uniqueId"); } + @Test + public void availabilityTimeOffset_staticManifest_setToTimeUnset() throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), SAMPLE_MPD_TEXT)); + + assertThat(manifest.getPeriodCount()).isEqualTo(1); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + assertThat(adaptationSets).hasSize(3); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(0))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(1))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(2))).isEqualTo(C.TIME_UNSET); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInBaseUrl_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_BASE_URL)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(5_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(4_321_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(9_876_543); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(0); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInSegmentTemplate_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_TEMPLATE)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(2_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(3_210_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(1_230_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(100_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(9_999_000); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInSegmentList_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_LIST)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(2_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(3_210_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(1_230_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(100_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(9_999_000); + } + private static List buildCea608AccessibilityDescriptors(String value) { return Collections.singletonList(new Descriptor("urn:scte:dash:cc:cea-608:2015", value, null)); } @@ -485,4 +573,13 @@ public class DashManifestParserTest { assertThat(xpp.getEventType()).isEqualTo(XmlPullParser.START_TAG); assertThat(xpp.getName()).isEqualTo(NEXT_TAG_NAME); } + + private static long getAvailabilityTimeOffsetUs(AdaptationSet adaptationSet) { + assertThat(adaptationSet.representations).isNotEmpty(); + Representation representation = adaptationSet.representations.get(0); + assertThat(representation).isInstanceOf(Representation.MultiSegmentRepresentation.class); + SegmentBase.MultiSegmentBase segmentBase = + ((Representation.MultiSegmentRepresentation) representation).segmentBase; + return segmentBase.availabilityTimeOffsetUs; + } } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java index b260bf2cee..a1b971068d 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestTest.java @@ -33,9 +33,9 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class DashManifestTest { - private static final UtcTimingElement DUMMY_UTC_TIMING = new UtcTimingElement("", ""); - private static final SingleSegmentBase DUMMY_SEGMENT_BASE = new SingleSegmentBase(); - private static final Format DUMMY_FORMAT = new Format.Builder().build(); + private static final UtcTimingElement UTC_TIMING = new UtcTimingElement("", ""); + private static final SingleSegmentBase SEGMENT_BASE = new SingleSegmentBase(); + private static final Format FORMAT = new Format.Builder().build(); @Test public void copy() { @@ -214,8 +214,7 @@ public class DashManifestTest { } private static Representation newRepresentation() { - return Representation.newInstance( - /* revisionId= */ 0, DUMMY_FORMAT, /* baseUrl= */ "", DUMMY_SEGMENT_BASE); + return Representation.newInstance(/* revisionId= */ 0, FORMAT, /* baseUrl= */ "", SEGMENT_BASE); } private static DashManifest newDashManifest(int duration, Period... periods) { @@ -229,7 +228,7 @@ public class DashManifestTest { /* suggestedPresentationDelayMs= */ 4, /* publishTimeMs= */ 12345, /* programInformation= */ null, - DUMMY_UTC_TIMING, + UTC_TIMING, Uri.EMPTY, Arrays.asList(periods)); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java index 71f7c9a187..95b460a4cf 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloadTestData.java @@ -16,8 +16,7 @@ package com.google.android.exoplayer2.source.dash.offline; import android.net.Uri; -import com.google.android.exoplayer2.C; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; /** Data for DASH downloading tests. */ /* package */ interface DashDownloadTestData { @@ -87,7 +86,7 @@ import java.nio.charset.Charset; + " \n" + " \n" + "") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); byte[] TEST_MPD_NO_INDEX = ("\n" @@ -100,5 +99,5 @@ import java.nio.charset.Charset; + " \n" + " \n" + "") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java index 49e111b7a7..d835b85725 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DashDownloaderTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.when; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; import com.google.android.exoplayer2.offline.DownloadException; import com.google.android.exoplayer2.offline.DownloadRequest; @@ -44,6 +45,7 @@ import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; @@ -69,7 +71,8 @@ public class DashDownloaderTest { MockitoAnnotations.initMocks(this); tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); progressListener = new ProgressListener(); } @@ -84,17 +87,17 @@ public class DashDownloaderTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( - new DownloadRequest( - "id", - DownloadRequest.TYPE_DASH, - Uri.parse("https://www.test.com/download"), - Collections.singletonList(new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0)), - /* customCacheKey= */ null, - /* data= */ null)); + new DownloadRequest.Builder(/* id= */ "id", Uri.parse("https://www.test.com/download")) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setStreamKeys( + Collections.singletonList( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0))) + .build()); assertThat(downloader).isInstanceOf(DashDownloader.class); } @@ -337,7 +340,9 @@ public class DashDownloaderTest { new CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(upstreamDataSourceFactory); - return new DashDownloader(TEST_MPD_URI, keysList(keys), cacheDataSourceFactory); + return new DashDownloader( + new MediaItem.Builder().setUri(TEST_MPD_URI).setStreamKeys(keysList(keys)).build(), + cacheDataSourceFactory); } private static ArrayList keysList(StreamKey... keys) { diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java index 5ecdba11eb..b2fae93bca 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadHelperTest.java @@ -15,13 +15,14 @@ */ package com.google.android.exoplayer2.source.dash.offline; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; @@ -31,16 +32,16 @@ public final class DownloadHelperTest { @Test public void staticDownloadHelperForDash_doesNotThrow() { - DownloadHelper.forDash( + DownloadHelper.forMediaItem( ApplicationProvider.getApplicationContext(), - Uri.parse("http://uri"), - new FakeDataSource.Factory(), - (handler, videoListener, audioListener, text, metadata) -> new Renderer[0]); - DownloadHelper.forDash( - Uri.parse("http://uri"), - new FakeDataSource.Factory(), + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_MPD).build(), (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], - /* drmSessionManager= */ DrmSessionManager.getDummyDrmSessionManager(), - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + new FakeDataSource.Factory()); + DownloadHelper.forMediaItem( + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_MPD).build(), + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, + (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], + new FakeDataSource.Factory(), + /* drmSessionManager= */ DrmSessionManager.getDummyDrmSessionManager()); } } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java index b16d5727b1..2993bb4442 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadManagerDashTest.java @@ -21,6 +21,7 @@ import static com.google.android.exoplayer2.source.dash.offline.DashDownloadTest import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCacheEmpty; import static com.google.android.exoplayer2.testutil.CacheAsserts.assertCachedData; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.content.Context; import androidx.test.core.app.ApplicationProvider; @@ -42,24 +43,22 @@ import com.google.android.exoplayer2.upstream.DataSource.Factory; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.util.ArrayList; import java.util.Collections; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import org.junit.After; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.MockitoAnnotations; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowLog; /** Tests {@link DownloadManager}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class DownloadManagerDashTest { private static final int ASSERT_TRUE_TIMEOUT_MS = 5000; @@ -72,17 +71,19 @@ public class DownloadManagerDashTest { private StreamKey fakeStreamKey2; private TestDownloadManagerListener downloadManagerListener; private DefaultDownloadIndex downloadIndex; - private DummyMainThread dummyMainThread; + private DummyMainThread testThread; @Before public void setUp() throws Exception { ShadowLog.stream = System.out; - dummyMainThread = new DummyMainThread(); + testThread = new DummyMainThread(); Context context = ApplicationProvider.getApplicationContext(); tempFolder = Util.createTempDirectory(context, "ExoPlayerTest"); File cacheFolder = new File(tempFolder, "cache"); cacheFolder.mkdir(); - cache = new SimpleCache(cacheFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache( + cacheFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); MockitoAnnotations.initMocks(this); fakeDataSet = new FakeDataSet() @@ -105,7 +106,7 @@ public class DownloadManagerDashTest { public void tearDown() { runOnMainThread(() -> downloadManager.release()); Util.recursiveDelete(tempFolder); - dummyMainThread.release(); + testThread.release(); } // Disabled due to flakiness. @@ -144,7 +145,7 @@ public class DownloadManagerDashTest { // Revert fakeDataSet to normal. fakeDataSet.setData(TEST_MPD_URI, TEST_MPD); - dummyMainThread.runOnMainThread(this::createDownloadManager); + testThread.runOnMainThread(this::createDownloadManager); // Block on the test thread. downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); @@ -204,8 +205,7 @@ public class DownloadManagerDashTest { .appendReadData(TestUtil.buildTestData(5)) .endData(); handleDownloadRequest(fakeStreamKey1); - assertThat(downloadInProgressLatch.await(ASSERT_TRUE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) - .isTrue(); + assertThat(downloadInProgressLatch.await(ASSERT_TRUE_TIMEOUT_MS, MILLISECONDS)).isTrue(); handleRemoveAction(); downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); @@ -220,13 +220,10 @@ public class DownloadManagerDashTest { private DownloadRequest getDownloadRequest(StreamKey... keys) { ArrayList keysList = new ArrayList<>(); Collections.addAll(keysList, keys); - return new DownloadRequest( - TEST_ID, - DownloadRequest.TYPE_DASH, - TEST_MPD_URI, - keysList, - /* customCacheKey= */ null, - null); + return new DownloadRequest.Builder(TEST_ID, TEST_MPD_URI) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setStreamKeys(keysList) + .build(); } private void handleRemoveAction() { @@ -241,7 +238,8 @@ public class DownloadManagerDashTest { new DefaultDownloaderFactory( new CacheDataSource.Factory() .setCache(cache) - .setUpstreamDataSourceFactory(fakeDataSourceFactory)); + .setUpstreamDataSourceFactory(fakeDataSourceFactory), + /* executor= */ Runnable::run); downloadManager = new DownloadManager( ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory); @@ -252,6 +250,6 @@ public class DownloadManagerDashTest { } private void runOnMainThread(TestRunnable r) { - dummyMainThread.runTestOnMainThread(r); + testThread.runTestOnMainThread(r); } } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java index 0073b3bfaf..6b528cdd82 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/offline/DownloadServiceDashTest.java @@ -45,6 +45,7 @@ import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import com.google.android.exoplayer2.util.ConditionVariable; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.IOException; @@ -56,11 +57,9 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link DownloadService}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class DownloadServiceDashTest { private SimpleCache cache; @@ -72,14 +71,15 @@ public class DownloadServiceDashTest { private DownloadService dashDownloadService; private ConditionVariable pauseDownloadCondition; private TestDownloadManagerListener downloadManagerListener; - private DummyMainThread dummyMainThread; + private DummyMainThread testThread; @Before public void setUp() throws IOException { - dummyMainThread = new DummyMainThread(); + testThread = new DummyMainThread(); context = ApplicationProvider.getApplicationContext(); tempFolder = Util.createTempDirectory(context, "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); Runnable pauseAction = () -> { @@ -109,7 +109,7 @@ public class DownloadServiceDashTest { fakeStreamKey1 = new StreamKey(0, 0, 0); fakeStreamKey2 = new StreamKey(0, 1, 0); - dummyMainThread.runTestOnMainThread( + testThread.runTestOnMainThread( () -> { DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(TestUtil.getInMemoryDatabaseProvider()); @@ -117,7 +117,8 @@ public class DownloadServiceDashTest { new DefaultDownloaderFactory( new CacheDataSource.Factory() .setCache(cache) - .setUpstreamDataSourceFactory(fakeDataSourceFactory)); + .setUpstreamDataSourceFactory(fakeDataSourceFactory), + /* executor= */ Runnable::run); final DownloadManager dashDownloadManager = new DownloadManager( ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory); @@ -148,9 +149,9 @@ public class DownloadServiceDashTest { @After public void tearDown() { - dummyMainThread.runOnMainThread(() -> dashDownloadService.onDestroy()); + testThread.runOnMainThread(() -> dashDownloadService.onDestroy()); Util.recursiveDelete(tempFolder); - dummyMainThread.release(); + testThread.release(); } @Ignore // b/78877092 @@ -192,7 +193,7 @@ public class DownloadServiceDashTest { } private void removeAll() { - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> { Intent startIntent = DownloadService.buildRemoveDownloadIntent( @@ -205,14 +206,12 @@ public class DownloadServiceDashTest { ArrayList keysList = new ArrayList<>(); Collections.addAll(keysList, keys); DownloadRequest action = - new DownloadRequest( - TEST_ID, - DownloadRequest.TYPE_DASH, - TEST_MPD_URI, - keysList, - /* customCacheKey= */ null, - null); - dummyMainThread.runOnMainThread( + new DownloadRequest.Builder(TEST_ID, TEST_MPD_URI) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setStreamKeys(keysList) + .build(); + + testThread.runOnMainThread( () -> { Intent startIntent = DownloadService.buildAddDownloadIntent( diff --git a/library/extractor/build.gradle b/library/extractor/build.gradle index 26b38705ee..82c2309c5f 100644 --- a/library/extractor/build.gradle +++ b/library/extractor/build.gradle @@ -11,22 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true @@ -34,16 +21,21 @@ android { } sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' - - testOptions.unitTests.includeAndroidResources = true } dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion - compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion - compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation project(modulePrefix + 'library-common') + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion + compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') testImplementation project(modulePrefix + 'testdata') diff --git a/library/extractor/proguard-rules.txt b/library/extractor/proguard-rules.txt index 5f97a491cb..d79f79a4a1 100644 --- a/library/extractor/proguard-rules.txt +++ b/library/extractor/proguard-rules.txt @@ -3,7 +3,7 @@ # Constructors accessed via reflection in DefaultExtractorsFactory -dontnote com.google.android.exoplayer2.ext.flac.FlacExtractor -keepclassmembers class com.google.android.exoplayer2.ext.flac.FlacExtractor { - (); + (int); } # Don't warn about checkerframework and Kotlin annotations diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java index 4c3f97975e..525b335f13 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/CeaUtil.java @@ -33,8 +33,8 @@ public final class CeaUtil { private static final int PROVIDER_CODE_DIRECTV = 0x2F; /** - * Consumes the unescaped content of an SEI NAL unit, writing the content of any CEA-608 messages - * as samples to all of the provided outputs. + * Consumes the unescaped content of an SEI NAL unit, writing the content of any CEA-608/708 + * messages as samples to all of the provided outputs. * * @param presentationTimeUs The presentation time in microseconds for any samples. * @param seiBuffer The unescaped SEI NAL unit data, excluding the NAL unit start code and type. diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java index abce01b5ef..4db7edf685 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor; +import static java.lang.Math.max; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; @@ -106,7 +108,7 @@ public class ConstantBitrateSeekMap implements SeekMap { * @return The stream time in microseconds for the given stream position. */ private static long getTimeUsAtPosition(long position, long firstFrameBytePosition, int bitrate) { - return Math.max(0, position - firstFrameBytePosition) + return max(0, position - firstFrameBytePosition) * C.BITS_PER_BYTE * C.MICROS_PER_SECOND / bitrate; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java index 4ab306a234..38844f61c7 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.upstream.DataReader; import com.google.android.exoplayer2.util.Assertions; @@ -85,8 +87,7 @@ public final class DefaultExtractorInput implements ExtractorInput { public int skip(int length) throws IOException { int bytesSkipped = skipFromPeekBuffer(length); if (bytesSkipped == 0) { - bytesSkipped = - readFromUpstream(scratchSpace, 0, Math.min(length, scratchSpace.length), 0, true); + bytesSkipped = readFromUpstream(scratchSpace, 0, min(length, scratchSpace.length), 0, true); } commitBytesRead(bytesSkipped); return bytesSkipped; @@ -96,7 +97,7 @@ public final class DefaultExtractorInput implements ExtractorInput { public boolean skipFully(int length, boolean allowEndOfInput) throws IOException { int bytesSkipped = skipFromPeekBuffer(length); while (bytesSkipped < length && bytesSkipped != C.RESULT_END_OF_INPUT) { - int minLength = Math.min(length, bytesSkipped + scratchSpace.length); + int minLength = min(length, bytesSkipped + scratchSpace.length); bytesSkipped = readFromUpstream(scratchSpace, -bytesSkipped, minLength, bytesSkipped, allowEndOfInput); } @@ -127,7 +128,7 @@ public final class DefaultExtractorInput implements ExtractorInput { } peekBufferLength += bytesPeeked; } else { - bytesPeeked = Math.min(length, peekBufferRemainingBytes); + bytesPeeked = min(length, peekBufferRemainingBytes); } System.arraycopy(peekBuffer, peekBufferPosition, target, offset, bytesPeeked); peekBufferPosition += bytesPeeked; @@ -217,7 +218,7 @@ public final class DefaultExtractorInput implements ExtractorInput { * @return The number of bytes skipped. */ private int skipFromPeekBuffer(int length) { - int bytesSkipped = Math.min(peekBufferLength, length); + int bytesSkipped = min(peekBufferLength, length); updatePeekBuffer(bytesSkipped); return bytesSkipped; } @@ -234,7 +235,7 @@ public final class DefaultExtractorInput implements ExtractorInput { if (peekBufferLength == 0) { return 0; } - int peekBytes = Math.min(peekBufferLength, length); + int peekBytes = min(peekBufferLength, length); System.arraycopy(peekBuffer, 0, target, offset, peekBytes); updatePeekBuffer(peekBytes); return peekBytes; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 9306a146d5..2eba1b1cca 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.extractor; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromResponseHeaders; +import static com.google.android.exoplayer2.util.FileTypes.inferFileTypeFromUri; + +import android.net.Uri; import androidx.annotation.Nullable; import com.google.android.exoplayer2.extractor.amr.AmrExtractor; import com.google.android.exoplayer2.extractor.flac.FlacExtractor; @@ -32,8 +36,13 @@ import com.google.android.exoplayer2.extractor.ts.PsExtractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.extractor.ts.TsPayloadReader; import com.google.android.exoplayer2.extractor.wav.WavExtractor; +import com.google.android.exoplayer2.util.FileTypes; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * An {@link ExtractorsFactory} that provides an array of extractors for the following formats: @@ -54,7 +63,8 @@ import java.lang.reflect.Constructor; *

    9. AMR ({@link AmrExtractor}) *
    10. FLAC *
        - *
      • If available, the FLAC extension extractor is used. + *
      • If available, the FLAC extension's {@code + * com.google.android.exoplayer2.ext.flac.FlacExtractor} is used. *
      • Otherwise, the core {@link FlacExtractor} is used. Note that Android devices do not * generally include a FLAC decoder before API 27. This can be worked around by using * the FLAC extension or the FFmpeg extension. @@ -63,6 +73,25 @@ import java.lang.reflect.Constructor; */ public final class DefaultExtractorsFactory implements ExtractorsFactory { + // Extractors order is optimized according to + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. + private static final int[] DEFAULT_EXTRACTOR_ORDER = + new int[] { + FileTypes.FLV, + FileTypes.FLAC, + FileTypes.WAV, + FileTypes.MP4, + FileTypes.AMR, + FileTypes.PS, + FileTypes.OGG, + FileTypes.TS, + FileTypes.MATROSKA, + FileTypes.ADTS, + FileTypes.AC3, + FileTypes.AC4, + FileTypes.MP3, + }; + @Nullable private static final Constructor FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR; @@ -80,7 +109,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { flacExtensionExtractorConstructor = Class.forName("com.google.android.exoplayer2.ext.flac.FlacExtractor") .asSubclass(Extractor.class) - .getConstructor(); + .getConstructor(int.class); } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) } catch (ClassNotFoundException e) { @@ -95,7 +124,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { private boolean constantBitrateSeekingEnabled; @AdtsExtractor.Flags private int adtsFlags; @AmrExtractor.Flags private int amrFlags; - @FlacExtractor.Flags private int coreFlacFlags; + @FlacExtractor.Flags private int flacFlags; @MatroskaExtractor.Flags private int matroskaFlags; @Mp4Extractor.Flags private int mp4Flags; @FragmentedMp4Extractor.Flags private int fragmentedMp4Flags; @@ -150,15 +179,17 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { } /** - * Sets flags for {@link FlacExtractor} instances created by the factory. + * Sets flags for {@link FlacExtractor} instances created by the factory. The flags are also used + * by {@code com.google.android.exoplayer2.ext.flac.FlacExtractor} instances if the FLAC extension + * is being used. * * @see FlacExtractor#FlacExtractor(int) * @param flags The flags to use. * @return The factory, for convenience. */ - public synchronized DefaultExtractorsFactory setCoreFlacExtractorFlags( + public synchronized DefaultExtractorsFactory setFlacExtractorFlags( @FlacExtractor.Flags int flags) { - this.coreFlacFlags = flags; + this.flacFlags = flags; return this; } @@ -240,48 +271,103 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { @Override public synchronized Extractor[] createExtractors() { - Extractor[] extractors = new Extractor[14]; - // Extractors order is optimized according to - // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. - extractors[0] = new FlvExtractor(); - if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { - try { - extractors[1] = FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance(); - } catch (Exception e) { - // Should never happen. - throw new IllegalStateException("Unexpected error creating FLAC extractor", e); - } - } else { - extractors[1] = new FlacExtractor(coreFlacFlags); - } - extractors[2] = new WavExtractor(); - extractors[3] = new FragmentedMp4Extractor(fragmentedMp4Flags); - extractors[4] = new Mp4Extractor(mp4Flags); - extractors[5] = - new AmrExtractor( - amrFlags - | (constantBitrateSeekingEnabled - ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - : 0)); - extractors[6] = new PsExtractor(); - extractors[7] = new OggExtractor(); - extractors[8] = new TsExtractor(tsMode, tsFlags); - extractors[9] = new MatroskaExtractor(matroskaFlags); - extractors[10] = - new AdtsExtractor( - adtsFlags - | (constantBitrateSeekingEnabled - ? AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - : 0)); - extractors[11] = new Ac3Extractor(); - extractors[12] = new Ac4Extractor(); - extractors[13] = - new Mp3Extractor( - mp3Flags - | (constantBitrateSeekingEnabled - ? Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - : 0)); - return extractors; + return createExtractors(Uri.EMPTY, new HashMap<>()); } + @Override + public synchronized Extractor[] createExtractors( + Uri uri, Map> responseHeaders) { + List extractors = new ArrayList<>(/* initialCapacity= */ 14); + + @FileTypes.Type + int responseHeadersInferredFileType = inferFileTypeFromResponseHeaders(responseHeaders); + if (responseHeadersInferredFileType != FileTypes.UNKNOWN) { + addExtractorsForFileType(responseHeadersInferredFileType, extractors); + } + + @FileTypes.Type int uriInferredFileType = inferFileTypeFromUri(uri); + if (uriInferredFileType != FileTypes.UNKNOWN + && uriInferredFileType != responseHeadersInferredFileType) { + addExtractorsForFileType(uriInferredFileType, extractors); + } + + for (int fileType : DEFAULT_EXTRACTOR_ORDER) { + if (fileType != responseHeadersInferredFileType && fileType != uriInferredFileType) { + addExtractorsForFileType(fileType, extractors); + } + } + + return extractors.toArray(new Extractor[extractors.size()]); + } + + private void addExtractorsForFileType(@FileTypes.Type int fileType, List extractors) { + switch (fileType) { + case FileTypes.AC3: + extractors.add(new Ac3Extractor()); + break; + case FileTypes.AC4: + extractors.add(new Ac4Extractor()); + break; + case FileTypes.ADTS: + extractors.add( + new AdtsExtractor( + adtsFlags + | (constantBitrateSeekingEnabled + ? AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0))); + break; + case FileTypes.AMR: + extractors.add( + new AmrExtractor( + amrFlags + | (constantBitrateSeekingEnabled + ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0))); + break; + case FileTypes.FLAC: + if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { + try { + extractors.add(FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance(flacFlags)); + } catch (Exception e) { + // Should never happen. + throw new IllegalStateException("Unexpected error creating FLAC extractor", e); + } + } else { + extractors.add(new FlacExtractor(flacFlags)); + } + break; + case FileTypes.FLV: + extractors.add(new FlvExtractor()); + break; + case FileTypes.MATROSKA: + extractors.add(new MatroskaExtractor(matroskaFlags)); + break; + case FileTypes.MP3: + extractors.add( + new Mp3Extractor( + mp3Flags + | (constantBitrateSeekingEnabled + ? Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0))); + break; + case FileTypes.MP4: + extractors.add(new FragmentedMp4Extractor(fragmentedMp4Flags)); + extractors.add(new Mp4Extractor(mp4Flags)); + break; + case FileTypes.OGG: + extractors.add(new OggExtractor()); + break; + case FileTypes.PS: + extractors.add(new PsExtractor()); + break; + case FileTypes.TS: + extractors.add(new TsExtractor(tsMode, tsFlags)); + break; + case FileTypes.WAV: + extractors.add(new WavExtractor()); + break; + default: + break; + } + } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java index f199493500..51fc59fd24 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.extractor; -/** A dummy {@link ExtractorOutput} implementation. */ +/** A fake {@link ExtractorOutput} implementation. */ public final class DummyExtractorOutput implements ExtractorOutput { @Override diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java index 4700bbb480..94c4a9af94 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DummyTrackOutput.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -23,9 +25,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.EOFException; import java.io.IOException; -/** - * A dummy {@link TrackOutput} implementation. - */ +/** A fake {@link TrackOutput} implementation. */ public final class DummyTrackOutput implements TrackOutput { // Even though read data is discarded, data source implementations could be making use of the @@ -46,7 +46,7 @@ public final class DummyTrackOutput implements TrackOutput { public int sampleData( DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart) throws IOException { - int bytesToSkipByReading = Math.min(readBuffer.length, length); + int bytesToSkipByReading = min(readBuffer.length, length); int bytesSkipped = input.read(readBuffer, /* offset= */ 0, bytesToSkipByReading); if (bytesSkipped == C.RESULT_END_OF_INPUT) { if (allowEndOfInput) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java index d1371d56b6..c3920ca7da 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Extractor.java @@ -96,7 +96,7 @@ public interface Extractor { * @param seekPosition If {@link #RESULT_SEEK} is returned, this holder is updated to hold the * position of the required data. * @return One of the {@code RESULT_} values defined in this interface. - * @throws IOException If an error occurred reading from the input. + * @throws IOException If an error occurred reading from or parsing the input. */ @ReadResult int read(ExtractorInput input, PositionHolder seekPosition) throws IOException; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorOutput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorOutput.java index a59cb1d1f2..95b1daeb6e 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorOutput.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorOutput.java @@ -20,10 +20,34 @@ package com.google.android.exoplayer2.extractor; */ public interface ExtractorOutput { + /** + * Placeholder {@link ExtractorOutput} implementation throwing an {@link + * UnsupportedOperationException} in each method. + */ + ExtractorOutput PLACEHOLDER = + new ExtractorOutput() { + + @Override + public TrackOutput track(int id, int type) { + throw new UnsupportedOperationException(); + } + + @Override + public void endTracks() { + throw new UnsupportedOperationException(); + } + + @Override + public void seekMap(SeekMap seekMap) { + throw new UnsupportedOperationException(); + } + }; + /** * Called by the {@link Extractor} to get the {@link TrackOutput} for a specific track. - *

        - * The same {@link TrackOutput} is returned if multiple calls are made with the same {@code id}. + * + *

        The same {@link TrackOutput} is returned if multiple calls are made with the same {@code + * id}. * * @param id A track identifier. * @param type The type of the track. Typically one of the {@link com.google.android.exoplayer2.C} diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java index ee29f376a1..97ae74b9d2 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java @@ -15,9 +15,31 @@ */ package com.google.android.exoplayer2.extractor; +import android.net.Uri; +import java.util.List; +import java.util.Map; + /** Factory for arrays of {@link Extractor} instances. */ public interface ExtractorsFactory { + /** + * Extractor factory that returns an empty list of extractors. Can be used whenever {@link + * Extractor Extractors} are not required. + */ + ExtractorsFactory EMPTY = () -> new Extractor[] {}; + /** Returns an array of new {@link Extractor} instances. */ Extractor[] createExtractors(); + + /** + * Returns an array of new {@link Extractor} instances. + * + * @param uri The {@link Uri} of the media to extract. + * @param responseHeaders The response headers of the media to extract, or an empty map if there + * are none. The map lookup should be case-insensitive. + * @return The {@link Extractor} instances. + */ + default Extractor[] createExtractors(Uri uri, Map> responseHeaders) { + return createExtractors(); + } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacFrameReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacFrameReader.java index 264c6d7b0d..fc1b121326 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacFrameReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacFrameReader.java @@ -107,10 +107,11 @@ public final class FlacFrameReader { ParsableByteArray scratch = new ParsableByteArray(FlacConstants.MAX_FRAME_HEADER_SIZE); System.arraycopy( - frameStartBytes, /* srcPos= */ 0, scratch.data, /* destPos= */ 0, /* length= */ 2); + frameStartBytes, /* srcPos= */ 0, scratch.getData(), /* destPos= */ 0, /* length= */ 2); int totalBytesPeeked = - ExtractorUtil.peekToLength(input, scratch.data, 2, FlacConstants.MAX_FRAME_HEADER_SIZE - 2); + ExtractorUtil.peekToLength( + input, scratch.getData(), 2, FlacConstants.MAX_FRAME_HEADER_SIZE - 2); scratch.setLimit(totalBytesPeeked); input.resetPeekPosition(); @@ -145,7 +146,7 @@ public final class FlacFrameReader { int maxUtf8SampleNumberSize = isBlockSizeVariable ? 7 : 6; ParsableByteArray scratch = new ParsableByteArray(maxUtf8SampleNumberSize); int totalBytesPeeked = - ExtractorUtil.peekToLength(input, scratch.data, 0, maxUtf8SampleNumberSize); + ExtractorUtil.peekToLength(input, scratch.getData(), 0, maxUtf8SampleNumberSize); scratch.setLimit(totalBytesPeeked); input.resetPeekPosition(); @@ -325,7 +326,7 @@ public final class FlacFrameReader { int crc = data.readUnsignedByte(); int frameEndPosition = data.getPosition(); int expectedCrc = - Util.crc8(data.data, frameStartPosition, frameEndPosition - 1, /* initialValue= */ 0); + Util.crc8(data.getData(), frameStartPosition, frameEndPosition - 1, /* initialValue= */ 0); return crc == expectedCrc; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java index 65e65c401e..922ef0f3da 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/FlacMetadataReader.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.extractor; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.VorbisUtil.CommentHeader; import com.google.android.exoplayer2.metadata.Metadata; @@ -25,8 +24,8 @@ import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.util.FlacConstants; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.base.Charsets; import java.io.IOException; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -80,7 +79,7 @@ public final class FlacMetadataReader { */ public static boolean checkAndPeekStreamMarker(ExtractorInput input) throws IOException { ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); - input.peekFully(scratch.data, 0, FlacConstants.STREAM_MARKER_SIZE); + input.peekFully(scratch.getData(), 0, FlacConstants.STREAM_MARKER_SIZE); return scratch.readUnsignedInt() == STREAM_MARKER; } @@ -119,7 +118,7 @@ public final class FlacMetadataReader { */ public static void readStreamMarker(ExtractorInput input) throws IOException { ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); - input.readFully(scratch.data, 0, FlacConstants.STREAM_MARKER_SIZE); + input.readFully(scratch.getData(), 0, FlacConstants.STREAM_MARKER_SIZE); if (scratch.readUnsignedInt() != STREAM_MARKER) { throw new ParserException("Failed to read FLAC stream marker."); } @@ -193,7 +192,7 @@ public final class FlacMetadataReader { data.skipBytes(1); int length = data.readUnsignedInt24(); - long seekTableEndPosition = data.getPosition() + length; + long seekTableEndPosition = (long) data.getPosition() + length; int seekPointCount = length / SEEK_POINT_SIZE; long[] pointSampleNumbers = new long[seekPointCount]; long[] pointOffsets = new long[seekPointCount]; @@ -229,7 +228,7 @@ public final class FlacMetadataReader { public static int getFrameStartMarker(ExtractorInput input) throws IOException { input.resetPeekPosition(); ParsableByteArray scratch = new ParsableByteArray(2); - input.peekFully(scratch.data, 0, 2); + input.peekFully(scratch.getData(), 0, 2); int frameStartMarker = scratch.readUnsignedShort(); int syncCode = frameStartMarker >> 2; @@ -252,14 +251,14 @@ public final class FlacMetadataReader { private static FlacStreamMetadata.SeekTable readSeekTableMetadataBlock( ExtractorInput input, int length) throws IOException { ParsableByteArray scratch = new ParsableByteArray(length); - input.readFully(scratch.data, 0, length); + input.readFully(scratch.getData(), 0, length); return readSeekTableMetadataBlock(scratch); } private static List readVorbisCommentMetadataBlock(ExtractorInput input, int length) throws IOException { ParsableByteArray scratch = new ParsableByteArray(length); - input.readFully(scratch.data, 0, length); + input.readFully(scratch.getData(), 0, length); scratch.skipBytes(FlacConstants.METADATA_BLOCK_HEADER_SIZE); CommentHeader commentHeader = VorbisUtil.readVorbisCommentHeader( @@ -270,12 +269,12 @@ public final class FlacMetadataReader { private static PictureFrame readPictureMetadataBlock(ExtractorInput input, int length) throws IOException { ParsableByteArray scratch = new ParsableByteArray(length); - input.readFully(scratch.data, 0, length); + input.readFully(scratch.getData(), 0, length); scratch.skipBytes(FlacConstants.METADATA_BLOCK_HEADER_SIZE); int pictureType = scratch.readInt(); int mimeTypeLength = scratch.readInt(); - String mimeType = scratch.readString(mimeTypeLength, Charset.forName(C.ASCII_NAME)); + String mimeType = scratch.readString(mimeTypeLength, Charsets.US_ASCII); int descriptionLength = scratch.readInt(); String description = scratch.readString(descriptionLength); int width = scratch.readInt(); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Id3Peeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Id3Peeker.java index cda6a805f5..3c78f7a7dd 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Id3Peeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/Id3Peeker.java @@ -52,7 +52,7 @@ public final class Id3Peeker { @Nullable Metadata metadata = null; while (true) { try { - input.peekFully(scratch.data, /* offset= */ 0, Id3Decoder.ID3_HEADER_LENGTH); + input.peekFully(scratch.getData(), /* offset= */ 0, Id3Decoder.ID3_HEADER_LENGTH); } catch (EOFException e) { // If input has less than ID3_HEADER_LENGTH, ignore the rest. break; @@ -68,7 +68,7 @@ public final class Id3Peeker { if (metadata == null) { byte[] id3Data = new byte[tagLength]; - System.arraycopy(scratch.data, 0, id3Data, 0, Id3Decoder.ID3_HEADER_LENGTH); + System.arraycopy(scratch.getData(), 0, id3Data, 0, Id3Decoder.ID3_HEADER_LENGTH); input.peekFully(id3Data, Id3Decoder.ID3_HEADER_LENGTH, framesLength); metadata = new Id3Decoder(id3FramePredicate).decode(id3Data, tagLength); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java index a203d164dd..b071237cf5 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/TrackOutput.java @@ -130,7 +130,20 @@ public interface TrackOutput { *

      */ int SAMPLE_DATA_PART_ENCRYPTION = 1; - /** Sample supplemental data. */ + /** + * Sample supplemental data. + * + *

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

        + *
      • If the sample has the {@link C#BUFFER_FLAG_ENCRYPTED} flag set, all encryption + * information. + *
      • (4 bytes) {@code sample_data_size}: The size of the actual sample data, not including + * supplemental data or encryption information. + *
      • ({@code sample_data_size} bytes): The media sample data. + *
      • (remaining bytes) The supplemental data. + *
      + */ int SAMPLE_DATA_PART_SUPPLEMENTAL = 2; /** diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisBitArray.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisBitArray.java index b498be4a33..7ec9c93832 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisBitArray.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisBitArray.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor; +import static java.lang.Math.min; + import com.google.android.exoplayer2.util.Assertions; /** @@ -68,7 +70,7 @@ public final class VorbisBitArray { */ public int readBits(int numBits) { int tempByteOffset = byteOffset; - int bitsRead = Math.min(numBits, 8 - bitOffset); + int bitsRead = min(numBits, 8 - bitOffset); int returnValue = ((data[tempByteOffset++] & 0xFF) >> bitOffset) & (0xFF >> (8 - bitsRead)); while (bitsRead < numBits) { returnValue |= (data[tempByteOffset++] & 0xFF) << bitsRead; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisUtil.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisUtil.java index 67d469b759..ede2ab39e9 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisUtil.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/VorbisUtil.java @@ -173,7 +173,7 @@ public final class VorbisUtil { boolean framingFlag = (headerData.readUnsignedByte() & 0x01) > 0; // raw data of Vorbis setup header has to be passed to decoder as CSD buffer #1 - byte[] data = Arrays.copyOf(headerData.data, headerData.limit()); + byte[] data = Arrays.copyOf(headerData.getData(), headerData.limit()); return new VorbisIdHeader( version, @@ -309,7 +309,7 @@ public final class VorbisUtil { int numberOfBooks = headerData.readUnsignedByte() + 1; - VorbisBitArray bitArray = new VorbisBitArray(headerData.data); + VorbisBitArray bitArray = new VorbisBitArray(headerData.getData()); bitArray.skipBits(headerData.getPosition() * 8); for (int i = 0; i < numberOfBooks; i++) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacBinarySearchSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacBinarySearchSeeker.java index 03fd1e792a..70c8395131 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacBinarySearchSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacBinarySearchSeeker.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.flac; +import static java.lang.Math.max; + import com.google.android.exoplayer2.extractor.BinarySearchSeeker; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.FlacFrameReader; @@ -55,7 +57,7 @@ import java.io.IOException; /* floorBytePosition= */ firstFramePosition, /* ceilingBytePosition= */ inputLength, /* approxBytesPerFrame= */ flacStreamMetadata.getApproxBytesPerFrame(), - /* minimumSearchRange= */ Math.max( + /* minimumSearchRange= */ max( FlacConstants.MIN_FRAME_HEADER_SIZE, flacStreamMetadata.minFrameSize)); } @@ -81,7 +83,7 @@ import java.io.IOException; long leftFramePosition = input.getPeekPosition(); input.advancePeekPosition( - Math.max(FlacConstants.MIN_FRAME_HEADER_SIZE, flacStreamMetadata.minFrameSize)); + max(FlacConstants.MIN_FRAME_HEADER_SIZE, flacStreamMetadata.minFrameSize)); // Find right frame. long rightFrameFirstSampleNumber = findNextFrame(input); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java index f0da2656a1..48fc13a735 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.extractor.flac; import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; +import static java.lang.Math.min; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -52,6 +54,11 @@ public final class FlacExtractor implements Extractor { /** Factory for {@link FlacExtractor} instances. */ public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + // LINT.IfChange + /* + * Flags in the two FLAC extractors should be kept in sync. If we ever change this then + * DefaultExtractorsFactory will need modifying, because it currently assumes this is the case. + */ /** * Flags controlling the behavior of the extractor. Possible flag value is {@link * #FLAG_DISABLE_ID3_METADATA}. @@ -68,6 +75,7 @@ public final class FlacExtractor implements Extractor { * required. */ public static final int FLAG_DISABLE_ID3_METADATA = 1; + // LINT.ThenChange(../../../../../../../../../../../extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java) /** Parser state. */ @Documented @@ -181,7 +189,7 @@ public final class FlacExtractor implements Extractor { } currentFrameFirstSampleNumber = timeUs == 0 ? 0 : SAMPLE_NUMBER_UNKNOWN; currentFrameBytesWritten = 0; - buffer.reset(); + buffer.reset(/* limit= */ 0); } @Override @@ -218,7 +226,7 @@ public final class FlacExtractor implements Extractor { } Assertions.checkNotNull(flacStreamMetadata); - minFrameSize = Math.max(flacStreamMetadata.minFrameSize, FlacConstants.MIN_FRAME_HEADER_SIZE); + minFrameSize = max(flacStreamMetadata.minFrameSize, FlacConstants.MIN_FRAME_HEADER_SIZE); castNonNull(trackOutput) .format(flacStreamMetadata.getFormat(streamMarkerAndInfoBlock, id3Metadata)); @@ -259,7 +267,9 @@ public final class FlacExtractor implements Extractor { if (currentLimit < BUFFER_LENGTH) { int bytesRead = input.read( - buffer.data, /* offset= */ currentLimit, /* length= */ BUFFER_LENGTH - currentLimit); + buffer.getData(), + /* offset= */ currentLimit, + /* length= */ BUFFER_LENGTH - currentLimit); foundEndOfInput = bytesRead == C.RESULT_END_OF_INPUT; if (!foundEndOfInput) { buffer.setLimit(currentLimit + bytesRead); @@ -274,7 +284,7 @@ public final class FlacExtractor implements Extractor { // Skip frame search on the bytes within the minimum frame size. if (currentFrameBytesWritten < minFrameSize) { - buffer.skipBytes(Math.min(minFrameSize - currentFrameBytesWritten, buffer.bytesLeft())); + buffer.skipBytes(min(minFrameSize - currentFrameBytesWritten, buffer.bytesLeft())); } long nextFrameFirstSampleNumber = findFrame(buffer, foundEndOfInput); @@ -294,7 +304,11 @@ public final class FlacExtractor implements Extractor { // The next frame header may not fit in the rest of the buffer, so put the trailing bytes at // the start of the buffer, and reset the position and limit. System.arraycopy( - buffer.data, buffer.getPosition(), buffer.data, /* destPos= */ 0, buffer.bytesLeft()); + buffer.getData(), + buffer.getPosition(), + buffer.getData(), + /* destPos= */ 0, + buffer.bytesLeft()); buffer.reset(buffer.bytesLeft()); } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java index 68e93b1f87..eccd74fc82 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.flv; +import static java.lang.Math.max; + import androidx.annotation.IntDef; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.ChunkIndex; @@ -101,21 +103,21 @@ public final class FlvExtractor implements Extractor { @Override public boolean sniff(ExtractorInput input) throws IOException { // Check if file starts with "FLV" tag - input.peekFully(scratch.data, 0, 3); + input.peekFully(scratch.getData(), 0, 3); scratch.setPosition(0); if (scratch.readUnsignedInt24() != FLV_TAG) { return false; } // Checking reserved flags are set to 0 - input.peekFully(scratch.data, 0, 2); + input.peekFully(scratch.getData(), 0, 2); scratch.setPosition(0); if ((scratch.readUnsignedShort() & 0xFA) != 0) { return false; } // Read data offset - input.peekFully(scratch.data, 0, 4); + input.peekFully(scratch.getData(), 0, 4); scratch.setPosition(0); int dataOffset = scratch.readInt(); @@ -123,7 +125,7 @@ public final class FlvExtractor implements Extractor { input.advancePeekPosition(dataOffset); // Checking first "previous tag size" is set to 0 - input.peekFully(scratch.data, 0, 4); + input.peekFully(scratch.getData(), 0, 4); scratch.setPosition(0); return scratch.readInt() == 0; @@ -190,7 +192,7 @@ public final class FlvExtractor implements Extractor { */ @RequiresNonNull("extractorOutput") private boolean readFlvHeader(ExtractorInput input) throws IOException { - if (!input.readFully(headerBuffer.data, 0, FLV_HEADER_SIZE, true)) { + if (!input.readFully(headerBuffer.getData(), 0, FLV_HEADER_SIZE, true)) { // We've reached the end of the stream. return false; } @@ -236,7 +238,7 @@ public final class FlvExtractor implements Extractor { * @throws IOException If an error occurred reading or parsing data from the source. */ private boolean readTagHeader(ExtractorInput input) throws IOException { - if (!input.readFully(tagHeaderBuffer.data, 0, FLV_TAG_HEADER_SIZE, true)) { + if (!input.readFully(tagHeaderBuffer.getData(), 0, FLV_TAG_HEADER_SIZE, true)) { // We've reached the end of the stream. return false; } @@ -294,12 +296,12 @@ public final class FlvExtractor implements Extractor { private ParsableByteArray prepareTagData(ExtractorInput input) throws IOException { if (tagDataSize > tagData.capacity()) { - tagData.reset(new byte[Math.max(tagData.capacity() * 2, tagDataSize)], 0); + tagData.reset(new byte[max(tagData.capacity() * 2, tagDataSize)], 0); } else { tagData.setPosition(0); } tagData.setLimit(tagDataSize); - input.readFully(tagData.data, 0, tagDataSize); + input.readFully(tagData.getData(), 0, tagDataSize); return tagData; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java index 1ce75b4c40..29b66af69d 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java @@ -17,7 +17,6 @@ package com.google.android.exoplayer2.extractor.flv; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.util.ParsableByteArray; import java.util.ArrayList; @@ -72,11 +71,11 @@ import java.util.Map; } @Override - protected boolean parsePayload(ParsableByteArray data, long timeUs) throws ParserException { + protected boolean parsePayload(ParsableByteArray data, long timeUs) { int nameType = readAmfType(data); if (nameType != AMF_TYPE_STRING) { - // Should never happen. - throw new ParserException(); + // Ignore segments with unexpected name type. + return false; } String name = readAmfString(data); if (!NAME_METADATA.equals(name)) { @@ -143,7 +142,7 @@ import java.util.Map; int size = data.readUnsignedShort(); int position = data.getPosition(); data.skipBytes(size); - return new String(data.data, position, size); + return new String(data.getData(), position, size); } /** diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/VideoTagPayloadReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/VideoTagPayloadReader.java index 891b228dbb..c91f6ce037 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/VideoTagPayloadReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/flv/VideoTagPayloadReader.java @@ -86,7 +86,7 @@ import com.google.android.exoplayer2.video.AvcConfig; // Parse avc sequence header in case this was not done before. if (packetType == AVC_PACKET_TYPE_SEQUENCE_HEADER && !hasOutputFormat) { ParsableByteArray videoSequence = new ParsableByteArray(new byte[data.bytesLeft()]); - data.readBytes(videoSequence.data, 0, data.bytesLeft()); + data.readBytes(videoSequence.getData(), 0, data.bytesLeft()); AvcConfig avcConfig = AvcConfig.parse(videoSequence); nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength; // Construct and output the format. @@ -109,7 +109,7 @@ import com.google.android.exoplayer2.video.AvcConfig; // TODO: Deduplicate with Mp4Extractor. // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case // they're only 1 or 2 bytes long. - byte[] nalLengthData = nalLength.data; + byte[] nalLengthData = nalLength.getData(); nalLengthData[0] = 0; nalLengthData[1] = 0; nalLengthData[2] = 0; @@ -121,7 +121,7 @@ import com.google.android.exoplayer2.video.AvcConfig; int bytesToWrite; while (data.bytesLeft() > 0) { // Read the NAL length so that we know where we find the next one. - data.readBytes(nalLength.data, nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); + data.readBytes(nalLength.getData(), nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); nalLength.setPosition(0); bytesToWrite = nalLength.readUnsignedIntToInt(); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 8bb057d404..660605ebe5 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.extractor.mkv; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.util.Pair; import android.util.SparseArray; import androidx.annotation.CallSuper; @@ -45,6 +48,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.AvcConfig; import com.google.android.exoplayer2.video.ColorInfo; +import com.google.android.exoplayer2.video.DolbyVisionConfig; import com.google.android.exoplayer2.video.HevcConfig; import java.io.IOException; import java.lang.annotation.Documented; @@ -167,6 +171,9 @@ public class MatroskaExtractor implements Extractor { private static final int ID_FLAG_FORCED = 0x55AA; private static final int ID_DEFAULT_DURATION = 0x23E383; private static final int ID_MAX_BLOCK_ADDITION_ID = 0x55EE; + private static final int ID_BLOCK_ADDITION_MAPPING = 0x41E4; + private static final int ID_BLOCK_ADD_ID_TYPE = 0x41E7; + private static final int ID_BLOCK_ADD_ID_EXTRA_DATA = 0x41ED; private static final int ID_NAME = 0x536E; private static final int ID_CODEC_ID = 0x86; private static final int ID_CODEC_PRIVATE = 0x63A2; @@ -231,6 +238,17 @@ public class MatroskaExtractor implements Extractor { */ private static final int BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4; + /** + * BlockAddIdType value for Dolby Vision configuration with profile <= 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private static final int BLOCK_ADD_ID_TYPE_DVCC = 0x64766343; + /** + * BlockAddIdType value for Dolby Vision configuration with profile > 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private static final int BLOCK_ADD_ID_TYPE_DVVC = 0x64767643; + private static final int LACING_NONE = 0; private static final int LACING_XIPH = 1; private static final int LACING_FIXED_SIZE = 2; @@ -246,8 +264,8 @@ public class MatroskaExtractor implements Extractor { *

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

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

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

      Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". */ @@ -299,7 +317,7 @@ public class MatroskaExtractor implements Extractor { * The value by which to divide a time in microseconds to convert it to the unit of the last value * in an SSA timecode (1/100ths of a second). */ - private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10000; + private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10_000; /** * The format of an SSA timecode. */ @@ -498,6 +516,7 @@ public class MatroskaExtractor implements Extractor { case ID_CLUSTER: case ID_TRACKS: case ID_TRACK_ENTRY: + case ID_BLOCK_ADDITION_MAPPING: case ID_AUDIO: case ID_VIDEO: case ID_CONTENT_ENCODINGS: @@ -532,6 +551,7 @@ public class MatroskaExtractor implements Extractor { case ID_FLAG_FORCED: case ID_DEFAULT_DURATION: case ID_MAX_BLOCK_ADDITION_ID: + case ID_BLOCK_ADD_ID_TYPE: case ID_CODEC_DELAY: case ID_SEEK_PRE_ROLL: case ID_CHANNELS: @@ -559,6 +579,7 @@ public class MatroskaExtractor implements Extractor { case ID_LANGUAGE: return EbmlProcessor.ELEMENT_TYPE_STRING; case ID_SEEK_ID: + case ID_BLOCK_ADD_ID_EXTRA_DATA: case ID_CONTENT_COMPRESSION_SETTINGS: case ID_CONTENT_ENCRYPTION_KEY_ID: case ID_SIMPLE_BLOCK: @@ -811,6 +832,9 @@ public class MatroskaExtractor implements Extractor { case ID_MAX_BLOCK_ADDITION_ID: currentTrack.maxBlockAdditionId = (int) value; break; + case ID_BLOCK_ADD_ID_TYPE: + currentTrack.blockAddIdType = (int) value; + break; case ID_CODEC_DELAY: currentTrack.codecDelayNs = value; break; @@ -1068,11 +1092,14 @@ public class MatroskaExtractor implements Extractor { protected void binaryElement(int id, int contentSize, ExtractorInput input) throws IOException { switch (id) { case ID_SEEK_ID: - Arrays.fill(seekEntryIdBytes.data, (byte) 0); - input.readFully(seekEntryIdBytes.data, 4 - contentSize, contentSize); + Arrays.fill(seekEntryIdBytes.getData(), (byte) 0); + input.readFully(seekEntryIdBytes.getData(), 4 - contentSize, contentSize); seekEntryIdBytes.setPosition(0); seekEntryId = (int) seekEntryIdBytes.readUnsignedInt(); break; + case ID_BLOCK_ADD_ID_EXTRA_DATA: + handleBlockAddIDExtraData(currentTrack, input, contentSize); + break; case ID_CODEC_PRIVATE: currentTrack.codecPrivate = new byte[contentSize]; input.readFully(currentTrack.codecPrivate, 0, contentSize); @@ -1104,7 +1131,7 @@ public class MatroskaExtractor implements Extractor { blockTrackNumberLength = varintReader.getLastLength(); blockDurationUs = C.TIME_UNSET; blockState = BLOCK_STATE_HEADER; - scratch.reset(); + scratch.reset(/* limit= */ 0); } Track track = tracks.get(blockTrackNumber); @@ -1119,7 +1146,7 @@ public class MatroskaExtractor implements Extractor { if (blockState == BLOCK_STATE_HEADER) { // Read the relative timecode (2 bytes) and flags (1 byte). readScratch(input, 3); - int lacing = (scratch.data[2] & 0x06) >> 1; + int lacing = (scratch.getData()[2] & 0x06) >> 1; if (lacing == LACING_NONE) { blockSampleCount = 1; blockSampleSizes = ensureArrayCapacity(blockSampleSizes, 1); @@ -1127,7 +1154,7 @@ public class MatroskaExtractor implements Extractor { } else { // Read the sample count (1 byte). readScratch(input, 4); - blockSampleCount = (scratch.data[3] & 0xFF) + 1; + blockSampleCount = (scratch.getData()[3] & 0xFF) + 1; blockSampleSizes = ensureArrayCapacity(blockSampleSizes, blockSampleCount); if (lacing == LACING_FIXED_SIZE) { int blockLacingSampleSize = @@ -1141,7 +1168,7 @@ public class MatroskaExtractor implements Extractor { int byteValue; do { readScratch(input, ++headerSize); - byteValue = scratch.data[headerSize - 1] & 0xFF; + byteValue = scratch.getData()[headerSize - 1] & 0xFF; blockSampleSizes[sampleIndex] += byteValue; } while (byteValue == 0xFF); totalSamplesSize += blockSampleSizes[sampleIndex]; @@ -1154,20 +1181,20 @@ public class MatroskaExtractor implements Extractor { for (int sampleIndex = 0; sampleIndex < blockSampleCount - 1; sampleIndex++) { blockSampleSizes[sampleIndex] = 0; readScratch(input, ++headerSize); - if (scratch.data[headerSize - 1] == 0) { + if (scratch.getData()[headerSize - 1] == 0) { throw new ParserException("No valid varint length mask found"); } long readValue = 0; for (int i = 0; i < 8; i++) { int lengthMask = 1 << (7 - i); - if ((scratch.data[headerSize - 1] & lengthMask) != 0) { + if ((scratch.getData()[headerSize - 1] & lengthMask) != 0) { int readPosition = headerSize - 1; headerSize += i; readScratch(input, headerSize); - readValue = (scratch.data[readPosition++] & 0xFF) & ~lengthMask; + readValue = (scratch.getData()[readPosition++] & 0xFF) & ~lengthMask; while (readPosition < headerSize) { readValue <<= 8; - readValue |= (scratch.data[readPosition++] & 0xFF); + readValue |= (scratch.getData()[readPosition++] & 0xFF); } // The first read value is the first size. Later values are signed offsets. if (sampleIndex > 0) { @@ -1194,13 +1221,12 @@ public class MatroskaExtractor implements Extractor { } } - int timecode = (scratch.data[0] << 8) | (scratch.data[1] & 0xFF); + int timecode = (scratch.getData()[0] << 8) | (scratch.getData()[1] & 0xFF); blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode); - boolean isInvisible = (scratch.data[2] & 0x08) == 0x08; - boolean isKeyframe = track.type == TRACK_TYPE_AUDIO - || (id == ID_SIMPLE_BLOCK && (scratch.data[2] & 0x80) == 0x80); - blockFlags = (isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0) - | (isInvisible ? C.BUFFER_FLAG_DECODE_ONLY : 0); + boolean isKeyframe = + track.type == TRACK_TYPE_AUDIO + || (id == ID_SIMPLE_BLOCK && (scratch.getData()[2] & 0x80) == 0x80); + blockFlags = isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; blockState = BLOCK_STATE_DATA; blockSampleIndex = 0; } @@ -1242,13 +1268,25 @@ public class MatroskaExtractor implements Extractor { } } + protected void handleBlockAddIDExtraData(Track track, ExtractorInput input, int contentSize) + throws IOException { + if (track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVVC + || track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVCC) { + track.dolbyVisionConfigBytes = new byte[contentSize]; + input.readFully(track.dolbyVisionConfigBytes, 0, contentSize); + } else { + // Unhandled BlockAddIDExtraData. + input.skipFully(contentSize); + } + } + protected void handleBlockAdditionalData( Track track, int blockAdditionalId, ExtractorInput input, int contentSize) throws IOException { if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 && CODEC_ID_VP9.equals(track.codecId)) { blockAdditionalData.reset(contentSize); - input.readFully(blockAdditionalData.data, 0, contentSize); + input.readFully(blockAdditionalData.getData(), 0, contentSize); } else { // Unhandled block additional data. input.skipFully(contentSize); @@ -1266,7 +1304,7 @@ public class MatroskaExtractor implements Extractor { } else if (blockDurationUs == C.TIME_UNSET) { Log.w(TAG, "Skipping subtitle sample with no duration."); } else { - setSubtitleEndTime(track.codecId, blockDurationUs, subtitleSample.data); + setSubtitleEndTime(track.codecId, blockDurationUs, subtitleSample.getData()); // Note: If we ever want to support DRM protected subtitles then we'll need to output the // appropriate encryption data here. track.output.sampleData(subtitleSample, subtitleSample.limit()); @@ -1301,10 +1339,11 @@ public class MatroskaExtractor implements Extractor { return; } if (scratch.capacity() < requiredLength) { - scratch.reset(Arrays.copyOf(scratch.data, Math.max(scratch.data.length * 2, requiredLength)), + scratch.reset( + Arrays.copyOf(scratch.getData(), max(scratch.getData().length * 2, requiredLength)), scratch.limit()); } - input.readFully(scratch.data, scratch.limit(), requiredLength - scratch.limit()); + input.readFully(scratch.getData(), scratch.limit(), requiredLength - scratch.limit()); scratch.setLimit(requiredLength); } @@ -1333,12 +1372,12 @@ public class MatroskaExtractor implements Extractor { // Clear the encrypted flag. blockFlags &= ~C.BUFFER_FLAG_ENCRYPTED; if (!sampleSignalByteRead) { - input.readFully(scratch.data, 0, 1); + input.readFully(scratch.getData(), 0, 1); sampleBytesRead++; - if ((scratch.data[0] & 0x80) == 0x80) { + if ((scratch.getData()[0] & 0x80) == 0x80) { throw new ParserException("Extension bit is set in signal byte"); } - sampleSignalByte = scratch.data[0]; + sampleSignalByte = scratch.getData()[0]; sampleSignalByteRead = true; } boolean isEncrypted = (sampleSignalByte & 0x01) == 0x01; @@ -1346,11 +1385,12 @@ public class MatroskaExtractor implements Extractor { boolean hasSubsampleEncryption = (sampleSignalByte & 0x02) == 0x02; blockFlags |= C.BUFFER_FLAG_ENCRYPTED; if (!sampleInitializationVectorRead) { - input.readFully(encryptionInitializationVector.data, 0, ENCRYPTION_IV_SIZE); + input.readFully(encryptionInitializationVector.getData(), 0, ENCRYPTION_IV_SIZE); sampleBytesRead += ENCRYPTION_IV_SIZE; sampleInitializationVectorRead = true; // Write the signal byte, containing the IV size and the subsample encryption flag. - scratch.data[0] = (byte) (ENCRYPTION_IV_SIZE | (hasSubsampleEncryption ? 0x80 : 0x00)); + scratch.getData()[0] = + (byte) (ENCRYPTION_IV_SIZE | (hasSubsampleEncryption ? 0x80 : 0x00)); scratch.setPosition(0); output.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); sampleBytesWritten++; @@ -1364,7 +1404,7 @@ public class MatroskaExtractor implements Extractor { } if (hasSubsampleEncryption) { if (!samplePartitionCountRead) { - input.readFully(scratch.data, 0, 1); + input.readFully(scratch.getData(), 0, 1); sampleBytesRead++; scratch.setPosition(0); samplePartitionCount = scratch.readUnsignedByte(); @@ -1372,7 +1412,7 @@ public class MatroskaExtractor implements Extractor { } int samplePartitionDataSize = samplePartitionCount * 4; scratch.reset(samplePartitionDataSize); - input.readFully(scratch.data, 0, samplePartitionDataSize); + input.readFully(scratch.getData(), 0, samplePartitionDataSize); sampleBytesRead += samplePartitionDataSize; short subsampleCount = (short) (1 + (samplePartitionCount / 2)); int subsampleDataSize = 2 + 6 * subsampleCount; @@ -1421,14 +1461,14 @@ public class MatroskaExtractor implements Extractor { if (track.maxBlockAdditionId > 0) { blockFlags |= C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA; - blockAdditionalData.reset(); + blockAdditionalData.reset(/* limit= */ 0); // If there is supplemental data, the structure of the sample data is: // sample size (4 bytes) || sample data || supplemental data scratch.reset(/* limit= */ 4); - scratch.data[0] = (byte) ((size >> 24) & 0xFF); - scratch.data[1] = (byte) ((size >> 16) & 0xFF); - scratch.data[2] = (byte) ((size >> 8) & 0xFF); - scratch.data[3] = (byte) (size & 0xFF); + scratch.getData()[0] = (byte) ((size >> 24) & 0xFF); + scratch.getData()[1] = (byte) ((size >> 16) & 0xFF); + scratch.getData()[2] = (byte) ((size >> 8) & 0xFF); + scratch.getData()[3] = (byte) (size & 0xFF); output.sampleData(scratch, 4, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL); sampleBytesWritten += 4; } @@ -1442,7 +1482,7 @@ public class MatroskaExtractor implements Extractor { // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case // they're only 1 or 2 bytes long. - byte[] nalLengthData = nalLength.data; + byte[] nalLengthData = nalLength.getData(); nalLengthData[0] = 0; nalLengthData[1] = 0; nalLengthData[2] = 0; @@ -1519,7 +1559,7 @@ public class MatroskaExtractor implements Extractor { samplePartitionCount = 0; sampleSignalByte = (byte) 0; sampleInitializationVectorRead = false; - sampleStrippedBytes.reset(); + sampleStrippedBytes.reset(/* limit= */ 0); } private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, int size) @@ -1528,11 +1568,11 @@ public class MatroskaExtractor implements Extractor { if (subtitleSample.capacity() < sizeWithPrefix) { // Initialize subripSample to contain the required prefix and have space to hold a subtitle // twice as long as this one. - subtitleSample.data = Arrays.copyOf(samplePrefix, sizeWithPrefix + size); + subtitleSample.reset(Arrays.copyOf(samplePrefix, sizeWithPrefix + size)); } else { - System.arraycopy(samplePrefix, 0, subtitleSample.data, 0, samplePrefix.length); + System.arraycopy(samplePrefix, 0, subtitleSample.getData(), 0, samplePrefix.length); } - input.readFully(subtitleSample.data, samplePrefix.length, size); + input.readFully(subtitleSample.getData(), samplePrefix.length, size); subtitleSample.reset(sizeWithPrefix); // Defer writing the data to the track output. We need to modify the sample data by setting // the correct end timecode, which we might not have yet. @@ -1599,7 +1639,7 @@ public class MatroskaExtractor implements Extractor { */ private void writeToTarget(ExtractorInput input, byte[] target, int offset, int length) throws IOException { - int pendingStrippedBytes = Math.min(length, sampleStrippedBytes.bytesLeft()); + int pendingStrippedBytes = min(length, sampleStrippedBytes.bytesLeft()); input.readFully(target, offset + pendingStrippedBytes, length - pendingStrippedBytes); if (pendingStrippedBytes > 0) { sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes); @@ -1615,7 +1655,7 @@ public class MatroskaExtractor implements Extractor { int bytesWritten; int strippedBytesLeft = sampleStrippedBytes.bytesLeft(); if (strippedBytesLeft > 0) { - bytesWritten = Math.min(length, strippedBytesLeft); + bytesWritten = min(length, strippedBytesLeft); output.sampleData(sampleStrippedBytes, bytesWritten); } else { bytesWritten = output.sampleData(input, length, false); @@ -1746,7 +1786,7 @@ public class MatroskaExtractor implements Extractor { return array; } else { // Double the size to avoid allocating constantly if the required length increases gradually. - return new int[Math.max(array.length * 2, length)]; + return new int[max(array.length * 2, length)]; } } @@ -1861,7 +1901,7 @@ public class MatroskaExtractor implements Extractor { private static final class Track { private static final int DISPLAY_UNIT_PIXELS = 0; - private static final int MAX_CHROMATICITY = 50000; // Defined in CTA-861.3. + private static final int MAX_CHROMATICITY = 50_000; // Defined in CTA-861.3. /** * Default max content light level (CLL) that should be encoded into hdrStaticInfo. */ @@ -1879,6 +1919,7 @@ public class MatroskaExtractor implements Extractor { public int type; public int defaultSampleDurationNs; public int maxBlockAdditionId; + private int blockAddIdType; public boolean hasContentEncryption; public byte[] sampleStrippedBytes; public TrackOutput.CryptoData cryptoData; @@ -1917,6 +1958,7 @@ public class MatroskaExtractor implements Extractor { public float whitePointChromaticityY = Format.NO_VALUE; public float maxMasteringLuminance = Format.NO_VALUE; public float minMasteringLuminance = Format.NO_VALUE; + @Nullable public byte[] dolbyVisionConfigBytes; // Audio elements. Initially set to their default values. public int channelCount = 1; @@ -2087,6 +2129,16 @@ public class MatroskaExtractor implements Extractor { throw new ParserException("Unrecognized codec identifier."); } + if (dolbyVisionConfigBytes != null) { + @Nullable + DolbyVisionConfig dolbyVisionConfig = + DolbyVisionConfig.parse(new ParsableByteArray(this.dolbyVisionConfigBytes)); + if (dolbyVisionConfig != null) { + codecs = dolbyVisionConfig.codecs; + mimeType = MimeTypes.VIDEO_DOLBY_VISION; + } + } + @C.SelectionFlags int selectionFlags = 0; selectionFlags |= flagDefault ? C.SELECTION_FLAG_DEFAULT : 0; selectionFlags |= flagForced ? C.SELECTION_FLAG_FORCED : 0; @@ -2245,7 +2297,7 @@ public class MatroskaExtractor implements Extractor { // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). int startOffset = buffer.getPosition() + 20; - byte[] bufferData = buffer.data; + byte[] bufferData = buffer.getData(); for (int offset = startOffset; offset < bufferData.length - 4; offset++) { if (bufferData[offset] == 0x00 && bufferData[offset + 1] == 0x00 diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java index d380fa47c7..415d3d4546 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mkv/Sniffer.java @@ -45,16 +45,16 @@ import java.io.IOException; int bytesToSearch = (int) (inputLength == C.LENGTH_UNSET || inputLength > SEARCH_LENGTH ? SEARCH_LENGTH : inputLength); // Find four bytes equal to ID_EBML near the start of the input. - input.peekFully(scratch.data, 0, 4); + input.peekFully(scratch.getData(), 0, 4); long tag = scratch.readUnsignedInt(); peekLength = 4; while (tag != ID_EBML) { if (++peekLength == bytesToSearch) { return false; } - input.peekFully(scratch.data, 0, 1); + input.peekFully(scratch.getData(), 0, 1); tag = (tag << 8) & 0xFFFFFF00; - tag |= scratch.data[0] & 0xFF; + tag |= scratch.getData()[0] & 0xFF; } // Read the size of the EBML header and make sure it is within the stream. @@ -86,8 +86,8 @@ import java.io.IOException; /** Peeks a variable-length unsigned EBML integer from the input. */ private long readUint(ExtractorInput input) throws IOException { - input.peekFully(scratch.data, 0, 1); - int value = scratch.data[0] & 0xFF; + input.peekFully(scratch.getData(), 0, 1); + int value = scratch.getData()[0] & 0xFF; if (value == 0) { return Long.MIN_VALUE; } @@ -98,10 +98,10 @@ import java.io.IOException; length++; } value &= ~mask; - input.peekFully(scratch.data, 1, length); + input.peekFully(scratch.getData(), 1, length); for (int i = 0; i < length; i++) { value <<= 8; - value += scratch.data[i + 1] & 0xFF; + value += scratch.getData()[i + 1] & 0xFF; } peekLength += length + 1; return value; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java index b9613f38f5..59d128ab9b 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -109,7 +109,7 @@ public final class Mp3Extractor implements Extractor { /** * The maximum number of bytes to peek when sniffing, excluding the ID3 header, before giving up. */ - private static final int MAX_SNIFF_BYTES = 16 * 1024; + private static final int MAX_SNIFF_BYTES = 32 * 1024; /** * Maximum length of data read into {@link #scratch}. */ @@ -414,7 +414,7 @@ public final class Mp3Extractor implements Extractor { } try { return !extractorInput.peekFully( - scratch.data, /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true); + scratch.getData(), /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true); } catch (EOFException e) { return true; } @@ -471,7 +471,7 @@ public final class Mp3Extractor implements Extractor { @Nullable private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException { ParsableByteArray frame = new ParsableByteArray(synchronizedHeader.frameSize); - input.peekFully(frame.data, 0, synchronizedHeader.frameSize); + input.peekFully(frame.getData(), 0, synchronizedHeader.frameSize); int xingBase = (synchronizedHeader.version & 1) != 0 ? (synchronizedHeader.channels != 1 ? 36 : 21) // MPEG 1 : (synchronizedHeader.channels != 1 ? 21 : 13); // MPEG 2 or 2.5 @@ -483,7 +483,7 @@ public final class Mp3Extractor implements Extractor { // If there is a Xing header, read gapless playback metadata at a fixed offset. input.resetPeekPosition(); input.advancePeekPosition(xingBase + 141); - input.peekFully(scratch.data, 0, 3); + input.peekFully(scratch.getData(), 0, 3); scratch.setPosition(0); gaplessInfoHolder.setFromXingHeaderValue(scratch.readUnsignedInt24()); } @@ -505,7 +505,7 @@ public final class Mp3Extractor implements Extractor { /** Peeks the next frame and returns a {@link ConstantBitrateSeeker} based on its bitrate. */ private Seeker getConstantBitrateSeeker(ExtractorInput input) throws IOException { - input.peekFully(scratch.data, 0, 4); + input.peekFully(scratch.getData(), 0, 4); scratch.setPosition(0); synchronizedHeader.setForHeaderData(scratch.readInt()); return new ConstantBitrateSeeker(input.getLength(), input.getPosition(), synchronizedHeader); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java index 29584e7be7..daf5265ddd 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.mp3; +import static java.lang.Math.max; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.audio.MpegAudioUtil; @@ -68,7 +70,7 @@ import com.google.android.exoplayer2.util.Util; timesUs[index] = (index * durationUs) / entryCount; // Ensure positions do not fall within the frame containing the VBRI header. This constraint // will normally only apply to the first entry in the table. - positions[index] = Math.max(position, minPosition); + positions[index] = max(position, minPosition); int segmentSize; switch (entrySize) { case 1: diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java index 9f31fba25e..d95721be5d 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java @@ -64,7 +64,7 @@ import com.google.android.exoplayer2.util.Util; return new XingSeeker(position, mpegAudioHeader.frameSize, durationUs); } - long dataSize = frame.readUnsignedIntToInt(); + long dataSize = frame.readUnsignedInt(); long[] tableOfContents = new long[100]; for (int i = 0; i < 100; i++) { tableOfContents[i] = frame.readUnsignedByte(); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index 3cf858558a..6eed09760e 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -15,7 +15,9 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.MimeTypes.getMimeTypeFromMp4ObjectType; +import static java.lang.Math.max; import android.util.Pair; import androidx.annotation.Nullable; @@ -25,6 +27,7 @@ import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.audio.AacUtil; import com.google.android.exoplayer2.audio.Ac3Util; import com.google.android.exoplayer2.audio.Ac4Util; +import com.google.android.exoplayer2.audio.OpusUtil; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.extractor.GaplessInfoHolder; import com.google.android.exoplayer2.metadata.Metadata; @@ -37,13 +40,14 @@ import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.AvcConfig; import com.google.android.exoplayer2.video.DolbyVisionConfig; import com.google.android.exoplayer2.video.HevcConfig; +import com.google.common.base.Function; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import org.checkerframework.checker.nullness.compatqual.NullableType; -/** Utility methods for parsing MP4 format atom payloads according to ISO 14496-12. */ +/** Utility methods for parsing MP4 format atom payloads according to ISO/IEC 14496-12. */ @SuppressWarnings({"ConstantField"}) /* package */ final class AtomParsers { @@ -83,7 +87,145 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private static final byte[] opusMagic = Util.getUtf8Bytes("OpusHead"); /** - * Parses a trak atom (defined in 14496-12). + * Parse the trak atoms in a moov atom (defined in ISO/IEC 14496-12). + * + * @param moov Moov atom to decode. + * @param gaplessInfoHolder Holder to populate with gapless playback information. + * @param duration The duration in units of the timescale declared in the mvhd atom, or {@link + * C#TIME_UNSET} if the duration should be parsed from the tkhd atom. + * @param drmInitData {@link DrmInitData} to be included in the format, or {@code null}. + * @param ignoreEditLists Whether to ignore any edit lists in the trak boxes. + * @param isQuickTime True for QuickTime media. False otherwise. + * @param modifyTrackFunction A function to apply to the {@link Track Tracks} in the result. + * @return A list of {@link TrackSampleTable} instances. + * @throws ParserException Thrown if the trak atoms can't be parsed. + */ + public static List parseTraks( + Atom.ContainerAtom moov, + GaplessInfoHolder gaplessInfoHolder, + long duration, + @Nullable DrmInitData drmInitData, + boolean ignoreEditLists, + boolean isQuickTime, + Function<@NullableType Track, @NullableType Track> modifyTrackFunction) + throws ParserException { + List trackSampleTables = new ArrayList<>(); + for (int i = 0; i < moov.containerChildren.size(); i++) { + Atom.ContainerAtom atom = moov.containerChildren.get(i); + if (atom.type != Atom.TYPE_trak) { + continue; + } + @Nullable + Track track = + modifyTrackFunction.apply( + parseTrak( + atom, + checkNotNull(moov.getLeafAtomOfType(Atom.TYPE_mvhd)), + duration, + drmInitData, + ignoreEditLists, + isQuickTime)); + if (track == null) { + continue; + } + Atom.ContainerAtom stblAtom = + checkNotNull( + checkNotNull( + checkNotNull(atom.getContainerAtomOfType(Atom.TYPE_mdia)) + .getContainerAtomOfType(Atom.TYPE_minf)) + .getContainerAtomOfType(Atom.TYPE_stbl)); + TrackSampleTable trackSampleTable = parseStbl(track, stblAtom, gaplessInfoHolder); + trackSampleTables.add(trackSampleTable); + } + return trackSampleTables; + } + + /** + * Parses a udta atom. + * + * @param udtaAtom The udta (user data) atom to decode. + * @param isQuickTime True for QuickTime media. False otherwise. + * @return Parsed metadata, or null. + */ + @Nullable + public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { + if (isQuickTime) { + // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and + // decode one. + return null; + } + ParsableByteArray udtaData = udtaAtom.data; + udtaData.setPosition(Atom.HEADER_SIZE); + while (udtaData.bytesLeft() >= Atom.HEADER_SIZE) { + int atomPosition = udtaData.getPosition(); + int atomSize = udtaData.readInt(); + int atomType = udtaData.readInt(); + if (atomType == Atom.TYPE_meta) { + udtaData.setPosition(atomPosition); + return parseUdtaMeta(udtaData, atomPosition + atomSize); + } + udtaData.setPosition(atomPosition + atomSize); + } + return null; + } + + /** + * Parses a metadata meta atom if it contains metadata with handler 'mdta'. + * + * @param meta The metadata atom to decode. + * @return Parsed metadata, or null. + */ + @Nullable + public static Metadata parseMdtaFromMeta(Atom.ContainerAtom meta) { + @Nullable Atom.LeafAtom hdlrAtom = meta.getLeafAtomOfType(Atom.TYPE_hdlr); + @Nullable Atom.LeafAtom keysAtom = meta.getLeafAtomOfType(Atom.TYPE_keys); + @Nullable Atom.LeafAtom ilstAtom = meta.getLeafAtomOfType(Atom.TYPE_ilst); + if (hdlrAtom == null + || keysAtom == null + || ilstAtom == null + || parseHdlr(hdlrAtom.data) != TYPE_mdta) { + // There isn't enough information to parse the metadata, or the handler type is unexpected. + return null; + } + + // Parse metadata keys. + ParsableByteArray keys = keysAtom.data; + keys.setPosition(Atom.FULL_HEADER_SIZE); + int entryCount = keys.readInt(); + String[] keyNames = new String[entryCount]; + for (int i = 0; i < entryCount; i++) { + int entrySize = keys.readInt(); + keys.skipBytes(4); // keyNamespace + int keySize = entrySize - 8; + keyNames[i] = keys.readString(keySize); + } + + // Parse metadata items. + ParsableByteArray ilst = ilstAtom.data; + ilst.setPosition(Atom.HEADER_SIZE); + ArrayList entries = new ArrayList<>(); + while (ilst.bytesLeft() > Atom.HEADER_SIZE) { + int atomPosition = ilst.getPosition(); + int atomSize = ilst.readInt(); + int keyIndex = ilst.readInt() - 1; + if (keyIndex >= 0 && keyIndex < keyNames.length) { + String key = keyNames[keyIndex]; + @Nullable + Metadata.Entry entry = + MetadataUtil.parseMdtaMetadataEntryFromIlst(ilst, atomPosition + atomSize, key); + if (entry != null) { + entries.add(entry); + } + } else { + Log.w(TAG, "Skipped metadata with unknown key index: " + keyIndex); + } + ilst.setPosition(atomPosition + atomSize); + } + return entries.isEmpty() ? null : new Metadata(entries); + } + + /** + * Parses a trak atom (defined in ISO/IEC 14496-12). * * @param trak Atom to decode. * @param mvhd Movie header atom, used to get the timescale. @@ -93,9 +235,10 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * @param ignoreEditLists Whether to ignore any edit lists in the trak box. * @param isQuickTime True for QuickTime media. False otherwise. * @return A {@link Track} instance, or {@code null} if the track's type isn't supported. + * @throws ParserException Thrown if the trak atom can't be parsed. */ @Nullable - public static Track parseTrak( + private static Track parseTrak( Atom.ContainerAtom trak, Atom.LeafAtom mvhd, long duration, @@ -103,13 +246,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; boolean ignoreEditLists, boolean isQuickTime) throws ParserException { - Atom.ContainerAtom mdia = trak.getContainerAtomOfType(Atom.TYPE_mdia); - int trackType = getTrackTypeForHdlr(parseHdlr(mdia.getLeafAtomOfType(Atom.TYPE_hdlr).data)); + Atom.ContainerAtom mdia = checkNotNull(trak.getContainerAtomOfType(Atom.TYPE_mdia)); + int trackType = + getTrackTypeForHdlr(parseHdlr(checkNotNull(mdia.getLeafAtomOfType(Atom.TYPE_hdlr)).data)); if (trackType == C.TRACK_TYPE_UNKNOWN) { return null; } - TkhdData tkhdData = parseTkhd(trak.getLeafAtomOfType(Atom.TYPE_tkhd).data); + TkhdData tkhdData = parseTkhd(checkNotNull(trak.getLeafAtomOfType(Atom.TYPE_tkhd)).data); if (duration == C.TIME_UNSET) { duration = tkhdData.duration; } @@ -120,12 +264,21 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } else { durationUs = Util.scaleLargeTimestamp(duration, C.MICROS_PER_SECOND, movieTimescale); } - Atom.ContainerAtom stbl = mdia.getContainerAtomOfType(Atom.TYPE_minf) - .getContainerAtomOfType(Atom.TYPE_stbl); + Atom.ContainerAtom stbl = + checkNotNull( + checkNotNull(mdia.getContainerAtomOfType(Atom.TYPE_minf)) + .getContainerAtomOfType(Atom.TYPE_stbl)); - Pair mdhdData = parseMdhd(mdia.getLeafAtomOfType(Atom.TYPE_mdhd).data); - StsdData stsdData = parseStsd(stbl.getLeafAtomOfType(Atom.TYPE_stsd).data, tkhdData.id, - tkhdData.rotationDegrees, mdhdData.second, drmInitData, isQuickTime); + Pair mdhdData = + parseMdhd(checkNotNull(mdia.getLeafAtomOfType(Atom.TYPE_mdhd)).data); + StsdData stsdData = + parseStsd( + checkNotNull(stbl.getLeafAtomOfType(Atom.TYPE_stsd)).data, + tkhdData.id, + tkhdData.rotationDegrees, + mdhdData.second, + drmInitData, + isQuickTime); @Nullable long[] editListDurations = null; @Nullable long[] editListMediaTimes = null; if (!ignoreEditLists) { @@ -145,7 +298,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses an stbl atom (defined in 14496-12). + * Parses an stbl atom (defined in ISO/IEC 14496-12). * * @param track Track to which this sample table corresponds. * @param stblAtom stbl (sample table) atom to decode. @@ -153,7 +306,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * @return Sample table described by the stbl atom. * @throws ParserException Thrown if the stbl atom can't be parsed. */ - public static TrackSampleTable parseStbl( + private static TrackSampleTable parseStbl( Track track, Atom.ContainerAtom stblAtom, GaplessInfoHolder gaplessInfoHolder) throws ParserException { SampleSizeBox sampleSizeBox; @@ -177,7 +330,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /* maximumSize= */ 0, /* timestampsUs= */ new long[0], /* flags= */ new int[0], - /* durationUs= */ C.TIME_UNSET); + /* durationUs= */ 0); } // Entries are byte offsets of chunks. @@ -185,13 +338,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable Atom.LeafAtom chunkOffsetsAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_stco); if (chunkOffsetsAtom == null) { chunkOffsetsAreLongs = true; - chunkOffsetsAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_co64); + chunkOffsetsAtom = checkNotNull(stblAtom.getLeafAtomOfType(Atom.TYPE_co64)); } ParsableByteArray chunkOffsets = chunkOffsetsAtom.data; // Entries are (chunk number, number of samples per chunk, sample description index). - ParsableByteArray stsc = stblAtom.getLeafAtomOfType(Atom.TYPE_stsc).data; + ParsableByteArray stsc = checkNotNull(stblAtom.getLeafAtomOfType(Atom.TYPE_stsc)).data; // Entries are (number of samples, timestamp delta between those samples). - ParsableByteArray stts = stblAtom.getLeafAtomOfType(Atom.TYPE_stts).data; + ParsableByteArray stts = checkNotNull(stblAtom.getLeafAtomOfType(Atom.TYPE_stts)).data; // Entries are the indices of samples that are synchronization samples. @Nullable Atom.LeafAtom stssAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_stss); @Nullable ParsableByteArray stss = stssAtom != null ? stssAtom.data : null; @@ -246,7 +399,25 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; long timestampTimeUnits = 0; long duration; - if (!isFixedSampleSizeRawAudio) { + if (isFixedSampleSizeRawAudio) { + long[] chunkOffsetsBytes = new long[chunkIterator.length]; + int[] chunkSampleCounts = new int[chunkIterator.length]; + while (chunkIterator.moveNext()) { + chunkOffsetsBytes[chunkIterator.index] = chunkIterator.offset; + chunkSampleCounts[chunkIterator.index] = chunkIterator.numSamples; + } + int fixedSampleSize = + Util.getPcmFrameSize(track.format.pcmEncoding, track.format.channelCount); + FixedSampleSizeRechunker.Results rechunkedResults = + FixedSampleSizeRechunker.rechunk( + fixedSampleSize, chunkOffsetsBytes, chunkSampleCounts, timestampDeltaInTimeUnits); + offsets = rechunkedResults.offsets; + sizes = rechunkedResults.sizes; + maximumSize = rechunkedResults.maximumSize; + timestamps = rechunkedResults.timestamps; + flags = rechunkedResults.flags; + duration = rechunkedResults.duration; + } else { offsets = new long[sampleCount]; sizes = new int[sampleCount]; timestamps = new long[sampleCount]; @@ -275,11 +446,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (ctts != null) { while (remainingSamplesAtTimestampOffset == 0 && remainingTimestampOffsetChanges > 0) { remainingSamplesAtTimestampOffset = ctts.readUnsignedIntToInt(); - // The BMFF spec (ISO 14496-12) states that sample offsets should be unsigned integers - // in version 0 ctts boxes, however some streams violate the spec and use signed - // integers instead. It's safe to always decode sample offsets as signed integers here, - // because unsigned integers will still be parsed correctly (unless their top bit is - // set, which is never true in practice because sample offsets are always small). + // The BMFF spec (ISO/IEC 14496-12) states that sample offsets should be unsigned + // integers in version 0 ctts boxes, however some streams violate the spec and use + // signed integers instead. It's safe to always decode sample offsets as signed integers + // here, because unsigned integers will still be parsed correctly (unless their top bit + // is set, which is never true in practice because sample offsets are always small). timestampOffset = ctts.readInt(); remainingTimestampOffsetChanges--; } @@ -299,7 +470,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; flags[i] = C.BUFFER_FLAG_KEY_FRAME; remainingSynchronizationSamples--; if (remainingSynchronizationSamples > 0) { - nextSynchronizationSampleIndex = stss.readUnsignedIntToInt() - 1; + nextSynchronizationSampleIndex = checkNotNull(stss).readUnsignedIntToInt() - 1; } } @@ -308,7 +479,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; remainingSamplesAtTimestampDelta--; if (remainingSamplesAtTimestampDelta == 0 && remainingTimestampDeltaChanges > 0) { remainingSamplesAtTimestampDelta = stts.readUnsignedIntToInt(); - // The BMFF spec (ISO 14496-12) states that sample deltas should be unsigned integers + // The BMFF spec (ISO/IEC 14496-12) states that sample deltas should be unsigned integers // in stts boxes, however some streams violate the spec and use signed integers instead. // See https://github.com/google/ExoPlayer/issues/3384. It's safe to always decode sample // deltas as signed integers here, because unsigned integers will still be parsed @@ -326,13 +497,15 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // If the stbl's child boxes are not consistent the container is malformed, but the stream may // still be playable. boolean isCttsValid = true; - while (remainingTimestampOffsetChanges > 0) { - if (ctts.readUnsignedIntToInt() != 0) { - isCttsValid = false; - break; + if (ctts != null) { + while (remainingTimestampOffsetChanges > 0) { + if (ctts.readUnsignedIntToInt() != 0) { + isCttsValid = false; + break; + } + ctts.readInt(); // Ignore offset. + remainingTimestampOffsetChanges--; } - ctts.readInt(); // Ignore offset. - remainingTimestampOffsetChanges--; } if (remainingSynchronizationSamples != 0 || remainingSamplesAtTimestampDelta != 0 @@ -356,23 +529,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; + remainingSamplesAtTimestampOffset + (!isCttsValid ? ", ctts invalid" : "")); } - } else { - long[] chunkOffsetsBytes = new long[chunkIterator.length]; - int[] chunkSampleCounts = new int[chunkIterator.length]; - while (chunkIterator.moveNext()) { - chunkOffsetsBytes[chunkIterator.index] = chunkIterator.offset; - chunkSampleCounts[chunkIterator.index] = chunkIterator.numSamples; - } - int fixedSampleSize = - Util.getPcmFrameSize(track.format.pcmEncoding, track.format.channelCount); - FixedSampleSizeRechunker.Results rechunkedResults = FixedSampleSizeRechunker.rechunk( - fixedSampleSize, chunkOffsetsBytes, chunkSampleCounts, timestampDeltaInTimeUnits); - offsets = rechunkedResults.offsets; - sizes = rechunkedResults.sizes; - maximumSize = rechunkedResults.maximumSize; - timestamps = rechunkedResults.timestamps; - flags = rechunkedResults.flags; - duration = rechunkedResults.duration; } long durationUs = Util.scaleLargeTimestamp(duration, C.MICROS_PER_SECOND, track.timescale); @@ -382,17 +538,17 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; track, offsets, sizes, maximumSize, timestamps, flags, durationUs); } - // See the BMFF spec (ISO 14496-12) subsection 8.6.6. Edit lists that require prerolling from a - // sync sample after reordering are not supported. Partial audio sample truncation is only - // supported in edit lists with one edit that removes less than MAX_GAPLESS_TRIM_SIZE_SAMPLES - // samples from the start/end of the track. This implementation handles simple - // discarding/delaying of samples. The extractor may place further restrictions on what edited - // streams are playable. + // See the BMFF spec (ISO/IEC 14496-12) subsection 8.6.6. Edit lists that require prerolling + // from a sync sample after reordering are not supported. Partial audio sample truncation is + // only supported in edit lists with one edit that removes less than + // MAX_GAPLESS_TRIM_SIZE_SAMPLES samples from the start/end of the track. This implementation + // handles simple discarding/delaying of samples. The extractor may place further restrictions + // on what edited streams are playable. if (track.editListDurations.length == 1 && track.type == C.TRACK_TYPE_AUDIO && timestamps.length >= 2) { - long editStartTime = track.editListMediaTimes[0]; + long editStartTime = checkNotNull(track.editListMediaTimes)[0]; long editEndTime = editStartTime + Util.scaleLargeTimestamp(track.editListDurations[0], track.timescale, track.movieTimescale); if (canApplyEditWithGaplessInfo(timestamps, duration, editStartTime, editEndTime)) { @@ -419,7 +575,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // The current version of the spec leaves handling of an edit with zero segment_duration in // unfragmented files open to interpretation. We handle this as a special case and include all // samples in the edit. - long editStartTime = track.editListMediaTimes[0]; + long editStartTime = checkNotNull(track.editListMediaTimes)[0]; for (int i = 0; i < timestamps.length; i++) { timestamps[i] = Util.scaleLargeTimestamp( @@ -440,8 +596,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; boolean copyMetadata = false; int[] startIndices = new int[track.editListDurations.length]; int[] endIndices = new int[track.editListDurations.length]; + long[] editListMediaTimes = checkNotNull(track.editListMediaTimes); for (int i = 0; i < track.editListDurations.length; i++) { - long editMediaTime = track.editListMediaTimes[i]; + long editMediaTime = editListMediaTimes[i]; if (editMediaTime != -1) { long editDuration = Util.scaleLargeTimestamp( @@ -492,7 +649,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; long ptsUs = Util.scaleLargeTimestamp(pts, C.MICROS_PER_SECOND, track.movieTimescale); long timeInSegmentUs = Util.scaleLargeTimestamp( - Math.max(0, timestamps[j] - editMediaTime), C.MICROS_PER_SECOND, track.timescale); + max(0, timestamps[j] - editMediaTime), C.MICROS_PER_SECOND, track.timescale); editedTimestamps[sampleIndex] = ptsUs + timeInSegmentUs; if (copyMetadata && editedSizes[sampleIndex] > editedMaximumSize) { editedMaximumSize = sizes[j]; @@ -513,90 +670,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; editedDurationUs); } - /** - * Parses a udta atom. - * - * @param udtaAtom The udta (user data) atom to decode. - * @param isQuickTime True for QuickTime media. False otherwise. - * @return Parsed metadata, or null. - */ - @Nullable - public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { - if (isQuickTime) { - // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and - // decode one. - return null; - } - ParsableByteArray udtaData = udtaAtom.data; - udtaData.setPosition(Atom.HEADER_SIZE); - while (udtaData.bytesLeft() >= Atom.HEADER_SIZE) { - int atomPosition = udtaData.getPosition(); - int atomSize = udtaData.readInt(); - int atomType = udtaData.readInt(); - if (atomType == Atom.TYPE_meta) { - udtaData.setPosition(atomPosition); - return parseUdtaMeta(udtaData, atomPosition + atomSize); - } - udtaData.setPosition(atomPosition + atomSize); - } - return null; - } - - /** - * Parses a metadata meta atom if it contains metadata with handler 'mdta'. - * - * @param meta The metadata atom to decode. - * @return Parsed metadata, or null. - */ - @Nullable - public static Metadata parseMdtaFromMeta(Atom.ContainerAtom meta) { - @Nullable Atom.LeafAtom hdlrAtom = meta.getLeafAtomOfType(Atom.TYPE_hdlr); - @Nullable Atom.LeafAtom keysAtom = meta.getLeafAtomOfType(Atom.TYPE_keys); - @Nullable Atom.LeafAtom ilstAtom = meta.getLeafAtomOfType(Atom.TYPE_ilst); - if (hdlrAtom == null - || keysAtom == null - || ilstAtom == null - || AtomParsers.parseHdlr(hdlrAtom.data) != TYPE_mdta) { - // There isn't enough information to parse the metadata, or the handler type is unexpected. - return null; - } - - // Parse metadata keys. - ParsableByteArray keys = keysAtom.data; - keys.setPosition(Atom.FULL_HEADER_SIZE); - int entryCount = keys.readInt(); - String[] keyNames = new String[entryCount]; - for (int i = 0; i < entryCount; i++) { - int entrySize = keys.readInt(); - keys.skipBytes(4); // keyNamespace - int keySize = entrySize - 8; - keyNames[i] = keys.readString(keySize); - } - - // Parse metadata items. - ParsableByteArray ilst = ilstAtom.data; - ilst.setPosition(Atom.HEADER_SIZE); - ArrayList entries = new ArrayList<>(); - while (ilst.bytesLeft() > Atom.HEADER_SIZE) { - int atomPosition = ilst.getPosition(); - int atomSize = ilst.readInt(); - int keyIndex = ilst.readInt() - 1; - if (keyIndex >= 0 && keyIndex < keyNames.length) { - String key = keyNames[keyIndex]; - @Nullable - Metadata.Entry entry = - MetadataUtil.parseMdtaMetadataEntryFromIlst(ilst, atomPosition + atomSize, key); - if (entry != null) { - entries.add(entry); - } - } else { - Log.w(TAG, "Skipped metadata with unknown key index: " + keyIndex); - } - ilst.setPosition(atomPosition + atomSize); - } - return entries.isEmpty() ? null : new Metadata(entries); - } - @Nullable private static Metadata parseUdtaMeta(ParsableByteArray meta, int limit) { meta.skipBytes(Atom.FULL_HEADER_SIZE); @@ -627,7 +700,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses a mvhd atom (defined in 14496-12), returning the timescale for the movie. + * Parses a mvhd atom (defined in ISO/IEC 14496-12), returning the timescale for the movie. * * @param mvhd Contents of the mvhd atom to be parsed. * @return Timescale for the movie. @@ -641,7 +714,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses a tkhd atom (defined in 14496-12). + * Parses a tkhd atom (defined in ISO/IEC 14496-12). * * @return An object containing the parsed data. */ @@ -658,7 +731,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int durationPosition = tkhd.getPosition(); int durationByteCount = version == 0 ? 4 : 8; for (int i = 0; i < durationByteCount; i++) { - if (tkhd.data[durationPosition + i] != -1) { + if (tkhd.getData()[durationPosition + i] != -1) { durationUnknown = false; break; } @@ -726,11 +799,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses an mdhd atom (defined in 14496-12). + * Parses an mdhd atom (defined in ISO/IEC 14496-12). * * @param mdhd The mdhd atom to decode. * @return A pair consisting of the media timescale defined as the number of time units that pass - * in one second, and the language code. + * in one second, and the language code. */ private static Pair parseMdhd(ParsableByteArray mdhd) { mdhd.setPosition(Atom.HEADER_SIZE); @@ -749,7 +822,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses a stsd atom (defined in 14496-12). + * Parses a stsd atom (defined in ISO/IEC 14496-12). * * @param stsd The stsd atom to decode. * @param trackId The track's identifier in its container. @@ -841,7 +914,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; parent.setPosition(position + Atom.HEADER_SIZE + StsdData.STSD_HEADER_SIZE); // Default values. - @Nullable List initializationData = null; + @Nullable ImmutableList initializationData = null; long subsampleOffsetUs = Format.OFFSET_SAMPLE_RELATIVE; String mimeType; @@ -852,7 +925,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int sampleDescriptionLength = atomSize - Atom.HEADER_SIZE - 8; byte[] sampleDescriptionData = new byte[sampleDescriptionLength]; parent.readBytes(sampleDescriptionData, 0, sampleDescriptionLength); - initializationData = Collections.singletonList(sampleDescriptionData); + initializationData = ImmutableList.of(sampleDescriptionData); } else if (atomType == Atom.TYPE_wvtt) { mimeType = MimeTypes.APPLICATION_MP4VTT; } else if (atomType == Atom.TYPE_stpp) { @@ -970,7 +1043,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; mimeType = mimeTypeAndInitializationDataBytes.first; @Nullable byte[] initializationDataBytes = mimeTypeAndInitializationDataBytes.second; if (initializationDataBytes != null) { - initializationData = Collections.singletonList(initializationDataBytes); + initializationData = ImmutableList.of(initializationDataBytes); } } else if (childAtomType == Atom.TYPE_pasp) { pixelWidthHeightRatio = parsePaspFromParent(parent, childStartPosition); @@ -1025,7 +1098,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } /** - * Parses the edts atom (defined in 14496-12 subsection 8.6.5). + * Parses the edts atom (defined in ISO/IEC 14496-12 subsection 8.6.5). * * @param edtsAtom edts (edit box) atom to decode. * @return Pair of edit list durations and edit list media times, or {@code null} if they are not @@ -1170,7 +1243,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; mimeType = MimeTypes.AUDIO_FLAC; } - @Nullable byte[] initializationData = null; + @Nullable List initializationData = null; while (childPosition - position < size) { parent.setPosition(childPosition); int childAtomSize = parent.readInt(); @@ -1183,14 +1256,17 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; Pair<@NullableType String, byte @NullableType []> mimeTypeAndInitializationData = parseEsdsFromParent(parent, esdsAtomPosition); mimeType = mimeTypeAndInitializationData.first; - initializationData = mimeTypeAndInitializationData.second; - if (MimeTypes.AUDIO_AAC.equals(mimeType) && initializationData != null) { - // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, - // which is more reliable. See [Internal: b/10903778]. - AacUtil.Config aacConfig = AacUtil.parseAudioSpecificConfig(initializationData); - sampleRate = aacConfig.sampleRateHz; - channelCount = aacConfig.channelCount; - codecs = aacConfig.codecs; + @Nullable byte[] initializationDataBytes = mimeTypeAndInitializationData.second; + if (initializationDataBytes != null) { + if (MimeTypes.AUDIO_AAC.equals(mimeType)) { + // Update sampleRate and channelCount from the AudioSpecificConfig initialization + // data, which is more reliable. See [Internal: b/10903778]. + AacUtil.Config aacConfig = AacUtil.parseAudioSpecificConfig(initializationDataBytes); + sampleRate = aacConfig.sampleRateHz; + channelCount = aacConfig.channelCount; + codecs = aacConfig.codecs; + } + initializationData = ImmutableList.of(initializationDataBytes); } } } else if (childAtomType == Atom.TYPE_dac3) { @@ -1219,30 +1295,32 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // Build an Opus Identification Header (defined in RFC-7845) by concatenating the Opus Magic // Signature and the body of the dOps atom. int childAtomBodySize = childAtomSize - Atom.HEADER_SIZE; - initializationData = new byte[opusMagic.length + childAtomBodySize]; - System.arraycopy(opusMagic, 0, initializationData, 0, opusMagic.length); + byte[] headerBytes = Arrays.copyOf(opusMagic, opusMagic.length + childAtomBodySize); parent.setPosition(childPosition + Atom.HEADER_SIZE); - parent.readBytes(initializationData, opusMagic.length, childAtomBodySize); + parent.readBytes(headerBytes, opusMagic.length, childAtomBodySize); + initializationData = OpusUtil.buildInitializationData(headerBytes); } else if (childAtomType == Atom.TYPE_dfLa) { int childAtomBodySize = childAtomSize - Atom.FULL_HEADER_SIZE; - initializationData = new byte[4 + childAtomBodySize]; - initializationData[0] = 0x66; // f - initializationData[1] = 0x4C; // L - initializationData[2] = 0x61; // a - initializationData[3] = 0x43; // C + byte[] initializationDataBytes = new byte[4 + childAtomBodySize]; + initializationDataBytes[0] = 0x66; // f + initializationDataBytes[1] = 0x4C; // L + initializationDataBytes[2] = 0x61; // a + initializationDataBytes[3] = 0x43; // C parent.setPosition(childPosition + Atom.FULL_HEADER_SIZE); - parent.readBytes(initializationData, /* offset= */ 4, childAtomBodySize); + parent.readBytes(initializationDataBytes, /* offset= */ 4, childAtomBodySize); + initializationData = ImmutableList.of(initializationDataBytes); } else if (childAtomType == Atom.TYPE_alac) { int childAtomBodySize = childAtomSize - Atom.FULL_HEADER_SIZE; - initializationData = new byte[childAtomBodySize]; + byte[] initializationDataBytes = new byte[childAtomBodySize]; parent.setPosition(childPosition + Atom.FULL_HEADER_SIZE); - parent.readBytes(initializationData, /* offset= */ 0, childAtomBodySize); + parent.readBytes(initializationDataBytes, /* offset= */ 0, childAtomBodySize); // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, // which is more reliable. See https://github.com/google/ExoPlayer/pull/6629. Pair audioSpecificConfig = - CodecSpecificDataUtil.parseAlacAudioSpecificConfig(initializationData); + CodecSpecificDataUtil.parseAlacAudioSpecificConfig(initializationDataBytes); sampleRate = audioSpecificConfig.first; channelCount = audioSpecificConfig.second; + initializationData = ImmutableList.of(initializationDataBytes); } childPosition += childAtomSize; } @@ -1256,8 +1334,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) - .setInitializationData( - initializationData == null ? null : Collections.singletonList(initializationData)) + .setInitializationData(initializationData) .setDrmInitData(drmInitData) .setLanguage(language) .build(); @@ -1287,7 +1364,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private static Pair<@NullableType String, byte @NullableType []> parseEsdsFromParent( ParsableByteArray parent, int position) { parent.setPosition(position + Atom.HEADER_SIZE + 4); - // Start of the ES_Descriptor (defined in 14496-1) + // Start of the ES_Descriptor (defined in ISO/IEC 14496-1) parent.skipBytes(1); // ES_Descriptor tag parseExpandableClassSize(parent); parent.skipBytes(2); // ES_ID @@ -1303,13 +1380,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; parent.skipBytes(2); } - // Start of the DecoderConfigDescriptor (defined in 14496-1) + // Start of the DecoderConfigDescriptor (defined in ISO/IEC 14496-1) parent.skipBytes(1); // DecoderConfigDescriptor tag parseExpandableClassSize(parent); - // Set the MIME type based on the object type indication (14496-1 table 5). + // Set the MIME type based on the object type indication (ISO/IEC 14496-1 table 5). int objectTypeIndication = parent.readUnsignedByte(); - String mimeType = getMimeTypeFromMp4ObjectType(objectTypeIndication); + @Nullable String mimeType = getMimeTypeFromMp4ObjectType(objectTypeIndication); if (MimeTypes.AUDIO_MPEG.equals(mimeType) || MimeTypes.AUDIO_DTS.equals(mimeType) || MimeTypes.AUDIO_DTS_HD.equals(mimeType)) { @@ -1341,8 +1418,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; Assertions.checkState(childAtomSize > 0, "childAtomSize should be positive"); int childAtomType = parent.readInt(); if (childAtomType == Atom.TYPE_sinf) { - Pair result = parseCommonEncryptionSinfFromParent(parent, - childPosition, childAtomSize); + @Nullable + Pair result = + parseCommonEncryptionSinfFromParent(parent, childPosition, childAtomSize); if (result != null) { return result; } @@ -1441,16 +1519,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int childAtomSize = parent.readInt(); int childAtomType = parent.readInt(); if (childAtomType == Atom.TYPE_proj) { - return Arrays.copyOfRange(parent.data, childPosition, childPosition + childAtomSize); + return Arrays.copyOfRange(parent.getData(), childPosition, childPosition + childAtomSize); } childPosition += childAtomSize; } return null; } - /** - * Parses the size of an expandable class, as specified by ISO 14496-1 subsection 8.3.3. - */ + /** Parses the size of an expandable class, as specified by ISO/IEC 14496-1 subsection 8.3.3. */ private static int parseExpandableClassSize(ParsableByteArray data) { int currentByte = data.readUnsignedByte(); int size = currentByte & 0x7F; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FixedSampleSizeRechunker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FixedSampleSizeRechunker.java index 536f70048c..5ebc0a3587 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FixedSampleSizeRechunker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FixedSampleSizeRechunker.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static java.lang.Math.max; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.Util; @@ -88,11 +91,11 @@ import com.google.android.exoplayer2.util.Util; long sampleOffset = chunkOffsets[chunkIndex]; while (chunkSamplesRemaining > 0) { - int bufferSampleCount = Math.min(maxSampleCount, chunkSamplesRemaining); + int bufferSampleCount = min(maxSampleCount, chunkSamplesRemaining); offsets[newSampleIndex] = sampleOffset; sizes[newSampleIndex] = fixedSampleSize * bufferSampleCount; - maximumSize = Math.max(maximumSize, sizes[newSampleIndex]); + maximumSize = max(maximumSize, sizes[newSampleIndex]); timestamps[newSampleIndex] = (timestampDeltaInTimeUnits * originalSampleIndex); flags[newSampleIndex] = C.BUFFER_FLAG_KEY_FRAME; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java index ae543c1642..859ce49b26 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -15,6 +15,13 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static com.google.android.exoplayer2.extractor.mp4.AtomParsers.parseTraks; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static com.google.android.exoplayer2.util.Util.nullSafeArrayCopy; +import static java.lang.Math.max; + import android.util.Pair; import android.util.SparseArray; import androidx.annotation.IntDef; @@ -31,6 +38,7 @@ import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.GaplessInfoHolder; import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.extractor.SeekMap; import com.google.android.exoplayer2.extractor.TrackOutput; @@ -38,7 +46,6 @@ import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; import com.google.android.exoplayer2.extractor.mp4.Atom.LeafAtom; import com.google.android.exoplayer2.metadata.emsg.EventMessage; import com.google.android.exoplayer2.metadata.emsg.EventMessageEncoder; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; @@ -55,7 +62,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.UUID; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** Extracts data from the FMP4 container format. */ @SuppressWarnings("ConstantField") @@ -110,11 +116,13 @@ public class FragmentedMp4Extractor implements Extractor { @SuppressWarnings("ConstantCaseForConstants") private static final int SAMPLE_GROUP_TYPE_seig = 0x73656967; - private static final byte[] PIFF_SAMPLE_ENCRYPTION_BOX_EXTENDED_TYPE = new byte[] {-94, 57, 79, 82, 90, -101, 79, 20, -94, 68, 108, 66, 124, 100, -115, -12}; + + // Extra tracks constants. private static final Format EMSG_FORMAT = new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_EMSG).build(); + private static final int EXTRA_TRACKS_BASE_ID = 100; // Parser states. private static final int STATE_READING_ATOM_HEADER = 0; @@ -168,10 +176,10 @@ public class FragmentedMp4Extractor implements Extractor { private int sampleCurrentNalBytesRemaining; private boolean processSeiNalUnitPayload; - // Extractor output. - private @MonotonicNonNull ExtractorOutput extractorOutput; + // Outputs. + private ExtractorOutput extractorOutput; private TrackOutput[] emsgTrackOutputs; - private TrackOutput[] cea608TrackOutputs; + private TrackOutput[] ceaTrackOutputs; // Whether extractorOutput.seekMap has been called. private boolean haveOutputSeekMap; @@ -264,7 +272,9 @@ public class FragmentedMp4Extractor implements Extractor { durationUs = C.TIME_UNSET; pendingSeekTimeUs = C.TIME_UNSET; segmentIndexEarliestPresentationTimeUs = C.TIME_UNSET; - enterReadingAtomHeaderState(); + extractorOutput = ExtractorOutput.PLACEHOLDER; + emsgTrackOutputs = new TrackOutput[0]; + ceaTrackOutputs = new TrackOutput[0]; } @Override @@ -275,11 +285,26 @@ public class FragmentedMp4Extractor implements Extractor { @Override public void init(ExtractorOutput output) { extractorOutput = output; + enterReadingAtomHeaderState(); + initExtraTracks(); if (sideloadedTrack != null) { - TrackBundle bundle = new TrackBundle(output.track(0, sideloadedTrack.type)); - bundle.init(sideloadedTrack, new DefaultSampleValues(0, 0, 0, 0)); + TrackBundle bundle = + new TrackBundle( + output.track(0, sideloadedTrack.type), + new TrackSampleTable( + sideloadedTrack, + /* offsets= */ new long[0], + /* sizes= */ new int[0], + /* maximumSize= */ 0, + /* timestampsUs= */ new long[0], + /* flags= */ new int[0], + /* durationUs= */ 0), + new DefaultSampleValues( + /* sampleDescriptionIndex= */ 0, + /* duration= */ 0, + /* size= */ 0, + /* flags= */ 0)); trackBundles.put(0, bundle); - maybeInitExtraTracks(); extractorOutput.endTracks(); } } @@ -288,7 +313,7 @@ public class FragmentedMp4Extractor implements Extractor { public void seek(long position, long timeUs) { int trackCount = trackBundles.size(); for (int i = 0; i < trackCount; i++) { - trackBundles.valueAt(i).reset(); + trackBundles.valueAt(i).resetFragmentInfo(); } pendingMetadataSampleInfos.clear(); pendingMetadataSampleBytes = 0; @@ -333,7 +358,7 @@ public class FragmentedMp4Extractor implements Extractor { private boolean readAtomHeader(ExtractorInput input) throws IOException { if (atomHeaderBytesRead == 0) { // Read the standard length atom header. - if (!input.readFully(atomHeader.data, 0, Atom.HEADER_SIZE, true)) { + if (!input.readFully(atomHeader.getData(), 0, Atom.HEADER_SIZE, true)) { return false; } atomHeaderBytesRead = Atom.HEADER_SIZE; @@ -345,7 +370,7 @@ public class FragmentedMp4Extractor implements Extractor { if (atomSize == Atom.DEFINES_LARGE_SIZE) { // Read the large size. int headerBytesRemaining = Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE; - input.readFully(atomHeader.data, Atom.HEADER_SIZE, headerBytesRemaining); + input.readFully(atomHeader.getData(), Atom.HEADER_SIZE, headerBytesRemaining); atomHeaderBytesRead += headerBytesRemaining; atomSize = atomHeader.readUnsignedLongToLong(); } else if (atomSize == Atom.EXTENDS_TO_END_SIZE) { @@ -365,6 +390,14 @@ public class FragmentedMp4Extractor implements Extractor { } long atomPosition = input.getPosition() - atomHeaderBytesRead; + if (atomType == Atom.TYPE_moof || atomType == Atom.TYPE_mdat) { + if (!haveOutputSeekMap) { + // This must be the first moof or mdat in the stream. + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs, atomPosition)); + haveOutputSeekMap = true; + } + } + if (atomType == Atom.TYPE_moof) { // The data positions may be updated when parsing the tfhd/trun. int trackCount = trackBundles.size(); @@ -379,11 +412,6 @@ public class FragmentedMp4Extractor implements Extractor { if (atomType == Atom.TYPE_mdat) { currentTrackBundle = null; endOfMdatPosition = atomPosition + atomSize; - if (!haveOutputSeekMap) { - // This must be the first mdat in the stream. - extractorOutput.seekMap(new SeekMap.Unseekable(durationUs, atomPosition)); - haveOutputSeekMap = true; - } parserState = STATE_READING_ENCRYPTION_DATA; return true; } @@ -404,8 +432,9 @@ public class FragmentedMp4Extractor implements Extractor { if (atomSize > Integer.MAX_VALUE) { throw new ParserException("Leaf atom with length > 2147483647 (unsupported)."); } - atomData = new ParsableByteArray((int) atomSize); - System.arraycopy(atomHeader.data, 0, atomData.data, 0, Atom.HEADER_SIZE); + ParsableByteArray atomData = new ParsableByteArray((int) atomSize); + System.arraycopy(atomHeader.getData(), 0, atomData.getData(), 0, Atom.HEADER_SIZE); + this.atomData = atomData; parserState = STATE_READING_ATOM_PAYLOAD; } else { if (atomSize > Integer.MAX_VALUE) { @@ -420,8 +449,9 @@ public class FragmentedMp4Extractor implements Extractor { private void readAtomPayload(ExtractorInput input) throws IOException { int atomPayloadSize = (int) atomSize - atomHeaderBytesRead; + @Nullable ParsableByteArray atomData = this.atomData; if (atomData != null) { - input.readFully(atomData.data, Atom.HEADER_SIZE, atomPayloadSize); + input.readFully(atomData.getData(), Atom.HEADER_SIZE, atomPayloadSize); onLeafAtomRead(new LeafAtom(atomType, atomData), input.getPosition()); } else { input.skipFully(atomPayloadSize); @@ -460,12 +490,12 @@ public class FragmentedMp4Extractor implements Extractor { } private void onMoovContainerAtomRead(ContainerAtom moov) throws ParserException { - Assertions.checkState(sideloadedTrack == null, "Unexpected moov box."); + checkState(sideloadedTrack == null, "Unexpected moov box."); @Nullable DrmInitData drmInitData = getDrmInitDataFromAtoms(moov.leafChildren); - // Read declaration of track fragments in the Moov box. - ContainerAtom mvex = moov.getContainerAtomOfType(Atom.TYPE_mvex); + // Read declaration of track fragments in the moov box. + ContainerAtom mvex = checkNotNull(moov.getContainerAtomOfType(Atom.TYPE_mvex)); SparseArray defaultSampleValuesArray = new SparseArray<>(); long duration = C.TIME_UNSET; int mvexChildrenSize = mvex.leafChildren.size(); @@ -479,47 +509,40 @@ public class FragmentedMp4Extractor implements Extractor { } } - // Construction of tracks. - SparseArray tracks = new SparseArray<>(); - int moovContainerChildrenSize = moov.containerChildren.size(); - for (int i = 0; i < moovContainerChildrenSize; i++) { - Atom.ContainerAtom atom = moov.containerChildren.get(i); - if (atom.type == Atom.TYPE_trak) { - @Nullable - Track track = - modifyTrack( - AtomParsers.parseTrak( - atom, - moov.getLeafAtomOfType(Atom.TYPE_mvhd), - duration, - drmInitData, - (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0, - false)); - if (track != null) { - tracks.put(track.id, track); - } - } - } + // Construction of tracks and sample tables. + List sampleTables = + parseTraks( + moov, + new GaplessInfoHolder(), + duration, + drmInitData, + /* ignoreEditLists= */ (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0, + /* isQuickTime= */ false, + this::modifyTrack); - int trackCount = tracks.size(); + int trackCount = sampleTables.size(); if (trackBundles.size() == 0) { // We need to create the track bundles. for (int i = 0; i < trackCount; i++) { - Track track = tracks.valueAt(i); - TrackBundle trackBundle = new TrackBundle(extractorOutput.track(i, track.type)); - trackBundle.init(track, getDefaultSampleValues(defaultSampleValuesArray, track.id)); + TrackSampleTable sampleTable = sampleTables.get(i); + Track track = sampleTable.track; + TrackBundle trackBundle = + new TrackBundle( + extractorOutput.track(i, track.type), + sampleTable, + getDefaultSampleValues(defaultSampleValuesArray, track.id)); trackBundles.put(track.id, trackBundle); - durationUs = Math.max(durationUs, track.durationUs); + durationUs = max(durationUs, track.durationUs); } - maybeInitExtraTracks(); extractorOutput.endTracks(); } else { - Assertions.checkState(trackBundles.size() == trackCount); + checkState(trackBundles.size() == trackCount); for (int i = 0; i < trackCount; i++) { - Track track = tracks.valueAt(i); + TrackSampleTable sampleTable = sampleTables.get(i); + Track track = sampleTable.track; trackBundles .get(track.id) - .init(track, getDefaultSampleValues(defaultSampleValuesArray, track.id)); + .reset(sampleTable, getDefaultSampleValues(defaultSampleValuesArray, track.id)); } } } @@ -536,7 +559,7 @@ public class FragmentedMp4Extractor implements Extractor { // See https://github.com/google/ExoPlayer/issues/4477. return defaultSampleValuesArray.valueAt(/* index= */ 0); } - return Assertions.checkNotNull(defaultSampleValuesArray.get(trackId)); + return checkNotNull(defaultSampleValuesArray.get(trackId)); } private void onMoofContainerAtomRead(ContainerAtom moof) throws ParserException { @@ -559,36 +582,34 @@ public class FragmentedMp4Extractor implements Extractor { } } - private void maybeInitExtraTracks() { - if (emsgTrackOutputs == null) { - emsgTrackOutputs = new TrackOutput[2]; - int emsgTrackOutputCount = 0; - if (additionalEmsgTrackOutput != null) { - emsgTrackOutputs[emsgTrackOutputCount++] = additionalEmsgTrackOutput; - } - if ((flags & FLAG_ENABLE_EMSG_TRACK) != 0) { - emsgTrackOutputs[emsgTrackOutputCount++] = - extractorOutput.track(trackBundles.size(), C.TRACK_TYPE_METADATA); - } - emsgTrackOutputs = Arrays.copyOf(emsgTrackOutputs, emsgTrackOutputCount); + private void initExtraTracks() { + int nextExtraTrackId = EXTRA_TRACKS_BASE_ID; - for (TrackOutput eventMessageTrackOutput : emsgTrackOutputs) { - eventMessageTrackOutput.format(EMSG_FORMAT); - } + emsgTrackOutputs = new TrackOutput[2]; + int emsgTrackOutputCount = 0; + if (additionalEmsgTrackOutput != null) { + emsgTrackOutputs[emsgTrackOutputCount++] = additionalEmsgTrackOutput; } - if (cea608TrackOutputs == null) { - cea608TrackOutputs = new TrackOutput[closedCaptionFormats.size()]; - for (int i = 0; i < cea608TrackOutputs.length; i++) { - TrackOutput output = extractorOutput.track(trackBundles.size() + 1 + i, C.TRACK_TYPE_TEXT); - output.format(closedCaptionFormats.get(i)); - cea608TrackOutputs[i] = output; - } + if ((flags & FLAG_ENABLE_EMSG_TRACK) != 0) { + emsgTrackOutputs[emsgTrackOutputCount++] = + extractorOutput.track(nextExtraTrackId++, C.TRACK_TYPE_METADATA); + } + emsgTrackOutputs = nullSafeArrayCopy(emsgTrackOutputs, emsgTrackOutputCount); + for (TrackOutput eventMessageTrackOutput : emsgTrackOutputs) { + eventMessageTrackOutput.format(EMSG_FORMAT); + } + + ceaTrackOutputs = new TrackOutput[closedCaptionFormats.size()]; + for (int i = 0; i < ceaTrackOutputs.length; i++) { + TrackOutput output = extractorOutput.track(nextExtraTrackId++, C.TRACK_TYPE_TEXT); + output.format(closedCaptionFormats.get(i)); + ceaTrackOutputs[i] = output; } } /** Handles an emsg atom (defined in 23009-1). */ private void onEmsgLeafAtomRead(ParsableByteArray atom) { - if (emsgTrackOutputs == null || emsgTrackOutputs.length == 0) { + if (emsgTrackOutputs.length == 0) { return; } atom.setPosition(Atom.HEADER_SIZE); @@ -603,8 +624,8 @@ public class FragmentedMp4Extractor implements Extractor { long id; switch (version) { case 0: - schemeIdUri = Assertions.checkNotNull(atom.readNullTerminatedString()); - value = Assertions.checkNotNull(atom.readNullTerminatedString()); + schemeIdUri = checkNotNull(atom.readNullTerminatedString()); + value = checkNotNull(atom.readNullTerminatedString()); timescale = atom.readUnsignedInt(); presentationTimeDeltaUs = Util.scaleLargeTimestamp(atom.readUnsignedInt(), C.MICROS_PER_SECOND, timescale); @@ -622,8 +643,8 @@ public class FragmentedMp4Extractor implements Extractor { durationMs = Util.scaleLargeTimestamp(atom.readUnsignedInt(), C.MILLIS_PER_SECOND, timescale); id = atom.readUnsignedInt(); - schemeIdUri = Assertions.checkNotNull(atom.readNullTerminatedString()); - value = Assertions.checkNotNull(atom.readNullTerminatedString()); + schemeIdUri = checkNotNull(atom.readNullTerminatedString()); + value = checkNotNull(atom.readNullTerminatedString()); break; default: Log.w(TAG, "Skipping unsupported emsg version: " + version); @@ -701,30 +722,36 @@ public class FragmentedMp4Extractor implements Extractor { */ private static void parseTraf(ContainerAtom traf, SparseArray trackBundleArray, @Flags int flags, byte[] extendedTypeScratch) throws ParserException { - LeafAtom tfhd = traf.getLeafAtomOfType(Atom.TYPE_tfhd); + LeafAtom tfhd = checkNotNull(traf.getLeafAtomOfType(Atom.TYPE_tfhd)); @Nullable TrackBundle trackBundle = parseTfhd(tfhd.data, trackBundleArray); if (trackBundle == null) { return; } TrackFragment fragment = trackBundle.fragment; - long decodeTime = fragment.nextFragmentDecodeTime; - trackBundle.reset(); - + long fragmentDecodeTime = fragment.nextFragmentDecodeTime; + boolean fragmentDecodeTimeIncludesMoov = fragment.nextFragmentDecodeTimeIncludesMoov; + trackBundle.resetFragmentInfo(); + trackBundle.currentlyInFragment = true; @Nullable LeafAtom tfdtAtom = traf.getLeafAtomOfType(Atom.TYPE_tfdt); if (tfdtAtom != null && (flags & FLAG_WORKAROUND_IGNORE_TFDT_BOX) == 0) { - decodeTime = parseTfdt(traf.getLeafAtomOfType(Atom.TYPE_tfdt).data); + fragment.nextFragmentDecodeTime = parseTfdt(tfdtAtom.data); + fragment.nextFragmentDecodeTimeIncludesMoov = true; + } else { + fragment.nextFragmentDecodeTime = fragmentDecodeTime; + fragment.nextFragmentDecodeTimeIncludesMoov = fragmentDecodeTimeIncludesMoov; } - parseTruns(traf, trackBundle, decodeTime, flags); + parseTruns(traf, trackBundle, flags); @Nullable TrackEncryptionBox encryptionBox = - trackBundle.track.getSampleDescriptionEncryptionBox(fragment.header.sampleDescriptionIndex); + trackBundle.moovSampleTable.track.getSampleDescriptionEncryptionBox( + checkNotNull(fragment.header).sampleDescriptionIndex); @Nullable LeafAtom saiz = traf.getLeafAtomOfType(Atom.TYPE_saiz); if (saiz != null) { - parseSaiz(encryptionBox, saiz.data, fragment); + parseSaiz(checkNotNull(encryptionBox), saiz.data, fragment); } @Nullable LeafAtom saio = traf.getLeafAtomOfType(Atom.TYPE_saio); @@ -737,12 +764,7 @@ public class FragmentedMp4Extractor implements Extractor { parseSenc(senc.data, fragment); } - @Nullable LeafAtom sbgp = traf.getLeafAtomOfType(Atom.TYPE_sbgp); - @Nullable LeafAtom sgpd = traf.getLeafAtomOfType(Atom.TYPE_sgpd); - if (sbgp != null && sgpd != null) { - parseSgpd(sbgp.data, sgpd.data, encryptionBox != null ? encryptionBox.schemeType : null, - fragment); - } + parseSampleGroups(traf, encryptionBox != null ? encryptionBox.schemeType : null, fragment); int leafChildrenSize = traf.leafChildren.size(); for (int i = 0; i < leafChildrenSize; i++) { @@ -753,8 +775,7 @@ 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, @Flags int flags) throws ParserException { int trunCount = 0; int totalSampleCount = 0; @@ -782,8 +803,8 @@ public class FragmentedMp4Extractor implements Extractor { for (int i = 0; i < leafChildrenSize; i++) { LeafAtom trun = leafChildren.get(i); if (trun.type == Atom.TYPE_trun) { - trunStartPosition = parseTrun(trackBundle, trunIndex++, decodeTime, flags, trun.data, - trunStartPosition); + trunStartPosition = + parseTrun(trackBundle, trunIndex++, flags, trun.data, trunStartPosition); } } } @@ -800,8 +821,12 @@ public class FragmentedMp4Extractor implements Extractor { int defaultSampleInfoSize = saiz.readUnsignedByte(); int sampleCount = saiz.readUnsignedIntToInt(); - if (sampleCount != out.sampleCount) { - throw new ParserException("Length mismatch: " + sampleCount + ", " + out.sampleCount); + if (sampleCount > out.sampleCount) { + throw new ParserException( + "Saiz sample count " + + sampleCount + + " is greater than fragment sample count" + + out.sampleCount); } int totalSize = 0; @@ -817,7 +842,10 @@ public class FragmentedMp4Extractor implements Extractor { totalSize += defaultSampleInfoSize * sampleCount; Arrays.fill(out.sampleHasSubsampleEncryptionTable, 0, sampleCount, subsampleEncryption); } - out.initEncryptionData(totalSize); + Arrays.fill(out.sampleHasSubsampleEncryptionTable, sampleCount, out.sampleCount, false); + if (totalSize > 0) { + out.initEncryptionData(totalSize); + } } /** @@ -924,7 +952,6 @@ public class FragmentedMp4Extractor implements Extractor { * @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. @@ -932,7 +959,6 @@ public class FragmentedMp4Extractor implements Extractor { private static int parseTrun( TrackBundle trackBundle, int index, - long decodeTime, @Flags int flags, ParsableByteArray trun, int trackRunStart) @@ -941,9 +967,9 @@ public class FragmentedMp4Extractor implements Extractor { int fullAtom = trun.readInt(); int atomFlags = Atom.parseFullAtomFlags(fullAtom); - Track track = trackBundle.track; + Track track = trackBundle.moovSampleTable.track; TrackFragment fragment = trackBundle.fragment; - DefaultSampleValues defaultSampleValues = fragment.header; + DefaultSampleValues defaultSampleValues = castNonNull(fragment.header); fragment.trunLength[index] = trun.readUnsignedIntToInt(); fragment.trunDataPosition[index] = fragment.dataPosition; @@ -973,7 +999,7 @@ public class FragmentedMp4Extractor implements Extractor { && track.editListDurations[0] == 0) { edtsOffsetUs = Util.scaleLargeTimestamp( - track.editListMediaTimes[0], C.MICROS_PER_SECOND, track.timescale); + castNonNull(track.editListMediaTimes)[0], C.MICROS_PER_SECOND, track.timescale); } int[] sampleSizeTable = fragment.sampleSizeTable; @@ -986,15 +1012,17 @@ public class FragmentedMp4Extractor implements Extractor { int trackRunEnd = trackRunStart + fragment.trunLength[index]; long timescale = track.timescale; - long cumulativeTime = index > 0 ? fragment.nextFragmentDecodeTime : decodeTime; + long cumulativeTime = fragment.nextFragmentDecodeTime; for (int i = trackRunStart; i < trackRunEnd; i++) { // Use trun values if present, otherwise tfhd, otherwise trex. 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; + int sampleFlags = + sampleFlagsPresent + ? trun.readInt() + : (i == 0 && firstSampleFlagsPresent) ? firstSampleFlags : defaultSampleValues.flags; if (sampleCompositionTimeOffsetsPresent) { // The BMFF spec (ISO 14496-12) states that sample offsets should be unsigned integers in // version 0 trun boxes, however a significant number of streams violate the spec and use @@ -1009,6 +1037,9 @@ public class FragmentedMp4Extractor implements Extractor { } sampleDecodingTimeUsTable[i] = Util.scaleLargeTimestamp(cumulativeTime, C.MICROS_PER_SECOND, timescale) - edtsOffsetUs; + if (!fragment.nextFragmentDecodeTimeIncludesMoov) { + sampleDecodingTimeUsTable[i] += trackBundle.moovSampleTable.durationUs; + } sampleSizeTable[i] = sampleSize; sampleIsSyncFrameTable[i] = ((sampleFlags >> 16) & 0x1) == 0 && (!workaroundEveryVideoFrameIsSyncFrame || i == 0); @@ -1058,8 +1089,16 @@ public class FragmentedMp4Extractor implements Extractor { boolean subsampleEncryption = (flags & 0x02 /* use_subsample_encryption */) != 0; int sampleCount = senc.readUnsignedIntToInt(); - if (sampleCount != out.sampleCount) { - throw new ParserException("Length mismatch: " + sampleCount + ", " + out.sampleCount); + if (sampleCount == 0) { + // Samples are unencrypted. + Arrays.fill(out.sampleHasSubsampleEncryptionTable, 0, out.sampleCount, false); + return; + } else if (sampleCount != out.sampleCount) { + throw new ParserException( + "Senc sample count " + + sampleCount + + " is different from fragment sample count" + + out.sampleCount); } Arrays.fill(out.sampleHasSubsampleEncryptionTable, 0, sampleCount, subsampleEncryption); @@ -1067,32 +1106,43 @@ public class FragmentedMp4Extractor implements Extractor { out.fillEncryptionData(senc); } - private static void parseSgpd( - ParsableByteArray sbgp, - ParsableByteArray sgpd, - @Nullable String schemeType, - TrackFragment out) - throws ParserException { - sbgp.setPosition(Atom.HEADER_SIZE); - int sbgpFullAtom = sbgp.readInt(); - if (sbgp.readInt() != SAMPLE_GROUP_TYPE_seig) { - // Only seig grouping type is supported. + private static void parseSampleGroups( + ContainerAtom traf, @Nullable String schemeType, TrackFragment out) throws ParserException { + // Find sbgp and sgpd boxes with grouping_type == seig. + @Nullable ParsableByteArray sbgp = null; + @Nullable ParsableByteArray sgpd = null; + for (int i = 0; i < traf.leafChildren.size(); i++) { + LeafAtom leafAtom = traf.leafChildren.get(i); + ParsableByteArray leafAtomData = leafAtom.data; + if (leafAtom.type == Atom.TYPE_sbgp) { + leafAtomData.setPosition(Atom.FULL_HEADER_SIZE); + if (leafAtomData.readInt() == SAMPLE_GROUP_TYPE_seig) { + sbgp = leafAtomData; + } + } else if (leafAtom.type == Atom.TYPE_sgpd) { + leafAtomData.setPosition(Atom.FULL_HEADER_SIZE); + if (leafAtomData.readInt() == SAMPLE_GROUP_TYPE_seig) { + sgpd = leafAtomData; + } + } + } + if (sbgp == null || sgpd == null) { return; } - if (Atom.parseFullAtomVersion(sbgpFullAtom) == 1) { - sbgp.skipBytes(4); // default_length. + + sbgp.setPosition(Atom.HEADER_SIZE); + int sbgpVersion = Atom.parseFullAtomVersion(sbgp.readInt()); + sbgp.skipBytes(4); // grouping_type == seig. + if (sbgpVersion == 1) { + sbgp.skipBytes(4); // grouping_type_parameter. } if (sbgp.readInt() != 1) { // entry_count. throw new ParserException("Entry count in sbgp != 1 (unsupported)."); } sgpd.setPosition(Atom.HEADER_SIZE); - int sgpdFullAtom = sgpd.readInt(); - if (sgpd.readInt() != SAMPLE_GROUP_TYPE_seig) { - // Only seig grouping type is supported. - return; - } - int sgpdVersion = Atom.parseFullAtomVersion(sgpdFullAtom); + int sgpdVersion = Atom.parseFullAtomVersion(sgpd.readInt()); + sgpd.skipBytes(4); // grouping_type == seig. if (sgpdVersion == 1) { if (sgpd.readUnsignedInt() == 0) { throw new ParserException("Variable length description in sgpd found (unsupported)"); @@ -1103,6 +1153,7 @@ public class FragmentedMp4Extractor implements Extractor { if (sgpd.readUnsignedInt() != 1) { // entry_count. throw new ParserException("Entry count in sgpd != 1 (unsupported)."); } + // CencSampleEncryptionInformationGroupEntry sgpd.skipBytes(1); // reserved = 0. int patternByte = sgpd.readUnsignedByte(); @@ -1115,7 +1166,7 @@ public class FragmentedMp4Extractor implements Extractor { int perSampleIvSize = sgpd.readUnsignedByte(); byte[] keyId = new byte[16]; sgpd.readBytes(keyId, 0, keyId.length); - byte[] constantIv = null; + @Nullable byte[] constantIv = null; if (perSampleIvSize == 0) { int constantIvSize = sgpd.readUnsignedByte(); constantIv = new byte[constantIvSize]; @@ -1192,7 +1243,7 @@ public class FragmentedMp4Extractor implements Extractor { } private void readEncryptionData(ExtractorInput input) throws IOException { - TrackBundle nextTrackBundle = null; + @Nullable TrackBundle nextTrackBundle = null; long nextDataOffset = Long.MAX_VALUE; int trackBundlesSize = trackBundles.size(); for (int i = 0; i < trackBundlesSize; i++) { @@ -1231,80 +1282,77 @@ public class FragmentedMp4Extractor implements Extractor { * @throws IOException If an error occurs reading from the input. */ private boolean readSample(ExtractorInput input) throws IOException { - if (parserState == STATE_READING_SAMPLE_START) { - if (currentTrackBundle == null) { - @Nullable TrackBundle currentTrackBundle = getNextFragmentRun(trackBundles); - if (currentTrackBundle == null) { - // We've run out of samples in the current mdat. Discard any trailing data and prepare to - // read the header of the next atom. - int bytesToSkip = (int) (endOfMdatPosition - input.getPosition()); - if (bytesToSkip < 0) { - throw new ParserException("Offset to end of mdat was negative."); - } - input.skipFully(bytesToSkip); - enterReadingAtomHeaderState(); - return false; - } - - long nextDataPosition = currentTrackBundle.fragment - .trunDataPosition[currentTrackBundle.currentTrackRunIndex]; - // We skip bytes preceding the next sample to read. - int bytesToSkip = (int) (nextDataPosition - input.getPosition()); + @Nullable TrackBundle trackBundle = currentTrackBundle; + if (trackBundle == null) { + trackBundle = getNextTrackBundle(trackBundles); + if (trackBundle == null) { + // We've run out of samples in the current mdat. Discard any trailing data and prepare to + // read the header of the next atom. + int bytesToSkip = (int) (endOfMdatPosition - input.getPosition()); if (bytesToSkip < 0) { - // Assume the sample data must be contiguous in the mdat with no preceding data. - Log.w(TAG, "Ignoring negative offset to sample data."); - bytesToSkip = 0; + throw new ParserException("Offset to end of mdat was negative."); } input.skipFully(bytesToSkip); - this.currentTrackBundle = currentTrackBundle; + enterReadingAtomHeaderState(); + return false; } - sampleSize = currentTrackBundle.fragment - .sampleSizeTable[currentTrackBundle.currentSampleIndex]; + long nextDataPosition = trackBundle.getCurrentSampleOffset(); + // We skip bytes preceding the next sample to read. + int bytesToSkip = (int) (nextDataPosition - input.getPosition()); + if (bytesToSkip < 0) { + // Assume the sample data must be contiguous in the mdat with no preceding data. + Log.w(TAG, "Ignoring negative offset to sample data."); + bytesToSkip = 0; + } + input.skipFully(bytesToSkip); + currentTrackBundle = trackBundle; + } + if (parserState == STATE_READING_SAMPLE_START) { + sampleSize = trackBundle.getCurrentSampleSize(); - if (currentTrackBundle.currentSampleIndex < currentTrackBundle.firstSampleToOutputIndex) { + if (trackBundle.currentSampleIndex < trackBundle.firstSampleToOutputIndex) { input.skipFully(sampleSize); - currentTrackBundle.skipSampleEncryptionData(); - if (!currentTrackBundle.next()) { + trackBundle.skipSampleEncryptionData(); + if (!trackBundle.next()) { currentTrackBundle = null; } parserState = STATE_READING_SAMPLE_START; return true; } - if (currentTrackBundle.track.sampleTransformation == Track.TRANSFORMATION_CEA608_CDAT) { + if (trackBundle.moovSampleTable.track.sampleTransformation + == Track.TRANSFORMATION_CEA608_CDAT) { sampleSize -= Atom.HEADER_SIZE; input.skipFully(Atom.HEADER_SIZE); } - if (MimeTypes.AUDIO_AC4.equals(currentTrackBundle.track.format.sampleMimeType)) { + if (MimeTypes.AUDIO_AC4.equals(trackBundle.moovSampleTable.track.format.sampleMimeType)) { // AC4 samples need to be prefixed with a clear sample header. sampleBytesWritten = - currentTrackBundle.outputSampleEncryptionData(sampleSize, Ac4Util.SAMPLE_HEADER_SIZE); + trackBundle.outputSampleEncryptionData(sampleSize, Ac4Util.SAMPLE_HEADER_SIZE); Ac4Util.getAc4SampleHeader(sampleSize, scratch); - currentTrackBundle.output.sampleData(scratch, Ac4Util.SAMPLE_HEADER_SIZE); + trackBundle.output.sampleData(scratch, Ac4Util.SAMPLE_HEADER_SIZE); sampleBytesWritten += Ac4Util.SAMPLE_HEADER_SIZE; } else { sampleBytesWritten = - currentTrackBundle.outputSampleEncryptionData(sampleSize, /* clearHeaderSize= */ 0); + trackBundle.outputSampleEncryptionData(sampleSize, /* clearHeaderSize= */ 0); } sampleSize += sampleBytesWritten; parserState = STATE_READING_SAMPLE_CONTINUE; sampleCurrentNalBytesRemaining = 0; } - TrackFragment fragment = currentTrackBundle.fragment; - Track track = currentTrackBundle.track; - TrackOutput output = currentTrackBundle.output; - int sampleIndex = currentTrackBundle.currentSampleIndex; - long sampleTimeUs = fragment.getSamplePresentationTimeUs(sampleIndex); + Track track = trackBundle.moovSampleTable.track; + TrackOutput output = trackBundle.output; + long sampleTimeUs = trackBundle.getCurrentSamplePresentationTimeUs(); if (timestampAdjuster != null) { sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs); } if (track.nalUnitLengthFieldLength != 0) { // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case // they're only 1 or 2 bytes long. - byte[] nalPrefixData = nalPrefix.data; + byte[] nalPrefixData = nalPrefix.getData(); nalPrefixData[0] = 0; nalPrefixData[1] = 0; nalPrefixData[2] = 0; @@ -1328,8 +1376,9 @@ public class FragmentedMp4Extractor implements Extractor { output.sampleData(nalStartCode, 4); // Write the NAL unit type byte. output.sampleData(nalPrefix, 1); - processSeiNalUnitPayload = cea608TrackOutputs.length > 0 - && NalUnitUtil.isNalUnitSei(track.format.sampleMimeType, nalPrefixData[4]); + processSeiNalUnitPayload = + ceaTrackOutputs.length > 0 + && NalUnitUtil.isNalUnitSei(track.format.sampleMimeType, nalPrefixData[4]); sampleBytesWritten += 5; sampleSize += nalUnitLengthFieldLengthDiff; } else { @@ -1337,15 +1386,16 @@ public class FragmentedMp4Extractor implements Extractor { if (processSeiNalUnitPayload) { // Read and write the payload of the SEI NAL unit. nalBuffer.reset(sampleCurrentNalBytesRemaining); - input.readFully(nalBuffer.data, 0, sampleCurrentNalBytesRemaining); + input.readFully(nalBuffer.getData(), 0, sampleCurrentNalBytesRemaining); output.sampleData(nalBuffer, sampleCurrentNalBytesRemaining); writtenBytes = sampleCurrentNalBytesRemaining; // Unescape and process the SEI NAL unit. - int unescapedLength = NalUnitUtil.unescapeStream(nalBuffer.data, nalBuffer.limit()); + int unescapedLength = + NalUnitUtil.unescapeStream(nalBuffer.getData(), nalBuffer.limit()); // If the format is H.265/HEVC the NAL unit header has two bytes so skip one more byte. nalBuffer.setPosition(MimeTypes.VIDEO_H265.equals(track.format.sampleMimeType) ? 1 : 0); nalBuffer.setLimit(unescapedLength); - CeaUtil.consume(sampleTimeUs, nalBuffer, cea608TrackOutputs); + CeaUtil.consume(sampleTimeUs, nalBuffer, ceaTrackOutputs); } else { // Write the payload of the NAL unit. writtenBytes = output.sampleData(input, sampleCurrentNalBytesRemaining, false); @@ -1361,14 +1411,12 @@ public class FragmentedMp4Extractor implements Extractor { } } - @C.BufferFlags int sampleFlags = fragment.sampleIsSyncFrameTable[sampleIndex] - ? C.BUFFER_FLAG_KEY_FRAME : 0; + @C.BufferFlags int sampleFlags = trackBundle.getCurrentSampleFlags(); // Encryption data. - TrackOutput.CryptoData cryptoData = null; - TrackEncryptionBox encryptionBox = currentTrackBundle.getEncryptionBoxIfEncrypted(); + @Nullable TrackOutput.CryptoData cryptoData = null; + @Nullable TrackEncryptionBox encryptionBox = trackBundle.getEncryptionBoxIfEncrypted(); if (encryptionBox != null) { - sampleFlags |= C.BUFFER_FLAG_ENCRYPTED; cryptoData = encryptionBox.cryptoData; } @@ -1376,7 +1424,7 @@ public class FragmentedMp4Extractor implements Extractor { // After we have the sampleTimeUs, we can commit all the pending metadata samples outputPendingMetadataSamples(sampleTimeUs); - if (!currentTrackBundle.next()) { + if (!trackBundle.next()) { currentTrackBundle = null; } parserState = STATE_READING_SAMPLE_START; @@ -1403,24 +1451,27 @@ public class FragmentedMp4Extractor implements Extractor { } /** - * Returns the {@link TrackBundle} whose fragment run has the earliest file position out of those - * yet to be consumed, or null if all have been consumed. + * Returns the {@link TrackBundle} whose sample has the earliest file position out of those yet to + * be consumed, or null if all have been consumed. */ @Nullable - private static TrackBundle getNextFragmentRun(SparseArray trackBundles) { - TrackBundle nextTrackBundle = null; - long nextTrackRunOffset = Long.MAX_VALUE; + private static TrackBundle getNextTrackBundle(SparseArray trackBundles) { + @Nullable TrackBundle nextTrackBundle = null; + long nextSampleOffset = Long.MAX_VALUE; int trackBundlesSize = trackBundles.size(); for (int i = 0; i < trackBundlesSize; i++) { TrackBundle trackBundle = trackBundles.valueAt(i); - if (trackBundle.currentTrackRunIndex == trackBundle.fragment.trunCount) { - // This track fragment contains no more runs in the next mdat box. + if ((!trackBundle.currentlyInFragment + && trackBundle.currentSampleIndex == trackBundle.moovSampleTable.sampleCount) + || (trackBundle.currentlyInFragment + && trackBundle.currentTrackRunIndex == trackBundle.fragment.trunCount)) { + // This track sample table or fragment contains no more runs in the next mdat box. } else { - long trunOffset = trackBundle.fragment.trunDataPosition[trackBundle.currentTrackRunIndex]; - if (trunOffset < nextTrackRunOffset) { + long sampleOffset = trackBundle.getCurrentSampleOffset(); + if (sampleOffset < nextSampleOffset) { nextTrackBundle = trackBundle; - nextTrackRunOffset = trunOffset; + nextSampleOffset = sampleOffset; } } } @@ -1438,7 +1489,7 @@ public class FragmentedMp4Extractor implements Extractor { if (schemeDatas == null) { schemeDatas = new ArrayList<>(); } - byte[] psshData = child.data.data; + byte[] psshData = child.data.getData(); @Nullable UUID uuid = PsshAtomUtil.parseUuid(psshData); if (uuid == null) { Log.w(TAG, "Skipped pssh atom (failed to extract uuid)"); @@ -1452,13 +1503,34 @@ public class FragmentedMp4Extractor implements Extractor { /** Returns whether the extractor should decode a leaf atom with type {@code atom}. */ private static boolean shouldParseLeafAtom(int atom) { - return atom == Atom.TYPE_hdlr || atom == Atom.TYPE_mdhd || atom == Atom.TYPE_mvhd - || atom == Atom.TYPE_sidx || atom == Atom.TYPE_stsd || atom == Atom.TYPE_tfdt - || atom == Atom.TYPE_tfhd || atom == Atom.TYPE_tkhd || atom == Atom.TYPE_trex - || atom == Atom.TYPE_trun || atom == Atom.TYPE_pssh || atom == Atom.TYPE_saiz - || atom == Atom.TYPE_saio || atom == Atom.TYPE_senc || atom == Atom.TYPE_uuid - || atom == Atom.TYPE_sbgp || atom == Atom.TYPE_sgpd || atom == Atom.TYPE_elst - || atom == Atom.TYPE_mehd || atom == Atom.TYPE_emsg; + return atom == Atom.TYPE_hdlr + || atom == Atom.TYPE_mdhd + || atom == Atom.TYPE_mvhd + || atom == Atom.TYPE_sidx + || atom == Atom.TYPE_stsd + || atom == Atom.TYPE_stts + || atom == Atom.TYPE_ctts + || atom == Atom.TYPE_stsc + || atom == Atom.TYPE_stsz + || atom == Atom.TYPE_stz2 + || atom == Atom.TYPE_stco + || atom == Atom.TYPE_co64 + || atom == Atom.TYPE_stss + || atom == Atom.TYPE_tfdt + || atom == Atom.TYPE_tfhd + || atom == Atom.TYPE_tkhd + || atom == Atom.TYPE_trex + || atom == Atom.TYPE_trun + || atom == Atom.TYPE_pssh + || atom == Atom.TYPE_saiz + || atom == Atom.TYPE_saio + || atom == Atom.TYPE_senc + || atom == Atom.TYPE_uuid + || atom == Atom.TYPE_sbgp + || atom == Atom.TYPE_sgpd + || atom == Atom.TYPE_elst + || atom == Atom.TYPE_mehd + || atom == Atom.TYPE_emsg; } /** Returns whether the extractor should decode a container atom with type {@code atom}. */ @@ -1494,7 +1566,7 @@ public class FragmentedMp4Extractor implements Extractor { public final TrackFragment fragment; public final ParsableByteArray scratch; - public Track track; + public TrackSampleTable moovSampleTable; public DefaultSampleValues defaultSampleValues; public int currentSampleIndex; public int currentSampleInTrackRun; @@ -1504,38 +1576,49 @@ public class FragmentedMp4Extractor implements Extractor { private final ParsableByteArray encryptionSignalByte; private final ParsableByteArray defaultInitializationVector; - public TrackBundle(TrackOutput output) { + private boolean currentlyInFragment; + + public TrackBundle( + TrackOutput output, + TrackSampleTable moovSampleTable, + DefaultSampleValues defaultSampleValues) { this.output = output; + this.moovSampleTable = moovSampleTable; + this.defaultSampleValues = defaultSampleValues; fragment = new TrackFragment(); scratch = new ParsableByteArray(); encryptionSignalByte = new ParsableByteArray(1); defaultInitializationVector = new ParsableByteArray(); + reset(moovSampleTable, defaultSampleValues); } - public void init(Track track, DefaultSampleValues defaultSampleValues) { - this.track = Assertions.checkNotNull(track); - this.defaultSampleValues = Assertions.checkNotNull(defaultSampleValues); - output.format(track.format); - reset(); + public void reset(TrackSampleTable moovSampleTable, DefaultSampleValues defaultSampleValues) { + this.moovSampleTable = moovSampleTable; + this.defaultSampleValues = defaultSampleValues; + output.format(moovSampleTable.track.format); + resetFragmentInfo(); } public void updateDrmInitData(DrmInitData drmInitData) { @Nullable TrackEncryptionBox encryptionBox = - track.getSampleDescriptionEncryptionBox(fragment.header.sampleDescriptionIndex); + moovSampleTable.track.getSampleDescriptionEncryptionBox( + castNonNull(fragment.header).sampleDescriptionIndex); @Nullable String schemeType = encryptionBox != null ? encryptionBox.schemeType : null; DrmInitData updatedDrmInitData = drmInitData.copyWithSchemeType(schemeType); - Format format = track.format.buildUpon().setDrmInitData(updatedDrmInitData).build(); + Format format = + moovSampleTable.track.format.buildUpon().setDrmInitData(updatedDrmInitData).build(); output.format(format); } - /** Resets the current fragment and sample indices. */ - public void reset() { + /** Resets the current fragment, sample indices and {@link #currentlyInFragment} boolean. */ + public void resetFragmentInfo() { fragment.reset(); currentSampleIndex = 0; currentTrackRunIndex = 0; currentSampleInTrackRun = 0; firstSampleToOutputIndex = 0; + currentlyInFragment = false; } /** @@ -1555,16 +1638,58 @@ public class FragmentedMp4Extractor implements Extractor { } } + /** Returns the presentation time of the current sample in microseconds. */ + public long getCurrentSamplePresentationTimeUs() { + return !currentlyInFragment + ? moovSampleTable.timestampsUs[currentSampleIndex] + : fragment.getSamplePresentationTimeUs(currentSampleIndex); + } + + /** Returns the byte offset of the current sample. */ + public long getCurrentSampleOffset() { + return !currentlyInFragment + ? moovSampleTable.offsets[currentSampleIndex] + : fragment.trunDataPosition[currentTrackRunIndex]; + } + + /** Returns the size of the current sample in bytes. */ + public int getCurrentSampleSize() { + return !currentlyInFragment + ? moovSampleTable.sizes[currentSampleIndex] + : fragment.sampleSizeTable[currentSampleIndex]; + } + + /** Returns the {@link C.BufferFlags} corresponding to the the current sample. */ + @C.BufferFlags + public int getCurrentSampleFlags() { + int flags = + !currentlyInFragment + ? moovSampleTable.flags[currentSampleIndex] + : (fragment.sampleIsSyncFrameTable[currentSampleIndex] ? C.BUFFER_FLAG_KEY_FRAME : 0); + if (getEncryptionBoxIfEncrypted() != null) { + flags |= C.BUFFER_FLAG_ENCRYPTED; + } + return flags; + } + /** - * Advances the indices in the bundle to point to the next sample in the current fragment. If - * the current sample is the last one in the current fragment, then the advanced state will be - * {@code currentSampleIndex == fragment.sampleCount}, {@code currentTrackRunIndex == - * fragment.trunCount} and {@code #currentSampleInTrackRun == 0}. + * Advances the indices in the bundle to point to the next sample in the sample table (if it has + * not reached the fragments yet) or in the current fragment. * - * @return Whether the next sample is in the same track run as the previous one. + *

      If the current sample is the last one in the sample table, then the advanced state will be + * {@code currentSampleIndex == moovSampleTable.sampleCount}. If the current sample is the last + * one in the current fragment, then the advanced state will be {@code currentSampleIndex == + * fragment.sampleCount}, {@code currentTrackRunIndex == fragment.trunCount} and {@code + * #currentSampleInTrackRun == 0}. + * + * @return Whether this {@link TrackBundle} can be used to read the next sample without + * recomputing the next {@link TrackBundle}. */ public boolean next() { currentSampleIndex++; + if (!currentlyInFragment) { + return false; + } currentSampleInTrackRun++; if (currentSampleInTrackRun == fragment.trunLength[currentTrackRunIndex]) { currentTrackRunIndex++; @@ -1577,6 +1702,8 @@ public class FragmentedMp4Extractor implements Extractor { /** * Outputs the encryption data for the current sample. * + *

      This is not supported yet for samples specified in the sample table. + * * @param sampleSize The size of the current sample in bytes, excluding any additional clear * header that will be prefixed to the sample by the extractor. * @param clearHeaderSize The size of a clear header that will be prefixed to the sample by the @@ -1584,7 +1711,7 @@ public class FragmentedMp4Extractor implements Extractor { * @return The number of written bytes. */ public int outputSampleEncryptionData(int sampleSize, int clearHeaderSize) { - TrackEncryptionBox encryptionBox = getEncryptionBoxIfEncrypted(); + @Nullable TrackEncryptionBox encryptionBox = getEncryptionBoxIfEncrypted(); if (encryptionBox == null) { return 0; } @@ -1596,7 +1723,7 @@ public class FragmentedMp4Extractor implements Extractor { vectorSize = encryptionBox.perSampleIvSize; } else { // The default initialization vector should be used. - byte[] initVectorData = encryptionBox.defaultInitializationVector; + byte[] initVectorData = castNonNull(encryptionBox.defaultInitializationVector); defaultInitializationVector.reset(initVectorData, initVectorData.length); initializationVectorData = defaultInitializationVector; vectorSize = initVectorData.length; @@ -1607,7 +1734,7 @@ public class FragmentedMp4Extractor implements Extractor { boolean writeSubsampleEncryptionData = haveSubsampleEncryptionTable || clearHeaderSize != 0; // Write the signal byte, containing the vector size and the subsample encryption flag. - encryptionSignalByte.data[0] = + encryptionSignalByte.getData()[0] = (byte) (vectorSize | (writeSubsampleEncryptionData ? 0x80 : 0)); encryptionSignalByte.setPosition(0); output.sampleData(encryptionSignalByte, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); @@ -1625,16 +1752,17 @@ public class FragmentedMp4Extractor implements Extractor { // into account. scratch.reset(SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH); // subsampleCount = 1 (unsigned short) - scratch.data[0] = (byte) 0; - scratch.data[1] = (byte) 1; + byte[] data = scratch.getData(); + data[0] = (byte) 0; + data[1] = (byte) 1; // clearDataSize = clearHeaderSize (unsigned short) - scratch.data[2] = (byte) ((clearHeaderSize >> 8) & 0xFF); - scratch.data[3] = (byte) (clearHeaderSize & 0xFF); + data[2] = (byte) ((clearHeaderSize >> 8) & 0xFF); + data[3] = (byte) (clearHeaderSize & 0xFF); // encryptedDataSize = sampleSize (unsigned int) - scratch.data[4] = (byte) ((sampleSize >> 24) & 0xFF); - scratch.data[5] = (byte) ((sampleSize >> 16) & 0xFF); - scratch.data[6] = (byte) ((sampleSize >> 8) & 0xFF); - scratch.data[7] = (byte) (sampleSize & 0xFF); + data[4] = (byte) ((sampleSize >> 24) & 0xFF); + data[5] = (byte) ((sampleSize >> 16) & 0xFF); + data[6] = (byte) ((sampleSize >> 8) & 0xFF); + data[7] = (byte) (sampleSize & 0xFF); output.sampleData( scratch, SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH, @@ -1651,13 +1779,13 @@ public class FragmentedMp4Extractor implements Extractor { // We need to account for the additional clear header by adding clearHeaderSize to // clearDataSize for the first subsample specified in the subsample encryption data. scratch.reset(subsampleDataLength); - scratch.readBytes(subsampleEncryptionData.data, /* offset= */ 0, subsampleDataLength); - subsampleEncryptionData.skipBytes(subsampleDataLength); + byte[] scratchData = scratch.getData(); + subsampleEncryptionData.readBytes(scratchData, /* offset= */ 0, subsampleDataLength); - int clearDataSize = (scratch.data[2] & 0xFF) << 8 | (scratch.data[3] & 0xFF); + int clearDataSize = (scratchData[2] & 0xFF) << 8 | (scratchData[3] & 0xFF); int adjustedClearDataSize = clearDataSize + clearHeaderSize; - scratch.data[2] = (byte) ((adjustedClearDataSize >> 8) & 0xFF); - scratch.data[3] = (byte) (adjustedClearDataSize & 0xFF); + scratchData[2] = (byte) ((adjustedClearDataSize >> 8) & 0xFF); + scratchData[3] = (byte) (adjustedClearDataSize & 0xFF); subsampleEncryptionData = scratch; } @@ -1666,8 +1794,12 @@ public class FragmentedMp4Extractor implements Extractor { return 1 + vectorSize + subsampleDataLength; } - /** Skips the encryption data for the current sample. */ - private void skipSampleEncryptionData() { + /** + * Skips the encryption data for the current sample. + * + *

      This is not supported yet for samples specified in the sample table. + */ + public void skipSampleEncryptionData() { @Nullable TrackEncryptionBox encryptionBox = getEncryptionBoxIfEncrypted(); if (encryptionBox == null) { return; @@ -1683,13 +1815,17 @@ public class FragmentedMp4Extractor implements Extractor { } @Nullable - private TrackEncryptionBox getEncryptionBoxIfEncrypted() { - int sampleDescriptionIndex = fragment.header.sampleDescriptionIndex; + public TrackEncryptionBox getEncryptionBoxIfEncrypted() { + if (!currentlyInFragment) { + // Encryption is not supported yet for samples specified in the sample table. + return null; + } + int sampleDescriptionIndex = castNonNull(fragment.header).sampleDescriptionIndex; @Nullable TrackEncryptionBox encryptionBox = fragment.trackEncryptionBox != null ? fragment.trackEncryptionBox - : track.getSampleDescriptionEncryptionBox(sampleDescriptionIndex); + : moovSampleTable.track.getSampleDescriptionEncryptionBox(sampleDescriptionIndex); return encryptionBox != null && encryptionBox.isEncrypted ? encryptionBox : null; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java index 365e336e65..d29b54a5e5 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; @@ -460,7 +462,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; boolean isBoolean) { int value = parseUint8AttributeValue(data); if (isBoolean) { - value = Math.min(1, value); + value = min(1, value); } if (value >= 0) { return isTextInformationFrame diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index 48c7e3e122..f9e70915bc 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -15,6 +15,12 @@ */ package com.google.android.exoplayer2.extractor.mp4; +import static com.google.android.exoplayer2.extractor.mp4.AtomParsers.parseTraks; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; +import static java.lang.Math.min; + import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -44,6 +50,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Extracts data from the MP4 container format. @@ -116,8 +123,8 @@ public final class Mp4Extractor implements Extractor, SeekMap { // Extractor outputs. private @MonotonicNonNull ExtractorOutput extractorOutput; - private Mp4Track[] tracks; - private long[][] accumulatedSampleSizes; + private Mp4Track @MonotonicNonNull [] tracks; + private long @MonotonicNonNull [][] accumulatedSampleSizes; private int firstVideoTrackIndex; private long durationUs; private boolean isQuickTime; @@ -211,7 +218,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { @Override public SeekPoints getSeekPoints(long timeUs) { - if (tracks.length == 0) { + if (checkNotNull(tracks).length == 0) { return new SeekPoints(SeekPoint.START); } @@ -272,7 +279,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { private boolean readAtomHeader(ExtractorInput input) throws IOException { if (atomHeaderBytesRead == 0) { // Read the standard length atom header. - if (!input.readFully(atomHeader.data, 0, Atom.HEADER_SIZE, true)) { + if (!input.readFully(atomHeader.getData(), 0, Atom.HEADER_SIZE, true)) { return false; } atomHeaderBytesRead = Atom.HEADER_SIZE; @@ -284,7 +291,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { if (atomSize == Atom.DEFINES_LARGE_SIZE) { // Read the large size. int headerBytesRemaining = Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE; - input.readFully(atomHeader.data, Atom.HEADER_SIZE, headerBytesRemaining); + input.readFully(atomHeader.getData(), Atom.HEADER_SIZE, headerBytesRemaining); atomHeaderBytesRead += headerBytesRemaining; atomSize = atomHeader.readUnsignedLongToLong(); } else if (atomSize == Atom.EXTENDS_TO_END_SIZE) { @@ -323,8 +330,9 @@ public final class Mp4Extractor implements Extractor, SeekMap { // lengths greater than Integer.MAX_VALUE. Assertions.checkState(atomHeaderBytesRead == Atom.HEADER_SIZE); Assertions.checkState(atomSize <= Integer.MAX_VALUE); - atomData = new ParsableByteArray((int) atomSize); - System.arraycopy(atomHeader.data, 0, atomData.data, 0, Atom.HEADER_SIZE); + ParsableByteArray atomData = new ParsableByteArray((int) atomSize); + System.arraycopy(atomHeader.getData(), 0, atomData.getData(), 0, Atom.HEADER_SIZE); + this.atomData = atomData; parserState = STATE_READING_ATOM_PAYLOAD; } else { atomData = null; @@ -344,8 +352,9 @@ public final class Mp4Extractor implements Extractor, SeekMap { long atomPayloadSize = atomSize - atomHeaderBytesRead; long atomEndPosition = input.getPosition() + atomPayloadSize; boolean seekRequired = false; + @Nullable ParsableByteArray atomData = this.atomData; if (atomData != null) { - input.readFully(atomData.data, atomHeaderBytesRead, (int) atomPayloadSize); + input.readFully(atomData.getData(), atomHeaderBytesRead, (int) atomPayloadSize); if (atomType == Atom.TYPE_ftyp) { isQuickTime = processFtypAtom(atomData); } else if (!containerAtoms.isEmpty()) { @@ -406,16 +415,27 @@ public final class Mp4Extractor implements Extractor, SeekMap { } boolean ignoreEditLists = (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0; - ArrayList trackSampleTables = - getTrackSampleTables(moov, gaplessInfoHolder, ignoreEditLists); + List trackSampleTables = + parseTraks( + moov, + gaplessInfoHolder, + /* duration= */ C.TIME_UNSET, + /* drmInitData= */ null, + ignoreEditLists, + isQuickTime, + /* modifyTrackFunction= */ track -> track); + ExtractorOutput extractorOutput = checkNotNull(this.extractorOutput); int trackCount = trackSampleTables.size(); for (int i = 0; i < trackCount; i++) { TrackSampleTable trackSampleTable = trackSampleTables.get(i); + if (trackSampleTable.sampleCount == 0) { + continue; + } Track track = trackSampleTable.track; long trackDurationUs = track.durationUs != C.TIME_UNSET ? track.durationUs : trackSampleTable.durationUs; - durationUs = Math.max(durationUs, trackDurationUs); + durationUs = max(durationUs, trackDurationUs); Mp4Track mp4Track = new Mp4Track(track, trackSampleTable, extractorOutput.track(i, track.type)); @@ -448,40 +468,6 @@ public final class Mp4Extractor implements Extractor, SeekMap { extractorOutput.seekMap(this); } - private ArrayList getTrackSampleTables( - ContainerAtom moov, GaplessInfoHolder gaplessInfoHolder, boolean ignoreEditLists) - throws ParserException { - ArrayList trackSampleTables = new ArrayList<>(); - for (int i = 0; i < moov.containerChildren.size(); i++) { - Atom.ContainerAtom atom = moov.containerChildren.get(i); - if (atom.type != Atom.TYPE_trak) { - continue; - } - @Nullable - Track track = - AtomParsers.parseTrak( - atom, - moov.getLeafAtomOfType(Atom.TYPE_mvhd), - /* duration= */ C.TIME_UNSET, - /* drmInitData= */ null, - ignoreEditLists, - isQuickTime); - if (track == null) { - continue; - } - Atom.ContainerAtom stblAtom = - atom.getContainerAtomOfType(Atom.TYPE_mdia) - .getContainerAtomOfType(Atom.TYPE_minf) - .getContainerAtomOfType(Atom.TYPE_stbl); - TrackSampleTable trackSampleTable = AtomParsers.parseStbl(track, stblAtom, gaplessInfoHolder); - if (trackSampleTable.sampleCount == 0) { - continue; - } - trackSampleTables.add(trackSampleTable); - } - return trackSampleTables; - } - /** * Attempts to extract the next sample in the current mdat atom for the specified track. * @@ -505,7 +491,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { return RESULT_END_OF_INPUT; } } - Mp4Track track = tracks[sampleTrackIndex]; + Mp4Track track = castNonNull(tracks)[sampleTrackIndex]; TrackOutput trackOutput = track.trackOutput; int sampleIndex = track.sampleIndex; long position = track.sampleTable.offsets[sampleIndex]; @@ -525,7 +511,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { if (track.track.nalUnitLengthFieldLength != 0) { // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case // they're only 1 or 2 bytes long. - byte[] nalLengthData = nalLength.data; + byte[] nalLengthData = nalLength.getData(); nalLengthData[0] = 0; nalLengthData[1] = 0; nalLengthData[2] = 0; @@ -605,14 +591,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { long minAccumulatedBytes = Long.MAX_VALUE; boolean minAccumulatedBytesRequiresReload = true; int minAccumulatedBytesTrackIndex = C.INDEX_UNSET; - for (int trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + for (int trackIndex = 0; trackIndex < castNonNull(tracks).length; trackIndex++) { Mp4Track track = tracks[trackIndex]; int sampleIndex = track.sampleIndex; if (sampleIndex == track.sampleTable.sampleCount) { continue; } long sampleOffset = track.sampleTable.offsets[sampleIndex]; - long sampleAccumulatedBytes = accumulatedSampleSizes[trackIndex][sampleIndex]; + long sampleAccumulatedBytes = castNonNull(accumulatedSampleSizes)[trackIndex][sampleIndex]; long skipAmount = sampleOffset - inputPosition; boolean requiresReload = skipAmount < 0 || skipAmount >= RELOAD_MINIMUM_SEEK_DISTANCE; if ((!requiresReload && preferredRequiresReload) @@ -638,6 +624,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { /** * Updates every track's sample index to point its latest sync sample before/at {@code timeUs}. */ + @RequiresNonNull("tracks") private void updateSampleIndices(long timeUs) { for (Mp4Track track : tracks) { TrackSampleTable sampleTable = track.sampleTable; @@ -666,7 +653,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { // (iso) [1 byte version + 3 bytes flags][4 byte size of next atom] // (qt) [4 byte size of next atom ][4 byte hdlr atom type ] // In case of (iso) we need to skip the next 4 bytes. - input.peekFully(scratch.data, 0, 8); + input.peekFully(scratch.getData(), 0, 8); scratch.skipBytes(4); if (scratch.readInt() == Atom.TYPE_hdlr) { input.resetPeekPosition(); @@ -730,7 +717,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { return offset; } long sampleOffset = sampleTable.offsets[sampleIndex]; - return Math.min(sampleOffset, offset); + return min(sampleOffset, offset); } /** diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java index c661e7be07..00acb29906 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Sniffer.java @@ -57,6 +57,8 @@ import java.io.IOException; 0x71742020, // qt[space][space], Apple QuickTime 0x4d534e56, // MSNV, Sony PSP 0x64627931, // dby1, Dolby Vision + 0x69736d6c, // isml + 0x70696666, // piff }; /** @@ -97,13 +99,19 @@ import java.io.IOException; // Read an atom header. int headerSize = Atom.HEADER_SIZE; buffer.reset(headerSize); - input.peekFully(buffer.data, 0, headerSize); + boolean success = + input.peekFully(buffer.getData(), 0, headerSize, /* allowEndOfInput= */ true); + if (!success) { + // We've reached the end of the file. + break; + } long atomSize = buffer.readUnsignedInt(); int atomType = buffer.readInt(); if (atomSize == Atom.DEFINES_LARGE_SIZE) { // Read the large atom size. headerSize = Atom.LONG_HEADER_SIZE; - input.peekFully(buffer.data, Atom.HEADER_SIZE, Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE); + input.peekFully( + buffer.getData(), Atom.HEADER_SIZE, Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE); buffer.setLimit(Atom.LONG_HEADER_SIZE); atomSize = buffer.readLong(); } else if (atomSize == Atom.EXTENDS_TO_END_SIZE) { @@ -151,7 +159,7 @@ import java.io.IOException; return false; } buffer.reset(atomDataSize); - input.peekFully(buffer.data, 0, atomDataSize); + input.peekFully(buffer.getData(), 0, atomDataSize); int brandsCount = atomDataSize / 4; for (int i = 0; i < brandsCount; i++) { if (i == 1) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java index b214114340..92ce551f48 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java @@ -89,9 +89,16 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; */ public boolean sampleEncryptionDataNeedsFill; /** - * The absolute decode time of the start of the next fragment. + * The duration of all the samples defined in the fragments up to and including this one, plus the + * duration of the samples defined in the moov atom if {@link #nextFragmentDecodeTimeIncludesMoov} + * is {@code true}. */ public long nextFragmentDecodeTime; + /** + * Whether {@link #nextFragmentDecodeTime} includes the duration of the samples referred to by the + * moov atom. + */ + public boolean nextFragmentDecodeTimeIncludesMoov; public TrackFragment() { trunDataPosition = new long[0]; @@ -114,6 +121,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public void reset() { trunCount = 0; nextFragmentDecodeTime = 0; + nextFragmentDecodeTimeIncludesMoov = false; definesEncryptionData = false; sampleEncryptionDataNeedsFill = false; trackEncryptionBox = null; @@ -166,7 +174,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * @param input An {@link ExtractorInput} from which to read the encryption data. */ public void fillEncryptionData(ExtractorInput input) throws IOException { - input.readFully(sampleEncryptionData.data, 0, sampleEncryptionData.limit()); + input.readFully(sampleEncryptionData.getData(), 0, sampleEncryptionData.limit()); sampleEncryptionData.setPosition(0); sampleEncryptionDataNeedsFill = false; } @@ -177,7 +185,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * @param source A source from which to read the encryption data. */ public void fillEncryptionData(ParsableByteArray source) { - source.readBytes(sampleEncryptionData.data, 0, sampleEncryptionData.limit()); + source.readBytes(sampleEncryptionData.getData(), 0, sampleEncryptionData.limit()); sampleEncryptionData.setPosition(0); sampleEncryptionDataNeedsFill = false; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java index 59ea386335..ca500b2931 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java @@ -38,10 +38,7 @@ import com.google.android.exoplayer2.util.Util; public final long[] timestampsUs; /** Sample flags. */ public final int[] flags; - /** - * The duration of the track sample table in microseconds, or {@link C#TIME_UNSET} if the sample - * table is empty. - */ + /** The duration of the track sample table in microseconds. */ public final long durationUs; public TrackSampleTable( diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java index 1d73a1b66a..b7a8039489 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java @@ -30,9 +30,9 @@ import java.io.IOException; /** Seeks in an Ogg stream. */ /* package */ final class DefaultOggSeeker implements OggSeeker { - private static final int MATCH_RANGE = 72000; - private static final int MATCH_BYTE_RANGE = 100000; - private static final int DEFAULT_OFFSET = 30000; + private static final int MATCH_RANGE = 72_000; + private static final int MATCH_BYTE_RANGE = 100_000; + private static final int DEFAULT_OFFSET = 30_000; private static final int STATE_SEEK_TO_END = 0; private static final int STATE_READ_LAST_PAGE = 1; @@ -155,7 +155,7 @@ import java.io.IOException; } long currentPosition = input.getPosition(); - if (!skipToNextPage(input, end)) { + if (!pageHeader.skipToNextPage(input, end)) { if (start == currentPosition) { throw new IOException("No ogg page can be found."); } @@ -200,68 +200,21 @@ import java.io.IOException; * @throws IOException If reading from the input fails. */ private void skipToPageOfTargetGranule(ExtractorInput input) throws IOException { - pageHeader.populate(input, /* quiet= */ false); - while (pageHeader.granulePosition <= targetGranule) { + while (true) { + // If pageHeader.skipToNextPage fails to find a page it will advance input.position to the + // end of the file, so pageHeader.populate will throw EOFException (because quiet=false). + pageHeader.skipToNextPage(input); + pageHeader.populate(input, /* quiet= */ false); + if (pageHeader.granulePosition > targetGranule) { + break; + } input.skipFully(pageHeader.headerSize + pageHeader.bodySize); start = input.getPosition(); startGranule = pageHeader.granulePosition; - pageHeader.populate(input, /* quiet= */ false); } input.resetPeekPosition(); } - /** - * Skips to the next page. - * - * @param input The {@code ExtractorInput} to skip to the next page. - * @throws IOException If peeking/reading from the input fails. - * @throws EOFException If the next page can't be found before the end of the input. - */ - @VisibleForTesting - void skipToNextPage(ExtractorInput input) throws IOException { - if (!skipToNextPage(input, payloadEndPosition)) { - // Not found until eof. - throw new EOFException(); - } - } - - /** - * Skips to the next page. Searches for the next page header. - * - * @param input The {@code ExtractorInput} to skip to the next page. - * @param limit The limit up to which the search should take place. - * @return Whether the next page was found. - * @throws IOException If peeking/reading from the input fails. - */ - private boolean skipToNextPage(ExtractorInput input, long limit) throws IOException { - limit = Math.min(limit + 3, payloadEndPosition); - byte[] buffer = new byte[2048]; - int peekLength = buffer.length; - while (true) { - if (input.getPosition() + peekLength > limit) { - // Make sure to not peek beyond the end of the input. - peekLength = (int) (limit - input.getPosition()); - if (peekLength < 4) { - // Not found until end. - return false; - } - } - input.peekFully(buffer, 0, peekLength, false); - for (int i = 0; i < peekLength - 3; i++) { - if (buffer[i] == 'O' - && buffer[i + 1] == 'g' - && buffer[i + 2] == 'g' - && buffer[i + 3] == 'S') { - // Match! Skip to the start of the pattern. - input.skipFully(i); - return true; - } - } - // Overlap by not skipping the entire peekLength. - input.skipFully(peekLength - 3); - } - } - /** * Skips to the last Ogg page in the stream and reads the header's granule field which is the * total number of samples per channel. @@ -272,12 +225,16 @@ import java.io.IOException; */ @VisibleForTesting long readGranuleOfLastPage(ExtractorInput input) throws IOException { - skipToNextPage(input); pageHeader.reset(); - while ((pageHeader.type & 0x04) != 0x04 && input.getPosition() < payloadEndPosition) { + if (!pageHeader.skipToNextPage(input)) { + throw new EOFException(); + } + do { pageHeader.populate(input, /* quiet= */ false); input.skipFully(pageHeader.headerSize + pageHeader.bodySize); - } + } while ((pageHeader.type & 0x04) != 0x04 + && pageHeader.skipToNextPage(input) + && input.getPosition() < payloadEndPosition); return pageHeader.granulePosition; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java index 1d6f0da9a1..e64e6b1dc2 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/FlacReader.java @@ -61,7 +61,7 @@ import java.util.Arrays; @Override protected long preparePayload(ParsableByteArray packet) { - if (!isAudioPacket(packet.data)) { + if (!isAudioPacket(packet.getData())) { return -1; } return getFlacFrameBlockSize(packet); @@ -69,7 +69,7 @@ import java.util.Arrays; @Override protected boolean readHeaders(ParsableByteArray packet, long position, SetupData setupData) { - byte[] data = packet.data; + byte[] data = packet.getData(); @Nullable FlacStreamMetadata streamMetadata = this.streamMetadata; if (streamMetadata == null) { streamMetadata = new FlacStreamMetadata(data, 17); @@ -92,7 +92,7 @@ import java.util.Arrays; } private int getFlacFrameBlockSize(ParsableByteArray packet) { - int blockSizeKey = (packet.data[2] & 0xFF) >> 4; + int blockSizeKey = (packet.getData()[2] & 0xFF) >> 4; if (blockSizeKey == 6 || blockSizeKey == 7) { // Skip the sample number. packet.skipBytes(FRAME_HEADER_SAMPLE_NUMBER_OFFSET); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java index 9aaa3332ce..0dfcc4ef91 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ogg; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.Extractor; @@ -94,9 +96,9 @@ public class OggExtractor implements Extractor { return false; } - int length = Math.min(header.bodySize, MAX_VERIFICATION_BYTES); + int length = min(header.bodySize, MAX_VERIFICATION_BYTES); ParsableByteArray scratch = new ParsableByteArray(length); - input.peekFully(scratch.data, 0, length); + input.peekFully(scratch.getData(), 0, length); if (FlacReader.verifyBitstreamType(resetPosition(scratch))) { streamReader = new FlacReader(); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java index 2ee65f0112..450bff4a36 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPacket.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ogg; +import static java.lang.Math.max; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.util.Assertions; @@ -40,7 +42,7 @@ import java.util.Arrays; */ public void reset() { pageHeader.reset(); - packetArray.reset(); + packetArray.reset(/* limit= */ 0); currentSegmentIndex = C.INDEX_UNSET; populated = false; } @@ -61,13 +63,13 @@ import java.util.Arrays; if (populated) { populated = false; - packetArray.reset(); + packetArray.reset(/* limit= */ 0); } while (!populated) { if (currentSegmentIndex < 0) { // We're at the start of a page. - if (!pageHeader.populate(input, true)) { + if (!pageHeader.skipToNextPage(input) || !pageHeader.populate(input, /* quiet= */ true)) { return false; } int segmentIndex = 0; @@ -86,9 +88,9 @@ import java.util.Arrays; int segmentIndex = currentSegmentIndex + segmentCount; if (size > 0) { if (packetArray.capacity() < packetArray.limit() + size) { - packetArray.data = Arrays.copyOf(packetArray.data, packetArray.limit() + size); + packetArray.reset(Arrays.copyOf(packetArray.getData(), packetArray.limit() + size)); } - input.readFully(packetArray.data, packetArray.limit(), size); + input.readFully(packetArray.getData(), packetArray.limit(), size); packetArray.setLimit(packetArray.limit() + size); populated = pageHeader.laces[segmentIndex - 1] != 255; } @@ -124,11 +126,12 @@ import java.util.Arrays; * Trims the packet data array. */ public void trimPayload() { - if (packetArray.data.length == OggPageHeader.MAX_PAGE_PAYLOAD) { + if (packetArray.getData().length == OggPageHeader.MAX_PAGE_PAYLOAD) { return; } - packetArray.data = Arrays.copyOf(packetArray.data, Math.max(OggPageHeader.MAX_PAGE_PAYLOAD, - packetArray.limit())); + packetArray.reset( + Arrays.copyOf( + packetArray.getData(), max(OggPageHeader.MAX_PAGE_PAYLOAD, packetArray.limit()))); } /** diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java index d96aaa4568..3fa5f88020 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.extractor.ogg; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.EOFException; import java.io.IOException; @@ -33,7 +34,8 @@ import java.io.IOException; public static final int MAX_PAGE_SIZE = EMPTY_PAGE_HEADER_SIZE + MAX_SEGMENT_COUNT + MAX_PAGE_PAYLOAD; - private static final int TYPE_OGGS = 0x4f676753; + private static final int CAPTURE_PATTERN = 0x4f676753; // OggS + private static final int CAPTURE_PATTERN_SIZE = 4; public int revision; public int type; @@ -73,6 +75,51 @@ import java.io.IOException; bodySize = 0; } + /** + * Advances through {@code input} looking for the start of the next Ogg page. + * + *

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

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

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

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

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

      This is slightly different to the behaviour of {@link ExtractorInput#peekFully(byte[], int, + * int, boolean)}, where {@code allowEndOfInput=true} only returns false (and suppresses the + * exception) if the end of the input is reached before reading any data. + */ + private static boolean peekSafely( + ExtractorInput input, byte[] output, int offset, int length, boolean quiet) + throws IOException { + try { + return input.peekFully(output, offset, length, /* allowEndOfInput= */ quiet); + } catch (EOFException e) { + if (quiet) { + return false; + } else { + throw e; + } + } + } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java index 018fd949b3..8144af7b66 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/OpusReader.java @@ -15,13 +15,10 @@ */ package com.google.android.exoplayer2.extractor.ogg; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.audio.OpusUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -30,13 +27,6 @@ import java.util.List; */ /* package */ final class OpusReader extends StreamReader { - private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; - - /** - * Opus streams are always decoded at 48000 Hz. - */ - private static final int SAMPLE_RATE = 48000; - private static final int OPUS_CODE = 0x4f707573; private static final byte[] OPUS_SIGNATURE = {'O', 'p', 'u', 's', 'H', 'e', 'a', 'd'}; @@ -61,26 +51,20 @@ import java.util.List; @Override protected long preparePayload(ParsableByteArray packet) { - return convertTimeToGranule(getPacketDurationUs(packet.data)); + return convertTimeToGranule(getPacketDurationUs(packet.getData())); } @Override protected boolean readHeaders(ParsableByteArray packet, long position, SetupData setupData) { if (!headerRead) { - byte[] metadata = Arrays.copyOf(packet.data, packet.limit()); - int channelCount = metadata[9] & 0xFF; - int preskip = ((metadata[11] & 0xFF) << 8) | (metadata[10] & 0xFF); - - List initializationData = new ArrayList<>(3); - initializationData.add(metadata); - putNativeOrderLong(initializationData, preskip); - putNativeOrderLong(initializationData, DEFAULT_SEEK_PRE_ROLL_SAMPLES); - + byte[] headerBytes = Arrays.copyOf(packet.getData(), packet.limit()); + int channelCount = OpusUtil.getChannelCount(headerBytes); + List initializationData = OpusUtil.buildInitializationData(headerBytes); setupData.format = new Format.Builder() .setSampleMimeType(MimeTypes.AUDIO_OPUS) .setChannelCount(channelCount) - .setSampleRate(SAMPLE_RATE) + .setSampleRate(OpusUtil.SAMPLE_RATE) .setInitializationData(initializationData) .build(); headerRead = true; @@ -92,12 +76,6 @@ import java.util.List; return true; } - private void putNativeOrderLong(List initializationData, int samples) { - long ns = (samples * C.NANOS_PER_SECOND) / SAMPLE_RATE; - byte[] array = ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).putLong(ns).array(); - initializationData.add(array); - } - /** * Returns the duration of the given audio packet. * diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java index d6faa90927..7cc193e698 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java @@ -68,12 +68,12 @@ import java.util.ArrayList; @Override protected long preparePayload(ParsableByteArray packet) { // if this is not an audio packet... - if ((packet.data[0] & 0x01) == 1) { + if ((packet.getData()[0] & 0x01) == 1) { return -1; } // ... we need to decode the block size - int packetBlockSize = decodeBlockSize(packet.data[0], vorbisSetup); + int packetBlockSize = decodeBlockSize(packet.getData()[0], vorbisSetup); // a packet contains samples produced from overlapping the previous and current frame data // (https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-350001.3.2) int samplesInPacket = seenFirstAudioPacket ? (packetBlockSize + previousPacketBlockSize) / 4 @@ -134,7 +134,7 @@ import java.util.ArrayList; // the third packet contains the setup header byte[] setupHeaderData = new byte[scratch.limit()]; // raw data of vorbis setup header has to be passed to decoder as CSD buffer #2 - System.arraycopy(scratch.data, 0, setupHeaderData, 0, scratch.limit()); + System.arraycopy(scratch.getData(), 0, setupHeaderData, 0, scratch.limit()); // partially decode setup header to get the modes Mode[] modes = VorbisUtil.readVorbisModes(scratch, vorbisIdHeader.channels); // we need the ilog of modes all the time when extracting, so we compute it once @@ -164,10 +164,11 @@ import java.util.ArrayList; buffer.setLimit(buffer.limit() + 4); // The vorbis decoder expects the number of samples in the packet // to be appended to the audio data as an int32 - buffer.data[buffer.limit() - 4] = (byte) (packetSampleCount & 0xFF); - buffer.data[buffer.limit() - 3] = (byte) ((packetSampleCount >>> 8) & 0xFF); - buffer.data[buffer.limit() - 2] = (byte) ((packetSampleCount >>> 16) & 0xFF); - buffer.data[buffer.limit() - 1] = (byte) ((packetSampleCount >>> 24) & 0xFF); + byte[] data = buffer.getData(); + data[buffer.limit() - 4] = (byte) (packetSampleCount & 0xFF); + data[buffer.limit() - 3] = (byte) ((packetSampleCount >>> 8) & 0xFF); + data[buffer.limit() - 2] = (byte) ((packetSampleCount >>> 16) & 0xFF); + data[buffer.limit() - 1] = (byte) ((packetSampleCount >>> 24) & 0xFF); } private static int decodeBlockSize(byte firstByteOfAudioPacket, VorbisSetup vorbisSetup) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java index ae30231a50..44e67c955c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java @@ -66,14 +66,14 @@ public final class RawCcExtractor implements Extractor { public void init(ExtractorOutput output) { output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); trackOutput = output.track(0, C.TRACK_TYPE_TEXT); - output.endTracks(); trackOutput.format(format); + output.endTracks(); } @Override public boolean sniff(ExtractorInput input) throws IOException { - dataScratch.reset(); - input.peekFully(dataScratch.data, 0, HEADER_SIZE); + dataScratch.reset(/* limit= */ HEADER_SIZE); + input.peekFully(dataScratch.getData(), 0, HEADER_SIZE); return dataScratch.readInt() == HEADER_ID; } @@ -118,8 +118,8 @@ public final class RawCcExtractor implements Extractor { } private boolean parseHeader(ExtractorInput input) throws IOException { - dataScratch.reset(); - if (input.readFully(dataScratch.data, 0, HEADER_SIZE, true)) { + dataScratch.reset(/* limit= */ HEADER_SIZE); + if (input.readFully(dataScratch.getData(), 0, HEADER_SIZE, true)) { if (dataScratch.readInt() != HEADER_ID) { throw new IOException("Input not RawCC"); } @@ -132,15 +132,16 @@ public final class RawCcExtractor implements Extractor { } private boolean parseTimestampAndSampleCount(ExtractorInput input) throws IOException { - dataScratch.reset(); if (version == 0) { - if (!input.readFully(dataScratch.data, 0, TIMESTAMP_SIZE_V0 + 1, true)) { + dataScratch.reset(/* limit= */ TIMESTAMP_SIZE_V0 + 1); + if (!input.readFully(dataScratch.getData(), 0, TIMESTAMP_SIZE_V0 + 1, true)) { return false; } // version 0 timestamps are 45kHz, so we need to convert them into us timestampUs = dataScratch.readUnsignedInt() * 1000 / 45; } else if (version == 1) { - if (!input.readFully(dataScratch.data, 0, TIMESTAMP_SIZE_V1 + 1, true)) { + dataScratch.reset(/* limit= */ TIMESTAMP_SIZE_V1 + 1); + if (!input.readFully(dataScratch.getData(), 0, TIMESTAMP_SIZE_V1 + 1, true)) { return false; } timestampUs = dataScratch.readLong(); @@ -156,8 +157,8 @@ public final class RawCcExtractor implements Extractor { @RequiresNonNull("trackOutput") private void parseSamples(ExtractorInput input) throws IOException { for (; remainingSampleCount > 0; remainingSampleCount--) { - dataScratch.reset(); - input.readFully(dataScratch.data, 0, 3); + dataScratch.reset(/* limit= */ 3); + input.readFully(dataScratch.getData(), 0, 3); trackOutput.sampleData(dataScratch, 3); sampleBytesWritten += 3; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java index f0cb8ca1f7..75839e0917 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java @@ -66,7 +66,7 @@ public final class Ac3Extractor implements Extractor { ParsableByteArray scratch = new ParsableByteArray(ID3_HEADER_LENGTH); int startPosition = 0; while (true) { - input.peekFully(scratch.data, /* offset= */ 0, ID3_HEADER_LENGTH); + input.peekFully(scratch.getData(), /* offset= */ 0, ID3_HEADER_LENGTH); scratch.setPosition(0); if (scratch.readUnsignedInt24() != ID3_TAG) { break; @@ -82,7 +82,7 @@ public final class Ac3Extractor implements Extractor { int headerPosition = startPosition; int validFramesCount = 0; while (true) { - input.peekFully(scratch.data, 0, 6); + input.peekFully(scratch.getData(), 0, 6); scratch.setPosition(0); int syncBytes = scratch.readUnsignedShort(); if (syncBytes != AC3_SYNC_WORD) { @@ -96,7 +96,7 @@ public final class Ac3Extractor implements Extractor { if (++validFramesCount >= 4) { return true; } - int frameSize = Ac3Util.parseAc3SyncframeSize(scratch.data); + int frameSize = Ac3Util.parseAc3SyncframeSize(scratch.getData()); if (frameSize == C.LENGTH_UNSET) { return false; } @@ -125,7 +125,7 @@ public final class Ac3Extractor implements Extractor { @Override public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { - int bytesRead = input.read(sampleData.data, 0, MAX_SYNC_FRAME_SIZE); + int bytesRead = input.read(sampleData.getData(), 0, MAX_SYNC_FRAME_SIZE); if (bytesRead == C.RESULT_END_OF_INPUT) { return RESULT_END_OF_INPUT; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java index b025be95e3..bfb828415c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -117,13 +119,13 @@ public final class Ac3Reader implements ElementaryStreamReader { case STATE_FINDING_SYNC: if (skipToNextSync(data)) { state = STATE_READING_HEADER; - headerScratchBytes.data[0] = 0x0B; - headerScratchBytes.data[1] = 0x77; + headerScratchBytes.getData()[0] = 0x0B; + headerScratchBytes.getData()[1] = 0x77; bytesRead = 2; } break; case STATE_READING_HEADER: - if (continueRead(data, headerScratchBytes.data, HEADER_SIZE)) { + if (continueRead(data, headerScratchBytes.getData(), HEADER_SIZE)) { parseHeader(); headerScratchBytes.setPosition(0); output.sampleData(headerScratchBytes, HEADER_SIZE); @@ -131,7 +133,7 @@ public final class Ac3Reader implements ElementaryStreamReader { } break; case STATE_READING_SAMPLE: - int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + int bytesToRead = min(data.bytesLeft(), sampleSize - bytesRead); output.sampleData(data, bytesToRead); bytesRead += bytesToRead; if (bytesRead == sampleSize) { @@ -161,7 +163,7 @@ public final class Ac3Reader implements ElementaryStreamReader { * @return Whether the target length was reached. */ private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { - int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + int bytesToRead = min(source.bytesLeft(), targetLength - bytesRead); source.readBytes(target, bytesRead, bytesToRead); bytesRead += bytesToRead; return bytesRead == targetLength; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Extractor.java index c493d1d0bd..996ae2f69b 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Extractor.java @@ -73,7 +73,7 @@ public final class Ac4Extractor implements Extractor { ParsableByteArray scratch = new ParsableByteArray(ID3_HEADER_LENGTH); int startPosition = 0; while (true) { - input.peekFully(scratch.data, /* offset= */ 0, ID3_HEADER_LENGTH); + input.peekFully(scratch.getData(), /* offset= */ 0, ID3_HEADER_LENGTH); scratch.setPosition(0); if (scratch.readUnsignedInt24() != ID3_TAG) { break; @@ -89,7 +89,7 @@ public final class Ac4Extractor implements Extractor { int headerPosition = startPosition; int validFramesCount = 0; while (true) { - input.peekFully(scratch.data, /* offset= */ 0, /* length= */ FRAME_HEADER_SIZE); + input.peekFully(scratch.getData(), /* offset= */ 0, /* length= */ FRAME_HEADER_SIZE); scratch.setPosition(0); int syncBytes = scratch.readUnsignedShort(); if (syncBytes != AC40_SYNCWORD && syncBytes != AC41_SYNCWORD) { @@ -103,7 +103,7 @@ public final class Ac4Extractor implements Extractor { if (++validFramesCount >= 4) { return true; } - int frameSize = Ac4Util.parseAc4SyncframeSize(scratch.data, syncBytes); + int frameSize = Ac4Util.parseAc4SyncframeSize(scratch.getData(), syncBytes); if (frameSize == C.LENGTH_UNSET) { return false; } @@ -133,7 +133,8 @@ public final class Ac4Extractor implements Extractor { @Override public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { - int bytesRead = input.read(sampleData.data, /* offset= */ 0, /* length= */ READ_BUFFER_SIZE); + int bytesRead = + input.read(sampleData.getData(), /* offset= */ 0, /* length= */ READ_BUFFER_SIZE); if (bytesRead == C.RESULT_END_OF_INPUT) { return RESULT_END_OF_INPUT; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Reader.java index 517a233530..0f088836d1 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Ac4Reader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -116,13 +118,13 @@ public final class Ac4Reader implements ElementaryStreamReader { case STATE_FINDING_SYNC: if (skipToNextSync(data)) { state = STATE_READING_HEADER; - headerScratchBytes.data[0] = (byte) 0xAC; - headerScratchBytes.data[1] = (byte) (hasCRC ? 0x41 : 0x40); + headerScratchBytes.getData()[0] = (byte) 0xAC; + headerScratchBytes.getData()[1] = (byte) (hasCRC ? 0x41 : 0x40); bytesRead = 2; } break; case STATE_READING_HEADER: - if (continueRead(data, headerScratchBytes.data, Ac4Util.HEADER_SIZE_FOR_PARSER)) { + if (continueRead(data, headerScratchBytes.getData(), Ac4Util.HEADER_SIZE_FOR_PARSER)) { parseHeader(); headerScratchBytes.setPosition(0); output.sampleData(headerScratchBytes, Ac4Util.HEADER_SIZE_FOR_PARSER); @@ -130,7 +132,7 @@ public final class Ac4Reader implements ElementaryStreamReader { } break; case STATE_READING_SAMPLE: - int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + int bytesToRead = min(data.bytesLeft(), sampleSize - bytesRead); output.sampleData(data, bytesToRead); bytesRead += bytesToRead; if (bytesRead == sampleSize) { @@ -160,7 +162,7 @@ public final class Ac4Reader implements ElementaryStreamReader { * @return Whether the target length was reached. */ private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { - int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + int bytesToRead = min(source.bytesLeft(), targetLength - bytesRead); source.readBytes(target, bytesRead, bytesToRead); bytesRead += bytesToRead; return bytesRead == targetLength; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java index f870527284..54a6a20b36 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java @@ -114,7 +114,7 @@ public final class AdtsExtractor implements Extractor { firstFramePosition = C.POSITION_UNSET; // Allocate scratch space for an ID3 header. The same buffer is also used to read 4 byte values. scratch = new ParsableByteArray(ID3_HEADER_LENGTH); - scratchBits = new ParsableBitArray(scratch.data); + scratchBits = new ParsableBitArray(scratch.getData()); } // Extractor implementation. @@ -129,7 +129,7 @@ public final class AdtsExtractor implements Extractor { int totalValidFramesSize = 0; int validFramesCount = 0; while (true) { - input.peekFully(scratch.data, 0, 2); + input.peekFully(scratch.getData(), 0, 2); scratch.setPosition(0); int syncBytes = scratch.readUnsignedShort(); if (!AdtsReader.isAdtsSyncWord(syncBytes)) { @@ -146,7 +146,7 @@ public final class AdtsExtractor implements Extractor { } // Skip the frame. - input.peekFully(scratch.data, 0, 4); + input.peekFully(scratch.getData(), 0, 4); scratchBits.setPosition(14); int frameSize = scratchBits.readBits(13); // Either the stream is malformed OR we're not parsing an ADTS stream. @@ -189,7 +189,7 @@ public final class AdtsExtractor implements Extractor { calculateAverageFrameSize(input); } - int bytesRead = input.read(packetBuffer.data, 0, MAX_PACKET_SIZE); + int bytesRead = input.read(packetBuffer.getData(), 0, MAX_PACKET_SIZE); boolean readEndOfStream = bytesRead == RESULT_END_OF_INPUT; maybeOutputSeekMap(inputLength, canUseConstantBitrateSeeking, readEndOfStream); if (readEndOfStream) { @@ -214,7 +214,7 @@ public final class AdtsExtractor implements Extractor { private int peekId3Header(ExtractorInput input) throws IOException { int firstFramePosition = 0; while (true) { - input.peekFully(scratch.data, /* offset= */ 0, ID3_HEADER_LENGTH); + input.peekFully(scratch.getData(), /* offset= */ 0, ID3_HEADER_LENGTH); scratch.setPosition(0); if (scratch.readUnsignedInt24() != ID3_TAG) { break; @@ -270,7 +270,7 @@ public final class AdtsExtractor implements Extractor { long totalValidFramesSize = 0; try { while (input.peekFully( - scratch.data, /* offset= */ 0, /* length= */ 2, /* allowEndOfInput= */ true)) { + scratch.getData(), /* offset= */ 0, /* length= */ 2, /* allowEndOfInput= */ true)) { scratch.setPosition(0); int syncBytes = scratch.readUnsignedShort(); if (!AdtsReader.isAdtsSyncWord(syncBytes)) { @@ -281,7 +281,7 @@ public final class AdtsExtractor implements Extractor { } else { // Read the frame size. if (!input.peekFully( - scratch.data, /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true)) { + scratch.getData(), /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true)) { break; } scratchBits.setPosition(14); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java index 59ab6599b0..5a024b0a15 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/AdtsReader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -163,7 +165,7 @@ public final class AdtsReader implements ElementaryStreamReader { findNextSample(data); break; case STATE_READING_ID3_HEADER: - if (continueRead(data, id3HeaderBuffer.data, ID3_HEADER_SIZE)) { + if (continueRead(data, id3HeaderBuffer.getData(), ID3_HEADER_SIZE)) { parseId3Header(); } break; @@ -213,7 +215,7 @@ public final class AdtsReader implements ElementaryStreamReader { * @return Whether the target length was reached. */ private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { - int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + int bytesToRead = min(source.bytesLeft(), targetLength - bytesRead); source.readBytes(target, bytesRead, bytesToRead); bytesRead += bytesToRead; return bytesRead == targetLength; @@ -277,7 +279,7 @@ public final class AdtsReader implements ElementaryStreamReader { * @param pesBuffer The buffer whose position should be advanced. */ private void findNextSample(ParsableByteArray pesBuffer) { - byte[] adtsData = pesBuffer.data; + byte[] adtsData = pesBuffer.getData(); int position = pesBuffer.getPosition(); int endOffset = pesBuffer.limit(); while (position < endOffset) { @@ -335,7 +337,7 @@ public final class AdtsReader implements ElementaryStreamReader { return; } // Peek the next byte of buffer into scratch array. - adtsScratch.data[0] = buffer.data[buffer.getPosition()]; + adtsScratch.data[0] = buffer.getData()[buffer.getPosition()]; adtsScratch.setPosition(2); int currentFrameSampleRateIndex = adtsScratch.readBits(4); @@ -416,7 +418,7 @@ public final class AdtsReader implements ElementaryStreamReader { // The bytes following the frame must be either another SYNC word with the same MPEG version, or // the start of an ID3 header. - byte[] data = pesBuffer.data; + byte[] data = pesBuffer.getData(); int dataLimit = pesBuffer.limit(); int nextSyncPosition = syncPositionCandidate + frameSize; if (nextSyncPosition >= dataLimit) { @@ -531,7 +533,7 @@ public final class AdtsReader implements ElementaryStreamReader { /** Reads the rest of the sample */ @RequiresNonNull("currentOutput") private void readSample(ParsableByteArray data) { - int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + int bytesToRead = min(data.bytesLeft(), sampleSize - bytesRead); currentOutput.sampleData(data, bytesToRead); bytesRead += bytesToRead; if (bytesRead == sampleSize) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java index c48c790fbf..c74b70fdec 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java @@ -23,11 +23,11 @@ import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo; import com.google.android.exoplayer2.util.CodecSpecificDataUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.collect.ImmutableList; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; -import java.util.Collections; import java.util.List; /** @@ -112,10 +112,7 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact * readers. */ public DefaultTsPayloadReaderFactory(@Flags int flags) { - this( - flags, - Collections.singletonList( - new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build())); + this(flags, ImmutableList.of()); } /** @@ -165,6 +162,8 @@ public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Fact return new PesReader(new DtsReader(esInfo.language)); case TsExtractor.TS_STREAM_TYPE_H262: return new PesReader(new H262Reader(buildUserDataReader(esInfo))); + case TsExtractor.TS_STREAM_TYPE_H263: + return new PesReader(new H263Reader(buildUserDataReader(esInfo))); case TsExtractor.TS_STREAM_TYPE_H264: return isSet(FLAG_IGNORE_H264_STREAM) ? null : new PesReader(new H264Reader(buildSeiReader(esInfo), diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java index a201fb72d7..f4f9e62975 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/DtsReader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -99,7 +101,7 @@ public final class DtsReader implements ElementaryStreamReader { } break; case STATE_READING_HEADER: - if (continueRead(data, headerScratchBytes.data, HEADER_SIZE)) { + if (continueRead(data, headerScratchBytes.getData(), HEADER_SIZE)) { parseHeader(); headerScratchBytes.setPosition(0); output.sampleData(headerScratchBytes, HEADER_SIZE); @@ -107,7 +109,7 @@ public final class DtsReader implements ElementaryStreamReader { } break; case STATE_READING_SAMPLE: - int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + int bytesToRead = min(data.bytesLeft(), sampleSize - bytesRead); output.sampleData(data, bytesToRead); bytesRead += bytesToRead; if (bytesRead == sampleSize) { @@ -137,7 +139,7 @@ public final class DtsReader implements ElementaryStreamReader { * @return Whether the target length was reached. */ private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { - int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + int bytesToRead = min(source.bytesLeft(), targetLength - bytesRead); source.readBytes(target, bytesRead, bytesToRead); bytesRead += bytesToRead; return bytesRead == targetLength; @@ -155,10 +157,11 @@ public final class DtsReader implements ElementaryStreamReader { syncBytes <<= 8; syncBytes |= pesBuffer.readUnsignedByte(); if (DtsUtil.isSyncWord(syncBytes)) { - headerScratchBytes.data[0] = (byte) ((syncBytes >> 24) & 0xFF); - headerScratchBytes.data[1] = (byte) ((syncBytes >> 16) & 0xFF); - headerScratchBytes.data[2] = (byte) ((syncBytes >> 8) & 0xFF); - headerScratchBytes.data[3] = (byte) (syncBytes & 0xFF); + byte[] headerData = headerScratchBytes.getData(); + headerData[0] = (byte) ((syncBytes >> 24) & 0xFF); + headerData[1] = (byte) ((syncBytes >> 16) & 0xFF); + headerData[2] = (byte) ((syncBytes >> 8) & 0xFF); + headerData[3] = (byte) (syncBytes & 0xFF); bytesRead = 4; syncBytes = 0; return true; @@ -170,7 +173,7 @@ public final class DtsReader implements ElementaryStreamReader { /** Parses the sample header. */ @RequiresNonNull("output") private void parseHeader() { - byte[] frameData = headerScratchBytes.data; + byte[] frameData = headerScratchBytes.getData(); if (format == null) { format = DtsUtil.parseDtsFormat(frameData, formatId, language, null); output.format(format); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java index 012de81297..898084013f 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H262Reader.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; + import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -22,7 +25,6 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; -import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -118,10 +120,10 @@ public final class H262Reader implements ElementaryStreamReader { @Override public void consume(ParsableByteArray data) { - Assertions.checkStateNotNull(output); // Asserts that createTracks has been called. + checkStateNotNull(output); // Asserts that createTracks has been called. int offset = data.getPosition(); int limit = data.limit(); - byte[] dataArray = data.data; + byte[] dataArray = data.getData(); // Append the data to the buffer. totalBytesWritten += data.bytesLeft(); @@ -142,7 +144,7 @@ public final class H262Reader implements ElementaryStreamReader { } // We've found a start code with the following value. - int startCodeValue = data.data[startCodeOffset + 3] & 0xFF; + int startCodeValue = data.getData()[startCodeOffset + 3] & 0xFF; // This is the number of bytes from the current offset to the start of the next start // code. It may be negative if the start code started in the previously consumed data. int lengthToStartCode = startCodeOffset - offset; @@ -156,7 +158,7 @@ public final class H262Reader implements ElementaryStreamReader { int bytesAlreadyPassed = lengthToStartCode < 0 ? -lengthToStartCode : 0; if (csdBuffer.onStartCode(startCodeValue, bytesAlreadyPassed)) { // The csd data is complete, so we can decode and output the media format. - Pair result = parseCsdBuffer(csdBuffer, formatId); + Pair result = parseCsdBuffer(csdBuffer, checkNotNull(formatId)); output.format(result.first); frameDurationUs = result.second; hasOutputFormat = true; @@ -176,7 +178,7 @@ public final class H262Reader implements ElementaryStreamReader { Util.castNonNull(userDataReader).consume(sampleTimeUs, userDataParsable); } - if (startCodeValue == START_USER_DATA && data.data[startCodeOffset + 2] == 0x1) { + if (startCodeValue == START_USER_DATA && data.getData()[startCodeOffset + 2] == 0x1) { userData.startNalUnit(startCodeValue); } } @@ -215,11 +217,11 @@ public final class H262Reader implements ElementaryStreamReader { * Parses the {@link Format} and frame duration from a csd buffer. * * @param csdBuffer The csd buffer. - * @param formatId The id for the generated format. May be null. + * @param formatId The id for the generated format. * @return A pair consisting of the {@link Format} and the frame duration in microseconds, or 0 if * the duration could not be determined. */ - private static Pair parseCsdBuffer(CsdBuffer csdBuffer, @Nullable String formatId) { + private static Pair parseCsdBuffer(CsdBuffer csdBuffer, String formatId) { byte[] csdData = Arrays.copyOf(csdBuffer.data, csdBuffer.length); int firstByte = csdData[4] & 0xFF; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H263Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H263Reader.java new file mode 100644 index 0000000000..4db898553c --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H263Reader.java @@ -0,0 +1,478 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.NalUnitUtil; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.Collections; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Parses an ISO/IEC 14496-2 (MPEG-4 Part 2) or ITU-T Recommendation H.263 byte stream and extracts + * individual frames. + */ +public final class H263Reader implements ElementaryStreamReader { + + private static final String TAG = "H263Reader"; + + private static final int START_CODE_VALUE_VISUAL_OBJECT_SEQUENCE = 0xB0; + private static final int START_CODE_VALUE_USER_DATA = 0xB2; + private static final int START_CODE_VALUE_GROUP_OF_VOP = 0xB3; + private static final int START_CODE_VALUE_VISUAL_OBJECT = 0xB5; + private static final int START_CODE_VALUE_VOP = 0xB6; + private static final int START_CODE_VALUE_MAX_VIDEO_OBJECT = 0x1F; + private static final int START_CODE_VALUE_UNSET = -1; + + // See ISO 14496-2 (2001) table 6-12 for the mapping from aspect_ratio_info to pixel aspect ratio. + private static final float[] PIXEL_WIDTH_HEIGHT_RATIO_BY_ASPECT_RATIO_INFO = + new float[] {1f, 1f, 12 / 11f, 10 / 11f, 16 / 11f, 40 / 33f, 1f}; + private static final int VIDEO_OBJECT_LAYER_SHAPE_RECTANGULAR = 0; + + @Nullable private final UserDataReader userDataReader; + @Nullable private final ParsableByteArray userDataParsable; + + // State that should be reset on seek. + private final boolean[] prefixFlags; + private final CsdBuffer csdBuffer; + @Nullable private final NalUnitTargetBuffer userData; + private H263Reader.@MonotonicNonNull SampleReader sampleReader; + private long totalBytesWritten; + + // State initialized once when tracks are created. + private @MonotonicNonNull String formatId; + private @MonotonicNonNull TrackOutput output; + + // State that should not be reset on seek. + private boolean hasOutputFormat; + + // Per packet state that gets reset at the start of each packet. + private long pesTimeUs; + + /** Creates a new reader. */ + public H263Reader() { + this(null); + } + + /* package */ H263Reader(@Nullable UserDataReader userDataReader) { + this.userDataReader = userDataReader; + prefixFlags = new boolean[4]; + csdBuffer = new CsdBuffer(128); + if (userDataReader != null) { + userData = new NalUnitTargetBuffer(START_CODE_VALUE_USER_DATA, 128); + userDataParsable = new ParsableByteArray(); + } else { + userData = null; + userDataParsable = null; + } + } + + @Override + public void seek() { + NalUnitUtil.clearPrefixFlags(prefixFlags); + csdBuffer.reset(); + if (sampleReader != null) { + sampleReader.reset(); + } + if (userData != null) { + userData.reset(); + } + totalBytesWritten = 0; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + formatId = idGenerator.getFormatId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_VIDEO); + sampleReader = new SampleReader(output); + if (userDataReader != null) { + userDataReader.createTracks(extractorOutput, idGenerator); + } + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + // TODO (Internal b/32267012): Consider using random access indicator. + this.pesTimeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) { + // Assert that createTracks has been called. + checkStateNotNull(sampleReader); + checkStateNotNull(output); + int offset = data.getPosition(); + int limit = data.limit(); + byte[] dataArray = data.getData(); + + // Append the data to the buffer. + totalBytesWritten += data.bytesLeft(); + output.sampleData(data, data.bytesLeft()); + + while (true) { + int startCodeOffset = NalUnitUtil.findNalUnit(dataArray, offset, limit, prefixFlags); + + if (startCodeOffset == limit) { + // We've scanned to the end of the data without finding another start code. + if (!hasOutputFormat) { + csdBuffer.onData(dataArray, offset, limit); + } + sampleReader.onData(dataArray, offset, limit); + if (userData != null) { + userData.appendToNalUnit(dataArray, offset, limit); + } + return; + } + + // We've found a start code with the following value. + int startCodeValue = data.getData()[startCodeOffset + 3] & 0xFF; + // This is the number of bytes from the current offset to the start of the next start + // code. It may be negative if the start code started in the previously consumed data. + int lengthToStartCode = startCodeOffset - offset; + + if (!hasOutputFormat) { + if (lengthToStartCode > 0) { + csdBuffer.onData(dataArray, offset, /* limit= */ startCodeOffset); + } + // This is the number of bytes belonging to the next start code that have already been + // passed to csdBuffer. + int bytesAlreadyPassed = lengthToStartCode < 0 ? -lengthToStartCode : 0; + if (csdBuffer.onStartCode(startCodeValue, bytesAlreadyPassed)) { + // The csd data is complete, so we can decode and output the media format. + output.format( + parseCsdBuffer(csdBuffer, csdBuffer.volStartPosition, checkNotNull(formatId))); + hasOutputFormat = true; + } + } + + sampleReader.onData(dataArray, offset, /* limit= */ startCodeOffset); + + if (userData != null) { + int bytesAlreadyPassed = 0; + if (lengthToStartCode > 0) { + userData.appendToNalUnit(dataArray, offset, /* limit= */ startCodeOffset); + } else { + bytesAlreadyPassed = -lengthToStartCode; + } + + if (userData.endNalUnit(bytesAlreadyPassed)) { + int unescapedLength = NalUnitUtil.unescapeStream(userData.nalData, userData.nalLength); + castNonNull(userDataParsable).reset(userData.nalData, unescapedLength); + castNonNull(userDataReader).consume(pesTimeUs, userDataParsable); + } + + if (startCodeValue == START_CODE_VALUE_USER_DATA + && data.getData()[startCodeOffset + 2] == 0x1) { + userData.startNalUnit(startCodeValue); + } + } + + int bytesWrittenPastPosition = limit - startCodeOffset; + long absolutePosition = totalBytesWritten - bytesWrittenPastPosition; + sampleReader.onDataEnd(absolutePosition, bytesWrittenPastPosition, hasOutputFormat); + // Indicate the start of the next chunk. + sampleReader.onStartCode(startCodeValue, pesTimeUs); + // Continue scanning the data. + offset = startCodeOffset + 3; + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Parses a codec-specific data buffer, returning the {@link Format} of the media. + * + * @param csdBuffer The buffer to parse. + * @param volStartPosition The byte offset of the start of the video object layer in the buffer. + * @param formatId The ID for the generated format. + * @return The {@link Format} of the media represented in the buffer. + */ + private static Format parseCsdBuffer(CsdBuffer csdBuffer, int volStartPosition, String formatId) { + byte[] csdData = Arrays.copyOf(csdBuffer.data, csdBuffer.length); + ParsableBitArray buffer = new ParsableBitArray(csdData); + buffer.skipBytes(volStartPosition); + + // Parse the video object layer defined in ISO 14496-2 (2001) subsection 6.2.3. + buffer.skipBytes(4); // video_object_layer_start_code + buffer.skipBit(); // random_accessible_vol + buffer.skipBits(8); // video_object_type_indication + if (buffer.readBit()) { // is_object_layer_identifier + buffer.skipBits(4); // video_object_layer_verid + buffer.skipBits(3); // video_object_layer_priority + } + float pixelWidthHeightRatio; + int aspectRatioInfo = buffer.readBits(4); + if (aspectRatioInfo == 0x0F) { // extended_PAR + int parWidth = buffer.readBits(8); + int parHeight = buffer.readBits(8); + if (parHeight == 0) { + Log.w(TAG, "Invalid aspect ratio"); + pixelWidthHeightRatio = 1f; + } else { + pixelWidthHeightRatio = (float) parWidth / parHeight; + } + } else if (aspectRatioInfo < PIXEL_WIDTH_HEIGHT_RATIO_BY_ASPECT_RATIO_INFO.length) { + pixelWidthHeightRatio = PIXEL_WIDTH_HEIGHT_RATIO_BY_ASPECT_RATIO_INFO[aspectRatioInfo]; + } else { + Log.w(TAG, "Invalid aspect ratio"); + pixelWidthHeightRatio = 1f; + } + if (buffer.readBit()) { // vol_control_parameters + buffer.skipBits(2); // chroma_format + buffer.skipBits(1); // low_delay + if (buffer.readBit()) { // vbv_parameters + buffer.skipBits(15); // first_half_bit_rate + buffer.skipBit(); // marker_bit + buffer.skipBits(15); // latter_half_bit_rate + buffer.skipBit(); // marker_bit + buffer.skipBits(15); // first_half_vbv_buffer_size + buffer.skipBit(); // marker_bit + buffer.skipBits(3); // latter_half_vbv_buffer_size + buffer.skipBits(11); // first_half_vbv_occupancy + buffer.skipBit(); // marker_bit + buffer.skipBits(15); // latter_half_vbv_occupancy + buffer.skipBit(); // marker_bit + } + } + int videoObjectLayerShape = buffer.readBits(2); + if (videoObjectLayerShape != VIDEO_OBJECT_LAYER_SHAPE_RECTANGULAR) { + Log.w(TAG, "Unhandled video object layer shape"); + } + buffer.skipBit(); // marker_bit + int vopTimeIncrementResolution = buffer.readBits(16); + buffer.skipBit(); // marker_bit + if (buffer.readBit()) { // fixed_vop_rate + if (vopTimeIncrementResolution == 0) { + Log.w(TAG, "Invalid vop_increment_time_resolution"); + } else { + vopTimeIncrementResolution--; + int numBits = 0; + while (vopTimeIncrementResolution > 0) { + ++numBits; + vopTimeIncrementResolution >>= 1; + } + buffer.skipBits(numBits); // fixed_vop_time_increment + } + } + buffer.skipBit(); // marker_bit + int videoObjectLayerWidth = buffer.readBits(13); + buffer.skipBit(); // marker_bit + int videoObjectLayerHeight = buffer.readBits(13); + buffer.skipBit(); // marker_bit + buffer.skipBit(); // interlaced + return new Format.Builder() + .setId(formatId) + .setSampleMimeType(MimeTypes.VIDEO_MP4V) + .setWidth(videoObjectLayerWidth) + .setHeight(videoObjectLayerHeight) + .setPixelWidthHeightRatio(pixelWidthHeightRatio) + .setInitializationData(Collections.singletonList(csdData)) + .build(); + } + + private static final class CsdBuffer { + + private static final byte[] START_CODE = new byte[] {0, 0, 1}; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_SKIP_TO_VISUAL_OBJECT_SEQUENCE_START, + STATE_EXPECT_VISUAL_OBJECT_START, + STATE_EXPECT_VIDEO_OBJECT_START, + STATE_EXPECT_VIDEO_OBJECT_LAYER_START, + STATE_WAIT_FOR_VOP_START + }) + private @interface State {} + + private static final int STATE_SKIP_TO_VISUAL_OBJECT_SEQUENCE_START = 0; + private static final int STATE_EXPECT_VISUAL_OBJECT_START = 1; + private static final int STATE_EXPECT_VIDEO_OBJECT_START = 2; + private static final int STATE_EXPECT_VIDEO_OBJECT_LAYER_START = 3; + private static final int STATE_WAIT_FOR_VOP_START = 4; + + private boolean isFilling; + @State private int state; + + public int length; + public int volStartPosition; + public byte[] data; + + public CsdBuffer(int initialCapacity) { + data = new byte[initialCapacity]; + } + + public void reset() { + isFilling = false; + length = 0; + state = STATE_SKIP_TO_VISUAL_OBJECT_SEQUENCE_START; + } + + /** + * Called when a start code is encountered in the stream. + * + * @param startCodeValue The start code value. + * @param bytesAlreadyPassed The number of bytes of the start code that have been passed to + * {@link #onData(byte[], int, int)}, or 0. + * @return Whether the csd data is now complete. If true is returned, neither this method nor + * {@link #onData(byte[], int, int)} should be called again without an interleaving call to + * {@link #reset()}. + */ + public boolean onStartCode(int startCodeValue, int bytesAlreadyPassed) { + switch (state) { + case STATE_SKIP_TO_VISUAL_OBJECT_SEQUENCE_START: + if (startCodeValue == START_CODE_VALUE_VISUAL_OBJECT_SEQUENCE) { + state = STATE_EXPECT_VISUAL_OBJECT_START; + isFilling = true; + } + break; + case STATE_EXPECT_VISUAL_OBJECT_START: + if (startCodeValue != START_CODE_VALUE_VISUAL_OBJECT) { + Log.w(TAG, "Unexpected start code value"); + reset(); + } else { + state = STATE_EXPECT_VIDEO_OBJECT_START; + } + break; + case STATE_EXPECT_VIDEO_OBJECT_START: + if (startCodeValue > START_CODE_VALUE_MAX_VIDEO_OBJECT) { + Log.w(TAG, "Unexpected start code value"); + reset(); + } else { + state = STATE_EXPECT_VIDEO_OBJECT_LAYER_START; + } + break; + case STATE_EXPECT_VIDEO_OBJECT_LAYER_START: + if ((startCodeValue & 0xF0) != 0x20) { + Log.w(TAG, "Unexpected start code value"); + reset(); + } else { + volStartPosition = length; + state = STATE_WAIT_FOR_VOP_START; + } + break; + case STATE_WAIT_FOR_VOP_START: + if (startCodeValue == START_CODE_VALUE_GROUP_OF_VOP + || startCodeValue == START_CODE_VALUE_VISUAL_OBJECT) { + length -= bytesAlreadyPassed; + isFilling = false; + return true; + } + break; + default: + throw new IllegalStateException(); + } + onData(START_CODE, /* offset= */ 0, /* limit= */ START_CODE.length); + return false; + } + + public void onData(byte[] newData, int offset, int limit) { + if (!isFilling) { + return; + } + int readLength = limit - offset; + if (data.length < length + readLength) { + data = Arrays.copyOf(data, (length + readLength) * 2); + } + System.arraycopy(newData, offset, data, length, readLength); + length += readLength; + } + } + + private static final class SampleReader { + + /** Byte offset of vop_coding_type after the start code value. */ + private static final int OFFSET_VOP_CODING_TYPE = 1; + /** Value of vop_coding_type for intra video object planes. */ + private static final int VOP_CODING_TYPE_INTRA = 0; + + private final TrackOutput output; + + private boolean readingSample; + private boolean lookingForVopCodingType; + private boolean sampleIsKeyframe; + private int startCodeValue; + private int vopBytesRead; + private long samplePosition; + private long sampleTimeUs; + + public SampleReader(TrackOutput output) { + this.output = output; + } + + public void reset() { + readingSample = false; + lookingForVopCodingType = false; + sampleIsKeyframe = false; + startCodeValue = START_CODE_VALUE_UNSET; + } + + public void onStartCode(int startCodeValue, long pesTimeUs) { + this.startCodeValue = startCodeValue; + sampleIsKeyframe = false; + readingSample = + startCodeValue == START_CODE_VALUE_VOP || startCodeValue == START_CODE_VALUE_GROUP_OF_VOP; + lookingForVopCodingType = startCodeValue == START_CODE_VALUE_VOP; + vopBytesRead = 0; + sampleTimeUs = pesTimeUs; + } + + public void onData(byte[] data, int offset, int limit) { + if (lookingForVopCodingType) { + int headerOffset = offset + OFFSET_VOP_CODING_TYPE - vopBytesRead; + if (headerOffset < limit) { + sampleIsKeyframe = ((data[headerOffset] & 0xC0) >> 6) == VOP_CODING_TYPE_INTRA; + lookingForVopCodingType = false; + } else { + vopBytesRead += limit - offset; + } + } + } + + public void onDataEnd(long position, int bytesWrittenPastPosition, boolean hasOutputFormat) { + if (startCodeValue == START_CODE_VALUE_VOP && hasOutputFormat && readingSample) { + int size = (int) (position - samplePosition); + @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; + output.sampleMetadata( + sampleTimeUs, flags, size, bytesWrittenPastPosition, /* encryptionData= */ null); + } + // Start a new sample, unless this is a 'group of video object plane' in which case we + // include the data at the start of a 'video object plane' coming next. + if (startCodeValue != START_CODE_VALUE_GROUP_OF_VOP) { + samplePosition = position; + } + } + } +} diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java index 55f5fb34c6..d0bf2067c9 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H264Reader.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.extractor.ts; import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_RANDOM_ACCESS_INDICATOR; import android.util.SparseArray; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -36,7 +37,6 @@ import java.util.Arrays; import java.util.List; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** @@ -125,7 +125,7 @@ public final class H264Reader implements ElementaryStreamReader { int offset = data.getPosition(); int limit = data.limit(); - byte[] dataArray = data.data; + byte[] dataArray = data.getData(); // Append the data to the buffer. totalBytesWritten += data.bytesLeft(); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java index c356b1c987..ea23e1ef7a 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.ExtractorOutput; @@ -30,7 +33,6 @@ import com.google.android.exoplayer2.util.Util; import java.util.Collections; import org.checkerframework.checker.nullness.qual.EnsuresNonNull; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** @@ -125,7 +127,7 @@ public final class H265Reader implements ElementaryStreamReader { while (data.bytesLeft() > 0) { int offset = data.getPosition(); int limit = data.limit(); - byte[] dataArray = data.data; + byte[] dataArray = data.getData(); // Append the data to the buffer. totalBytesWritten += data.bytesLeft(); @@ -354,7 +356,7 @@ public final class H265Reader implements ElementaryStreamReader { // scaling_list_pred_matrix_id_delta[sizeId][matrixId] bitArray.readUnsignedExpGolombCodedInt(); } else { - int coefNum = Math.min(64, 1 << (4 + (sizeId << 1))); + int coefNum = min(64, 1 << (4 + (sizeId << 1))); if (sizeId > 1) { // scaling_list_dc_coef_minus8[sizeId - 2][matrixId] bitArray.readSignedExpGolombCodedInt(); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java index 28c54892c4..a50e36b51c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/Id3Reader.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.extractor.ts; import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; import static com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_HEADER_LENGTH; +import static java.lang.Math.min; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -88,8 +89,12 @@ public final class Id3Reader implements ElementaryStreamReader { int bytesAvailable = data.bytesLeft(); if (sampleBytesRead < ID3_HEADER_LENGTH) { // We're still reading the ID3 header. - int headerBytesAvailable = Math.min(bytesAvailable, ID3_HEADER_LENGTH - sampleBytesRead); - System.arraycopy(data.data, data.getPosition(), id3Header.data, sampleBytesRead, + int headerBytesAvailable = min(bytesAvailable, ID3_HEADER_LENGTH - sampleBytesRead); + System.arraycopy( + data.getData(), + data.getPosition(), + id3Header.getData(), + sampleBytesRead, headerBytesAvailable); if (sampleBytesRead + headerBytesAvailable == ID3_HEADER_LENGTH) { // We've finished reading the ID3 header. Extract the sample size. @@ -105,7 +110,7 @@ public final class Id3Reader implements ElementaryStreamReader { } } // Write data to the output. - int bytesToWrite = Math.min(bytesAvailable, sampleSize - sampleBytesRead); + int bytesToWrite = min(bytesAvailable, sampleSize - sampleBytesRead); output.sampleData(data, bytesToWrite); sampleBytesRead += bytesToWrite; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java index 3465d89318..da477e88e5 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/LatmReader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -79,7 +81,7 @@ public final class LatmReader implements ElementaryStreamReader { public LatmReader(@Nullable String language) { this.language = language; sampleDataBuffer = new ParsableByteArray(INITIAL_BUFFER_SIZE); - sampleBitArray = new ParsableBitArray(sampleDataBuffer.data); + sampleBitArray = new ParsableBitArray(sampleDataBuffer.getData()); } @Override @@ -122,14 +124,14 @@ public final class LatmReader implements ElementaryStreamReader { break; case STATE_READING_HEADER: sampleSize = ((secondHeaderByte & ~SYNC_BYTE_SECOND) << 8) | data.readUnsignedByte(); - if (sampleSize > sampleDataBuffer.data.length) { + if (sampleSize > sampleDataBuffer.getData().length) { resetBufferForSize(sampleSize); } bytesRead = 0; state = STATE_READING_SAMPLE; break; case STATE_READING_SAMPLE: - bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + bytesToRead = min(data.bytesLeft(), sampleSize - bytesRead); data.readBytes(sampleBitArray.data, bytesRead, bytesToRead); bytesRead += bytesToRead; if (bytesRead == sampleSize) { @@ -302,7 +304,7 @@ public final class LatmReader implements ElementaryStreamReader { } else { // Sample data is not byte-aligned and we need align it ourselves before outputting. // Byte alignment is needed because LATM framing is not supported by MediaCodec. - data.readBits(sampleDataBuffer.data, 0, muxLengthBytes * 8); + data.readBits(sampleDataBuffer.getData(), 0, muxLengthBytes * 8); sampleDataBuffer.setPosition(0); } output.sampleData(sampleDataBuffer, muxLengthBytes); @@ -312,7 +314,7 @@ public final class LatmReader implements ElementaryStreamReader { private void resetBufferForSize(int newSize) { sampleDataBuffer.reset(newSize); - sampleBitArray.reset(sampleDataBuffer.data); + sampleBitArray.reset(sampleDataBuffer.getData()); } private static long latmGetValue(ParsableBitArray data) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java index 44870c3025..c89d61df2c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.MpegAudioUtil; @@ -24,7 +27,6 @@ import com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerat import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** @@ -67,7 +69,7 @@ public final class MpegAudioReader implements ElementaryStreamReader { state = STATE_FINDING_HEADER; // The first byte of an MPEG Audio frame header is always 0xFF. headerScratch = new ParsableByteArray(4); - headerScratch.data[0] = (byte) 0xFF; + headerScratch.getData()[0] = (byte) 0xFF; header = new MpegAudioUtil.Header(); this.language = language; } @@ -129,7 +131,7 @@ public final class MpegAudioReader implements ElementaryStreamReader { * @param source The source from which to read. */ private void findHeader(ParsableByteArray source) { - byte[] data = source.data; + byte[] data = source.getData(); int startOffset = source.getPosition(); int endOffset = source.limit(); for (int i = startOffset; i < endOffset; i++) { @@ -140,7 +142,7 @@ public final class MpegAudioReader implements ElementaryStreamReader { source.setPosition(i + 1); // Reset lastByteWasFF for next time. lastByteWasFF = false; - headerScratch.data[1] = data[i]; + headerScratch.getData()[1] = data[i]; frameBytesRead = 2; state = STATE_READING_HEADER; return; @@ -167,8 +169,8 @@ public final class MpegAudioReader implements ElementaryStreamReader { */ @RequiresNonNull("output") private void readHeaderRemainder(ParsableByteArray source) { - int bytesToRead = Math.min(source.bytesLeft(), HEADER_SIZE - frameBytesRead); - source.readBytes(headerScratch.data, frameBytesRead, bytesToRead); + int bytesToRead = min(source.bytesLeft(), HEADER_SIZE - frameBytesRead); + source.readBytes(headerScratch.getData(), frameBytesRead, bytesToRead); frameBytesRead += bytesToRead; if (frameBytesRead < HEADER_SIZE) { // We haven't read the whole header yet. @@ -219,7 +221,7 @@ public final class MpegAudioReader implements ElementaryStreamReader { */ @RequiresNonNull("output") private void readFrameRemainder(ParsableByteArray source) { - int bytesToRead = Math.min(source.bytesLeft(), frameSize - frameBytesRead); + int bytesToRead = min(source.bytesLeft(), frameSize - frameBytesRead); output.sampleData(source, bytesToRead); frameBytesRead += bytesToRead; if (frameBytesRead < frameSize) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java index f84d323f96..0764087b59 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PesReader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; @@ -122,7 +124,7 @@ public final class PesReader implements TsPayloadReader { } break; case STATE_READING_HEADER_EXTENSION: - int readLength = Math.min(MAX_HEADER_EXTENSION_SIZE, extendedHeaderLength); + int readLength = min(MAX_HEADER_EXTENSION_SIZE, extendedHeaderLength); // Read as much of the extended header as we're interested in, and skip the rest. if (continueRead(data, pesScratch.data, readLength) && continueRead(data, /* target= */ null, extendedHeaderLength)) { @@ -170,7 +172,7 @@ public final class PesReader implements TsPayloadReader { */ private boolean continueRead( ParsableByteArray source, @Nullable byte[] target, int targetLength) { - int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + int bytesToRead = min(source.bytesLeft(), targetLength - bytesRead); if (bytesToRead <= 0) { return true; } else if (target == null) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java index 09cf9b3f00..3616a0c354 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.BinarySearchSeeker; import com.google.android.exoplayer2.extractor.ExtractorInput; @@ -35,7 +37,7 @@ import java.io.IOException; private static final long SEEK_TOLERANCE_US = 100_000; private static final int MINIMUM_SEARCH_RANGE_BYTES = 1000; - private static final int TIMESTAMP_SEARCH_BYTES = 20000; + private static final int TIMESTAMP_SEARCH_BYTES = 20_000; public PsBinarySearchSeeker( TimestampAdjuster scrTimestampAdjuster, long streamDurationUs, long inputLength) { @@ -72,10 +74,10 @@ import java.io.IOException; public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) throws IOException { long inputPosition = input.getPosition(); - int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); packetBuffer.reset(bytesToSearch); - input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); return searchForScrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); } @@ -92,7 +94,7 @@ import java.io.IOException; long lastScrTimeUsInRange = C.TIME_UNSET; while (packetBuffer.bytesLeft() >= 4) { - int nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition()); if (nextStartCode != PsExtractor.PACK_START_CODE) { packetBuffer.skipBytes(1); continue; @@ -162,7 +164,7 @@ import java.io.IOException; return; } - int nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition()); if (nextStartCode == PsExtractor.SYSTEM_HEADER_START_CODE) { packetBuffer.skipBytes(4); int systemHeaderLength = packetBuffer.readUnsignedShort(); @@ -178,7 +180,7 @@ import java.io.IOException; // If we couldn't find these codes within the buffer, return the buffer limit, or return // the first position which PES packets pattern does not match (some malformed packets). while (packetBuffer.bytesLeft() >= 4) { - nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition()); if (nextStartCode == PsExtractor.PACK_START_CODE || nextStartCode == PsExtractor.MPEG_PROGRAM_END_CODE) { break; @@ -195,7 +197,7 @@ import java.io.IOException; } int pesPacketLength = packetBuffer.readUnsignedShort(); packetBuffer.setPosition( - Math.min(packetBuffer.limit(), packetBuffer.getPosition() + pesPacketLength)); + min(packetBuffer.limit(), packetBuffer.getPosition() + pesPacketLength)); } } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java index 4748b832de..55218c31f2 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; @@ -39,7 +41,7 @@ import java.io.IOException; */ /* package */ final class PsDurationReader { - private static final int TIMESTAMP_SEARCH_BYTES = 20000; + private static final int TIMESTAMP_SEARCH_BYTES = 20_000; private final TimestampAdjuster scrTimestampAdjuster; private final ParsableByteArray packetBuffer; @@ -136,7 +138,7 @@ import java.io.IOException; private int readFirstScrValue(ExtractorInput input, PositionHolder seekPositionHolder) throws IOException { - int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength()); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength()); int searchStartPosition = 0; if (input.getPosition() != searchStartPosition) { seekPositionHolder.position = searchStartPosition; @@ -145,7 +147,7 @@ import java.io.IOException; packetBuffer.reset(bytesToSearch); input.resetPeekPosition(); - input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); firstScrValue = readFirstScrValueFromBuffer(packetBuffer); isFirstScrValueRead = true; @@ -158,7 +160,7 @@ import java.io.IOException; for (int searchPosition = searchStartPosition; searchPosition < searchEndPosition - 3; searchPosition++) { - int nextStartCode = peekIntAtPosition(packetBuffer.data, searchPosition); + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), searchPosition); if (nextStartCode == PsExtractor.PACK_START_CODE) { packetBuffer.setPosition(searchPosition + 4); long scrValue = readScrValueFromPack(packetBuffer); @@ -173,7 +175,7 @@ import java.io.IOException; private int readLastScrValue(ExtractorInput input, PositionHolder seekPositionHolder) throws IOException { long inputLength = input.getLength(); - int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, inputLength); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, inputLength); long searchStartPosition = inputLength - bytesToSearch; if (input.getPosition() != searchStartPosition) { seekPositionHolder.position = searchStartPosition; @@ -182,7 +184,7 @@ import java.io.IOException; packetBuffer.reset(bytesToSearch); input.resetPeekPosition(); - input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); lastScrValue = readLastScrValueFromBuffer(packetBuffer); isLastScrValueRead = true; @@ -195,7 +197,7 @@ import java.io.IOException; for (int searchPosition = searchEndPosition - 4; searchPosition >= searchStartPosition; searchPosition--) { - int nextStartCode = peekIntAtPosition(packetBuffer.data, searchPosition); + int nextStartCode = peekIntAtPosition(packetBuffer.getData(), searchPosition); if (nextStartCode == PsExtractor.PACK_START_CODE) { packetBuffer.setPosition(searchPosition + 4); long scrValue = readScrValueFromPack(packetBuffer); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java index 96bdc22631..4ead98febb 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java @@ -182,7 +182,7 @@ public final class PsExtractor implements Extractor { return RESULT_END_OF_INPUT; } // First peek and check what type of start code is next. - if (!input.peekFully(psPacketBuffer.data, 0, 4, true)) { + if (!input.peekFully(psPacketBuffer.getData(), 0, 4, true)) { return RESULT_END_OF_INPUT; } @@ -192,7 +192,7 @@ public final class PsExtractor implements Extractor { return RESULT_END_OF_INPUT; } else if (nextStartCode == PACK_START_CODE) { // Now peek the rest of the pack_header. - input.peekFully(psPacketBuffer.data, 0, 10); + input.peekFully(psPacketBuffer.getData(), 0, 10); // We only care about the pack_stuffing_length in here, skip the first 77 bits. psPacketBuffer.setPosition(9); @@ -205,7 +205,7 @@ public final class PsExtractor implements Extractor { return RESULT_CONTINUE; } else if (nextStartCode == SYSTEM_HEADER_START_CODE) { // We just skip all this, but we need to get the length first. - input.peekFully(psPacketBuffer.data, 0, 2); + input.peekFully(psPacketBuffer.getData(), 0, 2); // Length is the next 2 bytes. psPacketBuffer.setPosition(0); @@ -260,7 +260,7 @@ public final class PsExtractor implements Extractor { } // The next 2 bytes are the length. Once we have that we can consume the complete packet. - input.peekFully(psPacketBuffer.data, 0, 2); + input.peekFully(psPacketBuffer.getData(), 0, 2); psPacketBuffer.setPosition(0); int payloadLength = psPacketBuffer.readUnsignedShort(); int pesLength = payloadLength + 6; @@ -271,7 +271,7 @@ public final class PsExtractor implements Extractor { } else { psPacketBuffer.reset(pesLength); // Read the whole packet and the header for consumption. - input.readFully(psPacketBuffer.data, 0, pesLength); + input.readFully(psPacketBuffer.getData(), 0, pesLength); psPacketBuffer.setPosition(6); payloadReader.consume(psPacketBuffer); psPacketBuffer.setLimit(psPacketBuffer.capacity()); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java index bc590c9d4c..8d935ad5f3 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SectionReader.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.max; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.ExtractorOutput; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -87,8 +90,8 @@ public final class SectionReader implements TsPayloadReader { return; } } - int headerBytesToRead = Math.min(data.bytesLeft(), SECTION_HEADER_LENGTH - bytesRead); - data.readBytes(sectionData.data, bytesRead, headerBytesToRead); + int headerBytesToRead = min(data.bytesLeft(), SECTION_HEADER_LENGTH - bytesRead); + data.readBytes(sectionData.getData(), bytesRead, headerBytesToRead); bytesRead += headerBytesToRead; if (bytesRead == SECTION_HEADER_LENGTH) { sectionData.reset(SECTION_HEADER_LENGTH); @@ -100,21 +103,20 @@ public final class SectionReader implements TsPayloadReader { (((secondHeaderByte & 0x0F) << 8) | thirdHeaderByte) + SECTION_HEADER_LENGTH; if (sectionData.capacity() < totalSectionLength) { // Ensure there is enough space to keep the whole section. - byte[] bytes = sectionData.data; - sectionData.reset( - Math.min(MAX_SECTION_LENGTH, Math.max(totalSectionLength, bytes.length * 2))); - System.arraycopy(bytes, 0, sectionData.data, 0, SECTION_HEADER_LENGTH); + byte[] bytes = sectionData.getData(); + sectionData.reset(min(MAX_SECTION_LENGTH, max(totalSectionLength, bytes.length * 2))); + System.arraycopy(bytes, 0, sectionData.getData(), 0, SECTION_HEADER_LENGTH); } } } else { // Reading the body. - int bodyBytesToRead = Math.min(data.bytesLeft(), totalSectionLength - bytesRead); - data.readBytes(sectionData.data, bytesRead, bodyBytesToRead); + int bodyBytesToRead = min(data.bytesLeft(), totalSectionLength - bytesRead); + data.readBytes(sectionData.getData(), bytesRead, bodyBytesToRead); bytesRead += bodyBytesToRead; if (bytesRead == totalSectionLength) { if (sectionSyntaxIndicator) { // This section has common syntax as defined in ISO/IEC 13818-1, section 2.4.4.11. - if (Util.crc32(sectionData.data, 0, totalSectionLength, 0xFFFFFFFF) != 0) { + if (Util.crc32(sectionData.getData(), 0, totalSectionLength, 0xFFFFFFFF) != 0) { // The CRC is invalid so discard the section. waitingForPayloadStart = true; return; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java index 6d8cb0da8c..9fff73315c 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/SeiReader.java @@ -27,7 +27,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import java.util.List; -/** Consumes SEI buffers, outputting contained CEA-608 messages to a {@link TrackOutput}. */ +/** Consumes SEI buffers, outputting contained CEA-608/708 messages to a {@link TrackOutput}. */ public final class SeiReader { private final List closedCaptionFormats; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java index 8a1d2b2fdf..8286189780 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.BinarySearchSeeker; import com.google.android.exoplayer2.extractor.ExtractorInput; @@ -76,10 +78,10 @@ import java.io.IOException; public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) throws IOException { long inputPosition = input.getPosition(); - int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); packetBuffer.reset(bytesToSearch); - input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); return searchForPcrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); } @@ -94,7 +96,7 @@ import java.io.IOException; while (packetBuffer.bytesLeft() >= TsExtractor.TS_PACKET_SIZE) { int startOfPacket = - TsUtil.findSyncBytePosition(packetBuffer.data, packetBuffer.getPosition(), limit); + TsUtil.findSyncBytePosition(packetBuffer.getData(), packetBuffer.getPosition(), limit); int endOfPacket = startOfPacket + TsExtractor.TS_PACKET_SIZE; if (endOfPacket > limit) { break; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java index a60d3fcb82..5020f4c76d 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static java.lang.Math.min; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; @@ -123,7 +125,7 @@ import java.io.IOException; private int readFirstPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) throws IOException { - int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength()); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength()); int searchStartPosition = 0; if (input.getPosition() != searchStartPosition) { seekPositionHolder.position = searchStartPosition; @@ -132,7 +134,7 @@ import java.io.IOException; packetBuffer.reset(bytesToSearch); input.resetPeekPosition(); - input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); firstPcrValue = readFirstPcrValueFromBuffer(packetBuffer, pcrPid); isFirstPcrValueRead = true; @@ -145,7 +147,7 @@ import java.io.IOException; for (int searchPosition = searchStartPosition; searchPosition < searchEndPosition; searchPosition++) { - if (packetBuffer.data[searchPosition] != TsExtractor.TS_SYNC_BYTE) { + if (packetBuffer.getData()[searchPosition] != TsExtractor.TS_SYNC_BYTE) { continue; } long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); @@ -159,7 +161,7 @@ import java.io.IOException; private int readLastPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) throws IOException { long inputLength = input.getLength(); - int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, inputLength); + int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, inputLength); long searchStartPosition = inputLength - bytesToSearch; if (input.getPosition() != searchStartPosition) { seekPositionHolder.position = searchStartPosition; @@ -168,7 +170,7 @@ import java.io.IOException; packetBuffer.reset(bytesToSearch); input.resetPeekPosition(); - input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch); lastPcrValue = readLastPcrValueFromBuffer(packetBuffer, pcrPid); isLastPcrValueRead = true; @@ -181,7 +183,7 @@ import java.io.IOException; for (int searchPosition = searchEndPosition - 1; searchPosition >= searchStartPosition; searchPosition--) { - if (packetBuffer.data[searchPosition] != TsExtractor.TS_SYNC_BYTE) { + if (packetBuffer.getData()[searchPosition] != TsExtractor.TS_SYNC_BYTE) { continue; } long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index 5e85a80a5d..2fcfd422a0 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -90,6 +90,7 @@ public final class TsExtractor implements Extractor { public static final int TS_STREAM_TYPE_E_AC3 = 0x87; public static final int TS_STREAM_TYPE_AC4 = 0xAC; // DVB/ATSC AC-4 Descriptor public static final int TS_STREAM_TYPE_H262 = 0x02; + public static final int TS_STREAM_TYPE_H263 = 0x10; // MPEG-4 Part 2 and H.263 public static final int TS_STREAM_TYPE_H264 = 0x1B; public static final int TS_STREAM_TYPE_H265 = 0x24; public static final int TS_STREAM_TYPE_ID3 = 0x15; @@ -191,7 +192,7 @@ public final class TsExtractor implements Extractor { @Override public boolean sniff(ExtractorInput input) throws IOException { - byte[] buffer = tsPacketBuffer.data; + byte[] buffer = tsPacketBuffer.getData(); input.peekFully(buffer, 0, TS_PACKET_SIZE * SNIFF_TS_PACKET_COUNT); for (int startPosCandidate = 0; startPosCandidate < TS_PACKET_SIZE; startPosCandidate++) { // Try to identify at least SNIFF_TS_PACKET_COUNT packets starting with TS_SYNC_BYTE. @@ -238,7 +239,7 @@ public final class TsExtractor implements Extractor { if (timeUs != 0 && tsBinarySearchSeeker != null) { tsBinarySearchSeeker.setSeekTargetUs(timeUs); } - tsPacketBuffer.reset(); + tsPacketBuffer.reset(/* limit= */ 0); continuityCounters.clear(); for (int i = 0; i < tsPayloadReaders.size(); i++) { tsPayloadReaders.valueAt(i).seek(); @@ -373,7 +374,7 @@ public final class TsExtractor implements Extractor { } private boolean fillBufferWithAtLeastOnePacket(ExtractorInput input) throws IOException { - byte[] data = tsPacketBuffer.data; + byte[] data = tsPacketBuffer.getData(); // Shift bytes to the start of the buffer if there isn't enough space left at the end. if (BUFFER_SIZE - tsPacketBuffer.getPosition() < TS_PACKET_SIZE) { int bytesLeft = tsPacketBuffer.bytesLeft(); @@ -403,7 +404,8 @@ public final class TsExtractor implements Extractor { private int findEndOfFirstTsPacketInBuffer() throws ParserException { int searchStart = tsPacketBuffer.getPosition(); int limit = tsPacketBuffer.limit(); - int syncBytePosition = TsUtil.findSyncBytePosition(tsPacketBuffer.data, searchStart, limit); + int syncBytePosition = + TsUtil.findSyncBytePosition(tsPacketBuffer.getData(), searchStart, limit); // Discard all bytes before the sync byte. // If sync byte is not found, this means discard the whole buffer. tsPacketBuffer.setPosition(syncBytePosition); @@ -463,10 +465,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++) { @@ -477,8 +484,10 @@ public final class TsExtractor implements Extractor { patScratch.skipBits(13); // network_PID (13) } else { int pid = patScratch.readBits(13); - tsPayloadReaders.put(pid, new SectionReader(new PmtReader(pid))); - remainingPmts++; + if (tsPayloadReaders.get(pid) == null) { + tsPayloadReaders.put(pid, new SectionReader(new PmtReader(pid))); + remainingPmts++; + } } } if (mode != MODE_HLS) { @@ -539,8 +548,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: @@ -564,8 +579,8 @@ public final class TsExtractor implements Extractor { if (mode == MODE_HLS && id3Reader == null) { // Setup an ID3 track regardless of whether there's a corresponding entry, in case one // appears intermittently during playback. See [Internal: b/20261500]. - EsInfo dummyEsInfo = new EsInfo(TS_STREAM_TYPE_ID3, null, null, Util.EMPTY_BYTE_ARRAY); - id3Reader = payloadReaderFactory.createPayloadReader(TS_STREAM_TYPE_ID3, dummyEsInfo); + EsInfo id3EsInfo = new EsInfo(TS_STREAM_TYPE_ID3, null, null, Util.EMPTY_BYTE_ARRAY); + id3Reader = payloadReaderFactory.createPayloadReader(TS_STREAM_TYPE_ID3, id3EsInfo); id3Reader.init(timestampAdjuster, output, new TrackIdGenerator(programNumber, TS_STREAM_TYPE_ID3, MAX_PID_PLUS_ONE)); } @@ -653,6 +668,10 @@ public final class TsExtractor implements Extractor { int descriptorTag = data.readUnsignedByte(); int descriptorLength = data.readUnsignedByte(); int positionOfNextDescriptor = data.getPosition() + descriptorLength; + if (positionOfNextDescriptor > descriptorsEndPosition) { + // Descriptor claims to extend past the end position. Skip it. + break; + } if (descriptorTag == TS_PMT_DESC_REGISTRATION) { // registration_descriptor long formatIdentifier = data.readUnsignedInt(); if (formatIdentifier == AC3_FORMAT_IDENTIFIER) { @@ -698,8 +717,11 @@ public final class TsExtractor implements Extractor { data.skipBytes(positionOfNextDescriptor - data.getPosition()); } data.setPosition(descriptorsEndPosition); - return new EsInfo(streamType, language, dvbSubtitleInfos, - Arrays.copyOfRange(data.data, descriptorsStartPosition, descriptorsEndPosition)); + return new EsInfo( + streamType, + language, + dvbSubtitleInfos, + Arrays.copyOfRange(data.getData(), descriptorsStartPosition, descriptorsEndPosition)); } } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java index 1d7b6b9c6e..acb06063b5 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavExtractor.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.extractor.wav; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.util.Pair; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -222,7 +225,7 @@ public final class WavExtractor implements Extractor { int constantBitrate = header.frameRateHz * bytesPerFrame * 8; targetSampleSizeBytes = - Math.max(bytesPerFrame, header.frameRateHz * bytesPerFrame / TARGET_SAMPLES_PER_SECOND); + max(bytesPerFrame, header.frameRateHz * bytesPerFrame / TARGET_SAMPLES_PER_SECOND); format = new Format.Builder() .setSampleMimeType(mimeType) @@ -253,7 +256,7 @@ public final class WavExtractor implements Extractor { public boolean sampleData(ExtractorInput input, long bytesLeft) throws IOException { // Write sample data until we've reached the target sample size, or the end of the data. while (bytesLeft > 0 && pendingOutputBytes < targetSampleSizeBytes) { - int bytesToRead = (int) Math.min(targetSampleSizeBytes - pendingOutputBytes, bytesLeft); + int bytesToRead = (int) min(targetSampleSizeBytes - pendingOutputBytes, bytesLeft); int bytesAppended = trackOutput.sampleData(input, bytesToRead, true); if (bytesAppended == RESULT_END_OF_INPUT) { bytesLeft = 0; @@ -337,7 +340,7 @@ public final class WavExtractor implements Extractor { this.extractorOutput = extractorOutput; this.trackOutput = trackOutput; this.header = header; - targetSampleSizeFrames = Math.max(1, header.frameRateHz / TARGET_SAMPLES_PER_SECOND); + targetSampleSizeFrames = max(1, header.frameRateHz / TARGET_SAMPLES_PER_SECOND); ParsableByteArray scratch = new ParsableByteArray(header.extraData); scratch.readLittleEndianUnsignedShort(); @@ -405,7 +408,7 @@ public final class WavExtractor implements Extractor { // Read input data until we've reached the target number of blocks, or the end of the data. boolean endOfSampleData = bytesLeft == 0; while (!endOfSampleData && pendingInputBytes < targetReadBytes) { - int bytesToRead = (int) Math.min(targetReadBytes - pendingInputBytes, bytesLeft); + int bytesToRead = (int) min(targetReadBytes - pendingInputBytes, bytesLeft); int bytesAppended = input.read(inputData, pendingInputBytes, bytesToRead); if (bytesAppended == RESULT_END_OF_INPUT) { endOfSampleData = true; @@ -465,7 +468,7 @@ public final class WavExtractor implements Extractor { private void decode(byte[] input, int blockCount, ParsableByteArray output) { for (int blockIndex = 0; blockIndex < blockCount; blockIndex++) { for (int channelIndex = 0; channelIndex < header.numChannels; channelIndex++) { - decodeBlockForChannel(input, blockIndex, channelIndex, output.data); + decodeBlockForChannel(input, blockIndex, channelIndex, output.getData()); } } int decodedDataSize = numOutputFramesToBytes(framesPerBlock * blockCount); @@ -493,7 +496,7 @@ public final class WavExtractor implements Extractor { // treated as -2^15 rather than 2^15. int predictedSample = (short) (((input[headerStartIndex + 1] & 0xFF) << 8) | (input[headerStartIndex] & 0xFF)); - int stepIndex = Math.min(input[headerStartIndex + 2] & 0xFF, 88); + int stepIndex = min(input[headerStartIndex + 2] & 0xFF, 88); int step = STEP_TABLE[stepIndex]; // Output the initial 16 bit PCM sample from the header. diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavHeaderReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavHeaderReader.java index bcc229f3e9..4387993f50 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavHeaderReader.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/wav/WavHeaderReader.java @@ -54,7 +54,7 @@ import java.io.IOException; return null; } - input.peekFully(scratch.data, 0, 4); + input.peekFully(scratch.getData(), 0, 4); scratch.setPosition(0); int riffFormat = scratch.readInt(); if (riffFormat != WavUtil.WAVE_FOURCC) { @@ -70,7 +70,7 @@ import java.io.IOException; } Assertions.checkState(chunkHeader.size >= 16); - input.peekFully(scratch.data, 0, 16); + input.peekFully(scratch.getData(), 0, 16); scratch.setPosition(0); int audioFormatType = scratch.readLittleEndianUnsignedShort(); int numChannels = scratch.readLittleEndianUnsignedShort(); @@ -175,7 +175,7 @@ import java.io.IOException; */ public static ChunkHeader peek(ExtractorInput input, ParsableByteArray scratch) throws IOException { - input.peekFully(scratch.data, /* offset= */ 0, /* length= */ SIZE_IN_BYTES); + input.peekFully(scratch.getData(), /* offset= */ 0, /* length= */ SIZE_IN_BYTES); scratch.setPosition(0); int id = scratch.readInt(); diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java index b24c76d262..ba10f56a51 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.extractor; import static com.google.common.truth.Truth.assertThat; +import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.extractor.amr.AmrExtractor; import com.google.android.exoplayer2.extractor.flac.FlacExtractor; @@ -32,8 +33,12 @@ import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; import com.google.android.exoplayer2.extractor.ts.PsExtractor; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.extractor.wav.WavExtractor; +import com.google.android.exoplayer2.util.MimeTypes; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; @@ -42,34 +47,77 @@ import org.junit.runner.RunWith; public final class DefaultExtractorsFactoryTest { @Test - public void createExtractors_returnExpectedClasses() { + public void createExtractors_withoutMediaInfo_optimizesSniffingOrder() { DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); Extractor[] extractors = defaultExtractorsFactory.createExtractors(); - List> listCreatedExtractorClasses = new ArrayList<>(); + + List> extractorClasses = getExtractorClasses(extractors); + assertThat(extractorClasses.subList(0, 3)) + .containsExactly(FlvExtractor.class, FlacExtractor.class, WavExtractor.class) + .inOrder(); + assertThat(extractorClasses.subList(3, 5)) + .containsExactly(Mp4Extractor.class, FragmentedMp4Extractor.class); + assertThat(extractorClasses.subList(5, extractors.length)) + .containsExactly( + AmrExtractor.class, + PsExtractor.class, + OggExtractor.class, + TsExtractor.class, + MatroskaExtractor.class, + AdtsExtractor.class, + Ac3Extractor.class, + Ac4Extractor.class, + Mp3Extractor.class) + .inOrder(); + } + + @Test + public void createExtractors_withMediaInfo_startsWithExtractorsMatchingHeadersAndThenUri() { + DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); + Uri uri = Uri.parse("test.mp3"); + Map> responseHeaders = new HashMap<>(); + responseHeaders.put("Content-Type", Collections.singletonList(MimeTypes.VIDEO_MP4)); + + Extractor[] extractors = defaultExtractorsFactory.createExtractors(uri, responseHeaders); + + List> extractorClasses = getExtractorClasses(extractors); + assertThat(extractorClasses.subList(0, 2)) + .containsExactly(Mp4Extractor.class, FragmentedMp4Extractor.class); + assertThat(extractorClasses.get(2)).isEqualTo(Mp3Extractor.class); + } + + @Test + public void createExtractors_withMediaInfo_optimizesSniffingOrder() { + DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); + Uri uri = Uri.parse("test.mp3"); + Map> responseHeaders = new HashMap<>(); + responseHeaders.put("Content-Type", Collections.singletonList(MimeTypes.VIDEO_MP4)); + + Extractor[] extractors = defaultExtractorsFactory.createExtractors(uri, responseHeaders); + + List> extractorClasses = getExtractorClasses(extractors); + assertThat(extractorClasses.subList(3, extractors.length)) + .containsExactly( + FlvExtractor.class, + FlacExtractor.class, + WavExtractor.class, + AmrExtractor.class, + PsExtractor.class, + OggExtractor.class, + TsExtractor.class, + MatroskaExtractor.class, + AdtsExtractor.class, + Ac3Extractor.class, + Ac4Extractor.class) + .inOrder(); + } + + private static List> getExtractorClasses(Extractor[] extractors) { + List> extractorClasses = new ArrayList<>(); for (Extractor extractor : extractors) { - listCreatedExtractorClasses.add(extractor.getClass()); + extractorClasses.add(extractor.getClass()); } - - Class[] expectedExtractorClassses = - new Class[] { - MatroskaExtractor.class, - FragmentedMp4Extractor.class, - Mp4Extractor.class, - Mp3Extractor.class, - AdtsExtractor.class, - Ac3Extractor.class, - TsExtractor.class, - FlvExtractor.class, - OggExtractor.class, - PsExtractor.class, - WavExtractor.class, - AmrExtractor.class, - Ac4Extractor.class, - FlacExtractor.class - }; - - assertThat(listCreatedExtractorClasses).containsNoDuplicates(); - assertThat(listCreatedExtractorClasses).containsExactlyElementsIn(expectedExtractorClassses); + return extractorClasses; } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ExtractorTest.java index c95804c297..caf184a119 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ExtractorTest.java @@ -28,9 +28,9 @@ public final class ExtractorTest { @Test public void constants() { - // Sanity check that constant values match those defined by {@link C}. + // Check that constant values match those defined by {@link C}. assertThat(Extractor.RESULT_END_OF_INPUT).isEqualTo(C.RESULT_END_OF_INPUT); - // Sanity check that the other constant values don't overlap. + // Check that the other constant values don't overlap. assertThat(C.RESULT_END_OF_INPUT != Extractor.RESULT_CONTINUE).isTrue(); assertThat(C.RESULT_END_OF_INPUT != Extractor.RESULT_SEEK).isTrue(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacFrameReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacFrameReaderTest.java index 9150493ea3..75ef1a201e 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacFrameReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacFrameReaderTest.java @@ -44,10 +44,10 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); int frameStartMarker = FlacMetadataReader.getFrameStartMarker(input); ParsableByteArray scratch = new ParsableByteArray(FlacConstants.MAX_FRAME_HEADER_SIZE); - input.read(scratch.data, 0, FlacConstants.MAX_FRAME_HEADER_SIZE); + input.read(scratch.getData(), 0, FlacConstants.MAX_FRAME_HEADER_SIZE); FlacFrameReader.checkAndReadFrameHeader( scratch, @@ -64,10 +64,10 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); int frameStartMarker = FlacMetadataReader.getFrameStartMarker(input); ParsableByteArray scratch = new ParsableByteArray(FlacConstants.MAX_FRAME_HEADER_SIZE); - input.read(scratch.data, 0, FlacConstants.MAX_FRAME_HEADER_SIZE); + input.read(scratch.getData(), 0, FlacConstants.MAX_FRAME_HEADER_SIZE); boolean result = FlacFrameReader.checkAndReadFrameHeader( @@ -85,12 +85,12 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); int frameStartMarker = FlacMetadataReader.getFrameStartMarker(input); // Skip first frame. input.skip(5030); ParsableByteArray scratch = new ParsableByteArray(FlacConstants.MAX_FRAME_HEADER_SIZE); - input.read(scratch.data, 0, FlacConstants.MAX_FRAME_HEADER_SIZE); + input.read(scratch.getData(), 0, FlacConstants.MAX_FRAME_HEADER_SIZE); SampleNumberHolder sampleNumberHolder = new SampleNumberHolder(); FlacFrameReader.checkAndReadFrameHeader( @@ -105,9 +105,9 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); ParsableByteArray scratch = new ParsableByteArray(FlacConstants.MAX_FRAME_HEADER_SIZE); - input.read(scratch.data, 0, FlacConstants.MAX_FRAME_HEADER_SIZE); + input.read(scratch.getData(), 0, FlacConstants.MAX_FRAME_HEADER_SIZE); // The first bytes of the frame are not equal to the frame start marker. boolean result = @@ -122,7 +122,7 @@ public class FlacFrameReaderTest { @Test public void checkFrameHeaderFromPeek_validData_doesNotUpdatePositions() throws Exception { - String file = "flac/bear_one_metadata_block.flac"; + String file = "media/flac/bear_one_metadata_block.flac"; FlacStreamMetadataHolder streamMetadataHolder = new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame(file, streamMetadataHolder); @@ -145,7 +145,7 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); int frameStartMarker = FlacMetadataReader.getFrameStartMarker(input); boolean result = @@ -164,7 +164,7 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); int frameStartMarker = FlacMetadataReader.getFrameStartMarker(input); // Skip first frame. input.skip(5030); @@ -182,7 +182,7 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); // The first bytes of the frame are not equal to the frame start marker. boolean result = @@ -197,7 +197,7 @@ public class FlacFrameReaderTest { @Test public void checkFrameHeaderFromPeek_invalidData_doesNotUpdatePositions() throws Exception { - String file = "flac/bear_one_metadata_block.flac"; + String file = "media/flac/bear_one_metadata_block.flac"; FlacStreamMetadataHolder streamMetadataHolder = new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame(file, streamMetadataHolder); @@ -224,7 +224,7 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); long initialReadPosition = input.getPosition(); // Advance peek position after block size bits. input.advancePeekPosition(FlacConstants.MAX_FRAME_HEADER_SIZE); @@ -241,7 +241,7 @@ public class FlacFrameReaderTest { new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); ExtractorInput input = buildExtractorInputReadingFromFirstFrame( - "flac/bear_one_metadata_block.flac", streamMetadataHolder); + "media/flac/bear_one_metadata_block.flac", streamMetadataHolder); // Skip first frame. input.skip(5030); @@ -272,11 +272,11 @@ public class FlacFrameReaderTest { @Test public void readFrameBlockSizeSamplesFromKey_keyBetween6And7_returnsCorrectBlockSize() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_one_metadata_block.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_one_metadata_block.flac"); // Skip to block size bits of last frame. input.skipFully(164033); ParsableByteArray scratch = new ParsableByteArray(2); - input.readFully(scratch.data, 0, 2); + input.readFully(scratch.getData(), 0, 2); int result = FlacFrameReader.readFrameBlockSizeSamplesFromKey(scratch, /* blockSizeKey= */ 7); diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacMetadataReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacMetadataReaderTest.java index a6a2cd35b6..1648d548d2 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacMetadataReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacMetadataReaderTest.java @@ -45,7 +45,7 @@ public class FlacMetadataReaderTest { @Test public void peekId3Metadata_updatesPeekPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_id3.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_id3.flac"); FlacMetadataReader.peekId3Metadata(input, /* parseData= */ false); @@ -55,7 +55,7 @@ public class FlacMetadataReaderTest { @Test public void peekId3Metadata_parseData_returnsNonEmptyMetadata() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_id3.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_id3.flac"); Metadata metadata = FlacMetadataReader.peekId3Metadata(input, /* parseData= */ true); @@ -65,7 +65,7 @@ public class FlacMetadataReaderTest { @Test public void peekId3Metadata_doNotParseData_returnsNull() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_id3.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_id3.flac"); Metadata metadata = FlacMetadataReader.peekId3Metadata(input, /* parseData= */ false); @@ -74,7 +74,7 @@ public class FlacMetadataReaderTest { @Test public void peekId3Metadata_noId3Metadata_returnsNull() throws Exception { - String fileWithoutId3Metadata = "flac/bear.flac"; + String fileWithoutId3Metadata = "media/flac/bear.flac"; ExtractorInput input = buildExtractorInput(fileWithoutId3Metadata); Metadata metadata = FlacMetadataReader.peekId3Metadata(input, /* parseData= */ true); @@ -84,7 +84,7 @@ public class FlacMetadataReaderTest { @Test public void checkAndPeekStreamMarker_updatesPeekPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); FlacMetadataReader.checkAndPeekStreamMarker(input); @@ -94,7 +94,7 @@ public class FlacMetadataReaderTest { @Test public void checkAndPeekStreamMarker_validData_isTrue() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); boolean result = FlacMetadataReader.checkAndPeekStreamMarker(input); @@ -103,7 +103,7 @@ public class FlacMetadataReaderTest { @Test public void checkAndPeekStreamMarker_invalidData_isFalse() throws Exception { - ExtractorInput input = buildExtractorInput("mp3/bear-vbr-xing-header.mp3"); + ExtractorInput input = buildExtractorInput("media/mp3/bear-vbr-xing-header.mp3"); boolean result = FlacMetadataReader.checkAndPeekStreamMarker(input); @@ -112,7 +112,7 @@ public class FlacMetadataReaderTest { @Test public void readId3Metadata_updatesReadPositionAndAlignsPeekPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_id3.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_id3.flac"); // Advance peek position after ID3 metadata. FlacMetadataReader.peekId3Metadata(input, /* parseData= */ false); input.advancePeekPosition(1); @@ -125,7 +125,7 @@ public class FlacMetadataReaderTest { @Test public void readId3Metadata_parseData_returnsNonEmptyMetadata() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_id3.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_id3.flac"); Metadata metadata = FlacMetadataReader.readId3Metadata(input, /* parseData= */ true); @@ -135,7 +135,7 @@ public class FlacMetadataReaderTest { @Test public void readId3Metadata_doNotParseData_returnsNull() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_id3.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_id3.flac"); Metadata metadata = FlacMetadataReader.readId3Metadata(input, /* parseData= */ false); @@ -144,7 +144,7 @@ public class FlacMetadataReaderTest { @Test public void readId3Metadata_noId3Metadata_returnsNull() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); Metadata metadata = FlacMetadataReader.readId3Metadata(input, /* parseData= */ true); @@ -153,7 +153,7 @@ public class FlacMetadataReaderTest { @Test public void readStreamMarker_updatesReadPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); FlacMetadataReader.readStreamMarker(input); @@ -163,14 +163,14 @@ public class FlacMetadataReaderTest { @Test public void readStreamMarker_invalidData_throwsException() throws Exception { - ExtractorInput input = buildExtractorInput("mp3/bear-vbr-xing-header.mp3"); + ExtractorInput input = buildExtractorInput("media/mp3/bear-vbr-xing-header.mp3"); assertThrows(ParserException.class, () -> FlacMetadataReader.readStreamMarker(input)); } @Test public void readMetadataBlock_updatesReadPositionAndAlignsPeekPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); input.skipFully(FlacConstants.STREAM_MARKER_SIZE); // Advance peek position after metadata block. input.advancePeekPosition(FlacConstants.STREAM_INFO_BLOCK_SIZE + 1); @@ -184,7 +184,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_lastMetadataBlock_isTrue() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_one_metadata_block.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_one_metadata_block.flac"); input.skipFully(FlacConstants.STREAM_MARKER_SIZE); boolean result = @@ -196,7 +196,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_notLastMetadataBlock_isFalse() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); input.skipFully(FlacConstants.STREAM_MARKER_SIZE); boolean result = @@ -208,7 +208,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_streamInfoBlock_setsStreamMetadata() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); input.skipFully(FlacConstants.STREAM_MARKER_SIZE); FlacStreamMetadataHolder metadataHolder = new FlacStreamMetadataHolder(/* flacStreamMetadata= */ null); @@ -221,7 +221,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_seekTableBlock_updatesStreamMetadata() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); // Skip to seek table block. input.skipFully(FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE); FlacStreamMetadataHolder metadataHolder = new FlacStreamMetadataHolder(buildStreamMetadata()); @@ -238,7 +238,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_vorbisCommentBlock_updatesStreamMetadata() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_vorbis_comments.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_vorbis_comments.flac"); // Skip to Vorbis comment block. input.skipFully(640); FlacStreamMetadataHolder metadataHolder = new FlacStreamMetadataHolder(buildStreamMetadata()); @@ -259,7 +259,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_pictureBlock_updatesStreamMetadata() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear_with_picture.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear_with_picture.flac"); // Skip to picture block. input.skipFully(640); FlacStreamMetadataHolder metadataHolder = new FlacStreamMetadataHolder(buildStreamMetadata()); @@ -286,7 +286,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_blockToSkip_updatesReadPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); // Skip to padding block. input.skipFully(640); FlacStreamMetadataHolder metadataHolder = new FlacStreamMetadataHolder(buildStreamMetadata()); @@ -300,7 +300,7 @@ public class FlacMetadataReaderTest { @Test public void readMetadataBlock_nonStreamInfoBlockWithNullStreamMetadata_throwsException() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); // Skip to seek table block. input.skipFully(FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE); @@ -313,12 +313,12 @@ public class FlacMetadataReaderTest { @Test public void readSeekTableMetadataBlock_updatesPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); // Skip to seek table block. input.skipFully(FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE); int seekTableBlockSize = 598; ParsableByteArray scratch = new ParsableByteArray(seekTableBlockSize); - input.read(scratch.data, 0, seekTableBlockSize); + input.read(scratch.getData(), 0, seekTableBlockSize); FlacMetadataReader.readSeekTableMetadataBlock(scratch); @@ -327,12 +327,12 @@ public class FlacMetadataReaderTest { @Test public void readSeekTableMetadataBlock_returnsCorrectSeekPoints() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); // Skip to seek table block. input.skipFully(FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE); int seekTableBlockSize = 598; ParsableByteArray scratch = new ParsableByteArray(seekTableBlockSize); - input.read(scratch.data, 0, seekTableBlockSize); + input.read(scratch.getData(), 0, seekTableBlockSize); FlacStreamMetadata.SeekTable seekTable = FlacMetadataReader.readSeekTableMetadataBlock(scratch); @@ -345,7 +345,7 @@ public class FlacMetadataReaderTest { @Test public void readSeekTableMetadataBlock_ignoresPlaceholders() throws IOException { byte[] fileData = - TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), "flac/bear.flac"); + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), "media/flac/bear.flac"); ParsableByteArray scratch = new ParsableByteArray(fileData); // Skip to seek table block. scratch.skipBytes(FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE); @@ -359,7 +359,7 @@ public class FlacMetadataReaderTest { @Test public void getFrameStartMarker_doesNotUpdateReadPositionAndAlignsPeekPosition() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); int firstFramePosition = 8880; input.skipFully(firstFramePosition); // Advance the peek position after the frame start marker. @@ -373,7 +373,7 @@ public class FlacMetadataReaderTest { @Test public void getFrameStartMarker_returnsCorrectFrameStartMarker() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); // Skip to first frame. input.skipFully(8880); @@ -384,7 +384,7 @@ public class FlacMetadataReaderTest { @Test public void getFrameStartMarker_invalidData_throwsException() throws Exception { - ExtractorInput input = buildExtractorInput("flac/bear.flac"); + ExtractorInput input = buildExtractorInput("media/flac/bear.flac"); // Input position is incorrect. assertThrows(ParserException.class, () -> FlacMetadataReader.getFrameStartMarker(input)); diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacStreamMetadataTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacStreamMetadataTest.java index 482781e615..9c6b63ee0a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacStreamMetadataTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/FlacStreamMetadataTest.java @@ -35,7 +35,7 @@ public final class FlacStreamMetadataTest { @Test public void constructFromByteArray_setsFieldsCorrectly() throws IOException { byte[] fileData = - TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), "flac/bear.flac"); + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), "media/flac/bear.flac"); FlacStreamMetadata streamMetadata = new FlacStreamMetadata( diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/Id3PeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/Id3PeekerTest.java index 2c7d7ad722..e0cf957a38 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/Id3PeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/Id3PeekerTest.java @@ -51,7 +51,8 @@ public final class Id3PeekerTest { Id3Peeker id3Peeker = new Id3Peeker(); FakeExtractorInput input = new FakeExtractorInput.Builder() - .setData(getByteArray(ApplicationProvider.getApplicationContext(), "id3/apic.id3")) + .setData( + getByteArray(ApplicationProvider.getApplicationContext(), "media/id3/apic.id3")) .build(); @Nullable Metadata metadata = id3Peeker.peekId3Data(input, /* id3FramePredicate= */ null); @@ -72,7 +73,9 @@ public final class Id3PeekerTest { Id3Peeker id3Peeker = new Id3Peeker(); FakeExtractorInput input = new FakeExtractorInput.Builder() - .setData(getByteArray(ApplicationProvider.getApplicationContext(), "id3/comm_apic.id3")) + .setData( + getByteArray( + ApplicationProvider.getApplicationContext(), "media/id3/comm_apic.id3")) .build(); @Nullable diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/VorbisUtilTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/VorbisUtilTest.java index 67ac6bd1cc..9f0d6e0ff2 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/VorbisUtilTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/VorbisUtilTest.java @@ -50,7 +50,7 @@ public final class VorbisUtilTest { public void readIdHeader() throws Exception { byte[] data = TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "binary/vorbis/id_header"); + ApplicationProvider.getApplicationContext(), "media/binary/vorbis/id_header"); ParsableByteArray headerData = new ParsableByteArray(data, data.length); VorbisUtil.VorbisIdHeader vorbisIdHeader = VorbisUtil.readVorbisIdentificationHeader(headerData); @@ -70,7 +70,7 @@ public final class VorbisUtilTest { public void readCommentHeader() throws IOException { byte[] data = TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "binary/vorbis/comment_header"); + ApplicationProvider.getApplicationContext(), "media/binary/vorbis/comment_header"); ParsableByteArray headerData = new ParsableByteArray(data, data.length); VorbisUtil.CommentHeader commentHeader = VorbisUtil.readVorbisCommentHeader(headerData); @@ -85,7 +85,7 @@ public final class VorbisUtilTest { public void readVorbisModes() throws IOException { byte[] data = TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "binary/vorbis/setup_header"); + ApplicationProvider.getApplicationContext(), "media/binary/vorbis/setup_header"); ParsableByteArray headerData = new ParsableByteArray(data, data.length); VorbisUtil.Mode[] modes = VorbisUtil.readVorbisModes(headerData, 2); diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java index e79020e5c6..53913e07cc 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java @@ -37,26 +37,29 @@ public final class AmrExtractorParameterizedTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void extractingNarrowBandSamples() throws Exception { ExtractorAsserts.assertBehavior( - createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_nb.amr", simulationConfig); + createAmrExtractorFactory(/* withSeeking= */ false), + "media/amr/sample_nb.amr", + simulationConfig); } @Test public void extractingWideBandSamples() throws Exception { ExtractorAsserts.assertBehavior( - createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_wb.amr", simulationConfig); + createAmrExtractorFactory(/* withSeeking= */ false), + "media/amr/sample_wb.amr", + simulationConfig); } @Test public void extractingNarrowBandSamples_withSeeking() throws Exception { ExtractorAsserts.assertBehavior( createAmrExtractorFactory(/* withSeeking= */ true), - "amr/sample_nb_cbr.amr", + "media/amr/sample_nb_cbr.amr", simulationConfig); } @@ -64,7 +67,7 @@ public final class AmrExtractorParameterizedTest { public void extractingWideBandSamples_withSeeking() throws Exception { ExtractorAsserts.assertBehavior( createAmrExtractorFactory(/* withSeeking= */ true), - "amr/sample_wb_cbr.amr", + "media/amr/sample_wb_cbr.amr", simulationConfig); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java index 42e9f93c00..534cb2572f 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java @@ -39,10 +39,10 @@ public final class AmrExtractorSeekTest { private static final Random random = new Random(1234L); - private static final String NARROW_BAND_AMR_FILE = "amr/sample_nb.amr"; + private static final String NARROW_BAND_AMR_FILE = "media/amr/sample_nb.amr"; private static final int NARROW_BAND_FILE_DURATION_US = 4_360_000; - private static final String WIDE_BAND_AMR_FILE = "amr/sample_wb.amr"; + private static final String WIDE_BAND_AMR_FILE = "media/amr/sample_wb.amr"; private static final int WIDE_BAND_FILE_DURATION_US = 3_380_000; private FakeTrackOutput expectedTrackOutput; @@ -51,7 +51,7 @@ public final class AmrExtractorSeekTest { @Before public void setUp() { dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java index 99cf464f68..16f92e2b4b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java @@ -37,16 +37,16 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class FlacExtractorSeekTest { - private static final String TEST_FILE_SEEK_TABLE = "flac/bear.flac"; - private static final String TEST_FILE_BINARY_SEARCH = "flac/bear_one_metadata_block.flac"; - private static final String TEST_FILE_UNSEEKABLE = "flac/bear_no_seek_table_no_num_samples.flac"; + private static final String TEST_FILE_SEEK_TABLE = "media/flac/bear.flac"; + private static final String TEST_FILE_BINARY_SEARCH = "media/flac/bear_one_metadata_block.flac"; + private static final String TEST_FILE_UNSEEKABLE = + "media/flac/bear_no_seek_table_no_num_samples.flac"; private static final int DURATION_US = 2_741_000; private FlacExtractor extractor = new FlacExtractor(); private FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); private DefaultDataSource dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") - .createDataSource(); + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()).createDataSource(); @Test public void flacExtractorReads_seekTable_returnSeekableSeekMap() throws IOException { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java index 94d1a5d612..500cdd4e86 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorTest.java @@ -33,15 +33,14 @@ public class FlacExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear.flac", - new AssertionConfig.Builder().setDumpFilesPrefix("flac/bear_flac").build(), + "media/flac/bear.flac", + new AssertionConfig.Builder().setDumpFilesPrefix("extractordumps/flac/bear_flac").build(), simulationConfig); } @@ -49,8 +48,10 @@ public class FlacExtractorTest { public void sampleWithId3HeaderAndId3Enabled() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_with_id3.flac", - new AssertionConfig.Builder().setDumpFilesPrefix("flac/bear_with_id3_enabled_flac").build(), + "media/flac/bear_with_id3.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_with_id3_enabled_flac") + .build(), simulationConfig); } @@ -58,9 +59,9 @@ public class FlacExtractorTest { public void sampleWithId3HeaderAndId3Disabled() throws Exception { ExtractorAsserts.assertBehavior( () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA), - "flac/bear_with_id3.flac", + "media/flac/bear_with_id3.flac", new AssertionConfig.Builder() - .setDumpFilesPrefix("flac/bear_with_id3_disabled_flac") + .setDumpFilesPrefix("extractordumps/flac/bear_with_id3_disabled_flac") .build(), simulationConfig); } @@ -69,9 +70,9 @@ public class FlacExtractorTest { public void sampleUnseekable() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_no_seek_table_no_num_samples.flac", + "media/flac/bear_no_seek_table_no_num_samples.flac", new AssertionConfig.Builder() - .setDumpFilesPrefix("flac/bear_no_seek_table_no_num_samples_flac") + .setDumpFilesPrefix("extractordumps/flac/bear_no_seek_table_no_num_samples_flac") .build(), simulationConfig); } @@ -80,9 +81,9 @@ public class FlacExtractorTest { public void sampleWithVorbisComments() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_with_vorbis_comments.flac", + "media/flac/bear_with_vorbis_comments.flac", new AssertionConfig.Builder() - .setDumpFilesPrefix("flac/bear_with_vorbis_comments_flac") + .setDumpFilesPrefix("extractordumps/flac/bear_with_vorbis_comments_flac") .build(), simulationConfig); } @@ -91,8 +92,10 @@ public class FlacExtractorTest { public void sampleWithPicture() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_with_picture.flac", - new AssertionConfig.Builder().setDumpFilesPrefix("flac/bear_with_picture_flac").build(), + "media/flac/bear_with_picture.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_with_picture_flac") + .build(), simulationConfig); } @@ -100,9 +103,9 @@ public class FlacExtractorTest { public void oneMetadataBlock() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_one_metadata_block.flac", + "media/flac/bear_one_metadata_block.flac", new AssertionConfig.Builder() - .setDumpFilesPrefix("flac/bear_one_metadata_block_flac") + .setDumpFilesPrefix("extractordumps/flac/bear_one_metadata_block_flac") .build(), simulationConfig); } @@ -111,9 +114,9 @@ public class FlacExtractorTest { public void noMinMaxFrameSize() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_no_min_max_frame_size.flac", + "media/flac/bear_no_min_max_frame_size.flac", new AssertionConfig.Builder() - .setDumpFilesPrefix("flac/bear_no_min_max_frame_size_flac") + .setDumpFilesPrefix("extractordumps/flac/bear_no_min_max_frame_size_flac") .build(), simulationConfig); } @@ -122,8 +125,10 @@ public class FlacExtractorTest { public void noNumSamples() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_no_num_samples.flac", - new AssertionConfig.Builder().setDumpFilesPrefix("flac/bear_no_num_samples_flac").build(), + "media/flac/bear_no_num_samples.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_no_num_samples_flac") + .build(), simulationConfig); } @@ -131,9 +136,9 @@ public class FlacExtractorTest { public void uncommonSampleRate() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - "flac/bear_uncommon_sample_rate.flac", + "media/flac/bear_uncommon_sample_rate.flac", new AssertionConfig.Builder() - .setDumpFilesPrefix("flac/bear_uncommon_sample_rate_flac") + .setDumpFilesPrefix("extractordumps/flac/bear_uncommon_sample_rate_flac") .build(), simulationConfig); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java index cde043f2d3..06678ae912 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flv/FlvExtractorTest.java @@ -32,11 +32,10 @@ public final class FlvExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { - ExtractorAsserts.assertBehavior(FlvExtractor::new, "flv/sample.flv", simulationConfig); + ExtractorAsserts.assertBehavior(FlvExtractor::new, "media/flv/sample.flv", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java index f9aafbd1fb..8e22aace8a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractorTest.java @@ -32,41 +32,43 @@ public final class MatroskaExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void mkvSample() throws Exception { - ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/sample.mkv", simulationConfig); + ExtractorAsserts.assertBehavior( + MatroskaExtractor::new, "media/mkv/sample.mkv", simulationConfig); } @Test public void mkvSample_withSubripSubtitles() throws Exception { ExtractorAsserts.assertBehavior( - MatroskaExtractor::new, "mkv/sample_with_srt.mkv", simulationConfig); + MatroskaExtractor::new, "media/mkv/sample_with_srt.mkv", simulationConfig); } @Test public void mkvSample_withHtcRotationInfoInTrackName() throws Exception { ExtractorAsserts.assertBehavior( - MatroskaExtractor::new, "mkv/sample_with_htc_rotation_track_name.mkv", simulationConfig); + MatroskaExtractor::new, + "media/mkv/sample_with_htc_rotation_track_name.mkv", + simulationConfig); } @Test public void mkvFullBlocksSample() throws Exception { ExtractorAsserts.assertBehavior( - MatroskaExtractor::new, "mkv/full_blocks.mkv", simulationConfig); + MatroskaExtractor::new, "media/mkv/full_blocks.mkv", simulationConfig); } @Test public void webmSubsampleEncryption() throws Exception { ExtractorAsserts.assertBehavior( - MatroskaExtractor::new, "mkv/subsample_encrypted_noaltref.webm", simulationConfig); + MatroskaExtractor::new, "media/mkv/subsample_encrypted_noaltref.webm", simulationConfig); } @Test public void webmSubsampleEncryptionWithAltrefFrames() throws Exception { ExtractorAsserts.assertBehavior( - MatroskaExtractor::new, "mkv/subsample_encrypted_altref.webm", simulationConfig); + MatroskaExtractor::new, "media/mkv/subsample_encrypted_altref.webm", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java index 8ff5e84d69..e3137a106d 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java @@ -39,9 +39,9 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class ConstantBitrateSeekerTest { private static final String CONSTANT_FRAME_SIZE_TEST_FILE = - "mp3/bear-cbr-constant-frame-size-no-seek-table.mp3"; + "media/mp3/bear-cbr-constant-frame-size-no-seek-table.mp3"; private static final String VARIABLE_FRAME_SIZE_TEST_FILE = - "mp3/bear-cbr-variable-frame-size-no-seek-table.mp3"; + "media/mp3/bear-cbr-variable-frame-size-no-seek-table.mp3"; private Mp3Extractor extractor; private FakeExtractorOutput extractorOutput; @@ -52,7 +52,7 @@ public class ConstantBitrateSeekerTest { extractor = new Mp3Extractor(); extractorOutput = new FakeExtractorOutput(); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java index 0e5c263644..24530c12f1 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java @@ -40,7 +40,7 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class IndexSeekerTest { - private static final String TEST_FILE_NO_SEEK_TABLE = "mp3/bear-vbr-no-seek-table.mp3"; + private static final String TEST_FILE_NO_SEEK_TABLE = "media/mp3/bear-vbr-no-seek-table.mp3"; private static final int TEST_FILE_NO_SEEK_TABLE_DURATION = 2_808_000; private Mp3Extractor extractor; @@ -52,7 +52,7 @@ public class IndexSeekerTest { extractor = new Mp3Extractor(FLAG_ENABLE_INDEX_SEEKING); extractorOutput = new FakeExtractorOutput(); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java index a142ac1a4d..f59e3e77a8 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java @@ -33,40 +33,44 @@ public final class Mp3ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void mp3SampleWithXingHeader() throws Exception { ExtractorAsserts.assertBehavior( - Mp3Extractor::new, "mp3/bear-vbr-xing-header.mp3", simulationConfig); + Mp3Extractor::new, "media/mp3/bear-vbr-xing-header.mp3", simulationConfig); } @Test public void mp3SampleWithCbrSeeker() throws Exception { ExtractorAsserts.assertBehavior( - Mp3Extractor::new, "mp3/bear-cbr-variable-frame-size-no-seek-table.mp3", simulationConfig); + Mp3Extractor::new, + "media/mp3/bear-cbr-variable-frame-size-no-seek-table.mp3", + simulationConfig); } @Test public void mp3SampleWithIndexSeeker() throws Exception { ExtractorAsserts.assertBehavior( () -> new Mp3Extractor(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING), - "mp3/bear-vbr-no-seek-table.mp3", + "media/mp3/bear-vbr-no-seek-table.mp3", simulationConfig); } @Test public void trimmedMp3Sample() throws Exception { - ExtractorAsserts.assertBehavior(Mp3Extractor::new, "mp3/play-trimmed.mp3", simulationConfig); + ExtractorAsserts.assertBehavior( + Mp3Extractor::new, "media/mp3/play-trimmed.mp3", simulationConfig); } @Test public void mp3SampleWithId3Enabled() throws Exception { ExtractorAsserts.assertBehavior( Mp3Extractor::new, - "mp3/bear-id3.mp3", - new AssertionConfig.Builder().setDumpFilesPrefix("mp3/bear-id3-enabled").build(), + "media/mp3/bear-id3.mp3", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/mp3/bear-id3-enabled") + .build(), simulationConfig); } @@ -74,8 +78,10 @@ public final class Mp3ExtractorTest { public void mp3SampleWithId3Disabled() throws Exception { ExtractorAsserts.assertBehavior( () -> new Mp3Extractor(Mp3Extractor.FLAG_DISABLE_ID3_METADATA), - "mp3/bear-id3.mp3", - new AssertionConfig.Builder().setDumpFilesPrefix("mp3/bear-id3-disabled").build(), + "media/mp3/bear-id3.mp3", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/mp3/bear-id3-disabled") + .build(), simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java index c09b3b439f..e8ab027e9b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4ExtractorTest.java @@ -37,20 +37,21 @@ public final class FragmentedMp4ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { ExtractorAsserts.assertBehavior( - getExtractorFactory(ImmutableList.of()), "mp4/sample_fragmented.mp4", simulationConfig); + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_fragmented.mp4", + simulationConfig); } @Test public void sampleSeekable() throws Exception { ExtractorAsserts.assertBehavior( getExtractorFactory(ImmutableList.of()), - "mp4/sample_fragmented_seekable.mp4", + "media/mp4/sample_fragmented_seekable.mp4", simulationConfig); } @@ -62,32 +63,38 @@ public final class FragmentedMp4ExtractorTest { Collections.singletonList( new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build())); ExtractorAsserts.assertBehavior( - extractorFactory, "mp4/sample_fragmented_sei.mp4", simulationConfig); + extractorFactory, "media/mp4/sample_fragmented_sei.mp4", simulationConfig); } @Test public void sampleWithAc3Track() throws Exception { ExtractorAsserts.assertBehavior( - getExtractorFactory(ImmutableList.of()), "mp4/sample_ac3_fragmented.mp4", simulationConfig); + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_ac3_fragmented.mp4", + simulationConfig); } @Test public void sampleWithAc4Track() throws Exception { ExtractorAsserts.assertBehavior( - getExtractorFactory(ImmutableList.of()), "mp4/sample_ac4_fragmented.mp4", simulationConfig); + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_ac4_fragmented.mp4", + simulationConfig); } @Test public void sampleWithProtectedAc4Track() throws Exception { ExtractorAsserts.assertBehavior( - getExtractorFactory(ImmutableList.of()), "mp4/sample_ac4_protected.mp4", simulationConfig); + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_ac4_protected.mp4", + simulationConfig); } @Test public void sampleWithEac3Track() throws Exception { ExtractorAsserts.assertBehavior( getExtractorFactory(ImmutableList.of()), - "mp4/sample_eac3_fragmented.mp4", + "media/mp4/sample_eac3_fragmented.mp4", simulationConfig); } @@ -95,7 +102,23 @@ public final class FragmentedMp4ExtractorTest { public void sampleWithEac3jocTrack() throws Exception { ExtractorAsserts.assertBehavior( getExtractorFactory(ImmutableList.of()), - "mp4/sample_eac3joc_fragmented.mp4", + "media/mp4/sample_eac3joc_fragmented.mp4", + simulationConfig); + } + + @Test + public void sampleWithOpusTrack() throws Exception { + ExtractorAsserts.assertBehavior( + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_opus_fragmented.mp4", + simulationConfig); + } + + @Test + public void samplePartiallyFragmented() throws Exception { + ExtractorAsserts.assertBehavior( + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_partially_fragmented.mp4", simulationConfig); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtilTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtilTest.java index 2466c46d8a..70e483457a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtilTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/MetadataUtilTest.java @@ -27,7 +27,7 @@ public final class MetadataUtilTest { @Test public void standardGenre_length_matchesNumberOfId3Genres() { - // Sanity check that we haven't forgotten a genre in the list. + // Check that we haven't forgotten a genre in the list. assertThat(MetadataUtil.STANDARD_GENRES).hasLength(192); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java index 503b78624e..c2e2367307 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp4/Mp4ExtractorTest.java @@ -32,18 +32,17 @@ public final class Mp4ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void mp4Sample() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample.mp4", simulationConfig); + ExtractorAsserts.assertBehavior(Mp4Extractor::new, "media/mp4/sample.mp4", simulationConfig); } @Test public void mp4SampleWithSlowMotionMetadata() throws Exception { ExtractorAsserts.assertBehavior( - Mp4Extractor::new, "mp4/sample_android_slow_motion.mp4", simulationConfig); + Mp4Extractor::new, "media/mp4/sample_android_slow_motion.mp4", simulationConfig); } /** @@ -53,26 +52,36 @@ public final class Mp4ExtractorTest { @Test public void mp4SampleWithMdatTooLong() throws Exception { ExtractorAsserts.assertBehavior( - Mp4Extractor::new, "mp4/sample_mdat_too_long.mp4", simulationConfig); + Mp4Extractor::new, "media/mp4/sample_mdat_too_long.mp4", simulationConfig); } @Test public void mp4SampleWithAc3Track() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac3.mp4", simulationConfig); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_ac3.mp4", simulationConfig); } @Test public void mp4SampleWithAc4Track() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac4.mp4", simulationConfig); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_ac4.mp4", simulationConfig); } @Test public void mp4SampleWithEac3Track() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3.mp4", simulationConfig); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_eac3.mp4", simulationConfig); } @Test public void mp4SampleWithEac3jocTrack() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3joc.mp4", simulationConfig); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_eac3joc.mp4", simulationConfig); + } + + @Test + public void mp4SampleWithOpusTrack() throws Exception { + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_opus.mp4", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java index 83aa8c6d9b..e30f27713e 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeekerTest.java @@ -22,7 +22,6 @@ import static org.junit.Assert.fail; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; @@ -57,7 +56,7 @@ public final class DefaultOggSeekerTest { @Test public void seeking() throws Exception { byte[] data = - getByteArray(ApplicationProvider.getApplicationContext(), "ogg/random_1000_pages"); + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/random_1000_pages"); int granuleCount = 49269395; int firstPayloadPageSize = 2023; int firstPayloadPageGranuleCount = 57058; @@ -121,58 +120,11 @@ public final class DefaultOggSeekerTest { } } - @Test - public void skipToNextPage_success() throws Exception { - FakeExtractorInput extractorInput = - createInput( - TestUtil.joinByteArrays( - TestUtil.buildTestData(4000, random), - new byte[] {'O', 'g', 'g', 'S'}, - TestUtil.buildTestData(4000, random)), - /* simulateUnknownLength= */ false); - skipToNextPage(extractorInput); - assertThat(extractorInput.getPosition()).isEqualTo(4000); - } - - @Test - public void skipToNextPage_withOverlappingInput_success() throws Exception { - FakeExtractorInput extractorInput = - createInput( - TestUtil.joinByteArrays( - TestUtil.buildTestData(2046, random), - new byte[] {'O', 'g', 'g', 'S'}, - TestUtil.buildTestData(4000, random)), - /* simulateUnknownLength= */ false); - skipToNextPage(extractorInput); - assertThat(extractorInput.getPosition()).isEqualTo(2046); - } - - @Test - public void skipToNextPage_withInputShorterThanPeekLength_success() throws Exception { - FakeExtractorInput extractorInput = - createInput( - TestUtil.joinByteArrays(new byte[] {'x', 'O', 'g', 'g', 'S'}), - /* simulateUnknownLength= */ false); - skipToNextPage(extractorInput); - assertThat(extractorInput.getPosition()).isEqualTo(1); - } - - @Test - public void skipToNextPage_withoutMatch_throwsException() throws Exception { - FakeExtractorInput extractorInput = - createInput(new byte[] {'g', 'g', 'S', 'O', 'g', 'g'}, /* simulateUnknownLength= */ false); - try { - skipToNextPage(extractorInput); - fail(); - } catch (EOFException e) { - // expected - } - } - @Test public void readGranuleOfLastPage() throws IOException { // This test stream has three headers with granule numbers 20000, 40000 and 60000. - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/three_headers"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/three_headers"); FakeExtractorInput input = createInput(data, /* simulateUnknownLength= */ false); assertReadGranuleOfLastPage(input, 60000); } @@ -200,25 +152,6 @@ public final class DefaultOggSeekerTest { } } - private static void skipToNextPage(ExtractorInput extractorInput) throws IOException { - DefaultOggSeeker oggSeeker = - new DefaultOggSeeker( - /* streamReader= */ new FlacReader(), - /* payloadStartPosition= */ 0, - /* payloadEndPosition= */ extractorInput.getLength(), - /* firstPayloadPageSize= */ 1, - /* firstPayloadPageGranulePosition= */ 2, - /* firstPayloadPageIsLastPage= */ false); - while (true) { - try { - oggSeeker.skipToNextPage(extractorInput); - break; - } catch (FakeExtractorInput.SimulatedIOException e) { - /* ignored */ - } - } - } - private static void assertReadGranuleOfLastPage(FakeExtractorInput input, int expected) throws IOException { DefaultOggSeeker oggSeeker = diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java index bf2a350aae..f25f97eaa2 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java @@ -36,38 +36,42 @@ public final class OggExtractorNonParameterizedTest { @Test public void sniffVorbis() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/vorbis_header"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/vorbis_header"); assertSniff(data, /* expectedResult= */ true); } @Test public void sniffFlac() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/flac_header"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/flac_header"); assertSniff(data, /* expectedResult= */ true); } @Test public void sniffFailsOpusFile() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/opus_header"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/opus_header"); assertSniff(data, /* expectedResult= */ false); } @Test public void sniffFailsInvalidOggHeader() throws Exception { byte[] data = - getByteArray(ApplicationProvider.getApplicationContext(), "ogg/invalid_ogg_header"); + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/invalid_ogg_header"); assertSniff(data, /* expectedResult= */ false); } @Test public void sniffInvalidHeader() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/invalid_header"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/invalid_header"); assertSniff(data, /* expectedResult= */ false); } @Test public void sniffFailsEOF() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/eof_header"); + byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/eof_header"); assertSniff(data, /* expectedResult= */ false); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java index 9b2c6caf89..cc78d59bf4 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java @@ -36,27 +36,35 @@ public final class OggExtractorParameterizedTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void opus() throws Exception { - ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear.opus", simulationConfig); + ExtractorAsserts.assertBehavior(OggExtractor::new, "media/ogg/bear.opus", simulationConfig); } @Test public void flac() throws Exception { - ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_flac.ogg", simulationConfig); + ExtractorAsserts.assertBehavior(OggExtractor::new, "media/ogg/bear_flac.ogg", simulationConfig); } @Test public void flacNoSeektable() throws Exception { ExtractorAsserts.assertBehavior( - OggExtractor::new, "ogg/bear_flac_noseektable.ogg", simulationConfig); + OggExtractor::new, "media/ogg/bear_flac_noseektable.ogg", simulationConfig); } @Test public void vorbis() throws Exception { - ExtractorAsserts.assertBehavior(OggExtractor::new, "ogg/bear_vorbis.ogg", simulationConfig); + ExtractorAsserts.assertBehavior( + OggExtractor::new, "media/ogg/bear_vorbis.ogg", simulationConfig); + } + + // Ensure the extractor can handle non-contiguous pages by using a file with 10 bytes of garbage + // data before the start of the second page. + @Test + public void vorbisWithGapBeforeSecondPage() throws Exception { + ExtractorAsserts.assertBehavior( + OggExtractor::new, "media/ogg/bear_vorbis_gap.ogg", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPacketTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPacketTest.java index 492b542e95..e74ecf7be0 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPacketTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPacketTest.java @@ -33,7 +33,7 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class OggPacketTest { - private static final String TEST_FILE = "ogg/bear.opus"; + private static final String TEST_FILE = "media/ogg/bear.opus"; private final Random random = new Random(/* seed= */ 0); private final OggPacket oggPacket = new OggPacket(); @@ -47,7 +47,8 @@ public final class OggPacketTest { FakeExtractorInput input = createInput( getByteArray( - ApplicationProvider.getApplicationContext(), "ogg/four_packets_with_empty_page")); + ApplicationProvider.getApplicationContext(), + "media/ogg/four_packets_with_empty_page")); assertReadPacket(input, firstPacket); assertThat((oggPacket.getPageHeader().type & 0x02) == 0x02).isTrue(); @@ -95,7 +96,7 @@ public final class OggPacketTest { createInput( getByteArray( ApplicationProvider.getApplicationContext(), - "ogg/packet_with_zero_size_terminator")); + "media/ogg/packet_with_zero_size_terminator")); assertReadPacket(input, firstPacket); assertReadPacket(input, secondPacket); @@ -109,7 +110,7 @@ public final class OggPacketTest { createInput( getByteArray( ApplicationProvider.getApplicationContext(), - "ogg/continued_packet_over_two_pages")); + "media/ogg/continued_packet_over_two_pages")); assertReadPacket(input, firstPacket); assertThat((oggPacket.getPageHeader().type & 0x04) == 0x04).isTrue(); @@ -126,7 +127,7 @@ public final class OggPacketTest { createInput( getByteArray( ApplicationProvider.getApplicationContext(), - "ogg/continued_packet_over_four_pages")); + "media/ogg/continued_packet_over_four_pages")); assertReadPacket(input, firstPacket); assertThat((oggPacket.getPageHeader().type & 0x04) == 0x04).isTrue(); @@ -142,7 +143,8 @@ public final class OggPacketTest { FakeExtractorInput input = createInput( getByteArray( - ApplicationProvider.getApplicationContext(), "ogg/continued_packet_at_start")); + ApplicationProvider.getApplicationContext(), + "media/ogg/continued_packet_at_start")); // Expect the first partial packet to be discarded. assertReadPacket(input, Arrays.copyOfRange(pageBody, 256, 256 + 8)); @@ -158,7 +160,7 @@ public final class OggPacketTest { createInput( getByteArray( ApplicationProvider.getApplicationContext(), - "ogg/zero_sized_packets_at_end_of_stream")); + "media/ogg/zero_sized_packets_at_end_of_stream")); assertReadPacket(input, firstPacket); assertReadPacket(input, secondPacket); @@ -190,7 +192,7 @@ public final class OggPacketTest { throws IOException { assertThat(readPacket(extractorInput)).isTrue(); ParsableByteArray payload = oggPacket.getPayload(); - assertThat(Arrays.copyOf(payload.data, payload.limit())).isEqualTo(expected); + assertThat(Arrays.copyOf(payload.getData(), payload.limit())).isEqualTo(expected); } private void assertReadEof(FakeExtractorInput extractorInput) throws IOException { diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java index 6b5ffe8f91..c952c0b220 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggPageHeaderTest.java @@ -23,6 +23,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import com.google.android.exoplayer2.testutil.FakeExtractorInput.SimulatedIOException; import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.common.primitives.Bytes; +import java.io.IOException; +import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,14 +33,70 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class OggPageHeaderTest { + private final Random random; + + public OggPageHeaderTest() { + this.random = new Random(/* seed= */ 0); + } + + @Test + public void skipToNextPage_success() throws Exception { + FakeExtractorInput input = + createInput( + Bytes.concat( + TestUtil.buildTestData(20, random), + new byte[] {'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(20, random)), + /* simulateUnknownLength= */ false); + OggPageHeader oggHeader = new OggPageHeader(); + + boolean result = retrySimulatedIOException(() -> oggHeader.skipToNextPage(input)); + + assertThat(result).isTrue(); + assertThat(input.getPosition()).isEqualTo(20); + } + + @Test + public void skipToNextPage_noPage_returnsFalse() throws Exception { + FakeExtractorInput input = + createInput( + Bytes.concat(TestUtil.buildTestData(20, random)), /* simulateUnknownLength= */ false); + OggPageHeader oggHeader = new OggPageHeader(); + + boolean result = retrySimulatedIOException(() -> oggHeader.skipToNextPage(input)); + + assertThat(result).isFalse(); + assertThat(input.getPosition()).isEqualTo(20); + } + + @Test + public void skipToNextPage_respectsLimit() throws Exception { + FakeExtractorInput input = + createInput( + Bytes.concat( + TestUtil.buildTestData(20, random), + new byte[] {'O', 'g', 'g', 'S'}, + TestUtil.buildTestData(20, random)), + /* simulateUnknownLength= */ false); + OggPageHeader oggHeader = new OggPageHeader(); + + boolean result = retrySimulatedIOException(() -> oggHeader.skipToNextPage(input, 10)); + + assertThat(result).isFalse(); + assertThat(input.getPosition()).isEqualTo(10); + } + @Test public void populatePageHeader_success() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/page_header"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/page_header"); FakeExtractorInput input = createInput(data, /* simulateUnknownLength= */ true); OggPageHeader header = new OggPageHeader(); - populatePageHeader(input, header, /* quiet= */ false); + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ false)); + + assertThat(result).isTrue(); assertThat(header.type).isEqualTo(0x01); assertThat(header.headerSize).isEqualTo(27 + 2); assertThat(header.bodySize).isEqualTo(4); @@ -55,38 +114,38 @@ public final class OggPageHeaderTest { FakeExtractorInput input = createInput(TestUtil.createByteArray(2, 2), /* simulateUnknownLength= */ false); OggPageHeader header = new OggPageHeader(); - assertThat(populatePageHeader(input, header, /* quiet= */ true)).isFalse(); + + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ true)); + + assertThat(result).isFalse(); } @Test public void populatePageHeader_withNotOgg_returnFalseWithoutException() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/page_header"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/page_header"); // change from 'O' to 'o' data[0] = 'o'; FakeExtractorInput input = createInput(data, /* simulateUnknownLength= */ false); OggPageHeader header = new OggPageHeader(); - assertThat(populatePageHeader(input, header, /* quiet= */ true)).isFalse(); + + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ true)); + + assertThat(result).isFalse(); } @Test public void populatePageHeader_withWrongRevision_returnFalseWithoutException() throws Exception { - byte[] data = getByteArray(ApplicationProvider.getApplicationContext(), "ogg/page_header"); + byte[] data = + getByteArray(ApplicationProvider.getApplicationContext(), "media/ogg/page_header"); // change revision from 0 to 1 data[4] = 0x01; FakeExtractorInput input = createInput(data, /* simulateUnknownLength= */ false); OggPageHeader header = new OggPageHeader(); - assertThat(populatePageHeader(input, header, /* quiet= */ true)).isFalse(); - } - private static boolean populatePageHeader( - FakeExtractorInput input, OggPageHeader header, boolean quiet) throws Exception { - while (true) { - try { - return header.populate(input, quiet); - } catch (SimulatedIOException e) { - // ignored - } - } + boolean result = retrySimulatedIOException(() -> header.populate(input, /* quiet= */ true)); + + assertThat(result).isFalse(); } private static FakeExtractorInput createInput(byte[] data, boolean simulateUnknownLength) { @@ -97,5 +156,20 @@ public final class OggPageHeaderTest { .setSimulatePartialReads(true) .build(); } + + private static T retrySimulatedIOException(ThrowingSupplier supplier) + throws IOException { + while (true) { + try { + return supplier.get(); + } catch (SimulatedIOException e) { + // ignored + } + } + } + + private interface ThrowingSupplier { + S get() throws E; + } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java index c7edff700a..7db02d4789 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/VorbisReaderTest.java @@ -49,10 +49,10 @@ public final class VorbisReaderTest { buffer.setLimit(0); VorbisReader.appendNumberOfSamples(buffer, 0x01234567); assertThat(buffer.limit()).isEqualTo(4); - assertThat(buffer.data[0]).isEqualTo(0x67); - assertThat(buffer.data[1]).isEqualTo(0x45); - assertThat(buffer.data[2]).isEqualTo(0x23); - assertThat(buffer.data[3]).isEqualTo(0x01); + assertThat(buffer.getData()[0]).isEqualTo(0x67); + assertThat(buffer.getData()[1]).isEqualTo(0x45); + assertThat(buffer.getData()[2]).isEqualTo(0x23); + assertThat(buffer.getData()[3]).isEqualTo(0x01); } @Test @@ -61,7 +61,7 @@ public final class VorbisReaderTest { // identification, comment and setup header. byte[] data = TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "binary/ogg/vorbis_header_pages"); + ApplicationProvider.getApplicationContext(), "media/binary/ogg/vorbis_header_pages"); ExtractorInput input = new FakeExtractorInput.Builder().setData(data).setSimulateIOErrors(true) .setSimulateUnknownLength(true).setSimulatePartialReads(true).build(); diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java index 92e0f21451..173a404961 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractorTest.java @@ -44,6 +44,6 @@ public final class RawCcExtractorTest { .setAccessibilityChannel(1) .build(); ExtractorAsserts.assertBehavior( - () -> new RawCcExtractor(format), "rawcc/sample.rawcc", simulationConfig); + () -> new RawCcExtractor(format), "media/rawcc/sample.rawcc", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java index 5cdaf91e74..4c8ddfb153 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac3ExtractorTest.java @@ -32,21 +32,21 @@ public final class Ac3ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void ac3Sample() throws Exception { - ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.ac3", simulationConfig); + ExtractorAsserts.assertBehavior(Ac3Extractor::new, "media/ts/sample.ac3", simulationConfig); } @Test public void eAc3Sample() throws Exception { - ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.eac3", simulationConfig); + ExtractorAsserts.assertBehavior(Ac3Extractor::new, "media/ts/sample.eac3", simulationConfig); } @Test public void eAc3jocSample() throws Exception { - ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample_eac3joc.ec3", simulationConfig); + ExtractorAsserts.assertBehavior( + Ac3Extractor::new, "media/ts/sample_eac3joc.ec3", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java index c95ec27dad..23b066088a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/Ac4ExtractorTest.java @@ -32,11 +32,10 @@ public final class Ac4ExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void ac4Sample() throws Exception { - ExtractorAsserts.assertBehavior(Ac4Extractor::new, "ts/sample.ac4", simulationConfig); + ExtractorAsserts.assertBehavior(Ac4Extractor::new, "media/ts/sample.ac4", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java index 5226aa71e9..2770d4ef66 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java @@ -39,7 +39,7 @@ public final class AdtsExtractorSeekTest { private static final Random random = new Random(1234L); - private static final String TEST_FILE = "ts/sample.adts"; + private static final String TEST_FILE = "media/ts/sample.adts"; private static final int FILE_DURATION_US = 3_356_772; private static final long DELTA_TIMESTAMP_THRESHOLD_US = 200_000; @@ -49,7 +49,7 @@ public final class AdtsExtractorSeekTest { @Before public void setUp() { dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java index 593180797d..e8bc727222 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java @@ -32,25 +32,24 @@ public final class AdtsExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sample() throws Exception { - ExtractorAsserts.assertBehavior(AdtsExtractor::new, "ts/sample.adts", simulationConfig); + ExtractorAsserts.assertBehavior(AdtsExtractor::new, "media/ts/sample.adts", simulationConfig); } @Test public void sample_with_id3() throws Exception { ExtractorAsserts.assertBehavior( - AdtsExtractor::new, "ts/sample_with_id3.adts", simulationConfig); + AdtsExtractor::new, "media/ts/sample_with_id3.adts", simulationConfig); } @Test public void sample_withSeeking() throws Exception { ExtractorAsserts.assertBehavior( () -> new AdtsExtractor(/* flags= */ AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING), - "ts/sample_cbs.adts", + "media/ts/sample_cbs.adts", simulationConfig); } @@ -59,7 +58,7 @@ public final class AdtsExtractorTest { public void sample_withSeekingAndTruncatedFile() throws Exception { ExtractorAsserts.assertBehavior( () -> new AdtsExtractor(/* flags= */ AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING), - "ts/sample_cbs_truncated.adts", + "media/ts/sample_cbs_truncated.adts", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java index c04c7224f9..6869c0314c 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsReaderTest.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.extractor.ts; import static com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; +import static java.lang.Math.min; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -25,6 +26,7 @@ import com.google.android.exoplayer2.testutil.FakeExtractorOutput; import com.google.android.exoplayer2.testutil.FakeTrackOutput; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.primitives.Bytes; import java.util.Arrays; import org.junit.Before; import org.junit.Test; @@ -57,7 +59,7 @@ public class AdtsReaderTest { TestUtil.createByteArray(0x20, 0x00, 0x20, 0x00, 0x00, 0x80, 0x0e); private static final byte[] TEST_DATA = - TestUtil.joinByteArrays(ID3_DATA_1, ID3_DATA_2, ADTS_HEADER, ADTS_CONTENT); + Bytes.concat(ID3_DATA_1, ID3_DATA_2, ADTS_HEADER, ADTS_CONTENT); private static final long ADTS_SAMPLE_DURATION = 23219L; @@ -85,7 +87,7 @@ public class AdtsReaderTest { data.setPosition(i); feed(); // Once the data position set to ID3_DATA_1.length, no more id3 samples are read - int id3SampleCount = Math.min(i, ID3_DATA_1.length); + int id3SampleCount = min(i, ID3_DATA_1.length); assertSampleCounts(id3SampleCount, i); } } @@ -94,7 +96,7 @@ public class AdtsReaderTest { public void skipToNextSampleResetsState() throws Exception { data = new ParsableByteArray( - TestUtil.joinByteArrays( + Bytes.concat( ADTS_HEADER, ADTS_CONTENT, ADTS_HEADER, diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsDurationReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsDurationReaderTest.java index 728a164b11..8c1805c568 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsDurationReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsDurationReaderTest.java @@ -52,7 +52,8 @@ public final class PsDurationReaderTest { new FakeExtractorInput.Builder() .setData( TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "ts/sample_h262_mpeg_audio.ps")) + ApplicationProvider.getApplicationContext(), + "media/ts/sample_h262_mpeg_audio.ps")) .build(); int result = Extractor.RESULT_CONTINUE; @@ -72,7 +73,8 @@ public final class PsDurationReaderTest { new FakeExtractorInput.Builder() .setData( TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "ts/sample_h262_mpeg_audio.ps")) + ApplicationProvider.getApplicationContext(), + "media/ts/sample_h262_mpeg_audio.ps")) .build(); input.setPosition(1234); diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java index b5eb3a5e88..d2d76d6695 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java @@ -46,7 +46,7 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class PsExtractorSeekTest { - private static final String PS_FILE_PATH = "ts/elephants_dream.mpg"; + private static final String PS_FILE_PATH = "media/ts/elephants_dream.mpg"; private static final int DURATION_US = 30436333; private static final int VIDEO_TRACK_ID = 224; private static final long DELTA_TIMESTAMP_THRESHOLD_US = 500_000L; @@ -68,7 +68,7 @@ public final class PsExtractorSeekTest { expectedTrackOutput = expectedOutput.trackOutputs.get(VIDEO_TRACK_ID); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); totalInputLength = readInputLength(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java index 3425221775..a7bd75a56c 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java @@ -32,17 +32,16 @@ public final class PsExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sampleWithH262AndMpegAudio() throws Exception { ExtractorAsserts.assertBehavior( - PsExtractor::new, "ts/sample_h262_mpeg_audio.ps", simulationConfig); + PsExtractor::new, "media/ts/sample_h262_mpeg_audio.ps", simulationConfig); } @Test public void sampleWithAc3() throws Exception { - ExtractorAsserts.assertBehavior(PsExtractor::new, "ts/sample_ac3.ps", simulationConfig); + ExtractorAsserts.assertBehavior(PsExtractor::new, "media/ts/sample_ac3.ps", simulationConfig); } } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java index 7a1a49d712..8f744e855d 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsDurationReaderTest.java @@ -52,7 +52,7 @@ public final class TsDurationReaderTest { new FakeExtractorInput.Builder() .setData( TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "ts/bbb_2500ms.ts")) + ApplicationProvider.getApplicationContext(), "media/ts/bbb_2500ms.ts")) .setSimulateIOErrors(false) .setSimulateUnknownLength(false) .setSimulatePartialReads(false) @@ -76,7 +76,7 @@ public final class TsDurationReaderTest { new FakeExtractorInput.Builder() .setData( TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "ts/bbb_2500ms.ts")) + ApplicationProvider.getApplicationContext(), "media/ts/bbb_2500ms.ts")) .setSimulateIOErrors(false) .setSimulateUnknownLength(false) .setSimulatePartialReads(false) diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java index 42e0acecd4..a796f3c994 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java @@ -41,7 +41,7 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class TsExtractorSeekTest { - private static final String TEST_FILE = "ts/bbb_2500ms.ts"; + private static final String TEST_FILE = "media/ts/bbb_2500ms.ts"; private static final int DURATION_US = 2_500_000; private static final int AUDIO_TRACK_ID = 257; private static final long MAXIMUM_TIMESTAMP_DELTA_US = 500_000L; @@ -62,7 +62,7 @@ public final class TsExtractorSeekTest { .get(AUDIO_TRACK_ID); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java index 18b6978967..c2fe39285f 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java @@ -52,26 +52,30 @@ public final class TsExtractorTest { return ExtractorAsserts.configs(); } - @Parameter(0) - public ExtractorAsserts.SimulationConfig simulationConfig; + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; @Test public void sampleWithH262AndMpegAudio() throws Exception { ExtractorAsserts.assertBehavior( - TsExtractor::new, "ts/sample_h262_mpeg_audio.ts", simulationConfig); + TsExtractor::new, "media/ts/sample_h262_mpeg_audio.ts", simulationConfig); + } + + @Test + public void sampleWithH263() throws Exception { + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_h263.ts", simulationConfig); } @Test public void sampleWithH264AndMpegAudio() throws Exception { ExtractorAsserts.assertBehavior( - TsExtractor::new, "ts/sample_h264_mpeg_audio.ts", simulationConfig); + TsExtractor::new, "media/ts/sample_h264_mpeg_audio.ts", simulationConfig); } @Test public void sampleWithH264NoAccessUnitDelimiters() throws Exception { ExtractorAsserts.assertBehavior( () -> new TsExtractor(FLAG_DETECT_ACCESS_UNITS), - "ts/sample_h264_no_access_unit_delimiters.ts", + "media/ts/sample_h264_no_access_unit_delimiters.ts", simulationConfig); } @@ -79,20 +83,20 @@ public final class TsExtractorTest { public void sampleWithH264AndDtsAudio() throws Exception { ExtractorAsserts.assertBehavior( () -> new TsExtractor(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS), - "ts/sample_h264_dts_audio.ts", + "media/ts/sample_h264_dts_audio.ts", simulationConfig); } @Test public void sampleWithH265() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_h265.ts", simulationConfig); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_h265.ts", simulationConfig); } @Test public void sampleWithScte35() throws Exception { ExtractorAsserts.assertBehavior( TsExtractor::new, - "ts/sample_scte35.ts", + "media/ts/sample_scte35.ts", new ExtractorAsserts.AssertionConfig.Builder() .setDeduplicateConsecutiveFormats(true) .build(), @@ -103,7 +107,7 @@ public final class TsExtractorTest { public void sampleWithAit() throws Exception { ExtractorAsserts.assertBehavior( TsExtractor::new, - "ts/sample_ait.ts", + "media/ts/sample_ait.ts", new ExtractorAsserts.AssertionConfig.Builder() .setDeduplicateConsecutiveFormats(true) .build(), @@ -112,32 +116,34 @@ public final class TsExtractorTest { @Test public void sampleWithAc3() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac3.ts", simulationConfig); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_ac3.ts", simulationConfig); } @Test public void sampleWithAc4() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac4.ts", simulationConfig); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_ac4.ts", simulationConfig); } @Test public void sampleWithEac3() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3.ts", simulationConfig); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_eac3.ts", simulationConfig); } @Test public void sampleWithEac3joc() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3joc.ts", simulationConfig); + ExtractorAsserts.assertBehavior( + TsExtractor::new, "media/ts/sample_eac3joc.ts", simulationConfig); } @Test public void sampleWithLatm() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_latm.ts", simulationConfig); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_latm.ts", simulationConfig); } @Test public void streamWithJunkData() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_with_junk", simulationConfig); + ExtractorAsserts.assertBehavior( + TsExtractor::new, "media/ts/sample_with_junk", simulationConfig); } @Test @@ -149,7 +155,8 @@ public final class TsExtractorTest { new FakeExtractorInput.Builder() .setData( TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "ts/sample_h262_mpeg_audio.ts")) + ApplicationProvider.getApplicationContext(), + "media/ts/sample_h262_mpeg_audio.ts")) .setSimulateIOErrors(false) .setSimulateUnknownLength(false) .setSimulatePartialReads(false) @@ -186,7 +193,7 @@ public final class TsExtractorTest { new FakeExtractorInput.Builder() .setData( TestUtil.getByteArray( - ApplicationProvider.getApplicationContext(), "ts/sample_with_sdt.ts")) + ApplicationProvider.getApplicationContext(), "media/ts/sample_with_sdt.ts")) .setSimulateIOErrors(false) .setSimulateUnknownLength(false) .setSimulatePartialReads(false) diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java index 07586e3af8..b411e7517a 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/wav/WavExtractorTest.java @@ -36,21 +36,21 @@ public final class WavExtractorTest { @Test public void sample() throws Exception { - ExtractorAsserts.assertBehavior(WavExtractor::new, "wav/sample.wav", simulationConfig); + ExtractorAsserts.assertBehavior(WavExtractor::new, "media/wav/sample.wav", simulationConfig); } @Test public void sample_withTrailingBytes_extractsSameData() throws Exception { ExtractorAsserts.assertBehavior( WavExtractor::new, - "wav/sample_with_trailing_bytes.wav", - new AssertionConfig.Builder().setDumpFilesPrefix("wav/sample.wav").build(), + "media/wav/sample_with_trailing_bytes.wav", + new AssertionConfig.Builder().setDumpFilesPrefix("extractordumps/wav/sample.wav").build(), simulationConfig); } @Test public void sample_imaAdpcm() throws Exception { ExtractorAsserts.assertBehavior( - WavExtractor::new, "wav/sample_ima_adpcm.wav", simulationConfig); + WavExtractor::new, "media/wav/sample_ima_adpcm.wav", simulationConfig); } } diff --git a/library/hls/README.md b/library/hls/README.md index 3470c29e3c..b7eecc1ff8 100644 --- a/library/hls/README.md +++ b/library/hls/README.md @@ -1,7 +1,20 @@ # ExoPlayer HLS library module # -Provides support for HTTP Live Streaming (HLS) content. To play HLS content, -instantiate a `HlsMediaSource` and pass it to `ExoPlayer.prepare`. +Provides support for HTTP Live Streaming (HLS) content. + +Adding a dependency to this module is all that's required to enable playback of +HLS `MediaItem`s added to an `ExoPlayer` or `SimpleExoPlayer` in their default +configurations. Internally, `DefaultMediaSourceFactory` will automatically +detect the presence of the module and convert HLS `MediaItem`s into +`HlsMediaSource` instances for playback. + +Similarly, a `DownloadManager` in its default configuration will use +`DefaultDownloaderFactory`, which will automatically detect the presence of +the module and build `HlsDownloader` instances to download HLS content. + +For advanced playback use cases, applications can build `HlsMediaSource` +instances and pass them directly to the player. For advanced download use cases, +`HlsDownloader` can be used directly. ## Links ## diff --git a/library/hls/build.gradle b/library/hls/build.gradle index 4764cf9882..df3b6d3586 100644 --- a/library/hls/build.gradle +++ b/library/hls/build.gradle @@ -11,38 +11,33 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true } } - testOptions.unitTests.includeAndroidResources = true + sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' } dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') + testImplementation project(modulePrefix + 'testdata') testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java index fe70298dc8..11d68b1c08 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/Aes128DataSource.java @@ -66,6 +66,7 @@ import javax.crypto.spec.SecretKeySpec; @Override public final void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java new file mode 100644 index 0000000000..78fc9ae732 --- /dev/null +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/BundledHlsMediaChunkExtractor.java @@ -0,0 +1,104 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import com.google.android.exoplayer2.extractor.ts.Ac4Extractor; +import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; + +/** + * {@link HlsMediaChunkExtractor} implementation that uses ExoPlayer app-bundled {@link Extractor + * Extractors}. + */ +public final class BundledHlsMediaChunkExtractor implements HlsMediaChunkExtractor { + + private static final PositionHolder POSITION_HOLDER = new PositionHolder(); + + @VisibleForTesting /* package */ final Extractor extractor; + private final Format masterPlaylistFormat; + private final TimestampAdjuster timestampAdjuster; + + /** + * Creates a new instance. + * + * @param extractor The underlying {@link Extractor}. + * @param masterPlaylistFormat The {@link Format} obtained from the master playlist. + * @param timestampAdjuster A {@link TimestampAdjuster} to adjust sample timestamps. + */ + public BundledHlsMediaChunkExtractor( + Extractor extractor, Format masterPlaylistFormat, TimestampAdjuster timestampAdjuster) { + this.extractor = extractor; + this.masterPlaylistFormat = masterPlaylistFormat; + this.timestampAdjuster = timestampAdjuster; + } + + @Override + public void init(ExtractorOutput extractorOutput) { + extractor.init(extractorOutput); + } + + @Override + public boolean read(ExtractorInput extractorInput) throws IOException { + return extractor.read(extractorInput, POSITION_HOLDER) == Extractor.RESULT_CONTINUE; + } + + @Override + public boolean isPackedAudioExtractor() { + return extractor instanceof AdtsExtractor + || extractor instanceof Ac3Extractor + || extractor instanceof Ac4Extractor + || extractor instanceof Mp3Extractor; + } + + @Override + public boolean isReusable() { + return extractor instanceof TsExtractor || extractor instanceof FragmentedMp4Extractor; + } + + @Override + public HlsMediaChunkExtractor recreate() { + Assertions.checkState(!isReusable()); + Extractor newExtractorInstance; + if (extractor instanceof WebvttExtractor) { + newExtractorInstance = new WebvttExtractor(masterPlaylistFormat.language, timestampAdjuster); + } else if (extractor instanceof AdtsExtractor) { + newExtractorInstance = new AdtsExtractor(); + } else if (extractor instanceof Ac3Extractor) { + newExtractorInstance = new Ac3Extractor(); + } else if (extractor instanceof Ac4Extractor) { + newExtractorInstance = new Ac4Extractor(); + } else if (extractor instanceof Mp3Extractor) { + newExtractorInstance = new Mp3Extractor(); + } else { + throw new IllegalStateException( + "Unexpected extractor type for recreation: " + extractor.getClass().getSimpleName()); + } + return new BundledHlsMediaChunkExtractor( + newExtractorInstance, masterPlaylistFormat, timestampAdjuster); + } +} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java index 2ba2cd83af..0a9ead7c48 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; import android.text.TextUtils; import androidx.annotation.Nullable; @@ -29,11 +31,12 @@ import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; import com.google.android.exoplayer2.extractor.ts.TsExtractor; import com.google.android.exoplayer2.metadata.Metadata; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.FileTypes; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.io.EOFException; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -43,19 +46,18 @@ import java.util.Map; */ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { - public static final String AAC_FILE_EXTENSION = ".aac"; - public static final String AC3_FILE_EXTENSION = ".ac3"; - public static final String EC3_FILE_EXTENSION = ".ec3"; - public static final String AC4_FILE_EXTENSION = ".ac4"; - public static final String MP3_FILE_EXTENSION = ".mp3"; - public static final String MP4_FILE_EXTENSION = ".mp4"; - public static final String M4_FILE_EXTENSION_PREFIX = ".m4"; - public static final String MP4_FILE_EXTENSION_PREFIX = ".mp4"; - public static final String CMF_FILE_EXTENSION_PREFIX = ".cmf"; - public static final String TS_FILE_EXTENSION = ".ts"; - public static final String TS_FILE_EXTENSION_PREFIX = ".ts"; - public static final String VTT_FILE_EXTENSION = ".vtt"; - public static final String WEBVTT_FILE_EXTENSION = ".webvtt"; + // Extractors order is optimized according to + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. + private static final int[] DEFAULT_EXTRACTOR_ORDER = + new int[] { + FileTypes.MP4, + FileTypes.WEBVTT, + FileTypes.TS, + FileTypes.ADTS, + FileTypes.AC3, + FileTypes.AC4, + FileTypes.MP3, + }; @DefaultTsPayloadReaderFactory.Flags private final int payloadReaderFactoryFlags; private final boolean exposeCea608WhenMissingDeclarations; @@ -86,8 +88,7 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } @Override - public Result createExtractor( - @Nullable Extractor previousExtractor, + public BundledHlsMediaChunkExtractor createExtractor( Uri uri, Format format, @Nullable List muxedCaptionFormats, @@ -95,139 +96,79 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { Map> responseHeaders, ExtractorInput extractorInput) throws IOException { + @FileTypes.Type + int formatInferredFileType = FileTypes.inferFileTypeFromMimeType(format.sampleMimeType); + @FileTypes.Type + int responseHeadersInferredFileType = + FileTypes.inferFileTypeFromResponseHeaders(responseHeaders); + @FileTypes.Type int uriInferredFileType = FileTypes.inferFileTypeFromUri(uri); - if (previousExtractor != null) { - // An extractor has already been successfully used. Return one of the same type. - if (isReusable(previousExtractor)) { - return buildResult(previousExtractor); - } else { - Result result = - buildResultForSameExtractorType(previousExtractor, format, timestampAdjuster); - if (result == null) { - throw new IllegalArgumentException( - "Unexpected previousExtractor type: " + previousExtractor.getClass().getSimpleName()); - } - } + // Defines the order in which to try the extractors. + List fileTypeOrder = + new ArrayList<>(/* initialCapacity= */ DEFAULT_EXTRACTOR_ORDER.length); + addFileTypeIfNotPresent(formatInferredFileType, fileTypeOrder); + addFileTypeIfNotPresent(responseHeadersInferredFileType, fileTypeOrder); + addFileTypeIfNotPresent(uriInferredFileType, fileTypeOrder); + for (int fileType : DEFAULT_EXTRACTOR_ORDER) { + addFileTypeIfNotPresent(fileType, fileTypeOrder); } - // Try selecting the extractor by the file extension. - @Nullable - Extractor extractorByFileExtension = - createExtractorByFileExtension(uri, format, muxedCaptionFormats, timestampAdjuster); - extractorInput.resetPeekPosition(); - if (extractorByFileExtension != null - && sniffQuietly(extractorByFileExtension, extractorInput)) { - return buildResult(extractorByFileExtension); - } - - // We need to manually sniff each known type, without retrying the one selected by file - // extension. Extractors order is optimized according to - // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. - // Extractor to be used if the type is not recognized. - @Nullable Extractor fallBackExtractor = extractorByFileExtension; - - if (!(extractorByFileExtension instanceof FragmentedMp4Extractor)) { - FragmentedMp4Extractor fragmentedMp4Extractor = - createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); - if (sniffQuietly(fragmentedMp4Extractor, extractorInput)) { - return buildResult(fragmentedMp4Extractor); + @Nullable Extractor fallBackExtractor = null; + extractorInput.resetPeekPosition(); + for (int i = 0; i < fileTypeOrder.size(); i++) { + int fileType = fileTypeOrder.get(i); + Extractor extractor = + checkNotNull( + createExtractorByFileType(fileType, format, muxedCaptionFormats, timestampAdjuster)); + if (sniffQuietly(extractor, extractorInput)) { + return new BundledHlsMediaChunkExtractor(extractor, format, timestampAdjuster); + } + if (fileType == FileTypes.TS) { + fallBackExtractor = extractor; } } - if (!(extractorByFileExtension instanceof WebvttExtractor)) { - WebvttExtractor webvttExtractor = new WebvttExtractor(format.language, timestampAdjuster); - if (sniffQuietly(webvttExtractor, extractorInput)) { - return buildResult(webvttExtractor); - } - } + return new BundledHlsMediaChunkExtractor( + checkNotNull(fallBackExtractor), format, timestampAdjuster); + } - if (!(extractorByFileExtension instanceof TsExtractor)) { - TsExtractor tsExtractor = - createTsExtractor( - payloadReaderFactoryFlags, - exposeCea608WhenMissingDeclarations, - format, - muxedCaptionFormats, - timestampAdjuster); - if (sniffQuietly(tsExtractor, extractorInput)) { - return buildResult(tsExtractor); - } - if (fallBackExtractor == null) { - fallBackExtractor = tsExtractor; - } + private static void addFileTypeIfNotPresent( + @FileTypes.Type int fileType, List fileTypes) { + if (fileType == FileTypes.UNKNOWN || fileTypes.contains(fileType)) { + return; } - - if (!(extractorByFileExtension instanceof AdtsExtractor)) { - AdtsExtractor adtsExtractor = new AdtsExtractor(); - if (sniffQuietly(adtsExtractor, extractorInput)) { - return buildResult(adtsExtractor); - } - } - - if (!(extractorByFileExtension instanceof Ac3Extractor)) { - Ac3Extractor ac3Extractor = new Ac3Extractor(); - if (sniffQuietly(ac3Extractor, extractorInput)) { - return buildResult(ac3Extractor); - } - } - - if (!(extractorByFileExtension instanceof Ac4Extractor)) { - Ac4Extractor ac4Extractor = new Ac4Extractor(); - if (sniffQuietly(ac4Extractor, extractorInput)) { - return buildResult(ac4Extractor); - } - } - - if (!(extractorByFileExtension instanceof Mp3Extractor)) { - Mp3Extractor mp3Extractor = - new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); - if (sniffQuietly(mp3Extractor, extractorInput)) { - return buildResult(mp3Extractor); - } - } - - return buildResult(Assertions.checkNotNull(fallBackExtractor)); + fileTypes.add(fileType); } @Nullable - private Extractor createExtractorByFileExtension( - Uri uri, + private Extractor createExtractorByFileType( + @FileTypes.Type int fileType, Format format, @Nullable List muxedCaptionFormats, TimestampAdjuster timestampAdjuster) { - String lastPathSegment = uri.getLastPathSegment(); - if (lastPathSegment == null) { - lastPathSegment = ""; - } - if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType) - || lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) - || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { - return new WebvttExtractor(format.language, timestampAdjuster); - } else if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { - return new AdtsExtractor(); - } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) - || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { - return new Ac3Extractor(); - } else if (lastPathSegment.endsWith(AC4_FILE_EXTENSION)) { - return new Ac4Extractor(); - } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { - return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); - } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION) - || lastPathSegment.startsWith(M4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 4) - || lastPathSegment.startsWith(MP4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5) - || lastPathSegment.startsWith(CMF_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5)) { - return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); - } else if (lastPathSegment.endsWith(TS_FILE_EXTENSION) - || lastPathSegment.startsWith(TS_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 4)) { - return createTsExtractor( - payloadReaderFactoryFlags, - exposeCea608WhenMissingDeclarations, - format, - muxedCaptionFormats, - timestampAdjuster); - } else { - return null; + switch (fileType) { + case FileTypes.WEBVTT: + return new WebvttExtractor(format.language, timestampAdjuster); + case FileTypes.ADTS: + return new AdtsExtractor(); + case FileTypes.AC3: + return new Ac3Extractor(); + case FileTypes.AC4: + return new Ac4Extractor(); + case FileTypes.MP3: + return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); + case FileTypes.MP4: + return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); + case FileTypes.TS: + return createTsExtractor( + payloadReaderFactoryFlags, + exposeCea608WhenMissingDeclarations, + format, + muxedCaptionFormats, + timestampAdjuster); + default: + return null; } } @@ -300,34 +241,6 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { return false; } - @Nullable - private static Result buildResultForSameExtractorType( - Extractor previousExtractor, Format format, TimestampAdjuster timestampAdjuster) { - if (previousExtractor instanceof WebvttExtractor) { - return buildResult(new WebvttExtractor(format.language, timestampAdjuster)); - } else if (previousExtractor instanceof AdtsExtractor) { - return buildResult(new AdtsExtractor()); - } else if (previousExtractor instanceof Ac3Extractor) { - return buildResult(new Ac3Extractor()); - } else if (previousExtractor instanceof Ac4Extractor) { - return buildResult(new Ac4Extractor()); - } else if (previousExtractor instanceof Mp3Extractor) { - return buildResult(new Mp3Extractor()); - } else { - return null; - } - } - - private static Result buildResult(Extractor extractor) { - return new Result( - extractor, - extractor instanceof AdtsExtractor - || extractor instanceof Ac3Extractor - || extractor instanceof Ac4Extractor - || extractor instanceof Mp3Extractor, - isReusable(extractor)); - } - private static boolean sniffQuietly(Extractor extractor, ExtractorInput input) throws IOException { boolean result = false; @@ -340,9 +253,4 @@ public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { } return result; } - - private static boolean isReusable(Extractor previousExtractor) { - return previousExtractor instanceof TsExtractor - || previousExtractor instanceof FragmentedMp4Extractor; - } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index c269691d77..530d56fa9c 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls; +import static java.lang.Math.max; + import android.net.Uri; import android.os.SystemClock; import androidx.annotation.Nullable; @@ -39,7 +41,9 @@ import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.TimestampAdjuster; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -149,11 +153,15 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } encryptionDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_DRM); trackGroup = new TrackGroup(playlistFormats); - int[] initialTrackSelection = new int[playlistUrls.length]; + // Use only non-trickplay variants for preparation. See [Internal ref: b/161529098]. + ArrayList initialTrackSelection = new ArrayList<>(); for (int i = 0; i < playlistUrls.length; i++) { - initialTrackSelection[i] = i; + if ((playlistFormats[i].roleFlags & C.ROLE_FLAG_TRICK_PLAY) == 0) { + initialTrackSelection.add(i); + } } - trackSelection = new InitializationTrackSelection(trackGroup, initialTrackSelection); + trackSelection = + new InitializationTrackSelection(trackGroup, Ints.toArray(initialTrackSelection)); } /** @@ -246,9 +254,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // buffered duration to time-to-live-edge to decide whether to switch. Therefore, we subtract // the duration of the last loaded segment from timeToLiveEdgeUs as well. long subtractedDurationUs = previous.getDurationUs(); - bufferedDurationUs = Math.max(0, bufferedDurationUs - subtractedDurationUs); + bufferedDurationUs = max(0, bufferedDurationUs - subtractedDurationUs); if (timeToLiveEdgeUs != C.TIME_UNSET) { - timeToLiveEdgeUs = Math.max(0, timeToLiveEdgeUs - subtractedDurationUs); + timeToLiveEdgeUs = max(0, timeToLiveEdgeUs - subtractedDurationUs); } } @@ -371,28 +379,28 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } /** - * Attempts to blacklist the track associated with the given chunk. Blacklisting will fail if the - * track is the only non-blacklisted track in the selection. + * Attempts to exclude the track associated with the given chunk. Exclusion will fail if the track + * is the only non-excluded track in the selection. * - * @param chunk The chunk whose load caused the blacklisting attempt. - * @param blacklistDurationMs The number of milliseconds for which the track selection should be - * blacklisted. - * @return Whether the blacklisting succeeded. + * @param chunk The chunk whose load caused the exclusion attempt. + * @param exclusionDurationMs The number of milliseconds for which the track selection should be + * excluded. + * @return Whether the exclusion succeeded. */ - public boolean maybeBlacklistTrack(Chunk chunk, long blacklistDurationMs) { + public boolean maybeExcludeTrack(Chunk chunk, long exclusionDurationMs) { return trackSelection.blacklist( - trackSelection.indexOf(trackGroup.indexOf(chunk.trackFormat)), blacklistDurationMs); + trackSelection.indexOf(trackGroup.indexOf(chunk.trackFormat)), exclusionDurationMs); } /** * Called when a playlist load encounters an error. * * @param playlistUrl The {@link Uri} of the playlist whose load encountered an error. - * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or {@link - * C#TIME_UNSET} if the playlist should not be blacklisted. - * @return True if blacklisting did not encounter errors. False otherwise. + * @param exclusionDurationMs The duration for which the playlist should be excluded. Or {@link + * C#TIME_UNSET} if the playlist should not be excluded. + * @return True if excluding did not encounter errors. False otherwise. */ - public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + public boolean onPlaylistError(Uri playlistUrl, long exclusionDurationMs) { int trackGroupIndex = C.INDEX_UNSET; for (int i = 0; i < playlistUrls.length; i++) { if (playlistUrls[i].equals(playlistUrl)) { @@ -408,8 +416,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return true; } seenExpectedPlaylistError |= playlistUrl.equals(expectedPlaylistUrl); - return blacklistDurationMs == C.TIME_UNSET - || trackSelection.blacklist(trackSelectionIndex, blacklistDurationMs); + return exclusionDurationMs == C.TIME_UNSET + || trackSelection.blacklist(trackSelectionIndex, exclusionDurationMs); } /** @@ -451,6 +459,42 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return chunkIterators; } + /** + * Evaluates whether {@link MediaChunk MediaChunks} should be removed from the back of the queue. + * + *

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

      Will only be called if no {@link MediaChunk} in the queue is currently loading. + * + * @param playbackPositionUs The current playback position, in microseconds. + * @param queue The queue of buffered {@link MediaChunk MediaChunks}. + * @return The preferred queue size. + */ + public int getPreferredQueueSize(long playbackPositionUs, List queue) { + if (fatalError != null || trackSelection.length() < 2) { + return queue.size(); + } + return trackSelection.evaluateQueueSize(playbackPositionUs, queue); + } + + /** + * Returns whether an ongoing load of a chunk should be canceled. + * + * @param playbackPositionUs The current playback position, in microseconds. + * @param loadingChunk The currently loading {@link Chunk}. + * @param queue The queue of buffered {@link MediaChunk MediaChunks}. + * @return Whether the ongoing load of {@code loadingChunk} should be canceled. + */ + public boolean shouldCancelLoad( + long playbackPositionUs, Chunk loadingChunk, List queue) { + if (fatalError != null) { + return false; + } + return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, queue); + } + // Private methods. /** @@ -487,9 +531,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /* stayInBounds= */ !playlistTracker.isLive() || previous == null) + mediaPlaylist.mediaSequence; } - // We ignore the case of previous not having loaded completely, in which case we load the next - // segment. - return previous.getNextChunkIndex(); + return previous.isLoadCompleted() ? previous.getNextChunkIndex() : previous.chunkIndex; } private long resolveTimeToLiveEdgeUs(long playbackPositionUs) { diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java index eb3cf8bfcf..4fe78514cf 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java @@ -31,41 +31,11 @@ import java.util.Map; */ public interface HlsExtractorFactory { - /** Holds an {@link Extractor} and associated parameters. */ - final class Result { - - /** The created extractor; */ - public final Extractor extractor; - /** Whether the segments for which {@link #extractor} is created are packed audio segments. */ - public final boolean isPackedAudioExtractor; - /** - * Whether {@link #extractor} may be reused for following continuous (no immediately preceding - * discontinuities) segments of the same variant. - */ - public final boolean isReusable; - - /** - * Creates a result. - * - * @param extractor See {@link #extractor}. - * @param isPackedAudioExtractor See {@link #isPackedAudioExtractor}. - * @param isReusable See {@link #isReusable}. - */ - public Result(Extractor extractor, boolean isPackedAudioExtractor, boolean isReusable) { - this.extractor = extractor; - this.isPackedAudioExtractor = isPackedAudioExtractor; - this.isReusable = isReusable; - } - } - HlsExtractorFactory DEFAULT = new DefaultHlsExtractorFactory(); /** * Creates an {@link Extractor} for extracting HLS media chunks. * - * @param previousExtractor A previously used {@link Extractor} which can be reused if the current - * chunk is a continuation of the previously extracted chunk, or null otherwise. It is the - * responsibility of implementers to only reuse extractors that are suited for reusage. * @param uri The URI of the media chunk. * @param format A {@link Format} associated with the chunk to extract. * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption @@ -76,11 +46,10 @@ public interface HlsExtractorFactory { * @param sniffingExtractorInput The first extractor input that will be passed to the returned * extractor's {@link Extractor#read(ExtractorInput, PositionHolder)}. Must only be used to * call {@link Extractor#sniff(ExtractorInput)}. - * @return A {@link Result}. + * @return An {@link HlsMediaChunkExtractor}. * @throws IOException If an I/O error is encountered while sniffing. */ - Result createExtractor( - @Nullable Extractor previousExtractor, + HlsMediaChunkExtractor createExtractor( Uri uri, Format format, @Nullable List muxedCaptionFormats, diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 3a2285a444..9994ede1cf 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -21,9 +21,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; -import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; -import com.google.android.exoplayer2.extractor.PositionHolder; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.metadata.id3.PrivFrame; @@ -36,6 +34,7 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.TimestampAdjuster; import com.google.android.exoplayer2.util.UriUtil; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; import java.io.EOFException; import java.io.IOException; import java.io.InterruptedIOException; @@ -54,12 +53,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Creates a new instance. * - * @param extractorFactory A {@link HlsExtractorFactory} from which the HLS media chunk extractor - * is obtained. + * @param extractorFactory A {@link HlsExtractorFactory} from which the {@link + * HlsMediaChunkExtractor} is obtained. * @param dataSource The source from which the data should be loaded. * @param format The chunk format. * @param startOfPlaylistInPeriodUs The position of the playlist in the period in microseconds. * @param mediaPlaylist The media playlist from which this chunk was obtained. + * @param segmentIndexInPlaylist The index of the segment in the media playlist. * @param playlistUrl The url of the playlist from which this chunk was obtained. * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption * information is available in the master playlist. @@ -127,19 +127,24 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int discontinuitySequenceNumber = mediaPlaylist.discontinuitySequence + mediaSegment.relativeDiscontinuitySequence; - @Nullable Extractor previousExtractor = null; + @Nullable HlsMediaChunkExtractor previousExtractor = null; Id3Decoder id3Decoder; ParsableByteArray scratchId3Data; boolean shouldSpliceIn; if (previousChunk != null) { + boolean isFollowingChunk = + playlistUrl.equals(previousChunk.playlistUrl) && previousChunk.loadCompleted; id3Decoder = previousChunk.id3Decoder; scratchId3Data = previousChunk.scratchId3Data; - shouldSpliceIn = - !playlistUrl.equals(previousChunk.playlistUrl) || !previousChunk.loadCompleted; + boolean canContinueWithoutSplice = + isFollowingChunk + || (mediaPlaylist.hasIndependentSegments + && segmentStartTimeInPeriodUs >= previousChunk.endTimeUs); + shouldSpliceIn = !canContinueWithoutSplice; previousExtractor = - previousChunk.isExtractorReusable + isFollowingChunk + && !previousChunk.extractorInvalidated && previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber - && !shouldSpliceIn ? previousChunk.extractor : null; } else { @@ -177,7 +182,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; public static final String PRIV_TIMESTAMP_FRAME_OWNER = "com.apple.streaming.transportStreamTimestamp"; - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); private static final AtomicInteger uidSource = new AtomicInteger(); @@ -194,12 +198,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** The url of the playlist from which this chunk was obtained. */ public final Uri playlistUrl; - /** Whether the samples parsed from this chunk should be spliced into already queued samples. */ + /** Whether samples for this chunk should be spliced into existing samples. */ public final boolean shouldSpliceIn; @Nullable private final DataSource initDataSource; @Nullable private final DataSpec initDataSpec; - @Nullable private final Extractor previousExtractor; + @Nullable private final HlsMediaChunkExtractor previousExtractor; private final boolean isMasterTimestampSource; private final boolean hasGapTag; @@ -212,8 +216,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final boolean mediaSegmentEncrypted; private final boolean initSegmentEncrypted; - private @MonotonicNonNull Extractor extractor; - private boolean isExtractorReusable; + private @MonotonicNonNull HlsMediaChunkExtractor extractor; private @MonotonicNonNull HlsSampleStreamWrapper output; // nextLoadPosition refers to the init segment if initDataLoadRequired is true. // Otherwise, nextLoadPosition refers to the media segment. @@ -221,6 +224,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private boolean initDataLoadRequired; private volatile boolean loadCanceled; private boolean loadCompleted; + private ImmutableList sampleQueueFirstSampleIndices; + private boolean extractorInvalidated; private HlsMediaChunk( HlsExtractorFactory extractorFactory, @@ -243,7 +248,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; boolean isMasterTimestampSource, TimestampAdjuster timestampAdjuster, @Nullable DrmInitData drmInitData, - @Nullable Extractor previousExtractor, + @Nullable HlsMediaChunkExtractor previousExtractor, Id3Decoder id3Decoder, ParsableByteArray scratchId3Data, boolean shouldSpliceIn) { @@ -273,17 +278,42 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.id3Decoder = id3Decoder; this.scratchId3Data = scratchId3Data; this.shouldSpliceIn = shouldSpliceIn; + sampleQueueFirstSampleIndices = ImmutableList.of(); uid = uidSource.getAndIncrement(); } /** - * Initializes the chunk for loading, setting the {@link HlsSampleStreamWrapper} that will receive - * samples as they are loaded. + * Initializes the chunk for loading. * - * @param output The output that will receive the loaded samples. + * @param output The {@link HlsSampleStreamWrapper} that will receive the loaded samples. + * @param sampleQueueWriteIndices The current write indices in the existing sample queues of the + * output. */ - public void init(HlsSampleStreamWrapper output) { + public void init(HlsSampleStreamWrapper output, ImmutableList sampleQueueWriteIndices) { this.output = output; + this.sampleQueueFirstSampleIndices = sampleQueueWriteIndices; + } + + /** + * Returns the first sample index of this chunk in the specified sample queue in the output. + * + *

      Must not be used if {@link #shouldSpliceIn} is true. + * + * @param sampleQueueIndex The index of the sample queue in the output. + * @return The first sample index of this chunk in the specified sample queue. + */ + public int getFirstSampleIndex(int sampleQueueIndex) { + Assertions.checkState(!shouldSpliceIn); + if (sampleQueueIndex >= sampleQueueFirstSampleIndices.size()) { + // The sample queue was created by this chunk or a later chunk. + return 0; + } + return sampleQueueFirstSampleIndices.get(sampleQueueIndex); + } + + /** Prevents the extractor from being reused by a following media chunk. */ + public void invalidateExtractor() { + extractorInvalidated = true; } @Override @@ -302,9 +332,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; public void load() throws IOException { // output == null means init() hasn't been called. Assertions.checkNotNull(output); - if (extractor == null && previousExtractor != null) { + if (extractor == null && previousExtractor != null && previousExtractor.isReusable()) { extractor = previousExtractor; - isExtractorReusable = true; initDataLoadRequired = false; } maybeLoadInitData(); @@ -312,7 +341,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (!hasGapTag) { loadMedia(); } - loadCompleted = true; + loadCompleted = !loadCanceled; } } @@ -373,10 +402,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; input.skipFully(nextLoadPosition); } try { - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, DUMMY_POSITION_HOLDER); - } + while (!loadCanceled && extractor.read(input)) {} } finally { nextLoadPosition = (int) (input.getPosition() - dataSpec.position); } @@ -397,18 +423,17 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; long id3Timestamp = peekId3PrivTimestamp(extractorInput); extractorInput.resetPeekPosition(); - HlsExtractorFactory.Result result = - extractorFactory.createExtractor( - previousExtractor, - dataSpec.uri, - trackFormat, - muxedCaptionFormats, - timestampAdjuster, - dataSource.getResponseHeaders(), - extractorInput); - extractor = result.extractor; - isExtractorReusable = result.isReusable; - if (result.isPackedAudioExtractor) { + extractor = + previousExtractor != null + ? previousExtractor.recreate() + : extractorFactory.createExtractor( + dataSpec.uri, + trackFormat, + muxedCaptionFormats, + timestampAdjuster, + dataSource.getResponseHeaders(), + extractorInput); + if (extractor.isPackedAudioExtractor()) { output.setSampleOffsetUs( id3Timestamp != C.TIME_UNSET ? timestampAdjuster.adjustTsTimestamp(id3Timestamp) @@ -437,7 +462,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private long peekId3PrivTimestamp(ExtractorInput input) throws IOException { input.resetPeekPosition(); try { - input.peekFully(scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + input.peekFully(scratchId3Data.getData(), 0, Id3Decoder.ID3_HEADER_LENGTH); } catch (EOFException e) { // The input isn't long enough for there to be any ID3 data. return C.TIME_UNSET; @@ -451,12 +476,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int id3Size = scratchId3Data.readSynchSafeInt(); int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH; if (requiredCapacity > scratchId3Data.capacity()) { - byte[] data = scratchId3Data.data; + byte[] data = scratchId3Data.getData(); scratchId3Data.reset(requiredCapacity); - System.arraycopy(data, 0, scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + System.arraycopy(data, 0, scratchId3Data.getData(), 0, Id3Decoder.ID3_HEADER_LENGTH); } - input.peekFully(scratchId3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size); - Metadata metadata = id3Decoder.decode(scratchId3Data.data, id3Size); + input.peekFully(scratchId3Data.getData(), Id3Decoder.ID3_HEADER_LENGTH, id3Size); + Metadata metadata = id3Decoder.decode(scratchId3Data.getData(), id3Size); if (metadata == null) { return C.TIME_UNSET; } @@ -467,7 +492,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; PrivFrame privFrame = (PrivFrame) frame; if (PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) { System.arraycopy( - privFrame.privateData, 0, scratchId3Data.data, 0, 8 /* timestamp size */); + privFrame.privateData, 0, scratchId3Data.getData(), 0, 8 /* timestamp size */); scratchId3Data.reset(8); // The top 31 bits should be zeros, but explicitly zero them to wrap in the case that the // streaming provider forgot. See: https://github.com/google/ExoPlayer/pull/3495. diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java new file mode 100644 index 0000000000..0ca5c5d0ad --- /dev/null +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunkExtractor.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import java.io.IOException; + +/** Extracts samples and track {@link Format Formats} from {@link HlsMediaChunk HlsMediaChunks}. */ +public interface HlsMediaChunkExtractor { + + /** + * Initializes the extractor with an {@link ExtractorOutput}. Called at most once. + * + * @param extractorOutput An {@link ExtractorOutput} to receive extracted data. + */ + void init(ExtractorOutput extractorOutput); + + /** + * Extracts data read from a provided {@link ExtractorInput}. Must not be called before {@link + * #init(ExtractorOutput)}. + * + *

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

      When this method throws an {@link IOException}, extraction may continue by providing an + * {@link ExtractorInput} with an unchanged {@link ExtractorInput#getPosition() read position} to + * a subsequent call to this method. + * + * @param extractorInput The input to read from. + * @return Whether there is any data left to extract. Returns false if the end of input has been + * reached. + * @throws IOException If an error occurred reading from or parsing the input. + */ + boolean read(ExtractorInput extractorInput) throws IOException; + + /** Returns whether this is a packed audio extractor, as defined in RFC 8216, Section 3.4. */ + boolean isPackedAudioExtractor(); + + /** Returns whether this instance can be used for extracting multiple continuous segments. */ + boolean isReusable(); + + /** + * Returns a new instance for extracting the same type of media as this one. Can only be called on + * instances that are not {@link #isReusable() reusable}. + */ + HlsMediaChunkExtractor recreate(); +} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index b6985a836c..5e0709228d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.metadata.Metadata; @@ -46,6 +47,7 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; +import com.google.common.primitives.Ints; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -68,6 +70,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper private final HlsDataSourceFactory dataSourceFactory; @Nullable private final TransferListener mediaTransferListener; private final DrmSessionManager drmSessionManager; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final EventDispatcher eventDispatcher; private final Allocator allocator; @@ -86,7 +89,6 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper // Maps sample stream wrappers to variant/rendition index by matching array positions. private int[][] manifestUrlIndicesPerWrapper; private SequenceableLoader compositeSequenceableLoader; - private boolean notifiedReadingStarted; /** * Creates an HLS media period. @@ -113,6 +115,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper HlsDataSourceFactory dataSourceFactory, @Nullable TransferListener mediaTransferListener, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, EventDispatcher eventDispatcher, Allocator allocator, @@ -125,6 +128,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper this.dataSourceFactory = dataSourceFactory; this.mediaTransferListener = mediaTransferListener; this.drmSessionManager = drmSessionManager; + this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.eventDispatcher = eventDispatcher; this.allocator = allocator; @@ -139,7 +143,6 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper sampleStreamWrappers = new HlsSampleStreamWrapper[0]; enabledSampleStreamWrappers = new HlsSampleStreamWrapper[0]; manifestUrlIndicesPerWrapper = new int[0][]; - eventDispatcher.mediaPeriodCreated(); } public void release() { @@ -148,7 +151,6 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper sampleStreamWrapper.release(); } callback = null; - eventDispatcher.mediaPeriodReleased(); } @Override @@ -376,10 +378,6 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } @@ -451,13 +449,13 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper } @Override - public boolean onPlaylistError(Uri url, long blacklistDurationMs) { - boolean noBlacklistingFailure = true; + public boolean onPlaylistError(Uri url, long exclusionDurationMs) { + boolean exclusionSucceeded = true; for (HlsSampleStreamWrapper streamWrapper : sampleStreamWrappers) { - noBlacklistingFailure &= streamWrapper.onPlaylistError(url, blacklistDurationMs); + exclusionSucceeded &= streamWrapper.onPlaylistError(url, exclusionDurationMs); } callback.onContinueLoadingRequested(this); - return noBlacklistingFailure; + return exclusionSucceeded; } // Internal methods. @@ -719,7 +717,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper /* muxedCaptionFormats= */ Collections.emptyList(), overridingDrmInitData, positionUs); - manifestUrlsIndicesPerWrapper.add(Util.toArray(scratchIndicesList)); + manifestUrlsIndicesPerWrapper.add(Ints.toArray(scratchIndicesList)); sampleStreamWrappers.add(sampleStreamWrapper); if (allowChunklessPreparation && renditionsHaveCodecs) { @@ -757,6 +755,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper positionUs, muxedAudioFormat, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, eventDispatcher, metadataType); diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java index 39fa99c498..b58f3da928 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.max; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.net.Uri; @@ -24,7 +26,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.offline.StreamKey; @@ -33,8 +35,8 @@ import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceDrmHelper; import com.google.android.exoplayer2.source.MediaSourceEventListener; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.SequenceableLoader; import com.google.android.exoplayer2.source.SinglePeriodTimeline; @@ -47,9 +49,11 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -90,12 +94,13 @@ public final class HlsMediaSource extends BaseMediaSource public static final class Factory implements MediaSourceFactory { private final HlsDataSourceFactory hlsDataSourceFactory; + private final MediaSourceDrmHelper mediaSourceDrmHelper; private HlsExtractorFactory extractorFactory; private HlsPlaylistParserFactory playlistParserFactory; private HlsPlaylistTracker.Factory playlistTrackerFactory; private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; - private DrmSessionManager drmSessionManager; + @Nullable private DrmSessionManager drmSessionManager; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private boolean allowChunklessPreparation; @MetadataType private int metadataType; @@ -121,11 +126,11 @@ public final class HlsMediaSource extends BaseMediaSource * manifests, segments and keys. */ public Factory(HlsDataSourceFactory hlsDataSourceFactory) { - this.hlsDataSourceFactory = Assertions.checkNotNull(hlsDataSourceFactory); + this.hlsDataSourceFactory = checkNotNull(hlsDataSourceFactory); + mediaSourceDrmHelper = new MediaSourceDrmHelper(); playlistParserFactory = new DefaultHlsPlaylistParserFactory(); playlistTrackerFactory = DefaultHlsPlaylistTracker.FACTORY; extractorFactory = HlsExtractorFactory.DEFAULT; - drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); metadataType = METADATA_TYPE_ID3; @@ -282,19 +287,22 @@ public final class HlsMediaSource extends BaseMediaSource return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - */ @Override public Factory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManager) { - this.drmSessionManager = - drmSessionManager != null - ? drmSessionManager - : DrmSessionManager.getDummyDrmSessionManager(); + this.drmSessionManager = drmSessionManager; + return this; + } + + @Override + public Factory setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { + mediaSourceDrmHelper.setDrmHttpDataSourceFactory(drmHttpDataSourceFactory); + return this; + } + + @Override + public MediaSourceFactory setDrmUserAgent(@Nullable String userAgent) { + mediaSourceDrmHelper.setDrmUserAgent(userAgent); return this; } @@ -311,7 +319,7 @@ public final class HlsMediaSource extends BaseMediaSource } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ @SuppressWarnings("deprecation") @@ -332,7 +340,8 @@ public final class HlsMediaSource extends BaseMediaSource @Deprecated @Override public HlsMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setUri(uri).build()); + return createMediaSource( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_M3U8).build()); } /** @@ -344,29 +353,39 @@ public final class HlsMediaSource extends BaseMediaSource */ @Override public HlsMediaSource createMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); + checkNotNull(mediaItem.playbackProperties); HlsPlaylistParserFactory playlistParserFactory = this.playlistParserFactory; List streamKeys = - !mediaItem.playbackProperties.streamKeys.isEmpty() - ? mediaItem.playbackProperties.streamKeys - : this.streamKeys; + mediaItem.playbackProperties.streamKeys.isEmpty() + ? this.streamKeys + : mediaItem.playbackProperties.streamKeys; if (!streamKeys.isEmpty()) { playlistParserFactory = new FilteringHlsPlaylistParserFactory(playlistParserFactory, streamKeys); } + + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsStreamKeys = + mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty(); + if (needsTag && needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setTag(tag).setStreamKeys(streamKeys).build(); + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setStreamKeys(streamKeys).build(); + } return new HlsMediaSource( - mediaItem.playbackProperties.uri, + mediaItem, hlsDataSourceFactory, extractorFactory, compositeSequenceableLoaderFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, playlistTrackerFactory.createTracker( hlsDataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory), allowChunklessPreparation, metadataType, - useSessionKeys, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + useSessionKeys); } @Override @@ -376,7 +395,8 @@ public final class HlsMediaSource extends BaseMediaSource } private final HlsExtractorFactory extractorFactory; - private final Uri manifestUri; + private final MediaItem mediaItem; + private final MediaItem.PlaybackProperties playbackProperties; private final HlsDataSourceFactory dataSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private final DrmSessionManager drmSessionManager; @@ -385,12 +405,11 @@ public final class HlsMediaSource extends BaseMediaSource private final @MetadataType int metadataType; private final boolean useSessionKeys; private final HlsPlaylistTracker playlistTracker; - @Nullable private final Object tag; @Nullable private TransferListener mediaTransferListener; private HlsMediaSource( - Uri manifestUri, + MediaItem mediaItem, HlsDataSourceFactory dataSourceFactory, HlsExtractorFactory extractorFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, @@ -399,9 +418,9 @@ public final class HlsMediaSource extends BaseMediaSource HlsPlaylistTracker playlistTracker, boolean allowChunklessPreparation, @MetadataType int metadataType, - boolean useSessionKeys, - @Nullable Object tag) { - this.manifestUri = manifestUri; + boolean useSessionKeys) { + this.playbackProperties = checkNotNull(mediaItem.playbackProperties); + this.mediaItem = mediaItem; this.dataSourceFactory = dataSourceFactory; this.extractorFactory = extractorFactory; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; @@ -411,21 +430,31 @@ public final class HlsMediaSource extends BaseMediaSource this.allowChunklessPreparation = allowChunklessPreparation; this.metadataType = metadataType; this.useSessionKeys = useSessionKeys; - this.tag = tag; } + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { - return tag; + return playbackProperties.tag; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; } @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; drmSessionManager.prepare(); - EventDispatcher eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); - playlistTracker.start(manifestUri, eventDispatcher, /* listener= */ this); + MediaSourceEventListener.EventDispatcher eventDispatcher = + createEventDispatcher(/* mediaPeriodId= */ null); + playlistTracker.start(playbackProperties.uri, eventDispatcher, /* listener= */ this); } @Override @@ -435,15 +464,17 @@ public final class HlsMediaSource extends BaseMediaSource @Override public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { - EventDispatcher eventDispatcher = createEventDispatcher(id); + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher = createEventDispatcher(id); + DrmSessionEventListener.EventDispatcher drmEventDispatcher = createDrmEventDispatcher(id); return new HlsMediaPeriod( extractorFactory, playlistTracker, dataSourceFactory, mediaTransferListener, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, - eventDispatcher, + mediaSourceEventDispatcher, allocator, compositeSequenceableLoaderFactory, allowChunklessPreparation, @@ -477,7 +508,7 @@ public final class HlsMediaSource extends BaseMediaSource long windowDefaultStartPositionUs = playlist.startOffsetUs; // masterPlaylist is non-null because the first playlist has been fetched by now. HlsManifest manifest = - new HlsManifest(Assertions.checkNotNull(playlistTracker.getMasterPlaylist()), playlist); + new HlsManifest(checkNotNull(playlistTracker.getMasterPlaylist()), playlist); if (playlistTracker.isLive()) { long offsetFromInitialStartTimeUs = playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); @@ -487,7 +518,7 @@ public final class HlsMediaSource extends BaseMediaSource if (windowDefaultStartPositionUs == C.TIME_UNSET) { windowDefaultStartPositionUs = 0; if (!segments.isEmpty()) { - int defaultStartSegmentIndex = Math.max(0, segments.size() - 3); + int defaultStartSegmentIndex = max(0, segments.size() - 3); // We attempt to set the default start position to be at least twice the target duration // behind the live edge. long minStartPositionUs = playlist.durationUs - playlist.targetDurationUs * 2; @@ -511,7 +542,7 @@ public final class HlsMediaSource extends BaseMediaSource /* isDynamic= */ !playlist.hasEndTag, /* isLive= */ true, manifest, - tag); + mediaItem); } else /* not live */ { if (windowDefaultStartPositionUs == C.TIME_UNSET) { windowDefaultStartPositionUs = 0; @@ -529,7 +560,7 @@ public final class HlsMediaSource extends BaseMediaSource /* isDynamic= */ false, /* isLive= */ false, manifest, - tag); + mediaItem); } refreshSourceInfo(timeline); } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 9f0a7f1f0d..89e7687a21 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 @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls; +import static java.lang.Math.max; + import android.net.Uri; import android.os.Handler; import android.os.Looper; @@ -27,6 +29,7 @@ import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.extractor.DummyTrackOutput; import com.google.android.exoplayer2.extractor.Extractor; @@ -39,7 +42,7 @@ import com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder; import com.google.android.exoplayer2.metadata.id3.PrivFrame; import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaLoadData; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.SampleQueue; import com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener; import com.google.android.exoplayer2.source.SampleStream; @@ -57,10 +60,11 @@ import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.MediaSourceEventDispatcher; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; import java.io.EOFException; import java.io.IOException; import java.util.ArrayList; @@ -119,9 +123,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final Allocator allocator; @Nullable private final Format muxedAudioFormat; private final DrmSessionManager drmSessionManager; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final Loader loader; - private final EventDispatcher eventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; private final @HlsMediaSource.MetadataType int metadataType; private final HlsChunkSource.HlsChunkHolder nextChunkHolder; private final ArrayList mediaChunks; @@ -133,6 +138,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final ArrayList hlsSampleStreams; private final Map overridingDrmInitData; + @Nullable private Chunk loadingChunk; private HlsSampleQueue[] sampleQueues; private int[] sampleQueueTrackIds; private Set sampleQueueMappingDoneByType; @@ -183,8 +189,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * @param muxedAudioFormat Optional muxed audio {@link Format} as defined by the master playlist. * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession * DrmSessions} with. + * @param drmEventDispatcher A dispatcher to notify of {@link DrmSessionEventListener} events. * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. - * @param eventDispatcher A dispatcher to notify of events. + * @param mediaSourceEventDispatcher A dispatcher to notify of {@link MediaSourceEventListener} + * events. */ public HlsSampleStreamWrapper( int trackType, @@ -195,8 +203,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; long positionUs, @Nullable Format muxedAudioFormat, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, @HlsMediaSource.MetadataType int metadataType) { this.trackType = trackType; this.callback = callback; @@ -205,8 +214,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.allocator = allocator; this.muxedAudioFormat = muxedAudioFormat; this.drmSessionManager = drmSessionManager; + this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; - this.eventDispatcher = eventDispatcher; + this.mediaSourceEventDispatcher = mediaSourceEventDispatcher; this.metadataType = metadataType; loader = new Loader("Loader:HlsSampleStreamWrapper"); nextChunkHolder = new HlsChunkSource.HlsChunkHolder(); @@ -226,7 +236,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @SuppressWarnings("nullness:methodref.receiver.bound.invalid") Runnable onTracksEndedRunnable = this::onTracksEnded; this.onTracksEndedRunnable = onTracksEndedRunnable; - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentLooper(); lastSeekPositionUs = positionUs; pendingResetPositionUs = positionUs; } @@ -513,8 +523,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; chunkSource.setIsTimestampMaster(isTimestampMaster); } - public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) { - return chunkSource.onPlaylistError(playlistUrl, blacklistDurationMs); + public boolean onPlaylistError(Uri playlistUrl, long exclusionDurationMs) { + return chunkSource.onPlaylistError(playlistUrl, exclusionDurationMs); } // SampleStream implementation. @@ -550,16 +560,18 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; HlsMediaChunk currentChunk = mediaChunks.get(0); Format trackFormat = currentChunk.trackFormat; if (!trackFormat.equals(downstreamTrackFormat)) { - eventDispatcher.downstreamFormatChanged(trackType, trackFormat, - currentChunk.trackSelectionReason, currentChunk.trackSelectionData, + mediaSourceEventDispatcher.downstreamFormatChanged( + trackType, + trackFormat, + currentChunk.trackSelectionReason, + currentChunk.trackSelectionData, currentChunk.startTimeUs); } downstreamTrackFormat = trackFormat; } int result = - sampleQueues[sampleQueueIndex].read( - formatHolder, buffer, requireFormat, loadingFinished, lastSeekPositionUs); + sampleQueues[sampleQueueIndex].read(formatHolder, buffer, requireFormat, loadingFinished); if (result == C.RESULT_FORMAT_READ) { Format format = Assertions.checkNotNull(formatHolder.format); if (sampleQueueIndex == primarySampleQueueIndex) { @@ -586,11 +598,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } SampleQueue sampleQueue = sampleQueues[sampleQueueIndex]; - if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { - return sampleQueue.advanceToEnd(); - } else { - return sampleQueue.advanceTo(positionUs); - } + int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished); + sampleQueue.skip(skipCount); + return skipCount; } // SequenceableLoader implementation @@ -607,12 +617,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; HlsMediaChunk lastCompletedMediaChunk = lastMediaChunk.isLoadCompleted() ? lastMediaChunk : mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null; if (lastCompletedMediaChunk != null) { - bufferedPositionUs = Math.max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); + bufferedPositionUs = max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); } if (sampleQueuesBuilt) { for (SampleQueue sampleQueue : sampleQueues) { - bufferedPositionUs = - Math.max(bufferedPositionUs, sampleQueue.getLargestQueuedTimestampUs()); + bufferedPositionUs = max(bufferedPositionUs, sampleQueue.getLargestQueuedTimestampUs()); } } return bufferedPositionUs; @@ -639,13 +648,16 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (isPendingReset()) { chunkQueue = Collections.emptyList(); loadPositionUs = pendingResetPositionUs; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.setStartTimeUs(pendingResetPositionUs); + } } else { chunkQueue = readOnlyMediaChunks; HlsMediaChunk lastMediaChunk = getLastMediaChunk(); loadPositionUs = lastMediaChunk.isLoadCompleted() ? lastMediaChunk.endTimeUs - : Math.max(lastSeekPositionUs, lastMediaChunk.startTimeUs); + : max(lastSeekPositionUs, lastMediaChunk.startTimeUs); } chunkSource.getNextChunk( positionUs, @@ -674,10 +686,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (isMediaChunk(loadable)) { initMediaChunkLoad((HlsMediaChunk) loadable); } + loadingChunk = loadable; long elapsedRealtimeMs = loader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); - eventDispatcher.loadStarted( + mediaSourceEventDispatcher.loadStarted( new LoadEventInfo(loadable.loadTaskId, loadable.dataSpec, elapsedRealtimeMs), loadable.type, trackType, @@ -696,13 +709,29 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public void reevaluateBuffer(long positionUs) { - // Do nothing. + if (loader.hasFatalError() || isPendingReset()) { + return; + } + + if (loader.isLoading()) { + Assertions.checkNotNull(loadingChunk); + if (chunkSource.shouldCancelLoad(positionUs, loadingChunk, readOnlyMediaChunks)) { + loader.cancelLoading(); + } + return; + } + + int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + if (preferredQueueSize < mediaChunks.size()) { + discardUpstream(preferredQueueSize); + } } // Loader.Callback implementation. @Override public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + loadingChunk = null; chunkSource.onChunkLoadCompleted(loadable); LoadEventInfo loadEventInfo = new LoadEventInfo( @@ -714,7 +743,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; loadDurationMs, loadable.bytesLoaded()); loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); - eventDispatcher.loadCompleted( + mediaSourceEventDispatcher.loadCompleted( loadEventInfo, loadable.type, trackType, @@ -733,6 +762,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public void onLoadCanceled( Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) { + loadingChunk = null; LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -743,7 +773,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; loadDurationMs, loadable.bytesLoaded()); loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); - eventDispatcher.loadCanceled( + mediaSourceEventDispatcher.loadCanceled( loadEventInfo, loadable.type, trackType, @@ -753,7 +783,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; loadable.startTimeUs, loadable.endTimeUs); if (!released) { - resetSampleQueues(); + if (isPendingReset() || enabledTrackGroupCount == 0) { + resetSampleQueues(); + } if (enabledTrackGroupCount > 0) { callback.onContinueLoadingRequested(this); } @@ -769,7 +801,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int errorCount) { long bytesLoaded = loadable.bytesLoaded(); boolean isMediaChunk = isMediaChunk(loadable); - boolean blacklistSucceeded = false; + boolean exclusionSucceeded = false; LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -791,21 +823,23 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); LoadErrorAction loadErrorAction; - long blacklistDurationMs = loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo); - if (blacklistDurationMs != C.TIME_UNSET) { - blacklistSucceeded = chunkSource.maybeBlacklistTrack(loadable, blacklistDurationMs); + long exclusionDurationMs = loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo); + if (exclusionDurationMs != C.TIME_UNSET) { + exclusionSucceeded = chunkSource.maybeExcludeTrack(loadable, exclusionDurationMs); } - if (blacklistSucceeded) { + if (exclusionSucceeded) { if (isMediaChunk && bytesLoaded == 0) { HlsMediaChunk removed = mediaChunks.remove(mediaChunks.size() - 1); Assertions.checkState(removed == loadable); if (mediaChunks.isEmpty()) { pendingResetPositionUs = lastSeekPositionUs; + } else { + Iterables.getLast(mediaChunks).invalidateExtractor(); } } loadErrorAction = Loader.DONT_RETRY; - } else /* did not blacklist */ { + } else /* did not exclude */ { long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo); loadErrorAction = retryDelayMs != C.TIME_UNSET @@ -814,7 +848,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } boolean wasCanceled = !loadErrorAction.isRetry(); - eventDispatcher.loadError( + mediaSourceEventDispatcher.loadError( loadEventInfo, loadable.type, trackType, @@ -826,10 +860,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; error, wasCanceled); if (wasCanceled) { + loadingChunk = null; loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); } - if (blacklistSucceeded) { + if (exclusionSucceeded) { if (!prepared) { continueLoading(lastSeekPositionUs); } else { @@ -851,18 +886,46 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; upstreamTrackFormat = chunk.trackFormat; pendingResetPositionUs = C.TIME_UNSET; mediaChunks.add(chunk); - - chunk.init(this); + ImmutableList.Builder sampleQueueWriteIndicesBuilder = ImmutableList.builder(); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueueWriteIndicesBuilder.add(sampleQueue.getWriteIndex()); + } + chunk.init(/* output= */ this, sampleQueueWriteIndicesBuilder.build()); for (HlsSampleQueue sampleQueue : sampleQueues) { sampleQueue.setSourceChunk(chunk); - } - if (chunk.shouldSpliceIn) { - for (SampleQueue sampleQueue : sampleQueues) { + if (chunk.shouldSpliceIn) { sampleQueue.splice(); } } } + private void discardUpstream(int preferredQueueSize) { + Assertions.checkState(!loader.isLoading()); + + int newQueueSize = C.LENGTH_UNSET; + for (int i = preferredQueueSize; i < mediaChunks.size(); i++) { + if (canDiscardUpstreamMediaChunksFromIndex(i)) { + newQueueSize = i; + break; + } + } + if (newQueueSize == C.LENGTH_UNSET) { + return; + } + + long endTimeUs = getLastMediaChunk().endTimeUs; + HlsMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } else { + Iterables.getLast(mediaChunks).invalidateExtractor(); + } + loadingFinished = false; + + mediaSourceEventDispatcher.upstreamDiscarded( + primarySampleQueueType, firstRemovedChunk.startTimeUs, endTimeUs); + } + // ExtractorOutput implementation. Called by the loading thread. @Override @@ -882,7 +945,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (trackOutput == null) { if (tracksEnded) { - return createDummyTrackOutput(id, type); + return createFakeTrackOutput(id, type); } else { // The relevant SampleQueue hasn't been constructed yet - so construct it. trackOutput = createSampleQueue(id, type); @@ -926,7 +989,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } return sampleQueueTrackIds[sampleQueueIndex] == id ? sampleQueues[sampleQueueIndex] - : createDummyTrackOutput(id, type); + : createFakeTrackOutput(id, type); } private SampleQueue createSampleQueue(int id, int type) { @@ -938,7 +1001,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; allocator, /* playbackLooper= */ handler.getLooper(), drmSessionManager, - eventDispatcher, + drmEventDispatcher, overridingDrmInitData); if (isAudioVideo) { sampleQueue.setDrmInitData(drmInitData); @@ -1061,6 +1124,38 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return true; } + private boolean canDiscardUpstreamMediaChunksFromIndex(int mediaChunkIndex) { + for (int i = mediaChunkIndex; i < mediaChunks.size(); i++) { + if (mediaChunks.get(i).shouldSpliceIn) { + // Discarding not possible because a spliced-in chunk potentially removed sample metadata + // from the previous chunks. + // TODO: Keep sample metadata to allow restoring these chunks [internal b/159904763]. + return false; + } + } + HlsMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); + for (int i = 0; i < sampleQueues.length; i++) { + int discardFromIndex = mediaChunk.getFirstSampleIndex(/* sampleQueueIndex= */ i); + if (sampleQueues[i].getReadIndex() > discardFromIndex) { + // Discarding not possible because we already read from the chunk. + // TODO: Sparse tracks (e.g. ID3) may prevent discarding in almost all cases because it + // means that most chunks have been read from already. See [internal b/161126666]. + return false; + } + } + return true; + } + + private HlsMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) { + HlsMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); + Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); + for (int i = 0; i < sampleQueues.length; i++) { + int discardFromIndex = firstRemovedChunk.getFirstSampleIndex(/* sampleQueueIndex= */ i); + sampleQueues[i].discardUpstreamSamples(discardFromIndex); + } + return firstRemovedChunk; + } + private void resetSampleQueues() { for (SampleQueue sampleQueue : sampleQueues) { sampleQueue.reset(pendingResetUpstreamFormats); @@ -1223,12 +1318,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; Format[] exposedFormats = new Format[trackGroup.length]; for (int j = 0; j < trackGroup.length; j++) { Format format = trackGroup.getFormat(j); - if (format.drmInitData != null) { - format = - format.copyWithExoMediaCryptoType( - drmSessionManager.getExoMediaCryptoType(format.drmInitData)); - } - exposedFormats[j] = format; + exposedFormats[j] = + format.copyWithExoMediaCryptoType(drmSessionManager.getExoMediaCryptoType(format)); } trackGroups[i] = new TrackGroup(exposedFormats); } @@ -1370,7 +1461,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return true; } - private static DummyTrackOutput createDummyTrackOutput(int id, int type) { + private static DummyTrackOutput createFakeTrackOutput(int id, int type) { Log.w(TAG, "Unmapped track with id " + id + " of type " + type); return new DummyTrackOutput(); } @@ -1388,22 +1479,25 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; */ private static final class HlsSampleQueue extends SampleQueue { - /** - * The fraction of the chunk duration from which timestamps of samples loaded from within a - * chunk are allowed to deviate from the expected range. - */ - private static final double MAX_TIMESTAMP_DEVIATION_FRACTION = 0.5; - - /** - * A minimum tolerance for sample timestamps in microseconds. Timestamps of samples loaded from - * within a chunk are always allowed to deviate up to this amount from the expected range. - */ - private static final long MIN_TIMESTAMP_DEVIATION_TOLERANCE_US = 4_000_000; - - @Nullable private HlsMediaChunk sourceChunk; - private long sourceChunkLastSampleTimeUs; - private long minAllowedSampleTimeUs; - private long maxAllowedSampleTimeUs; + // TODO: Uncomment this to reject samples with unexpected timestamps. See + // https://github.com/google/ExoPlayer/issues/7030. + // /** + // * The fraction of the chunk duration from which timestamps of samples loaded from within a + // * chunk are allowed to deviate from the expected range. + // */ + // private static final double MAX_TIMESTAMP_DEVIATION_FRACTION = 0.5; + // + // /** + // * A minimum tolerance for sample timestamps in microseconds. Timestamps of samples loaded + // * from within a chunk are always allowed to deviate up to this amount from the expected + // * range. + // */ + // private static final long MIN_TIMESTAMP_DEVIATION_TOLERANCE_US = 4_000_000; + // + // @Nullable private HlsMediaChunk sourceChunk; + // private long sourceChunkLastSampleTimeUs; + // private long minAllowedSampleTimeUs; + // private long maxAllowedSampleTimeUs; private final Map overridingDrmInitData; @Nullable private DrmInitData drmInitData; @@ -1412,23 +1506,25 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; Allocator allocator, Looper playbackLooper, DrmSessionManager drmSessionManager, - MediaSourceEventDispatcher eventDispatcher, + DrmSessionEventListener.EventDispatcher eventDispatcher, Map overridingDrmInitData) { super(allocator, playbackLooper, drmSessionManager, eventDispatcher); this.overridingDrmInitData = overridingDrmInitData; } public void setSourceChunk(HlsMediaChunk chunk) { - sourceChunk = chunk; - sourceChunkLastSampleTimeUs = C.TIME_UNSET; sourceId(chunk.uid); - long allowedDeviationUs = - Math.max( - (long) ((chunk.endTimeUs - chunk.startTimeUs) * MAX_TIMESTAMP_DEVIATION_FRACTION), - MIN_TIMESTAMP_DEVIATION_TOLERANCE_US); - minAllowedSampleTimeUs = chunk.startTimeUs - allowedDeviationUs; - maxAllowedSampleTimeUs = chunk.endTimeUs + allowedDeviationUs; + // TODO: Uncomment this to reject samples with unexpected timestamps. See + // https://github.com/google/ExoPlayer/issues/7030. + // sourceChunk = chunk; + // sourceChunkLastSampleTimeUs = C.TIME_UNSET; + // long allowedDeviationUs = + // Math.max( + // (long) ((chunk.endTimeUs - chunk.startTimeUs) * MAX_TIMESTAMP_DEVIATION_FRACTION), + // MIN_TIMESTAMP_DEVIATION_TOLERANCE_US); + // minAllowedSampleTimeUs = chunk.startTimeUs - allowedDeviationUs; + // maxAllowedSampleTimeUs = chunk.endTimeUs + allowedDeviationUs; } public void setDrmInitData(@Nullable DrmInitData drmInitData) { @@ -1506,7 +1602,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // new UnexpectedSampleTimestampException( // sourceChunk, sourceChunkLastSampleTimeUs, timeUs)); // } - sourceChunkLastSampleTimeUs = timeUs; + // sourceChunkLastSampleTimeUs = timeUs; super.sampleMetadata(timeUs, flags, size, offset, cryptoData); } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java index 6a390001d2..832de00cf9 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/WebvttExtractor.java @@ -176,8 +176,9 @@ public final class WebvttExtractor implements Extractor { long firstCueTimeUs = WebvttParserUtil.parseTimestampUs(Assertions.checkNotNull(cueHeaderMatcher.group(1))); - long sampleTimeUs = timestampAdjuster.adjustTsTimestamp( - TimestampAdjuster.usToPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs)); + long sampleTimeUs = + timestampAdjuster.adjustTsTimestamp( + TimestampAdjuster.usToWrappedPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs)); long subsampleOffsetUs = sampleTimeUs - firstCueTimeUs; // Output the track. TrackOutput trackOutput = buildTrackOutput(subsampleOffsetUs); diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java index 2e97c4bc58..39462f3d06 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.hls.offline; import android.net.Uri; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.SegmentDownloader; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; @@ -25,6 +26,7 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.util.UriUtil; import java.io.IOException; @@ -47,8 +49,13 @@ import java.util.concurrent.Executor; * // Create a downloader for the first variant in a master playlist. * HlsDownloader hlsDownloader = * new HlsDownloader( - * playlistUri, - * Collections.singletonList(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0)); + * new MediaItem.Builder() + * .setUri(playlistUri) + * .setStreamKeys( + * Collections.singletonList( + * new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0))) + * .build(), + * Collections.singletonList(); * // Perform the download. * hlsDownloader.download(progressListener); * // Use the downloaded data for playback. @@ -58,22 +65,44 @@ import java.util.concurrent.Executor; */ public final class HlsDownloader extends SegmentDownloader { - /** - * @param playlistUri The {@link Uri} of the playlist to be downloaded. - * @param streamKeys Keys defining which renditions in the playlist should be selected for - * download. If empty, all renditions are downloaded. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. - */ + /** @deprecated Use {@link #HlsDownloader(MediaItem, CacheDataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public HlsDownloader( Uri playlistUri, List streamKeys, CacheDataSource.Factory cacheDataSourceFactory) { this(playlistUri, streamKeys, cacheDataSourceFactory, Runnable::run); } /** - * @param playlistUri The {@link Uri} of the playlist to be downloaded. - * @param streamKeys Keys defining which renditions in the playlist should be selected for - * download. If empty, all renditions are downloaded. + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public HlsDownloader(MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #HlsDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. + */ + @Deprecated + public HlsDownloader( + Uri playlistUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(playlistUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -81,16 +110,32 @@ public final class HlsDownloader extends SegmentDownloader { * allowing parts of it to be executed in parallel. */ public HlsDownloader( - Uri playlistUri, - List streamKeys, + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + this(mediaItem, new HlsPlaylistParser(), cacheDataSourceFactory, executor); + } + + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param manifestParser A parser for HLS playlists. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + * @param executor An {@link Executor} used to make requests for the media being downloaded. + * Providing an {@link Executor} that uses multiple threads will speed up the download by + * allowing parts of it to be executed in parallel. + */ + public HlsDownloader( + MediaItem mediaItem, + Parser manifestParser, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { - super(playlistUri, new HlsPlaylistParser(), streamKeys, cacheDataSourceFactory, executor); + super(mediaItem, manifestParser, cacheDataSourceFactory, executor); } @Override - protected List getSegments( - DataSource dataSource, HlsPlaylist playlist, boolean allowIncompleteList) throws IOException { + protected List getSegments(DataSource dataSource, HlsPlaylist playlist, boolean removing) + throws IOException, InterruptedException { ArrayList mediaPlaylistDataSpecs = new ArrayList<>(); if (playlist instanceof HlsMasterPlaylist) { HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; @@ -106,9 +151,9 @@ public final class HlsDownloader extends SegmentDownloader { segments.add(new Segment(/* startTimeUs= */ 0, mediaPlaylistDataSpec)); HlsMediaPlaylist mediaPlaylist; try { - mediaPlaylist = (HlsMediaPlaylist) getManifest(dataSource, mediaPlaylistDataSpec); + mediaPlaylist = (HlsMediaPlaylist) getManifest(dataSource, mediaPlaylistDataSpec, removing); } catch (IOException e) { - if (!allowIncompleteList) { + if (!removing) { throw e; } // Generating an incomplete segment list is allowed. Advance to the next media playlist. diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java index f179447785..ccbcb986c1 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls.playlist; +import static java.lang.Math.max; + import android.net.Uri; import android.os.Handler; import android.os.SystemClock; @@ -121,7 +123,7 @@ public final class DefaultHlsPlaylistTracker Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener primaryPlaylistListener) { - this.playlistRefreshHandler = Util.createHandler(); + this.playlistRefreshHandler = Util.createHandlerForCurrentLooper(); this.eventDispatcher = eventDispatcher; this.primaryPlaylistListener = primaryPlaylistListener; ParsingLoadable masterPlaylistLoadable = @@ -161,6 +163,7 @@ public final class DefaultHlsPlaylistTracker @Override public void addListener(PlaylistEventListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } @@ -238,12 +241,6 @@ public final class DefaultHlsPlaylistTracker primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url; createBundles(masterPlaylist.mediaPlaylistUrls); MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); - if (isMediaPlaylist) { - // We don't need to load the playlist again. We can use the same result. - primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); - } else { - primaryBundle.loadPlaylist(); - } LoadEventInfo loadEventInfo = new LoadEventInfo( loadable.loadTaskId, @@ -253,6 +250,12 @@ public final class DefaultHlsPlaylistTracker elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + if (isMediaPlaylist) { + // We don't need to load the playlist again. We can use the same result. + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo); + } else { + primaryBundle.loadPlaylist(); + } loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); } @@ -314,7 +317,7 @@ public final class DefaultHlsPlaylistTracker long currentTimeMs = SystemClock.elapsedRealtime(); for (int i = 0; i < variantsSize; i++) { MediaPlaylistBundle bundle = playlistBundles.get(variants.get(i).url); - if (currentTimeMs > bundle.blacklistUntilMs) { + if (currentTimeMs > bundle.excludeUntilMs) { primaryMediaPlaylistUrl = bundle.playlistUrl; bundle.loadPlaylist(); return true; @@ -377,13 +380,13 @@ public final class DefaultHlsPlaylistTracker } } - private boolean notifyPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + private boolean notifyPlaylistError(Uri playlistUrl, long exclusionDurationMs) { int listenersSize = listeners.size(); - boolean anyBlacklistingFailed = false; + boolean anyExclusionFailed = false; for (int i = 0; i < listenersSize; i++) { - anyBlacklistingFailed |= !listeners.get(i).onPlaylistError(playlistUrl, blacklistDurationMs); + anyExclusionFailed |= !listeners.get(i).onPlaylistError(playlistUrl, exclusionDurationMs); } - return anyBlacklistingFailed; + return anyExclusionFailed; } private HlsMediaPlaylist getLatestPlaylistSnapshot( @@ -467,7 +470,7 @@ public final class DefaultHlsPlaylistTracker private long lastSnapshotLoadMs; private long lastSnapshotChangeMs; private long earliestNextLoadTimeMs; - private long blacklistUntilMs; + private long excludeUntilMs; private boolean loadPending; private IOException playlistError; @@ -492,7 +495,7 @@ public final class DefaultHlsPlaylistTracker return false; } long currentTimeMs = SystemClock.elapsedRealtime(); - long snapshotValidityDurationMs = Math.max(30000, C.usToMs(playlistSnapshot.durationUs)); + long snapshotValidityDurationMs = max(30000, C.usToMs(playlistSnapshot.durationUs)); return playlistSnapshot.hasEndTag || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD @@ -504,7 +507,7 @@ public final class DefaultHlsPlaylistTracker } public void loadPlaylist() { - blacklistUntilMs = 0; + excludeUntilMs = 0; if (loadPending || mediaPlaylistLoader.isLoading() || mediaPlaylistLoader.hasFatalError()) { // Load already pending, in progress, or a fatal error has been encountered. Do nothing. return; @@ -540,15 +543,15 @@ public final class DefaultHlsPlaylistTracker elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); - loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); if (result instanceof HlsMediaPlaylist) { - processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); + processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo); eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); } else { playlistError = new ParserException("Loaded playlist has unexpected type."); eventDispatcher.loadError( loadEventInfo, C.DATA_TYPE_MANIFEST, playlistError, /* wasCanceled= */ true); } + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); } @Override @@ -590,16 +593,16 @@ public final class DefaultHlsPlaylistTracker LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); LoadErrorAction loadErrorAction; - long blacklistDurationMs = loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo); - boolean shouldBlacklist = blacklistDurationMs != C.TIME_UNSET; + long exclusionDurationMs = loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo); + boolean shouldExclude = exclusionDurationMs != C.TIME_UNSET; - boolean blacklistingFailed = - notifyPlaylistError(playlistUrl, blacklistDurationMs) || !shouldBlacklist; - if (shouldBlacklist) { - blacklistingFailed |= blacklistPlaylist(blacklistDurationMs); + boolean exclusionFailed = + notifyPlaylistError(playlistUrl, exclusionDurationMs) || !shouldExclude; + if (shouldExclude) { + exclusionFailed |= excludePlaylist(exclusionDurationMs); } - if (blacklistingFailed) { + if (exclusionFailed) { long retryDelay = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo); loadErrorAction = retryDelay != C.TIME_UNSET @@ -639,7 +642,8 @@ public final class DefaultHlsPlaylistTracker mediaPlaylistLoadable.type); } - private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDurationMs) { + private void processLoadedPlaylist( + HlsMediaPlaylist loadedPlaylist, LoadEventInfo loadEventInfo) { HlsMediaPlaylist oldPlaylist = playlistSnapshot; long currentTimeMs = SystemClock.elapsedRealtime(); lastSnapshotLoadMs = currentTimeMs; @@ -653,7 +657,7 @@ public final class DefaultHlsPlaylistTracker < playlistSnapshot.mediaSequence) { // TODO: Allow customization of playlist resets handling. // The media sequence jumped backwards. The server has probably reset. We do not try - // blacklisting in this case. + // excluding in this case. playlistError = new PlaylistResetException(playlistUrl); notifyPlaylistError(playlistUrl, C.TIME_UNSET); } else if (currentTimeMs - lastSnapshotChangeMs @@ -661,12 +665,17 @@ public final class DefaultHlsPlaylistTracker * playlistStuckTargetDurationCoefficient) { // TODO: Allow customization of stuck playlists handling. playlistError = new PlaylistStuckException(playlistUrl); - long blacklistDurationMs = - loadErrorHandlingPolicy.getBlacklistDurationMsFor( - C.DATA_TYPE_MANIFEST, loadDurationMs, playlistError, /* errorCount= */ 1); - notifyPlaylistError(playlistUrl, blacklistDurationMs); - if (blacklistDurationMs != C.TIME_UNSET) { - blacklistPlaylist(blacklistDurationMs); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo( + loadEventInfo, + new MediaLoadData(C.DATA_TYPE_MANIFEST), + playlistError, + /* errorCount= */ 1); + long exclusionDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo); + notifyPlaylistError(playlistUrl, exclusionDurationMs); + if (exclusionDurationMs != C.TIME_UNSET) { + excludePlaylist(exclusionDurationMs); } } } @@ -687,14 +696,14 @@ public final class DefaultHlsPlaylistTracker } /** - * Blacklists the playlist. + * Excludes the playlist. * - * @param blacklistDurationMs The number of milliseconds for which the playlist should be - * blacklisted. - * @return Whether the playlist is the primary, despite being blacklisted. + * @param exclusionDurationMs The number of milliseconds for which the playlist should be + * excluded. + * @return Whether the playlist is the primary, despite being excluded. */ - private boolean blacklistPlaylist(long blacklistDurationMs) { - blacklistUntilMs = SystemClock.elapsedRealtime() + blacklistDurationMs; + private boolean excludePlaylist(long exclusionDurationMs) { + excludeUntilMs = SystemClock.elapsedRealtime() + exclusionDurationMs; return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl(); } } diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index 77e541fb57..fd6efbf445 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -459,12 +459,12 @@ public final class HlsPlaylistParser implements ParsingLoadable.ParserPlaylist loads might encounter errors. The tracker may choose to blacklist them to ensure a + *

      Playlist loads might encounter errors. The tracker may choose to exclude them to ensure a * primary playlist is always available. */ public interface HlsPlaylistTracker { @@ -76,11 +76,11 @@ public interface HlsPlaylistTracker { * Called if an error is encountered while loading a playlist. * * @param url The loaded url that caused the error. - * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or - * {@link C#TIME_UNSET} if the playlist should not be blacklisted. - * @return True if blacklisting did not encounter errors. False otherwise. + * @param exclusionDurationMs The duration for which the playlist should be excluded. Or {@link + * C#TIME_UNSET} if the playlist should not be excluded. + * @return True if excluding did not encounter errors. False otherwise. */ - boolean onPlaylistError(Uri url, long blacklistDurationMs); + boolean onPlaylistError(Uri url, long exclusionDurationMs); } /** Thrown when a playlist is considered to be stuck due to a server side error. */ @@ -208,10 +208,10 @@ public interface HlsPlaylistTracker { void maybeThrowPlaylistRefreshError(Uri url) throws IOException; /** - * Requests a playlist refresh and whitelists it. + * Requests a playlist refresh and removes it from the exclusion list. * - *

      The playlist tracker may choose the delay the playlist refresh. The request is discarded if - * a refresh was already pending. + *

      The playlist tracker may choose to delay the playlist refresh. The request is discarded if a + * refresh was already pending. * * @param url The {@link Uri} of the playlist to be refreshed. */ diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java new file mode 100644 index 0000000000..d51a800b88 --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactoryTest.java @@ -0,0 +1,167 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.TimestampAdjuster; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link DefaultExtractorsFactory}. */ +@RunWith(AndroidJUnit4.class) +public class DefaultHlsExtractorFactoryTest { + + private Uri tsUri; + private Format webVttFormat; + private TimestampAdjuster timestampAdjuster; + private Map> ac3ResponseHeaders; + + @Before + public void setUp() { + tsUri = Uri.parse("http://path/filename.ts"); + webVttFormat = new Format.Builder().setSampleMimeType(MimeTypes.TEXT_VTT).build(); + timestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + ac3ResponseHeaders = new HashMap<>(); + ac3ResponseHeaders.put("Content-Type", Collections.singletonList(MimeTypes.AUDIO_AC3)); + } + + @Test + public void createExtractor_withFileTypeInFormat_returnsExtractorMatchingFormat() + throws Exception { + ExtractorInput webVttExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "media/webvtt/typical")) + .build(); + + BundledHlsMediaChunkExtractor result = + new DefaultHlsExtractorFactory() + .createExtractor( + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + webVttExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(WebvttExtractor.class); + } + + @Test + public void + createExtractor_withFileTypeInResponseHeaders_returnsExtractorMatchingResponseHeaders() + throws Exception { + ExtractorInput ac3ExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "media/ts/sample.ac3")) + .build(); + + BundledHlsMediaChunkExtractor result = + new DefaultHlsExtractorFactory() + .createExtractor( + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + ac3ExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(Ac3Extractor.class); + } + + @Test + public void createExtractor_withFileTypeInUri_returnsExtractorMatchingUri() throws Exception { + ExtractorInput tsExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "media/ts/sample_ac3.ts")) + .build(); + + BundledHlsMediaChunkExtractor result = + new DefaultHlsExtractorFactory() + .createExtractor( + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + tsExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(TsExtractor.class); + } + + @Test + public void createExtractor_withFileTypeNotInMediaInfo_returnsExpectedExtractor() + throws Exception { + ExtractorInput mp3ExtractorInput = + new FakeExtractorInput.Builder() + .setData( + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), "media/mp3/bear-id3.mp3")) + .build(); + + BundledHlsMediaChunkExtractor result = + new DefaultHlsExtractorFactory() + .createExtractor( + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + mp3ExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(Mp3Extractor.class); + } + + @Test + public void createExtractor_withNoMatchingExtractor_fallsBackOnTsExtractor() throws Exception { + ExtractorInput emptyExtractorInput = new FakeExtractorInput.Builder().build(); + + BundledHlsMediaChunkExtractor result = + new DefaultHlsExtractorFactory() + .createExtractor( + tsUri, + webVttFormat, + /* muxedCaptionFormats= */ null, + timestampAdjuster, + ac3ResponseHeaders, + emptyExtractorInput); + + assertThat(result.extractor.getClass()).isEqualTo(TsExtractor.class); + } +} diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java index 0c9f54881d..54383ffe33 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.hls; import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -36,7 +37,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withMimeType_hlsSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_M3U8).build(); @@ -49,7 +50,7 @@ public class DefaultMediaSourceFactoryTest { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -59,13 +60,13 @@ public class DefaultMediaSourceFactoryTest { MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - assertThat(mediaSource.getTag()).isEqualTo(tag); + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); } @Test public void createMediaSource_withPath_hlsSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.m3u8").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -76,7 +77,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.m3u8").build(); MediaSource mediaSource = @@ -92,7 +93,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void getSupportedTypes_hlsModule_containsTypeHls() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_HLS); diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java index fe42ebb07e..a6c42f9754 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java @@ -22,10 +22,11 @@ import static org.mockito.Mockito.when; import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; 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.MediaSourceEventListener; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; @@ -43,11 +44,9 @@ import java.util.Collections; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit test for {@link HlsMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public final class HlsMediaPeriodTest { @Test @@ -77,18 +76,18 @@ public final class HlsMediaPeriodTest { when(mockDataSourceFactory.createDataSource(anyInt())).thenReturn(mock(DataSource.class)); HlsPlaylistTracker mockPlaylistTracker = mock(HlsPlaylistTracker.class); when(mockPlaylistTracker.getMasterPlaylist()).thenReturn((HlsMasterPlaylist) playlist); + MediaPeriodId mediaPeriodId = new MediaPeriodId(/* periodUid= */ new Object()); return new HlsMediaPeriod( mock(HlsExtractorFactory.class), mockPlaylistTracker, mockDataSourceFactory, mock(TransferListener.class), mock(DrmSessionManager.class), + new DrmSessionEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId), mock(LoadErrorHandlingPolicy.class), - new EventDispatcher() - .withParameters( - /* windowIndex= */ 0, - /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), - /* mediaTimeOffsetMs= */ 0), + new MediaSourceEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId, /* mediaTimeOffsetMs= */ 0), mock(Allocator.class), mock(CompositeSequenceableLoaderFactory.class), /* allowChunklessPreparation =*/ true, diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaSourceTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaSourceTest.java new file mode 100644 index 0000000000..7001417186 --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaSourceTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.hls; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.upstream.DataSource; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link HlsMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class HlsMediaSourceTest { + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nullMediaItemTag_setsMediaItemTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nonNullMediaItemTag_doesNotOverrideMediaItemTag() { + Object factoryTag = new Object(); + Object mediaItemTag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(mediaItemTag).build(); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(factoryTag); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(tag); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factoryCreateMediaSource_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(tag).build(); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)).setTag(new Object()); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_emptyMediaItemStreamKeys_setsMediaItemStreamKeys() { + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + StreamKey streamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)) + .setStreamKeys(Collections.singletonList(streamKey)); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotOverrideMediaItemStreamKeys() { + StreamKey mediaItemStreamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("http://www.google.com") + .setStreamKeys(Collections.singletonList(mediaItemStreamKey)) + .build(); + HlsMediaSource.Factory factory = + new HlsMediaSource.Factory(mock(DataSource.Factory.class)) + .setStreamKeys( + Collections.singletonList(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); + + MediaItem dashMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(dashMediaItem.playbackProperties).isNotNull(); + assertThat(dashMediaItem.playbackProperties.uri).isEqualTo(mediaItem.playbackProperties.uri); + assertThat(dashMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); + } +} diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java index 2f7f8e3fc0..b9c3477456 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/WebvttExtractorTest.java @@ -17,9 +17,13 @@ package com.google.android.exoplayer2.source.hls; import static com.google.common.truth.Truth.assertThat; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.testutil.DumpFileAsserts; import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.FakeExtractorOutput; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.TimestampAdjuster; import java.io.EOFException; import java.io.IOException; @@ -63,6 +67,29 @@ public class WebvttExtractorTest { assertThat(sniffData(data)).isFalse(); } + @Test + public void read_handlesLargeCueTimestamps() throws Exception { + TimestampAdjuster timestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + // Prime the TimestampAdjuster with a close-ish timestamp (5s before the first cue). + timestampAdjuster.adjustTsTimestamp(384615190); + WebvttExtractor extractor = new WebvttExtractor(/* language= */ null, timestampAdjuster); + // We can't use ExtractorAsserts because WebvttExtractor doesn't fulfill the whole Extractor + // interface (e.g. throws an exception from seek()). + FakeExtractorOutput output = + TestUtil.extractAllSamplesFromFile( + extractor, + ApplicationProvider.getApplicationContext(), + "media/webvtt/with_x-timestamp-map_header"); + + // The output has a ~5s sampleTime and a large, negative subsampleOffset because the cue + // timestamps are ~10 days ahead of the PTS (due to wrapping) so the offset is used to ensure + // they're rendered at the right time. + DumpFileAsserts.assertOutput( + ApplicationProvider.getApplicationContext(), + output, + "extractordumps/webvtt/with_x-timestamp-map_header.dump"); + } + private static boolean sniffData(byte[] data) throws IOException { ExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); try { diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java index f1d0b8ab8a..9d1127a3d7 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/DownloadHelperTest.java @@ -15,12 +15,13 @@ */ package com.google.android.exoplayer2.source.hls.offline; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,16 +31,22 @@ public final class DownloadHelperTest { @Test public void staticDownloadHelperForHls_doesNotThrow() { - DownloadHelper.forHls( + DownloadHelper.forMediaItem( ApplicationProvider.getApplicationContext(), - Uri.parse("http://uri"), - new FakeDataSource.Factory(), - (handler, videoListener, audioListener, text, metadata) -> new Renderer[0]); - DownloadHelper.forHls( - Uri.parse("http://uri"), - new FakeDataSource.Factory(), + new MediaItem.Builder() + .setUri("http://uri") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(), (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], - /* drmSessionManager= */ null, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + new FakeDataSource.Factory()); + DownloadHelper.forMediaItem( + new MediaItem.Builder() + .setUri("http://uri") + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(), + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, + (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], + new FakeDataSource.Factory(), + /* drmSessionManager= */ null); } } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java index f38a4577be..9215dd31f0 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloadTestData.java @@ -15,8 +15,7 @@ */ package com.google.android.exoplayer2.source.hls.offline; -import com.google.android.exoplayer2.C; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; /** Data for HLS downloading tests. */ /* package */ interface HlsDownloadTestData { @@ -49,7 +48,7 @@ import java.nio.charset.Charset; + "\n" + "#EXT-X-STREAM-INF:BANDWIDTH=41457,CODECS=\"mp4a.40.2\"\n" + MEDIA_PLAYLIST_0_URI) - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); byte[] MEDIA_PLAYLIST_DATA = ("#EXTM3U\n" @@ -64,7 +63,7 @@ import java.nio.charset.Charset; + "#EXTINF:9.97667,\n" + "fileSequence2.ts\n" + "#EXT-X-ENDLIST") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); String ENC_MEDIA_PLAYLIST_URI = "enc_index.m3u8"; @@ -83,5 +82,5 @@ import java.nio.charset.Charset; + "#EXTINF:9.97667,\n" + "fileSequence2.ts\n" + "#EXT-X-ENDLIST") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java index 0dcae17f74..986ee4deda 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/offline/HlsDownloaderTest.java @@ -37,6 +37,7 @@ import static com.google.common.truth.Truth.assertThat; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.offline.Downloader; @@ -46,11 +47,13 @@ import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; import com.google.android.exoplayer2.testutil.CacheAsserts.RequestSet; import com.google.android.exoplayer2.testutil.FakeDataSet; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.File; import java.util.ArrayList; @@ -75,7 +78,8 @@ public class HlsDownloaderTest { public void setUp() throws Exception { tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); progressListener = new ProgressListener(); fakeDataSet = new FakeDataSet() @@ -101,17 +105,17 @@ public class HlsDownloaderTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( - new DownloadRequest( - "id", - DownloadRequest.TYPE_HLS, - Uri.parse("https://www.test.com/download"), - Collections.singletonList(new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0)), - /* customCacheKey= */ null, - /* data= */ null)); + new DownloadRequest.Builder(/* id= */ "id", Uri.parse("https://www.test.com/download")) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .setStreamKeys( + Collections.singletonList( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0))) + .build()); assertThat(downloader).isInstanceOf(HlsDownloader.class); } @@ -219,7 +223,9 @@ public class HlsDownloaderTest { new CacheDataSource.Factory() .setCache(cache) .setUpstreamDataSourceFactory(new FakeDataSource.Factory().setFakeDataSet(fakeDataSet)); - return new HlsDownloader(Uri.parse(mediaPlaylistUri), keys, cacheDataSourceFactory); + return new HlsDownloader( + new MediaItem.Builder().setUri(mediaPlaylistUri).setStreamKeys(keys).build(), + cacheDataSourceFactory); } private static ArrayList getKeys(int... variantIndices) { diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java index 05bc3ba985..145d01bbb5 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java @@ -27,9 +27,9 @@ import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.base.Charsets; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -461,7 +461,7 @@ public class HlsMasterPlaylistParserTest { throws IOException { Uri playlistUri = Uri.parse(uri); ByteArrayInputStream inputStream = - new ByteArrayInputStream(playlistString.getBytes(Charset.forName(C.UTF8_NAME))); + new ByteArrayInputStream(playlistString.getBytes(Charsets.UTF_8)); return (HlsMasterPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); } } diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java index dd8a32b7f0..42b51056cf 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java @@ -321,7 +321,7 @@ public class HlsMediaPlaylistParserTest { + "#EXT-X-KEY:METHOD=NONE\n" + "#EXTINF:5.005,\n" + "#EXT-X-GAP \n" - + "../dummy.ts\n" + + "../test.ts\n" + "#EXT-X-KEY:METHOD=AES-128,URI=\"https://key-service.bamgrid.com/1.0/key?" + "hex-value=9FB8989D15EEAAF8B21B860D7ED3072A\",IV=0x410C8AC18AA42EFA18B5155484F5FC34\n" + "#EXTINF:5.005,\n" diff --git a/library/smoothstreaming/README.md b/library/smoothstreaming/README.md index d53471d17c..2fab69c756 100644 --- a/library/smoothstreaming/README.md +++ b/library/smoothstreaming/README.md @@ -1,7 +1,21 @@ # ExoPlayer SmoothStreaming library module # -Provides support for Smooth Streaming content. To play Smooth Streaming content, -instantiate a `SsMediaSource` and pass it to `ExoPlayer.prepare`. +Provides support for SmoothStreaming content. + +Adding a dependency to this module is all that's required to enable playback of +SmoothStreaming `MediaItem`s added to an `ExoPlayer` or `SimpleExoPlayer` in +their default configurations. Internally, `DefaultMediaSourceFactory` will +automatically detect the presence of the module and convert SmoothStreaming +`MediaItem`s into `SsMediaSource` instances for playback. + +Similarly, a `DownloadManager` in its default configuration will use +`DefaultDownloaderFactory`, which will automatically detect the presence of +the module and build `SsDownloader` instances to download SmoothStreaming +content. + +For advanced playback use cases, applications can build `SsMediaSource` +instances and pass them directly to the player. For advanced download use cases, +`SsDownloader` can be used directly. ## Links ## diff --git a/library/smoothstreaming/build.gradle b/library/smoothstreaming/build.gradle index 404f1d6541..34fa62e096 100644 --- a/library/smoothstreaming/build.gradle +++ b/library/smoothstreaming/build.gradle @@ -11,22 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - buildTypes { debug { testCoverageEnabled = true @@ -34,14 +21,12 @@ android { } sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' - - testOptions.unitTests.includeAndroidResources = true } dependencies { implementation project(modulePrefix + 'library-core') compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion - compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion testImplementation project(modulePrefix + 'testutils') diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java index 5ce2e6a1c5..868cea7fd0 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultSsChunkSource.java @@ -25,8 +25,9 @@ import com.google.android.exoplayer2.extractor.mp4.Track; import com.google.android.exoplayer2.extractor.mp4.TrackEncryptionBox; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import com.google.android.exoplayer2.source.chunk.BundledChunkExtractor; import com.google.android.exoplayer2.source.chunk.Chunk; -import com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper; +import com.google.android.exoplayer2.source.chunk.ChunkExtractor; import com.google.android.exoplayer2.source.chunk.ChunkHolder; import com.google.android.exoplayer2.source.chunk.ContainerMediaChunk; import com.google.android.exoplayer2.source.chunk.MediaChunk; @@ -74,7 +75,7 @@ public class DefaultSsChunkSource implements SsChunkSource { private final LoaderErrorThrower manifestLoaderErrorThrower; private final int streamElementIndex; - private final ChunkExtractorWrapper[] extractorWrappers; + private final ChunkExtractor[] chunkExtractors; private final DataSource dataSource; private TrackSelection trackSelection; @@ -103,8 +104,8 @@ public class DefaultSsChunkSource implements SsChunkSource { this.dataSource = dataSource; StreamElement streamElement = manifest.streamElements[streamElementIndex]; - extractorWrappers = new ChunkExtractorWrapper[trackSelection.length()]; - for (int i = 0; i < extractorWrappers.length; i++) { + chunkExtractors = new ChunkExtractor[trackSelection.length()]; + for (int i = 0; i < chunkExtractors.length; i++) { int manifestTrackIndex = trackSelection.getIndexInTrackGroup(i); Format format = streamElement.formats[manifestTrackIndex]; @Nullable @@ -122,7 +123,7 @@ public class DefaultSsChunkSource implements SsChunkSource { | FragmentedMp4Extractor.FLAG_WORKAROUND_IGNORE_TFDT_BOX, /* timestampAdjuster= */ null, track); - extractorWrappers[i] = new ChunkExtractorWrapper(extractor, streamElement.type, format); + chunkExtractors[i] = new BundledChunkExtractor(extractor, streamElement.type, format); } } @@ -185,6 +186,15 @@ public class DefaultSsChunkSource implements SsChunkSource { return trackSelection.evaluateQueueSize(playbackPositionUs, queue); } + @Override + public boolean shouldCancelLoad( + long playbackPositionUs, Chunk loadingChunk, List queue) { + if (fatalError != null) { + return false; + } + return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, queue); + } + @Override public final void getNextChunk( long playbackPositionUs, @@ -238,7 +248,7 @@ public class DefaultSsChunkSource implements SsChunkSource { int currentAbsoluteChunkIndex = chunkIndex + currentManifestChunkOffset; int trackSelectionIndex = trackSelection.getSelectedIndex(); - ChunkExtractorWrapper extractorWrapper = extractorWrappers[trackSelectionIndex]; + ChunkExtractor chunkExtractor = chunkExtractors[trackSelectionIndex]; int manifestTrackIndex = trackSelection.getIndexInTrackGroup(trackSelectionIndex); Uri uri = streamElement.buildRequestUri(manifestTrackIndex, chunkIndex); @@ -254,7 +264,7 @@ public class DefaultSsChunkSource implements SsChunkSource { chunkSeekTimeUs, trackSelection.getSelectionReason(), trackSelection.getSelectionData(), - extractorWrapper); + chunkExtractor); } @Override @@ -264,10 +274,17 @@ public class DefaultSsChunkSource implements SsChunkSource { @Override public boolean onChunkLoadError( - Chunk chunk, boolean cancelable, Exception e, long blacklistDurationMs) { + Chunk chunk, boolean cancelable, Exception e, long exclusionDurationMs) { return cancelable - && blacklistDurationMs != C.TIME_UNSET - && trackSelection.blacklist(trackSelection.indexOf(chunk.trackFormat), blacklistDurationMs); + && exclusionDurationMs != C.TIME_UNSET + && trackSelection.blacklist(trackSelection.indexOf(chunk.trackFormat), exclusionDurationMs); + } + + @Override + public void release() { + for (ChunkExtractor chunkExtractor : chunkExtractors) { + chunkExtractor.release(); + } } // Private methods. @@ -282,7 +299,7 @@ public class DefaultSsChunkSource implements SsChunkSource { long chunkSeekTimeUs, int trackSelectionReason, @Nullable Object trackSelectionData, - ChunkExtractorWrapper extractorWrapper) { + ChunkExtractor chunkExtractor) { DataSpec dataSpec = new DataSpec(uri); // In SmoothStreaming each chunk contains sample timestamps relative to the start of the chunk. // To convert them the absolute timestamps, we need to set sampleOffsetUs to chunkStartTimeUs. @@ -300,7 +317,7 @@ public class DefaultSsChunkSource implements SsChunkSource { chunkIndex, /* chunkCount= */ 1, sampleOffsetUs, - extractorWrapper); + chunkExtractor); } private long resolveTimeToLiveEdgeUs(long playbackPositionUs) { diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java index 8efff23f43..b6e21cd870 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java @@ -19,11 +19,12 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.SequenceableLoader; import com.google.android.exoplayer2.source.TrackGroup; @@ -48,8 +49,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable private final TransferListener transferListener; private final LoaderErrorThrower manifestLoaderErrorThrower; private final DrmSessionManager drmSessionManager; + private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; - private final EventDispatcher eventDispatcher; + private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; private final Allocator allocator; private final TrackGroupArray trackGroups; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; @@ -58,7 +60,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private SsManifest manifest; private ChunkSampleStream[] sampleStreams; private SequenceableLoader compositeSequenceableLoader; - private boolean notifiedReadingStarted; public SsMediaPeriod( SsManifest manifest, @@ -66,8 +67,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable TransferListener transferListener, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, LoaderErrorThrower manifestLoaderErrorThrower, Allocator allocator) { this.manifest = manifest; @@ -75,15 +77,15 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; this.transferListener = transferListener; this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; this.drmSessionManager = drmSessionManager; + this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; - this.eventDispatcher = eventDispatcher; + this.mediaSourceEventDispatcher = mediaSourceEventDispatcher; this.allocator = allocator; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; trackGroups = buildTrackGroups(manifest, drmSessionManager); sampleStreams = newSampleStreamArray(0); compositeSequenceableLoader = compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(sampleStreams); - eventDispatcher.mediaPeriodCreated(); } public void updateManifest(SsManifest manifest) { @@ -99,7 +101,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; sampleStream.release(); } callback = null; - eventDispatcher.mediaPeriodReleased(); } // MediaPeriod implementation. @@ -196,10 +197,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } @@ -254,8 +251,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; allocator, positionUs, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, - eventDispatcher); + mediaSourceEventDispatcher); } private static TrackGroupArray buildTrackGroups( @@ -267,10 +265,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; for (int j = 0; j < manifestFormats.length; j++) { Format manifestFormat = manifestFormats[j]; exposedFormats[j] = - manifestFormat.drmInitData != null - ? manifestFormat.copyWithExoMediaCryptoType( - drmSessionManager.getExoMediaCryptoType(manifestFormat.drmInitData)) - : manifestFormat; + manifestFormat.copyWithExoMediaCryptoType( + drmSessionManager.getExoMediaCryptoType(manifestFormat)); } trackGroups[i] = new TrackGroup(exposedFormats); } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java index 03506284ec..a2ebb06936 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSource.java @@ -15,6 +15,10 @@ */ package com.google.android.exoplayer2.source.smoothstreaming; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.max; +import static java.lang.Math.min; + import android.net.Uri; import android.os.Handler; import android.os.SystemClock; @@ -23,7 +27,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.offline.FilteringManifestParser; import com.google.android.exoplayer2.offline.StreamKey; @@ -34,6 +38,7 @@ import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceDrmHelper; import com.google.android.exoplayer2.source.MediaSourceEventListener; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; import com.google.android.exoplayer2.source.MediaSourceFactory; @@ -42,10 +47,10 @@ import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest.StreamElement; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsUtil; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.upstream.Loader; @@ -54,6 +59,7 @@ import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.upstream.ParsingLoadable; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.ArrayList; @@ -72,10 +78,11 @@ public final class SsMediaSource extends BaseMediaSource public static final class Factory implements MediaSourceFactory { private final SsChunkSource.Factory chunkSourceFactory; + private final MediaSourceDrmHelper mediaSourceDrmHelper; @Nullable private final DataSource.Factory manifestDataSourceFactory; private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; - private DrmSessionManager drmSessionManager; + @Nullable private DrmSessionManager drmSessionManager; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private long livePresentationDelayMs; @Nullable private ParsingLoadable.Parser manifestParser; @@ -104,9 +111,9 @@ public final class SsMediaSource extends BaseMediaSource public Factory( SsChunkSource.Factory chunkSourceFactory, @Nullable DataSource.Factory manifestDataSourceFactory) { - this.chunkSourceFactory = Assertions.checkNotNull(chunkSourceFactory); + this.chunkSourceFactory = checkNotNull(chunkSourceFactory); this.manifestDataSourceFactory = manifestDataSourceFactory; - drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); + mediaSourceDrmHelper = new MediaSourceDrmHelper(); loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); livePresentationDelayMs = DEFAULT_LIVE_PRESENTATION_DELAY_MS; compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); @@ -192,19 +199,22 @@ public final class SsMediaSource extends BaseMediaSource return this; } - /** - * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The - * default value is {@link DrmSessionManager#DUMMY}. - * - * @param drmSessionManager The {@link DrmSessionManager}. - * @return This factory, for convenience. - */ @Override public Factory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManager) { - this.drmSessionManager = - drmSessionManager != null - ? drmSessionManager - : DrmSessionManager.getDummyDrmSessionManager(); + this.drmSessionManager = drmSessionManager; + return this; + } + + @Override + public Factory setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { + mediaSourceDrmHelper.setDrmHttpDataSourceFactory(drmHttpDataSourceFactory); + return this; + } + + @Override + public Factory setDrmUserAgent(@Nullable String userAgent) { + mediaSourceDrmHelper.setDrmUserAgent(userAgent); return this; } @@ -237,21 +247,47 @@ public final class SsMediaSource extends BaseMediaSource * @throws IllegalArgumentException If {@link SsManifest#isLive} is true. */ public SsMediaSource createMediaSource(SsManifest manifest) { + return createMediaSource(manifest, MediaItem.fromUri(Uri.EMPTY)); + } + + /** + * Returns a new {@link SsMediaSource} using the current parameters and the specified sideloaded + * manifest. + * + * @param manifest The manifest. {@link SsManifest#isLive} must be false. + * @param mediaItem The {@link MediaItem} to be included in the timeline. + * @return The new {@link SsMediaSource}. + * @throws IllegalArgumentException If {@link SsManifest#isLive} is true. + */ + public SsMediaSource createMediaSource(SsManifest manifest, MediaItem mediaItem) { Assertions.checkArgument(!manifest.isLive); + List streamKeys = + mediaItem.playbackProperties != null && !mediaItem.playbackProperties.streamKeys.isEmpty() + ? mediaItem.playbackProperties.streamKeys + : this.streamKeys; if (!streamKeys.isEmpty()) { manifest = manifest.copy(streamKeys); } + boolean hasUri = mediaItem.playbackProperties != null; + boolean hasTag = hasUri && mediaItem.playbackProperties.tag != null; + mediaItem = + mediaItem + .buildUpon() + .setMimeType(MimeTypes.APPLICATION_SS) + .setUri(hasUri ? mediaItem.playbackProperties.uri : Uri.EMPTY) + .setTag(hasTag ? mediaItem.playbackProperties.tag : tag) + .setStreamKeys(streamKeys) + .build(); return new SsMediaSource( + mediaItem, manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, compositeSequenceableLoaderFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, - livePresentationDelayMs, - tag); + livePresentationDelayMs); } /** @@ -271,9 +307,10 @@ public final class SsMediaSource extends BaseMediaSource } /** - * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * @deprecated Use {@link #createMediaSource(MediaItem)} and {@link #addEventListener(Handler, * MediaSourceEventListener)} instead. */ + @SuppressWarnings("deprecation") @Deprecated public SsMediaSource createMediaSource( Uri manifestUri, @@ -295,7 +332,7 @@ public final class SsMediaSource extends BaseMediaSource */ @Override public SsMediaSource createMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); + checkNotNull(mediaItem.playbackProperties); @Nullable ParsingLoadable.Parser manifestParser = this.manifestParser; if (manifestParser == null) { manifestParser = new SsManifestParser(); @@ -307,17 +344,27 @@ public final class SsMediaSource extends BaseMediaSource if (!streamKeys.isEmpty()) { manifestParser = new FilteringManifestParser<>(manifestParser, streamKeys); } + + boolean needsTag = mediaItem.playbackProperties.tag == null && tag != null; + boolean needsStreamKeys = + mediaItem.playbackProperties.streamKeys.isEmpty() && !streamKeys.isEmpty(); + if (needsTag && needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setTag(tag).setStreamKeys(streamKeys).build(); + } else if (needsTag) { + mediaItem = mediaItem.buildUpon().setTag(tag).build(); + } else if (needsStreamKeys) { + mediaItem = mediaItem.buildUpon().setStreamKeys(streamKeys).build(); + } return new SsMediaSource( + mediaItem, /* manifest= */ null, - mediaItem.playbackProperties.uri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, compositeSequenceableLoaderFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, - livePresentationDelayMs, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + livePresentationDelayMs); } @Override @@ -330,7 +377,7 @@ public final class SsMediaSource extends BaseMediaSource * The default presentation delay for live streams. The presentation delay is the duration by * which the default start position precedes the end of the live window. */ - public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30000; + public static final long DEFAULT_LIVE_PRESENTATION_DELAY_MS = 30_000; /** * The minimum period between manifest refreshes. @@ -339,10 +386,12 @@ public final class SsMediaSource extends BaseMediaSource /** * The minimum default start position for live streams, relative to the start of the live window. */ - private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5000000; + private static final long MIN_LIVE_DEFAULT_START_POSITION_US = 5_000_000; private final boolean sideloadedManifest; private final Uri manifestUri; + private final MediaItem.PlaybackProperties playbackProperties; + private final MediaItem mediaItem; private final DataSource.Factory manifestDataSourceFactory; private final SsChunkSource.Factory chunkSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; @@ -352,7 +401,6 @@ public final class SsMediaSource extends BaseMediaSource private final EventDispatcher manifestEventDispatcher; private final ParsingLoadable.Parser manifestParser; private final ArrayList mediaPeriods; - @Nullable private final Object tag; private DataSource manifestDataSource; private Loader manifestLoader; @@ -406,16 +454,15 @@ public final class SsMediaSource extends BaseMediaSource @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder().setUri(Uri.EMPTY).setMimeType(MimeTypes.APPLICATION_SS).build(), manifest, - /* manifestUri= */ null, /* manifestDataSourceFactory= */ null, /* manifestParser= */ null, chunkSourceFactory, new DefaultCompositeSequenceableLoaderFactory(), DrmSessionManager.getDummyDrmSessionManager(), new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), - DEFAULT_LIVE_PRESENTATION_DELAY_MS, - /* tag= */ null); + DEFAULT_LIVE_PRESENTATION_DELAY_MS); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } @@ -507,35 +554,38 @@ public final class SsMediaSource extends BaseMediaSource @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder().setUri(manifestUri).setMimeType(MimeTypes.APPLICATION_SS).build(), /* manifest= */ null, - manifestUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, new DefaultCompositeSequenceableLoaderFactory(), DrmSessionManager.getDummyDrmSessionManager(), new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), - livePresentationDelayMs, - /* tag= */ null); + livePresentationDelayMs); if (eventHandler != null && eventListener != null) { addEventListener(eventHandler, eventListener); } } private SsMediaSource( + MediaItem mediaItem, @Nullable SsManifest manifest, - @Nullable Uri manifestUri, @Nullable DataSource.Factory manifestDataSourceFactory, @Nullable ParsingLoadable.Parser manifestParser, SsChunkSource.Factory chunkSourceFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - long livePresentationDelayMs, - @Nullable Object tag) { + long livePresentationDelayMs) { Assertions.checkState(manifest == null || !manifest.isLive); + this.mediaItem = mediaItem; + playbackProperties = checkNotNull(mediaItem.playbackProperties); this.manifest = manifest; - this.manifestUri = manifestUri == null ? null : SsUtil.fixManifestUri(manifestUri); + this.manifestUri = + playbackProperties.uri.equals(Uri.EMPTY) + ? null + : Util.fixSmoothStreamingIsmManifestUri(playbackProperties.uri); this.manifestDataSourceFactory = manifestDataSourceFactory; this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; @@ -544,17 +594,26 @@ public final class SsMediaSource extends BaseMediaSource this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.livePresentationDelayMs = livePresentationDelayMs; this.manifestEventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); - this.tag = tag; sideloadedManifest = manifest != null; mediaPeriods = new ArrayList<>(); } // MediaSource implementation. + /** + * @deprecated Use {@link #getMediaItem()} and {@link MediaItem.PlaybackProperties#tag} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated @Override @Nullable public Object getTag() { - return tag; + return playbackProperties.tag; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; } @Override @@ -568,7 +627,7 @@ public final class SsMediaSource extends BaseMediaSource manifestDataSource = manifestDataSourceFactory.createDataSource(); manifestLoader = new Loader("Loader:Manifest"); manifestLoaderErrorThrower = manifestLoader; - manifestRefreshHandler = Util.createHandler(); + manifestRefreshHandler = Util.createHandlerForCurrentLooper(); startLoadingManifest(); } } @@ -580,7 +639,8 @@ public final class SsMediaSource extends BaseMediaSource @Override public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { - EventDispatcher eventDispatcher = createEventDispatcher(id); + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher = createEventDispatcher(id); + DrmSessionEventListener.EventDispatcher drmEventDispatcher = createDrmEventDispatcher(id); SsMediaPeriod period = new SsMediaPeriod( manifest, @@ -588,8 +648,9 @@ public final class SsMediaSource extends BaseMediaSource mediaTransferListener, compositeSequenceableLoaderFactory, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, - eventDispatcher, + mediaSourceEventDispatcher, manifestLoaderErrorThrower, allocator); mediaPeriods.add(period); @@ -702,9 +763,12 @@ public final class SsMediaSource extends BaseMediaSource long endTimeUs = Long.MIN_VALUE; for (StreamElement element : manifest.streamElements) { if (element.chunkCount > 0) { - startTimeUs = Math.min(startTimeUs, element.getStartTimeUs(0)); - endTimeUs = Math.max(endTimeUs, element.getStartTimeUs(element.chunkCount - 1) - + element.getChunkDurationUs(element.chunkCount - 1)); + startTimeUs = min(startTimeUs, element.getStartTimeUs(0)); + endTimeUs = + max( + endTimeUs, + element.getStartTimeUs(element.chunkCount - 1) + + element.getChunkDurationUs(element.chunkCount - 1)); } } @@ -721,10 +785,10 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ manifest.isLive, /* isLive= */ manifest.isLive, manifest, - tag); + mediaItem); } else if (manifest.isLive) { if (manifest.dvrWindowLengthUs != C.TIME_UNSET && manifest.dvrWindowLengthUs > 0) { - startTimeUs = Math.max(startTimeUs, endTimeUs - manifest.dvrWindowLengthUs); + startTimeUs = max(startTimeUs, endTimeUs - manifest.dvrWindowLengthUs); } long durationUs = endTimeUs - startTimeUs; long defaultStartPositionUs = durationUs - C.msToUs(livePresentationDelayMs); @@ -732,7 +796,7 @@ public final class SsMediaSource extends BaseMediaSource // The default start position is too close to the start of the live window. Set it to the // minimum default start position provided the window is at least twice as big. Else set // it to the middle of the window. - defaultStartPositionUs = Math.min(MIN_LIVE_DEFAULT_START_POSITION_US, durationUs / 2); + defaultStartPositionUs = min(MIN_LIVE_DEFAULT_START_POSITION_US, durationUs / 2); } timeline = new SinglePeriodTimeline( @@ -744,7 +808,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ true, /* isLive= */ true, manifest, - tag); + mediaItem); } else { long durationUs = manifest.durationUs != C.TIME_UNSET ? manifest.durationUs : endTimeUs - startTimeUs; @@ -758,7 +822,7 @@ public final class SsMediaSource extends BaseMediaSource /* isDynamic= */ false, /* isLive= */ false, manifest, - tag); + mediaItem); } refreshSourceInfo(timeline); } @@ -768,7 +832,7 @@ public final class SsMediaSource extends BaseMediaSource return; } long nextLoadTimestamp = manifestLoadStartTimestamp + MINIMUM_MANIFEST_REFRESH_PERIOD_MS; - long delayUntilNextLoad = Math.max(0, nextLoadTimestamp - SystemClock.elapsedRealtime()); + long delayUntilNextLoad = max(0, nextLoadTimestamp - SystemClock.elapsedRealtime()); manifestRefreshHandler.postDelayed(this::startLoadingManifest, delayUntilNextLoad); } diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsUtil.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsUtil.java deleted file mode 100644 index b54b2abc74..0000000000 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsUtil.java +++ /dev/null @@ -1,35 +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.source.smoothstreaming.manifest; - -import android.net.Uri; -import com.google.android.exoplayer2.util.Util; - -/** SmoothStreaming related utility methods. */ -public final class SsUtil { - - /** Returns a fixed SmoothStreaming client manifest {@link Uri}. */ - public static Uri fixManifestUri(Uri manifestUri) { - String lastPathSegment = manifestUri.getLastPathSegment(); - if (lastPathSegment != null - && Util.toLowerInvariant(lastPathSegment).matches("manifest(\\(.+\\))?")) { - return manifestUri; - } - return Uri.withAppendedPath(manifestUri, "Manifest"); - } - - private SsUtil() {} -} diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java index 3a2cf10439..998820de4b 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloader.java @@ -15,16 +15,20 @@ */ package com.google.android.exoplayer2.source.smoothstreaming.offline; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.net.Uri; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.offline.SegmentDownloader; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest.StreamElement; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; -import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsUtil; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executor; @@ -43,8 +47,10 @@ import java.util.concurrent.Executor; * // Create a downloader for the first track of the first stream element. * SsDownloader ssDownloader = * new SsDownloader( - * manifestUrl, - * Collections.singletonList(new StreamKey(0, 0)), + * new MediaItem.Builder() + * .setUri(manifestUri) + * .setStreamKeys(Collections.singletonList(new StreamKey(0, 0))) + * .build(), * cacheDataSourceFactory); * // Perform the download. * ssDownloader.download(progressListener); @@ -56,21 +62,45 @@ import java.util.concurrent.Executor; public final class SsDownloader extends SegmentDownloader { /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which streams in the manifest should be selected for download. - * If empty, all streams are downloaded. - * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the - * download will be written. + * @deprecated Use {@link #SsDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public SsDownloader( Uri manifestUri, List streamKeys, CacheDataSource.Factory cacheDataSourceFactory) { this(manifestUri, streamKeys, cacheDataSourceFactory, Runnable::run); } /** - * @param manifestUri The {@link Uri} of the manifest to be downloaded. - * @param streamKeys Keys defining which streams in the manifest should be selected for download. - * If empty, all streams are downloaded. + * Creates an instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + */ + public SsDownloader(MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory) { + this(mediaItem, cacheDataSourceFactory, Runnable::run); + } + + /** + * @deprecated Use {@link #SsDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. + */ + @Deprecated + public SsDownloader( + Uri manifestUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(manifestUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); + } + + /** + * Creates an instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the * download will be written. * @param executor An {@link Executor} used to make requests for the media being downloaded. @@ -78,18 +108,38 @@ public final class SsDownloader extends SegmentDownloader { * allowing parts of it to be executed in parallel. */ public SsDownloader( - Uri manifestUri, - List streamKeys, - CacheDataSource.Factory cacheDataSourceFactory, - Executor executor) { - super( - SsUtil.fixManifestUri(manifestUri), + MediaItem mediaItem, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + this( + mediaItem + .buildUpon() + .setUri( + Util.fixSmoothStreamingIsmManifestUri( + checkNotNull(mediaItem.playbackProperties).uri)) + .build(), new SsManifestParser(), - streamKeys, cacheDataSourceFactory, executor); } + /** + * Creates a new instance. + * + * @param mediaItem The {@link MediaItem} to be downloaded. + * @param manifestParser A parser for SmoothStreaming manifests. + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which the + * download will be written. + * @param executor An {@link Executor} used to make requests for the media being downloaded. + * Providing an {@link Executor} that uses multiple threads will speed up the download by + * allowing parts of it to be executed in parallel. + */ + public SsDownloader( + MediaItem mediaItem, + Parser manifestParser, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + super(mediaItem, manifestParser, cacheDataSourceFactory, executor); + } + @Override protected List getSegments( DataSource dataSource, SsManifest manifest, boolean allowIncompleteList) { diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java index 37b686183f..43c62071d3 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source.smoothstreaming; import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -38,7 +39,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withMimeType_smoothstreamingSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_SS).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -49,7 +50,7 @@ public class DefaultMediaSourceFactoryTest { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() .setUri(URI_MEDIA) @@ -59,13 +60,13 @@ public class DefaultMediaSourceFactoryTest { MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); - assertThat(mediaSource.getTag()).isEqualTo(tag); + assertThat(mediaSource.getMediaItem().playbackProperties.tag).isEqualTo(tag); } @Test public void createMediaSource_withIsmPath_smoothstreamingSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.ism").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -76,7 +77,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withManifestPath_smoothstreamingSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + ".ism/Manifest").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -87,7 +88,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.ism").build(); MediaSource mediaSource = @@ -103,7 +104,7 @@ public class DefaultMediaSourceFactoryTest { @Test public void getSupportedTypes_smoothstreamingModule_containsTypeSS() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_SS); diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java index a20e5790a7..81648706c4 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriodTest.java @@ -22,10 +22,11 @@ import static org.mockito.Mockito.mock; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; 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.MediaSourceEventListener; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.testutil.MediaPeriodAsserts; import com.google.android.exoplayer2.testutil.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory; @@ -36,11 +37,9 @@ import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.annotation.LooperMode; /** Unit tests for {@link SsMediaPeriod}. */ @RunWith(AndroidJUnit4.class) -@LooperMode(LooperMode.Mode.PAUSED) public class SsMediaPeriodTest { @Test @@ -61,21 +60,22 @@ public class SsMediaPeriodTest { createStreamElement( /* name= */ "text", C.TRACK_TYPE_TEXT, createTextFormat(/* language= */ "eng"))); FilterableManifestMediaPeriodFactory mediaPeriodFactory = - (manifest, periodIndex) -> - new SsMediaPeriod( - manifest, - mock(SsChunkSource.Factory.class), - mock(TransferListener.class), - mock(CompositeSequenceableLoaderFactory.class), - mock(DrmSessionManager.class), - mock(LoadErrorHandlingPolicy.class), - new EventDispatcher() - .withParameters( - /* windowIndex= */ 0, - /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), - /* mediaTimeOffsetMs= */ 0), - mock(LoaderErrorThrower.class), - mock(Allocator.class)); + (manifest, periodIndex) -> { + MediaPeriodId mediaPeriodId = new MediaPeriodId(/* periodUid= */ new Object()); + return new SsMediaPeriod( + manifest, + mock(SsChunkSource.Factory.class), + mock(TransferListener.class), + mock(CompositeSequenceableLoaderFactory.class), + mock(DrmSessionManager.class), + new DrmSessionEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId), + mock(LoadErrorHandlingPolicy.class), + new MediaSourceEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId, /* mediaTimeOffsetMs= */ 0), + mock(LoaderErrorThrower.class), + mock(Allocator.class)); + }; MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration( mediaPeriodFactory, testManifest); diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java new file mode 100644 index 0000000000..1f28d2263b --- /dev/null +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaSourceTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.smoothstreaming; + +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static com.google.common.truth.Truth.assertThat; + +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.upstream.FileDataSource; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link SsMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public class SsMediaSourceTest { + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nullMediaItemTag_setsMediaItemTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.tag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_nonNullMediaItemTag_doesNotOverrideMediaItemTag() { + Object factoryTag = new Object(); + Object mediaItemTag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(mediaItemTag).build(); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()).setTag(factoryTag); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.tag).isEqualTo(mediaItemTag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetTag_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()).setTag(tag); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factoryCreateMediaSource_setsDeprecatedMediaSourceTag() { + Object tag = new Object(); + MediaItem mediaItem = + new MediaItem.Builder().setUri("http://www.google.com").setTag(tag).build(); + SsMediaSource.Factory factory = new SsMediaSource.Factory(new FileDataSource.Factory()); + + @Nullable Object mediaSourceTag = factory.createMediaSource(mediaItem).getTag(); + + assertThat(mediaSourceTag).isEqualTo(tag); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_emptyMediaItemStreamKeys_setsMediaItemStreamKeys() { + MediaItem mediaItem = MediaItem.fromUri("http://www.google.com"); + StreamKey streamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()) + .setStreamKeys(Collections.singletonList(streamKey)); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.streamKeys).containsExactly(streamKey); + } + + // Tests backwards compatibility + @SuppressWarnings("deprecation") + @Test + public void factorySetStreamKeys_withMediaItemStreamKeys_doesNotOverrideMediaItemStreamKeys() { + StreamKey mediaItemStreamKey = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 1); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("http://www.google.com") + .setStreamKeys(Collections.singletonList(mediaItemStreamKey)) + .build(); + SsMediaSource.Factory factory = + new SsMediaSource.Factory(new FileDataSource.Factory()) + .setStreamKeys( + Collections.singletonList(new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 0))); + + MediaItem ssMediaItem = factory.createMediaSource(mediaItem).getMediaItem(); + + assertThat(ssMediaItem.playbackProperties).isNotNull(); + assertThat(ssMediaItem.playbackProperties.uri) + .isEqualTo(castNonNull(mediaItem.playbackProperties).uri); + assertThat(ssMediaItem.playbackProperties.streamKeys).containsExactly(mediaItemStreamKey); + } +} diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParserTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParserTest.java index 60d9c40dc3..e5a7ee5add 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParserTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifestParserTest.java @@ -27,8 +27,8 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public final class SsManifestParserTest { - private static final String SAMPLE_ISMC_1 = "smooth-streaming/sample_ismc_1"; - private static final String SAMPLE_ISMC_2 = "smooth-streaming/sample_ismc_2"; + private static final String SAMPLE_ISMC_1 = "media/smooth-streaming/sample_ismc_1"; + private static final String SAMPLE_ISMC_2 = "media/smooth-streaming/sample_ismc_2"; /** Simple test to ensure the sample manifests parse without any exceptions being thrown. */ @Test diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java index b6d29d8b72..df1a0bd6da 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/DownloadHelperTest.java @@ -15,12 +15,13 @@ */ package com.google.android.exoplayer2.source.smoothstreaming.offline; -import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.testutil.FakeDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import org.junit.Test; import org.junit.runner.RunWith; @@ -30,16 +31,16 @@ public final class DownloadHelperTest { @Test public void staticDownloadHelperForSmoothStreaming_doesNotThrow() { - DownloadHelper.forSmoothStreaming( + DownloadHelper.forMediaItem( ApplicationProvider.getApplicationContext(), - Uri.parse("http://uri"), - new FakeDataSource.Factory(), - (handler, videoListener, audioListener, text, metadata) -> new Renderer[0]); - DownloadHelper.forSmoothStreaming( - Uri.parse("http://uri"), - new FakeDataSource.Factory(), + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_SS).build(), (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], - /* drmSessionManager= */ null, - DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + new FakeDataSource.Factory()); + DownloadHelper.forMediaItem( + new MediaItem.Builder().setUri("http://uri").setMimeType(MimeTypes.APPLICATION_SS).build(), + DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT, + (handler, videoListener, audioListener, text, metadata) -> new Renderer[0], + new FakeDataSource.Factory(), + /* drmSessionManager= */ null); } } diff --git a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java index 1bbe0b191d..38132b55ed 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/offline/SsDownloaderTest.java @@ -27,6 +27,7 @@ import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.cache.Cache; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.util.MimeTypes; import java.util.Collections; import org.junit.Test; import org.junit.runner.RunWith; @@ -42,17 +43,17 @@ public final class SsDownloaderTest { new CacheDataSource.Factory() .setCache(Mockito.mock(Cache.class)) .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(cacheDataSourceFactory); + DownloaderFactory factory = + new DefaultDownloaderFactory(cacheDataSourceFactory, /* executor= */ Runnable::run); Downloader downloader = factory.createDownloader( - new DownloadRequest( - "id", - DownloadRequest.TYPE_SS, - Uri.parse("https://www.test.com/download"), - Collections.singletonList(new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0)), - /* customCacheKey= */ null, - /* data= */ null)); + new DownloadRequest.Builder(/* id= */ "id", Uri.parse("https://www.test.com/download")) + .setMimeType(MimeTypes.APPLICATION_SS) + .setStreamKeys( + Collections.singletonList( + new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0))) + .build()); assertThat(downloader).isInstanceOf(SsDownloader.class); } } diff --git a/library/ui/build.gradle b/library/ui/build.gradle index f404ee38a5..f63e55b3b3 100644 --- a/library/ui/build.gradle +++ b/library/ui/build.gradle @@ -11,35 +11,22 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -apply from: '../../constants.gradle' -apply plugin: 'com.android.library' +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" -android { - compileSdkVersion project.ext.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - minSdkVersion project.ext.minSdkVersion - targetSdkVersion project.ext.targetSdkVersion - } - - buildTypes { - debug { - testCoverageEnabled = true - } - } - - testOptions.unitTests.includeAndroidResources = true -} +android.buildTypes.debug.testCoverageEnabled true dependencies { implementation project(modulePrefix + 'library-core') api 'androidx.media:media:' + androidxMediaVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'androidx.recyclerview:recyclerview:' + androidxRecyclerViewVersion + implementation ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'com.google.errorprone', module: 'error_prone_annotations' + exclude group: 'com.google.j2objc', module: 'j2objc-annotations' + exclude group: 'org.codehaus.mojo', module: 'animal-sniffer-annotations' + } compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion testImplementation project(modulePrefix + 'testutils') diff --git a/library/ui/proguard-rules.txt b/library/ui/proguard-rules.txt new file mode 100644 index 0000000000..ad7c139ea8 --- /dev/null +++ b/library/ui/proguard-rules.txt @@ -0,0 +1,18 @@ +# Proguard rules specific to the UI module. + +# Constructor method accessed via reflection in TrackSelectionDialogBuilder +-dontnote androidx.appcompat.app.AlertDialog.Builder +-keepclassmembers class androidx.appcompat.app.AlertDialog$Builder { + (android.content.Context, int); + public android.content.Context getContext(); + public androidx.appcompat.app.AlertDialog$Builder setTitle(java.lang.CharSequence); + public androidx.appcompat.app.AlertDialog$Builder setView(android.view.View); + public androidx.appcompat.app.AlertDialog$Builder setPositiveButton(int, android.content.DialogInterface$OnClickListener); + public androidx.appcompat.app.AlertDialog$Builder setNegativeButton(int, android.content.DialogInterface$OnClickListener); + public androidx.appcompat.app.AlertDialog create(); +} + +# Don't warn about checkerframework and Kotlin annotations +-dontwarn org.checkerframework.** +-dontwarn kotlin.annotations.jvm.** +-dontwarn javax.annotation.** diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleTextView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java similarity index 75% rename from library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleTextView.java rename to library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java index 9d0dfb78a2..19ad36c29d 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleTextView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java @@ -30,43 +30,45 @@ import java.util.Collections; import java.util.List; /** - * A {@link SubtitleView.Output} that uses Android's native text tooling via {@link + * A {@link SubtitleView.Output} that uses Android's native layout framework via {@link * SubtitlePainter}. */ -/* package */ final class SubtitleTextView extends View implements SubtitleView.Output { +/* package */ final class CanvasSubtitleOutput extends View implements SubtitleView.Output { private final List painters; private List cues; @Cue.TextSizeType private int textSizeType; private float textSize; - private boolean applyEmbeddedStyles; - private boolean applyEmbeddedFontSizes; private CaptionStyleCompat style; private float bottomPaddingFraction; - public SubtitleTextView(Context context) { + public CanvasSubtitleOutput(Context context) { this(context, /* attrs= */ null); } - public SubtitleTextView(Context context, @Nullable AttributeSet attrs) { + public CanvasSubtitleOutput(Context context, @Nullable AttributeSet attrs) { super(context, attrs); painters = new ArrayList<>(); cues = Collections.emptyList(); textSizeType = Cue.TEXT_SIZE_TYPE_FRACTIONAL; textSize = DEFAULT_TEXT_SIZE_FRACTION; - applyEmbeddedStyles = true; - applyEmbeddedFontSizes = true; style = CaptionStyleCompat.DEFAULT; bottomPaddingFraction = DEFAULT_BOTTOM_PADDING_FRACTION; } @Override - public void onCues(List cues) { - if (this.cues == cues || this.cues.isEmpty() && cues.isEmpty()) { - return; - } + public void update( + List cues, + CaptionStyleCompat style, + float textSize, + @Cue.TextSizeType int textSizeType, + float bottomPaddingFraction) { this.cues = cues; + this.style = style; + this.textSize = textSize; + this.textSizeType = textSizeType; + this.bottomPaddingFraction = bottomPaddingFraction; // Ensure we have sufficient painters. while (painters.size() < cues.size()) { painters.add(new SubtitlePainter(getContext())); @@ -75,54 +77,6 @@ import java.util.List; invalidate(); } - @Override - public void setTextSize(@Cue.TextSizeType int textSizeType, float textSize) { - if (this.textSizeType == textSizeType && this.textSize == textSize) { - return; - } - this.textSizeType = textSizeType; - this.textSize = textSize; - invalidate(); - } - - @Override - public void setApplyEmbeddedStyles(boolean applyEmbeddedStyles) { - if (this.applyEmbeddedStyles == applyEmbeddedStyles - && this.applyEmbeddedFontSizes == applyEmbeddedStyles) { - return; - } - this.applyEmbeddedStyles = applyEmbeddedStyles; - this.applyEmbeddedFontSizes = applyEmbeddedStyles; - invalidate(); - } - - @Override - public void setApplyEmbeddedFontSizes(boolean applyEmbeddedFontSizes) { - if (this.applyEmbeddedFontSizes == applyEmbeddedFontSizes) { - return; - } - this.applyEmbeddedFontSizes = applyEmbeddedFontSizes; - invalidate(); - } - - @Override - public void setStyle(CaptionStyleCompat style) { - if (this.style == style) { - return; - } - this.style = style; - invalidate(); - } - - @Override - public void setBottomPaddingFraction(float bottomPaddingFraction) { - if (this.bottomPaddingFraction == bottomPaddingFraction) { - return; - } - this.bottomPaddingFraction = bottomPaddingFraction; - invalidate(); - } - @Override public void dispatchDraw(Canvas canvas) { @Nullable List cues = this.cues; @@ -163,8 +117,6 @@ import java.util.List; SubtitlePainter painter = painters.get(i); painter.draw( cue, - applyEmbeddedStyles, - applyEmbeddedFontSizes, style, defaultViewTextSizePx, cueTextSizePx, diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java index 44c0035278..24d890134a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DefaultTimeBar.java @@ -450,6 +450,7 @@ public class DefaultTimeBar extends View implements TimeBar { @Override public void addListener(OnScrubListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java index 178cd44dd3..83da4d54a8 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationHelper.java @@ -31,7 +31,6 @@ public final class DownloadNotificationHelper { private static final @StringRes int NULL_STRING_ID = 0; - private final Context context; private final NotificationCompat.Builder notificationBuilder; /** @@ -39,14 +38,14 @@ public final class DownloadNotificationHelper { * @param channelId The id of the notification channel to use. */ public DownloadNotificationHelper(Context context, String channelId) { - context = context.getApplicationContext(); - this.context = context; - this.notificationBuilder = new NotificationCompat.Builder(context, channelId); + this.notificationBuilder = + new NotificationCompat.Builder(context.getApplicationContext(), channelId); } /** * Returns a progress notification for the given downloads. * + * @param context A context. * @param smallIcon A small icon for the notification. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. @@ -54,6 +53,7 @@ public final class DownloadNotificationHelper { * @return The notification. */ public Notification buildProgressNotification( + Context context, @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message, @@ -95,6 +95,7 @@ public final class DownloadNotificationHelper { indeterminate = allDownloadPercentagesUnknown && haveDownloadedBytes; } return buildNotification( + context, smallIcon, contentIntent, message, @@ -109,37 +110,47 @@ public final class DownloadNotificationHelper { /** * Returns a notification for a completed download. * + * @param context A context. * @param smallIcon A small icon for the notifications. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. * @return The notification. */ public Notification buildDownloadCompletedNotification( - @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) { + Context context, + @DrawableRes int smallIcon, + @Nullable PendingIntent contentIntent, + @Nullable String message) { int titleStringId = R.string.exo_download_completed; - return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId); + return buildEndStateNotification(context, smallIcon, contentIntent, message, titleStringId); } /** * Returns a notification for a failed download. * + * @param context A context. * @param smallIcon A small icon for the notifications. * @param contentIntent An optional content intent to send when the notification is clicked. * @param message An optional message to display on the notification. * @return The notification. */ public Notification buildDownloadFailedNotification( - @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) { + Context context, + @DrawableRes int smallIcon, + @Nullable PendingIntent contentIntent, + @Nullable String message) { @StringRes int titleStringId = R.string.exo_download_failed; - return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId); + return buildEndStateNotification(context, smallIcon, contentIntent, message, titleStringId); } private Notification buildEndStateNotification( + Context context, @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message, @StringRes int titleStringId) { return buildNotification( + context, smallIcon, contentIntent, message, @@ -152,6 +163,7 @@ public final class DownloadNotificationHelper { } private Notification buildNotification( + Context context, @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message, diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java index 223a97f69c..8c03dbea42 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/DownloadNotificationUtil.java @@ -52,7 +52,7 @@ public final class DownloadNotificationUtil { @Nullable String message, List downloads) { return new DownloadNotificationHelper(context, channelId) - .buildProgressNotification(smallIcon, contentIntent, message, downloads); + .buildProgressNotification(context, smallIcon, contentIntent, message, downloads); } /** @@ -72,7 +72,7 @@ public final class DownloadNotificationUtil { @Nullable PendingIntent contentIntent, @Nullable String message) { return new DownloadNotificationHelper(context, channelId) - .buildDownloadCompletedNotification(smallIcon, contentIntent, message); + .buildDownloadCompletedNotification(context, smallIcon, contentIntent, message); } /** @@ -92,6 +92,6 @@ public final class DownloadNotificationUtil { @Nullable PendingIntent contentIntent, @Nullable String message) { return new DownloadNotificationHelper(context, channelId) - .buildDownloadFailedNotification(smallIcon, contentIntent, message); + .buildDownloadFailedNotification(context, smallIcon, contentIntent, message); } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java index 0edee287a9..13a14d0033 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java @@ -20,7 +20,7 @@ import androidx.annotation.ColorInt; import com.google.android.exoplayer2.util.Util; /** - * Utility methods for generating HTML and CSS for use with {@link SubtitleWebView} and {@link + * Utility methods for generating HTML and CSS for use with {@link WebViewSubtitleOutput} and {@link * SpannedToHtmlConverter}. */ /* package */ final class HtmlUtils { @@ -32,4 +32,12 @@ import com.google.android.exoplayer2.util.Util; "rgba(%d,%d,%d,%.3f)", Color.red(color), Color.green(color), Color.blue(color), Color.alpha(color) / 255.0); } + + /** + * Returns a CSS selector that selects all elements with {@code class=className} and all their + * descendants. + */ + public static String cssAllClassDescendantsSelector(String className) { + return "." + className + ",." + className + " *"; + } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java deleted file mode 100644 index 47d60e0233..0000000000 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlaybackControlView.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ui; - -import android.content.Context; -import android.util.AttributeSet; - -/** @deprecated Use {@link PlayerControlView}. */ -@Deprecated -public class PlaybackControlView extends PlayerControlView { - - /** @deprecated Use {@link com.google.android.exoplayer2.ControlDispatcher}. */ - @Deprecated - public interface ControlDispatcher extends com.google.android.exoplayer2.ControlDispatcher {} - - @Deprecated - @SuppressWarnings("deprecation") - private static final class DefaultControlDispatcher - extends com.google.android.exoplayer2.DefaultControlDispatcher implements ControlDispatcher {} - /** @deprecated Use {@link com.google.android.exoplayer2.DefaultControlDispatcher}. */ - @Deprecated - @SuppressWarnings("deprecation") - public static final ControlDispatcher DEFAULT_CONTROL_DISPATCHER = new DefaultControlDispatcher(); - - public PlaybackControlView(Context context) { - super(context); - } - - public PlaybackControlView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public PlaybackControlView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public PlaybackControlView( - Context context, AttributeSet attrs, int defStyleAttr, AttributeSet playbackAttrs) { - super(context, attrs, defStyleAttr, playbackAttrs); - } -} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java index 778f033f0c..65a9a5ed8f 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.DefaultControlDispatcher; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.State; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.RepeatModeUtil; @@ -66,6 +67,26 @@ import java.util.concurrent.CopyOnWriteArrayList; *

    11. Corresponding method: {@link #setShowTimeoutMs(int)} *
    12. Default: {@link #DEFAULT_SHOW_TIMEOUT_MS} * + *
    13. {@code show_rewind_button} - Whether the rewind button is shown. + *
        + *
      • Corresponding method: {@link #setShowRewindButton(boolean)} + *
      • Default: true + *
      + *
    14. {@code show_fastforward_button} - Whether the fast forward button is shown. + *
        + *
      • Corresponding method: {@link #setShowFastForwardButton(boolean)} + *
      • Default: true + *
      + *
    15. {@code show_previous_button} - Whether the previous button is shown. + *
        + *
      • Corresponding method: {@link #setShowPreviousButton(boolean)} + *
      • Default: true + *
      + *
    16. {@code show_next_button} - Whether the next button is shown. + *
        + *
      • Corresponding method: {@link #setShowNextButton(boolean)} + *
      • Default: true + *
      *
    17. {@code rewind_increment} - The duration of the rewind applied when the user taps the * rewind button, in milliseconds. Use zero to disable the rewind button. *
        @@ -305,6 +326,10 @@ public class PlayerControlView extends FrameLayout { private int showTimeoutMs; private int timeBarMinUpdateIntervalMs; private @RepeatModeUtil.RepeatToggleModes int repeatToggleModes; + private boolean showRewindButton; + private boolean showFastForwardButton; + private boolean showPreviousButton; + private boolean showNextButton; private boolean showShuffleButton; private long hideAtMs; private long[] adGroupTimesMs; @@ -341,6 +366,10 @@ public class PlayerControlView extends FrameLayout { repeatToggleModes = DEFAULT_REPEAT_TOGGLE_MODES; timeBarMinUpdateIntervalMs = DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS; hideAtMs = C.TIME_UNSET; + showRewindButton = true; + showFastForwardButton = true; + showPreviousButton = true; + showNextButton = true; showShuffleButton = false; int rewindMs = DefaultControlDispatcher.DEFAULT_REWIND_MS; int fastForwardMs = DefaultControlDispatcher.DEFAULT_FAST_FORWARD_MS; @@ -357,6 +386,15 @@ public class PlayerControlView extends FrameLayout { controllerLayoutId = a.getResourceId(R.styleable.PlayerControlView_controller_layout_id, controllerLayoutId); repeatToggleModes = getRepeatToggleModes(a, repeatToggleModes); + showRewindButton = + a.getBoolean(R.styleable.PlayerControlView_show_rewind_button, showRewindButton); + showFastForwardButton = + a.getBoolean( + R.styleable.PlayerControlView_show_fastforward_button, showFastForwardButton); + showPreviousButton = + a.getBoolean(R.styleable.PlayerControlView_show_previous_button, showPreviousButton); + showNextButton = + a.getBoolean(R.styleable.PlayerControlView_show_next_button, showNextButton); showShuffleButton = a.getBoolean(R.styleable.PlayerControlView_show_shuffle_button, showShuffleButton); setTimeBarMinUpdateInterval( @@ -443,6 +481,7 @@ public class PlayerControlView extends FrameLayout { } vrButton = findViewById(R.id.exo_vr); setShowVrButton(false); + updateButton(false, false, vrButton); Resources resources = context.getResources(); @@ -549,6 +588,7 @@ public class PlayerControlView extends FrameLayout { * @param listener The listener to be notified about visibility changes. */ public void addVisibilityListener(VisibilityListener listener) { + Assertions.checkNotNull(listener); visibilityListeners.add(listener); } @@ -592,6 +632,46 @@ public class PlayerControlView extends FrameLayout { } } + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + this.showRewindButton = showRewindButton; + updateNavigation(); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + this.showFastForwardButton = showFastForwardButton; + updateNavigation(); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + this.showPreviousButton = showPreviousButton; + updateNavigation(); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + this.showNextButton = showNextButton; + updateNavigation(); + } + /** * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. @@ -715,6 +795,7 @@ public class PlayerControlView extends FrameLayout { public void setVrButtonListener(@Nullable OnClickListener onClickListener) { if (vrButton != null) { vrButton.setOnClickListener(onClickListener); + updateButton(getShowVrButton(), onClickListener != null, vrButton); } } @@ -832,10 +913,10 @@ public class PlayerControlView extends FrameLayout { } } - setButtonEnabled(enablePrevious, previousButton); - setButtonEnabled(enableRewind, rewindButton); - setButtonEnabled(enableFastForward, fastForwardButton); - setButtonEnabled(enableNext, nextButton); + updateButton(showPreviousButton, enablePrevious, previousButton); + updateButton(showRewindButton, enableRewind, rewindButton); + updateButton(showFastForwardButton, enableFastForward, fastForwardButton); + updateButton(showNextButton, enableNext, nextButton); if (timeBar != null) { timeBar.setEnabled(enableSeeking); } @@ -847,19 +928,19 @@ public class PlayerControlView extends FrameLayout { } if (repeatToggleModes == RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE) { - repeatToggleButton.setVisibility(GONE); + updateButton(/* visible= */ false, /* enabled= */ false, repeatToggleButton); return; } @Nullable Player player = this.player; if (player == null) { - setButtonEnabled(false, repeatToggleButton); + updateButton(/* visible= */ true, /* enabled= */ false, repeatToggleButton); repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); repeatToggleButton.setContentDescription(repeatOffButtonContentDescription); return; } - setButtonEnabled(true, repeatToggleButton); + updateButton(/* visible= */ true, /* enabled= */ true, repeatToggleButton); switch (player.getRepeatMode()) { case Player.REPEAT_MODE_OFF: repeatToggleButton.setImageDrawable(repeatOffButtonDrawable); @@ -886,13 +967,13 @@ public class PlayerControlView extends FrameLayout { @Nullable Player player = this.player; if (!showShuffleButton) { - shuffleButton.setVisibility(GONE); + updateButton(/* visible= */ false, /* enabled= */ false, shuffleButton); } else if (player == null) { - setButtonEnabled(false, shuffleButton); + updateButton(/* visible= */ true, /* enabled= */ false, shuffleButton); shuffleButton.setImageDrawable(shuffleOffButtonDrawable); shuffleButton.setContentDescription(shuffleOffContentDescription); } else { - setButtonEnabled(true, shuffleButton); + updateButton(/* visible= */ true, /* enabled= */ true, shuffleButton); shuffleButton.setImageDrawable( player.getShuffleModeEnabled() ? shuffleOnButtonDrawable : shuffleOffButtonDrawable); shuffleButton.setContentDescription( @@ -1007,8 +1088,8 @@ public class PlayerControlView extends FrameLayout { long mediaTimeUntilNextFullSecondMs = 1000 - position % 1000; mediaTimeDelayMs = Math.min(mediaTimeDelayMs, mediaTimeUntilNextFullSecondMs); - // Calculate the delay until the next update in real time, taking playbackSpeed into account. - float playbackSpeed = player.getPlaybackSpeed(); + // Calculate the delay until the next update in real time, taking playback speed into account. + float playbackSpeed = player.getPlaybackParameters().speed; long delayMs = playbackSpeed > 0 ? (long) (mediaTimeDelayMs / playbackSpeed) : MAX_UPDATE_INTERVAL_MS; @@ -1029,13 +1110,13 @@ public class PlayerControlView extends FrameLayout { } } - private void setButtonEnabled(boolean enabled, @Nullable View view) { + private void updateButton(boolean visible, boolean enabled, @Nullable View view) { if (view == null) { return; } view.setEnabled(enabled); view.setAlpha(enabled ? buttonAlphaEnabled : buttonAlphaDisabled); - view.setVisibility(VISIBLE); + view.setVisibility(visible ? VISIBLE : GONE); } private void seekToTimeBarPosition(Player player, long positionMs) { @@ -1126,19 +1207,22 @@ public class PlayerControlView extends FrameLayout { } if (event.getAction() == KeyEvent.ACTION_DOWN) { if (keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) { - controlDispatcher.dispatchFastForward(player); + if (player.getPlaybackState() != Player.STATE_ENDED) { + controlDispatcher.dispatchFastForward(player); + } } else if (keyCode == KeyEvent.KEYCODE_MEDIA_REWIND) { controlDispatcher.dispatchRewind(player); } else if (event.getRepeatCount() == 0) { switch (keyCode) { case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - controlDispatcher.dispatchSetPlayWhenReady(player, !player.getPlayWhenReady()); + case KeyEvent.KEYCODE_HEADSETHOOK: + dispatchPlayPause(player); break; case KeyEvent.KEYCODE_MEDIA_PLAY: - controlDispatcher.dispatchSetPlayWhenReady(player, true); + dispatchPlay(player); break; case KeyEvent.KEYCODE_MEDIA_PAUSE: - controlDispatcher.dispatchSetPlayWhenReady(player, false); + dispatchPause(player); break; case KeyEvent.KEYCODE_MEDIA_NEXT: controlDispatcher.dispatchNext(player); @@ -1161,11 +1245,37 @@ public class PlayerControlView extends FrameLayout { && player.getPlayWhenReady(); } + private void dispatchPlayPause(Player player) { + @State int state = player.getPlaybackState(); + if (state == Player.STATE_IDLE || state == Player.STATE_ENDED || !player.getPlayWhenReady()) { + dispatchPlay(player); + } else { + dispatchPause(player); + } + } + + private void dispatchPlay(Player player) { + @State int state = player.getPlaybackState(); + if (state == Player.STATE_IDLE) { + if (playbackPreparer != null) { + playbackPreparer.preparePlayback(); + } + } else if (state == Player.STATE_ENDED) { + seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); + } + controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ true); + } + + private void dispatchPause(Player player) { + controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false); + } + @SuppressLint("InlinedApi") private static boolean isHandledMediaKey(int keyCode) { return keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD || keyCode == KeyEvent.KEYCODE_MEDIA_REWIND || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_HEADSETHOOK || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT @@ -1271,20 +1381,15 @@ public class PlayerControlView extends FrameLayout { } else if (previousButton == view) { controlDispatcher.dispatchPrevious(player); } else if (fastForwardButton == view) { - controlDispatcher.dispatchFastForward(player); + if (player.getPlaybackState() != Player.STATE_ENDED) { + controlDispatcher.dispatchFastForward(player); + } } else if (rewindButton == view) { controlDispatcher.dispatchRewind(player); } else if (playButton == view) { - if (player.getPlaybackState() == Player.STATE_IDLE) { - if (playbackPreparer != null) { - playbackPreparer.preparePlayback(); - } - } else if (player.getPlaybackState() == Player.STATE_ENDED) { - seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); - } - controlDispatcher.dispatchSetPlayWhenReady(player, true); + dispatchPlay(player); } else if (pauseButton == view) { - controlDispatcher.dispatchSetPlayWhenReady(player, false); + dispatchPause(player); } else if (repeatToggleButton == view) { controlDispatcher.dispatchSetRepeatMode( player, RepeatModeUtil.getNextRepeatMode(player.getRepeatMode(), repeatToggleModes)); diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java index b3d646c99d..e23c91cd16 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ui; import android.app.Notification; +import android.app.NotificationChannel; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; @@ -37,6 +38,7 @@ import androidx.media.app.NotificationCompat.MediaStyle; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ControlDispatcher; import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; @@ -876,6 +878,10 @@ public class PlayerNotificationManager { * *

        See {@link NotificationCompat.Builder#setPriority(int)}. * + *

        To set the priority for API levels above 25, you can create your own {@link + * NotificationChannel} with a given importance level and pass the id of the channel to the {@link + * #PlayerNotificationManager(Context, String, int, MediaDescriptionAdapter) constructor}. + * * @param priority The priority which can be one of {@link NotificationCompat#PRIORITY_DEFAULT}, * {@link NotificationCompat#PRIORITY_MAX}, {@link NotificationCompat#PRIORITY_HIGH}, {@link * NotificationCompat#PRIORITY_LOW} or {@link NotificationCompat#PRIORITY_MIN}. If not set @@ -971,6 +977,8 @@ public class PlayerNotificationManager { } } + // We're calling a deprecated listener method that we still want to notify. + @SuppressWarnings("deprecation") private void startOrUpdateNotification(Player player, @Nullable Bitmap bitmap) { boolean ongoing = getOngoing(player); builder = createNotification(player, builder, ongoing, bitmap); @@ -993,6 +1001,8 @@ public class PlayerNotificationManager { } } + // We're calling a deprecated listener method that we still want to notify. + @SuppressWarnings("deprecation") private void stopNotification(boolean dismissedByUser) { if (isNotificationStarted) { isNotificationStarted = false; @@ -1332,7 +1342,7 @@ public class PlayerNotificationManager { } @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { postStartOrUpdateNotification(); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java index 6ee2e3f6a4..049af9b64a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerView.java @@ -48,6 +48,8 @@ import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.flac.PictureFrame; import com.google.android.exoplayer2.metadata.id3.ApicFrame; @@ -66,6 +68,7 @@ import com.google.android.exoplayer2.util.RepeatModeUtil; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoDecoderGLSurfaceView; import com.google.android.exoplayer2.video.VideoListener; +import com.google.common.collect.ImmutableList; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -1000,6 +1003,46 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider controller.setControlDispatcher(controlDispatcher); } + /** + * Sets whether the rewind button is shown. + * + * @param showRewindButton Whether the rewind button is shown. + */ + public void setShowRewindButton(boolean showRewindButton) { + Assertions.checkStateNotNull(controller); + controller.setShowRewindButton(showRewindButton); + } + + /** + * Sets whether the fast forward button is shown. + * + * @param showFastForwardButton Whether the fast forward button is shown. + */ + public void setShowFastForwardButton(boolean showFastForwardButton) { + Assertions.checkStateNotNull(controller); + controller.setShowFastForwardButton(showFastForwardButton); + } + + /** + * Sets whether the previous button is shown. + * + * @param showPreviousButton Whether the previous button is shown. + */ + public void setShowPreviousButton(boolean showPreviousButton) { + Assertions.checkStateNotNull(controller); + controller.setShowPreviousButton(showPreviousButton); + } + + /** + * Sets whether the next button is shown. + * + * @param showNextButton Whether the next button is shown. + */ + public void setShowNextButton(boolean showNextButton) { + Assertions.checkStateNotNull(controller); + controller.setShowNextButton(showNextButton); + } + /** * @deprecated Use {@link #setControlDispatcher(ControlDispatcher)} with {@link * DefaultControlDispatcher#DefaultControlDispatcher(long, long)}. @@ -1216,15 +1259,20 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider } @Override - public View[] getAdOverlayViews() { - ArrayList overlayViews = new ArrayList<>(); + public List getAdOverlayInfos() { + List overlayViews = new ArrayList<>(); if (overlayFrameLayout != null) { - overlayViews.add(overlayFrameLayout); + overlayViews.add( + new AdsLoader.OverlayInfo( + overlayFrameLayout, + AdsLoader.OverlayInfo.PURPOSE_NOT_VISIBLE, + /* detailedReason= */ "Transparent overlay does not impact viewability")); } if (controller != null) { - overlayViews.add(controller); + overlayViews.add( + new AdsLoader.OverlayInfo(controller, AdsLoader.OverlayInfo.PURPOSE_CONTROLS)); } - return overlayViews.toArray(new View[0]); + return ImmutableList.copyOf(overlayViews); } // Internal methods. @@ -1514,6 +1562,13 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider SingleTapListener, PlayerControlView.VisibilityListener { + private final Period period; + private @Nullable Object lastPeriodUidWithTracks; + + public ComponentListener() { + period = new Period(); + } + // TextOutput implementation @Override @@ -1562,6 +1617,29 @@ public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider @Override public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Suppress the update if transitioning to an unprepared period within the same window. This + // is necessary to avoid closing the shutter when such a transition occurs. See: + // https://github.com/google/ExoPlayer/issues/5507. + Player player = Assertions.checkNotNull(PlayerView.this.player); + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + lastPeriodUidWithTracks = null; + } else if (!player.getCurrentTrackGroups().isEmpty()) { + lastPeriodUidWithTracks = + timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid; + } else if (lastPeriodUidWithTracks != null) { + int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks); + if (lastPeriodIndexWithTracks != C.INDEX_UNSET) { + int lastWindowIndexWithTracks = + timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex; + if (player.getCurrentWindowIndex() == lastWindowIndexWithTracks) { + // We're in the same window. Suppress the update. + return; + } + } + lastPeriodUidWithTracks = null; + } + updateForCurrentTrackSelections(/* isNewPlayer= */ false); } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java deleted file mode 100644 index fae3382a32..0000000000 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SimpleExoPlayerView.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ui; - -import android.content.Context; -import android.util.AttributeSet; -import androidx.annotation.Nullable; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.SimpleExoPlayer; - -/** @deprecated Use {@link PlayerView}. */ -@Deprecated -public final class SimpleExoPlayerView extends PlayerView { - - public SimpleExoPlayerView(Context context) { - super(context); - } - - public SimpleExoPlayerView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public SimpleExoPlayerView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - /** - * Switches the view targeted by a given {@link SimpleExoPlayer}. - * - * @param player The player whose target view is being switched. - * @param oldPlayerView The old view to detach from the player. - * @param newPlayerView The new view to attach to the player. - * @deprecated Use {@link PlayerView#switchTargetView(Player, PlayerView, PlayerView)} instead. - */ - @Deprecated - @SuppressWarnings("deprecation") - public static void switchTargetView( - SimpleExoPlayer player, - @Nullable SimpleExoPlayerView oldPlayerView, - @Nullable SimpleExoPlayerView newPlayerView) { - PlayerView.switchTargetView(player, oldPlayerView, newPlayerView); - } - -} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java index 8f61902205..7ea2b55cf4 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SpannedToHtmlConverter.java @@ -23,6 +23,7 @@ import android.text.style.AbsoluteSizeSpan; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; +import android.text.style.StrikethroughSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.text.style.UnderlineSpan; @@ -32,10 +33,15 @@ import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSp import com.google.android.exoplayer2.text.span.RubySpan; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableMap; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; /** @@ -45,7 +51,6 @@ import java.util.regex.Pattern; *

        Supports all of the spans used by ExoPlayer's subtitle decoders, including custom ones found * in {@link com.google.android.exoplayer2.text.span}. */ -// TODO: Add support for more span types - only a small selection are currently implemented. /* package */ final class SpannedToHtmlConverter { // Matches /n and /r/n in ampersand-encoding (returned from Html.escapeHtml). @@ -73,16 +78,29 @@ import java.util.regex.Pattern; * @param displayDensity The screen density of the device. WebView treats 1 CSS px as one Android * dp, so to convert size values from Android px to CSS px we need to know the screen density. */ - public static String convert(@Nullable CharSequence text, float displayDensity) { + public static HtmlAndCss convert(@Nullable CharSequence text, float displayDensity) { if (text == null) { - return ""; + return new HtmlAndCss("", /* cssRuleSets= */ ImmutableMap.of()); } if (!(text instanceof Spanned)) { - return escapeHtml(text); + return new HtmlAndCss(escapeHtml(text), /* cssRuleSets= */ ImmutableMap.of()); } Spanned spanned = (Spanned) text; - SparseArray spanTransitions = findSpanTransitions(spanned, displayDensity); + // Use CSS inheritance to ensure BackgroundColorSpans affect all inner elements + Set backgroundColors = new HashSet<>(); + for (BackgroundColorSpan backgroundColorSpan : + spanned.getSpans(0, spanned.length(), BackgroundColorSpan.class)) { + backgroundColors.add(backgroundColorSpan.getBackgroundColor()); + } + HashMap cssRuleSets = new HashMap<>(); + for (int backgroundColor : backgroundColors) { + cssRuleSets.put( + HtmlUtils.cssAllClassDescendantsSelector("bg_" + backgroundColor), + Util.formatInvariant("background-color:%s;", HtmlUtils.toCssRgba(backgroundColor))); + } + + SparseArray spanTransitions = findSpanTransitions(spanned, displayDensity); StringBuilder html = new StringBuilder(spanned.length()); int previousTransition = 0; for (int i = 0; i < spanTransitions.size(); i++) { @@ -103,7 +121,7 @@ import java.util.regex.Pattern; html.append(escapeHtml(spanned.subSequence(previousTransition, spanned.length()))); - return html.toString(); + return new HtmlAndCss(html.toString(), cssRuleSets); } private static SparseArray findSpanTransitions( @@ -128,15 +146,15 @@ import java.util.regex.Pattern; @Nullable private static String getOpeningTag(Object span, float displayDensity) { - if (span instanceof ForegroundColorSpan) { + if (span instanceof StrikethroughSpan) { + return ""; + } else if (span instanceof ForegroundColorSpan) { ForegroundColorSpan colorSpan = (ForegroundColorSpan) span; return Util.formatInvariant( "", HtmlUtils.toCssRgba(colorSpan.getForegroundColor())); } else if (span instanceof BackgroundColorSpan) { BackgroundColorSpan colorSpan = (BackgroundColorSpan) span; - return Util.formatInvariant( - "", - HtmlUtils.toCssRgba(colorSpan.getBackgroundColor())); + return Util.formatInvariant("", colorSpan.getBackgroundColor()); } else if (span instanceof HorizontalTextInVerticalContextSpan) { return ""; } else if (span instanceof AbsoluteSizeSpan) { @@ -186,7 +204,8 @@ import java.util.regex.Pattern; @Nullable private static String getClosingTag(Object span) { - if (span instanceof ForegroundColorSpan + if (span instanceof StrikethroughSpan + || span instanceof ForegroundColorSpan || span instanceof BackgroundColorSpan || span instanceof HorizontalTextInVerticalContextSpan || span instanceof AbsoluteSizeSpan @@ -227,6 +246,26 @@ import java.util.regex.Pattern; return NEWLINE_PATTERN.matcher(escaped).replaceAll("
        "); } + /** Container class for an HTML string and associated CSS rulesets. */ + public static class HtmlAndCss { + + /** A raw HTML string. */ + public final String html; + + /** + * CSS rulesets used to style {@link #html}. + * + *

        Each key is a CSS selector, and each value is a CSS declaration (i.e. a semi-colon + * separated list of colon-separated key-value pairs, e.g "prop1:val1;prop2:val2;"). + */ + public final Map cssRuleSets; + + private HtmlAndCss(String html, Map cssRuleSets) { + this.html = html; + this.cssRuleSets = cssRuleSets; + } + } + private static final class SpanInfo { /** * Sort by end index (descending), then by opening tag and then closing tag (both ascending, for diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java new file mode 100644 index 0000000000..8bb9babeb0 --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java @@ -0,0 +1,2238 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ui; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; +import androidx.annotation.Nullable; +import androidx.core.content.res.ResourcesCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.PlaybackPreparer; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.State; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.ParametersBuilder; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.RepeatModeUtil; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Formatter; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A view for controlling {@link Player} instances. + * + *

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

        Attributes

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

        Overriding drawables

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

        Overriding the layout file

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

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

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

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

        Specifying a custom layout file

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

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

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

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

        Attributes

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

        Overriding drawables

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

        Overriding the layout file

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

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

        Specifying a custom layout file

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

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

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

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

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

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

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

        This method should typically be called in {@code Activity.onStop()}, or {@code + * Activity.onPause()} for API versions <= 23. + */ + public void onPause() { + if (surfaceView instanceof SphericalGLSurfaceView) { + ((SphericalGLSurfaceView) surfaceView).onPause(); + } + } + + /** + * Called when there's a change in the aspect ratio of the content being displayed. The default + * implementation sets the aspect ratio of the content frame to that of the content, unless the + * content view is a {@link SphericalGLSurfaceView} in which case the frame's aspect ratio is + * cleared. + * + * @param contentAspectRatio The aspect ratio of the content. + * @param contentFrame The content frame, or {@code null}. + * @param contentView The view that holds the content being displayed, or {@code null}. + */ + protected void onContentAspectRatioChanged( + float contentAspectRatio, + @Nullable AspectRatioFrameLayout contentFrame, + @Nullable View contentView) { + if (contentFrame != null) { + contentFrame.setAspectRatio( + contentView instanceof SphericalGLSurfaceView ? 0 : contentAspectRatio); + } + } + + // AdsLoader.AdViewProvider implementation. + + @Override + public ViewGroup getAdViewGroup() { + return Assertions.checkStateNotNull( + adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback"); + } + + @Override + public List getAdOverlayInfos() { + List overlayViews = new ArrayList<>(); + if (overlayFrameLayout != null) { + overlayViews.add( + new AdsLoader.OverlayInfo( + overlayFrameLayout, + AdsLoader.OverlayInfo.PURPOSE_NOT_VISIBLE, + /* detailedReason= */ "Transparent overlay does not impact viewability")); + } + if (controller != null) { + overlayViews.add( + new AdsLoader.OverlayInfo(controller, AdsLoader.OverlayInfo.PURPOSE_CONTROLS)); + } + return ImmutableList.copyOf(overlayViews); + } + + // Internal methods. + + @EnsuresNonNullIf(expression = "controller", result = true) + private boolean useController() { + if (useController) { + Assertions.checkStateNotNull(controller); + return true; + } + return false; + } + + @EnsuresNonNullIf(expression = "artworkView", result = true) + private boolean useArtwork() { + if (useArtwork) { + Assertions.checkStateNotNull(artworkView); + return true; + } + return false; + } + + private boolean toggleControllerVisibility() { + if (!useController() || player == null) { + return false; + } + if (!controller.isFullyVisible()) { + maybeShowController(true); + return true; + } else if (controllerHideOnTouch) { + controller.hide(); + return true; + } + return false; + } + + /** Shows the playback controls, but only if forced or shown indefinitely. */ + private void maybeShowController(boolean isForced) { + if (isPlayingAd() && controllerHideDuringAds) { + return; + } + if (useController()) { + boolean wasShowingIndefinitely = + controller.isFullyVisible() && controller.getShowTimeoutMs() <= 0; + boolean shouldShowIndefinitely = shouldShowControllerIndefinitely(); + if (isForced || wasShowingIndefinitely || shouldShowIndefinitely) { + showController(shouldShowIndefinitely); + } + } + } + + private boolean shouldShowControllerIndefinitely() { + if (player == null) { + return true; + } + int playbackState = player.getPlaybackState(); + return controllerAutoShow + && !player.getCurrentTimeline().isEmpty() + && (playbackState == Player.STATE_IDLE + || playbackState == Player.STATE_ENDED + || !checkNotNull(player).getPlayWhenReady()); + } + + private void showController(boolean showIndefinitely) { + if (!useController()) { + return; + } + controller.setShowTimeoutMs(showIndefinitely ? 0 : controllerShowTimeoutMs); + controller.show(); + } + + private boolean isPlayingAd() { + return player != null && player.isPlayingAd() && player.getPlayWhenReady(); + } + + private void updateForCurrentTrackSelections(boolean isNewPlayer) { + @Nullable Player player = this.player; + if (player == null || player.getCurrentTrackGroups().isEmpty()) { + if (!keepContentOnPlayerReset) { + hideArtwork(); + closeShutter(); + } + return; + } + + if (isNewPlayer && !keepContentOnPlayerReset) { + // Hide any video from the previous player. + closeShutter(); + } + + TrackSelectionArray selections = player.getCurrentTrackSelections(); + for (int i = 0; i < selections.length; i++) { + if (player.getRendererType(i) == C.TRACK_TYPE_VIDEO && selections.get(i) != null) { + // Video enabled so artwork must be hidden. If the shutter is closed, it will be opened in + // onRenderedFirstFrame(). + hideArtwork(); + return; + } + } + + // Video disabled so the shutter must be closed. + closeShutter(); + // Display artwork if enabled and available, else hide it. + if (useArtwork()) { + for (int i = 0; i < selections.length; i++) { + @Nullable TrackSelection selection = selections.get(i); + if (selection != null) { + for (int j = 0; j < selection.length(); j++) { + @Nullable Metadata metadata = selection.getFormat(j).metadata; + if (metadata != null && setArtworkFromMetadata(metadata)) { + return; + } + } + } + } + if (setDrawableArtwork(defaultArtwork)) { + return; + } + } + // Artwork disabled or unavailable. + hideArtwork(); + } + + @RequiresNonNull("artworkView") + private boolean setArtworkFromMetadata(Metadata metadata) { + boolean isArtworkSet = false; + int currentPictureType = PICTURE_TYPE_NOT_SET; + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry metadataEntry = metadata.get(i); + int pictureType; + byte[] bitmapData; + if (metadataEntry instanceof ApicFrame) { + bitmapData = ((ApicFrame) metadataEntry).pictureData; + pictureType = ((ApicFrame) metadataEntry).pictureType; + } else if (metadataEntry instanceof PictureFrame) { + bitmapData = ((PictureFrame) metadataEntry).pictureData; + pictureType = ((PictureFrame) metadataEntry).pictureType; + } else { + continue; + } + // Prefer the first front cover picture. If there aren't any, prefer the first picture. + if (currentPictureType == PICTURE_TYPE_NOT_SET || pictureType == PICTURE_TYPE_FRONT_COVER) { + Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length); + isArtworkSet = setDrawableArtwork(new BitmapDrawable(getResources(), bitmap)); + currentPictureType = pictureType; + if (currentPictureType == PICTURE_TYPE_FRONT_COVER) { + break; + } + } + } + return isArtworkSet; + } + + @RequiresNonNull("artworkView") + private boolean setDrawableArtwork(@Nullable Drawable drawable) { + if (drawable != null) { + int drawableWidth = drawable.getIntrinsicWidth(); + int drawableHeight = drawable.getIntrinsicHeight(); + if (drawableWidth > 0 && drawableHeight > 0) { + float artworkAspectRatio = (float) drawableWidth / drawableHeight; + onContentAspectRatioChanged(artworkAspectRatio, contentFrame, artworkView); + artworkView.setImageDrawable(drawable); + artworkView.setVisibility(VISIBLE); + return true; + } + } + return false; + } + + private void hideArtwork() { + if (artworkView != null) { + artworkView.setImageResource(android.R.color.transparent); // Clears any bitmap reference. + artworkView.setVisibility(INVISIBLE); + } + } + + private void closeShutter() { + if (shutterView != null) { + shutterView.setVisibility(View.VISIBLE); + } + } + + private void updateBuffering() { + if (bufferingView != null) { + boolean showBufferingSpinner = + player != null + && player.getPlaybackState() == Player.STATE_BUFFERING + && (showBuffering == SHOW_BUFFERING_ALWAYS + || (showBuffering == SHOW_BUFFERING_WHEN_PLAYING && player.getPlayWhenReady())); + bufferingView.setVisibility(showBufferingSpinner ? View.VISIBLE : View.GONE); + } + } + + private void updateErrorMessage() { + if (errorMessageView != null) { + if (customErrorMessage != null) { + errorMessageView.setText(customErrorMessage); + errorMessageView.setVisibility(View.VISIBLE); + return; + } + @Nullable ExoPlaybackException error = player != null ? player.getPlayerError() : null; + if (error != null && errorMessageProvider != null) { + CharSequence errorMessage = errorMessageProvider.getErrorMessage(error).second; + errorMessageView.setText(errorMessage); + errorMessageView.setVisibility(View.VISIBLE); + } else { + errorMessageView.setVisibility(View.GONE); + } + } + } + + private void updateContentDescription() { + if (controller == null || !useController) { + setContentDescription(/* contentDescription= */ null); + } else if (controller.isFullyVisible()) { + setContentDescription( + /* contentDescription= */ controllerHideOnTouch + ? getResources().getString(R.string.exo_controls_hide) + : null); + } else { + setContentDescription( + /* contentDescription= */ getResources().getString(R.string.exo_controls_show)); + } + } + + private void updateControllerVisibility() { + if (isPlayingAd() && controllerHideDuringAds) { + hideController(); + } else { + maybeShowController(false); + } + } + + @RequiresApi(23) + private static void configureEditModeLogoV23(Resources resources, ImageView logo) { + logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo, null)); + logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color, null)); + } + + private static void configureEditModeLogo(Resources resources, ImageView logo) { + logo.setImageDrawable(resources.getDrawable(R.drawable.exo_edit_mode_logo)); + logo.setBackgroundColor(resources.getColor(R.color.exo_edit_mode_background_color)); + } + + @SuppressWarnings("ResourceType") + private static void setResizeModeRaw(AspectRatioFrameLayout aspectRatioFrame, int resizeMode) { + aspectRatioFrame.setResizeMode(resizeMode); + } + + /** Applies a texture rotation to a {@link TextureView}. */ + private static void applyTextureViewRotation(TextureView textureView, int textureViewRotation) { + Matrix transformMatrix = new Matrix(); + float textureViewWidth = textureView.getWidth(); + float textureViewHeight = textureView.getHeight(); + if (textureViewWidth != 0 && textureViewHeight != 0 && textureViewRotation != 0) { + float pivotX = textureViewWidth / 2; + float pivotY = textureViewHeight / 2; + transformMatrix.postRotate(textureViewRotation, pivotX, pivotY); + + // After rotation, scale the rotated texture to fit the TextureView size. + RectF originalTextureRect = new RectF(0, 0, textureViewWidth, textureViewHeight); + RectF rotatedTextureRect = new RectF(); + transformMatrix.mapRect(rotatedTextureRect, originalTextureRect); + transformMatrix.postScale( + textureViewWidth / rotatedTextureRect.width(), + textureViewHeight / rotatedTextureRect.height(), + pivotX, + pivotY); + } + textureView.setTransform(transformMatrix); + } + + @SuppressLint("InlinedApi") + private boolean isDpadKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_UP_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_UP_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_CENTER; + } + + private final class ComponentListener + implements Player.EventListener, + TextOutput, + VideoListener, + OnLayoutChangeListener, + SingleTapListener, + StyledPlayerControlView.VisibilityListener { + + private final Period period; + private @Nullable Object lastPeriodUidWithTracks; + + public ComponentListener() { + period = new Period(); + } + + // TextOutput implementation + + @Override + public void onCues(List cues) { + if (subtitleView != null) { + subtitleView.onCues(cues); + } + } + + // VideoListener implementation + + @Override + public void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + float videoAspectRatio = + (height == 0 || width == 0) ? 1 : (width * pixelWidthHeightRatio) / height; + + if (surfaceView instanceof TextureView) { + // Try to apply rotation transformation when our surface is a TextureView. + if (unappliedRotationDegrees == 90 || unappliedRotationDegrees == 270) { + // We will apply a rotation 90/270 degree to the output texture of the TextureView. + // In this case, the output video's width and height will be swapped. + videoAspectRatio = 1 / videoAspectRatio; + } + if (textureViewRotation != 0) { + surfaceView.removeOnLayoutChangeListener(this); + } + textureViewRotation = unappliedRotationDegrees; + if (textureViewRotation != 0) { + // The texture view's dimensions might be changed after layout step. + // So add an OnLayoutChangeListener to apply rotation after layout step. + surfaceView.addOnLayoutChangeListener(this); + } + applyTextureViewRotation((TextureView) surfaceView, textureViewRotation); + } + + onContentAspectRatioChanged(videoAspectRatio, contentFrame, surfaceView); + } + + @Override + public void onRenderedFirstFrame() { + if (shutterView != null) { + shutterView.setVisibility(INVISIBLE); + } + } + + @Override + public void onTracksChanged(TrackGroupArray tracks, TrackSelectionArray selections) { + // Suppress the update if transitioning to an unprepared period within the same window. This + // is necessary to avoid closing the shutter when such a transition occurs. See: + // https://github.com/google/ExoPlayer/issues/5507. + Player player = checkNotNull(StyledPlayerView.this.player); + Timeline timeline = player.getCurrentTimeline(); + if (timeline.isEmpty()) { + lastPeriodUidWithTracks = null; + } else if (!player.getCurrentTrackGroups().isEmpty()) { + lastPeriodUidWithTracks = + timeline.getPeriod(player.getCurrentPeriodIndex(), period, /* setIds= */ true).uid; + } else if (lastPeriodUidWithTracks != null) { + int lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks); + if (lastPeriodIndexWithTracks != C.INDEX_UNSET) { + int lastWindowIndexWithTracks = + timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex; + if (player.getCurrentWindowIndex() == lastWindowIndexWithTracks) { + // We're in the same window. Suppress the update. + return; + } + } + lastPeriodUidWithTracks = null; + } + + updateForCurrentTrackSelections(/* isNewPlayer= */ false); + } + + // Player.EventListener implementation + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + updateBuffering(); + updateErrorMessage(); + updateControllerVisibility(); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + updateBuffering(); + updateControllerVisibility(); + } + + @Override + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + if (isPlayingAd() && controllerHideDuringAds) { + hideController(); + } + } + + // OnLayoutChangeListener implementation + + @Override + public void onLayoutChange( + View view, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + applyTextureViewRotation((TextureView) view, textureViewRotation); + } + + // SingleTapListener implementation + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return toggleControllerVisibility(); + } + + // StyledPlayerControlView.VisibilityListener implementation + + @Override + public void onVisibilityChange(int visibility) { + updateContentDescription(); + } + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java index f0093a282c..fd7c3bffee 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitlePainter.java @@ -34,7 +34,6 @@ 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; import com.google.android.exoplayer2.text.CaptionStyleCompat; @@ -82,8 +81,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private int cuePositionAnchor; private float cueSize; private float cueBitmapHeight; - private boolean applyEmbeddedStyles; - private boolean applyEmbeddedFontSizes; private int foregroundColor; private int backgroundColor; private int windowColor; @@ -142,8 +139,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * which the same parameters are passed. * * @param cue The cue to draw. - * @param applyEmbeddedStyles Whether styling embedded within the cue should be applied. - * @param applyEmbeddedFontSizes If {@code applyEmbeddedStyles} is true, defines whether font * sizes embedded within the cue should be applied. Otherwise, it is ignored. * @param style The style to use when drawing the cue text. * @param defaultTextSizePx The default text size to use when drawing the text, in pixels. @@ -158,8 +153,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; */ public void draw( Cue cue, - boolean applyEmbeddedStyles, - boolean applyEmbeddedFontSizes, CaptionStyleCompat style, float defaultTextSizePx, float cueTextSizePx, @@ -176,8 +169,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; // Nothing to draw. return; } - windowColor = (cue.windowColorSet && applyEmbeddedStyles) - ? cue.windowColor : style.windowColor; + windowColor = cue.windowColorSet ? cue.windowColor : style.windowColor; } if (areCharSequencesEqual(this.cueText, cue.text) && Util.areEqual(this.cueTextAlignment, cue.textAlignment) @@ -189,8 +181,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; && Util.areEqual(this.cuePositionAnchor, cue.positionAnchor) && this.cueSize == cue.size && this.cueBitmapHeight == cue.bitmapHeight - && this.applyEmbeddedStyles == applyEmbeddedStyles - && this.applyEmbeddedFontSizes == applyEmbeddedFontSizes && this.foregroundColor == style.foregroundColor && this.backgroundColor == style.backgroundColor && this.windowColor == windowColor @@ -219,8 +209,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.cuePositionAnchor = cue.positionAnchor; this.cueSize = cue.size; this.cueBitmapHeight = cue.bitmapHeight; - this.applyEmbeddedStyles = applyEmbeddedStyles; - this.applyEmbeddedFontSizes = applyEmbeddedFontSizes; this.foregroundColor = style.foregroundColor; this.backgroundColor = style.backgroundColor; this.windowColor = windowColor; @@ -266,31 +254,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return; } - // Remove embedded styling or font size if requested. - if (!applyEmbeddedStyles) { - // Remove all spans, regardless of type. - for (Object span : cueText.getSpans(0, cueText.length(), Object.class)) { - cueText.removeSpan(span); - } - } else if (!applyEmbeddedFontSizes) { - AbsoluteSizeSpan[] absSpans = cueText.getSpans(0, cueText.length(), AbsoluteSizeSpan.class); - for (AbsoluteSizeSpan absSpan : absSpans) { - cueText.removeSpan(absSpan); - } - RelativeSizeSpan[] relSpans = cueText.getSpans(0, cueText.length(), RelativeSizeSpan.class); - for (RelativeSizeSpan relSpan : relSpans) { - cueText.removeSpan(relSpan); - } - } else { - // Apply embedded styles & font size. - if (cueTextSizePx > 0) { - // Use an AbsoluteSizeSpan encompassing the whole text to apply the default cueTextSizePx. - cueText.setSpan( - new AbsoluteSizeSpan((int) cueTextSizePx), - /* start= */ 0, - /* end= */ cueText.length(), - Spanned.SPAN_PRIORITY); - } + if (cueTextSizePx > 0) { + // Use an AbsoluteSizeSpan encompassing the whole text to apply the default cueTextSizePx. + cueText.setSpan( + new AbsoluteSizeSpan((int) cueTextSizePx), + /* start= */ 0, + /* end= */ cueText.length(), + Spanned.SPAN_PRIORITY); } // Remove embedded font color to not destroy edges, otherwise it overrides edge color. @@ -367,21 +337,24 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int textTop; if (cueLine != Cue.DIMEN_UNSET) { - int anchorPosition; if (cueLineType == Cue.LINE_TYPE_FRACTION) { - anchorPosition = Math.round(parentHeight * cueLine) + parentTop; + int anchorPosition = Math.round(parentHeight * cueLine) + parentTop; + textTop = + cueLineAnchor == Cue.ANCHOR_TYPE_END + ? anchorPosition - textHeight + : cueLineAnchor == Cue.ANCHOR_TYPE_MIDDLE + ? (anchorPosition * 2 - textHeight) / 2 + : anchorPosition; } else { // cueLineType == Cue.LINE_TYPE_NUMBER int firstLineHeight = textLayout.getLineBottom(0) - textLayout.getLineTop(0); if (cueLine >= 0) { - anchorPosition = Math.round(cueLine * firstLineHeight) + parentTop; + textTop = Math.round(cueLine * firstLineHeight) + parentTop; } else { - anchorPosition = Math.round((cueLine + 1) * firstLineHeight) + parentBottom; + textTop = Math.round((cueLine + 1) * firstLineHeight) + parentBottom - textHeight; } } - textTop = cueLineAnchor == Cue.ANCHOR_TYPE_END ? anchorPosition - textHeight - : cueLineAnchor == Cue.ANCHOR_TYPE_MIDDLE ? (anchorPosition * 2 - textHeight) / 2 - : anchorPosition; + if (textTop + textHeight > parentBottom) { textTop = parentBottom - textHeight; } else if (textTop < parentTop) { diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java index 23a1add0fc..452be5a3b7 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleView.java @@ -20,27 +20,64 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import android.content.Context; import android.content.res.Resources; +import android.graphics.Canvas; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.RelativeSizeSpan; import android.util.AttributeSet; import android.util.TypedValue; import android.view.View; import android.view.accessibility.CaptioningManager; +import android.webkit.WebView; import android.widget.FrameLayout; import androidx.annotation.Dimension; import androidx.annotation.IntDef; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.text.CaptionStyleCompat; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextOutput; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; +import java.util.ArrayList; import java.util.Collections; import java.util.List; /** A view for displaying subtitle {@link Cue}s. */ public final class SubtitleView extends FrameLayout implements TextOutput { + /** + * An output for displaying subtitles. + * + *

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

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

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

        One of: * *

          - *
        • {@link #VIEW_TYPE_TEXT} + *
        • {@link #VIEW_TYPE_CANVAS} *
        • {@link #VIEW_TYPE_WEB} *
        */ @Documented @Retention(SOURCE) - @IntDef({VIEW_TYPE_TEXT, VIEW_TYPE_WEB}) + @IntDef({VIEW_TYPE_CANVAS, VIEW_TYPE_WEB}) public @interface ViewType {} + private List cues; + private CaptionStyleCompat style; + @Cue.TextSizeType private int defaultTextSizeType; + private float defaultTextSize; + private float bottomPaddingFraction; + private boolean applyEmbeddedStyles; + private boolean applyEmbeddedFontSizes; + private @ViewType int viewType; private Output output; private View innerSubtitleView; @@ -95,11 +137,19 @@ public final class SubtitleView extends FrameLayout implements TextOutput { public SubtitleView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); - SubtitleTextView subtitleTextView = new SubtitleTextView(context, attrs); - output = subtitleTextView; - innerSubtitleView = subtitleTextView; + cues = Collections.emptyList(); + style = CaptionStyleCompat.DEFAULT; + defaultTextSizeType = Cue.TEXT_SIZE_TYPE_FRACTIONAL; + defaultTextSize = DEFAULT_TEXT_SIZE_FRACTION; + bottomPaddingFraction = DEFAULT_BOTTOM_PADDING_FRACTION; + applyEmbeddedStyles = true; + applyEmbeddedFontSizes = true; + + CanvasSubtitleOutput canvasSubtitleOutput = new CanvasSubtitleOutput(context, attrs); + output = canvasSubtitleOutput; + innerSubtitleView = canvasSubtitleOutput; addView(innerSubtitleView); - viewType = VIEW_TYPE_TEXT; + viewType = VIEW_TYPE_CANVAS; } @Override @@ -113,7 +163,8 @@ public final class SubtitleView extends FrameLayout implements TextOutput { * @param cues The cues to display, or null to clear the cues. */ public void setCues(@Nullable List cues) { - output.onCues(cues != null ? cues : Collections.emptyList()); + this.cues = (cues != null ? cues : Collections.emptyList()); + updateOutput(); } /** @@ -129,11 +180,11 @@ public final class SubtitleView extends FrameLayout implements TextOutput { return; } switch (viewType) { - case VIEW_TYPE_TEXT: - setView(new SubtitleTextView(getContext())); + case VIEW_TYPE_CANVAS: + setView(new CanvasSubtitleOutput(getContext())); break; case VIEW_TYPE_WEB: - setView(new SubtitleWebView(getContext())); + setView(new WebViewSubtitleOutput(getContext())); break; default: throw new IllegalArgumentException(); @@ -143,8 +194,8 @@ public final class SubtitleView extends FrameLayout implements TextOutput { private void setView(T view) { removeView(innerSubtitleView); - if (innerSubtitleView instanceof SubtitleWebView) { - ((SubtitleWebView) innerSubtitleView).destroy(); + if (innerSubtitleView instanceof WebViewSubtitleOutput) { + ((WebViewSubtitleOutput) innerSubtitleView).destroy(); } innerSubtitleView = view; output = view; @@ -173,12 +224,13 @@ public final class SubtitleView extends FrameLayout implements TextOutput { } /** - * Sets the text size to one derived from {@link CaptioningManager#getFontScale()}, or to a - * default size before API level 19. + * Sets the text size based on {@link CaptioningManager#getFontScale()} if {@link + * CaptioningManager} is available and enabled. + * + *

        Otherwise (and always before API level 19) uses a default font scale of 1.0. */ public void setUserDefaultTextSize() { - float fontScale = Util.SDK_INT >= 19 && !isInEditMode() ? getUserCaptionFontScaleV19() : 1f; - setFractionalTextSize(DEFAULT_TEXT_SIZE_FRACTION * fontScale); + setFractionalTextSize(DEFAULT_TEXT_SIZE_FRACTION * getUserCaptionFontScale()); } /** @@ -211,7 +263,9 @@ public final class SubtitleView extends FrameLayout implements TextOutput { } private void setTextSize(@Cue.TextSizeType int textSizeType, float textSize) { - output.setTextSize(textSizeType, textSize); + this.defaultTextSizeType = textSizeType; + this.defaultTextSize = textSize; + updateOutput(); } /** @@ -221,7 +275,8 @@ public final class SubtitleView extends FrameLayout implements TextOutput { * @param applyEmbeddedStyles Whether styling embedded within the cues should be applied. */ public void setApplyEmbeddedStyles(boolean applyEmbeddedStyles) { - output.setApplyEmbeddedStyles(applyEmbeddedStyles); + this.applyEmbeddedStyles = applyEmbeddedStyles; + updateOutput(); } /** @@ -231,18 +286,18 @@ public final class SubtitleView extends FrameLayout implements TextOutput { * @param applyEmbeddedFontSizes Whether font sizes embedded within the cues should be applied. */ public void setApplyEmbeddedFontSizes(boolean applyEmbeddedFontSizes) { - output.setApplyEmbeddedFontSizes(applyEmbeddedFontSizes); + this.applyEmbeddedFontSizes = applyEmbeddedFontSizes; + updateOutput(); } /** - * Sets the caption style to be equivalent to the one returned by - * {@link CaptioningManager#getUserStyle()}, or to a default style before API level 19. + * Styles the captions using {@link CaptioningManager#getUserStyle()} if {@link CaptioningManager} + * is available and enabled. + * + *

        Otherwise (and always before API level 19) uses a default style. */ public void setUserDefaultStyle() { - setStyle( - Util.SDK_INT >= 19 && isCaptionManagerEnabled() && !isInEditMode() - ? getUserCaptionStyleV19() - : CaptionStyleCompat.DEFAULT); + setStyle(getUserCaptionStyle()); } /** @@ -251,7 +306,8 @@ public final class SubtitleView extends FrameLayout implements TextOutput { * @param style A style for the view. */ public void setStyle(CaptionStyleCompat style) { - output.setStyle(style); + this.style = style; + updateOutput(); } /** @@ -264,36 +320,99 @@ public final class SubtitleView extends FrameLayout implements TextOutput { * @param bottomPaddingFraction The bottom padding fraction. */ public void setBottomPaddingFraction(float bottomPaddingFraction) { - output.setBottomPaddingFraction(bottomPaddingFraction); + this.bottomPaddingFraction = bottomPaddingFraction; + updateOutput(); } - @RequiresApi(19) - private boolean isCaptionManagerEnabled() { + private float getUserCaptionFontScale() { + if (Util.SDK_INT < 19 || isInEditMode()) { + return 1f; + } + @Nullable CaptioningManager captioningManager = (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE); - return captioningManager.isEnabled(); + return captioningManager != null && captioningManager.isEnabled() + ? captioningManager.getFontScale() + : 1f; } - @RequiresApi(19) - private float getUserCaptionFontScaleV19() { + private CaptionStyleCompat getUserCaptionStyle() { + if (Util.SDK_INT < 19 || isInEditMode()) { + return CaptionStyleCompat.DEFAULT; + } + @Nullable CaptioningManager captioningManager = (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE); - return captioningManager.getFontScale(); + return captioningManager != null && captioningManager.isEnabled() + ? CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()) + : CaptionStyleCompat.DEFAULT; } - @RequiresApi(19) - private CaptionStyleCompat getUserCaptionStyleV19() { - CaptioningManager captioningManager = - (CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE); - return CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); + private void updateOutput() { + output.update( + getCuesWithStylingPreferencesApplied(), + style, + defaultTextSize, + defaultTextSizeType, + bottomPaddingFraction); } - /* package */ interface Output { - void onCues(List cues); - void setTextSize(@Cue.TextSizeType int textSizeType, float textSize); - void setApplyEmbeddedStyles(boolean applyEmbeddedStyles); - void setApplyEmbeddedFontSizes(boolean applyEmbeddedFontSizes); - void setStyle(CaptionStyleCompat style); - void setBottomPaddingFraction(float bottomPaddingFraction); + /** + * Returns {@link #cues} with {@link #applyEmbeddedStyles} and {@link #applyEmbeddedFontSizes} + * applied. + * + *

        If {@link #applyEmbeddedStyles} is false then all styling spans are removed from {@link + * Cue#text}, {@link Cue#textSize} and {@link Cue#textSizeType} are set to {@link Cue#DIMEN_UNSET} + * and {@link Cue#windowColorSet} is set to false. + * + *

        Otherwise if {@link #applyEmbeddedFontSizes} is false then only size-related styling spans + * are removed from {@link Cue#text} and {@link Cue#textSize} and {@link Cue#textSizeType} are set + * to {@link Cue#DIMEN_UNSET} + */ + private List getCuesWithStylingPreferencesApplied() { + if (applyEmbeddedStyles && applyEmbeddedFontSizes) { + return cues; + } + List strippedCues = new ArrayList<>(cues.size()); + for (int i = 0; i < cues.size(); i++) { + strippedCues.add(removeEmbeddedStyling(cues.get(i))); + } + return strippedCues; } + + private Cue removeEmbeddedStyling(Cue cue) { + @Nullable CharSequence cueText = cue.text; + if (!applyEmbeddedStyles) { + Cue.Builder strippedCue = + cue.buildUpon().setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET).clearWindowColor(); + if (cueText != null) { + // Remove all spans, regardless of type. + strippedCue.setText(cueText.toString()); + } + return strippedCue.build(); + } else if (!applyEmbeddedFontSizes) { + if (cueText == null) { + return cue; + } + Cue.Builder strippedCue = cue.buildUpon().setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET); + if (cueText instanceof Spanned) { + SpannableString spannable = SpannableString.valueOf(cueText); + AbsoluteSizeSpan[] absSpans = + spannable.getSpans(0, spannable.length(), AbsoluteSizeSpan.class); + for (AbsoluteSizeSpan absSpan : absSpans) { + spannable.removeSpan(absSpan); + } + RelativeSizeSpan[] relSpans = + spannable.getSpans(0, spannable.length(), RelativeSizeSpan.class); + for (RelativeSizeSpan relSpan : relSpans) { + spannable.removeSpan(relSpan); + } + strippedCue.setText(spannable); + } + return strippedCue.build(); + } + return cue; + } + + } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java index f8a016bc8b..520b2d7580 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionDialogBuilder.java @@ -15,18 +15,22 @@ */ package com.google.android.exoplayer2.ui; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; +import android.content.DialogInterface; import android.view.LayoutInflater; import android.view.View; import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; import com.google.android.exoplayer2.trackselection.TrackSelectionUtil; -import com.google.android.exoplayer2.util.Assertions; +import java.lang.reflect.Constructor; import java.util.Collections; import java.util.List; @@ -46,6 +50,7 @@ public final class TrackSelectionDialogBuilder { } private final Context context; + @StyleRes private int themeResId; private final CharSequence title; private final MappedTrackInfo mappedTrackInfo; private final int rendererIndex; @@ -97,7 +102,7 @@ public final class TrackSelectionDialogBuilder { Context context, CharSequence title, DefaultTrackSelector trackSelector, int rendererIndex) { this.context = context; this.title = title; - this.mappedTrackInfo = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + this.mappedTrackInfo = checkNotNull(trackSelector.getCurrentMappedTrackInfo()); this.rendererIndex = rendererIndex; TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); @@ -118,6 +123,17 @@ public final class TrackSelectionDialogBuilder { newOverrides.isEmpty() ? null : newOverrides.get(0))); } + /** + * Sets the resource ID of the theme used to inflate this dialog. + * + * @param themeResId The resource ID of the theme. + * @return This builder, for convenience. + */ + public TrackSelectionDialogBuilder setTheme(@StyleRes int themeResId) { + this.themeResId = themeResId; + return this; + } + /** * Sets whether the selection is initially shown as disabled. * @@ -205,24 +221,18 @@ public final class TrackSelectionDialogBuilder { } /** Builds the dialog. */ - public AlertDialog build() { - AlertDialog.Builder builder = new AlertDialog.Builder(context); + public Dialog build() { + @Nullable Dialog dialog = buildForAndroidX(); + return dialog == null ? buildForPlatform() : dialog; + } + + private Dialog buildForPlatform() { + AlertDialog.Builder builder = new AlertDialog.Builder(context, themeResId); // Inflate with the builder's context to ensure the correct style is used. LayoutInflater dialogInflater = LayoutInflater.from(builder.getContext()); View dialogView = dialogInflater.inflate(R.layout.exo_track_selection_dialog, /* root= */ null); - - TrackSelectionView selectionView = dialogView.findViewById(R.id.exo_track_selection_view); - selectionView.setAllowMultipleOverrides(allowMultipleOverrides); - selectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); - selectionView.setShowDisableOption(showDisableOption); - if (trackNameProvider != null) { - selectionView.setTrackNameProvider(trackNameProvider); - } - selectionView.init(mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ null); - Dialog.OnClickListener okClickListener = - (dialog, which) -> - callback.onTracksSelected(selectionView.getIsDisabled(), selectionView.getOverrides()); + Dialog.OnClickListener okClickListener = setUpDialogView(dialogView); return builder .setTitle(title) @@ -231,4 +241,54 @@ public final class TrackSelectionDialogBuilder { .setNegativeButton(android.R.string.cancel, null) .create(); } + + // Reflection calls can't verify null safety of return values or parameters. + @SuppressWarnings("nullness:argument.type.incompatible") + @Nullable + private Dialog buildForAndroidX() { + try { + // This method uses reflection to avoid a dependency on AndroidX appcompat that adds 800KB to + // the APK size even with shrinking. See https://issuetracker.google.com/161514204. + // LINT.IfChange + Class builderClazz = Class.forName("androidx.appcompat.app.AlertDialog$Builder"); + Constructor builderConstructor = builderClazz.getConstructor(Context.class, int.class); + Object builder = builderConstructor.newInstance(context, themeResId); + + // Inflate with the builder's context to ensure the correct style is used. + Context builderContext = (Context) builderClazz.getMethod("getContext").invoke(builder); + LayoutInflater dialogInflater = LayoutInflater.from(builderContext); + View dialogView = + dialogInflater.inflate(R.layout.exo_track_selection_dialog, /* root= */ null); + Dialog.OnClickListener okClickListener = setUpDialogView(dialogView); + + builderClazz.getMethod("setTitle", CharSequence.class).invoke(builder, title); + builderClazz.getMethod("setView", View.class).invoke(builder, dialogView); + builderClazz + .getMethod("setPositiveButton", int.class, DialogInterface.OnClickListener.class) + .invoke(builder, android.R.string.ok, okClickListener); + builderClazz + .getMethod("setNegativeButton", int.class, DialogInterface.OnClickListener.class) + .invoke(builder, android.R.string.cancel, null); + return (Dialog) builderClazz.getMethod("create").invoke(builder); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (ClassNotFoundException e) { + // Expected if the AndroidX compat library is not available. + return null; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private Dialog.OnClickListener setUpDialogView(View dialogView) { + TrackSelectionView selectionView = dialogView.findViewById(R.id.exo_track_selection_view); + selectionView.setAllowMultipleOverrides(allowMultipleOverrides); + selectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); + selectionView.setShowDisableOption(showDisableOption); + if (trackNameProvider != null) { + selectionView.setTrackNameProvider(trackNameProvider); + } + selectionView.init(mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ null); + return (dialog, which) -> + callback.onTracksSelected(selectionView.getIsDisabled(), selectionView.getOverrides()); + } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java similarity index 59% rename from library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java rename to library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java index ee081f384e..f3de4298a5 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -29,58 +29,63 @@ import android.view.MotionEvent; import android.webkit.WebView; import android.widget.FrameLayout; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.text.CaptionStyleCompat; import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; -import java.nio.charset.Charset; +import com.google.common.base.Charsets; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * A {@link SubtitleView.Output} that uses a {@link WebView} to render subtitles. * *

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

        NOTE: This is currently extremely experimental and doesn't support most {@link Cue} styling - * properties. */ -/* package */ final class SubtitleWebView extends FrameLayout implements SubtitleView.Output { +/* package */ final class WebViewSubtitleOutput extends FrameLayout implements SubtitleView.Output { /** - * A {@link SubtitleTextView} used for displaying bitmap cues. + * A hard-coded value for the line-height attribute, so we can use it to move text up and down by + * one line-height. Most browsers default 'normal' (CSS default) to 1.2 for most font families. + */ + private static final float CSS_LINE_HEIGHT = 1.2f; + + private static final String DEFAULT_BACKGROUND_CSS_CLASS = "default_bg"; + + /** + * A {@link CanvasSubtitleOutput} used for displaying bitmap cues. * *

        There's no advantage to displaying bitmap cues in a {@link WebView}, so we re-use the * existing logic. */ - private final SubtitleTextView subtitleTextView; + private final CanvasSubtitleOutput canvasSubtitleOutput; private final WebView webView; - private final List cues; - @Cue.TextSizeType private int defaultTextSizeType; - private float defaultTextSize; - private boolean applyEmbeddedStyles; - private boolean applyEmbeddedFontSizes; + private List textCues; private CaptionStyleCompat style; + private float defaultTextSize; + @Cue.TextSizeType private int defaultTextSizeType; private float bottomPaddingFraction; - public SubtitleWebView(Context context) { + public WebViewSubtitleOutput(Context context) { this(context, null); } - public SubtitleWebView(Context context, @Nullable AttributeSet attrs) { + public WebViewSubtitleOutput(Context context, @Nullable AttributeSet attrs) { super(context, attrs); - cues = new ArrayList<>(); - defaultTextSizeType = Cue.TEXT_SIZE_TYPE_FRACTIONAL; - defaultTextSize = DEFAULT_TEXT_SIZE_FRACTION; - applyEmbeddedStyles = true; - applyEmbeddedFontSizes = true; + + textCues = Collections.emptyList(); style = CaptionStyleCompat.DEFAULT; + defaultTextSize = DEFAULT_TEXT_SIZE_FRACTION; + defaultTextSizeType = Cue.TEXT_SIZE_TYPE_FRACTIONAL; bottomPaddingFraction = DEFAULT_BOTTOM_PADDING_FRACTION; - subtitleTextView = new SubtitleTextView(context, attrs); + canvasSubtitleOutput = new CanvasSubtitleOutput(context, attrs); webView = new WebView(context, attrs) { @Override @@ -99,79 +104,53 @@ import java.util.List; }; webView.setBackgroundColor(Color.TRANSPARENT); - addView(subtitleTextView); + addView(canvasSubtitleOutput); addView(webView); } @Override - public void onCues(List cues) { + public void update( + List cues, + CaptionStyleCompat style, + float textSize, + @Cue.TextSizeType int textSizeType, + float bottomPaddingFraction) { + this.style = style; + this.defaultTextSize = textSize; + this.defaultTextSizeType = textSizeType; + this.bottomPaddingFraction = bottomPaddingFraction; + List bitmapCues = new ArrayList<>(); - this.cues.clear(); + List textCues = new ArrayList<>(); for (int i = 0; i < cues.size(); i++) { Cue cue = cues.get(i); if (cue.bitmap != null) { bitmapCues.add(cue); } else { - this.cues.add(cue); + textCues.add(cue); } } - subtitleTextView.onCues(bitmapCues); - // Invalidate to trigger subtitleTextView to draw. + + if (!this.textCues.isEmpty() || !textCues.isEmpty()) { + this.textCues = textCues; + // Skip updating if this is a transition from empty-cues to empty-cues (i.e. only positioning + // info has changed) since a positional-only change with no cues is a visual no-op. The new + // position info will be used when we get non-empty cue data in a future update() call. + updateWebView(); + } + canvasSubtitleOutput.update(bitmapCues, style, textSize, textSizeType, bottomPaddingFraction); + // Invalidate to trigger canvasSubtitleOutput to draw. invalidate(); - updateWebView(); } @Override - public void setTextSize(@Cue.TextSizeType int textSizeType, float textSize) { - if (this.defaultTextSizeType == textSizeType && this.defaultTextSize == textSize) { - return; + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed && !textCues.isEmpty()) { + // A positional change with no cues is a visual no-op. The new layout info will be used + // automatically next time update() is called. + updateWebView(); } - this.defaultTextSizeType = textSizeType; - this.defaultTextSize = textSize; - invalidate(); - updateWebView(); - } - - @Override - public void setApplyEmbeddedStyles(boolean applyEmbeddedStyles) { - if (this.applyEmbeddedStyles == applyEmbeddedStyles - && this.applyEmbeddedFontSizes == applyEmbeddedStyles) { - return; - } - this.applyEmbeddedStyles = applyEmbeddedStyles; - this.applyEmbeddedFontSizes = applyEmbeddedStyles; - invalidate(); - updateWebView(); - } - - @Override - public void setApplyEmbeddedFontSizes(boolean applyEmbeddedFontSizes) { - if (this.applyEmbeddedFontSizes == applyEmbeddedFontSizes) { - return; - } - this.applyEmbeddedFontSizes = applyEmbeddedFontSizes; - invalidate(); - updateWebView(); - } - - @Override - public void setStyle(CaptionStyleCompat style) { - if (this.style == style) { - return; - } - this.style = style; - invalidate(); - updateWebView(); - } - - @Override - public void setBottomPaddingFraction(float bottomPaddingFraction) { - if (this.bottomPaddingFraction == bottomPaddingFraction) { - return; - } - this.bottomPaddingFraction = bottomPaddingFraction; - invalidate(); - updateWebView(); } /** @@ -181,7 +160,6 @@ import java.util.List; * other methods may be called on this view after destroy. */ public void destroy() { - cues.clear(); webView.destroy(); } @@ -189,7 +167,7 @@ import java.util.List; StringBuilder html = new StringBuilder(); html.append( Util.formatInvariant( - "

        ", HtmlUtils.toCssRgba(style.foregroundColor), - convertTextSizeToCss(defaultTextSizeType, defaultTextSize))); + convertTextSizeToCss(defaultTextSizeType, defaultTextSize), + CSS_LINE_HEIGHT, + convertCaptionStyleToCssTextShadow(style))); - String backgroundColorCss = HtmlUtils.toCssRgba(style.backgroundColor); - - for (int i = 0; i < cues.size(); i++) { - Cue cue = cues.get(i); + Map cssRuleSets = new HashMap<>(); + cssRuleSets.put( + HtmlUtils.cssAllClassDescendantsSelector(DEFAULT_BACKGROUND_CSS_CLASS), + Util.formatInvariant("background-color:%s;", HtmlUtils.toCssRgba(style.backgroundColor))); + for (int i = 0; i < textCues.size(); i++) { + Cue cue = textCues.get(i); float positionPercent = (cue.position != Cue.DIMEN_UNSET) ? (cue.position * 100) : 50; int positionAnchorTranslatePercent = anchorTypeToTranslatePercent(cue.positionAnchor); - float linePercent; - int lineTranslatePercent; - @Cue.AnchorType int lineAnchor; + String lineValue; + boolean lineMeasuredFromEnd = false; + int lineAnchorTranslatePercent = 0; if (cue.line != Cue.DIMEN_UNSET) { switch (cue.lineType) { case Cue.LINE_TYPE_NUMBER: if (cue.line >= 0) { - linePercent = 0; - lineTranslatePercent = Math.round(cue.line) * 100; + lineValue = Util.formatInvariant("%.2fem", cue.line * CSS_LINE_HEIGHT); } else { - linePercent = 100; - lineTranslatePercent = Math.round(cue.line + 1) * 100; + lineValue = Util.formatInvariant("%.2fem", (-cue.line - 1) * CSS_LINE_HEIGHT); + lineMeasuredFromEnd = true; } break; case Cue.LINE_TYPE_FRACTION: case Cue.TYPE_UNSET: default: - linePercent = cue.line * 100; - lineTranslatePercent = 0; + lineValue = Util.formatInvariant("%.2f%%", cue.line * 100); + + lineAnchorTranslatePercent = + cue.verticalType == Cue.VERTICAL_TYPE_RL + ? -anchorTypeToTranslatePercent(cue.lineAnchor) + : anchorTypeToTranslatePercent(cue.lineAnchor); } - lineAnchor = cue.lineAnchor; } else { - linePercent = (1.0f - bottomPaddingFraction) * 100; - lineTranslatePercent = 0; - // If Cue.line == DIMEN_UNSET then ignore Cue.lineAnchor and assume ANCHOR_TYPE_END. - lineAnchor = Cue.ANCHOR_TYPE_END; + lineValue = Util.formatInvariant("%.2f%%", (1.0f - bottomPaddingFraction) * 100); + lineAnchorTranslatePercent = -100; } - int lineAnchorTranslatePercent = - cue.verticalType == Cue.VERTICAL_TYPE_RL - ? -anchorTypeToTranslatePercent(lineAnchor) - : anchorTypeToTranslatePercent(lineAnchor); String size = cue.size != Cue.DIMEN_UNSET @@ -250,23 +230,22 @@ import java.util.List; String writingMode = convertVerticalTypeToCss(cue.verticalType); String cueTextSizeCssPx = convertTextSizeToCss(cue.textSizeType, cue.textSize); String windowCssColor = - HtmlUtils.toCssRgba( - cue.windowColorSet && applyEmbeddedStyles ? cue.windowColor : style.windowColor); + HtmlUtils.toCssRgba(cue.windowColorSet ? cue.windowColor : style.windowColor); String positionProperty; String lineProperty; switch (cue.verticalType) { case Cue.VERTICAL_TYPE_LR: - lineProperty = "left"; + lineProperty = lineMeasuredFromEnd ? "right" : "left"; positionProperty = "top"; break; case Cue.VERTICAL_TYPE_RL: - lineProperty = "right"; + lineProperty = lineMeasuredFromEnd ? "left" : "right"; positionProperty = "top"; break; case Cue.TYPE_UNSET: default: - lineProperty = "top"; + lineProperty = lineMeasuredFromEnd ? "bottom" : "top"; positionProperty = "left"; } @@ -275,31 +254,43 @@ import java.util.List; int verticalTranslatePercent; if (cue.verticalType == Cue.VERTICAL_TYPE_LR || cue.verticalType == Cue.VERTICAL_TYPE_RL) { sizeProperty = "height"; - horizontalTranslatePercent = lineTranslatePercent + lineAnchorTranslatePercent; + horizontalTranslatePercent = lineAnchorTranslatePercent; verticalTranslatePercent = positionAnchorTranslatePercent; } else { sizeProperty = "width"; horizontalTranslatePercent = positionAnchorTranslatePercent; - verticalTranslatePercent = lineTranslatePercent + lineAnchorTranslatePercent; + verticalTranslatePercent = lineAnchorTranslatePercent; + } + + SpannedToHtmlConverter.HtmlAndCss htmlAndCss = + SpannedToHtmlConverter.convert( + cue.text, getContext().getResources().getDisplayMetrics().density); + for (String cssSelector : cssRuleSets.keySet()) { + @Nullable + String previousCssDeclarationBlock = + cssRuleSets.put(cssSelector, cssRuleSets.get(cssSelector)); + Assertions.checkState( + previousCssDeclarationBlock == null + || previousCssDeclarationBlock.equals(cssRuleSets.get(cssSelector))); } html.append( Util.formatInvariant( - "
        ", positionProperty, positionPercent, lineProperty, - linePercent, + lineValue, sizeProperty, size, textAlign, @@ -308,19 +299,23 @@ import java.util.List; windowCssColor, horizontalTranslatePercent, verticalTranslatePercent)) - .append(Util.formatInvariant("", backgroundColorCss)) - .append( - SpannedToHtmlConverter.convert( - cue.text, getContext().getResources().getDisplayMetrics().density)) + .append(Util.formatInvariant("", DEFAULT_BACKGROUND_CSS_CLASS)) + .append(htmlAndCss.html) .append("") .append("
        "); } - html.append("
        "); + StringBuilder htmlHead = new StringBuilder(); + htmlHead.append(""); + html.insert(0, htmlHead.toString()); + webView.loadData( - Base64.encodeToString( - html.toString().getBytes(Charset.forName(C.UTF8_NAME)), Base64.NO_PADDING), + Base64.encodeToString(html.toString().getBytes(Charsets.UTF_8), Base64.NO_PADDING), "text/html", "base64"); } @@ -345,6 +340,28 @@ import java.util.List; return Util.formatInvariant("%.2fpx", sizeDp); } + private static String convertCaptionStyleToCssTextShadow(CaptionStyleCompat style) { + switch (style.edgeType) { + case CaptionStyleCompat.EDGE_TYPE_DEPRESSED: + return Util.formatInvariant( + "-0.05em -0.05em 0.15em %s", HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW: + return Util.formatInvariant("0.1em 0.12em 0.15em %s", HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_OUTLINE: + // -webkit-text-stroke makes the underlying text appear too narrow, so we 'fake' an edge + // outline using 4 text-shadows each offset by 1px in different directions. + return Util.formatInvariant( + "1px 1px 0 %1$s, 1px -1px 0 %1$s, -1px 1px 0 %1$s, -1px -1px 0 %1$s", + HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_RAISED: + return Util.formatInvariant( + "0.06em 0.08em 0.15em %s", HtmlUtils.toCssRgba(style.edgeColor)); + case CaptionStyleCompat.EDGE_TYPE_NONE: + default: + return "unset"; + } + } + private static String convertVerticalTypeToCss(@Cue.VerticalType int verticalType) { switch (verticalType) { case Cue.VERTICAL_TYPE_LR: diff --git a/library/common/src/main/proguard-rules.txt b/library/ui/src/main/proguard-rules.txt similarity index 100% rename from library/common/src/main/proguard-rules.txt rename to library/ui/src/main/proguard-rules.txt diff --git a/library/ui/src/main/res/drawable/exo_edit_mode_logo.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_edit_mode_logo.xml similarity index 100% rename from library/ui/src/main/res/drawable/exo_edit_mode_logo.xml rename to library/ui/src/main/res/drawable-anydpi-v21/exo_edit_mode_logo.xml diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_audiotrack.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_audiotrack.xml new file mode 100644 index 0000000000..7ee298e357 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_audiotrack.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_check.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_check.xml new file mode 100644 index 0000000000..ad5d63ac5c --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_check.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_left.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_left.xml new file mode 100644 index 0000000000..d614a9e2f2 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_left.xml @@ -0,0 +1,24 @@ + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_right.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_right.xml new file mode 100644 index 0000000000..9b25426dd1 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_chevron_right.xml @@ -0,0 +1,24 @@ + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_default_album_image.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_default_album_image.xml new file mode 100644 index 0000000000..d95f42ab3d --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_default_album_image.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_forward.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_forward.xml new file mode 100644 index 0000000000..dd023b2fd8 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_forward.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_enter.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_enter.xml new file mode 100644 index 0000000000..f0faf4d025 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_enter.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_exit.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_exit.xml new file mode 100644 index 0000000000..73d35277a3 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_fullscreen_exit.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_pause_circle_filled.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_pause_circle_filled.xml new file mode 100644 index 0000000000..6789374094 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_pause_circle_filled.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_play_circle_filled.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_play_circle_filled.xml new file mode 100644 index 0000000000..f00f85f543 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_play_circle_filled.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_rewind.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_rewind.xml new file mode 100644 index 0000000000..487a178369 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_rewind.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_settings.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_settings.xml new file mode 100644 index 0000000000..2dab2c0f17 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_settings.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_next.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_next.xml new file mode 100644 index 0000000000..183434e864 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_next.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_previous.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_previous.xml new file mode 100644 index 0000000000..363b94f3dc --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_skip_previous.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_speed.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_speed.xml new file mode 100644 index 0000000000..fd1fd8e1d5 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_speed.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_off.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_off.xml new file mode 100644 index 0000000000..ea6819eb3a --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_off.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_on.xml b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_on.xml new file mode 100644 index 0000000000..b1d36cde79 --- /dev/null +++ b/library/ui/src/main/res/drawable-anydpi-v21/exo_ic_subtitle_on.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/library/ui/src/main/res/drawable-hdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-hdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000..49119345eb Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_edit_mode_logo.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000..f034030e94 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_audiotrack.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_check.png new file mode 100644 index 0000000000..8b0090016b Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_check.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000..136a2f11f6 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_left.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000..5524979c80 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_chevron_right.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000..a6fe957666 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_default_album_image.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_forward.png new file mode 100644 index 0000000000..9a060ba139 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_forward.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000..0916a679c0 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_enter.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000..175037d7db Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_fullscreen_exit.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000..9c78d75da3 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_pause_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000..4233b9de2f Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_play_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_rewind.png new file mode 100644 index 0000000000..46d4827248 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_rewind.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_settings.png new file mode 100644 index 0000000000..1ff783555a Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_settings.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000..0351aa8d86 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_next.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000..a730d2f058 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_skip_previous.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_speed.png new file mode 100644 index 0000000000..eb3aa47167 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_speed.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000..ef03dfb091 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_off.png differ diff --git a/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000..deda835983 Binary files /dev/null and b/library/ui/src/main/res/drawable-hdpi/exo_ic_subtitle_on.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-ldpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000..594518467c Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_edit_mode_logo.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000..d4cbf83246 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_audiotrack.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_check.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_check.png new file mode 100644 index 0000000000..cef4663c17 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_check.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000..c471afcd4e Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_left.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000..b6a718ff00 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_chevron_right.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000..c8e5a072c6 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_default_album_image.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_forward.png new file mode 100644 index 0000000000..74d8a009be Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_forward.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000..e5cca64c17 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_enter.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000..d1b24e8d1c Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_fullscreen_exit.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000..cc9962ade7 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_pause_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000..40fb18a4e9 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_play_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_rewind.png new file mode 100644 index 0000000000..d93dddca41 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_rewind.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_settings.png new file mode 100644 index 0000000000..1d45348eec Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_settings.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_next.png new file mode 100644 index 0000000000..5847a7e79a Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_next.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000..b89a9411fa Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_skip_previous.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_speed.png new file mode 100644 index 0000000000..22e85352bc Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_speed.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000..f4ced43664 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_off.png differ diff --git a/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000..10bcaa3267 Binary files /dev/null and b/library/ui/src/main/res/drawable-ldpi/exo_ic_subtitle_on.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-mdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000..fc0243bf4c Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_edit_mode_logo.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000..5bd2902aed Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_audiotrack.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_check.png new file mode 100644 index 0000000000..9eacd7f57e Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_check.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000..36da4e6348 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_left.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000..fc4f4efb95 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_chevron_right.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000..8d4b1337d9 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_default_album_image.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_forward.png new file mode 100644 index 0000000000..9afa617786 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_forward.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000..6039e3cfd8 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_enter.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000..23c3eb55d8 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_fullscreen_exit.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000..cafa79d921 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_pause_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000..027bc1157b Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_play_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_rewind.png new file mode 100644 index 0000000000..02980c5b46 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_rewind.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_settings.png new file mode 100644 index 0000000000..10448d399d Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_settings.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000..f6be472ad2 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_skip_next.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000..7dc4a41a3e Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_skip_previous.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_speed.png new file mode 100644 index 0000000000..040ba0ab69 Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_speed.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000..eea21c2ebb Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_off.png differ diff --git a/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000..51df3049dc Binary files /dev/null and b/library/ui/src/main/res/drawable-mdpi/exo_ic_subtitle_on.png differ diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml new file mode 100644 index 0000000000..5e4dd5550f --- /dev/null +++ b/library/ui/src/main/res/drawable-v21/exo_ripple_ffwd.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml b/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml new file mode 100644 index 0000000000..ee43206b4a --- /dev/null +++ b/library/ui/src/main/res/drawable-v21/exo_ripple_rew.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-xhdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000..ee42e2374a Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_edit_mode_logo.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000..ae4cc4689b Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_audiotrack.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_check.png new file mode 100644 index 0000000000..1f58c697e7 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_check.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000..32ce426b80 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_left.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000..2f47653961 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_chevron_right.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000..201f6ff580 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_default_album_image.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_forward.png new file mode 100644 index 0000000000..fdacfa9e71 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_forward.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000..4423c7ce99 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_enter.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000..364bad0b84 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_fullscreen_exit.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000..06f936803f Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_pause_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000..c978556298 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_play_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_rewind.png new file mode 100644 index 0000000000..d9b2a4e9db Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_rewind.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_settings.png new file mode 100644 index 0000000000..23358ae9cf Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_settings.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000..974ee29acf Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_next.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000..eb08953752 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_skip_previous.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_speed.png new file mode 100644 index 0000000000..ace3e4378b Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_speed.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000..820c7983fe Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_off.png differ diff --git a/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000..2b5bf9fe77 Binary files /dev/null and b/library/ui/src/main/res/drawable-xhdpi/exo_ic_subtitle_on.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-xxhdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000..bd2e9e6331 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_edit_mode_logo.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000..da5609f865 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_audiotrack.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_check.png new file mode 100644 index 0000000000..338d25f8b4 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_check.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000..955e07861f Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_left.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000..32ec519cd1 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_chevron_right.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000..d3901f6622 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_default_album_image.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_forward.png new file mode 100644 index 0000000000..96eebf8747 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_forward.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000..9652e513db Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_enter.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000..5fb4d7bef4 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_fullscreen_exit.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000..92af725463 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_pause_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000..352b28a5d5 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_play_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_rewind.png new file mode 100644 index 0000000000..46bf418a7c Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_rewind.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_settings.png new file mode 100644 index 0000000000..01cbd16678 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_settings.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000..59f5bc33fd Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_next.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000..381625dd7c Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_skip_previous.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_speed.png new file mode 100644 index 0000000000..628c75380d Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_speed.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000..35968f85e2 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_off.png differ diff --git a/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000..1770a0d438 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxhdpi/exo_ic_subtitle_on.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_edit_mode_logo.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_edit_mode_logo.png new file mode 100644 index 0000000000..0e9df1baa0 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_edit_mode_logo.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_audiotrack.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_audiotrack.png new file mode 100644 index 0000000000..b34687258a Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_audiotrack.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_check.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_check.png new file mode 100644 index 0000000000..bce32333d2 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_check.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_left.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_left.png new file mode 100644 index 0000000000..92e6ea5844 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_left.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_right.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_right.png new file mode 100644 index 0000000000..a7aa4c71b4 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_chevron_right.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_default_album_image.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_default_album_image.png new file mode 100644 index 0000000000..25a59d6f92 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_default_album_image.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_forward.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_forward.png new file mode 100644 index 0000000000..669a9d3fcc Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_forward.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_enter.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_enter.png new file mode 100644 index 0000000000..c1dcfb2902 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_enter.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_exit.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_exit.png new file mode 100644 index 0000000000..ef360fe40c Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_fullscreen_exit.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_pause_circle_filled.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_pause_circle_filled.png new file mode 100644 index 0000000000..0143694140 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_pause_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_play_circle_filled.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_play_circle_filled.png new file mode 100644 index 0000000000..f4ab8b20f2 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_play_circle_filled.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_rewind.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_rewind.png new file mode 100644 index 0000000000..a85aa70d5e Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_rewind.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_settings.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_settings.png new file mode 100644 index 0000000000..b87c23ee33 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_settings.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_next.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_next.png new file mode 100644 index 0000000000..2368f14d55 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_next.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_previous.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_previous.png new file mode 100644 index 0000000000..412ae6a0b7 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_skip_previous.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_speed.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_speed.png new file mode 100644 index 0000000000..a9b45609c6 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_speed.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_off.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_off.png new file mode 100644 index 0000000000..3a2398f9cb Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_off.png differ diff --git a/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_on.png b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_on.png new file mode 100644 index 0000000000..5178e3a619 Binary files /dev/null and b/library/ui/src/main/res/drawable-xxxhdpi/exo_ic_subtitle_on.png differ diff --git a/library/ui/src/main/res/drawable/exo_progress.xml b/library/ui/src/main/res/drawable/exo_progress.xml new file mode 100644 index 0000000000..2ba05326f0 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_progress.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_progress_thumb.xml b/library/ui/src/main/res/drawable/exo_progress_thumb.xml new file mode 100644 index 0000000000..e61a015f7d --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_progress_thumb.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml new file mode 100644 index 0000000000..9f7e1fd027 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ripple_ffwd.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_ripple_rew.xml b/library/ui/src/main/res/drawable/exo_ripple_rew.xml new file mode 100644 index 0000000000..5562b1352c --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_ripple_rew.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/drawable/exo_rounded_rectangle.xml b/library/ui/src/main/res/drawable/exo_rounded_rectangle.xml new file mode 100644 index 0000000000..c5bbb6ecc3 --- /dev/null +++ b/library/ui/src/main/res/drawable/exo_rounded_rectangle.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/library/ui/src/main/res/font/roboto_medium_numbers.ttf b/library/ui/src/main/res/font/roboto_medium_numbers.ttf new file mode 100644 index 0000000000..b61ac79ddf Binary files /dev/null and b/library/ui/src/main/res/font/roboto_medium_numbers.ttf differ diff --git a/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml b/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml new file mode 100644 index 0000000000..e0703ab394 --- /dev/null +++ b/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml @@ -0,0 +1,28 @@ + + + + + +