diff --git a/.hgignore b/.hgignore index a32dfee85d4..e2c54bcf610 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 d488f4113e6..ac4c41b0fed 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 9daedd60665..de48e5d90e6 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -3,124 +3,522 @@ ### dev-v2 (not yet released) * Core library: - * 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. - * 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`. - * 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 sample count parameter to `MediaCodecRenderer.processOutputBuffer` - and `AudioSink.handleBuffer` to allow batching multiple encoded frames - in one buffer. - * 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 + * Fix bug where streams with highly uneven durations may get stuck in a + buffering state + ([#7943](https://github.com/google/ExoPlayer/issues/7943)). + * Verify correct thread usage in `SimpleExoPlayer` by default. Opt-out is + still possible until the next major release using + `setThrowsWhenUsingWrongThread(false)` + ([#4463](https://github.com/google/ExoPlayer/issues/4463)). +* Track selection: + * Add option to specify multiple preferred audio or text languages. +* Data sources: + * Add support for `android.resource` URI scheme in `RawResourceDataSource` + ([#7866](https://github.com/google/ExoPlayer/issues/7866)). +* Text: + * Add support for `\h` SSA/ASS style override code (non-breaking space). +* Audio: + * Retry playback after some types of `AudioTrack` error. +* Extractors: + * Add support for .mp2 boxes in the `AtomParsers` + ([#7967](https://github.com/google/ExoPlayer/issues/7967)). + * Use TLEN ID3 tag to compute the duration in Mp3Extractor + ([#7949](https://github.com/google/ExoPlayer/issues/7949)). +* UI + * Add the option to sort tracks by `Format` in `TrackSelectionView` and + `TrackSelectionDialogBuilder` + ([#7709](https://github.com/google/ExoPlayer/issues/7709)). + +### 2.12.0 (2020-09-11) ### + +To learn more about what's new in 2.12, read the corresponding +[blog post](https://medium.com/google-exoplayer/exoplayer-2-12-whats-new-e43ef8ff72e7). + +* 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. Learn more by + reading + [this blog post](https://medium.com/google-exoplayer/a-top-level-playlist-api-for-exoplayer-abe0a24edb55). + * 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`. + * Remove `PlaybackParameters.skipSilence`, and replace it with + `AudioComponent.setSkipSilenceEnabled`. This method is also + available on `SimpleExoPlayer`. An + `AudioListener.onSkipSilenceEnabledChanged` callback is also + added. + * 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. + * Update [APK shrinking guide](https://exoplayer.dev/shrinking.html) + to explain how shrinking works with the new `MediaItem` and + `DefaultMediaSourceFactory` implementations + ([#7937](https://github.com/google/ExoPlayer/issues/7937)). + * Add additional options to `Builder` that were previously only + accessible via setters. + * Add opt-in to verify correct thread usage with + `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. - * Update `CachedContentIndex` to use `SecureRandom` for generating the - initialization vector used to encrypt the cache contents. - * 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)). + 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). + * Many of the changes described below improve support for Japanese + subtitles. Read + [this blog post](https://medium.com/google-exoplayer/improved-japanese-subtitle-support-7598fee12cf4) + to learn more. + * 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: + * 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 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: - * 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 + * 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)). - * Remove generics from DRM components. -* Downloads: Merge downloads in `SegmentDownloader` to improve overall - download speed ([#5978](https://github.com/google/ExoPlayer/issues/5978)). -* 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. -* 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 - * 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)). + * 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. Read + [this blog post](https://medium.com/google-exoplayer/the-media2-extension-for-exoplayer-d6b7d89b9063) + to learn more. * 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)). +* 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 + the main adaptation sets to which they refer. Trick play tracks are + marked with the `C.ROLE_FLAG_TRICK_PLAY` flag. + * Fix assertion failure in `SampleQueue` when playing DASH streams with + EMSG tracks ([#7273](https://github.com/google/ExoPlayer/issues/7273)). +* MP4: Store the Android capture frame rate only in `Format.metadata`. + `Format.frameRate` now stores the calculated frame rate. +* FMP4: Avoid throwing an exception while parsing default sample values whose + most significant bits are set + ([#7207](https://github.com/google/ExoPlayer/issues/7207)). +* MP3: Fix issue parsing the XING headers belonging to files larger than 2GB + ([#7337](https://github.com/google/ExoPlayer/issues/7337)). +* MPEG-TS: Fix issue where SEI NAL units were incorrectly dropped from H.265 + samples ([#7113](https://github.com/google/ExoPlayer/issues/7113)). +* UI: + * Fix `DefaultTimeBar` to respect touch transformations + ([#7303](https://github.com/google/ExoPlayer/issues/7303)). + * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. +* Text: + * Use anti-aliasing and bitmap filtering when displaying bitmap + subtitles. + * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct + color. +* IMA extension: + * Upgrade to IMA SDK version 3.19.0, and migrate to new + preloading APIs + ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes + several issues involving preloading and handling of ad loading error + cases: ([#4140](https://github.com/google/ExoPlayer/issues/4140), + [#5006](https://github.com/google/ExoPlayer/issues/5006), + [#6030](https://github.com/google/ExoPlayer/issues/6030), + [#6097](https://github.com/google/ExoPlayer/issues/6097), + [#6425](https://github.com/google/ExoPlayer/issues/6425), + [#6967](https://github.com/google/ExoPlayer/issues/6967), + [#7041](https://github.com/google/ExoPlayer/issues/7041), + [#7161](https://github.com/google/ExoPlayer/issues/7161), + [#7212](https://github.com/google/ExoPlayer/issues/7212), + [#7340](https://github.com/google/ExoPlayer/issues/7340)). + * Add support for timing out ad preloading, to avoid playback getting + stuck if an ad group unexpectedly fails to load + ([#5444](https://github.com/google/ExoPlayer/issues/5444), + [#5966](https://github.com/google/ExoPlayer/issues/5966), + [#7002](https://github.com/google/ExoPlayer/issues/7002)). + * Fix `AdsMediaSource` child `MediaSource`s not being released. +* Cronet extension: Default to using the Cronet implementation in Google Play + Services rather than Cronet Embedded. This allows Cronet to be used with a + negligible increase in application size, compared to approximately 8MB when + embedding the library. +* OkHttp extension: Upgrade OkHttp dependency to 3.12.11. +* MediaSession extension: + * Only set the playback state to `BUFFERING` if `playWhenReady` is true + ([#7206](https://github.com/google/ExoPlayer/issues/7206)). + * Add missing `@Nullable` annotations to `MediaSessionConnector` + ([#7234](https://github.com/google/ExoPlayer/issues/7234)). +* AV1 extension: Add a heuristic to determine the default number of threads + used for AV1 playback using the extension. ### 2.11.4 (2020-04-08) @@ -143,11 +541,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 @@ -1096,7 +1495,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)). @@ -1527,7 +1926,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 a4823b94eec..8c044f2fdaa 100644 --- a/build.gradle +++ b/build.gradle @@ -17,15 +17,16 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.1' + classpath 'com.android.tools.build:gradle:4.0.1' classpath 'com.novoda:bintray-release:0.9.1' - classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.0' + classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.1' } } 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 00000000000..431a7ab14d7 --- /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 1a7840588f7..c2b00003680 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 ac569331556..b5082433712 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 7e62249db1b..2360e011371 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 2c68a5277a5..fd682433f9f 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 c929f09c87b..868e3c7b438 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 900f32db456..f2d2288b6a6 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/DemoUtil.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java index 68b9d05370d..50343f9205f 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/DemoUtil.java @@ -41,19 +41,19 @@ // Clear content. samples.add( new MediaItem.Builder() - .setSourceUri("https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd") + .setUri("https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd") .setMediaMetadata(new MediaMetadata.Builder().setTitle("Clear DASH: Tears").build()) .setMimeType(MIME_TYPE_DASH) .build()); samples.add( new MediaItem.Builder() - .setSourceUri("https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8") + .setUri("https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8") .setMediaMetadata(new MediaMetadata.Builder().setTitle("Clear HLS: Angel one").build()) .setMimeType(MIME_TYPE_HLS) .build()); samples.add( new MediaItem.Builder() - .setSourceUri("https://html5demos.com/assets/dizzy.mp4") + .setUri("https://html5demos.com/assets/dizzy.mp4") .setMediaMetadata(new MediaMetadata.Builder().setTitle("Clear MP4: Dizzy").build()) .setMimeType(MIME_TYPE_VIDEO_MP4) .build()); @@ -61,8 +61,7 @@ // DRM content. samples.add( new MediaItem.Builder() - .setSourceUri( - Uri.parse("https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd")) + .setUri(Uri.parse("https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd")) .setMediaMetadata( new MediaMetadata.Builder().setTitle("Widevine DASH cenc: Tears").build()) .setMimeType(MIME_TYPE_DASH) @@ -71,8 +70,7 @@ .build()); samples.add( new MediaItem.Builder() - .setSourceUri( - "https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1.mpd") + .setUri("https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1.mpd") .setMediaMetadata( new MediaMetadata.Builder().setTitle("Widevine DASH cbc1: Tears").build()) .setMimeType(MIME_TYPE_DASH) @@ -81,8 +79,7 @@ .build()); samples.add( new MediaItem.Builder() - .setSourceUri( - "https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd") + .setUri("https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd") .setMediaMetadata( new MediaMetadata.Builder().setTitle("Widevine DASH cbcs: Tears").build()) .setMimeType(MIME_TYPE_DASH) 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 c5dfe70d933..9dc82e0b236 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.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 @@ interface Listener { 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 @@ public PlayerManager( 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 723047b1ede..70e2af79dfb 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 12dabe902be..9bffc3edea5 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 8fe3e040454..e065f9b8f26 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 e54d0c256dd..17fec0601d5 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 e333d977b2e..0c07c12a70c 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 063b6607513..89bea325814 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 @@ public void initialize() { 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 c788f752f7e..dc0a8b990ac 100644 --- a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java @@ -24,6 +24,7 @@ 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 @@ private void initializePlayer() { ACTION_VIEW.equals(action) ? Assertions.checkNotNull(intent.getData()) : Uri.parse(DEFAULT_MEDIA_URI); - String userAgent = Util.getUserAgent(this, getString(R.string.application_name)); DrmSessionManager drmSessionManager; if (Util.SDK_INT >= 18 && intent.hasExtra(DRM_SCHEME_EXTRA)) { String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA)); String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA)); UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme)); - HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); + HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(); HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory); drmSessionManager = @@ -155,21 +155,19 @@ private void initializePlayer() { drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); } - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - this, Util.getUserAgent(this, getString(R.string.application_name))); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this); MediaSource mediaSource; @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA)); if (type == C.TYPE_DASH) { 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 00000000000..59ad0524497 --- /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 ec3868d6a88..4728dc2d496 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 bdb04e5ba8e..00072c070b8 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 b7a8666fe37..716b3c1f998 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 cd201892abc..5358f3cec7c 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 0240a377ac3..053665502b3 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" - }, - { - "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" + "drm_license_uri": "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", + "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": "WV: Secure HD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_hd.mpd", + "name": "Secure L3", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + "drm_license_uri": "https://proxy.uat.widevine.com/proxy?video_id=GTS_SW_SECURE_CRYPTO&provider=widevine_test" }, { - "name": "WV: Secure UHD (MP4,H265)", - "uri": "https://storage.googleapis.com/wvmedia/cenc/hevc/tears/tears_uhd.mpd", + "name": "Secure L1", + "uri": "https://storage.googleapis.com/wvmedia/2019/cenc/av1/24/webm/llama_av1_480p_400.webm", "drm_scheme": "widevine", - "drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test" + "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,37 @@ "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" + }, + { + "name": "Japanese features (vertical + rubies) [TTML]", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ttml/japanese-ttml.xml", + "subtitle_mime_type": "application/ttml+xml", + "subtitle_language": "ja" + }, + { + "name": "Japanese features (vertical + rubies) [WebVTT]", + "uri": "https://html5demos.com/assets/dizzy.mp4", + "subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/webvtt/japanese.vtt", + "subtitle_mime_type": "text/vtt", + "subtitle_language": "ja" } ] }, @@ -632,13 +559,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 bd74eb5c2cd..00000000000 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DemoApplication.java +++ /dev/null @@ -1,185 +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.DefaultDownloaderFactory; -import com.google.android.exoplayer2.offline.DownloadManager; -import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; -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.FileDataSource; -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.CacheDataSourceFactory; -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); - DownloaderConstructorHelper downloaderConstructorHelper = - new DownloaderConstructorHelper(getDownloadCache(), buildHttpDataSourceFactory()); - downloadManager = - new DownloadManager( - this, downloadIndex, new DefaultDownloaderFactory(downloaderConstructorHelper)); - 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 CacheDataSourceFactory buildReadOnlyCacheDataSource( - DataSource.Factory upstreamFactory, Cache cache) { - return new CacheDataSourceFactory( - cache, - upstreamFactory, - new FileDataSource.Factory(), - /* cacheWriteDataSinkFactory= */ null, - CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, - /* eventListener= */ null); - } -} 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 71b1eda7bfb..c462c14c75c 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 DemoDownloadService() { 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 @@ protected PlatformScheduler getScheduler() { @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 TerminalStateNotificationHelper( } @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 00000000000..2d15dfcbb48 --- /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 2b79071393e..07f4dd2f6ee 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -15,26 +15,38 @@ */ 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.demo.Sample.UriSample; +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; @@ -54,7 +66,7 @@ public interface Listener { 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; @@ -63,9 +75,11 @@ public interface Listener { @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(); @@ -75,6 +89,7 @@ public DownloadTracker( } public void addListener(Listener listener) { + checkNotNull(listener); listeners.add(listener); } @@ -82,19 +97,20 @@ public void removeListener(Listener listener) { listeners.remove(listener); } - public boolean isDownloaded(Uri uri) { - Download download = downloads.get(uri); + public boolean isDownloaded(MediaItem mediaItem) { + Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).uri); 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; } public void toggleDownload( - FragmentManager fragmentManager, UriSample sample, RenderersFactory renderersFactory) { - Download download = downloads.get(sample.uri); + FragmentManager fragmentManager, MediaItem mediaItem, RenderersFactory renderersFactory) { + Download download = downloads.get(checkNotNull(mediaItem.playbackProperties).uri); if (download != null) { DownloadService.sendRemoveDownload( context, DemoDownloadService.class, download.request.id, /* foreground= */ false); @@ -105,8 +121,9 @@ public void toggleDownload( startDownloadDialogHelper = new StartDownloadDialogHelper( fragmentManager, - getDownloadHelper(sample.uri, sample.extension, renderersFactory), - sample); + DownloadHelper.forMediaItem( + context, mediaItem, renderersFactory, httpDataSourceFactory), + mediaItem); } } @@ -121,28 +138,13 @@ private void loadDownloads() { } } - private DownloadHelper getDownloadHelper( - Uri uri, String extension, RenderersFactory renderersFactory) { - int type = Util.inferContentType(uri, extension); - switch (type) { - case C.TYPE_DASH: - return DownloadHelper.forDash(context, uri, dataSourceFactory, renderersFactory); - case C.TYPE_SS: - return DownloadHelper.forSmoothStreaming(context, uri, dataSourceFactory, renderersFactory); - case C.TYPE_HLS: - return DownloadHelper.forHls(context, uri, dataSourceFactory, renderersFactory); - case C.TYPE_OTHER: - return DownloadHelper.forProgressive(context, 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(); @@ -166,16 +168,18 @@ private final class StartDownloadDialogHelper private final FragmentManager fragmentManager; private final DownloadHelper downloadHelper; - private final UriSample sample; + private final MediaItem mediaItem; private TrackSelectionDialog trackSelectionDialog; private MappedTrackInfo mappedTrackInfo; + private WidevineOfflineLicenseFetchTask widevineOfflineLicenseFetchTask; + @Nullable private byte[] keySetId; public StartDownloadDialogHelper( - FragmentManager fragmentManager, DownloadHelper downloadHelper, UriSample sample) { + FragmentManager fragmentManager, DownloadHelper downloadHelper, MediaItem mediaItem) { this.fragmentManager = fragmentManager; this.downloadHelper = downloadHelper; - this.sample = sample; + this.mediaItem = mediaItem; downloadHelper.prepare(this); } @@ -184,46 +188,57 @@ public void release() { 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. @@ -260,6 +275,83 @@ public void onDismiss(DialogInterface dialogInterface) { // Internal methods. + /** + * Returns the first {@link Format} with a non-null {@link Format#drmInitData} found in the + * content's tracks, or null if none is found. + */ + @Nullable + private Format getFirstFormatWithDrmInitData(DownloadHelper helper) { + for (int periodIndex = 0; periodIndex < helper.getPeriodCount(); periodIndex++) { + MappedTrackInfo mappedTrackInfo = helper.getMappedTrackInfo(periodIndex); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + for (int trackGroupIndex = 0; trackGroupIndex < trackGroups.length; trackGroupIndex++) { + TrackGroup trackGroup = trackGroups.get(trackGroupIndex); + for (int formatIndex = 0; formatIndex < trackGroup.length; formatIndex++) { + Format format = trackGroup.getFormat(formatIndex); + if (format.drmInitData != null) { + return format; + } + } + } + } + } + return null; + } + + private void onOfflineLicenseFetched(DownloadHelper helper, byte[] keySetId) { + this.keySetId = keySetId; + onDownloadPrepared(helper); + } + + 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()); } @@ -270,7 +362,62 @@ private void startDownload(DownloadRequest downloadRequest) { } private DownloadRequest buildDownloadRequest() { - return downloadHelper.getDownloadRequest(Util.getUtf8Bytes(sample.name)); + 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 new file mode 100644 index 00000000000..d2d962c568e --- /dev/null +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/IntentUtil.java @@ -0,0 +1,230 @@ +/* + * 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.demo; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; + +import android.content.Intent; +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.util.Assertions; +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.List; +import java.util.Map; + +/** Util to read from and populate an intent. */ +public class IntentUtil { + + // Actions. + + public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW"; + public static final String ACTION_VIEW_LIST = + "com.google.android.exoplayer.demo.action.VIEW_LIST"; + + // Activity extras. + + 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 MIME_TYPE_EXTRA = "mime_type"; + 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_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_CONTENT = "drm_session_for_clear_content"; + public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session"; + 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"; + + /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */ + 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)); + index++; + } + } else { + Uri uri = intent.getData(); + mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "")); + } + return mediaItems; + } + + /** Populates the intent with the given list of {@link MediaItem media items}. */ + public static void addToIntent(List mediaItems, Intent intent) { + Assertions.checkArgument(!mediaItems.isEmpty()); + if (mediaItems.size() == 1) { + 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(ACTION_VIEW_LIST); + for (int i = 0; i < mediaItems.size(); i++) { + MediaItem mediaItem = mediaItems.get(i); + MediaItem.PlaybackProperties playbackProperties = + checkNotNull(mediaItem.playbackProperties); + intent.putExtra(URI_EXTRA + ("_" + i), playbackProperties.uri.toString()); + addPlaybackPropertiesToIntent(playbackProperties, intent, /* extrasKeySuffix= */ "_" + i); + addClippingPropertiesToIntent( + mediaItem.clippingProperties, intent, /* extrasKeySuffix= */ "_" + i); + } + } + } + + private static MediaItem createMediaItemFromIntent( + Uri uri, Intent intent, String extrasKeySuffix) { + @Nullable String mimeType = intent.getStringExtra(MIME_TYPE_EXTRA + extrasKeySuffix); + MediaItem.Builder builder = + new MediaItem.Builder() + .setUri(uri) + .setMimeType(mimeType) + .setAdTagUri(intent.getStringExtra(AD_TAG_URI_EXTRA + 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(); + } + + private static List createSubtitlesFromIntent( + Intent intent, String extrasKeySuffix) { + if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) { + return Collections.emptyList(); + } + return Collections.singletonList( + new MediaItem.Subtitle( + Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)), + checkNotNull(intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix)), + intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix), + C.SELECTION_FLAG_DEFAULT)); + } + + private static MediaItem.Builder populateDrmPropertiesFromIntent( + MediaItem.Builder builder, Intent intent, String extrasKeySuffix) { + String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix; + @Nullable String drmSchemeExtra = intent.getStringExtra(schemeKey); + if (drmSchemeExtra == null) { + return builder; + } + Map headers = new HashMap<>(); + @Nullable + String[] keyRequestPropertiesArray = + intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix); + if (keyRequestPropertiesArray != null) { + for (int i = 0; i < keyRequestPropertiesArray.length; i += 2) { + headers.put(keyRequestPropertiesArray[i], keyRequestPropertiesArray[i + 1]); + } + } + builder + .setDrmUuid(Util.getDrmUuid(Util.castNonNull(drmSchemeExtra))) + .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 void addPlaybackPropertiesToIntent( + MediaItem.PlaybackProperties playbackProperties, Intent intent, String extrasKeySuffix) { + intent + .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, playbackProperties.mimeType) + .putExtra( + AD_TAG_URI_EXTRA + extrasKeySuffix, + playbackProperties.adTagUri != null ? playbackProperties.adTagUri.toString() : null); + if (playbackProperties.drmConfiguration != null) { + addDrmConfigurationToIntent(playbackProperties.drmConfiguration, intent, extrasKeySuffix); + } + if (!playbackProperties.subtitles.isEmpty()) { + checkState(playbackProperties.subtitles.size() == 1); + MediaItem.Subtitle subtitle = playbackProperties.subtitles.get(0); + intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, subtitle.uri.toString()); + intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, subtitle.mimeType); + intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, subtitle.language); + } + } + + private static void addDrmConfigurationToIntent( + MediaItem.DrmConfiguration drmConfiguration, Intent intent, String extrasKeySuffix) { + intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmConfiguration.uuid.toString()); + intent.putExtra( + 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; + for (Map.Entry entry : drmConfiguration.requestHeaders.entrySet()) { + drmKeyRequestProperties[index++] = entry.getKey(); + drmKeyRequestProperties[index++] = entry.getValue(); + } + intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties); + + 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); + } + } +} 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 f94123426e0..eae302887e0 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -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,33 +40,26 @@ import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; -import com.google.android.exoplayer2.demo.Sample.UriSample; +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.MediaSource; +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.source.ads.AdsMediaSource; -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.upstream.HttpDataSource; -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; @@ -75,46 +69,7 @@ /** An activity that plays media using {@link SimpleExoPlayer}. */ public class PlayerActivity extends AppCompatActivity - implements OnClickListener, PlaybackPreparer, PlayerControlView.VisibilityListener { - - // 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"; - - // Actions. - - public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW"; - public static final String ACTION_VIEW_LIST = - "com.google.android.exoplayer.demo.action.VIEW_LIST"; - - // Player configuration extras. - - public static final String ABR_ALGORITHM_EXTRA = "abr_algorithm"; - public static final String ABR_ALGORITHM_DEFAULT = "default"; - public static final String ABR_ALGORITHM_RANDOM = "random"; - - // Media item configuration extras. - - public static final String URI_EXTRA = "uri"; - public static final String EXTENSION_EXTRA = "extension"; - public static final String IS_LIVE_EXTRA = "is_live"; - - 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_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_MULTI_SESSION_EXTRA = "drm_multi_session"; - public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders"; - public static final String TUNNELING_EXTRA = "tunneling"; - public static final String AD_TAG_URI_EXTRA = "ad_tag_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"; + implements OnClickListener, PlaybackPreparer, StyledPlayerControlView.VisibilityListener { // Saved instance state keys. @@ -130,20 +85,19 @@ 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 mediaSources; + private List mediaItems; private DefaultTrackSelector trackSelector; private DefaultTrackSelector.Parameters trackSelectorParameters; private DebugTextViewHelper debugViewHelper; private TrackGroupArray lastSeenTrackGroupArray; - private DefaultMediaSourceFactory mediaSourceFactory; private boolean startAutoPlay; private int startWindow; private long startPosition; @@ -157,20 +111,13 @@ public class PlayerActivity extends AppCompatActivity @Override public void onCreate(Bundle savedInstanceState) { - Intent intent = getIntent(); - String sphericalStereoMode = intent.getStringExtra(SPHERICAL_STEREO_MODE_EXTRA); - if (sphericalStereoMode != null) { - setTheme(R.style.PlayerTheme_Spherical); - } super.onCreate(savedInstanceState); - dataSourceFactory = buildDataSourceFactory(); - mediaSourceFactory = - DefaultMediaSourceFactory.newInstance(/* context= */ this, dataSourceFactory); + 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); @@ -180,21 +127,6 @@ public void onCreate(Bundle savedInstanceState) { playerView.setControllerVisibilityListener(this); playerView.setErrorMessageProvider(new PlayerErrorMessageProvider()); playerView.requestFocus(); - if (sphericalStereoMode != null) { - int stereoMode; - if (SPHERICAL_STEREO_MODE_MONO.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_MONO; - } else if (SPHERICAL_STEREO_MODE_TOP_BOTTOM.equals(sphericalStereoMode)) { - stereoMode = C.STEREO_MODE_TOP_BOTTOM; - } else if (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); @@ -204,10 +136,6 @@ public void onCreate(Bundle savedInstanceState) { } else { DefaultTrackSelector.ParametersBuilder builder = new DefaultTrackSelector.ParametersBuilder(/* context= */ this); - boolean tunneling = intent.getBooleanExtra(TUNNELING_EXTRA, false); - if (Util.SDK_INT >= 21 && tunneling) { - builder.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(/* context= */ this)); - } trackSelectorParameters = builder.build(); clearStartPosition(); } @@ -324,14 +252,14 @@ public void onClick(View view) { } } - // PlaybackControlView.PlaybackPreparer implementation + // PlaybackPreparer implementation @Override public void preparePlayback() { - player.retry(); + player.prepare(); } - // PlaybackControlView.VisibilityListener implementation + // PlayerControlView.VisibilityListener implementation @Override public void onVisibilityChange(int visibility) { @@ -340,79 +268,71 @@ public void onVisibilityChange(int visibility) { // 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(); - mediaSources = createTopLevelMediaSources(intent); - if (mediaSources.isEmpty()) { - return; - } - TrackSelection.Factory trackSelectionFactory; - String abrAlgorithm = intent.getStringExtra(ABR_ALGORITHM_EXTRA); - if (abrAlgorithm == null || ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) { - trackSelectionFactory = new AdaptiveTrackSelection.Factory(); - } else if (ABR_ALGORITHM_RANDOM.equals(abrAlgorithm)) { - trackSelectionFactory = new RandomTrackSelection.Factory(); - } else { - showToast(R.string.error_unrecognized_abr_algorithm); - finish(); - return; + + mediaItems = createMediaItems(intent); + if (mediaItems.isEmpty()) { + return false; } boolean preferExtensionDecoders = - intent.getBooleanExtra(PREFER_EXTENSION_DECODERS_EXTRA, false); + 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(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); debugViewHelper.start(); - if (adsLoader != null) { - adsLoader.setPlayer(player); - } } boolean haveStartPosition = startWindow != C.INDEX_UNSET; if (haveStartPosition) { player.seekTo(startWindow, startPosition); } - player.setMediaSources(mediaSources, /* resetPosition= */ !haveStartPosition); + player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition); player.prepare(); updateButtonVisibility(); + return true; } - private List createTopLevelMediaSources(Intent intent) { + private List createMediaItems(Intent intent) { String action = intent.getAction(); - boolean actionIsListView = ACTION_VIEW_LIST.equals(action); - if (!actionIsListView && !ACTION_VIEW.equals(action)) { + boolean actionIsListView = IntentUtil.ACTION_VIEW_LIST.equals(action); + if (!actionIsListView && !IntentUtil.ACTION_VIEW.equals(action)) { showToast(getString(R.string.unexpected_intent_action, action)); finish(); return Collections.emptyList(); } - Sample intentAsSample = Sample.createFromIntent(intent); - UriSample[] samples = - intentAsSample instanceof Sample.PlaylistSample - ? ((Sample.PlaylistSample) intentAsSample).children - : new UriSample[] {(UriSample) intentAsSample}; - - List mediaSources = new ArrayList<>(); - Uri adTagUri = null; - for (UriSample sample : samples) { - MediaItem mediaItem = sample.toMediaItem(); - Assertions.checkNotNull(mediaItem.playbackProperties); + List mediaItems = + createMediaItems(intent, DemoUtil.getDownloadTracker(/* context= */ this)); + boolean hasAds = false; + for (int i = 0; i < mediaItems.size(); i++) { + MediaItem mediaItem = mediaItems.get(i); + if (!Util.checkCleartextTrafficPermitted(mediaItem)) { showToast(R.string.error_cleartext_not_permitted); return Collections.emptyList(); @@ -421,70 +341,47 @@ private List createTopLevelMediaSources(Intent intent) { // The player will be reinitialized if the permission is granted. return Collections.emptyList(); } - MediaSource mediaSource = createLeafMediaSource(mediaItem); - if (mediaSource != null) { - adTagUri = sample.adTagUri; - mediaSources.add(mediaSource); - } - } - if (adTagUri == null) { - releaseAdsLoader(); - } else if (mediaSources.size() == 1) { - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; - } - MediaSource adsMediaSource = createAdsMediaSource(mediaSources.get(0), adTagUri); - if (adsMediaSource != null) { - mediaSources.set(0, adsMediaSource); - } else { - showToast(R.string.ima_not_loaded); + MediaItem.DrmConfiguration 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 (!FrameworkMediaDrm.isCryptoSchemeSupported(drmConfiguration.uuid)) { + showToast(R.string.error_drm_unsupported_scheme); + finish(); + return Collections.emptyList(); + } } - } else if (mediaSources.size() > 1) { - showToast(R.string.unsupported_ads_in_concatenation); + hasAds |= mediaItem.playbackProperties.adTagUri != null; + } + if (!hasAds) { releaseAdsLoader(); } - - return mediaSources; + return mediaItems; } - @Nullable - private MediaSource createLeafMediaSource(MediaItem mediaItem) { - Assertions.checkNotNull(mediaItem.playbackProperties); - HttpDataSource.Factory drmDataSourceFactory = null; - if (mediaItem.playbackProperties.drmConfiguration != null) { - if (Util.SDK_INT < 18) { - showToast(R.string.error_drm_unsupported_before_api_18); - finish(); - return null; - } else if (!MediaDrm.isCryptoSchemeSupported( - mediaItem.playbackProperties.drmConfiguration.uuid)) { - showToast(R.string.error_drm_unsupported_scheme); - finish(); - return null; - } - drmDataSourceFactory = ((DemoApplication) getApplication()).buildHttpDataSourceFactory(); + private AdsLoader getAdsLoader(Uri adTagUri) { + if (mediaItems.size() > 1) { + showToast(R.string.unsupported_ads_in_playlist); + releaseAdsLoader(); + return null; } - - DownloadRequest downloadRequest = - ((DemoApplication) getApplication()) - .getDownloadTracker() - .getDownloadRequest(mediaItem.playbackProperties.sourceUri); - if (downloadRequest != null) { - mediaItem = - mediaItem - .buildUpon() - .setStreamKeys(downloadRequest.streamKeys) - .setCustomCacheKey(downloadRequest.customCacheKey) - .build(); + if (!adTagUri.equals(loadedAdTagUri)) { + releaseAdsLoader(); + loadedAdTagUri = adTagUri; } - return mediaSourceFactory - .setDrmHttpDataSourceFactory(drmDataSourceFactory) - .createMediaSource(mediaItem); + // 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; } - private void releasePlayer() { + protected void releasePlayer() { if (player != null) { updateTrackSelectorParameters(); updateStartPosition(); @@ -492,7 +389,7 @@ private void releasePlayer() { debugViewHelper = null; player.release(); player = null; - mediaSources = Collections.emptyList(); + mediaItems = Collections.emptyList(); trackSelector = null; } if (adsLoader != null) { @@ -523,43 +420,12 @@ private void updateStartPosition() { } } - 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 media source, reusing the ads loader if one exists. */ - @Nullable - private MediaSource createAdsMediaSource(MediaSource mediaSource, Uri adTagUri) { - // Load the extension source using reflection so the demo app doesn't have to depend on it. - // The ads loader is reused for multiple playbacks, so that ad playback can resume. - try { - Class loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader"); - if (adsLoader == null) { - // 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) - adsLoader = loaderConstructor.newInstance(this, adTagUri); - } - return new AdsMediaSource(mediaSource, mediaSourceFactory, adsLoader, playerView); - } catch (ClassNotFoundException e) { - // IMA extension not loaded. - return null; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - // User controls private void updateButtonVisibility() { @@ -670,4 +536,27 @@ public Pair getErrorMessage(@NonNull ExoPlaybackException e) { return Pair.create(0, errorString); } } + + 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 { + mediaItems.add(item); + } + } + return mediaItems; + } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java deleted file mode 100644 index 1225c8b6c46..00000000000 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.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.demo; - -import static com.google.android.exoplayer2.demo.PlayerActivity.ACTION_VIEW_LIST; -import static com.google.android.exoplayer2.demo.PlayerActivity.AD_TAG_URI_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_KEY_REQUEST_PROPERTIES_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_LICENSE_URL_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_MULTI_SESSION_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SCHEME_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SCHEME_UUID_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SESSION_FOR_CLEAR_TYPES_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.EXTENSION_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.IS_LIVE_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_LANGUAGE_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_MIME_TYPE_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_URI_EXTRA; -import static com.google.android.exoplayer2.demo.PlayerActivity.URI_EXTRA; - -import android.content.Intent; -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.util.Assertions; -import com.google.android.exoplayer2.util.MimeTypes; -import com.google.android.exoplayer2.util.Util; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.UUID; - -/* package */ abstract class Sample { - - /** - * Returns the mime type which is one of {@link MimeTypes#APPLICATION_MPD} for DASH, {@link - * MimeTypes#APPLICATION_M3U8} for HLS, {@link MimeTypes#APPLICATION_SS} for SmoothStreaming or - * {@code null} for all other streams. - * - * @param uri The uri of the stream. - * @param extension The extension - * @return The adaptive mime type or {@code null} for non-adaptive streams. - */ - @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; - } - } - - public static final class UriSample extends Sample { - - public static UriSample createFromIntent(Uri uri, Intent intent, String extrasKeySuffix) { - String extension = intent.getStringExtra(EXTENSION_EXTRA + extrasKeySuffix); - String adsTagUriString = intent.getStringExtra(AD_TAG_URI_EXTRA + extrasKeySuffix); - boolean isLive = - intent.getBooleanExtra(IS_LIVE_EXTRA + extrasKeySuffix, /* defaultValue= */ false); - Uri adTagUri = adsTagUriString != null ? Uri.parse(adsTagUriString) : null; - return new UriSample( - /* name= */ null, - uri, - extension, - isLive, - DrmInfo.createFromIntent(intent, extrasKeySuffix), - adTagUri, - /* sphericalStereoMode= */ null, - SubtitleInfo.createFromIntent(intent, extrasKeySuffix)); - } - - public final Uri uri; - public final String extension; - public final boolean isLive; - public final DrmInfo drmInfo; - public final Uri adTagUri; - @Nullable public final String sphericalStereoMode; - @Nullable SubtitleInfo subtitleInfo; - - public UriSample( - String name, - Uri uri, - String extension, - boolean isLive, - DrmInfo drmInfo, - Uri adTagUri, - @Nullable String sphericalStereoMode, - @Nullable SubtitleInfo subtitleInfo) { - super(name); - this.uri = uri; - this.extension = extension; - this.isLive = isLive; - this.drmInfo = drmInfo; - this.adTagUri = adTagUri; - this.sphericalStereoMode = sphericalStereoMode; - this.subtitleInfo = subtitleInfo; - } - - @Override - public void addToIntent(Intent intent) { - intent.setAction(PlayerActivity.ACTION_VIEW).setData(uri); - intent.putExtra(PlayerActivity.IS_LIVE_EXTRA, isLive); - intent.putExtra(PlayerActivity.SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode); - addPlayerConfigToIntent(intent, /* extrasKeySuffix= */ ""); - } - - public void addToPlaylistIntent(Intent intent, String extrasKeySuffix) { - intent.putExtra(PlayerActivity.URI_EXTRA + extrasKeySuffix, uri.toString()); - intent.putExtra(PlayerActivity.IS_LIVE_EXTRA + extrasKeySuffix, isLive); - addPlayerConfigToIntent(intent, extrasKeySuffix); - } - - private void addPlayerConfigToIntent(Intent intent, String extrasKeySuffix) { - intent - .putExtra(EXTENSION_EXTRA + extrasKeySuffix, extension) - .putExtra( - AD_TAG_URI_EXTRA + extrasKeySuffix, adTagUri != null ? adTagUri.toString() : null); - if (drmInfo != null) { - drmInfo.addToIntent(intent, extrasKeySuffix); - } - if (subtitleInfo != null) { - subtitleInfo.addToIntent(intent, extrasKeySuffix); - } - } - - public MediaItem toMediaItem() { - MediaItem.Builder builder = new MediaItem.Builder().setSourceUri(uri); - builder.setMimeType(inferAdaptiveStreamMimeType(uri, extension)); - if (drmInfo != null) { - Map headers = new HashMap<>(); - if (drmInfo.drmKeyRequestProperties != null) { - for (int i = 0; i < drmInfo.drmKeyRequestProperties.length; i += 2) { - headers.put(drmInfo.drmKeyRequestProperties[i], drmInfo.drmKeyRequestProperties[i + 1]); - } - } - builder - .setDrmLicenseUri(drmInfo.drmLicenseUrl) - .setDrmLicenseRequestHeaders(headers) - .setDrmUuid(drmInfo.drmScheme) - .setDrmMultiSession(drmInfo.drmMultiSession) - .setDrmSessionForClearTypes(Util.toList(drmInfo.drmSessionForClearTypes)); - } - if (subtitleInfo != null) { - builder.setSubtitles( - Collections.singletonList( - new MediaItem.Subtitle( - subtitleInfo.uri, - subtitleInfo.mimeType, - subtitleInfo.language, - C.SELECTION_FLAG_DEFAULT))); - } - return builder.build(); - } - } - - public static final class PlaylistSample extends Sample { - - public final UriSample[] children; - - public PlaylistSample(String name, UriSample... children) { - super(name); - this.children = children; - } - - @Override - public void addToIntent(Intent intent) { - intent.setAction(PlayerActivity.ACTION_VIEW_LIST); - for (int i = 0; i < children.length; i++) { - children[i].addToPlaylistIntent(intent, /* extrasKeySuffix= */ "_" + i); - } - } - } - - public static final class DrmInfo { - - public static DrmInfo createFromIntent(Intent intent, String extrasKeySuffix) { - String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix; - String schemeUuidKey = DRM_SCHEME_UUID_EXTRA + extrasKeySuffix; - if (!intent.hasExtra(schemeKey) && !intent.hasExtra(schemeUuidKey)) { - return null; - } - String drmSchemeExtra = - intent.hasExtra(schemeKey) - ? intent.getStringExtra(schemeKey) - : intent.getStringExtra(schemeUuidKey); - UUID drmScheme = Util.getDrmUuid(drmSchemeExtra); - String drmLicenseUrl = intent.getStringExtra(DRM_LICENSE_URL_EXTRA + extrasKeySuffix); - String[] keyRequestPropertiesArray = - intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix); - String[] drmSessionForClearTypesExtra = - intent.getStringArrayExtra(DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix); - int[] drmSessionForClearTypes = toTrackTypeArray(drmSessionForClearTypesExtra); - boolean drmMultiSession = - intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false); - return new DrmInfo( - drmScheme, - drmLicenseUrl, - keyRequestPropertiesArray, - drmSessionForClearTypes, - drmMultiSession); - } - - public final UUID drmScheme; - public final String drmLicenseUrl; - public final String[] drmKeyRequestProperties; - public final int[] drmSessionForClearTypes; - public final boolean drmMultiSession; - - public DrmInfo( - UUID drmScheme, - String drmLicenseUrl, - String[] drmKeyRequestProperties, - int[] drmSessionForClearTypes, - boolean drmMultiSession) { - this.drmScheme = drmScheme; - this.drmLicenseUrl = drmLicenseUrl; - this.drmKeyRequestProperties = drmKeyRequestProperties; - this.drmSessionForClearTypes = drmSessionForClearTypes; - this.drmMultiSession = drmMultiSession; - } - - public void addToIntent(Intent intent, String extrasKeySuffix) { - Assertions.checkNotNull(intent); - intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmScheme.toString()); - intent.putExtra(DRM_LICENSE_URL_EXTRA + extrasKeySuffix, drmLicenseUrl); - intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties); - ArrayList typeStrings = new ArrayList<>(); - for (int type : drmSessionForClearTypes) { - // Only audio and video are supported. - typeStrings.add(type == C.TRACK_TYPE_AUDIO ? "audio" : "video"); - } - intent.putExtra( - DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix, typeStrings.toArray(new String[0])); - intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmMultiSession); - } - } - - public static final class SubtitleInfo { - - @Nullable - public static SubtitleInfo createFromIntent(Intent intent, String extrasKeySuffix) { - if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) { - return null; - } - return new SubtitleInfo( - Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)), - intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix), - intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix)); - } - - public final Uri uri; - public final String mimeType; - @Nullable public final String language; - - public SubtitleInfo(Uri uri, String mimeType, @Nullable String language) { - this.uri = Assertions.checkNotNull(uri); - this.mimeType = Assertions.checkNotNull(mimeType); - this.language = language; - } - - public void addToIntent(Intent intent, String extrasKeySuffix) { - intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, uri.toString()); - intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, mimeType); - intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, language); - } - } - - public static int[] toTrackTypeArray(@Nullable String[] trackTypeStringsArray) { - if (trackTypeStringsArray == null) { - return new int[0]; - } - 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 Util.toArray(new ArrayList<>(trackTypes)); - } - - public static Sample createFromIntent(Intent intent) { - if (ACTION_VIEW_LIST.equals(intent.getAction())) { - ArrayList intentUris = new ArrayList<>(); - int index = 0; - while (intent.hasExtra(URI_EXTRA + "_" + index)) { - intentUris.add(intent.getStringExtra(URI_EXTRA + "_" + index)); - index++; - } - UriSample[] children = new UriSample[intentUris.size()]; - for (int i = 0; i < children.length; i++) { - Uri uri = Uri.parse(intentUris.get(i)); - children[i] = UriSample.createFromIntent(uri, intent, /* extrasKeySuffix= */ "_" + i); - } - return new PlaylistSample(/* name= */ null, children); - } else { - return UriSample.createFromIntent(intent.getData(), intent, /* extrasKeySuffix= */ ""); - } - } - - public final String name; - - public Sample(String name) { - this.name = name; - } - - public abstract void addToIntent(Intent intent); -} diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java index 740f016fcb6..ea5b38ce8e8 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java @@ -15,10 +15,15 @@ */ 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; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.content.res.AssetManager; import android.net.Uri; import android.os.AsyncTask; @@ -39,42 +44,41 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.MediaMetadata; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.RenderersFactory; -import com.google.android.exoplayer2.demo.Sample.DrmInfo; -import com.google.android.exoplayer2.demo.Sample.PlaylistSample; -import com.google.android.exoplayer2.demo.Sample.UriSample; 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; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** An activity for selecting from a list of media samples. */ 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 @@ -109,9 +113,8 @@ public void onCreate(Bundle savedInstanceState) { 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. @@ -131,11 +134,6 @@ public boolean onCreateOptionsMenu(Menu menu) { 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; } @@ -182,7 +180,7 @@ public void onRequestPermissionsResult( } private void loadSample() { - Assertions.checkNotNull(uris); + checkNotNull(uris); for (int i = 0; i < uris.length; i++) { Uri uri = Uri.parse(uris[i]); @@ -195,24 +193,21 @@ private void loadSample() { loaderTask.execute(uris); } - private void onSampleGroups(final List groups, boolean sawError) { + private void onPlaylistGroups(final List groups, boolean sawError) { if (sawError) { Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) .show(); } - sampleAdapter.setSampleGroups(groups); + 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); } @@ -227,51 +222,40 @@ public boolean onChildClick( prefEditor.putInt(CHILD_POSITION_PREFERENCE_KEY, childPosition); prefEditor.apply(); - Sample sample = (Sample) view.getTag(); + PlaylistHolder playlistHolder = (PlaylistHolder) view.getTag(); Intent intent = new Intent(this, PlayerActivity.class); intent.putExtra( - PlayerActivity.PREFER_EXTENSION_DECODERS_EXTRA, + IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, isNonNullAndChecked(preferExtensionDecodersMenuItem)); - String abrAlgorithm = - isNonNullAndChecked(randomAbrMenuItem) - ? PlayerActivity.ABR_ALGORITHM_RANDOM - : PlayerActivity.ABR_ALGORITHM_DEFAULT; - intent.putExtra(PlayerActivity.ABR_ALGORITHM_EXTRA, abrAlgorithm); - intent.putExtra(PlayerActivity.TUNNELING_EXTRA, isNonNullAndChecked(tunnelingMenuItem)); - sample.addToIntent(intent); + IntentUtil.addToIntent(playlistHolder.mediaItems, intent); startActivity(intent); return true; } - private void onSampleDownloadButtonClicked(Sample sample) { - int downloadUnsupportedStringId = getDownloadUnsupportedStringId(sample); + private void onSampleDownloadButtonClicked(PlaylistHolder playlistHolder) { + int downloadUnsupportedStringId = getDownloadUnsupportedStringId(playlistHolder); if (downloadUnsupportedStringId != 0) { Toast.makeText(getApplicationContext(), downloadUnsupportedStringId, Toast.LENGTH_LONG) .show(); } else { RenderersFactory renderersFactory = - ((DemoApplication) getApplication()) - .buildRenderersFactory(isNonNullAndChecked(preferExtensionDecodersMenuItem)); + DemoUtil.buildRenderersFactory( + /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem)); downloadTracker.toggleDownload( - getSupportFragmentManager(), (UriSample) sample, renderersFactory); + getSupportFragmentManager(), playlistHolder.mediaItems.get(0), renderersFactory); } } - private int getDownloadUnsupportedStringId(Sample sample) { - if (sample instanceof PlaylistSample) { + private int getDownloadUnsupportedStringId(PlaylistHolder playlistHolder) { + if (playlistHolder.mediaItems.size() > 1) { return R.string.download_playlist_unsupported; } - UriSample uriSample = (UriSample) sample; - if (uriSample.drmInfo != null) { - return R.string.download_drm_unsupported; - } - if (uriSample.isLive) { - return R.string.download_live_unsupported; - } - if (uriSample.adTagUri != null) { + MediaItem.PlaybackProperties playbackProperties = + checkNotNull(playlistHolder.mediaItems.get(0).playbackProperties); + if (playbackProperties.adTagUri != null) { return R.string.download_ads_unsupported; } - String scheme = uriSample.uri.getScheme(); + String scheme = playbackProperties.uri.getScheme(); if (!("http".equals(scheme) || "https".equals(scheme))) { return R.string.download_scheme_unsupported; } @@ -283,22 +267,20 @@ private static boolean isNonNullAndChecked(@Nullable MenuItem menuItem) { return menuItem != null && menuItem.isChecked(); } - private final class SampleListLoader extends AsyncTask> { + private final class SampleListLoader extends AsyncTask> { private boolean sawError; @Override - protected List doInBackground(String... uris) { - List result = new ArrayList<>(); + 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); try { - readSampleGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result); + readPlaylistGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result); } catch (Exception e) { Log.e(TAG, "Error loading sample list: " + uri, e); sawError = true; @@ -310,21 +292,23 @@ protected List doInBackground(String... uris) { } @Override - protected void onPostExecute(List result) { - onSampleGroups(result, sawError); + protected void onPostExecute(List result) { + onPlaylistGroups(result, sawError); } - private void readSampleGroups(JsonReader reader, List groups) throws IOException { + private void readPlaylistGroups(JsonReader reader, List groups) + throws IOException { reader.beginArray(); while (reader.hasNext()) { - readSampleGroup(reader, groups); + readPlaylistGroup(reader, groups); } reader.endArray(); } - private void readSampleGroup(JsonReader reader, List groups) throws IOException { + private void readPlaylistGroup(JsonReader reader, List groups) + throws IOException { String groupName = ""; - ArrayList samples = new ArrayList<>(); + ArrayList playlistHolders = new ArrayList<>(); reader.beginObject(); while (reader.hasNext()) { @@ -336,7 +320,7 @@ private void readSampleGroup(JsonReader reader, List groups) throws case "samples": reader.beginArray(); while (reader.hasNext()) { - samples.add(readEntry(reader, false)); + playlistHolders.add(readEntry(reader, false)); } reader.endArray(); break; @@ -349,34 +333,26 @@ private void readSampleGroup(JsonReader reader, List groups) throws } reader.endObject(); - SampleGroup group = getGroup(groupName, groups); - group.samples.addAll(samples); + PlaylistGroup group = getGroup(groupName, groups); + group.playlists.addAll(playlistHolders); } - private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOException { - String sampleName = null; + private PlaylistHolder readEntry(JsonReader reader, boolean insidePlaylist) throws IOException { Uri uri = null; String extension = null; - boolean isLive = false; - String drmScheme = null; - String drmLicenseUrl = null; - String[] drmKeyRequestProperties = null; - String[] drmSessionForClearTypes = null; - boolean drmMultiSession = false; - ArrayList playlistSamples = null; - String adTagUri = null; - String sphericalStereoMode = null; - List subtitleInfos = new ArrayList<>(); + String title = null; + ArrayList children = null; Uri subtitleUri = null; String subtitleMimeType = null; String subtitleLanguage = null; + MediaItem.Builder mediaItem = new MediaItem.Builder(); reader.beginObject(); while (reader.hasNext()) { String name = reader.nextName(); switch (name) { case "name": - sampleName = reader.nextString(); + title = reader.nextString(); break; case "uri": uri = Uri.parse(reader.nextString()); @@ -384,53 +360,42 @@ private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOExc case "extension": extension = reader.nextString(); break; - case "drm_scheme": - drmScheme = reader.nextString(); + case "clip_start_position_ms": + mediaItem.setClipStartPositionMs(reader.nextLong()); break; - case "is_live": - isLive = reader.nextBoolean(); + case "clip_end_position_ms": + mediaItem.setClipEndPositionMs(reader.nextLong()); break; - case "drm_license_url": - drmLicenseUrl = reader.nextString(); + case "ad_tag_uri": + mediaItem.setAdTagUri(reader.nextString()); + break; + case "drm_scheme": + mediaItem.setDrmUuid(Util.getDrmUuid(reader.nextString())); + break; + case "drm_license_uri": + case "drm_license_url": // For backward compatibility only. + mediaItem.setDrmLicenseUri(reader.nextString()); break; case "drm_key_request_properties": - ArrayList drmKeyRequestPropertiesList = new ArrayList<>(); + Map requestHeaders = new HashMap<>(); reader.beginObject(); while (reader.hasNext()) { - drmKeyRequestPropertiesList.add(reader.nextName()); - drmKeyRequestPropertiesList.add(reader.nextString()); + requestHeaders.put(reader.nextName(), reader.nextString()); } reader.endObject(); - drmKeyRequestProperties = drmKeyRequestPropertiesList.toArray(new String[0]); + mediaItem.setDrmLicenseRequestHeaders(requestHeaders); break; - case "drm_session_for_clear_types": - ArrayList drmSessionForClearTypesList = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - drmSessionForClearTypesList.add(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(); - drmSessionForClearTypes = drmSessionForClearTypesList.toArray(new String[0]); break; case "drm_multi_session": - drmMultiSession = reader.nextBoolean(); + mediaItem.setDrmMultiSession(reader.nextBoolean()); break; - case "playlist": - Assertions.checkState(!insidePlaylist, "Invalid nesting of playlists"); - playlistSamples = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - playlistSamples.add((UriSample) readEntry(reader, /* insidePlaylist= */ true)); - } - reader.endArray(); - break; - case "ad_tag_uri": - adTagUri = 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()); @@ -441,51 +406,55 @@ private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOExc 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); } } reader.endObject(); - DrmInfo drmInfo = - drmScheme == null - ? null - : new DrmInfo( - Util.getDrmUuid(drmScheme), - drmLicenseUrl, - drmKeyRequestProperties, - Sample.toTrackTypeArray(drmSessionForClearTypes), - drmMultiSession); - Sample.SubtitleInfo subtitleInfo = - subtitleUri == null - ? null - : new Sample.SubtitleInfo( + + if (children != null) { + List mediaItems = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + mediaItems.addAll(children.get(i).mediaItems); + } + 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(adaptiveMimeType); + if (subtitleUri != null) { + MediaItem.Subtitle subtitle = + new MediaItem.Subtitle( subtitleUri, - Assertions.checkNotNull( + checkNotNull( subtitleMimeType, "subtitle_mime_type is required if subtitle_uri is set."), subtitleLanguage); - if (playlistSamples != null) { - UriSample[] playlistSamplesArray = playlistSamples.toArray(new UriSample[0]); - return new PlaylistSample(sampleName, playlistSamplesArray); - } else { - return new UriSample( - sampleName, - uri, - extension, - isLive, - drmInfo, - adTagUri != null ? Uri.parse(adTagUri) : null, - sphericalStereoMode, - subtitleInfo); + mediaItem.setSubtitles(Collections.singletonList(subtitle)); + } + return new PlaylistHolder(title, Collections.singletonList(mediaItem.build())); } } - private SampleGroup getGroup(String groupName, List groups) { + private PlaylistGroup getGroup(String groupName, List groups) { for (int i = 0; i < groups.size(); i++) { if (Util.areEqual(groupName, groups.get(i).title)) { return groups.get(i); } } - SampleGroup group = new SampleGroup(groupName); + PlaylistGroup group = new PlaylistGroup(groupName); groups.add(group); return group; } @@ -493,20 +462,20 @@ private SampleGroup getGroup(String groupName, List groups) { private final class SampleAdapter extends BaseExpandableListAdapter implements OnClickListener { - private List sampleGroups; + private List playlistGroups; public SampleAdapter() { - sampleGroups = Collections.emptyList(); + playlistGroups = Collections.emptyList(); } - public void setSampleGroups(List sampleGroups) { - this.sampleGroups = sampleGroups; + public void setPlaylistGroups(List playlistGroups) { + this.playlistGroups = playlistGroups; notifyDataSetChanged(); } @Override - public Sample getChild(int groupPosition, int childPosition) { - return getGroup(groupPosition).samples.get(childPosition); + public PlaylistHolder getChild(int groupPosition, int childPosition) { + return getGroup(groupPosition).playlists.get(childPosition); } @Override @@ -534,12 +503,12 @@ public View getChildView( @Override public int getChildrenCount(int groupPosition) { - return getGroup(groupPosition).samples.size(); + return getGroup(groupPosition).playlists.size(); } @Override - public SampleGroup getGroup(int groupPosition) { - return sampleGroups.get(groupPosition); + public PlaylistGroup getGroup(int groupPosition) { + return playlistGroups.get(groupPosition); } @Override @@ -562,7 +531,7 @@ public View getGroupView( @Override public int getGroupCount() { - return sampleGroups.size(); + return playlistGroups.size(); } @Override @@ -577,18 +546,19 @@ public boolean isChildSelectable(int groupPosition, int childPosition) { @Override public void onClick(View view) { - onSampleDownloadButtonClicked((Sample) view.getTag()); + onSampleDownloadButtonClicked((PlaylistHolder) view.getTag()); } - private void initializeChildView(View view, Sample sample) { - view.setTag(sample); + private void initializeChildView(View view, PlaylistHolder playlistHolder) { + view.setTag(playlistHolder); TextView sampleTitle = view.findViewById(R.id.sample_title); - sampleTitle.setText(sample.name); + sampleTitle.setText(playlistHolder.title); - boolean canDownload = getDownloadUnsupportedStringId(sample) == 0; - boolean isDownloaded = canDownload && downloadTracker.isDownloaded(((UriSample) sample).uri); + boolean canDownload = getDownloadUnsupportedStringId(playlistHolder) == 0; + boolean isDownloaded = + canDownload && downloadTracker.isDownloaded(playlistHolder.mediaItems.get(0)); ImageButton downloadButton = view.findViewById(R.id.download_button); - downloadButton.setTag(sample); + downloadButton.setTag(playlistHolder); downloadButton.setColorFilter( canDownload ? (isDownloaded ? 0xFF42A5F5 : 0xFFBDBDBD) : 0xFF666666); downloadButton.setImageResource( @@ -596,14 +566,26 @@ private void initializeChildView(View view, Sample sample) { } } - private static final class SampleGroup { + private static final class PlaylistHolder { + + public final String title; + public final List mediaItems; + + private PlaylistHolder(String title, List mediaItems) { + checkArgument(!mediaItems.isEmpty()); + this.title = title; + this.mediaItems = Collections.unmodifiableList(new ArrayList<>(mediaItems)); + } + } + + private static final class PlaylistGroup { public final String title; - public final List samples; + public final List playlists; - public SampleGroup(String title) { + public PlaylistGroup(String title) { this.title = title; - this.samples = new ArrayList<>(); + this.playlists = new ArrayList<>(); } } } 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 b1db44110dd..d3f9b3880da 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 @@ private static String getTrackTypeString(Resources resources, int trackType) { private final class FragmentAdapter extends FragmentPagerAdapter { public FragmentAdapter(FragmentManager fragmentManager) { - super(fragmentManager); + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); } @Override @@ -354,7 +354,12 @@ public View onCreateView( trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides); trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); trackSelectionView.init( - mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this); + mappedTrackInfo, + rendererIndex, + isDisabled, + overrides, + /* trackFormatComparator= */ null, + /* listener= */ this); return rootView; } diff --git a/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 00000000000..cc22be27e0b --- /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 ea3de257e28..5b897fa7eae 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 671303a5225..bd5cd634679 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 a2ebde37bd8..3a8740d80af 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 312259dbf68..3febb23feb3 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 67419edf3b0..eb669ecf946 100644 --- a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java +++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java @@ -28,6 +28,7 @@ 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 @@ private void initializePlayer() { ACTION_VIEW.equals(action) ? Assertions.checkNotNull(intent.getData()) : Uri.parse(DEFAULT_MEDIA_URI); - String userAgent = Util.getUserAgent(this, getString(R.string.application_name)); DrmSessionManager drmSessionManager; if (intent.hasExtra(DRM_SCHEME_EXTRA)) { String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA)); String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA)); UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme)); - HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); + HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(); HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory); drmSessionManager = @@ -200,21 +200,19 @@ private void initializePlayer() { drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); } - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - this, Util.getUserAgent(this, getString(R.string.application_name))); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(this); MediaSource mediaSource; @C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA)); if (type == C.TYPE_DASH) { 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 00000000000..0f632a6e3c7 --- /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 54e27a3b873..8e11a5e2e71 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. - -* 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. +* 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 `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 d61a3a97f83..95a953d1453 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 8837d0ed27c..ad8c8a682cc 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java @@ -15,9 +15,12 @@ */ package com.google.android.exoplayer2.ext.av1; +import static java.lang.Runtime.getRuntime; + import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +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; @@ -44,7 +47,9 @@ * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. * @param initialInputBufferSize The initial size of each input buffer, in bytes. - * @param threads Number of threads libgav1 will use to decode. + * @param threads Number of threads libgav1 will use to decode. If {@link + * Libgav1VideoRenderer#THREAD_COUNT_AUTODETECT} is passed, then this class will auto detect + * the number of threads to be used. * @throws Gav1DecoderException Thrown if an exception occurs when initializing the decoder. */ public Gav1Decoder( @@ -56,6 +61,16 @@ public Gav1Decoder( if (!Gav1Library.isAvailable()) { throw new Gav1DecoderException("Failed to load decoder native library."); } + + if (threads == Libgav1VideoRenderer.THREAD_COUNT_AUTODETECT) { + // Try to get the optimal number of threads from the AV1 heuristic. + threads = gav1GetThreads(); + if (threads <= 0) { + // If that is not available, default to the number of available processors. + threads = getRuntime().availableProcessors(); + } + } + gav1DecoderContext = gav1Init(threads); if (gav1DecoderContext == GAV1_ERROR || gav1CheckError(gav1DecoderContext) == GAV1_ERROR) { throw new Gav1DecoderException( @@ -69,18 +84,9 @@ public String getName() { 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 @@ -114,7 +120,7 @@ protected Gav1DecoderException decode( outputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); } if (!decodeOnly) { - outputBuffer.colorInfo = inputBuffer.colorInfo; + outputBuffer.format = inputBuffer.format; } return null; @@ -141,6 +147,15 @@ protected void releaseOutputBuffer(VideoDecoderOutputBuffer buffer) { 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. @@ -231,4 +246,11 @@ private native int gav1RenderFrame( * @return {@link #GAV1_OK} if there was no error, {@link #GAV1_ERROR} if an error occurred. */ private native int gav1CheckError(long context); + + /** + * Returns the optimal number of threads to be used for AV1 decoding. + * + * @return Optimal number of threads if there was no error, 0 if an error occurred. + */ + private native int gav1GetThreads(); } diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java index 0a3733883ae..7c558d24b2d 100644 --- a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java +++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer2.ext.av1; -import static java.lang.Runtime.getRuntime; - import android.os.Handler; import android.view.Surface; import androidx.annotation.Nullable; @@ -34,6 +32,13 @@ /** Decodes and renders video using libgav1 decoder. */ public class Libgav1VideoRenderer extends DecoderVideoRenderer { + /** + * Attempts to use as many threads as performance processors available on the device. If the + * number of performance processors cannot be detected, the number of available processors is + * used. + */ + public static final int THREAD_COUNT_AUTODETECT = 0; + private static final String TAG = "Libgav1VideoRenderer"; private static final int DEFAULT_NUM_OF_INPUT_BUFFERS = 4; private static final int DEFAULT_NUM_OF_OUTPUT_BUFFERS = 4; @@ -74,7 +79,7 @@ public Libgav1VideoRenderer( eventHandler, eventListener, maxDroppedFramesToNotify, - /* threads= */ getRuntime().availableProcessors(), + THREAD_COUNT_AUTODETECT, DEFAULT_NUM_OF_INPUT_BUFFERS, DEFAULT_NUM_OF_OUTPUT_BUFFERS); } @@ -89,7 +94,9 @@ public Libgav1VideoRenderer( * @param eventListener A listener of events. May be null if delivery of events is not required. * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. - * @param threads Number of threads libgav1 will use to decode. + * @param threads Number of threads libgav1 will use to decode. If {@link + * #THREAD_COUNT_AUTODETECT} is passed, then the number of threads to use is autodetected + * based on CPU capabilities. * @param numInputBuffers Number of input buffers. * @param numOutputBuffers Number of output buffers. */ @@ -119,7 +126,7 @@ public final int supportsFormat(Format format) { || !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); @@ -155,4 +162,9 @@ protected void setDecoderOutputMode(@C.VideoOutputMode int outputMode) { decoder.setOutputMode(outputMode); } } + + @Override + protected boolean canKeepCodec(Format oldFormat, Format newFormat) { + return true; + } } diff --git a/extensions/av1/src/main/jni/CMakeLists.txt b/extensions/av1/src/main/jni/CMakeLists.txt index c7989d4ef25..fe0e8edaeb3 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,30 +18,21 @@ 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. add_library(gav1JNI SHARED - gav1_jni.cc) + gav1_jni.cc + cpu_info.cc + cpu_info.h) # Locate NDK log library. find_library(android_log_lib log) @@ -56,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/av1/src/main/jni/cpu_info.cc b/extensions/av1/src/main/jni/cpu_info.cc new file mode 100644 index 00000000000..8f4a405f4fa --- /dev/null +++ b/extensions/av1/src/main/jni/cpu_info.cc @@ -0,0 +1,153 @@ +#include "cpu_info.h" // NOLINT + +#include + +#include +#include +#include +#include +#include + +namespace gav1_jni { +namespace { + +// Note: The code in this file needs to use the 'long' type because it is the +// return type of the Standard C Library function strtol(). The linter warnings +// are suppressed with NOLINT comments since they are integers at runtime. + +// Returns the number of online processor cores. +int GetNumberOfProcessorsOnline() { + // See https://developer.android.com/ndk/guides/cpu-features. + long num_cpus = sysconf(_SC_NPROCESSORS_ONLN); // NOLINT + if (num_cpus < 0) { + return 0; + } + // It is safe to cast num_cpus to int. sysconf(_SC_NPROCESSORS_ONLN) returns + // the return value of get_nprocs(), which is an int. + return static_cast(num_cpus); +} + +} // namespace + +// These CPUs support heterogeneous multiprocessing. +#if defined(__arm__) || defined(__aarch64__) + +// A helper function used by GetNumberOfPerformanceCoresOnline(). +// +// Returns the cpuinfo_max_freq value (in kHz) of the given CPU. Returns 0 on +// failure. +long GetCpuinfoMaxFreq(int cpu_index) { // NOLINT + char buffer[128]; + const int rv = snprintf( + buffer, sizeof(buffer), + "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", cpu_index); + if (rv < 0 || rv >= sizeof(buffer)) { + return 0; + } + FILE* file = fopen(buffer, "r"); + if (file == nullptr) { + return 0; + } + char* const str = fgets(buffer, sizeof(buffer), file); + fclose(file); + if (str == nullptr) { + return 0; + } + const long freq = strtol(str, nullptr, 10); // NOLINT + if (freq <= 0 || freq == LONG_MAX) { + return 0; + } + return freq; +} + +// Returns the number of performance CPU cores that are online. The number of +// efficiency CPU cores is subtracted from the total number of CPU cores. Uses +// cpuinfo_max_freq to determine whether a CPU is a performance core or an +// efficiency core. +// +// This function is not perfect. For example, the Snapdragon 632 SoC used in +// Motorola Moto G7 has performance and efficiency cores with the same +// cpuinfo_max_freq but different cpuinfo_min_freq. This function fails to +// differentiate the two kinds of cores and reports all the cores as +// performance cores. +int GetNumberOfPerformanceCoresOnline() { + // Get the online CPU list. Some examples of the online CPU list are: + // "0-7" + // "0" + // "0-1,2,3,4-7" + FILE* file = fopen("/sys/devices/system/cpu/online", "r"); + if (file == nullptr) { + return 0; + } + char online[512]; + char* const str = fgets(online, sizeof(online), file); + fclose(file); + file = nullptr; + if (str == nullptr) { + return 0; + } + + // Count the number of the slowest CPUs. Some SoCs such as Snapdragon 855 + // have performance cores with different max frequencies, so only the slowest + // CPUs are efficiency cores. If we count the number of the fastest CPUs, we + // will fail to count the second fastest performance cores. + long slowest_cpu_freq = LONG_MAX; // NOLINT + int num_slowest_cpus = 0; + int num_cpus = 0; + const char* cp = online; + int range_begin = -1; + while (true) { + char* str_end; + const int cpu = static_cast(strtol(cp, &str_end, 10)); // NOLINT + if (str_end == cp) { + break; + } + cp = str_end; + if (*cp == '-') { + range_begin = cpu; + } else { + if (range_begin == -1) { + range_begin = cpu; + } + + num_cpus += cpu - range_begin + 1; + for (int i = range_begin; i <= cpu; ++i) { + const long freq = GetCpuinfoMaxFreq(i); // NOLINT + if (freq <= 0) { + return 0; + } + if (freq < slowest_cpu_freq) { + slowest_cpu_freq = freq; + num_slowest_cpus = 0; + } + if (freq == slowest_cpu_freq) { + ++num_slowest_cpus; + } + } + + range_begin = -1; + } + if (*cp == '\0') { + break; + } + ++cp; + } + + // If there are faster CPU cores than the slowest CPU cores, exclude the + // slowest CPU cores. + if (num_slowest_cpus < num_cpus) { + num_cpus -= num_slowest_cpus; + } + return num_cpus; +} + +#else + +// Assume symmetric multiprocessing. +int GetNumberOfPerformanceCoresOnline() { + return GetNumberOfProcessorsOnline(); +} + +#endif + +} // namespace gav1_jni diff --git a/extensions/av1/src/main/jni/cpu_info.h b/extensions/av1/src/main/jni/cpu_info.h new file mode 100644 index 00000000000..77f869a93e9 --- /dev/null +++ b/extensions/av1/src/main/jni/cpu_info.h @@ -0,0 +1,13 @@ +#ifndef EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ +#define EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ + +namespace gav1_jni { + +// Returns the number of performance cores that are available for AV1 decoding. +// This is a heuristic that works on most common android devices. Returns 0 on +// error or if the number of performance cores cannot be determined. +int GetNumberOfPerformanceCoresOnline(); + +} // namespace gav1_jni + +#endif // EXOPLAYER_V2_EXTENSIONS_AV1_SRC_MAIN_JNI_CPU_INFO_H_ diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc index 078ecdc7a25..6b25798e3fb 100644 --- a/extensions/av1/src/main/jni/gav1_jni.cc +++ b/extensions/av1/src/main/jni/gav1_jni.cc @@ -32,6 +32,7 @@ #include // NOLINT #include +#include "cpu_info.h" // NOLINT #include "gav1/decoder.h" #define LOG_TAG "gav1_jni" @@ -774,5 +775,9 @@ DECODER_FUNC(jint, gav1CheckError, jlong jContext) { return kStatusOk; } +DECODER_FUNC(jint, gav1GetThreads) { + return gav1_jni::GetNumberOfPerformanceCoresOnline(); +} + // TODO(b/139902005): Add functions for getting libgav1 version and build // configuration once libgav1 ABI provides this information. diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle index 853861e4ad6..4c8f648e344 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 835d6a33fc6..80d9817a463 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.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 Looper getApplicationLooper() { @Override public void addListener(EventListener listener) { + Assertions.checkNotNull(listener); listeners.addIfAbsent(new ListenerHolder(listener)); } @@ -333,7 +337,7 @@ public void moveMediaItems(int fromIndex, int toIndex, int newIndex) { && 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 boolean getPlayWhenReady() { 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 void seekTo(int windowIndex, long positionMs) { 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 int getRendererType(int index) { } } + @Override + @Nullable + public TrackSelector getTrackSelector() { + return null; + } + @Override public void setRepeatMode(@RepeatMode int repeatMode) { if (remoteMediaClient == null) { @@ -800,7 +797,7 @@ private PendingResult setMediaItemsInternal( } return remoteMediaClient.queueLoad( mediaQueueItems, - Math.min(startWindowIndex, mediaQueueItems.length - 1), + min(startWindowIndex, mediaQueueItems.length - 1), getCastRepeatMode(repeatMode), startPositionMs, /* customData= */ null); @@ -874,7 +871,7 @@ private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) return; } if (this.remoteMediaClient != null) { - this.remoteMediaClient.removeListener(statusListener); + this.remoteMediaClient.unregisterCallback(statusListener); this.remoteMediaClient.removeProgressListener(statusListener); } this.remoteMediaClient = remoteMediaClient; @@ -882,7 +879,7 @@ private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) if (sessionAvailabilityListener != null) { sessionAvailabilityListener.onCastSessionAvailable(); } - remoteMediaClient.addListener(statusListener); + remoteMediaClient.registerCallback(statusListener); remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS); updateInternalStateAndNotifyIfChanged(); } else { @@ -996,10 +993,8 @@ private MediaQueueItem[] toMediaQueueItems(List mediaItems) { // 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 void onProgressUpdated(long progressMs, long unusedDurationMs) { lastReportedPositionMs = progressMs; } - // RemoteMediaClient.Listener implementation. + // RemoteMediaClient.Callback implementation. @Override public void onStatusUpdated() { @@ -1085,6 +1080,9 @@ public void onSessionResuming(CastSession castSession, String s) { 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 38a7a692b25..edd2a060d28 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 @@ public Window getWindow(int windowIndex, Window window, long defaultPositionProj 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/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java index ab02b5efba2..705f2c25085 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java @@ -59,7 +59,7 @@ public MediaQueueItem toMediaQueueItem(MediaItem item) { metadata.putString(MediaMetadata.KEY_TITLE, item.mediaMetadata.title); } MediaInfo mediaInfo = - new MediaInfo.Builder(item.playbackProperties.sourceUri.toString()) + new MediaInfo.Builder(item.playbackProperties.uri.toString()) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setContentType(item.playbackProperties.mimeType) .setMetadata(metadata) @@ -74,7 +74,7 @@ private static MediaItem getMediaItem(JSONObject customData) { try { JSONObject mediaItemJson = customData.getJSONObject(KEY_MEDIA_ITEM); MediaItem.Builder builder = new MediaItem.Builder(); - builder.setSourceUri(Uri.parse(mediaItemJson.getString(KEY_URI))); + builder.setUri(Uri.parse(mediaItemJson.getString(KEY_URI))); if (mediaItemJson.has(KEY_TITLE)) { com.google.android.exoplayer2.MediaMetadata mediaMetadata = new com.google.android.exoplayer2.MediaMetadata.Builder() @@ -127,7 +127,7 @@ private static JSONObject getMediaItemJson(MediaItem mediaItem) throws JSONExcep Assertions.checkNotNull(mediaItem.playbackProperties); JSONObject json = new JSONObject(); json.put(KEY_TITLE, mediaItem.mediaMetadata.title); - json.put(KEY_URI, mediaItem.playbackProperties.sourceUri.toString()); + json.put(KEY_URI, mediaItem.playbackProperties.uri.toString()); json.put(KEY_MIME_TYPE, mediaItem.playbackProperties.mimeType); if (mediaItem.playbackProperties.drmConfiguration != null) { json.put( 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 79cf9aa85c1..049bc89b722 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 void setUp() { 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 void setPlayWhenReady_masksRemoteState() { .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 void setPlayWhenReady_correctChangeReasonOnPause() { 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 void setRepeatMode_masksRemoteState() { // 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 void setRepeatMode_updatesUponResultChange() { // 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 void setRepeatMode_updatesUponResultChange() { 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); } @@ -232,36 +231,30 @@ public void repeatMode_changesOnStatusUpdates() { @Test public void setMediaItems_callsRemoteMediaClient() { List mediaItems = new ArrayList<>(); - String sourceUri1 = "http://www.google.com/video1"; - String sourceUri2 = "http://www.google.com/video2"; + String uri1 = "http://www.google.com/video1"; + String uri2 = "http://www.google.com/video2"; mediaItems.add( - new MediaItem.Builder() - .setSourceUri(sourceUri1) - .setMimeType(MimeTypes.APPLICATION_MPD) - .build()); + new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); mediaItems.add( - new MediaItem.Builder() - .setSourceUri(sourceUri2) - .setMimeType(MimeTypes.APPLICATION_MP4) - .build()); + new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); castPlayer.setMediaItems(mediaItems, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L); verify(mockRemoteMediaClient) .queueLoad(queueItemsArgumentCaptor.capture(), eq(1), anyInt(), eq(2000L), any()); MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue(); - assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(sourceUri1); - assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(sourceUri2); + assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri1); + assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2); } @Test public void setMediaItems_doNotReset_callsRemoteMediaClient() { MediaItem.Builder builder = new MediaItem.Builder(); List mediaItems = new ArrayList<>(); - String sourceUri1 = "http://www.google.com/video1"; - String sourceUri2 = "http://www.google.com/video2"; - mediaItems.add(builder.setSourceUri(sourceUri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); - mediaItems.add(builder.setSourceUri(sourceUri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); + String uri1 = "http://www.google.com/video1"; + String uri2 = "http://www.google.com/video2"; + mediaItems.add(builder.setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); + mediaItems.add(builder.setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); int startWindowIndex = C.INDEX_UNSET; long startPositionMs = 2000L; @@ -271,18 +264,18 @@ public void setMediaItems_doNotReset_callsRemoteMediaClient() { .queueLoad(queueItemsArgumentCaptor.capture(), eq(0), anyInt(), eq(0L), any()); MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue(); - assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(sourceUri1); - assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(sourceUri2); + assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri1); + assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2); } @Test public void addMediaItems_callsRemoteMediaClient() { MediaItem.Builder builder = new MediaItem.Builder(); List mediaItems = new ArrayList<>(); - String sourceUri1 = "http://www.google.com/video1"; - String sourceUri2 = "http://www.google.com/video2"; - mediaItems.add(builder.setSourceUri(sourceUri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); - mediaItems.add(builder.setSourceUri(sourceUri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); + String uri1 = "http://www.google.com/video1"; + String uri2 = "http://www.google.com/video2"; + mediaItems.add(builder.setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); + mediaItems.add(builder.setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); castPlayer.addMediaItems(mediaItems); @@ -291,8 +284,8 @@ public void addMediaItems_callsRemoteMediaClient() { queueItemsArgumentCaptor.capture(), eq(MediaQueueItem.INVALID_ITEM_ID), any()); MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue(); - assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(sourceUri1); - assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(sourceUri2); + assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri1); + assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2); } @SuppressWarnings("ConstantConditions") @@ -301,12 +294,9 @@ public void addMediaItems_insertAtIndex_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 2); List mediaItems = createMediaItems(mediaQueueItemIds); fillTimeline(mediaItems, mediaQueueItemIds); - String sourceUri = "http://www.google.com/video3"; + String uri = "http://www.google.com/video3"; MediaItem anotherMediaItem = - new MediaItem.Builder() - .setSourceUri(sourceUri) - .setMimeType(MimeTypes.APPLICATION_MPD) - .build(); + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(); // Add another on position 1 int index = 1; @@ -319,7 +309,7 @@ public void addMediaItems_insertAtIndex_callsRemoteMediaClient() { any()); MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue(); - assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(sourceUri); + assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri); } @Test @@ -477,7 +467,7 @@ private List createMediaItems(int[] mediaQueueItemIds) { for (int mediaQueueItemId : mediaQueueItemIds) { MediaItem mediaItem = builder - .setSourceUri("http://www.google.com/video" + mediaQueueItemId) + .setUri("http://www.google.com/video" + mediaQueueItemId) .setMimeType(MimeTypes.APPLICATION_MPD) .setTag(mediaQueueItemId) .build(); @@ -503,6 +493,6 @@ private void fillTimeline(List mediaItems, int[] mediaQueueItemIds) { 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 cb852eb1d6b..cae117ea009 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 @@ private static RemoteMediaClient mockRemoteMediaClient( 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/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java index fc1413ae667..9d65bada167 100644 --- a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java @@ -36,7 +36,7 @@ public class DefaultMediaItemConverterTest { public void serialize_deserialize_minimal() { MediaItem.Builder builder = new MediaItem.Builder(); MediaItem item = - builder.setSourceUri("http://example.com").setMimeType(MimeTypes.APPLICATION_MPD).build(); + builder.setUri("http://example.com").setMimeType(MimeTypes.APPLICATION_MPD).build(); DefaultMediaItemConverter converter = new DefaultMediaItemConverter(); MediaQueueItem queueItem = converter.toMediaQueueItem(item); @@ -50,7 +50,7 @@ public void serialize_deserialize_complete() { MediaItem.Builder builder = new MediaItem.Builder(); MediaItem item = builder - .setSourceUri(Uri.parse("http://example.com")) + .setUri(Uri.parse("http://example.com")) .setMediaMetadata(new MediaMetadata.Builder().build()) .setMimeType(MimeTypes.APPLICATION_MPD) .setDrmUuid(C.WIDEVINE_UUID) diff --git a/extensions/cronet/README.md b/extensions/cronet/README.md index dc64b862b69..112ad26bbab 100644 --- a/extensions/cronet/README.md +++ b/extensions/cronet/README.md @@ -20,6 +20,10 @@ Alternatively, you can clone the ExoPlayer repository and depend on the module locally. Instructions for doing this can be found in ExoPlayer's [top level README][]. +Note that by default, the extension will use the Cronet implementation in +Google Play Services. If you prefer, it's also possible to embed the Cronet +implementation directly into your application. See below for more details. + [top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md ## Using the extension ## @@ -47,6 +51,46 @@ new DefaultDataSourceFactory( ``` respectively. +## Choosing between Google Play Services Cronet and Cronet Embedded ## + +The underlying Cronet implementation is available both via a [Google Play +Services](https://developers.google.com/android/guides/overview) API, and as a +library that can be embedded directly into your application. When you depend on +`com.google.android.exoplayer:extension-cronet:2.X.X`, the library will _not_ be +embedded into your application by default. The extension will attempt to use the +Cronet implementation in Google Play Services. The benefits of this approach +are: + +* A negligible increase in the size of your application. +* The Cronet implementation is updated automatically by Google Play Services. + +If Google Play Services is not available on a device, `CronetDataSourceFactory` +will fall back to creating `DefaultHttpDataSource` instances, or +`HttpDataSource` instances created by a `fallbackFactory` that you can specify. + +It's also possible to embed the Cronet implementation directly into your +application. To do this, add an additional gradle dependency to the Cronet +Embedded library: + +```gradle +implementation 'com.google.android.exoplayer:extension-cronet:2.X.X' +implementation 'org.chromium.net:cronet-embedded:XX.XXXX.XXX' +``` + +where `XX.XXXX.XXX` is the version of the library that you wish to use. The +extension will automatically detect and use the library. Embedding will add +approximately 8MB to your application, however it may be suitable if: + +* Your application is likely to be used in markets where Google Play Services is + not widely available. +* You want to control the exact version of the Cronet implementation being used. + +If you do embed the library, you can specify which implementation should +be preferred if the Google Play Services implementation is also available. This +is controlled by a `preferGMSCoreCronet` parameter, which can be passed to the +`CronetEngineWrapper` constructor (GMS Core is another name for Google Play +Services). + ## Links ## * [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.cronet.*` diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle index 742b163ebf9..0dd1d42d724 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 'org.chromium.net:cronet-embedded:76.3809.111' + api "com.google.android.gms:play-services-cronet:17.0.0" implementation project(modulePrefix + 'library-core') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + 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 314e06900e5..e70538d7bec 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 @@ public long getLength() { @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 9a2e3e491d7..26a60d33324 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java @@ -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,11 +32,14 @@ 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; @@ -83,14 +88,6 @@ public OpenException(String errorMessage, DataSpec dataSpec, int cronetConnectio } - /** Thrown on catching an InterruptedException. */ - public static final class InterruptedIOException extends IOException { - - public InterruptedIOException(InterruptedException e) { - super(e); - } - } - static { ExoPlayerLibraryInfo.registerModule("goog.exo.cronet"); } @@ -153,6 +150,8 @@ public InterruptedIOException(InterruptedException e) { 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 @@ -171,6 +170,8 @@ public CronetDataSource(CronetEngine cronetEngine, Executor executor) { } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -202,6 +203,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -236,6 +239,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -248,6 +253,7 @@ public CronetDataSource( * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public CronetDataSource( CronetEngine cronetEngine, @@ -264,6 +270,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -281,6 +289,7 @@ public CronetDataSource( * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean, * RequestProperties)} and {@link #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public CronetDataSource( CronetEngine cronetEngine, @@ -302,6 +311,8 @@ public CronetDataSource( } /** + * Creates an instance. + * * @param cronetEngine A CronetEngine. * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread @@ -440,19 +451,35 @@ public long open(DataSpec dataSpec) throws HttpDataSourceException { } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new OpenException(new InterruptedIOException(e), dataSpec, Status.INVALID); + throw new OpenException(new InterruptedIOException(), dataSpec, Status.INVALID); } // Check for a valid response code. 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)); } @@ -464,7 +491,7 @@ public long open(DataSpec dataSpec) throws HttpDataSourceException { 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); } } @@ -503,17 +530,12 @@ public int read(byte[] buffer, int offset, int readLength) throws 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; @@ -523,14 +545,14 @@ public int read(byte[] buffer, int offset, int readLength) throws 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) { @@ -610,11 +632,8 @@ public int read(ByteBuffer buffer) throws HttpDataSourceException { 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); } @@ -705,7 +724,7 @@ private UrlRequest.Builder buildRequestBuilder(DataSpec dataSpec) throws IOExcep if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) { throw new IOException("HTTP request with non-empty body must set Content-Type"); } - + // Set the Range header. if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) { StringBuilder rangeValue = new StringBuilder(); @@ -769,7 +788,7 @@ private void readInternal(ByteBuffer buffer) throws HttpDataSourceException { } Thread.currentThread().interrupt(); throw new HttpDataSourceException( - new InterruptedIOException(e), + new InterruptedIOException(), castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ); } catch (SocketTimeoutException e) { @@ -788,6 +807,14 @@ private void readInternal(ByteBuffer buffer) throws HttpDataSourceException { } } + 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")) { @@ -833,7 +860,7 @@ private static long getContentLength(UrlResponseInfo info) { // 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 + "]"); @@ -876,7 +903,7 @@ private static boolean isEmpty(@Nullable List list) { // 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); @@ -900,7 +927,11 @@ public synchronized void onRedirectReceived( 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 4086011b4f3..85c9d09a791 100644 --- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java +++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java @@ -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 CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @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 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 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 CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

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

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

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

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 CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. + * + *

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

Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, + * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout. + * + * @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 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. @@ -244,7 +276,7 @@ public CronetDataSourceFactory( } /** - * Constructs a CronetDataSourceFactory. + * Creates an instance. * *

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

If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided * fallback {@link HttpDataSource.Factory} will be used instead. diff --git a/extensions/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 2c25c32269f..9f709b14d03 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; @@ -166,7 +168,7 @@ private static class CronetProviderComparator implements Comparator> responseHeaderList = new ArrayList<>(); - responseHeaderList.addAll(testResponseHeader.entrySet()); - return new UrlResponseInfoImpl( - Collections.singletonList(url), - statusCode, - null, // httpStatusText - responseHeaderList, - false, // wasCached - null, // negotiatedProtocol - null); // proxyServer + Map> responseHeaderMap = new HashMap<>(); + for (Map.Entry entry : testResponseHeader.entrySet()) { + responseHeaderList.add(entry); + responseHeaderMap.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + return new UrlResponseInfo() { + @Override + public String getUrl() { + return url; + } + + @Override + public List getUrlChain() { + return Collections.singletonList(url); + } + + @Override + public int getHttpStatusCode() { + return statusCode; + } + + @Override + public String getHttpStatusText() { + return null; + } + + @Override + public List> getAllHeadersAsList() { + return responseHeaderList; + } + + @Override + public Map> getAllHeaders() { + return responseHeaderMap; + } + + @Override + public boolean wasCached() { + return false; + } + + @Override + public String getNegotiatedProtocol() { + return null; + } + + @Override + public String getProxyServer() { + return null; + } + + @Override + public long getReceivedByteCount() { + return 0; + } + }; } @Test @@ -284,7 +330,7 @@ public void requestOpenFail() { fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { // Check for connection not automatically closed. - assertThat(e.getCause() instanceof UnknownHostException).isFalse(); + assertThat(e).hasCauseThat().isNotInstanceOf(UnknownHostException.class); verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); @@ -322,7 +368,7 @@ public void requestOpenFailDueToDnsFailure() { fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { // Check for connection not automatically closed. - assertThat(e.getCause() instanceof UnknownHostException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(UnknownHostException.class); verify(mockUrlRequest, never()).cancel(); verify(mockTransferListener, never()) .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true); @@ -330,15 +376,18 @@ public void requestOpenFailDueToDnsFailure() { } @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 instanceof HttpDataSource.InvalidResponseCodeException).isTrue(); + 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()) @@ -361,7 +410,7 @@ public void requestOpenValidatesContentTypePredicate() { dataSourceUnderTest.open(testDataSpec); fail("HttpDataSource.HttpDataSourceException expected"); } catch (HttpDataSourceException e) { - assertThat(e instanceof HttpDataSource.InvalidContentTypeException).isTrue(); + assertThat(e).isInstanceOf(HttpDataSource.InvalidContentTypeException.class); // Check for connection not automatically closed. verify(mockUrlRequest, never()).cancel(); assertThat(testedContentTypes).hasSize(1); @@ -892,8 +941,8 @@ public void run() { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) .isEqualTo(TEST_CONNECTION_STATUS); timedOutLatch.countDown(); @@ -932,8 +981,8 @@ public void run() { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus) .isEqualTo(TEST_INVALID_CONNECTION_STATUS); timedOutLatch.countDown(); @@ -1005,8 +1054,8 @@ public void run() { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e instanceof CronetDataSource.OpenException).isTrue(); - assertThat(e.getCause() instanceof SocketTimeoutException).isTrue(); + assertThat(e).isInstanceOf(CronetDataSource.OpenException.class); + assertThat(e).hasCauseThat().isInstanceOf(SocketTimeoutException.class); openExceptions.getAndIncrement(); timedOutLatch.countDown(); } @@ -1231,7 +1280,7 @@ public void run() { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); timedOutLatch.countDown(); } } @@ -1262,7 +1311,7 @@ public void run() { fail(); } catch (HttpDataSourceException e) { // Expected. - assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue(); + assertThat(e).hasCauseThat().isInstanceOf(InterruptedIOException.class); timedOutLatch.countDown(); } } @@ -1375,7 +1424,7 @@ private void mockReadSuccess(int position, int length) { 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 f6e39445728..639d1f6d6c7 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,22 +60,21 @@ 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}" && \ -./build_ffmpeg.sh \ - "${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}" +cd "${FFMPEG_EXT_PATH}/jni" && \ +ln -s "$FFMPEG_PATH" ffmpeg ``` -* Build the JNI native libraries, setting `APP_ABI` to include the architectures - built in the previous step. For example: +* 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}" && \ -${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86 x86_64" -j4 +cd "${FFMPEG_EXT_PATH}/jni" && \ +./build_ffmpeg.sh \ + "${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}" ``` ## Build instructions (Windows) ## diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle index 4fe753427ac..a9edeaff6bd 100644 --- a/extensions/ffmpeg/build.gradle +++ b/extensions/ffmpeg/build.gradle @@ -11,65 +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' - - externalNativeBuild { - cmake { - // Debug CMake build type causes video frames to drop, - // so native library should always use Release build type. - arguments "-DCMAKE_BUILD_TYPE=Release" - targets "ffmpeg" - } - } - } - - externalNativeBuild { - cmake { - version '3.10.2' - path "src/main/jni/CMakeLists.txt" - } - } - - buildTypes { - debug { - ndk { - abiFilters 'arm64-v8a'/*, 'x86_64'*/ - } - } - } - - // This option resolves the problem of finding libffmpeg.so - // on multiple paths. The first one found is picked. - packagingOptions { - pickFirst 'lib/arm64-v8a/libffmpeg.so' - pickFirst 'lib/armeabi-v7a/libffmpeg.so' - pickFirst 'lib/x86/libffmpeg.so' - pickFirst 'lib/x86_64/libffmpeg.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' - 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 c5072a3398e..d6980f28019 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 @@ 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 @@ public String getName() { @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 4bb64cbaa02..0718dc2c5c7 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.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 FfmpegAudioRenderer( this( eventHandler, eventListener, - new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors), - /* enableFloatOutput= */ false); + new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors)); } /** @@ -74,21 +74,15 @@ public FfmpegAudioRenderer( * 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 @@ protected int supportsFormatInternal(Format format) { 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 @@ protected FfmpegAudioDecoder createDecoder(Format format, @Nullable ExoMediaCryp 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,29 +137,36 @@ public Format getOutputFormat() { .build(); } - private boolean isOutputSupported(Format inputFormat) { - return shouldUseFloatOutput(inputFormat) - || supportsOutput(inputFormat.channelCount, 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, 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 e3752aad5c9..71912aea2f9 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() {} @@ -56,8 +61,29 @@ public static boolean isAvailable() { } /** Returns the version of the underlying library if available, or null otherwise. */ - public static @Nullable String getVersion() { - return isAvailable() ? ffmpegGetVersion() : null; + @Nullable + public static String getVersion() { + 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; } /** @@ -69,7 +95,7 @@ public static boolean supportsFormat(String mimeType) { if (!isAvailable()) { return false; } - String codecName = getCodecName(mimeType); + @Nullable String codecName = getCodecName(mimeType); if (codecName == null) { return false; } @@ -84,7 +110,8 @@ public static boolean supportsFormat(String mimeType) { * Returns the name of the FFmpeg decoder that could be used to decode the format, or {@code null} * if it's unsupported. */ - /* package */ static @Nullable String getCodecName(String mimeType) { + @Nullable + /* package */ static String getCodecName(String mimeType) { switch (mimeType) { case MimeTypes.AUDIO_AAC: return "aac"; @@ -128,6 +155,8 @@ public static boolean supportsFormat(String mimeType) { } 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 e1071b9fef5..a72f458936a 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 @@ -129,7 +129,7 @@ public final int supportsFormat(Format format) { 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( @@ -170,4 +170,9 @@ protected void setDecoderOutputMode(@C.VideoOutputMode int outputMode) { decoder.setOutputMode(outputMode); } } + + @Override + protected boolean canKeepCodec(Format oldFormat, Format newFormat) { + return Util.areEqual(oldFormat.sampleMimeType, newFormat.sampleMimeType); + } } diff --git a/extensions/ffmpeg/src/main/jni/Android.mk b/extensions/ffmpeg/src/main/jni/Android.mk deleted file mode 100644 index 01de61ccc1e..00000000000 --- 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 -landroid -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 7d6f7325489..00000000000 --- 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 index adab7d4f540..b60af4fa18a 100644 --- a/extensions/ffmpeg/src/main/jni/CMakeLists.txt +++ b/extensions/ffmpeg/src/main/jni/CMakeLists.txt @@ -1,61 +1,36 @@ -# libffmpegJNI requires modern CMake. cmake_minimum_required(VERSION 3.7.1 FATAL_ERROR) -# libffmpegJNI requires C++11. +# Enable C++11 features. set(CMAKE_CXX_STANDARD 11) -project(libffmpeg C CXX) +project(libffmpeg_jni C CXX) -set(libgffmpeg_jni_root "${CMAKE_CURRENT_SOURCE_DIR}") -set(libgffmpeg_jni_build "${CMAKE_BINARY_DIR}") -set(libgffmpeg_jni_output_directory - ${libgffmpeg_jni_root}/../libs/${ANDROID_ABI}/) +set(ffmpeg_location "${CMAKE_CURRENT_SOURCE_DIR}/ffmpeg") +set(ffmpeg_binaries "${ffmpeg_location}/android-libs/${ANDROID_ABI}") -add_library( - avutil - SHARED - IMPORTED) -set_target_properties( - avutil PROPERTIES - IMPORTED_LOCATION - ${libgffmpeg_jni_output_directory}/libavutil.so) - -add_library( - swresample - SHARED - IMPORTED) -set_target_properties( - swresample PROPERTIES - IMPORTED_LOCATION - ${libgffmpeg_jni_output_directory}/libswresample.so) - -add_library( - avcodec - SHARED - IMPORTED) -set_target_properties( - avcodec PROPERTIES - IMPORTED_LOCATION - ${libgffmpeg_jni_output_directory}/libavcodec.so) +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) -# Build libgffmpegJNI. -add_library(ffmpeg +add_library(ffmpeg_jni SHARED ffmpeg_jni.cc) -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include) -# Locate NDK log library. -find_library(android_log_lib log) - -# Link libgffmpegJNI against used libraries. -target_link_libraries(ffmpeg +target_link_libraries(ffmpeg_jni PRIVATE android PRIVATE avutil PRIVATE swresample PRIVATE avcodec PRIVATE ${android_log_lib}) - -# Specify output directory for libgffmpegJNI. -set_target_properties(ffmpeg PROPERTIES - LIBRARY_OUTPUT_DIRECTORY - ${libgffmpeg_jni_output_directory}) diff --git a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh index 833ea189b23..4660669a336 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 44d086e93f4..fe5e4126cf2 100644 --- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc +++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc @@ -134,6 +134,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 a52d1b1d7ad..cc8ca5487e0 100644 --- a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java +++ b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java @@ -21,13 +21,22 @@ import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer}. */ +/** + * Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer} and {@link + * FfmpegVideoRenderer}. + */ @RunWith(AndroidJUnit4.class) public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesFfmpegAudioRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( FfmpegAudioRenderer.class, C.TRACK_TYPE_AUDIO); } + + @Test + public void createRenderers_instantiatesFfmpegVideoRenderer() { + DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( + FfmpegVideoRenderer.class, C.TRACK_TYPE_VIDEO); + } } diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle index f220d21106b..9aeeb83eb3f 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 1c0c450a30f..e6e66fbe29b 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java @@ -37,16 +37,16 @@ @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 e203849bb9e..d260a58e5d5 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 @@ -17,7 +17,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.testutil.ExtractorAsserts; import org.junit.Before; @@ -25,6 +24,8 @@ import org.junit.runner.RunWith; /** Unit test for {@link FlacExtractor}. */ +// TODO(internal: b/26110951): Use org.junit.runners.Parameterized (and corresponding methods on +// ExtractorAsserts) when it's supported by our testing infrastructure. @RunWith(AndroidJUnit4.class) public class FlacExtractorTest { @@ -37,91 +38,81 @@ public void setUp() { @Test public void sample() throws Exception { - ExtractorAsserts.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_raw"); + /* file= */ "media/flac/bear.flac", + /* dumpFilesPrefix= */ "extractordumps/flac/bear_raw"); } @Test public void sampleWithId3HeaderAndId3Enabled() throws Exception { - ExtractorAsserts.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_with_id3.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA), - /* file= */ "flac/bear_with_id3.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_no_seek_table_no_num_samples.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_with_vorbis_comments.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_with_picture.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_one_metadata_block.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_no_min_max_frame_size.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_no_num_samples.flac", - ApplicationProvider.getApplicationContext(), - /* 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.assertBehavior( + ExtractorAsserts.assertAllBehaviors( FlacExtractor::new, - /* file= */ "flac/bear_uncommon_sample_rate.flac", - ApplicationProvider.getApplicationContext(), - /* 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 e9b1fd10193..bbcc26fb64f 100644 --- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java +++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java @@ -25,6 +25,7 @@ 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.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 @@ private static void playAndAssertAudioSinkInput(String fileName) throws Exceptio 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 @@ private static void playAndAssertAudioSinkInput(String fileName) throws Exceptio 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 void run() { 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 742ade214de..b736c4d7434 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 @@ public FlacBinarySearchSeeker( /* 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 daf45849485..af4e5710249 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 @@ public void clearData() { 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 @@ public int read(ByteBuffer target) throws IOException { } 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 364cf80ef8e..0ac4dbeffa2 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 @@ private void decodeStreamMetadata(ExtractorInput input) throws IOException { 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 cbdf42dbaff..df511866a38 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.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 LibflacAudioRenderer( } /** + * 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 @@ protected int supportsFormatInternal(Format format) { || !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, 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 @@ protected FlacDecoder createDecoder(Format format, @Nullable ExoMediaCrypto medi 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 fb20ff1114a..3fb8f2cece4 100644 --- a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java +++ b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java @@ -26,7 +26,7 @@ public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesFlacRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( LibflacAudioRenderer.class, C.TRACK_TYPE_AUDIO); } diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle index 4e6bd76cb41..891888a0d25 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 f28ba2977e2..c67dfdbb5d5 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 1bd0b8dbd46..f7b2b3f77c0 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.11.3' + 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 ed9130bc721..88bc4e14c53 100644 --- a/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java +++ b/extensions/ima/src/androidTest/java/com/google/android/exoplayer2/ext/ima/ImaPlaybackTest.java @@ -20,7 +20,6 @@ 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; @@ -28,6 +27,7 @@ import androidx.test.rule.ActivityTestRule; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; import com.google.android.exoplayer2.Player.EventListener; import com.google.android.exoplayer2.Player.TimelineChangeReason; @@ -38,8 +38,10 @@ 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; import com.google.android.exoplayer2.testutil.ExoHostedTest; import com.google.android.exoplayer2.testutil.HostActivity; import com.google.android.exoplayer2.testutil.TestUtil; @@ -47,11 +49,12 @@ 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; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -60,39 +63,86 @@ @RunWith(AndroidJUnit4.class) public final class ImaPlaybackTest { + private static final String TAG = "ImaPlaybackTest"; + private static final long TIMEOUT_MS = 5 * 60 * C.MILLIS_PER_SECOND; - private static final String CONTENT_URI = + private static final String CONTENT_URI_SHORT = "https://storage.googleapis.com/exoplayer-test-media-1/mp4/android-screens-10s.mp4"; - private static final String PREROLL_ADS_RESPONSE_FILE_NAME = "ad-responses/preroll.xml"; - private static final String MIDROLL_ADS_RESPONSE_FILE_NAME = "ad-responses/midroll.xml"; - + private static final String CONTENT_URI_LONG = + "https://storage.googleapis.com/exoplayer-test-media-1/mp4/android-screens-25s.mp4"; private static final AdId CONTENT = new AdId(C.INDEX_UNSET, C.INDEX_UNSET); @Rule public ActivityTestRule testRule = new ActivityTestRule<>(HostActivity.class); @Test public void playbackWithPrerollAdTag_playsAdAndContent() throws Exception { - AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT}; String adsResponse = - TestUtil.getString(/* context= */ testRule.getActivity(), PREROLL_ADS_RESPONSE_FILE_NAME); + TestUtil.getString(/* context= */ testRule.getActivity(), "ad-responses/preroll.xml"); + AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT}; ImaHostedTest hostedTest = - new ImaHostedTest(Uri.parse(CONTENT_URI), adsResponse, expectedAdIds); + new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds); testRule.getActivity().runTest(hostedTest, TIMEOUT_MS); } @Test public void playbackWithMidrolls_playsAdAndContent() throws Exception { + String adsResponse = + TestUtil.getString( + /* context= */ testRule.getActivity(), "ad-responses/preroll_midroll6s_postroll.xml"); AdId[] expectedAdIds = new AdId[] {ad(0), CONTENT, ad(1), CONTENT, ad(2), CONTENT}; + ImaHostedTest hostedTest = + new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds); + + testRule.getActivity().runTest(hostedTest, TIMEOUT_MS); + } + + @Test + public void playbackWithMidrolls1And7_playsAdsAndContent() throws Exception { String adsResponse = - TestUtil.getString(/* context= */ testRule.getActivity(), MIDROLL_ADS_RESPONSE_FILE_NAME); + TestUtil.getString( + /* context= */ testRule.getActivity(), "ad-responses/midroll1s_midroll7s.xml"); + AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT}; ImaHostedTest hostedTest = - new ImaHostedTest(Uri.parse(CONTENT_URI), adsResponse, expectedAdIds); + new ImaHostedTest(Uri.parse(CONTENT_URI_SHORT), adsResponse, expectedAdIds); testRule.getActivity().runTest(hostedTest, TIMEOUT_MS); } + @Test + public void playbackWithMidrolls10And20WithSeekTo12_playsAdsAndContent() throws Exception { + String adsResponse = + TestUtil.getString( + /* context= */ testRule.getActivity(), "ad-responses/midroll10s_midroll20s.xml"); + AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT}; + ImaHostedTest hostedTest = + new ImaHostedTest(Uri.parse(CONTENT_URI_LONG), adsResponse, expectedAdIds); + hostedTest.setSchedule( + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .seek(12 * C.MILLIS_PER_SECOND) + .build()); + testRule.getActivity().runTest(hostedTest, TIMEOUT_MS); + } + + @Ignore("The second ad doesn't preload so playback gets stuck. See [internal: b/155615925].") + @Test + public void playbackWithMidrolls10And20WithSeekTo18_playsAdsAndContent() throws Exception { + String adsResponse = + TestUtil.getString( + /* context= */ testRule.getActivity(), "ad-responses/midroll10s_midroll20s.xml"); + AdId[] expectedAdIds = new AdId[] {CONTENT, ad(0), CONTENT, ad(1), CONTENT}; + ImaHostedTest hostedTest = + new ImaHostedTest(Uri.parse(CONTENT_URI_LONG), adsResponse, expectedAdIds); + hostedTest.setSchedule( + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .seek(18 * C.MILLIS_PER_SECOND) + .build()); + testRule.getActivity().runTest(hostedTest, TIMEOUT_MS); + } + private static AdId ad(int groupIndex) { return new AdId(groupIndex, /* indexInGroup= */ 0); } @@ -170,7 +220,9 @@ public void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int rea @Override public void onPositionDiscontinuity( EventTime eventTime, @DiscontinuityReason int reason) { - maybeUpdateSeenAdIdentifiers(); + if (reason != Player.DISCONTINUITY_REASON_SEEK) { + maybeUpdateSeenAdIdentifiers(); + } } }); Context context = host.getApplicationContext(); @@ -182,29 +234,26 @@ public void onPositionDiscontinuity( @Override protected MediaSource buildSource( HostActivity host, - String userAgent, DrmSessionManager drmSessionManager, FrameLayout overlayFrameLayout) { Context context = host.getApplicationContext(); - DataSource.Factory dataSourceFactory = - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ImaPlaybackTest.class.getSimpleName())); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context); MediaSource contentMediaSource = - 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 00000000000..a97307a4195 --- /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 04a12e5c022..88b0daac493 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -15,8 +15,15 @@ */ 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; +import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import android.view.View; @@ -24,7 +31,6 @@ import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import com.google.ads.interactivemedia.v3.api.Ad; import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; import com.google.ads.interactivemedia.v3.api.AdError; import com.google.ads.interactivemedia.v3.api.AdError.AdErrorCode; @@ -34,15 +40,19 @@ 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; +import com.google.ads.interactivemedia.v3.api.player.AdMediaInfo; import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer; import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; @@ -52,15 +62,16 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; -import com.google.android.exoplayer2.source.ads.AdPlaybackState.AdState; -import com.google.android.exoplayer2.source.ads.AdsLoader; import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; 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; @@ -69,30 +80,28 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; -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"); @@ -101,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; /** @@ -118,11 +142,13 @@ public static final class Builder { * @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(); } @@ -136,7 +162,20 @@ public Builder(Context context) { * @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; } @@ -148,7 +187,7 @@ public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) { * @return This builder, for convenience. */ public Builder setAdEventListener(AdEventListener adEventListener) { - this.adEventListener = Assertions.checkNotNull(adEventListener); + this.adEventListener = checkNotNull(adEventListener); return this; } @@ -160,7 +199,38 @@ public Builder setAdEventListener(AdEventListener adEventListener) { * @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; } @@ -172,7 +242,7 @@ public Builder setAdUiElements(Set adUiElements) { * @see AdsRequest#setVastLoadTimeout(float) */ public Builder setVastLoadTimeoutMs(int vastLoadTimeoutMs) { - Assertions.checkArgument(vastLoadTimeoutMs > 0); + checkArgument(vastLoadTimeoutMs > 0); this.vastLoadTimeoutMs = vastLoadTimeoutMs; return this; } @@ -185,7 +255,7 @@ public Builder setVastLoadTimeoutMs(int vastLoadTimeoutMs) { * @see AdsRenderingSettings#setLoadVideoTimeout(int) */ public Builder setMediaLoadTimeoutMs(int mediaLoadTimeoutMs) { - Assertions.checkArgument(mediaLoadTimeoutMs > 0); + checkArgument(mediaLoadTimeoutMs > 0); this.mediaLoadTimeoutMs = mediaLoadTimeoutMs; return this; } @@ -198,7 +268,7 @@ public Builder setMediaLoadTimeoutMs(int mediaLoadTimeoutMs) { * @see AdsRenderingSettings#setBitrateKbps(int) */ public Builder setMaxMediaBitrate(int bitrate) { - Assertions.checkArgument(bitrate > 0); + checkArgument(bitrate > 0); this.mediaBitrate = bitrate; return this; } @@ -217,9 +287,24 @@ public Builder setFocusSkipButtonWhenAvailable(boolean focusSkipButtonWhenAvaila 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; } @@ -236,12 +321,16 @@ public ImaAdsLoader buildForAdTag(Uri adTagUri) { context, adTagUri, imaSdkSettings, - null, + /* adsResponse= */ null, + adPreloadTimeoutMs, vastLoadTimeoutMs, mediaLoadTimeoutMs, mediaBitrate, focusSkipButtonWhenAvailable, + playAdBeforeStartPosition, adUiElements, + companionAdSlots, + adErrorListener, adEventListener, imaFactory); } @@ -256,14 +345,18 @@ public ImaAdsLoader buildForAdTag(Uri adTagUri) { 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); } @@ -275,6 +368,14 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima"; private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION; + /** + * Interval at which ad progress updates are provided to the IMA SDK, in milliseconds. 100 ms is + * the interval recommended by the IMA documentation. + * + * @see VideoAdPlayer.VideoAdPlayerCallback + */ + private static final int AD_PROGRESS_UPDATE_INTERVAL_MS = 100; + /** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */ private static final long IMA_DURATION_UNSET = -1L; @@ -282,10 +383,14 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { * Threshold before the end of content at which IMA is notified that content is complete if the * player buffers, in milliseconds. */ - private static final long END_OF_CONTENT_POSITION_THRESHOLD_MS = 5000; - - /** The maximum duration before an ad break that IMA may start preloading the next ad. */ - private static final long MAXIMUM_PRELOAD_DURATION_MS = 8000; + private static final long THRESHOLD_END_OF_CONTENT_MS = 5000; + /** + * Threshold before the start of an ad at which IMA is expected to be able to preload the ad, in + * milliseconds. + */ + private static final long THRESHOLD_AD_PRELOAD_MS = 4000; + /** 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; @@ -295,85 +400,95 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { @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()} and not {@link #pauseAd()}. + * 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()} 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 List adCallbacks; - private final AdDisplayContainer adDisplayContainer; - private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; - + private final Handler handler; + private final ComponentListener componentListener; + private final List adCallbacks; + private final Runnable updateAdProgressRunnable; + private final BiMap adInfoByAdMediaInfo; + + private @MonotonicNonNull AdDisplayContainer adDisplayContainer; + private @MonotonicNonNull AdsLoader adsLoader; private boolean wasSetPlayerCalled; @Nullable private Player nextPlayer; - private Object pendingAdRequestContext; + @Nullable private Object pendingAdRequestContext; private List supportedMimeTypes; @Nullable private EventListener eventListener; @Nullable private Player player; private VideoProgressUpdate lastContentProgress; private VideoProgressUpdate lastAdProgress; - private int lastVolumePercentage; + private int lastVolumePercent; @Nullable private AdsManager adsManager; - private boolean initializedAdsManager; - private AdLoadException pendingAdLoadError; + private boolean isAdsManagerInitialized; + private boolean hasAdPlaybackState; + @Nullable private AdLoadException pendingAdLoadError; private Timeline timeline; private long contentDurationMs; - private int podIndexOffset; private AdPlaybackState adPlaybackState; // Fields tracking IMA's state. - /** The expected ad group index that IMA should load next. */ - private int expectedAdGroupIndex; - /** The index of the current ad group that IMA is loading. */ - private int adGroupIndex; /** Whether IMA has sent an ad event to pause content since the last resume content event. */ private boolean imaPausedContent; /** The current ad playback state. */ private @ImaAdState int imaAdState; - /** - * Whether {@link com.google.ads.interactivemedia.v3.api.AdsLoader#contentComplete()} has been - * called since starting ad playback. - */ + /** The current ad media info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdMediaInfo imaAdMediaInfo; + /** The current ad info, or {@code null} if in state {@link #IMA_AD_STATE_NONE}. */ + @Nullable private AdInfo imaAdInfo; + /** Whether IMA has been notified that playback of content has finished. */ private boolean sentContentComplete; // Fields tracking the player/loader state. /** Whether the player is playing an ad. */ private boolean playingAd; + /** Whether the player is buffering an ad. */ + private boolean bufferingAd; /** * If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET} * otherwise. */ private int playingAdIndexInAdGroup; /** - * Whether there's a pending ad preparation error which IMA needs to be notified of when it - * transitions from playing content to playing the ad. + * The ad info for a pending ad for which the media failed preparation, or {@code null} if no + * pending ads have failed to prepare. */ - private boolean shouldNotifyAdPrepareError; + @Nullable private AdInfo pendingAdPrepareErrorAdInfo; /** - * If a content period has finished but IMA has not yet called {@link #playAd()}, stores the value - * of {@link SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to - * determine a fake, increasing content position. {@link C#TIME_UNSET} otherwise. + * If a content period has finished but IMA has not yet called {@link + * 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; /** @@ -383,8 +498,16 @@ public ImaAdsLoader buildForAdsResponse(String adsResponse) { 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. @@ -402,62 +525,49 @@ public ImaAdsLoader(Context context, Uri adTagUri) { adTagUri, /* imaSdkSettings= */ null, /* adsResponse= */ null, + /* adPreloadTimeoutMs= */ Builder.DEFAULT_AD_PRELOAD_TIMEOUT_MS, /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, /* mediaBitrate= */ BITRATE_UNSET, /* focusSkipButtonWhenAvailable= */ true, + /* playAdBeforeStartPosition= */ true, /* adUiElements= */ null, + /* companionAdSlots= */ null, + /* adErrorListener= */ null, /* adEventListener= */ null, /* imaFactory= */ new DefaultImaFactory()); } - /** - * Creates a new IMA ads loader. - * - * @param context The context. - * @param adTagUri The {@link Uri} of an ad tag compatible with the Android IMA SDK. See - * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for - * more information. - * @param imaSdkSettings {@link ImaSdkSettings} used to configure the IMA SDK, or {@code null} to - * use the default settings. If set, the player type and version fields may be overwritten. - * @deprecated Use {@link ImaAdsLoader.Builder}. - */ - @Deprecated - public ImaAdsLoader(Context context, Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings) { - this( - context, - adTagUri, - imaSdkSettings, - /* adsResponse= */ null, - /* vastLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET, - /* mediaBitrate= */ BITRATE_UNSET, - /* focusSkipButtonWhenAvailable= */ true, - /* adUiElements= */ null, - /* adEventListener= */ null, - /* imaFactory= */ new DefaultImaFactory()); - } - + @SuppressWarnings({"nullness:argument.type.incompatible", "methodref.receiver.bound.invalid"}) private ImaAdsLoader( Context context, @Nullable Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings, @Nullable String adsResponse, + long adPreloadTimeoutMs, int vastLoadTimeoutMs, int mediaLoadTimeoutMs, int mediaBitrate, 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) { @@ -468,56 +578,50 @@ private 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, imaSdkSettings, adDisplayContainer); - adsLoader.addAdErrorListener(/* adErrorListener= */ this); - adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this); + updateAdProgressRunnable = this::updateAdProgress; + 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; - adGroupIndex = C.INDEX_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. * @@ -525,36 +629,64 @@ public void setCompanionSlots(Collection companionSlots) { * 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) { - if (adPlaybackState != null || adsManager != null || pendingAdRequestContext != null) { + public void requestAds(@Nullable ViewGroup adViewGroup) { + if (hasAdPlaybackState || adsManager != null || pendingAdRequestContext != null) { // Ads have already been requested. return; } - adDisplayContainer.setAdContainer(adViewGroup); - pendingAdRequestContext = new Object(); + 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()); - } else /* adsResponse != null */ { - request.setAdsResponse(adsResponse); + } else { + request.setAdsResponse(castNonNull(adsResponse)); } 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.getMainLooper() == Looper.myLooper()); - Assertions.checkState( - player == null || player.getApplicationLooper() == Looper.getMainLooper()); + checkState(Looper.myLooper() == getImaLooper()); + checkState(player == null || player.getApplicationLooper() == getImaLooper()); nextPlayer = player; wasSetPlayerCalled = true; } @@ -563,6 +695,7 @@ public void setPlayer(@Nullable Player player) { public void setSupportedContentTypes(@C.ContentType int... contentTypes) { List supportedMimeTypes = new ArrayList<>(); for (@C.ContentType int contentType : contentTypes) { + // IMA does not support Smooth Streaming ad media. if (contentType == C.TYPE_DASH) { supportedMimeTypes.add(MimeTypes.APPLICATION_MPD); } else if (contentType == C.TYPE_HLS) { @@ -575,8 +708,6 @@ public void setSupportedContentTypes(@C.ContentType int... contentTypes) { MimeTypes.VIDEO_H263, MimeTypes.AUDIO_MP4, MimeTypes.AUDIO_MPEG)); - } else if (contentType == C.TYPE_SS) { - // IMA does not support Smooth Streaming ad media. } } this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes); @@ -584,80 +715,104 @@ public void setSupportedContentTypes(@C.ContentType int... contentTypes) { @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) { return; } - this.eventListener = eventListener; - lastVolumePercentage = 0; - lastAdProgress = null; - lastContentProgress = null; - ViewGroup adViewGroup = adViewProvider.getAdViewGroup(); - adDisplayContainer.setAdContainer(adViewGroup); - View[] adOverlayViews = adViewProvider.getAdOverlayViews(); - for (View view : adOverlayViews) { - adDisplayContainer.registerVideoControlsOverlay(view); - } player.addListener(this); + boolean playWhenReady = player.getPlayWhenReady(); + this.eventListener = eventListener; + lastVolumePercent = 0; + lastAdProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; + lastContentProgress = VideoProgressUpdate.VIDEO_TIME_NOT_READY; maybeNotifyPendingAdLoadError(); - if (adPlaybackState != null) { + if (hasAdPlaybackState) { // Pass the ad playback state to the player, and resume ads if necessary. eventListener.onAdPlaybackState(adPlaybackState); - if (imaPausedContent && player.getPlayWhenReady()) { + if (adsManager != null && imaPausedContent && playWhenReady) { adsManager.resume(); } } else if (adsManager != null) { - 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)); + } } } @Override public void stop() { + @Nullable Player player = this.player; if (player == null) { return; } if (adsManager != null && imaPausedContent) { + adsManager.pause(); adPlaybackState = adPlaybackState.withAdResumePositionUs( playingAd ? C.msToUs(player.getCurrentPosition()) : 0); - adsManager.pause(); } - lastVolumePercentage = getVolume(); - lastAdProgress = getAdProgress(); - lastContentProgress = getContentProgress(); - adDisplayContainer.unregisterAllVideoControlsOverlays(); + lastVolumePercent = getPlayerVolumePercent(); + lastAdProgress = getAdVideoProgressUpdate(); + lastContentProgress = getContentVideoProgressUpdate(); + if (adDisplayContainer != null) { + adDisplayContainer.unregisterAllFriendlyObstructions(); + } player.removeListener(this); - player = null; + this.player = null; eventListener = null; } @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 = 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) { @@ -665,87 +820,179 @@ public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOExcepti } try { handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception); - } catch (Exception e) { + } catch (RuntimeException e) { maybeNotifyInternalError("handlePrepareError", e); } } - // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation. + // Player.EventListener implementation. @Override - public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) { - AdsManager adsManager = adsManagerLoadedEvent.getAdsManager(); - if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) { - adsManager.destroy(); + public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + if (timeline.isEmpty()) { + // The player is being reset or contains no media. 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())); - updateAdPlaybackState(); - } catch (Exception e) { - maybeNotifyInternalError("onAdsManagerLoaded", e); + checkArgument(timeline.getPeriodCount() == 1); + this.timeline = timeline; + long contentDurationUs = timeline.getPeriod(/* periodIndex= */ 0, period).durationUs; + contentDurationMs = C.usToMs(contentDurationUs); + if (contentDurationUs != C.TIME_UNSET) { + adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs); + } + @Nullable AdsManager adsManager = this.adsManager; + if (!isAdsManagerInitialized && adsManager != null) { + isAdsManagerInitialized = true; + @Nullable AdsRenderingSettings adsRenderingSettings = setupAdsRendering(); + if (adsRenderingSettings == null) { + // There are no ads to play. + destroyAdsManager(); + } else { + adsManager.init(adsRenderingSettings); + adsManager.start(); + if (DEBUG) { + Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); + } } + updateAdPlaybackState(); } + handleTimelineOrPositionChanged(); } - // AdEvent.AdEventListener implementation. + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + handleTimelineOrPositionChanged(); + } @Override - public void onAdEvent(AdEvent adEvent) { - AdEventType adEventType = adEvent.getType(); - if (DEBUG) { - Log.d(TAG, "onAdEvent: " + adEventType); - } - if (adsManager == null) { - Log.w(TAG, "Ignoring AdEvent after release: " + adEvent); + public void onPlaybackStateChanged(@Player.State int playbackState) { + @Nullable Player player = this.player; + if (adsManager == null || player == null) { return; } - try { - handleAdEvent(adEvent); - } catch (Exception e) { - maybeNotifyInternalError("onAdEvent", e); + + 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; } - } - // AdErrorEvent.AdErrorListener implementation. + handlePlayerStateChanged(player.getPlayWhenReady(), playbackState); + } @Override - public void onAdError(AdErrorEvent adErrorEvent) { - AdError error = adErrorEvent.getError(); - if (DEBUG) { - Log.d(TAG, "onAdError", error); + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + if (adsManager == null || player == null) { + return; } - if (adsManager == null) { - // No ads were loaded, so allow playback to start without any ads. - pendingAdRequestContext = null; - adPlaybackState = new AdPlaybackState(); - updateAdPlaybackState(); - } else if (isAdGroupLoadError(error)) { - try { - handleAdGroupLoadError(error); - } catch (Exception e) { - maybeNotifyInternalError("onAdError", e); - } + + if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { + adsManager.pause(); + return; } - if (pendingAdLoadError == null) { - pendingAdLoadError = AdLoadException.createForAllAds(error); + + if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) { + adsManager.resume(); + return; } - maybeNotifyPendingAdLoadError(); + handlePlayerStateChanged(playWhenReady, player.getPlaybackState()); } - // ContentProgressProvider implementation. - @Override - public VideoProgressUpdate getContentProgress() { + public void onPlayerError(ExoPlaybackException error) { + if (imaAdState != IMA_AD_STATE_NONE) { + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onError(adMediaInfo); + } + } + } + + // Internal methods. + + /** + * Configures ads rendering for starting playback, returning the settings for the IMA SDK or + * {@code null} if no ads should play. + */ + @Nullable + private AdsRenderingSettings setupAdsRendering() { + AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); + adsRenderingSettings.setEnablePreloading(true); + adsRenderingSettings.setMimeTypes(supportedMimeTypes); + if (mediaLoadTimeoutMs != TIMEOUT_UNSET) { + adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs); + } + if (mediaBitrate != BITRATE_UNSET) { + adsRenderingSettings.setBitrateKbps(mediaBitrate / 1000); + } + adsRenderingSettings.setFocusSkipButtonWhenAvailable(focusSkipButtonWhenAvailable); + if (adUiElements != null) { + adsRenderingSettings.setUiElements(adUiElements); + } + + // Skip ads based on the start position as required. + long[] adGroupTimesUs = adPlaybackState.adGroupTimesUs; + long contentPositionMs = getContentPeriodPositionMs(checkNotNull(player), timeline, period); + int adGroupForPositionIndex = + adPlaybackState.getAdGroupIndexForPositionUs( + C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); + if (adGroupForPositionIndex != C.INDEX_UNSET) { + boolean playAdWhenStartingPlayback = + playAdBeforeStartPosition + || adGroupTimesUs[adGroupForPositionIndex] == C.msToUs(contentPositionMs); + if (!playAdWhenStartingPlayback) { + adGroupForPositionIndex++; + } else if (hasMidrollAdGroups(adGroupTimesUs)) { + // Provide the player's initial position to trigger loading and playing the ad. If there are + // no midrolls, we are playing a preroll and any pending content position wouldn't be + // cleared. + pendingContentPositionMs = contentPositionMs; + } + if (adGroupForPositionIndex > 0) { + for (int i = 0; i < adGroupForPositionIndex; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); + } + if (adGroupForPositionIndex == adGroupTimesUs.length) { + // We don't need to play any ads. Because setPlayAdsAfterTime does not discard non-VMAP + // ads, we signal that no ads will render so the caller can destroy the ads manager. + return null; + } + long adGroupForPositionTimeUs = adGroupTimesUs[adGroupForPositionIndex]; + long adGroupBeforePositionTimeUs = adGroupTimesUs[adGroupForPositionIndex - 1]; + if (adGroupForPositionTimeUs == C.TIME_END_OF_SOURCE) { + // Play the postroll by offsetting the start position just past the last non-postroll ad. + adsRenderingSettings.setPlayAdsAfterTime( + (double) adGroupBeforePositionTimeUs / C.MICROS_PER_SECOND + 1d); + } else { + // Play ads after the midpoint between the ad to play and the one before it, to avoid + // issues with rounding one of the two ad times. + double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforePositionTimeUs) / 2d; + adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND); + } + } + } + return adsRenderingSettings; + } + + private VideoProgressUpdate getContentVideoProgressUpdate() { if (player == null) { return lastContentProgress; } @@ -754,32 +1001,11 @@ public VideoProgressUpdate getContentProgress() { if (pendingContentPositionMs != C.TIME_UNSET) { sentPendingContentPositionMs = true; contentPositionMs = pendingContentPositionMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) { long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs; contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs; - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) { - contentPositionMs = getContentPeriodPositionMs(); - // Update the expected ad group index for the current content position. The update is delayed - // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered - // just after an ad group isn't incorrectly attributed to the next ad group. - int nextAdGroupIndex = - adPlaybackState.getAdGroupIndexAfterPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (nextAdGroupIndex != expectedAdGroupIndex && nextAdGroupIndex != C.INDEX_UNSET) { - long nextAdGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[nextAdGroupIndex]); - if (nextAdGroupTimeMs == C.TIME_END_OF_SOURCE) { - nextAdGroupTimeMs = contentDurationMs; - } - if (nextAdGroupTimeMs - contentPositionMs < MAXIMUM_PRELOAD_DURATION_MS) { - expectedAdGroupIndex = nextAdGroupIndex; - } - } + contentPositionMs = getContentPeriodPositionMs(player, timeline, period); } else { return VideoProgressUpdate.VIDEO_TIME_NOT_READY; } @@ -787,28 +1013,40 @@ public VideoProgressUpdate getContentProgress() { return new VideoProgressUpdate(contentPositionMs, contentDurationMs); } - // VideoAdPlayer implementation. - - @Override - public VideoProgressUpdate getAdProgress() { + 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 + return adDuration == C.TIME_UNSET + ? VideoProgressUpdate.VIDEO_TIME_NOT_READY : new VideoProgressUpdate(player.getCurrentPosition(), adDuration); } else { return VideoProgressUpdate.VIDEO_TIME_NOT_READY; } } - @Override - public int getVolume() { + 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 lastVolumePercentage; + return lastVolumePercent; } - Player.AudioComponent audioComponent = player.getAudioComponent(); + @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); if (audioComponent != null) { return (int) (audioComponent.getVolume() * 100); } @@ -823,297 +1061,23 @@ public int getVolume() { return 0; } - @Override - public void loadAd(String adUriString) { - try { - if (DEBUG) { - Log.d(TAG, "loadAd in ad group " + adGroupIndex); - } - if (adsManager == null) { - Log.w(TAG, "Ignoring loadAd after release"); - return; - } - if (adGroupIndex == C.INDEX_UNSET) { - Log.w( - TAG, - "Unexpected loadAd without LOADED event; assuming ad group index is actually " - + expectedAdGroupIndex); - adGroupIndex = expectedAdGroupIndex; - adsManager.start(); - } - int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex); - if (adIndexInAdGroup == C.INDEX_UNSET) { - Log.w(TAG, "Unexpected loadAd in an ad group with no remaining unavailable ads"); - return; - } - adPlaybackState = - adPlaybackState.withAdUri(adGroupIndex, adIndexInAdGroup, Uri.parse(adUriString)); - 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() { - if (DEBUG) { - Log.d(TAG, "playAd"); - } + private void handleAdEvent(AdEvent adEvent) { if (adsManager == null) { - Log.w(TAG, "Ignoring playAd after release"); + // Drop events after release. return; } - switch (imaAdState) { - case IMA_AD_STATE_PLAYING: - // IMA does not always call stopAd before resuming content. - // See [Internal: b/38354028, b/63320878]. - Log.w(TAG, "Unexpected playAd without stopAd"); - break; - case IMA_AD_STATE_NONE: - // IMA is requesting to play the ad, so stop faking the content position. - fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET; - fakeContentProgressOffsetMs = C.TIME_UNSET; - imaAdState = IMA_AD_STATE_PLAYING; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPlay(); - } - if (shouldNotifyAdPrepareError) { - shouldNotifyAdPrepareError = false; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); - } - } - break; - case IMA_AD_STATE_PAUSED: - imaAdState = IMA_AD_STATE_PLAYING; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onResume(); - } - break; - default: - throw new IllegalStateException(); - } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected playAd while detached"); - } else if (!player.getPlayWhenReady()) { - adsManager.pause(); - } - } - - @Override - public void stopAd() { - if (DEBUG) { - Log.d(TAG, "stopAd"); - } - if (adsManager == null) { - Log.w(TAG, "Ignoring stopAd after release"); - return; - } - if (player == null) { - // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642]. - Log.w(TAG, "Unexpected stopAd while detached"); - } - if (imaAdState == IMA_AD_STATE_NONE) { - Log.w(TAG, "Unexpected stopAd"); - return; - } - try { - stopAdInternal(); - } catch (Exception e) { - maybeNotifyInternalError("stopAd", e); - } - } - - @Override - public void pauseAd() { - if (DEBUG) { - Log.d(TAG, "pauseAd"); - } - if (imaAdState == IMA_AD_STATE_NONE) { - // This method is called after content is resumed. - return; - } - imaAdState = IMA_AD_STATE_PAUSED; - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onPause(); - } - } - - @Override - public void resumeAd() { - // This method is never called. See [Internal: b/18931719]. - maybeNotifyInternalError("resumeAd", new IllegalStateException("Unexpected call to resumeAd")); - } - - // Player.EventListener implementation. - - @Override - public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { - if (timeline.isEmpty()) { - // The player is being reset or contains no media. - return; - } - Assertions.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(); - } - checkForContentCompleteOrNewAdGroup(); - } - - @Override - public void onPlaybackStateChanged(@Player.State int playbackState) { - if (adsManager == null || player == null) { - return; - } - handlePlayerStateChanged(player.getPlayWhenReady(), playbackState); - } - - @Override - public void onPlayWhenReadyChanged( - boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { - if (adsManager == null || player == null) { - return; - } - - if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) { - adsManager.pause(); - return; - } - - if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) { - adsManager.resume(); - return; - } - handlePlayerStateChanged(playWhenReady, player.getPlaybackState()); - } - - @Override - public void onPlayerError(ExoPlaybackException error) { - if (imaAdState != IMA_AD_STATE_NONE) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); - } - } - } - - @Override - public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { - checkForContentCompleteOrNewAdGroup(); - } - - // Internal methods. - - private void initializeAdsManager() { - AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings(); - adsRenderingSettings.setEnablePreloading(true); - adsRenderingSettings.setMimeTypes(supportedMimeTypes); - if (mediaLoadTimeoutMs != TIMEOUT_UNSET) { - adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs); - } - if (mediaBitrate != BITRATE_UNSET) { - adsRenderingSettings.setBitrateKbps(mediaBitrate / 1000); - } - adsRenderingSettings.setFocusSkipButtonWhenAvailable(focusSkipButtonWhenAvailable); - if (adUiElements != null) { - adsRenderingSettings.setUiElements(adUiElements); - } - - // Skip ads based on the start position as required. - long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints()); - long contentPositionMs = getContentPeriodPositionMs(); - int adGroupIndexForPosition = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentPositionMs), C.msToUs(contentDurationMs)); - if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) { - // Skip any ad groups before the one at or immediately before the playback position. - for (int i = 0; i < adGroupIndexForPosition; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } - // 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); - } - - // IMA indexes any remaining midroll ad pods from 1. A preroll (if present) has index 0. - // Store an index offset as we want to index all ads (including skipped ones) from 0. - if (adGroupIndexForPosition == 0 && adGroupTimesUs[0] == 0) { - // We are playing a preroll. - podIndexOffset = 0; - } else if (adGroupIndexForPosition == C.INDEX_UNSET) { - // There's no ad to play which means there's no preroll. - podIndexOffset = -1; - } else { - // We are playing a midroll and any ads before it were skipped. - podIndexOffset = adGroupIndexForPosition - 1; - } - - if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) { - // Provide the player's initial position to trigger loading and playing the ad. - pendingContentPositionMs = contentPositionMs; - } - - adsManager.init(adsRenderingSettings); - updateAdPlaybackState(); - if (DEBUG) { - Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings); - } - } - - private void handleAdEvent(AdEvent adEvent) { - Ad ad = adEvent.getAd(); - switch (adEvent.getType()) { - case LOADED: - // The ad position is not always accurate when using preloading. See [Internal: b/62613240]. - AdPodInfo adPodInfo = ad.getAdPodInfo(); - int podIndex = adPodInfo.getPodIndex(); - adGroupIndex = - podIndex == -1 ? (adPlaybackState.adGroupCount - 1) : (podIndex + podIndexOffset); - int adPosition = adPodInfo.getAdPosition(); - int adCount = adPodInfo.getTotalAds(); - adsManager.start(); - if (DEBUG) { - Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex); - } - int oldAdCount = adPlaybackState.adGroups[adGroupIndex].count; - if (adCount != oldAdCount) { - if (oldAdCount == C.LENGTH_UNSET) { - adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, adCount); - updateAdPlaybackState(); - } else { - // IMA sometimes unexpectedly decreases the ad count in an ad group. - Log.w(TAG, "Unexpected ad count in LOADED, " + adCount + ", expected " + oldAdCount); - } - } - if (adGroupIndex != expectedAdGroupIndex) { - Log.w( - TAG, - "Expected ad group index " - + expectedAdGroupIndex - + ", actual ad group index " - + adGroupIndex); - expectedAdGroupIndex = adGroupIndex; - } + switch (adEvent.getType()) { + case AD_BREAK_FETCH_ERROR: + String adGroupTimeSecondsString = checkNotNull(adEvent.getAdData().get("adBreakTime")); + if (DEBUG) { + Log.d(TAG, "Fetch error for ad at " + adGroupTimeSecondsString + " seconds"); + } + double adGroupTimeSeconds = Double.parseDouble(adGroupTimeSecondsString); + int adGroupIndex = + adGroupTimeSeconds == -1.0 + ? adPlaybackState.adGroupCount - 1 + : getAdGroupIndexForCuePointTimeSeconds(adGroupTimeSeconds); + handleAdGroupFetchError(adGroupIndex); break; case CONTENT_PAUSE_REQUESTED: // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads @@ -1139,25 +1103,58 @@ private void handleAdEvent(AdEvent adEvent) { Map adData = adEvent.getAdData(); String message = "AdEvent: " + adData; Log.i(TAG, message); - if ("adLoadError".equals(adData.get("type"))) { - handleAdGroupLoadError(new IOException(message)); - } break; - case STARTED: - case ALL_ADS_COMPLETED: default: break; } } + private void pauseContentInternal() { + imaAdState = IMA_AD_STATE_NONE; + if (sentPendingContentPositionMs) { + pendingContentPositionMs = C.TIME_UNSET; + sentPendingContentPositionMs = false; + } + } + + private void resumeContentInternal() { + if (imaAdInfo != null) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); + updateAdPlaybackState(); + } else 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(); + } + } + private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + if (playingAd && imaAdState == IMA_AD_STATE_PLAYING) { + if (!bufferingAd && playbackState == Player.STATE_BUFFERING) { + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onBuffering(adMediaInfo); + } + stopUpdatingAdProgress(); + } else if (bufferingAd && playbackState == Player.STATE_READY) { + bufferingAd = false; + updateAdProgress(); + } + } + if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING && playWhenReady) { - checkForContentComplete(); + ensureSentContentCompleteIfAtEndOfStream(); } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) { - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } } if (DEBUG) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlaybackStateChanged"); @@ -1165,36 +1162,24 @@ private void handlePlayerStateChanged(boolean playWhenReady, @Player.State int p } } - private void checkForContentCompleteOrNewAdGroup() { + private void handleTimelineOrPositionChanged() { + @Nullable Player player = this.player; if (adsManager == null || player == null) { return; } if (!playingAd && !player.isPlayingAd()) { - checkForContentComplete(); - if (sentContentComplete) { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ i); - } - } - updateAdPlaybackState(); - } else if (!timeline.isEmpty()) { - long positionMs = getContentPeriodPositionMs(); + ensureSentContentCompleteIfAtEndOfStream(); + if (!sentContentComplete && !timeline.isEmpty()) { + long positionMs = getContentPeriodPositionMs(player, timeline, period); timeline.getPeriod(/* periodIndex= */ 0, period); int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs)); if (newAdGroupIndex != C.INDEX_UNSET) { sentPendingContentPositionMs = false; pendingContentPositionMs = positionMs; - if (newAdGroupIndex != adGroupIndex) { - shouldNotifyAdPrepareError = false; - } } } } - updateImaStateForPlayerState(); - } - private void updateImaStateForPlayerState() { boolean wasPlayingAd = playingAd; int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup; playingAd = player.isPlayingAd(); @@ -1203,8 +1188,13 @@ private void updateImaStateForPlayerState() { if (adFinished) { // IMA is waiting for the ad playback to finish so invoke the callback now. // Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again. - for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + @Nullable AdMediaInfo adMediaInfo = imaAdMediaInfo; + if (adMediaInfo == null) { + Log.w(TAG, "onEnded without ad media info"); + } else { + for (int i = 0; i < adCallbacks.size(); i++) { + adCallbacks.get(i).onEnded(adMediaInfo); + } } if (DEBUG) { Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity"); @@ -1212,60 +1202,200 @@ private void updateImaStateForPlayerState() { } 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 (imaAdState != IMA_AD_STATE_NONE) { - imaAdState = IMA_AD_STATE_NONE; + private void loadAdInternal(AdMediaInfo adMediaInfo, AdPodInfo adPodInfo) { + if (adsManager == null) { + // Drop events after release. if (DEBUG) { - Log.d(TAG, "Unexpected CONTENT_RESUME_REQUESTED without stopAd"); + Log.d( + TAG, + "loadAd after release " + getAdMediaInfoString(adMediaInfo) + ", ad pod " + adPodInfo); } + return; } - if (adGroupIndex != C.INDEX_UNSET) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex); - adGroupIndex = C.INDEX_UNSET; - updateAdPlaybackState(); + + 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 pauseContentInternal() { - imaAdState = IMA_AD_STATE_NONE; - if (sentPendingContentPositionMs) { - pendingContentPositionMs = C.TIME_UNSET; - sentPendingContentPositionMs = false; + 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 stopAdInternal() { + 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(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; - int adIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); + stopUpdatingAdProgress(); // TODO: Handle the skipped event so the ad can be marked as skipped rather than played. + checkNotNull(imaAdInfo); + int adGroupIndex = imaAdInfo.adGroupIndex; + int adIndexInAdGroup = imaAdInfo.adIndexInAdGroup; + if (adPlaybackState.isAdInErrorState(adGroupIndex, adIndexInAdGroup)) { + // We have already marked this ad as having failed to load, so ignore the request. + return; + } adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0); updateAdPlaybackState(); if (!playingAd) { - adGroupIndex = C.INDEX_UNSET; + imaAdMediaInfo = null; + imaAdInfo = null; + } + } + + 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) { - int adGroupIndex = - this.adGroupIndex == C.INDEX_UNSET ? expectedAdGroupIndex : this.adGroupIndex; + if (player == null) { + return; + } + + // TODO: Once IMA signals which ad group failed to load, remove this call. + int adGroupIndex = getLoadingAdGroupIndex(); if (adGroupIndex == C.INDEX_UNSET) { - // Drop the error, as we don't know which ad group it relates to. + 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++) { @@ -1301,41 +1431,51 @@ private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Except if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) { fakeContentProgressOffsetMs = contentDurationMs; } - shouldNotifyAdPrepareError = true; + pendingAdPrepareErrorAdInfo = new AdInfo(adGroupIndex, adIndexInAdGroup); } else { + AdMediaInfo adMediaInfo = checkNotNull(imaAdMediaInfo); // We're already playing an ad. if (adIndexInAdGroup > playingAdIndexInAdGroup) { // Mark the playing ad as ended so we can notify the error on the next ad and remove it, // which means that the ad after will load (if any). for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onEnded(); + adCallbacks.get(i).onEnded(adMediaInfo); } } playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); for (int i = 0; i < adCallbacks.size(); i++) { - adCallbacks.get(i).onError(); + adCallbacks.get(i).onError(checkNotNull(adMediaInfo)); } } adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup); updateAdPlaybackState(); } - private void checkForContentComplete() { - if (contentDurationMs != C.TIME_UNSET + private void ensureSentContentCompleteIfAtEndOfStream() { + if (!sentContentComplete + && contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET - && getContentPeriodPositionMs() + END_OF_CONTENT_POSITION_THRESHOLD_MS >= contentDurationMs - && !sentContentComplete) { - adsLoader.contentComplete(); - if (DEBUG) { - Log.d(TAG, "adsLoader.contentComplete"); + && 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); } - sentContentComplete = true; - // After sending content complete IMA will not poll the content position, so set the expected - // ad group index. - expectedAdGroupIndex = - adPlaybackState.getAdGroupIndexForPositionUs( - C.msToUs(contentDurationMs), C.msToUs(contentDurationMs)); } + updateAdPlaybackState(); } private void updateAdPlaybackState() { @@ -1345,24 +1485,9 @@ private void updateAdPlaybackState() { } } - /** - * Returns the next ad index in the specified ad group to load, or {@link C#INDEX_UNSET} if all - * ads in the ad group have loaded. - */ - private int getAdIndexInAdGroupToLoad(int adGroupIndex) { - @AdState int[] states = adPlaybackState.adGroups[adGroupIndex].states; - int adIndexInAdGroup = 0; - // IMA loads ads in order. - while (adIndexInAdGroup < states.length - && states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE) { - adIndexInAdGroup++; - } - return adIndexInAdGroup == states.length ? C.INDEX_UNSET : adIndexInAdGroup; - } - private void maybeNotifyPendingAdLoadError() { if (pendingAdLoadError != null && eventListener != null) { - eventListener.onAdLoadError(pendingAdLoadError, new DataSpec(adTagUri)); + eventListener.onAdLoadError(pendingAdLoadError, getAdsDataSpec(adTagUri)); pendingAdLoadError = null; } } @@ -1371,47 +1496,90 @@ private void maybeNotifyInternalError(String name, Exception cause) { String message = "Internal error in " + name; Log.e(TAG, message, cause); // We can't recover from an unexpected error in general, so skip all remaining ads. - if (adPlaybackState == null) { - adPlaybackState = AdPlaybackState.NONE; - } else { - for (int i = 0; i < adPlaybackState.adGroupCount; i++) { - adPlaybackState = adPlaybackState.withSkippedAdGroup(i); - } + for (int i = 0; i < adPlaybackState.adGroupCount; i++) { + adPlaybackState = adPlaybackState.withSkippedAdGroup(i); } updateAdPlaybackState(); if (eventListener != null) { eventListener.onAdLoadError( AdLoadException.createForUnexpected(new RuntimeException(message, cause)), - new DataSpec(adTagUri)); + getAdsDataSpec(adTagUri)); } } - private long getContentPeriodPositionMs() { - long contentWindowPositionMs = player.getContentPosition(); - return contentWindowPositionMs - - timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs(); + 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()); } - private static long[] getAdGroupTimesUs(List cuePoints) { - if (cuePoints.isEmpty()) { - // If no cue points are specified, there is a preroll ad. - return new long[] {0}; + /** + * 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; + } - 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); + 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; } } - // Cue points may be out of order, so sort them. - Arrays.sort(adGroupTimesUs, 0, adGroupIndex); - return adGroupTimesUs; + 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); + } + + private static long getContentPeriodPositionMs( + Player player, Timeline timeline, Timeline.Period period) { + long contentWindowPositionMs = player.getContentPosition(); + return contentWindowPositionMs + - (timeline.isEmpty() + ? 0 + : timeline.getPeriod(/* periodIndex= */ 0, period).getPositionInWindowMs()); } private static boolean isAdGroupLoadError(AdError adError) { @@ -1421,6 +1589,12 @@ private static boolean isAdGroupLoadError(AdError adError) { || adError.getErrorCode() == AdErrorCode.UNKNOWN_ERROR; } + private static Looper getImaLooper() { + // IMA SDK callbacks occur on the main thread. This method can be used to check that the player + // is using the same looper, to ensure all interaction with this class is on the main thread. + return Looper.getMainLooper(); + } + private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { int count = adGroupTimesUs.length; if (count == 1) { @@ -1433,22 +1607,267 @@ private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) { } } + private void destroyAdsManager() { + if (adsManager != null) { + adsManager.removeAdErrorListener(componentListener); + if (adErrorListener != null) { + adsManager.removeAdErrorListener(adErrorListener); + } + adsManager.removeAdEventListener(componentListener); + if (adEventListener != null) { + adsManager.removeAdEventListener(adEventListener); + } + 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 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. + private static final class AdInfo { + public final int adGroupIndex; + public final int adIndexInAdGroup; + + public AdInfo(int adGroupIndex, int adIndexInAdGroup) { + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdInfo adInfo = (AdInfo) o; + if (adGroupIndex != adInfo.adGroupIndex) { + return false; + } + return adIndexInAdGroup == adInfo.adIndexInAdGroup; + } + + @Override + public int hashCode() { + int result = adGroupIndex; + result = 31 * result + adIndexInAdGroup; + return result; + } + + @Override + public String toString() { + return "(" + adGroupIndex + ", " + adIndexInAdGroup + ')'; + } + } + /** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */ private static final class DefaultImaFactory implements ImaFactory { @Override @@ -1462,8 +1881,25 @@ public AdsRenderingSettings createAdsRenderingSettings() { } @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 @@ -1472,7 +1908,7 @@ public AdsRequest createAdsRequest() { } @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/FakeAd.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java deleted file mode 100644 index 59dfc6473cb..00000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.Ad; -import com.google.ads.interactivemedia.v3.api.AdPodInfo; -import com.google.ads.interactivemedia.v3.api.CompanionAd; -import com.google.ads.interactivemedia.v3.api.UiElement; -import java.util.List; -import java.util.Set; - -/** A fake ad for testing. */ -/* package */ final class FakeAd implements Ad { - - private final boolean skippable; - private final AdPodInfo adPodInfo; - - public FakeAd(boolean skippable, int podIndex, int totalAds, int adPosition) { - this.skippable = skippable; - adPodInfo = - new AdPodInfo() { - @Override - public int getTotalAds() { - return totalAds; - } - - @Override - public int getAdPosition() { - return adPosition; - } - - @Override - public int getPodIndex() { - return podIndex; - } - - @Override - public boolean isBumper() { - throw new UnsupportedOperationException(); - } - - @Override - public double getMaxDuration() { - throw new UnsupportedOperationException(); - } - - @Override - public double getTimeOffset() { - throw new UnsupportedOperationException(); - } - }; - } - - @Override - public int getVastMediaWidth() { - throw new UnsupportedOperationException(); - } - - @Override - public int getVastMediaHeight() { - throw new UnsupportedOperationException(); - } - - @Override - public int getVastMediaBitrate() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isSkippable() { - return skippable; - } - - @Override - public AdPodInfo getAdPodInfo() { - return adPodInfo; - } - - @Override - public String getAdId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getCreativeId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getCreativeAdId() { - throw new UnsupportedOperationException(); - } - - @Override - public String getUniversalAdIdValue() { - throw new UnsupportedOperationException(); - } - - @Override - public String getUniversalAdIdRegistry() { - throw new UnsupportedOperationException(); - } - - @Override - public String getAdSystem() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperIds() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperSystems() { - throw new UnsupportedOperationException(); - } - - @Override - public String[] getAdWrapperCreativeIds() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isLinear() { - throw new UnsupportedOperationException(); - } - - @Override - public double getSkipTimeOffset() { - throw new UnsupportedOperationException(); - } - - @Override - public boolean isUiDisabled() { - throw new UnsupportedOperationException(); - } - - @Override - public String getDescription() { - throw new UnsupportedOperationException(); - } - - @Override - public String getTitle() { - throw new UnsupportedOperationException(); - } - - @Override - public String getContentType() { - throw new UnsupportedOperationException(); - } - - @Override - public String getAdvertiserName() { - throw new UnsupportedOperationException(); - } - - @Override - public String getSurveyUrl() { - throw new UnsupportedOperationException(); - } - - @Override - public String getDealId() { - throw new UnsupportedOperationException(); - } - - @Override - public int getWidth() { - throw new UnsupportedOperationException(); - } - - @Override - public int getHeight() { - throw new UnsupportedOperationException(); - } - - @Override - public String getTraffickingParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public double getDuration() { - throw new UnsupportedOperationException(); - } - - @Override - public Set getUiElements() { - throw new UnsupportedOperationException(); - } - - @Override - public List getCompanionAds() { - throw new UnsupportedOperationException(); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java deleted file mode 100644 index a8f3daae335..00000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; -import com.google.ads.interactivemedia.v3.api.AdsManager; -import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; -import com.google.ads.interactivemedia.v3.api.StreamManager; -import com.google.ads.interactivemedia.v3.api.StreamRequest; -import com.google.android.exoplayer2.util.Assertions; -import java.util.ArrayList; - -/** Fake {@link com.google.ads.interactivemedia.v3.api.AdsLoader} implementation for tests. */ -public final class FakeAdsLoader implements com.google.ads.interactivemedia.v3.api.AdsLoader { - - private final ImaSdkSettings imaSdkSettings; - private final AdsManager adsManager; - private final ArrayList adsLoadedListeners; - private final ArrayList adErrorListeners; - - public FakeAdsLoader(ImaSdkSettings imaSdkSettings, AdsManager adsManager) { - this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings); - this.adsManager = Assertions.checkNotNull(adsManager); - adsLoadedListeners = new ArrayList<>(); - adErrorListeners = new ArrayList<>(); - } - - @Override - public void contentComplete() { - // Do nothing. - } - - @Override - public ImaSdkSettings getSettings() { - return imaSdkSettings; - } - - @Override - public void requestAds(AdsRequest adsRequest) { - for (AdsLoadedListener listener : adsLoadedListeners) { - listener.onAdsManagerLoaded( - new AdsManagerLoadedEvent() { - @Override - public AdsManager getAdsManager() { - return adsManager; - } - - @Override - public StreamManager getStreamManager() { - throw new UnsupportedOperationException(); - } - - @Override - public Object getUserRequestContext() { - return adsRequest.getUserRequestContext(); - } - }); - } - } - - @Override - public String requestStream(StreamRequest streamRequest) { - throw new UnsupportedOperationException(); - } - - @Override - public void addAdsLoadedListener(AdsLoadedListener adsLoadedListener) { - adsLoadedListeners.add(adsLoadedListener); - } - - @Override - public void removeAdsLoadedListener(AdsLoadedListener adsLoadedListener) { - adsLoadedListeners.remove(adsLoadedListener); - } - - @Override - public void addAdErrorListener(AdErrorListener adErrorListener) { - adErrorListeners.add(adErrorListener); - } - - @Override - public void removeAdErrorListener(AdErrorListener adErrorListener) { - adErrorListeners.remove(adErrorListener); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java deleted file mode 100644 index 7c2c8a6e0b1..00000000000 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.android.exoplayer2.ext.ima; - -import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; -import com.google.ads.interactivemedia.v3.api.AdsRequest; -import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider; -import java.util.List; -import java.util.Map; - -/** Fake {@link AdsRequest} implementation for tests. */ -public final class FakeAdsRequest implements AdsRequest { - - private String adTagUrl; - private String adsResponse; - private Object userRequestContext; - private AdDisplayContainer adDisplayContainer; - private ContentProgressProvider contentProgressProvider; - - @Override - public void setAdTagUrl(String adTagUrl) { - this.adTagUrl = adTagUrl; - } - - @Override - public String getAdTagUrl() { - return adTagUrl; - } - - @Override - public void setExtraParameter(String s, String s1) { - throw new UnsupportedOperationException(); - } - - @Override - public String getExtraParameter(String s) { - throw new UnsupportedOperationException(); - } - - @Override - public Map getExtraParameters() { - throw new UnsupportedOperationException(); - } - - @Override - public void setUserRequestContext(Object userRequestContext) { - this.userRequestContext = userRequestContext; - } - - @Override - public Object getUserRequestContext() { - return userRequestContext; - } - - @Override - public AdDisplayContainer getAdDisplayContainer() { - return adDisplayContainer; - } - - @Override - public void setAdDisplayContainer(AdDisplayContainer adDisplayContainer) { - this.adDisplayContainer = adDisplayContainer; - } - - @Override - public ContentProgressProvider getContentProgressProvider() { - return contentProgressProvider; - } - - @Override - public void setContentProgressProvider(ContentProgressProvider contentProgressProvider) { - this.contentProgressProvider = contentProgressProvider; - } - - @Override - public String getAdsResponse() { - return adsResponse; - } - - @Override - public void setAdsResponse(String adsResponse) { - this.adsResponse = adsResponse; - } - - @Override - public void setAdWillAutoPlay(boolean b) { - throw new UnsupportedOperationException(); - } - - @Override - public void setAdWillPlayMuted(boolean b) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentDuration(float v) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentKeywords(List list) { - throw new UnsupportedOperationException(); - } - - @Override - public void setContentTitle(String s) { - throw new UnsupportedOperationException(); - } - - @Override - public void setVastLoadTimeout(float v) { - throw new UnsupportedOperationException(); - } - - @Override - public void setLiveStreamPrefetchSeconds(float v) { - throw new UnsupportedOperationException(); - } -} diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 44395fa0d1b..e32a1992006 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -15,10 +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; @@ -27,22 +33,29 @@ 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; import com.google.ads.interactivemedia.v3.api.AdEvent; import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.AdsRequest; +import com.google.ads.interactivemedia.v3.api.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; @@ -50,21 +63,31 @@ 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; +import java.util.List; import java.util.Map; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.stubbing.Answer; +import org.robolectric.shadows.ShadowSystemClock; -/** Test for {@link ImaAdsLoader}. */ +/** Tests for {@link ImaAdsLoader}. */ @RunWith(AndroidJUnit4.class) -public class ImaAdsLoaderTest { +public final class ImaAdsLoaderTest { private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND; private static final Timeline CONTENT_TIMELINE = @@ -74,36 +97,39 @@ public class ImaAdsLoaderTest { private static final long CONTENT_PERIOD_DURATION_US = CONTENT_TIMELINE.getPeriod(/* periodIndex= */ 0, new Period()).durationUs; private static final Uri TEST_URI = Uri.EMPTY; + private static final AdMediaInfo TEST_AD_MEDIA_INFO = new AdMediaInfo(TEST_URI.toString()); private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND; - private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}}; - private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f}; - private static final FakeAd UNSKIPPABLE_AD = - new FakeAd(/* skippable= */ false, /* podIndex= */ 0, /* totalAds= */ 1, /* adPosition= */ 1); - - @Mock private ImaSdkSettings imaSdkSettings; - @Mock private AdsRenderingSettings adsRenderingSettings; - @Mock private AdDisplayContainer adDisplayContainer; - @Mock private AdsManager adsManager; + private static final ImmutableList PREROLL_CUE_POINTS_SECONDS = ImmutableList.of(0f); + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock private ImaSdkSettings mockImaSdkSettings; + @Mock private AdsRenderingSettings mockAdsRenderingSettings; + @Mock private AdDisplayContainer mockAdDisplayContainer; + @Mock private AdsManager mockAdsManager; + @Mock private AdsRequest mockAdsRequest; + @Mock private AdsManagerLoadedEvent mockAdsManagerLoadedEvent; + @Mock private com.google.ads.interactivemedia.v3.api.AdsLoader mockAdsLoader; + @Mock private 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; @Before public void setUp() { - MockitoAnnotations.initMocks(this); - FakeAdsRequest fakeAdsRequest = new FakeAdsRequest(); - FakeAdsLoader fakeAdsLoader = new FakeAdsLoader(imaSdkSettings, adsManager); - when(mockImaFactory.createAdDisplayContainer()).thenReturn(adDisplayContainer); - when(mockImaFactory.createAdsRenderingSettings()).thenReturn(adsRenderingSettings); - when(mockImaFactory.createAdsRequest()).thenReturn(fakeAdsRequest); - when(mockImaFactory.createImaSdkSettings()).thenReturn(imaSdkSettings); - when(mockImaFactory.createAdsLoader(any(), any(), any())).thenReturn(fakeAdsLoader); - adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext()); - adOverlayView = new View(ApplicationProvider.getApplicationContext()); + setupMocks(); + adViewGroup = new FrameLayout(getApplicationContext()); + View adOverlayView = new View(getApplicationContext()); adViewProvider = new AdsLoader.AdViewProvider() { @Override @@ -112,8 +138,21 @@ public ViewGroup getAdViewGroup() { } @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(); } }; } @@ -127,25 +166,37 @@ public void teardown() { @Test public void builder_overridesPlayerType() { - when(imaSdkSettings.getPlayerType()).thenReturn("test player type"); - setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS); + when(mockImaSdkSettings.getPlayerType()).thenReturn("test player type"); + setupPlayback(CONTENT_TIMELINE, PREROLL_CUE_POINTS_SECONDS); - verify(imaSdkSettings).setPlayerType("google/exo.ext.ima"); + 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(adDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup); - verify(adDisplayContainer, 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. @@ -154,26 +205,27 @@ public void start_withPlaceholderContent_initializedAdsLoader() { @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); @@ -184,47 +236,47 @@ public void startAndCallbacksAfterRelease() { // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA // SDK being proguarded. imaAdsLoader.requestAds(adViewGroup); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD)); - imaAdsLoader.loadAd(TEST_URI.toString()); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD)); - imaAdsLoader.playAd(); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD)); - imaAdsLoader.pauseAd(); - imaAdsLoader.stopAd(); + 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, UNSKIPPABLE_AD)); - imaAdsLoader.loadAd(TEST_URI.toString()); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD)); + 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(); + 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, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, UNSKIPPABLE_AD)); - imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, UNSKIPPABLE_AD)); + 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(); - 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) @@ -233,35 +285,556 @@ public void playback_withPrerollAd_marksAdAsPlayed() { .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(adDisplayContainer); - inOrder.verify(adDisplayContainer).registerVideoControlsOverlay(adOverlayView); - inOrder.verify(adDisplayContainer).unregisterAllVideoControlsOverlays(); + InOrder inOrder = inOrder(mockAdDisplayContainer); + 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(adsManager.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(imaSdkSettings) - .buildForAdTag(TEST_URI); + .setImaSdkSettings(mockImaSdkSettings) + .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); } + private void setupMocks() { + ArgumentCaptor userRequestContextCaptor = ArgumentCaptor.forClass(Object.class); + doNothing().when(mockAdsRequest).setUserRequestContext(userRequestContextCaptor.capture()); + when(mockAdsRequest.getUserRequestContext()) + .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)); + return null; + }) + .when(mockAdsLoader) + .addAdsLoadedListener(any()); + when(mockAdsManagerLoadedEvent.getAdsManager()).thenReturn(mockAdsManager); + when(mockAdsManagerLoadedEvent.getUserRequestContext()) + .thenAnswer(invocation -> mockAdsRequest.getUserRequestContext()); + doAnswer( + (Answer) + invocation -> { + for (com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener listener : + adsLoadedListeners) { + listener.onAdsManagerLoaded(mockAdsManagerLoadedEvent); + } + return null; + }) + .when(mockAdsLoader) + .requestAds(mockAdsRequest); + + 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); + when(mockAdPodInfo.getAdPosition()).thenReturn(1); + + when(mockPrerollSingleAd.getAdPodInfo()).thenReturn(mockAdPodInfo); + } + private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) { return new AdEvent() { @Override @@ -287,19 +860,21 @@ private static final class TestAdsLoaderListener implements AdsLoader.EventListe 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 613277bad2b..9e26c07c5da 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 05ac82ba089..df50cde8f91 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 8841f8355fc..b65988a5e21 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 @@ @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 JobDispatcherScheduler(Context context, String jobTag) { 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 @@ private static Job buildJob( 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 19b4cde3bf8..14ced09f121 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 e385cd52e9a..6538160b8b9 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 LeanbackPlayerAdapter(Context context, Player player, final int updatePer 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 00000000000..32ea8649406 --- /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 00000000000..744d79980b2 --- /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 00000000000..b699de67b16 --- /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 00000000000..8cf586b8462 --- /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 00000000000..23a44913891 --- /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 00000000000..df6963c2fc1 --- /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 00000000000..c578b0ba8c5 --- /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 00000000000..b80cbe5a5fa --- /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 00000000000..a7eb058ee62 --- /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 00000000000..1861e5e44e8 --- /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 00000000000..657faf77186 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 00000000000..b8d9236def5 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 00000000000..c29d88c21f8 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 00000000000..767bd5c6474 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 00000000000..571ff4459d0 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 00000000000..c51f109f97d 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 00000000000..3b87ee9dfa7 --- /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 00000000000..c23bdd56692 --- /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 00000000000..218c2a737e5 --- /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 00000000000..e7cc9545b15 --- /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 00000000000..fc80c85856f --- /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 00000000000..09e0325e936 --- /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 00000000000..1f60db947ed --- /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 00000000000..516ec20b3bc --- /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 00000000000..1c6cc151c95 --- /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 00000000000..873e35cc25e --- /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 00000000000..4003847b3fc --- /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 f32ef263e04..5c827084da7 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 913c1d86c07..85d0155bd77 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.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"; @@ -207,25 +208,25 @@ public interface PlaybackPreparer extends CommandReceiver { * * @param mediaId The media id of the media item to be prepared. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromMediaId(String mediaId, boolean playWhenReady, Bundle extras); + void onPrepareFromMediaId(String mediaId, boolean playWhenReady, @Nullable Bundle extras); /** * See {@link MediaSessionCompat.Callback#onPrepareFromSearch(String, Bundle)}. * * @param query The search query. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromSearch(String query, boolean playWhenReady, Bundle extras); + void onPrepareFromSearch(String query, boolean playWhenReady, @Nullable Bundle extras); /** * See {@link MediaSessionCompat.Callback#onPrepareFromUri(Uri, Bundle)}. * * @param uri The {@link Uri} of the media item to be prepared. * @param playWhenReady Whether playback should be started after preparation. - * @param extras A {@link Bundle} of extras passed by the media controller. + * @param extras A {@link Bundle} of extras passed by the media controller, may be null. */ - void onPrepareFromUri(Uri uri, boolean playWhenReady, Bundle extras); + void onPrepareFromUri(Uri uri, boolean playWhenReady, @Nullable Bundle extras); } /** @@ -325,7 +326,7 @@ public interface RatingCallback extends CommandReceiver { void onSetRating(Player player, RatingCompat rating); /** See {@link MediaSessionCompat.Callback#onSetRating(RatingCompat, Bundle)}. */ - void onSetRating(Player player, RatingCompat rating, Bundle extras); + void onSetRating(Player player, RatingCompat rating, @Nullable Bundle extras); } /** Handles requests for enabling or disabling captions. */ @@ -370,7 +371,7 @@ public interface CustomActionProvider { * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching * changes to the player. * @param action The name of the action which was sent by a media controller. - * @param extras Optional extras sent by a media controller. + * @param extras Optional extras sent by a media controller, may be null. */ void onCustomAction( Player player, ControlDispatcher controlDispatcher, String action, @Nullable Bundle extras); @@ -437,7 +438,7 @@ public interface MediaMetadataProvider { */ 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 void invalidateMediaSessionPlaybackState() { 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 @@ private static int getMediaSessionPlaybackState( @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 void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { } @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { invalidateMediaSessionPlaybackState(); } @@ -1284,42 +1287,42 @@ public void onPrepare() { } @Override - public void onPrepareFromMediaId(String mediaId, Bundle extras) { + public void onPrepareFromMediaId(String mediaId, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID)) { playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ false, extras); } } @Override - public void onPrepareFromSearch(String query, Bundle extras) { + public void onPrepareFromSearch(String query, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH)) { playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ false, extras); } } @Override - public void onPrepareFromUri(Uri uri, Bundle extras) { + public void onPrepareFromUri(Uri uri, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_URI)) { playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ false, extras); } } @Override - public void onPlayFromMediaId(String mediaId, Bundle extras) { + public void onPlayFromMediaId(String mediaId, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID)) { playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ true, extras); } } @Override - public void onPlayFromSearch(String query, Bundle extras) { + public void onPlayFromSearch(String query, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH)) { playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ true, extras); } } @Override - public void onPlayFromUri(Uri uri, Bundle extras) { + public void onPlayFromUri(Uri uri, @Nullable Bundle extras) { if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_URI)) { playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ true, extras); } @@ -1333,7 +1336,7 @@ public void onSetRating(RatingCompat rating) { } @Override - public void onSetRating(RatingCompat rating, Bundle extras) { + public void onSetRating(RatingCompat rating, @Nullable Bundle extras) { if (canDispatchSetRating()) { ratingCallback.onSetRating(player, rating, extras); } diff --git a/extensions/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 024faea2092..203479a7ed1 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 @@ private void publishFloatingQueueWindow(Player player) { 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 b03abac6709..f16e382aa1b 100644 --- a/extensions/okhttp/build.gradle +++ b/extensions/okhttp/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' - -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 // Since OkHttp is distributed as a jar rather than an aar, Gradle won't // stop us from making this mistake! - api 'com.squareup.okhttp3:okhttp:3.12.8' + api 'com.squareup.okhttp3:okhttp:3.12.11' } ext { diff --git a/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 fe2bdd672b0..57fee20d04d 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java @@ -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.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 OkHttpDataSource(Call.Factory callFactory, @Nullable String userAgent) { } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -110,6 +125,8 @@ public OkHttpDataSource( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -119,6 +136,7 @@ public OkHttpDataSource( * @deprecated Use {@link #OkHttpDataSource(Call.Factory, String)} and {@link * #setContentTypePredicate(Predicate)}. */ + @SuppressWarnings("deprecation") @Deprecated public OkHttpDataSource( Call.Factory callFactory, @@ -133,6 +151,8 @@ public OkHttpDataSource( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the source. * @param userAgent An optional User-Agent string. @@ -230,10 +250,18 @@ public long open(DataSpec dataSpec) throws HttpDataSourceException { // 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 long open(DataSpec dataSpec) throws HttpDataSourceException { // 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 @@ private void skipInternal() throws IOException { } 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 @@ private int readInternal(byte[] buffer, int offset, int readLength) throws IOExc 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 f3d74f92330..728428c8118 100644 --- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java +++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.okhttp; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; @@ -34,6 +36,18 @@ public final class OkHttpDataSourceFactory extends BaseFactory { @Nullable private final CacheControl cacheControl; /** + * Creates an instance. + * + * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use + * by the sources created by the factory. + */ + public OkHttpDataSourceFactory(Call.Factory callFactory) { + this(callFactory, DEFAULT_USER_AGENT, /* listener= */ null, /* cacheControl= */ null); + } + + /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -43,6 +57,8 @@ public OkHttpDataSourceFactory(Call.Factory callFactory, @Nullable String userAg } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -54,6 +70,8 @@ public OkHttpDataSourceFactory( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. @@ -65,6 +83,8 @@ public OkHttpDataSourceFactory( } /** + * Creates an instance. + * * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use * by the sources created by the factory. * @param userAgent An optional User-Agent string. diff --git a/extensions/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 393c048eecf..73e9909a8db 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); + + 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)); - // 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); + 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 545b5a7af89..ba670037f60 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 e4e392f2d35..c964b0cc1cb 100644 --- a/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java +++ b/extensions/opus/src/androidTest/java/com/google/android/exoplayer2/ext/opus/OpusPlaybackTest.java @@ -25,6 +25,7 @@ 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 @@ @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 void run() { 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 3917214afdc..603241486c2 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.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 LibopusAudioRenderer( 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 String getName() { @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, 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 @@ protected int supportsFormatInternal(Format format) { 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 @@ protected OpusDecoder createDecoder(Format format, @Nullable ExoMediaCrypto medi 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 87959506710..6b96cc5e49f 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 @@ 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 @@ * 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 @@ public OpusDecoder( 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 @@ public OpusDecoder( 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 @@ public OpusDecoder( 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 @@ protected OpusDecoderException decode( 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 @@ public void release() { 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 @@ private native int opusSecureDecode( 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 d09d69bf03c..5529701c060 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 static String getVersion() { * 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 9042e4cb89e..a2515be7f6e 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 e57ad84a416..9931f2d05fc 100644 --- a/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java +++ b/extensions/opus/src/test/java/com/google/android/exoplayer2/ext/opus/DefaultRenderersFactoryTest.java @@ -26,7 +26,7 @@ public final class DefaultRenderersFactoryTest { @Test - public void createRenderers_instantiatesVpxRenderer() { + public void createRenderers_instantiatesOpusRenderer() { DefaultRenderersFactoryAsserts.assertExtensionRendererCreated( LibopusAudioRenderer.class, C.TRACK_TYPE_AUDIO); } diff --git a/extensions/rtmp/build.gradle b/extensions/rtmp/build.gradle index 621f8b29989..3d912bebf69 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 fd0836648aa..765cdbca3bd 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. - -* 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. +* 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 `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 ffd76d6e2fa..79d85a6ac52 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 7b81c0b9b8d..823ce02cfe2 100644 --- a/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java +++ b/extensions/vp9/src/androidTest/java/com/google/android/exoplayer2/ext/vp9/VpxPlaybackTest.java @@ -24,10 +24,11 @@ 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 @@ @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 void run() { 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 d5167806f9b..61ebc8b0d9d 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 LibvpxVideoRenderer( * @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 final int supportsFormat(Format format) { 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); @@ -168,4 +167,9 @@ protected void setDecoderOutputMode(@C.VideoOutputMode int outputMode) { decoder.setOutputMode(outputMode); } } + + @Override + protected boolean canKeepCodec(Format oldFormat, Format newFormat) { + return true; + } } 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 98a26727eec..ce0873ad404 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 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 @@ public String getName() { 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 @@ protected VpxDecoderException decode( 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 @@ protected VpxDecoderException decode( } else if (getFrameResult == -1) { return new VpxDecoderException("Buffer initialization failed."); } - outputBuffer.colorInfo = inputBuffer.colorInfo; + outputBuffer.format = inputBuffer.format; } return null; } @@ -182,6 +183,15 @@ public void release() { 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 @@ private native long vpxSecureDecode( 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 e620332fc89..5106ab67ada 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 static boolean isHighBitDepthSupported() { * 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 9996848047f..1fc0f9d56e9 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 6025ecfcd08..1882ebac81f 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 97b132980d7..ff9335ad84b 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 @@ /** 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 boolean schedule(Requirements requirements, String servicePackage, String 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; } + @Override + public Requirements getSupportedRequirements(Requirements requirements) { + return requirements.filterRequirements(SUPPORTED_REQUIREMENTS); + } + private static Constraints buildConstraints(Requirements requirements) { - Constraints.Builder builder = new Constraints.Builder(); + 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 @@ private static Constraints buildConstraints(Requirements requirements) { } 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 @@ private static OneTimeWorkRequest buildWorkRequest(Constraints constraints, Data 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 SchedulerWorker(Context context, WorkerParameters workerParams) { @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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 02d1a893788..eefcdc910f8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/javadoc_combined.gradle b/javadoc_combined.gradle index 3b482910ae5..1030d3e16a2 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 dd508a17814..bb17dcb0359 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 f78b8b21325..fa3491bb5d7 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 9dc3aabac3f..2888b7e24c5 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 c83dbaee2de..18e5264c203 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 9f4e8beb1cb..c4f4a2bbb56 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.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 @@ private 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 @@ private 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 @@ private 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 @@ private 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 @@ private 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 @@ private 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 @@ private 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 static long msToUs(long timeMs) { */ @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 06743732e70..15c4bf1c1d2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2; +import android.os.Build; import java.util.HashSet; /** @@ -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 e7db47d5354..05062727c30 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 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 static final class Builder { private int accessibilityChannel; - // Provided by source. + // Provided by the source. @Nullable private Class exoMediaCryptoType; @@ -228,7 +230,7 @@ private Builder(Format format) { 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 Builder setExoMediaCryptoType( // 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 Format build() { // 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 static Format createSampleFormat(@Nullable String id, @Nullable String sa 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 static Format createSampleFormat(@Nullable String id, @Nullable String sa 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 static Format createSampleFormat(@Nullable String id, @Nullable String sa // 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 int hashCode() { 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 f484d3f80e0..dfff9a9e73e 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,30 +32,30 @@ public final class MediaItem { /** - * Creates a {@link MediaItem} for the given source uri. + * Creates a {@link MediaItem} for the given URI. * - * @param sourceUri The source uri. - * @return An {@link MediaItem} for the given source uri. + * @param uri The URI. + * @return An {@link MediaItem} for the given URI. */ - public static MediaItem fromUri(String sourceUri) { - return new MediaItem.Builder().setSourceUri(sourceUri).build(); + public static MediaItem fromUri(String uri) { + return new MediaItem.Builder().setUri(uri).build(); } /** - * Creates a {@link MediaItem} for the given {@link Uri source uri}. + * Creates a {@link MediaItem} for the given {@link Uri URI}. * - * @param sourceUri The {@link Uri source uri}. - * @return An {@link MediaItem} for the given source uri. + * @param uri The {@link Uri uri}. + * @return An {@link MediaItem} for the given URI. */ - public static MediaItem fromUri(Uri sourceUri) { - return new MediaItem.Builder().setSourceUri(sourceUri).build(); + public static MediaItem fromUri(Uri uri) { + return new MediaItem.Builder().setUri(uri).build(); } /** A builder for {@link MediaItem} instances. */ public static final class Builder { @Nullable private String mediaId; - @Nullable private Uri sourceUri; + @Nullable private Uri uri; @Nullable private String mimeType; private long clipStartPositionMs; private long clipEndPositionMs; @@ -67,10 +67,13 @@ public static final class Builder { @Nullable private UUID drmUuid; private boolean drmMultiSession; private boolean drmPlayClearContentWithoutKey; + private boolean drmForceDefaultLicenseUri; private List drmSessionForClearTypes; + @Nullable private byte[] drmKeySetId; private List streamKeys; @Nullable private String customCacheKey; private List subtitles; + @Nullable private Uri adTagUri; @Nullable private Object tag; @Nullable private MediaMetadata mediaMetadata; @@ -88,15 +91,16 @@ private Builder(MediaItem mediaItem) { clipEndPositionMs = mediaItem.clippingProperties.endPositionMs; clipRelativeToLiveWindow = mediaItem.clippingProperties.relativeToLiveWindow; clipRelativeToDefaultPosition = mediaItem.clippingProperties.relativeToDefaultPosition; - clipStartsAtKeyFrame = mediaItem.clippingProperties.startsAtKeyFrame; clipStartPositionMs = mediaItem.clippingProperties.startPositionMs; + clipStartsAtKeyFrame = mediaItem.clippingProperties.startsAtKeyFrame; mediaId = mediaItem.mediaId; mediaMetadata = mediaItem.mediaMetadata; @Nullable PlaybackProperties playbackProperties = mediaItem.playbackProperties; if (playbackProperties != null) { + adTagUri = playbackProperties.adTagUri; customCacheKey = playbackProperties.customCacheKey; mimeType = playbackProperties.mimeType; - sourceUri = playbackProperties.sourceUri; + uri = playbackProperties.uri; streamKeys = playbackProperties.streamKeys; subtitles = playbackProperties.subtitles; tag = playbackProperties.tag; @@ -105,17 +109,19 @@ private Builder(MediaItem mediaItem) { drmLicenseUri = drmConfiguration.licenseUri; drmLicenseRequestHeaders = drmConfiguration.requestHeaders; drmMultiSession = drmConfiguration.multiSession; + drmForceDefaultLicenseUri = drmConfiguration.forceDefaultLicenseUri; drmPlayClearContentWithoutKey = drmConfiguration.playClearContentWithoutKey; drmSessionForClearTypes = drmConfiguration.sessionForClearTypes; drmUuid = drmConfiguration.uuid; + drmKeySetId = drmConfiguration.getKeySetId(); } } } /** - * Sets the optional media id which identifies the media item. If not specified, {@link - * #setSourceUri} must be called and the string representation of {@link - * PlaybackProperties#sourceUri} is used as the media id. + * 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. */ public Builder setMediaId(@Nullable String mediaId) { this.mediaId = mediaId; @@ -123,30 +129,37 @@ public Builder setMediaId(@Nullable String mediaId) { } /** - * Sets the optional source 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 setSourceUri(@Nullable String sourceUri) { - return setSourceUri(sourceUri == null ? null : Uri.parse(sourceUri)); + public Builder setUri(@Nullable String uri) { + return setUri(uri == null ? null : Uri.parse(uri)); } /** - * Sets the optional source {@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 setSourceUri(@Nullable Uri sourceUri) { - this.sourceUri = sourceUri; + public Builder setUri(@Nullable Uri uri) { + this.uri = uri; return this; } /** - * 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#sourceUri} 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; @@ -203,11 +216,11 @@ public Builder setClipStartsAtKeyFrame(boolean startsAtKeyFrame) { } /** - * 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#sourceUri} 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; @@ -215,11 +228,11 @@ public Builder setDrmLicenseUri(@Nullable Uri licenseUri) { } /** - * 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#sourceUri} 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); @@ -227,11 +240,11 @@ public Builder setDrmLicenseUri(@Nullable String licenseUri) { } /** - * 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) { @@ -243,11 +256,11 @@ public Builder setDrmLicenseRequestHeaders( } /** - * 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#sourceUri} 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; @@ -255,9 +268,9 @@ public Builder setDrmUuid(@Nullable UUID uuid) { } /** - * Sets whether the drm configuration is multi session enabled. + * Sets whether the DRM configuration is multi session enabled. * - *

If a {@link PlaybackProperties#sourceUri} is set, the drm multi session flag is used to + *

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) { @@ -265,6 +278,18 @@ public Builder setDrmMultiSession(boolean 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. @@ -275,7 +300,7 @@ public Builder setDrmPlayClearContentWithoutKey(boolean playClearContentWithoutK } /** - * 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 @@ -290,10 +315,10 @@ public Builder setDrmSessionForClearPeriods(boolean sessionForClearPeriods) { } /** - * 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 @@ -309,14 +334,28 @@ public Builder setDrmSessionForClearTypes(@Nullable List sessionForClea return this; } + /** + * Sets the key set ID of the offline license. + * + *

The key set ID identifies an offline license. The ID is required to query, renew or + * release an existing offline license (see {@code DefaultDrmSessionManager#setMode(int + * mode,byte[] offlineLicenseKeySetId)}). + * + *

If no valid DRM configuration is specified, the key set ID is ignored. + */ + public Builder setDrmKeySetId(@Nullable byte[] keySetId) { + this.drmKeySetId = keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : null; + return this; + } + /** * Sets the optional stream keys by which the manifest is filtered (only used for adaptive * streams). * *

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

If a {@link PlaybackProperties#sourceUri} 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 = @@ -329,8 +368,8 @@ public Builder setStreamKeys(@Nullable List streamKeys) { /** * Sets the optional custom cache key (only used for progressive streams). * - *

If a {@link PlaybackProperties#sourceUri} 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; @@ -342,8 +381,8 @@ public Builder setCustomCacheKey(@Nullable String customCacheKey) { * *

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

If a {@link PlaybackProperties#sourceUri} 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 = @@ -353,12 +392,34 @@ public Builder setSubtitles(@Nullable List subtitles) { return this; } + /** + * Sets the optional ad tag URI. + * + *

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

If {@link #setUri} is passed a non-null {@code uri}, the ad tag URI is used to create a + * {@link PlaybackProperties} object. Otherwise it will be ignored. + */ + public Builder setAdTagUri(@Nullable Uri adTagUri) { + this.adTagUri = adTagUri; + return this; + } + /** * Sets the optional tag for custom attributes. The tag for the media source which will be * published in the {@code com.google.android.exoplayer2.Timeline} of the source as {@code * com.google.android.exoplayer2.Timeline.Window#tag}. * - *

If a {@link PlaybackProperties#sourceUri} 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) { @@ -378,10 +439,10 @@ public Builder setMediaMetadata(MediaMetadata mediaMetadata) { public MediaItem build() { Assertions.checkState(drmLicenseUri == null || drmUuid != null); @Nullable PlaybackProperties playbackProperties = null; - if (sourceUri != null) { + if (uri != null) { playbackProperties = new PlaybackProperties( - sourceUri, + uri, mimeType, drmUuid != null ? new DrmConfiguration( @@ -389,14 +450,17 @@ public MediaItem build() { drmLicenseUri, drmLicenseRequestHeaders, drmMultiSession, + drmForceDefaultLicenseUri, drmPlayClearContentWithoutKey, - drmSessionForClearTypes) + drmSessionForClearTypes, + drmKeySetId) : null, streamKeys, customCacheKey, subtitles, + adTagUri, tag); - mediaId = mediaId != null ? mediaId : sourceUri.toString(); + mediaId = mediaId != null ? mediaId : uri.toString(); } return new MediaItem( Assertions.checkNotNull(mediaId), @@ -418,15 +482,15 @@ public static final class DrmConfiguration { 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; /** @@ -435,22 +499,40 @@ public static final class DrmConfiguration { */ 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; + private DrmConfiguration( UUID uuid, @Nullable Uri licenseUri, Map requestHeaders, boolean multiSession, + boolean forceDefaultLicenseUri, boolean playClearContentWithoutKey, - List drmSessionForClearTypes) { + List drmSessionForClearTypes, + @Nullable byte[] keySetId) { this.uuid = uuid; 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; + } + + /** Returns the key set ID of the offline license. */ + @Nullable + public byte[] getKeySetId() { + return keySetId != null ? Arrays.copyOf(keySetId, keySetId.length) : null; } @Override @@ -467,8 +549,10 @@ public boolean equals(@Nullable Object obj) { && Util.areEqual(licenseUri, other.licenseUri) && Util.areEqual(requestHeaders, other.requestHeaders) && multiSession == other.multiSession + && forceDefaultLicenseUri == other.forceDefaultLicenseUri && playClearContentWithoutKey == other.playClearContentWithoutKey - && sessionForClearTypes.equals(other.sessionForClearTypes); + && sessionForClearTypes.equals(other.sessionForClearTypes) + && Arrays.equals(keySetId, other.keySetId); } @Override @@ -477,8 +561,10 @@ public int hashCode() { 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); return result; } } @@ -486,13 +572,13 @@ public int hashCode() { /** Properties for local playback. */ public static final class PlaybackProperties { - /** The source {@link Uri}. */ - public final Uri sourceUri; + /** The {@link Uri}. */ + 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; @@ -509,6 +595,9 @@ public static final class PlaybackProperties { /** Optional subtitles to be sideloaded. */ public final List subtitles; + /** Optional ad tag {@link Uri}. */ + @Nullable public final Uri adTagUri; + /** * Optional tag for custom attributes. The tag for the media source which will be published in * the {@code com.google.android.exoplayer2.Timeline} of the source as {@code @@ -517,19 +606,21 @@ public static final class PlaybackProperties { @Nullable public final Object tag; private PlaybackProperties( - Uri sourceUri, + Uri uri, @Nullable String mimeType, @Nullable DrmConfiguration drmConfiguration, List streamKeys, @Nullable String customCacheKey, List subtitles, + @Nullable Uri adTagUri, @Nullable Object tag) { - this.sourceUri = sourceUri; + this.uri = uri; this.mimeType = mimeType; this.drmConfiguration = drmConfiguration; this.streamKeys = streamKeys; this.customCacheKey = customCacheKey; this.subtitles = subtitles; + this.adTagUri = adTagUri; this.tag = tag; } @@ -543,23 +634,25 @@ public boolean equals(@Nullable Object obj) { } PlaybackProperties other = (PlaybackProperties) obj; - return sourceUri.equals(other.sourceUri) + return uri.equals(other.uri) && Util.areEqual(mimeType, other.mimeType) && Util.areEqual(drmConfiguration, other.drmConfiguration) && streamKeys.equals(other.streamKeys) && Util.areEqual(customCacheKey, other.customCacheKey) && subtitles.equals(other.subtitles) + && Util.areEqual(adTagUri, other.adTagUri) && Util.areEqual(tag, other.tag); } @Override public int hashCode() { - int result = sourceUri.hashCode(); + int result = uri.hashCode(); result = 31 * result + (mimeType == null ? 0 : mimeType.hashCode()); result = 31 * result + (drmConfiguration == null ? 0 : drmConfiguration.hashCode()); result = 31 * result + streamKeys.hashCode(); result = 31 * result + (customCacheKey == null ? 0 : customCacheKey.hashCode()); result = 31 * result + subtitles.hashCode(); + result = 31 * result + (adTagUri == null ? 0 : adTagUri.hashCode()); result = 31 * result + (tag == null ? 0 : tag.hashCode()); return result; } @@ -580,8 +673,8 @@ public static final class Subtitle { /** * 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) { @@ -591,8 +684,8 @@ public Subtitle(Uri uri, String mimeType, @Nullable String language) { /** * 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 024f848ea26..4a03b79856e 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 @@ -15,11 +15,15 @@ */ package com.google.android.exoplayer2.audio; +import androidx.annotation.IntDef; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.ParsableBitArray; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; /** Utility methods for handling AAC audio streams. */ public final class AacUtil { @@ -132,19 +136,37 @@ private Config(int sampleRateHz, int channelCount, String codecs) { private static final String CODECS_STRING_PREFIX = "mp4a.40."; // Advanced Audio Coding Low-Complexity profile. - private static final int AUDIO_OBJECT_TYPE_AAC_LC = 2; + public static final int AUDIO_OBJECT_TYPE_AAC_LC = 2; // Spectral Band Replication. - private static final int AUDIO_OBJECT_TYPE_AAC_SBR = 5; + public static final int AUDIO_OBJECT_TYPE_AAC_SBR = 5; // Error Resilient Bit-Sliced Arithmetic Coding. - private static final int AUDIO_OBJECT_TYPE_AAC_ER_BSAC = 22; + public static final int AUDIO_OBJECT_TYPE_AAC_ER_BSAC = 22; // Enhanced low delay. - private static final int AUDIO_OBJECT_TYPE_AAC_ELD = 23; + public static final int AUDIO_OBJECT_TYPE_AAC_ELD = 23; // Parametric Stereo. - private static final int AUDIO_OBJECT_TYPE_AAC_PS = 29; + public static final int AUDIO_OBJECT_TYPE_AAC_PS = 29; // Escape code for extended audio object types. private static final int AUDIO_OBJECT_TYPE_ESCAPE = 31; // Extended high efficiency. - private static final int AUDIO_OBJECT_TYPE_AAC_XHE = 42; + public static final int AUDIO_OBJECT_TYPE_AAC_XHE = 42; + + /** + * Valid AAC Audio object types. One of {@link #AUDIO_OBJECT_TYPE_AAC_LC}, {@link + * #AUDIO_OBJECT_TYPE_AAC_SBR}, {@link #AUDIO_OBJECT_TYPE_AAC_ER_BSAC}, {@link + * #AUDIO_OBJECT_TYPE_AAC_ELD}, {@link #AUDIO_OBJECT_TYPE_AAC_PS} or {@link + * #AUDIO_OBJECT_TYPE_AAC_XHE}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + AUDIO_OBJECT_TYPE_AAC_LC, + AUDIO_OBJECT_TYPE_AAC_SBR, + AUDIO_OBJECT_TYPE_AAC_ER_BSAC, + AUDIO_OBJECT_TYPE_AAC_ELD, + AUDIO_OBJECT_TYPE_AAC_PS, + AUDIO_OBJECT_TYPE_AAC_XHE + }) + public @interface AacAudioObjectType {} /** * Parses an AAC AudioSpecificConfig, as defined in ISO 14496-3 1.6.2.1 @@ -275,7 +297,7 @@ public static byte[] buildAudioSpecificConfig( /** Returns the encoding for a given AAC audio object type. */ @C.Encoding - public static int getEncodingForAudioObjectType(int audioObjectType) { + public static int getEncodingForAudioObjectType(@AacAudioObjectType int audioObjectType) { switch (audioObjectType) { case AUDIO_OBJECT_TYPE_AAC_LC: return C.ENCODING_AAC_LC; @@ -287,6 +309,8 @@ public static int getEncodingForAudioObjectType(int audioObjectType) { 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/Ac3Util.java b/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac3Util.java index d4042a99b1e..f9a97d961f2 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac3Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/audio/Ac3Util.java @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.ParsableBitArray; import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -516,7 +517,7 @@ public static int findTrueHdSyncframeOffset(ByteBuffer buffer) { int endIndex = buffer.limit() - TRUEHD_SYNCFRAME_PREFIX_LENGTH; for (int i = startIndex; i <= endIndex; i++) { // The syncword ends 0xBA for TrueHD or 0xBB for MLP. - if ((buffer.getInt(i + 4) & 0xFEFFFFFF) == 0xBA6F72F8) { + if ((Util.getBigEndianInt(buffer, i + 4) & 0xFFFFFFFE) == 0xF8726FBA) { return i - startIndex; } } 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 2e4367f4e2d..96712f04cbd 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 static int parseAc4SyncframeAudioSampleCount(ByteBuffer buffer) { 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 53eed6c5515..71ffb00982e 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.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 00000000000..3e434bb7e88 --- /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 1c52abc4764..7eaab6ae1d1 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 void set(int numSubSamples, int[] numBytesOfClearData, int[] numBytesOfEn 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 bd5df4c8b12..0ae8ce31f9b 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 @@ 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 static DecoderInputBuffer newFlagsOnlyInstance() { } /** - * @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 void resetSupplementalData(int length) { */ @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 final boolean isEncrypted() { * @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 @@ private ByteBuffer createReplacementByteBuffer(int requiredCapacity) { + 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 43c37028eaa..8d662c318ee 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 00000000000..e8e6d6074b9 --- /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 046c1fef556..21dacd4f9bd 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 Metadata(Entry... entries) { * @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 dee0db5a8e1..46501ce0027 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 00000000000..cf3954b7c5c --- /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 c03a5cb0381..8a7e1851c6b 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 EventMessage decode(ParsableByteArray emsgData) { 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 84a316e8483..f660e21bfda 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 @@ 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.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 Id3Decoder(@Nullable FramePredicate framePredicate) { @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 Metadata decode(byte[] data, int size) { 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 Metadata decode(byte[] data, int size) { } 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 @@ private static ChapterFrame decodeChapterFrame( @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 @@ private static ChapterFrame decodeChapterFrame( } } - 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 @@ private static ChapterTocFrame decodeChapterTOCFrame( @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 @@ private static ChapterTocFrame decodeChapterTOCFrame( 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 @@ private static BinaryFrame decodeBinaryFrame(ParsableByteArray id3Data, int fram * @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 9a321fbdd85..bbc182d7afa 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 @@ interface Factory { /** * 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 e6b3ae27071..a45b7db2f29 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 @@ */ 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 cdbf3fee7d8..75e23ae6f2a 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.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 static String getStringForHttpMethod(@HttpMethod int httpMethod) { } } - /** 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 static String getStringForHttpMethod(@HttpMethod int httpMethod) { 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 static String getStringForHttpMethod(@HttpMethod int httpMethod) { 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 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 9d4f9b6811a..d2170b9eab0 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 @@ 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 @@ final class InvalidResponseCodeException extends HttpDataSourceException { */ 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, + 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 b582cf3f7c1..8e982fc6462 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 e8eb0d0df9d..505ff55cbe7 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 @@ * * @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 00000000000..d4b87abfddf --- /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 e9e74d4ba87..6d5f167047d 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,8 +17,12 @@ 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; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Defines common MIME types and helper methods. @@ -31,6 +35,7 @@ public final class MimeTypes { public static final String BASE_TYPE_APPLICATION = "application"; public static final String VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4"; + public static final String VIDEO_MATROSKA = BASE_TYPE_VIDEO + "/x-matroska"; public static final String VIDEO_WEBM = BASE_TYPE_VIDEO + "/webm"; public static final String VIDEO_H263 = BASE_TYPE_VIDEO + "/3gpp"; public static final String VIDEO_H264 = BASE_TYPE_VIDEO + "/avc"; @@ -38,16 +43,21 @@ public final class MimeTypes { public static final String VIDEO_VP8 = BASE_TYPE_VIDEO + "/x-vnd.on2.vp8"; public static final String VIDEO_VP9 = BASE_TYPE_VIDEO + "/x-vnd.on2.vp9"; public static final String VIDEO_AV1 = BASE_TYPE_VIDEO + "/av01"; + public static final String VIDEO_MP2T = BASE_TYPE_VIDEO + "/mp2t"; public static final String VIDEO_MP4V = BASE_TYPE_VIDEO + "/mp4v-es"; public static final String VIDEO_MPEG = BASE_TYPE_VIDEO + "/mpeg"; + public static final String VIDEO_PS = BASE_TYPE_VIDEO + "/mp2p"; public static final String VIDEO_MPEG2 = BASE_TYPE_VIDEO + "/mpeg2"; public static final String VIDEO_VC1 = BASE_TYPE_VIDEO + "/wvc1"; 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"; @@ -65,11 +75,14 @@ public final class MimeTypes { public static final String AUDIO_DTS_EXPRESS = BASE_TYPE_AUDIO + "/vnd.dts.hd;profile=lbr"; public static final String AUDIO_VORBIS = BASE_TYPE_AUDIO + "/vorbis"; public static final String AUDIO_OPUS = BASE_TYPE_AUDIO + "/opus"; + public static final String AUDIO_AMR = BASE_TYPE_AUDIO + "/amr"; public static final String AUDIO_AMR_NB = BASE_TYPE_AUDIO + "/3gpp"; public static final String AUDIO_AMR_WB = BASE_TYPE_AUDIO + "/amr-wb"; public static final String AUDIO_FLAC = BASE_TYPE_AUDIO + "/flac"; 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"; @@ -77,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"; @@ -101,13 +115,16 @@ public final class MimeTypes { private static final ArrayList customMimeTypes = new ArrayList<>(); + private static final Pattern MP4A_RFC_6381_CODEC_PATTERN = + Pattern.compile("^mp4a\\.([a-zA-Z0-9]{2})(?:\\.([0-9]{1,2}))?$"); + /** * Registers a custom MIME type. Most applications do not need to call this method, as handling of * standard MIME types is built in. These built-in MIME types take precedence over any registered * 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. */ @@ -153,35 +170,59 @@ public static boolean isText(@Nullable String mimeType) { } /** - * 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) { @@ -199,10 +240,10 @@ public static String getVideoMediaMimeType(@Nullable String codecs) { } /** - * 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) { @@ -220,10 +261,10 @@ public static String getAudioMediaMimeType(@Nullable String codecs) { } /** - * 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) { @@ -241,10 +282,11 @@ public static String getTextMediaMimeType(@Nullable String codecs) { } /** - * 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) { @@ -270,15 +312,9 @@ public static String getMediaMimeType(@Nullable String codec) { } else if (codec.startsWith("mp4a")) { @Nullable String mimeType = null; if (codec.startsWith("mp4a.")) { - String objectTypeString = codec.substring(5); // remove the 'mp4a.' prefix - if (objectTypeString.length() >= 2) { - try { - String objectTypeHexString = Util.toUpperInvariant(objectTypeString.substring(0, 2)); - int objectTypeInt = Integer.parseInt(objectTypeHexString, 16); - mimeType = getMimeTypeFromMp4ObjectType(objectTypeInt); - } catch (NumberFormatException ignored) { - // Ignored. - } + @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codec); + if (objectType != null) { + mimeType = getMimeTypeFromMp4ObjectType(objectType.objectTypeIndication); } } return mimeType == null ? MimeTypes.AUDIO_AAC : mimeType; @@ -314,11 +350,11 @@ public static String getMediaMimeType(@Nullable String codec) { } /** - * 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) { @@ -370,12 +406,12 @@ public static String getMimeTypeFromMp4ObjectType(int objectType) { } /** - * 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)) { @@ -398,17 +434,28 @@ public static int getTrackType(@Nullable String mimeType) { } /** - * 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. - * @return The {@link C}{@code .ENCODING_*} constant 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}. */ - public static @C.Encoding int getEncoding(String mimeType) { + @C.Encoding + public static int getEncoding(String mimeType, @Nullable String codec) { switch (mimeType) { case MimeTypes.AUDIO_MPEG: return C.ENCODING_MP3; + case MimeTypes.AUDIO_AAC: + if (codec == null) { + return C.ENCODING_INVALID; + } + @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codec); + if (objectType == null) { + return C.ENCODING_INVALID; + } + return AacUtil.getEncodingForAudioObjectType(objectType.audioObjectTypeIndication); case MimeTypes.AUDIO_AC3: return C.ENCODING_AC3; case MimeTypes.AUDIO_E_AC3: @@ -431,13 +478,46 @@ public static int getTrackType(@Nullable String mimeType) { /** * 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)); } + /** + * Normalizes the MIME type provided so that equivalent MIME types are uniquely represented. + * + * @param mimeType A MIME type to normalize. + * @return The normalized MIME type, or the argument MIME type if its normalized form is unknown. + */ + public static String normalizeMimeType(String mimeType) { + switch (mimeType) { + case BASE_TYPE_AUDIO + "/x-flac": + return AUDIO_FLAC; + case BASE_TYPE_AUDIO + "/mp3": + return AUDIO_MPEG; + case BASE_TYPE_AUDIO + "/x-wav": + return AUDIO_WAV; + default: + return mimeType; + } + } + + /** Returns whether the given {@code mimeType} is a Matroska MIME type, including WebM. */ + public static boolean isMatroska(@Nullable String mimeType) { + if (mimeType == null) { + return false; + } + return mimeType.startsWith(MimeTypes.VIDEO_WEBM) + || mimeType.startsWith(MimeTypes.AUDIO_WEBM) + || mimeType.startsWith(MimeTypes.APPLICATION_WEBM) + || mimeType.startsWith(MimeTypes.VIDEO_MATROSKA) + || mimeType.startsWith(MimeTypes.AUDIO_MATROSKA) + || mimeType.startsWith(MimeTypes.APPLICATION_MATROSKA); + } + /** * Returns the top-level type of {@code mimeType}, or null if {@code mimeType} is null or does not * contain a forward slash character ({@code '/'}). @@ -481,6 +561,59 @@ private 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 05585d53010..4831ec59e2d 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 static void discardToSps(ByteBuffer data) { * 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 static int findNalUnit(byte[] data, int startOffset, int endOffset, 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 static int findNalUnit(byte[] data, int startOffset, int endOffset, // 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 static int findNalUnit(byte[] data, int startOffset, int endOffset, } } - 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 963e43fc7e4..3ad5fd97035 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 void reset(byte[] data) { * @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 void skipBytes(int length) { * @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 void putInt(int value, int numBits) { 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 67686ad64fc..a4e3c1dfbe9 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 @@ */ 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 ParsableByteArray(byte[] data, int limit) { 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 int getPosition() { 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 void setPosition(int position) { 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 double readDouble() { * @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 439374a0861..65f88b19832 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 long adjustTsTimestamp(long pts90Khz) { 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 static long ptsToUs(long pts) { 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 60fe1a39d4b..0c13900330c 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -16,6 +16,10 @@ package com.google.android.exoplayer2.util; import static android.content.Context.UI_MODE_SERVICE; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static java.lang.Math.abs; +import static java.lang.Math.max; +import static java.lang.Math.min; import android.Manifest.permission; import android.annotation.SuppressLint; @@ -29,6 +33,8 @@ 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 +54,14 @@ 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; @@ -60,8 +69,9 @@ import java.io.InputStream; import java.lang.reflect.Method; import java.math.BigDecimal; -import java.nio.charset.Charset; -import java.util.ArrayList; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayDeque; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; @@ -93,7 +103,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 = 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 @@ -132,6 +145,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; @@ -211,7 +229,7 @@ public static boolean maybeRequestReadExternalStoragePermission( if (mediaItem.playbackProperties == null) { continue; } - if (isLocalFileUri(mediaItem.playbackProperties.sourceUri)) { + if (isLocalFileUri(mediaItem.playbackProperties.uri)) { return requestExternalStoragePermission(activity); } for (int i = 0; i < mediaItem.playbackProperties.subtitles.size(); i++) { @@ -239,7 +257,7 @@ public static boolean checkCleartextTrafficPermitted(MediaItem... mediaItems) { if (mediaItem.playbackProperties == null) { continue; } - if (isTrafficRestricted(mediaItem.playbackProperties.sourceUri)) { + if (isTrafficRestricted(mediaItem.playbackProperties.uri)) { return false; } for (int i = 0; i < mediaItem.playbackProperties.subtitles.size(); i++) { @@ -391,21 +409,63 @@ public static T[] nullSafeArrayConcatenation(T[] first, T[] second) { 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. @@ -414,15 +474,17 @@ public static Handler createHandler() { * 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 @@ -435,12 +497,30 @@ public static Handler createHandler( 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(); } @@ -552,7 +632,7 @@ public static String getLocaleLanguageTag(Locale locale) { mainLanguage = replacedLanguage; } if ("no".equals(mainLanguage) || "i".equals(mainLanguage) || "zh".equals(mainLanguage)) { - normalizedTag = maybeReplaceGrandfatheredLanguageTags(normalizedTag); + normalizedTag = maybeReplaceLegacyLanguageTags(normalizedTag); } return normalizedTag; } @@ -564,7 +644,7 @@ public static String getLocaleLanguageTag(Locale locale) { * @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); } /** @@ -576,7 +656,7 @@ public static String fromUtf8Bytes(byte[] bytes) { * @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); } /** @@ -586,7 +666,7 @@ public static String fromUtf8Bytes(byte[] bytes, int offset, int length) { * @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); } /** @@ -686,7 +766,7 @@ public static long ceilDivide(long numerator, long denominator) { * @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)); } /** @@ -698,7 +778,7 @@ public static int constrainValue(int value, int min, int max) { * @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)); } /** @@ -710,7 +790,7 @@ public static long constrainValue(long value, long min, long max) { * @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)); } /** @@ -765,6 +845,24 @@ public static int linearSearch(int[] array, int value) { 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}. @@ -794,7 +892,7 @@ public static int binarySearchFloor( index++; } } - return stayInBounds ? Math.max(0, index) : index; + return stayInBounds ? max(0, index) : index; } /** @@ -826,7 +924,7 @@ public static int binarySearchFloor(long[] array, long value, boolean inclusive, index++; } } - return stayInBounds ? Math.max(0, index) : index; + return stayInBounds ? max(0, index) : index; } /** @@ -862,7 +960,7 @@ public static > int binarySearchFloor( index++; } } - return stayInBounds ? Math.max(0, index) : index; + return stayInBounds ? max(0, index) : index; } /** @@ -936,7 +1034,7 @@ public static int binarySearchCeil( index--; } } - return stayInBounds ? Math.min(array.length - 1, index) : index; + return stayInBounds ? min(array.length - 1, index) : index; } /** @@ -969,7 +1067,7 @@ public static int binarySearchCeil( index--; } } - return stayInBounds ? Math.min(array.length - 1, index) : index; + return stayInBounds ? min(array.length - 1, index) : index; } /** @@ -1007,7 +1105,7 @@ public static > int binarySearchCeil( index--; } } - return stayInBounds ? Math.min(list.size() - 1, index) : index; + return stayInBounds ? min(list.size() - 1, index) : index; } /** @@ -1214,41 +1312,6 @@ public static long getPlayoutDurationForMediaDuration(long mediaDuration, float 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. @@ -1289,6 +1352,24 @@ public static long toLong(int mostSignificantBits, int leastSignificantBits) { 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. * @@ -1397,14 +1478,30 @@ public static String[] splitCodecs(@Nullable String codecs) { 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) { @@ -1451,7 +1548,7 @@ public static boolean isEncodingHighResolutionPcm(@C.PcmEncoding int encoding) { /** * 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 @@ -1621,13 +1718,13 @@ public static int getStreamTypeForAudioUsage(@C.AudioUsage int usage) { } /** - * 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) @@ -1635,35 +1732,108 @@ public static int inferContentType(Uri uri, @Nullable String overrideExtension) } /** - * 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(\\(.+\\))?)?")) { + } + 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; - } else { - return C.TYPE_OTHER; } + return C.TYPE_OTHER; + } + + /** + * 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 MIME type, or {@code null}. + * @return The content type. + */ + @ContentType + public static int inferContentTypeForUriAndMimeType(Uri uri, @Nullable String mimeType) { + if (mimeType == null) { + return Util.inferContentType(uri); + } + switch (mimeType) { + case MimeTypes.APPLICATION_MPD: + return C.TYPE_DASH; + case MimeTypes.APPLICATION_M3U8: + return C.TYPE_HLS; + case MimeTypes.APPLICATION_SS: + return C.TYPE_SS; + default: + 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; } /** @@ -1678,13 +1848,16 @@ public static String getStringForTime(StringBuilder builder, Formatter formatter if (timeMs == C.TIME_UNSET) { timeMs = 0; } + String prefix = timeMs < 0 ? "-" : ""; + timeMs = abs(timeMs); long totalSeconds = (timeMs + 500) / 1000; long seconds = totalSeconds % 60; long minutes = (totalSeconds / 60) % 60; long hours = totalSeconds / 3600; builder.setLength(0); - return hours > 0 ? formatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() - : formatter.format("%02d:%02d", minutes, seconds).toString(); + return hours > 0 + ? formatter.format("%s%d:%02d:%02d", prefix, hours, minutes, seconds).toString() + : formatter.format("%s%02d:%02d", prefix, minutes, seconds).toString(); } /** @@ -1772,8 +1945,7 @@ private static boolean shouldEscapeCharacter(char c) { 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--; @@ -1859,12 +2031,29 @@ public static int crc8(byte[] bytes, int start, int end, int initialValue) { return initialValue; } + /** + * Absolute get method for reading an int value in {@link ByteOrder#BIG_ENDIAN} in a {@link + * ByteBuffer}. Same as {@link ByteBuffer#getInt(int)} except the buffer's order as returned by + * {@link ByteBuffer#order()} is ignored and {@link ByteOrder#BIG_ENDIAN} is used instead. + * + * @param buffer The buffer from which to read an int in big endian. + * @param index The index from which the bytes will be read. + * @return The int value at the given index with the buffer bytes ordered most significant to + * least significant. + */ + public static int getBigEndianInt(ByteBuffer buffer, int index) { + int value = buffer.getInt(index); + return buffer.order() == ByteOrder.BIG_ENDIAN ? value : Integer.reverseBytes(value); + } + /** * Returns the {@link C.NetworkType} of the current network connection. * * @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) { @@ -1872,6 +2061,7 @@ public static int getNetworkType(Context context) { return C.NETWORK_TYPE_UNKNOWN; } NetworkInfo networkInfo; + @Nullable ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager == null) { @@ -1911,6 +2101,7 @@ public static int getNetworkType(Context context) { */ public static String getCountryCode(@Nullable Context context) { if (context != null) { + @Nullable TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager != null) { @@ -1952,14 +2143,14 @@ public static boolean inflate( 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) { @@ -1990,6 +2181,7 @@ public static boolean inflate( */ 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 @@ -2009,7 +2201,8 @@ public static boolean isTv(Context context) { * @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()); } @@ -2115,6 +2308,32 @@ public static long getNowUnixTimeMs(long elapsedRealtimeEpochOffsetMs) { : 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 { @@ -2183,7 +2402,7 @@ private static String getLocaleLanguageTagV21(Locale locale) { 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: @@ -2232,14 +2451,14 @@ private static boolean requestExternalStoragePermission(Activity activity) { 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; @@ -2299,9 +2518,9 @@ private static String maybeReplaceGrandfatheredLanguageTags(String languageTag) "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 3886fdfb237..b794d2db905 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 @@ private static byte[] buildNalUnitForChild(ParsableByteArray data) { 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 bb11ef0005b..100a824a970 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 static HevcConfig parse(ParsableByteArray data) throws ParserException { 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 ac5edc6f6bc..de39888b419 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 void bufferFlagConstants_equalToMediaCodecConstants() { @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 135aace2a3e..1ad888c8685 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 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 void parcelFormat_createsEqualFormat_exceptExoMediaCryptoType() { 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 @@ private static Format createTestFormat() { 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 @@ private static Format createTestFormat() { 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 adfbc60085a..86f03a3ddba 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 @@ -38,26 +38,26 @@ public class MediaItemTest { private static final String URI_STRING = "http://www.google.com"; @Test - public void builder_needsSourceUriOrMediaId() { + public void builder_needsUriOrMediaId() { assertThrows(NullPointerException.class, () -> new MediaItem.Builder().build()); } @Test - public void builderWithUri_setsSourceUri() { + public void builderWithUri_setsUri() { Uri uri = Uri.parse(URI_STRING); MediaItem mediaItem = MediaItem.fromUri(uri); - assertThat(mediaItem.playbackProperties.sourceUri.toString()).isEqualTo(URI_STRING); + assertThat(mediaItem.playbackProperties.uri.toString()).isEqualTo(URI_STRING); assertThat(mediaItem.mediaId).isEqualTo(URI_STRING); assertThat(mediaItem.mediaMetadata).isNotNull(); } @Test - public void builderWithUriAsString_setsSourceUri() { + public void builderWithUriAsString_setsUri() { MediaItem mediaItem = MediaItem.fromUri(URI_STRING); - assertThat(mediaItem.playbackProperties.sourceUri.toString()).isEqualTo(URI_STRING); + assertThat(mediaItem.playbackProperties.uri.toString()).isEqualTo(URI_STRING); assertThat(mediaItem.mediaId).isEqualTo(URI_STRING); } @@ -71,10 +71,7 @@ public void builderSetMimeType_isNullByDefault() { @Test public void builderSetMimeType_setsMimeType() { MediaItem mediaItem = - new MediaItem.Builder() - .setSourceUri(URI_STRING) - .setMimeType(MimeTypes.APPLICATION_MPD) - .build(); + new MediaItem.Builder().setUri(URI_STRING).setMimeType(MimeTypes.APPLICATION_MPD).build(); assertThat(mediaItem.playbackProperties.mimeType).isEqualTo(MimeTypes.APPLICATION_MPD); } @@ -82,7 +79,7 @@ public void builderSetMimeType_setsMimeType() { @Test public void builderSetDrmConfig_isNullByDefault() { // Null value by default. - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).build(); assertThat(mediaItem.playbackProperties.drmConfiguration).isNull(); } @@ -91,15 +88,18 @@ public void builderSetDrmConfig_setsAllProperties() { Uri licenseUri = Uri.parse(URI_STRING); Map requestHeaders = new HashMap<>(); requestHeaders.put("Referer", "http://www.google.com"); + byte[] keySetId = new byte[] {1, 2, 3}; MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_STRING) + .setUri(URI_STRING) .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) .build(); assertThat(mediaItem.playbackProperties.drmConfiguration).isNotNull(); @@ -108,9 +108,11 @@ public void builderSetDrmConfig_setsAllProperties() { 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); + assertThat(mediaItem.playbackProperties.drmConfiguration.getKeySetId()).isEqualTo(keySetId); } @Test @@ -118,7 +120,7 @@ public void builderSetDrmSessionForClearPeriods_setsAudioAndVideoTracks() { Uri licenseUri = Uri.parse(URI_STRING); MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_STRING) + .setUri(URI_STRING) .setDrmUuid(C.WIDEVINE_UUID) .setDrmLicenseUri(licenseUri) .setDrmSessionForClearTypes(Arrays.asList(C.TRACK_TYPE_AUDIO)) @@ -135,7 +137,7 @@ public void builderSetDrmUuid_notCalled_throwsIllegalStateException() { IllegalStateException.class, () -> new MediaItem.Builder() - .setSourceUri(URI_STRING) + .setUri(URI_STRING) // missing uuid .setDrmLicenseUri(Uri.parse(URI_STRING)) .build()); @@ -144,7 +146,7 @@ public void builderSetDrmUuid_notCalled_throwsIllegalStateException() { @Test public void builderSetCustomCacheKey_setsCustomCacheKey() { MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_STRING).setCustomCacheKey("key").build(); + new MediaItem.Builder().setUri(URI_STRING).setCustomCacheKey("key").build(); assertThat(mediaItem.playbackProperties.customCacheKey).isEqualTo("key"); } @@ -156,7 +158,7 @@ public void builderSetStreamKeys_setsStreamKeys() { streamKeys.add(new StreamKey(0, 1, 1)); MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_STRING).setStreamKeys(streamKeys).build(); + new MediaItem.Builder().setUri(URI_STRING).setStreamKeys(streamKeys).build(); assertThat(mediaItem.playbackProperties.streamKeys).isEqualTo(streamKeys); } @@ -174,14 +176,14 @@ public void builderSetSubtitles_setsSubtitles() { C.SELECTION_FLAG_DEFAULT)); MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_STRING).setSubtitles(subtitles).build(); + new MediaItem.Builder().setUri(URI_STRING).setSubtitles(subtitles).build(); assertThat(mediaItem.playbackProperties.subtitles).isEqualTo(subtitles); } @Test public void builderSetTag_isNullByDefault() { - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).build(); assertThat(mediaItem.playbackProperties.tag).isNull(); } @@ -190,7 +192,7 @@ public void builderSetTag_isNullByDefault() { public void builderSetTag_setsTag() { Object tag = new Object(); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).setTag(tag).build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).setTag(tag).build(); assertThat(mediaItem.playbackProperties.tag).isEqualTo(tag); } @@ -198,14 +200,14 @@ public void builderSetTag_setsTag() { @Test public void builderSetStartPositionMs_setsStartPositionMs() { MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_STRING).setClipStartPositionMs(1000L).build(); + new MediaItem.Builder().setUri(URI_STRING).setClipStartPositionMs(1000L).build(); assertThat(mediaItem.clippingProperties.startPositionMs).isEqualTo(1000L); } @Test public void builderSetStartPositionMs_zeroByDefault() { - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).build(); assertThat(mediaItem.clippingProperties.startPositionMs).isEqualTo(0); } @@ -220,14 +222,14 @@ public void builderSetStartPositionMs_negativeValue_throws() { @Test public void builderSetEndPositionMs_setsEndPositionMs() { MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_STRING).setClipEndPositionMs(1000L).build(); + new MediaItem.Builder().setUri(URI_STRING).setClipEndPositionMs(1000L).build(); assertThat(mediaItem.clippingProperties.endPositionMs).isEqualTo(1000L); } @Test public void builderSetEndPositionMs_timeEndOfSourceByDefault() { - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).build(); assertThat(mediaItem.clippingProperties.endPositionMs).isEqualTo(C.TIME_END_OF_SOURCE); } @@ -236,7 +238,7 @@ public void builderSetEndPositionMs_timeEndOfSourceByDefault() { public void builderSetEndPositionMs_timeEndOfSource_setsEndPositionMs() { MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_STRING) + .setUri(URI_STRING) .setClipEndPositionMs(1000) .setClipEndPositionMs(C.TIME_END_OF_SOURCE) .build(); @@ -255,7 +257,7 @@ public void builderSetEndPositionMs_negativeValue_throws() { public void builderSetClippingFlags_setsClippingFlags() { MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_STRING) + .setUri(URI_STRING) .setClipRelativeToDefaultPosition(true) .setClipRelativeToLiveWindow(true) .setClipStartsAtKeyFrame(true) @@ -266,12 +268,21 @@ public void builderSetClippingFlags_setsClippingFlags() { assertThat(mediaItem.clippingProperties.startsAtKeyFrame).isTrue(); } + @Test + public void builderSetAdTagUri_setsAdTagUri() { + Uri adTagUri = Uri.parse(URI_STRING + "/ad"); + + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_STRING).setAdTagUri(adTagUri).build(); + + assertThat(mediaItem.playbackProperties.adTagUri).isEqualTo(adTagUri); + } + @Test public void builderSetMediaMetadata_setsMetadata() { MediaMetadata mediaMetadata = new MediaMetadata.Builder().setTitle("title").build(); MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_STRING).setMediaMetadata(mediaMetadata).build(); + new MediaItem.Builder().setUri(URI_STRING).setMediaMetadata(mediaMetadata).build(); assertThat(mediaItem.mediaMetadata).isEqualTo(mediaMetadata); } @@ -280,6 +291,7 @@ public void builderSetMediaMetadata_setsMetadata() { public void buildUpon_equalsToOriginal() { MediaItem mediaItem = new MediaItem.Builder() + .setAdTagUri(URI_STRING) .setClipEndPositionMs(1000) .setClipRelativeToDefaultPosition(true) .setClipRelativeToLiveWindow(true) @@ -291,12 +303,14 @@ public void buildUpon_equalsToOriginal() { .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}) .setMediaId("mediaId") .setMediaMetadata(new MediaMetadata.Builder().setTitle("title").build()) .setMimeType(MimeTypes.APPLICATION_MP4) - .setSourceUri(URI_STRING) + .setUri(URI_STRING) .setStreamKeys(Collections.singletonList(new StreamKey(1, 0, 0))) .setSubtitles( Collections.singletonList( 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 00000000000..4fe18aa4d0a --- /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 00000000000..58e2db93dc8 --- /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 e7b46e5c998..f1966413322 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 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 @@ @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 @@ private List getAllSchemeData(DrmInitData drmInitData) { 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 00000000000..be969fc0317 --- /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 ee2c55a735a..ed06eb0aff8 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 void decodeEventMessage_failsIfArrayOffsetNonZero() { 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 fc73b0cdaf4..c6d2231eb2d 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 void encodeEventStreamMultipleTimesWorkingCorrectly() throws IOException 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 void encodeEventStreamMultipleTimesWorkingCorrectly() throws IOException 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 63894174648..972e855a5b0 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 static byte[] buildMultiFramesTag(FrameSpec... frames) { 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 FrameSpec(String frameId, byte[] frameData) { 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 00000000000..59a4939a68e --- /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 00000000000..aee23f9c17f --- /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 e88385bbcac..46202a59912 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,8 +15,11 @@ */ 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; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -133,4 +136,54 @@ public void getMimeTypeFromMp4ObjectType_forInvalidObjectType_returnsNull() { assertThat(MimeTypes.getMimeTypeFromMp4ObjectType(0x01)).isNull(); assertThat(MimeTypes.getMimeTypeFromMp4ObjectType(-1)).isNull(); } + + @Test + public void getObjectTypeFromMp4aRFC6381CodecString_onInvalidInput_returnsNull() { + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("abc")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.1")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.a")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.1g")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4v.20.9")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.100.1")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.10.")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.a.1")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.10,01")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.1f.f1")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.1a.a")).isNull(); + assertThat(MimeTypes.getObjectTypeFromMp4aRFC6381CodecString("mp4a.01.110")).isNull(); + } + + @Test + public void getObjectTypeFromMp4aRFC6381CodecString_onValidInput_returnsCorrectObjectType() { + assert_getObjectTypeFromMp4aRFC6381CodecString_for_returns("mp4a.00.0", 0x00, 0); + assert_getObjectTypeFromMp4aRFC6381CodecString_for_returns("mp4a.01.01", 0x01, 1); + assert_getObjectTypeFromMp4aRFC6381CodecString_for_returns("mp4a.10.10", 0x10, 10); + assert_getObjectTypeFromMp4aRFC6381CodecString_for_returns("mp4a.a0.90", 0xa0, 90); + assert_getObjectTypeFromMp4aRFC6381CodecString_for_returns("mp4a.Ff.99", 0xff, 99); + assert_getObjectTypeFromMp4aRFC6381CodecString_for_returns("mp4a.D0.9", 0xd0, 9); + } + + private static void assert_getObjectTypeFromMp4aRFC6381CodecString_for_returns( + String codec, int expectedObjectTypeIndicator, int expectedAudioObjectTypeIndicator) { + @Nullable + MimeTypes.Mp4aObjectType objectType = MimeTypes.getObjectTypeFromMp4aRFC6381CodecString(codec); + assertThat(objectType).isNotNull(); + 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 365cff8aff2..fe081a99db8 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 void findNalUnit() { 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 @@ private static void assertDiscardToSpsMatchesExpected(String input, String expec 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 9a2d17cbfc9..deab13880b4 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 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 void readAllBytes() { @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 void readBitInSameByte() { @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 void readBitInMultipleBytes() { @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 void readBits0Bits() { @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 void readBitsByteAligned() { @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 void readBitsNonByteAligned() { @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 void readBitsNegativeValue() { @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 void readBitsToLong0Bits() { @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 void readBitsToLongByteAligned() { @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 void readBitsToLongNonByteAligned() { @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 void readBitsToLongNegativeValue() { @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 void readBitsToByteArray() { @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 void skipBytes() { @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 void skipBitsByteAligned() { @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 void skipBitsNonByteAligned() { @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 void setPositionByteAligned() { @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 void setPositionNonByteAligned() { @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 void byteAlignFromNonByteAligned() { @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 void byteAlignFromByteAligned() { @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 void readBytesAsStringDefaultsToUtf8() { @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 void putFullBytes() { @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 void noOverwriting() { 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 894de47e6ed..919f50fdc55 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 void modificationsAffectParsableArray() { 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 void readingUnsignedLongWithMsbSetThrows() { 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 void readingBytesReturnsCopy() { 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 8fffb9a5d4e..3e7b2e9558c 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 void reset() { 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 825988cf484..cda9e054f16 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/UtilTest.java @@ -19,16 +19,26 @@ import static com.google.android.exoplayer2.util.Util.binarySearchFloor; import static com.google.android.exoplayer2.util.Util.escapeFileName; import static com.google.android.exoplayer2.util.Util.getCodecsOfType; +import static com.google.android.exoplayer2.util.Util.getStringForTime; import static com.google.android.exoplayer2.util.Util.parseXsDateTime; import static com.google.android.exoplayer2.util.Util.parseXsDuration; import static com.google.android.exoplayer2.util.Util.unescapeFileName; 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; import java.util.Arrays; +import java.util.Formatter; import java.util.Random; import java.util.zip.Deflater; import org.junit.Test; @@ -82,15 +92,77 @@ public void subtrackWithOverflowDefault_withUnderflow_returnsOverflowDefault() { } @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 @@ -711,9 +783,48 @@ public void toLong_withBigNegativeValue_returnsValue() { 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"); } @@ -760,7 +871,7 @@ public void escapeUnescapeFileName_returnsEscapedString() { 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); } } @@ -789,9 +900,33 @@ public void crc8_returnsUpdatedCrc8() { assertThat(result).isEqualTo(0x4); } + @Test + public void getBigEndianInt_fromBigEndian() { + byte[] bytes = {0x1F, 0x2E, 0x3D, 0x4C}; + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + + assertThat(Util.getBigEndianInt(byteBuffer, 0)).isEqualTo(0x1F2E3D4C); + } + + @Test + public void getBigEndianInt_fromLittleEndian() { + byte[] bytes = {(byte) 0xC2, (byte) 0xD3, (byte) 0xE4, (byte) 0xF5}; + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + + assertThat(Util.getBigEndianInt(byteBuffer, 0)).isEqualTo(0xC2D3E4F5); + } + + @Test + public void getBigEndianInt_unaligned() { + byte[] bytes = {9, 8, 7, 6, 5}; + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + + assertThat(Util.getBigEndianInt(byteBuffer, 1)).isEqualTo(0x08070605); + } + @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); @@ -803,7 +938,7 @@ public void inflate_withDeflatedData_success() { 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 @@ -877,7 +1012,7 @@ public void normalizeLanguageCode_iso6392BibliographicalAndTextualCodes_areNorma 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")); @@ -935,18 +1070,24 @@ public void normalizeLanguageCode_macrolanguageTags_areFullyMaintained() { } @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(); + + assertThat(Util.tableExists(database, "table")).isFalse(); } @Test - public void toList_emptyArrayPassed_returnsEmptyList() { - assertThat(Util.toList(new int[0])).isEmpty(); + public void getStringForTime_withNegativeTime_setsNegativePrefix() { + assertThat(getStringForTime(new StringBuilder(), new Formatter(), /* timeMs= */ -35000)) + .isEqualTo("-00:35"); } private static void assertEscapeUnescapeFileName(String fileName, String escapedFileName) { @@ -966,4 +1107,51 @@ private static LongArray newLongArray(long... values) { } 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 8b8c3fd520f..ddeb734947c 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 36038b9078b..67c33679cdb 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.offline.DownloaderConstructorHelper); + (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.offline.DownloaderConstructorHelper); + (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.offline.DownloaderConstructorHelper); + (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 39c12e1b755..22442ca85f8 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 @@ 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.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 @@ 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 void readFromOffsetFixedLength() throws Exception { @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 void readInvalidUri() throws Exception { } } + @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 5aeca440ffe..b56e8838c54 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 @@ public interface PlayerControl { */ 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 @@ private void abandonAudioFocus() { private int requestAudioFocusDefault() { return audioManager.requestAudioFocus( focusListener, - Util.getStreamTypeForAudioUsage(Assertions.checkNotNull(audioAttributes).usage), + Util.getStreamTypeForAudioUsage(checkNotNull(audioAttributes).usage), focusGain); } @@ -227,7 +230,7 @@ private int requestAudioFocusV26() { 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 5692b1dae76..9d7af2dce61 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java @@ -158,11 +158,41 @@ public final int getPreviousWindowIndex() { 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 fc2cbbce28c..315431c6e81 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BaseRenderer.java @@ -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 final void enable( 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 final void start() throws ExoPlaybackException { } @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 final boolean isCurrentStreamFinal() { @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 @@ protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) *

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 @@ protected void onStarted() throws ExoPlaybackException { /** * 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 @@ protected void onReset() { // 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); } /** @@ -318,6 +341,19 @@ protected final int getIndex() { */ protected final ExoPlaybackException createRendererException( Exception cause, @Nullable Format format) { + return createRendererException(cause, format, /* isRecoverable= */ false); + } + + /** + * Creates an {@link ExoPlaybackException} of type {@link ExoPlaybackException#TYPE_RENDERER} for + * this renderer. + * + * @param cause The cause of the exception. + * @param format The current format used by the renderer. May be null. + * @param isRecoverable If the error is recoverable by disabling and re-enabling the renderer. + */ + protected final ExoPlaybackException createRendererException( + Exception cause, @Nullable Format format, boolean isRecoverable) { @FormatSupport int formatSupport = RendererCapabilities.FORMAT_HANDLED; if (format != null && !throwRendererExceptionIsExecuting) { // Prevent recursive re-entry from subclass supportsFormat implementations. @@ -331,7 +367,7 @@ protected final ExoPlaybackException createRendererException( } } return ExoPlaybackException.createForRenderer( - cause, getName(), getIndex(), format, formatSupport); + cause, getName(), getIndex(), format, formatSupport, isRecoverable); } /** @@ -339,6 +375,9 @@ protected final ExoPlaybackException createRendererException( * {@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 +390,17 @@ protected final ExoPlaybackException createRendererException( @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 +417,23 @@ protected final int readSource( * 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 7f24e6113ff..d46b939c1fc 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 @@ private static void seekToOffset(Player player, long offsetMs) { 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 ce35c8959a4..2b72fc6c095 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 static final class Builder { 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 Builder() { * * @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 Builder setAllocator(DefaultAllocator allocator) { * 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 Builder setBufferDurationsMs( * * @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 Builder setTargetBufferBytes(int targetBufferBytes) { * @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 Builder setPrioritizeTimeOverSizeThresholds(boolean prioritizeTimeOverSiz * @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); } @@ -370,7 +380,8 @@ public boolean retainBackBufferFromKeyframe() { } @Override - public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) { + public boolean shouldContinueLoading( + long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) { boolean targetBufferSizeReached = allocator.getTotalBytesAllocated() >= targetBufferBytes; long minBufferUs = this.minBufferUs; if (playbackSpeed > 1) { @@ -378,10 +389,10 @@ public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpee // 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) { @@ -422,7 +433,7 @@ protected int calculateTargetBufferBytes( 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 5700964967c..9ee1846fc12 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 @@ */ /* 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 @@ public interface PlaybackSpeedListener { 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 @@ public void onRendererEnabled(Renderer renderer) throws ExoPlaybackException { } this.rendererClock = rendererMediaClock; this.rendererClockSource = renderer; - rendererClock.setPlaybackSpeed(standaloneClock.getPlaybackSpeed()); + rendererClock.setPlaybackParameters(standaloneClock.getPlaybackParameters()); } } @@ -133,23 +134,25 @@ public long syncAndGetPositionUs(boolean isReadingAhead) { @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 @@ private void syncClocks(boolean isReadingAhead) { } 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 @@ private void syncClocks(boolean isReadingAhead) { } // 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 ccdddb8ce79..5d130442b32 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -17,15 +17,17 @@ 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; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.metadata.MetadataRenderer; @@ -89,7 +91,10 @@ public class DefaultRenderersFactory implements RenderersFactory { private long allowedVideoJoiningTimeMs; private boolean enableDecoderFallback; private MediaCodecSelector mediaCodecSelector; - @MediaCodecRenderer.MediaCodecOperationMode private int mediaCodecOperationMode; + private boolean enableAsyncQueueing; + private boolean enableFloatOutput; + private boolean enableAudioTrackPlaybackParams; + private boolean enableOffload; /** @param context A {@link Context}. */ public DefaultRenderersFactory(Context context) { @@ -97,7 +102,6 @@ public DefaultRenderersFactory(Context context) { extensionRendererMode = EXTENSION_RENDERER_MODE_OFF; allowedVideoJoiningTimeMs = DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; mediaCodecSelector = MediaCodecSelector.DEFAULT; - mediaCodecOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; } /** @@ -143,17 +147,16 @@ public DefaultRenderersFactory setExtensionRendererMode( } /** - * Set the {@link MediaCodecRenderer.MediaCodecOperationMode} of {@link MediaCodecRenderer} - * instances. + * Enable asynchronous buffer queueing for both {@link MediaCodecAudioRenderer} and {@link + * MediaCodecVideoRenderer} instances. * *

This method is experimental, and will be renamed or removed in a future release. * - * @param mode The {@link MediaCodecRenderer.MediaCodecOperationMode} to set. + * @param enabled Whether asynchronous queueing is enabled. * @return This factory, for convenience. */ - public DefaultRenderersFactory experimental_setMediaCodecOperationMode( - @MediaCodecRenderer.MediaCodecOperationMode int mode) { - mediaCodecOperationMode = mode; + public DefaultRenderersFactory experimentalEnableAsynchronousBufferQueueing(boolean enabled) { + enableAsyncQueueing = enabled; return this; } @@ -183,6 +186,68 @@ public DefaultRenderersFactory setMediaCodecSelector(MediaCodecSelector mediaCod 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 +280,20 @@ public Renderer[] createRenderers( 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 +336,7 @@ protected void buildVideoRenderers( eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); - videoRenderer.experimental_setMediaCodecOperationMode(mediaCodecOperationMode); + videoRenderer.experimentalEnableAsynchronousBufferQueueing(enableAsyncQueueing); out.add(videoRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { @@ -369,8 +439,7 @@ protected void buildVideoRenderers( * @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 +449,7 @@ protected void buildAudioRenderers( @ExtensionRendererMode int extensionRendererMode, MediaCodecSelector mediaCodecSelector, boolean enableDecoderFallback, - AudioProcessor[] audioProcessors, + AudioSink audioSink, Handler eventHandler, AudioRendererEventListener eventListener, ArrayList out) { @@ -391,8 +460,8 @@ protected void buildAudioRenderers( enableDecoderFallback, eventHandler, eventListener, - new DefaultAudioSink(AudioCapabilities.getCapabilities(context), audioProcessors)); - audioRenderer.experimental_setMediaCodecOperationMode(mediaCodecOperationMode); + audioSink); + audioRenderer.experimentalEnableAsynchronousBufferQueueing(enableAsyncQueueing); out.add(audioRenderer); if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { @@ -411,10 +480,10 @@ protected void buildAudioRenderers( 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 +501,10 @@ protected void buildAudioRenderers( 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 +523,10 @@ protected void buildAudioRenderers( 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 +599,29 @@ protected void buildMiscellaneousRenderers(Context context, Handler eventHandler } /** - * 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 cd9662a2515..d69b747f8dc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlaybackException.java @@ -17,6 +17,7 @@ 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,20 +27,27 @@ 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. - */ +/** Thrown when a non locally recoverable playback failure occurs. */ 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 +75,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 +122,31 @@ 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; + + /** + * Whether the error may be recoverable. + * + *

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

If the {@link #type} is {@link #TYPE_RENDERER}, it may be possible to recover from the error + * by disabling and re-enabling the renderers. + */ + /* package */ final boolean isRecoverable; + @Nullable private final Throwable cause; /** @@ -122,6 +176,34 @@ public static ExoPlaybackException createForRenderer( int rendererIndex, @Nullable Format rendererFormat, @FormatSupport int rendererFormatSupport) { + return createForRenderer( + cause, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + /* isRecoverable= */ false); + } + + /** + * Creates an instance of type {@link #TYPE_RENDERER}. + * + * @param cause The cause of the failure. + * @param rendererIndex The index of the renderer in which the failure occurred. + * @param rendererFormat The {@link Format} the renderer was using at the time of the exception, + * or null if the renderer wasn't using a {@link Format}. + * @param rendererFormatSupport The {@link FormatSupport} of the renderer for {@code + * rendererFormat}. Ignored if {@code rendererFormat} is null. + * @param isRecoverable If the failure can be recovered by disabling and re-enabling the renderer. + * @return The created instance. + */ + public static ExoPlaybackException createForRenderer( + Exception cause, + String rendererName, + int rendererIndex, + @Nullable Format rendererFormat, + @FormatSupport int rendererFormatSupport, + boolean isRecoverable) { return new ExoPlaybackException( TYPE_RENDERER, cause, @@ -129,7 +211,9 @@ public static ExoPlaybackException createForRenderer( rendererName, rendererIndex, rendererFormat, - rendererFormat == null ? RendererCapabilities.FORMAT_HANDLED : rendererFormatSupport); + rendererFormat == null ? RendererCapabilities.FORMAT_HANDLED : rendererFormatSupport, + TIMEOUT_OPERATION_UNDEFINED, + isRecoverable); } /** @@ -158,10 +242,31 @@ public static ExoPlaybackException createForRemote(String message) { * @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, + /* isRecoverable= */ false); + } + private ExoPlaybackException(@Type int type, Throwable cause) { this( type, @@ -170,7 +275,9 @@ private ExoPlaybackException(@Type int type, Throwable cause) { /* rendererName= */ null, /* rendererIndex= */ C.INDEX_UNSET, /* rendererFormat= */ null, - /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED); + /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, + TIMEOUT_OPERATION_UNDEFINED, + /* isRecoverable= */ false); } private ExoPlaybackException(@Type int type, String message) { @@ -181,7 +288,9 @@ private ExoPlaybackException(@Type int type, String message) { /* rendererName= */ null, /* rendererIndex= */ C.INDEX_UNSET, /* rendererFormat= */ null, - /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED); + /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED, + /* timeoutOperation= */ TIMEOUT_OPERATION_UNDEFINED, + /* isRecoverable= */ false); } private ExoPlaybackException( @@ -191,8 +300,10 @@ private ExoPlaybackException( @Nullable String rendererName, int rendererIndex, @Nullable Format rendererFormat, - @FormatSupport int rendererFormatSupport) { - super( + @FormatSupport int rendererFormatSupport, + @TimeoutOperation int timeoutOperation, + boolean isRecoverable) { + this( deriveMessage( type, customMessage, @@ -200,14 +311,41 @@ private ExoPlaybackException( rendererIndex, rendererFormat, rendererFormatSupport), - cause); + cause, + type, + rendererName, + rendererIndex, + rendererFormat, + rendererFormatSupport, + /* mediaPeriodId= */ null, + timeoutOperation, + /* timestampMs= */ SystemClock.elapsedRealtime(), + isRecoverable); + } + + 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, + boolean isRecoverable) { + 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; + this.isRecoverable = isRecoverable; } /** @@ -250,6 +388,39 @@ public OutOfMemoryError getOutOfMemoryError() { 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, + isRecoverable); + } + @Nullable private static String deriveMessage( @Type int type, @@ -280,6 +451,9 @@ private static String deriveMessage( 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 d7790378170..ccb67866a41 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -16,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 @@ *

    *
  • 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 @@ final class Builder { 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 @@ final class Builder { * 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 Builder(Context context, Renderer... renderers) { 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 Builder( * * @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 Builder experimental_setReleaseTimeoutMs(long timeoutMs) { * @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 Builder setUseLazyPreparation(boolean useLazyPreparation) { 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 ExoPlayer build() { 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 ExoPlayer build() { * @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 bb4acccd8b9..dfe96ffa322 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -101,11 +101,7 @@ public static SimpleExoPlayer newSimpleInstance( 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 static SimpleExoPlayer newSimpleInstance( loadControl, bandwidthMeter, new AnalyticsCollector(Clock.DEFAULT), - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -142,7 +138,7 @@ public static SimpleExoPlayer newSimpleInstance( trackSelector, loadControl, analyticsCollector, - Util.getLooper()); + Util.getCurrentOrMainLooper()); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -153,14 +149,14 @@ public static SimpleExoPlayer newSimpleInstance( RenderersFactory renderersFactory, TrackSelector trackSelector, LoadControl loadControl, - Looper looper) { + Looper applicationLooper) { return newSimpleInstance( context, renderersFactory, trackSelector, loadControl, new AnalyticsCollector(Clock.DEFAULT), - looper); + applicationLooper); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -172,7 +168,7 @@ public static SimpleExoPlayer newSimpleInstance( TrackSelector trackSelector, LoadControl loadControl, AnalyticsCollector analyticsCollector, - Looper looper) { + Looper applicationLooper) { return newSimpleInstance( context, renderersFactory, @@ -180,7 +176,7 @@ public static SimpleExoPlayer newSimpleInstance( loadControl, DefaultBandwidthMeter.getSingletonInstance(context), analyticsCollector, - looper); + applicationLooper); } /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ @@ -193,18 +189,18 @@ public static SimpleExoPlayer newSimpleInstance( LoadControl loadControl, BandwidthMeter bandwidthMeter, AnalyticsCollector analyticsCollector, - Looper looper) { + Looper applicationLooper) { return new SimpleExoPlayer( context, renderersFactory, trackSelector, - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), loadControl, bandwidthMeter, analyticsCollector, /* useLazyPreparation= */ true, Clock.DEFAULT, - looper); + applicationLooper); } /** @deprecated Use {@link ExoPlayer.Builder} instead. */ @@ -220,7 +216,8 @@ public static ExoPlayer newInstance( @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. */ @@ -231,14 +228,14 @@ public static ExoPlayer newInstance( Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl, - Looper looper) { + Looper applicationLooper) { return newInstance( context, renderers, trackSelector, loadControl, DefaultBandwidthMeter.getSingletonInstance(context), - looper); + applicationLooper); } /** @deprecated Use {@link ExoPlayer.Builder} instead. */ @@ -249,16 +246,18 @@ public static ExoPlayer newInstance( TrackSelector trackSelector, LoadControl loadControl, BandwidthMeter bandwidthMeter, - Looper looper) { + Looper applicationLooper) { return new ExoPlayerImpl( renderers, trackSelector, - DefaultMediaSourceFactory.newInstance(context), + new DefaultMediaSourceFactory(context), loadControl, bandwidthMeter, /* analyticsCollector= */ null, /* useLazyPreparation= */ true, + SeekParameters.DEFAULT, + /* pauseAtEndOfMediaItems= */ false, Clock.DEFAULT, - looper); + 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 0344b097077..1b0b34bd7bd 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -15,10 +15,15 @@ */ 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; @@ -28,6 +33,7 @@ import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelector; @@ -62,15 +68,19 @@ private final Renderer[] renderers; private final TrackSelector trackSelector; - private final Handler eventHandler; + 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; @@ -79,11 +89,10 @@ @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; + private boolean hasAdsMediaSource; // Playback information when there is no pending seek/set source operation. private PlaybackInfo playbackInfo; @@ -105,9 +114,11 @@ * @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 looper The {@link Looper} which must be used for all calls to the player and which is - * used to call listeners on. + * @param applicationLooper The {@link Looper} that must be used for all calls to the player and + * which is used to call listeners on. */ @SuppressLint("HandlerLeak") public ExoPlayerImpl( @@ -118,18 +129,25 @@ public ExoPlayerImpl( BandwidthMeter bandwidthMeter, @Nullable AnalyticsCollector analyticsCollector, boolean useLazyPreparation, + SeekParameters seekParameters, + boolean pauseAtEndOfMediaItems, Clock clock, - Looper looper) { + Looper applicationLooper) { Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "]"); - Assertions.checkState(renderers.length > 0); - this.renderers = Assertions.checkNotNull(renderers); - this.trackSelector = Assertions.checkNotNull(trackSelector); + 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( @@ -137,20 +155,17 @@ public ExoPlayerImpl( new TrackSelection[renderers.length], null); period = new Timeline.Period(); - playbackSpeed = Player.DEFAULT_PLAYBACK_SPEED; - seekParameters = SeekParameters.DEFAULT; maskingWindowIndex = C.INDEX_UNSET; - eventHandler = - new Handler(looper) { - @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( @@ -162,8 +177,11 @@ public void handleMessage(Message msg) { repeatMode, shuffleModeEnabled, analyticsCollector, - eventHandler, - clock); + seekParameters, + pauseAtEndOfMediaItems, + applicationLooper, + clock, + playbackInfoUpdateListener); internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); } @@ -177,18 +195,23 @@ public void handleMessage(Message msg) { * * @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 @@ -228,11 +251,12 @@ public Looper getPlaybackLooper() { @Override public Looper getApplicationLooper() { - return eventHandler.getLooper(); + return applicationLooper; } @Override public void addListener(Player.EventListener listener) { + Assertions.checkNotNull(listener); listeners.addIfAbsent(new ListenerHolder(listener)); } @@ -283,13 +307,10 @@ public void prepare() { 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 @@ -371,7 +392,7 @@ public void setMediaSources( @Override public void addMediaItems(List mediaItems) { - addMediaItems(/* index= */ mediaSourceHolders.size(), mediaItems); + addMediaItems(/* index= */ mediaSourceHolderSnapshots.size(), mediaItems); } @Override @@ -391,25 +412,25 @@ public void addMediaSource(int index, MediaSource mediaSource) { @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); - for (int i = 0; i < mediaSources.size(); i++) { - Assertions.checkArgument(mediaSources.get(i) != null); - } - int currentWindowIndex = getCurrentWindowIndex(); - long currentPositionMs = getCurrentPosition(); + validateMediaSources(mediaSources, /* mediaSourceReplacement= */ false); 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, @@ -419,8 +440,14 @@ public void addMediaSources(int index, List mediaSources) { @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 @@ -428,19 +455,21 @@ public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) { 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, @@ -450,21 +479,23 @@ public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) { @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, @@ -502,7 +533,6 @@ public void setPlayWhenReady( && playbackInfo.playbackSuppressionReason == playbackSuppressionReason) { return; } - maskWithCurrentPosition(); pendingOperationAcks++; PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason); @@ -566,23 +596,22 @@ public void seekTo(int windowIndex, long positionMs) { // 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"); - eventHandler - .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, @@ -590,44 +619,29 @@ public void seekTo(int windowIndex, long positionMs) { /* 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 @@ -650,23 +664,33 @@ public SeekParameters getSeekParameters() { 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, @@ -685,15 +709,18 @@ public void release() { 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))); } - eventHandler.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 @@ -708,7 +735,7 @@ public PlayerMessage createMessage(Target target) { @Override public int getCurrentPeriodIndex() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingPeriodIndex; } else { return playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); @@ -734,7 +761,7 @@ public long getDuration() { @Override public long getCurrentPosition() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowPositionMs; } else if (playbackInfo.periodId.isAd()) { return C.usToMs(playbackInfo.positionUs); @@ -760,7 +787,7 @@ public long getTotalBufferedDuration() { @Override public boolean isPlayingAd() { - return !shouldMaskPosition() && playbackInfo.periodId.isAd(); + return playbackInfo.periodId.isAd(); } @Override @@ -787,7 +814,7 @@ public long getContentPosition() { @Override public long getContentBufferedPosition() { - if (shouldMaskPosition()) { + if (playbackInfo.timeline.isEmpty()) { return maskingWindowPositionMs; } if (playbackInfo.loadingMediaPeriodId.windowSequenceNumber @@ -818,6 +845,12 @@ public int getRendererType(int index) { return renderers[index].getTrackType(); } + @Override + @Nullable + public TrackSelector getTrackSelector() { + return trackSelector; + } + @Override public TrackGroupArray getCurrentTrackGroups() { return playbackInfo.trackGroups; @@ -833,22 +866,8 @@ public Timeline getCurrentTimeline() { 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) @@ -864,23 +883,6 @@ private List createMediaSources(List mediaItems) { 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) { @@ -891,10 +893,20 @@ private void handlePlaybackInfo(ExoPlayerImplInternal.PlaybackInfoUpdate playbac 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; @@ -908,43 +920,6 @@ private void handlePlaybackInfo(ExoPlayerImplInternal.PlaybackInfoUpdate playbac } } - 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, @@ -955,6 +930,22 @@ private void updatePlaybackInfo( // 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, @@ -964,29 +955,75 @@ private void updatePlaybackInfo( 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, long startPositionMs, boolean resetToDefaultPosition) { - for (int i = 0; i < mediaSources.size(); i++) { - Assertions.checkArgument(mediaSources.get(i) != null); - } + validateMediaSources(mediaSources, /* mediaSourceReplacement= */ true); 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 @@ private void setMediaSourcesInternal( 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 @@ private void setMediaSourcesInternal( 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 @@ private List addMediaSourceHolders( 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,148 +1079,231 @@ private List addMediaSourceHolders( 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); - return removed; - } - - private PlaybackInfo maskTimeline() { - return playbackInfo.copyWithTimeline( - mediaSourceHolders.isEmpty() - ? Timeline.EMPTY - : new MediaSourceList.PlaylistTimeline(mediaSourceHolders, 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); + if (mediaSourceHolderSnapshots.isEmpty()) { + hasAdsMediaSource = false; + } + } + + /** + * Validates media sources before any modification of the existing list of media sources is made. + * This way we can throw an exception before changing the state of the player in case of a + * validation failure. + * + * @param mediaSources The media sources to set or add. + * @param mediaSourceReplacement Whether the given media sources will replace existing ones. + */ + private void validateMediaSources( + List mediaSources, boolean mediaSourceReplacement) { + if (hasAdsMediaSource && !mediaSourceReplacement && !mediaSources.isEmpty()) { + // Adding media sources to an ads media source is not allowed + // (see https://github.com/google/ExoPlayer/issues/3750). + throw new IllegalStateException(); + } + int sizeAfterModification = + mediaSources.size() + (mediaSourceReplacement ? 0 : mediaSourceHolderSnapshots.size()); + for (int i = 0; i < mediaSources.size(); i++) { + MediaSource mediaSource = checkNotNull(mediaSources.get(i)); + if (mediaSource instanceof AdsMediaSource) { + if (sizeAfterModification > 1) { + // Ads media sources only allowed with a single source + // (see https://github.com/google/ExoPlayer/issues/3750). + throw new IllegalArgumentException(); + } + hasAdsMediaSource = true; } + } + } + + private Timeline createMaskingTimeline() { + return new PlaylistTimeline(mediaSourceHolderSnapshots, shuffleOrder); + } + + 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; + + 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 { - // 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); + 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) { @@ -1206,10 +1330,6 @@ private long periodPositionUsToWindowPositionMs(MediaPeriodId periodId, long pos return positionMs; } - private boolean shouldMaskPosition() { - return playbackInfo.timeline.isEmpty() || pendingOperationAcks > 0; - } - private static final class PlaybackInfoUpdate implements Runnable { private final PlaybackInfo playbackInfo; @@ -1218,16 +1338,22 @@ private static final class PlaybackInfoUpdate implements Runnable { 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; + private final boolean sleepingForOffloadChanged; public PlaybackInfoUpdate( PlaybackInfo playbackInfo, @@ -1237,6 +1363,9 @@ public PlaybackInfoUpdate( boolean positionDiscontinuity, @DiscontinuityReason int positionDiscontinuityReason, @TimelineChangeReason int timelineChangeReason, + boolean mediaItemTransitioned, + @MediaItemTransitionReason int mediaItemTransitionReason, + @Nullable MediaItem mediaItem, @PlayWhenReadyChangeReason int playWhenReadyChangeReason, boolean seekProcessed) { this.playbackInfo = playbackInfo; @@ -1245,6 +1374,9 @@ public PlaybackInfoUpdate( 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; @@ -1259,6 +1391,12 @@ public PlaybackInfoUpdate( playbackSuppressionReasonChanged = previousPlaybackInfo.playbackSuppressionReason != playbackInfo.playbackSuppressionReason; isPlayingChanged = isPlaying(previousPlaybackInfo) != isPlaying(playbackInfo); + playbackParametersChanged = + !previousPlaybackInfo.playbackParameters.equals(playbackInfo.playbackParameters); + offloadSchedulingEnabledChanged = + previousPlaybackInfo.offloadSchedulingEnabled != playbackInfo.offloadSchedulingEnabled; + sleepingForOffloadChanged = + previousPlaybackInfo.sleepingForOffload != playbackInfo.sleepingForOffload; } @SuppressWarnings("deprecation") @@ -1274,6 +1412,11 @@ public void run() { listenerSnapshot, listener -> listener.onPositionDiscontinuity(positionDiscontinuityReason)); } + if (mediaItemTransitioned) { + invokeAll( + listenerSnapshot, + listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason)); + } if (playbackErrorChanged) { invokeAll(listenerSnapshot, listener -> listener.onPlayerError(playbackInfo.playbackError)); } @@ -1319,9 +1462,29 @@ public void run() { 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)); + } + if (sleepingForOffloadChanged) { + invokeAll( + listenerSnapshot, + listener -> + listener.onExperimentalSleepingForOffloadChanged(playbackInfo.sleepingForOffload)); + } } private static boolean isPlaying(PlaybackInfo playbackInfo) { @@ -1337,4 +1500,26 @@ private static void invokeAll( 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 56cce6d984d..45af6d601f3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -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.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.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 @@ 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 @@ 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,20 @@ 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 MSG_ATTEMPT_ERROR_RECOVERY = 25; 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 +164,7 @@ 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 +172,7 @@ 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,12 +189,15 @@ @Player.RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private boolean foregroundMode; + private boolean requestForRendererSleep; + private boolean offloadSchedulingEnabled; private int enabledRendererCount; @Nullable private SeekPosition pendingInitialSeekPosition; private long rendererPositionUs; - private int nextPendingMessageIndex; + private int nextPendingMessageIndexHint; private boolean deliverPendingMessageAtStartPositionRequired; + @Nullable private ExoPlaybackException pendingRecoverableError; private long releaseTimeoutMs; private boolean throwWhenStuckBuffering; @@ -146,8 +211,12 @@ public ExoPlayerImplInternal( @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 +224,14 @@ public ExoPlayerImplInternal( 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 +245,33 @@ public ExoPlayerImplInternal( 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 +305,16 @@ public void seekTo(Timeline timeline, int windowIndex, long positionUs) { .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 +371,24 @@ public synchronized void sendMessage(PlayerMessage message) { 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 +398,16 @@ public synchronized boolean release() { } 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 +436,11 @@ public void onTrackSelectionsInvalidated() { 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 +471,8 @@ public boolean handleMessage(Message msg) { 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 +482,7 @@ public boolean handleMessage(Message msg) { /* 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 +493,9 @@ public boolean handleMessage(Message msg) { 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 +524,12 @@ public boolean handleMessage(Message msg) { 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_ATTEMPT_ERROR_RECOVERY: + attemptErrorRecovery((ExoPlaybackException) msg.obj); + break; case MSG_RELEASE: releaseInternal(); // Return immediately to not send playback info updates after release. @@ -467,32 +539,49 @@ public boolean handleMessage(Message msg) { } maybeNotifyPlaybackInfoChanged(); } catch (ExoPlaybackException e) { - Log.e(TAG, "Playback error", e); - stopInternal( - /* forceResetRenderers= */ true, - /* resetPositionAndState= */ false, - /* acknowledgeStop= */ false); - playbackInfo = playbackInfo.copyWithPlaybackError(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); + } + } + if (e.isRecoverable && pendingRecoverableError == null) { + Log.w(TAG, "Recoverable playback error", e); + pendingRecoverableError = e; + Message message = handler.obtainMessage(MSG_ATTEMPT_ERROR_RECOVERY, e); + // Given that the player is now in an unhandled exception state, the error needs to be + // recovered or the player stopped before any other message is handled. + message.getTarget().sendMessageAtFrontOfQueue(message); + } else { + if (pendingRecoverableError != null) { + e.addSuppressed(pendingRecoverableError); + pendingRecoverableError = null; + } + Log.e(TAG, "Playback error", e); + stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false); + playbackInfo = playbackInfo.copyWithPlaybackError(e); + } maybeNotifyPlaybackInfoChanged(); } catch (IOException e) { ExoPlaybackException error = ExoPlaybackException.createForSource(e); + @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(); } @@ -501,60 +590,68 @@ public boolean handleMessage(Message msg) { // Private methods. + private void attemptErrorRecovery(ExoPlaybackException exceptionToRecoverFrom) + throws ExoPlaybackException { + Assertions.checkArgument( + exceptionToRecoverFrom.isRecoverable + && exceptionToRecoverFrom.type == ExoPlaybackException.TYPE_RENDERER); + try { + seekToCurrentPosition(/* sendDiscontinuity= */ true); + } catch (Exception e) { + exceptionToRecoverFrom.addSuppressed(e); + throw exceptionToRecoverFrom; + } + } + /** - * Blocks the current thread until {@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 +664,7 @@ private void setState(int state) { 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 +675,6 @@ private void prepareInternal() { /* resetRenderers= */ false, /* resetPosition= */ false, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); loadControl.onPrepared(); setState(playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); @@ -592,7 +688,7 @@ private void setMediaItemsInternal(MediaSourceListUpdateMessage mediaSourceListU if (mediaSourceListUpdateMessage.windowIndex != C.INDEX_UNSET) { pendingInitialSeekPosition = new SeekPosition( - new MediaSourceList.PlaylistTimeline( + new PlaylistTimeline( mediaSourceListUpdateMessage.mediaSourceHolders, mediaSourceListUpdateMessage.shuffleOrder), mediaSourceListUpdateMessage.windowIndex, @@ -671,11 +767,26 @@ private void setPlayWhenReadyInternal( private void setPauseAtEndOfWindowInternal(boolean pauseAtEndOfWindow) throws ExoPlaybackException { this.pauseAtEndOfWindow = pauseAtEndOfWindow; - if (queue.getReadingPeriod() != queue.getPlayingPeriod()) { + resetPendingPauseAtEndOfPeriod(); + 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); } - resetPendingPauseAtEndOfPeriod(); - handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); } private void setRepeatModeInternal(@Player.RepeatMode int repeatMode) @@ -817,10 +928,7 @@ private void doSomeWork() throws ExoPlaybackException, IOException { // tracks in the current period have uneven durations and are still being read by another // renderer. See: https://github.com/google/ExoPlayer/issues/1874. boolean isReadingAhead = playingPeriodHolder.sampleStreams[i] != renderer.getStream(); - boolean isWaitingForNextStream = - !isReadingAhead - && playingPeriodHolder.getNext() != null - && renderer.hasReadStreamToEnd(); + boolean isWaitingForNextStream = !isReadingAhead && renderer.hasReadStreamToEnd(); boolean allowsPlayback = isReadingAhead || isWaitingForNextStream || renderer.isReady() || renderer.isEnded(); renderersAllowPlayback = renderersAllowPlayback && allowsPlayback; @@ -852,6 +960,7 @@ private void doSomeWork() throws ExoPlaybackException, IOException { } else if (playbackInfo.playbackState == Player.STATE_BUFFERING && shouldTransitionToReadyState(renderersAllowPlayback)) { setState(Player.STATE_READY); + pendingRecoverableError = null; // Any pending error was successfully recovered from. if (shouldPlayWhenReady()) { startRenderers(); } @@ -870,7 +979,7 @@ && shouldTransitionToReadyState(renderersAllowPlayback)) { } } if (throwWhenStuckBuffering - && !shouldContinueLoading + && !playbackInfo.isLoading && playbackInfo.totalBufferedDurationUs < 500_000 && isLoadingPossible()) { // Throw if the LoadControl prevents loading even if the buffer is empty or almost empty. We @@ -879,15 +988,23 @@ && isLoadingPossible()) { throw new IllegalStateException("Playback stuck buffering and not loading"); } } + if (offloadSchedulingEnabled != playbackInfo.offloadSchedulingEnabled) { + playbackInfo = playbackInfo.copyWithOffloadSchedulingEnabled(offloadSchedulingEnabled); + } + boolean sleepingForOffload = false; if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) || playbackInfo.playbackState == Player.STATE_BUFFERING) { - scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + sleepingForOffload = !maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); } else { handler.removeMessages(MSG_DO_SOME_WORK); } + if (playbackInfo.sleepingForOffload != sleepingForOffload) { + playbackInfo = playbackInfo.copyWithSleepingForOffload(sleepingForOffload); + } + requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. TraceUtil.endSection(); } @@ -897,6 +1014,15 @@ private void scheduleNextWork(long thisOperationStartTimeMs, long intervalMs) { handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } + private boolean maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { + if (offloadSchedulingEnabled && requestForRendererSleep) { + return false; + } + + scheduleNextWork(operationStartTimeMs, intervalMs); + return true; + } + private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -918,7 +1044,7 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti // 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 +1084,6 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti /* resetRenderers= */ false, /* resetPosition= */ true, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); } else { // Execute the seek in the current media periods. @@ -1060,7 +1185,7 @@ private long seekToPeriodPosition( 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 +1221,10 @@ private void resetRendererPosition(long periodPositionUs) throws ExoPlaybackExce 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 +1251,12 @@ private void setForegroundModeInternal( } } - 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 +1265,8 @@ private void stopInternal( private void releaseInternal() { resetInternal( /* resetRenderers= */ true, - /* resetPosition= */ true, + /* resetPosition= */ false, /* releaseMediaSourceList= */ true, - /* clearMediaSourceList= */ true, /* resetError= */ false); loadControl.onReleased(); setState(Player.STATE_IDLE); @@ -1158,7 +1281,6 @@ private void resetInternal( boolean resetRenderers, boolean resetPosition, boolean releaseMediaSourceList, - boolean clearMediaSourceList, boolean resetError) { handler.removeMessages(MSG_DO_SOME_WORK); rebuffering = false; @@ -1184,26 +1306,17 @@ private void resetInternal( } enabledRendererCount = 0; - Timeline timeline = playbackInfo.timeline; - if (clearMediaSourceList) { - timeline = mediaSourceList.clear(/* shuffleOrder= */ null); - for (PendingMessageInfo pendingMessageInfo : pendingMessages) { - pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); - } - pendingMessages.clear(); - nextPendingMessageIndex = 0; - 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; @@ -1217,7 +1330,7 @@ private void resetInternal( playbackInfo = new PlaybackInfo( - timeline, + playbackInfo.timeline, mediaPeriodId, requestedContentPositionUs, playbackInfo.playbackState, @@ -1228,15 +1341,19 @@ private void resetInternal( mediaPeriodId, playbackInfo.playWhenReady, playbackInfo.playbackSuppressionReason, + playbackInfo.playbackParameters, startPositionUs, /* totalBufferedDurationUs= */ 0, - startPositionUs); + startPositionUs, + offloadSchedulingEnabled, + /* sleepingForOffload= */ false); if (releaseMediaSourceList) { mediaSourceList.release(); } + pendingRecoverableError = null; } - private Pair getDummyFirstMediaPeriodPosition(Timeline timeline) { + private Pair getPlaceholderFirstMediaPeriodPosition(Timeline timeline) { if (timeline.isEmpty()) { return Pair.create(PlaybackInfo.getDummyPeriodForEmptyTimeline(), 0L); } @@ -1286,7 +1403,7 @@ private void sendMessageInternal(PlayerMessage message) throws ExoPlaybackExcept } 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) { @@ -1365,6 +1482,7 @@ private void maybeTriggerPendingMessages(long oldPeriodPositionUs, long newPerio // Correct next index if necessary (e.g. after seeking, timeline changes, or new messages) int currentPeriodIndex = playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); + int nextPendingMessageIndex = min(nextPendingMessageIndexHint, pendingMessages.size()); PendingMessageInfo previousInfo = nextPendingMessageIndex > 0 ? pendingMessages.get(nextPendingMessageIndex - 1) : null; while (previousInfo != null @@ -1410,6 +1528,7 @@ private void maybeTriggerPendingMessages(long oldPeriodPositionUs, long newPerio ? pendingMessages.get(nextPendingMessageIndex) : null; } + nextPendingMessageIndexHint = nextPendingMessageIndex; } private void ensureStopped(Renderer renderer) throws ExoPlaybackException { @@ -1429,7 +1548,7 @@ private void disableRenderer(Renderer renderer) throws ExoPlaybackException { } 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(); @@ -1491,8 +1610,7 @@ private void reselectTracksInternal() throws ExoPlaybackException { 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); } } @@ -1548,7 +1666,7 @@ private boolean shouldTransitionToReadyState(boolean renderersReadyOrEnded) { boolean bufferedToEnd = loadingHolder.isFullyBuffered() && loadingHolder.info.isFinal; return bufferedToEnd || loadControl.shouldStartPlayback( - getTotalBufferedDurationUs(), mediaClock.getPlaybackSpeed(), rebuffering); + getTotalBufferedDurationUs(), mediaClock.getPlaybackParameters().speed, rebuffering); } private boolean isTimelineReady() { @@ -1560,19 +1678,6 @@ private boolean isTimelineReady() { || !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( @@ -1600,7 +1705,6 @@ private void handleMediaSourceListInfoRefreshed(Timeline timeline) throws ExoPla /* resetRenderers= */ false, /* resetPosition= */ false, /* releaseMediaSourceList= */ false, - /* clearMediaSourceList= */ false, /* resetError= */ true); } if (!periodPositionChanged) { @@ -1658,7 +1762,7 @@ private long getMaxRendererReadPositionUs() { 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; @@ -1666,8 +1770,7 @@ private long getMaxRendererReadPositionUs() { 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(); @@ -1676,13 +1779,12 @@ private void updatePeriods() throws ExoPlaybackException, IOException { 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, @@ -1806,7 +1908,10 @@ private boolean replaceStreamsOrDisableRendererForTransition() throws ExoPlaybac // 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); @@ -1897,7 +2002,8 @@ private void handlePeriodPrepared(MediaPeriod mediaPeriod) throws ExoPlaybackExc 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()) { @@ -1922,15 +2028,15 @@ private void handleContinueLoadingRequested(MediaPeriod mediaPeriod) { 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); } } } @@ -1956,7 +2062,7 @@ private boolean shouldContinueLoading() { : loadingPeriodHolder.toPeriodTime(rendererPositionUs) - loadingPeriodHolder.info.startPositionUs; return loadControl.shouldContinueLoading( - playbackPositionUs, bufferedDurationUs, mediaClock.getPlaybackSpeed()); + playbackPositionUs, bufferedDurationUs, mediaClock.getPlaybackParameters().speed); } private boolean isLoadingPossible() { @@ -2063,7 +2169,26 @@ private void enableRenderer(int rendererIndex, boolean wasRendererEnabled) 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) { @@ -2105,7 +2230,7 @@ private long getTotalBufferedDurationUs(long bufferedPositionInLoadingPeriodUs) } long totalBufferedDurationUs = bufferedPositionInLoadingPeriodUs - loadingPeriodHolder.toPeriodTime(rendererPositionUs); - return Math.max(0, totalBufferedDurationUs); + return max(0, totalBufferedDurationUs); } private void updateLoadControlTrackSelection( @@ -2113,10 +2238,14 @@ private void updateLoadControlTrackSelection( 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(); } @@ -2241,13 +2370,18 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange( // Ensure ad insertion metadata is up to date. MediaPeriodId periodIdWithAds = queue.resolveMediaPeriodIdForAds(timeline, newPeriodUid, contentPositionForAdResolutionUs); + boolean earliestCuePointIsUnchangedOrLater = + periodIdWithAds.nextAdGroupIndex == C.INDEX_UNSET + || (oldPeriodId.nextAdGroupIndex != C.INDEX_UNSET + && periodIdWithAds.adGroupIndex >= oldPeriodId.nextAdGroupIndex); + // Drop update if we keep playing the same content (MediaPeriod.periodUid are identical) and + // the only change is that MediaPeriodId.nextAdGroupIndex increased. This postpones a potential + // discontinuity until we reach the former next ad group position. boolean oldAndNewPeriodIdAreSame = oldPeriodId.periodUid.equals(newPeriodUid) && !oldPeriodId.isAd() - && !periodIdWithAds.isAd(); - // Drop update if we keep playing the same content (MediaPeriod.periodUid are identical) and - // only MediaPeriodId.nextAdGroupIndex may have changed. This postpones a potential - // discontinuity until we reach the former next ad group position. + && !periodIdWithAds.isAd() + && earliestCuePointIsUnchangedOrLater; MediaPeriodId newPeriodId = oldAndNewPeriodIdAreSame ? oldPeriodId : periodIdWithAds; long periodPositionUs = contentPositionForAdResolutionUs; @@ -2613,50 +2747,4 @@ public MoveMediaItemsMessage( 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/LoadControl.java b/library/core/src/main/java/com/google/android/exoplayer2/LoadControl.java index d91830a5aa8..94f61bb618c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/LoadControl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/LoadControl.java @@ -87,28 +87,20 @@ void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroups, */ boolean retainBackBufferFromKeyframe(); - /** @deprecated Use {@link LoadControl#shouldContinueLoading(long, long, float)}. */ - @Deprecated - default boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) { - return false; - } - /** * Called by the player to determine whether it should continue to load the source. * * @param playbackPositionUs The current playback position in microseconds, relative to the start * of the {@link Timeline.Period period} that will continue to be loaded if this method - * returns {@code true}. If the playback for this period has not yet started, the value will + * returns {@code true}. If playback of this period has not yet started, the value will be * negative and equal in magnitude to the duration of any media in previous periods still to * be played. * @param bufferedDurationUs The duration of media that's currently buffered. * @param playbackSpeed The current playback speed. * @return Whether the loading should continue. */ - default boolean shouldContinueLoading( - long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) { - return shouldContinueLoading(bufferedDurationUs, playbackSpeed); - } + boolean shouldContinueLoading( + long playbackPositionUs, long bufferedDurationUs, float playbackSpeed); /** * Called repeatedly by the player when it's loading the source, has yet to start playback, and 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 3c7a41439ad..65f40e9c619 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 @@ public void handlePrepared(float playbackSpeed, Timeline timeline) throws ExoPla 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 @@ public long applyTrackSelection( 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 @@ private void disableTrackSelectionsInResult() { } /** - * 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 @@ private void disassociateNoSampleRenderersWithEmptySampleStream( /** * 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 a749f09f930..b64a9c8087a 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 @@ 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 @@ @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 @@ public MediaPeriodHolder enqueueNextMediaPeriodHolder( oldFrontPeriodUid = null; loading = newPeriodHolder; length++; + notifyQueueUpdate(); return newPeriodHolder; } @@ -203,6 +221,7 @@ public MediaPeriodHolder getReadingPeriod() { public MediaPeriodHolder advanceReadingPeriod() { Assertions.checkState(reading != null && reading.getNext() != null); reading = reading.getNext(); + notifyQueueUpdate(); return reading; } @@ -228,6 +247,7 @@ public MediaPeriodHolder advancePlayingPeriod() { oldFrontPeriodWindowSequenceNumber = playing.info.id.windowSequenceNumber; } playing = playing.getNext(); + notifyQueueUpdate(); return playing; } @@ -241,6 +261,9 @@ public MediaPeriodHolder advancePlayingPeriod() { */ 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 @@ public boolean removeAfter(MediaPeriodHolder mediaPeriodHolder) { 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 @@ public MediaPeriodId resolveMediaPeriodIdForAds( // 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 @@ private boolean updateForPlaybackModeChange(Timeline timeline) { /** * 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 @@ private MediaPeriodInfo getFollowingMediaPeriodInfo( period, nextWindowIndex, /* windowPositionUs= */ C.TIME_UNSET, - /* defaultPositionProjectionUs= */ Math.max(0, bufferedDurationUs)); + /* defaultPositionProjectionUs= */ max(0, bufferedDurationUs)); if (defaultPosition == null) { return null; } @@ -651,7 +694,7 @@ private MediaPeriodInfo getFollowingMediaPeriodInfo( 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 @@ private MediaPeriodInfo getFollowingMediaPeriodInfo( } } + @Nullable private MediaPeriodInfo getMediaPeriodInfo( Timeline timeline, MediaPeriodId id, long requestedContentPositionUs, long startPositionUs) { timeline.getPeriodByUid(id.periodUid, period); @@ -732,7 +776,7 @@ private MediaPeriodInfo getMediaPeriodInfoForAd( : 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 @@ private MediaPeriodInfo getMediaPeriodInfoForContent( : 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 00000000000..f8624995ad1 --- /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 1ffb8f59c8a..1227dbb3972 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; @@ -31,11 +34,10 @@ import com.google.android.exoplayer2.upstream.Allocator; 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.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; @@ -51,7 +53,7 @@ * *

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 { @@ -65,11 +67,14 @@ public interface MediaSourceListInfoRefreshListener { void onPlaylistUpdateRequested(); } + 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; @@ -78,16 +83,33 @@ public interface MediaSourceListInfoRefreshListener { @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); + } } /** @@ -97,8 +119,7 @@ public MediaSourceList(MediaSourceListInfoRefreshListener listener) { * @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); } @@ -112,7 +133,7 @@ public final Timeline setMediaSources( * @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; @@ -162,8 +183,7 @@ public final Timeline addMediaSources( * @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); @@ -182,7 +202,7 @@ public final Timeline removeMediaSourceRange( * @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); } @@ -211,11 +231,11 @@ public Timeline moveMediaSourceRange( 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; @@ -225,39 +245,28 @@ public Timeline moveMediaSourceRange( } /** 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 = @@ -270,7 +279,7 @@ public final Timeline setShuffleOrder(ShuffleOrder shuffleOrder) { } /** 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++) { @@ -309,7 +318,7 @@ public MediaPeriod createPeriod( * * @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); @@ -321,9 +330,14 @@ public final void releasePeriod(MediaPeriod mediaPeriod) { } /** Releases the playlist. */ - public final void release() { + public void release() { for (MediaSourceAndListener childSource : childSources.values()) { - childSource.mediaSource.releaseSource(childSource.caller); + try { + childSource.mediaSource.releaseSource(childSource.caller); + } catch (RuntimeException e) { + // There's nothing we can do. + Log.e(TAG, "Failed to release child source.", e); + } childSource.mediaSource.removeEventListener(childSource.eventListener); } childSources.clear(); @@ -331,15 +345,8 @@ public final void release() { 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; } @@ -429,8 +436,8 @@ private void prepareChildSource(MediaSourceHolder holder) { (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); } @@ -459,18 +466,8 @@ private static Object getPeriodUid(MediaSourceHolder holder, Object childPeriodU 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; @@ -490,88 +487,15 @@ public void reset(int firstWindowIndexInChild) { 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 - 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]; + public Object getUid() { + return uid; } @Override - public int getWindowCount() { - return windowCount; - } - - @Override - public int getPeriodCount() { - return periodCount; + public Timeline getTimeline() { + return mediaSource.getTimeline(); } } @@ -595,29 +519,17 @@ private final class ForwardingEventListener 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, @@ -625,7 +537,7 @@ public void onLoadStarted( LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadStarted(loadEventData, mediaLoadData); + mediaSourceEventDispatcher.loadStarted(loadEventData, mediaLoadData); } } @@ -636,7 +548,7 @@ public void onLoadCompleted( LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCompleted(loadEventData, mediaLoadData); + mediaSourceEventDispatcher.loadCompleted(loadEventData, mediaLoadData); } } @@ -647,7 +559,7 @@ public void onLoadCanceled( LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCanceled(loadEventData, mediaLoadData); + mediaSourceEventDispatcher.loadCanceled(loadEventData, mediaLoadData); } } @@ -660,14 +572,7 @@ public void onLoadError( 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); } } @@ -677,7 +582,7 @@ public void onUpstreamDiscarded( @Nullable MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.upstreamDiscarded(mediaLoadData); + mediaSourceEventDispatcher.upstreamDiscarded(mediaLoadData); } } @@ -687,52 +592,58 @@ public void onDownstreamFormatChanged( @Nullable MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.downstreamFormatChanged(mediaLoadData); + mediaSourceEventDispatcher.downstreamFormatChanged(mediaLoadData); } } // DrmSessionEventListener implementation @Override - public void onDrmSessionAcquired() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionAcquired(), - DrmSessionEventListener.class); + public void onDrmSessionAcquired( + int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmSessionAcquired(); + } } @Override - public void onDrmKeysLoaded() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysLoaded(), - DrmSessionEventListener.class); + public void onDrmKeysLoaded( + int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmKeysLoaded(); + } } @Override - public void onDrmSessionManagerError(Exception error) { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionManagerError(error), - DrmSessionEventListener.class); + public void onDrmSessionManagerError( + int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, Exception error) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmSessionManagerError(error); + } } @Override - public void onDrmKeysRestored() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysRestored(), - DrmSessionEventListener.class); + public void onDrmKeysRestored( + int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmKeysRestored(); + } } @Override - public void onDrmKeysRemoved() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysRemoved(), - DrmSessionEventListener.class); + public void onDrmKeysRemoved( + int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmKeysRemoved(); + } } @Override - public void onDrmSessionReleased() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionReleased(), - DrmSessionEventListener.class); + public void onDrmSessionReleased( + int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmSessionReleased(); + } } /** Updates the event dispatcher and returns whether the event should be dispatched. */ @@ -747,12 +658,17 @@ private boolean maybeUpdateEventDispatcher( } } 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 00000000000..72f6957865e --- /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 47ed8cec6ab..fd5f0431e1a 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 final void enable( 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 final void start() throws ExoPlaybackException { } @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 final void resetPosition(long positionUs) throws ExoPlaybackException { } @Override - public final void stop() throws ExoPlaybackException { + public final void stop() { Assertions.checkState(state == STATE_STARTED); state = STATE_ENABLED; onStopped(); @@ -237,12 +239,10 @@ protected void onStarted() throws ExoPlaybackException { /** * 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 f183af0d8c8..e7f200d8b7d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java @@ -28,10 +28,10 @@ /* 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,12 @@ 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; + /** Whether the main player loop is sleeping, while using offload scheduling. */ + public final boolean sleepingForOffload; /** * Position up to which media is buffered in {@link #loadingMediaPeriodId) relative to the start @@ -81,29 +87,32 @@ 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, + /* sleepingForOffload= */ false); } /** @@ -113,13 +122,19 @@ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorRes * @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}. + * @param sleepingForOffload See {@link #sleepingForOffload}. */ public PlaybackInfo( Timeline timeline, @@ -133,9 +148,12 @@ public PlaybackInfo( MediaPeriodId loadingMediaPeriodId, boolean playWhenReady, @PlaybackSuppressionReason int playbackSuppressionReason, + PlaybackParameters playbackParameters, long bufferedPositionUs, long totalBufferedDurationUs, - long positionUs) { + long positionUs, + boolean offloadSchedulingEnabled, + boolean sleepingForOffload) { this.timeline = timeline; this.periodId = periodId; this.requestedContentPositionUs = requestedContentPositionUs; @@ -147,14 +165,17 @@ public PlaybackInfo( this.loadingMediaPeriodId = loadingMediaPeriodId; this.playWhenReady = playWhenReady; this.playbackSuppressionReason = playbackSuppressionReason; + this.playbackParameters = playbackParameters; this.bufferedPositionUs = bufferedPositionUs; this.totalBufferedDurationUs = totalBufferedDurationUs; this.positionUs = positionUs; + this.offloadSchedulingEnabled = offloadSchedulingEnabled; + this.sleepingForOffload = sleepingForOffload; } - /** 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 +211,12 @@ public PlaybackInfo copyWithNewPosition( loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -215,9 +239,12 @@ public PlaybackInfo copyWithTimeline(Timeline timeline) { loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -240,9 +267,12 @@ public PlaybackInfo copyWithPlaybackState(int playbackState) { loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -265,9 +295,12 @@ public PlaybackInfo copyWithPlaybackError(@Nullable ExoPlaybackException playbac loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -290,9 +323,12 @@ public PlaybackInfo copyWithIsLoading(boolean isLoading) { loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -315,9 +351,12 @@ public PlaybackInfo copyWithLoadingMediaPeriodId(MediaPeriodId loadingMediaPerio loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } /** @@ -344,8 +383,96 @@ public PlaybackInfo copyWithPlayWhenReady( loadingMediaPeriodId, playWhenReady, playbackSuppressionReason, + playbackParameters, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); + } + + /** + * 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, + sleepingForOffload); + } + + /** + * 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, + sleepingForOffload); + } + + /** + * Copies playback info with new sleepingForOffload. + * + * @param sleepingForOffload New main player loop sleeping state. See {@link #sleepingForOffload}. + * @return Copied playback info with new main player loop sleeping state. + */ + @CheckResult + public PlaybackInfo copyWithSleepingForOffload(boolean sleepingForOffload) { + return new PlaybackInfo( + timeline, + periodId, + requestedContentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + playWhenReady, + playbackSuppressionReason, + playbackParameters, bufferedPositionUs, totalBufferedDurationUs, - positionUs); + positionUs, + offloadSchedulingEnabled, + sleepingForOffload); } } 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 afa0a7ebc40..7dcd6f80aa0 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 @@ 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 boolean equals(@Nullable Object obj) { 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 f692629dff0..89a00eb4758 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -23,7 +23,6 @@ import android.view.TextureView; import androidx.annotation.IntDef; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C.VideoScalingMode; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.audio.AudioListener; import com.google.android.exoplayer2.audio.AuxEffectInfo; @@ -31,8 +30,10 @@ import com.google.android.exoplayer2.device.DeviceListener; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextOutput; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer; import com.google.android.exoplayer2.video.VideoFrameMetadataListener; @@ -173,14 +174,14 @@ interface AudioComponent { interface VideoComponent { /** - * Sets the {@link VideoScalingMode}. + * Sets the {@link Renderer.VideoScalingMode}. * - * @param videoScalingMode The {@link VideoScalingMode}. + * @param videoScalingMode The {@link Renderer.VideoScalingMode}. */ - void setVideoScalingMode(@VideoScalingMode int videoScalingMode); + void setVideoScalingMode(@Renderer.VideoScalingMode int videoScalingMode); - /** Returns the {@link VideoScalingMode}. */ - @VideoScalingMode + /** Returns the {@link Renderer.VideoScalingMode}. */ + @Renderer.VideoScalingMode int getVideoScalingMode(); /** @@ -310,9 +311,9 @@ interface VideoComponent { /** * 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. @@ -349,6 +350,9 @@ interface TextComponent { * @param listener The output to remove. */ void removeTextOutput(TextOutput listener); + + /** Returns the current {@link Cue Cues}. This list may be empty. */ + List getCurrentCues(); } /** The metadata component of a {@link Player}. */ @@ -370,8 +374,6 @@ interface MetadataComponent { } /** 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. */ @@ -466,6 +468,19 @@ default void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reas 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. * @@ -566,27 +581,35 @@ default void onPlayerError(ExoPlaybackException error) {} 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). + * @deprecated Seeks are processed without delay. Listen to {@link + * #onPositionDiscontinuity(int)} with reason {@link #DISCONTINUITY_REASON_SEEK} instead. */ - default void onPlaybackSpeedChanged(float playbackSpeed) {} + @Deprecated + default void onSeekProcessed() {} /** - * Called when all pending seek requests have been processed by the player. This is guaranteed - * to happen after any necessary changes to the player state were reported to {@link - * #onPlaybackStateChanged(int)}. + * 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 onSeekProcessed() {} + default void onExperimentalOffloadSchedulingEnabledChanged(boolean offloadSchedulingEnabled) {} + /** + * Called when the player has started or finished sleeping for offload. + * + *

This method is experimental, and will be renamed or removed in a future release. + */ + default void onExperimentalSleepingForOffloadChanged(boolean sleepingForOffload) {} } /** @@ -762,8 +785,28 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { /** 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 @@ -837,6 +880,8 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { * 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); @@ -1060,6 +1105,8 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { * * @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); @@ -1109,38 +1156,23 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { void next(); /** - * @deprecated Use {@link #setPlaybackSpeed(float)} or {@link - * AudioComponent#setSkipSilenceEnabled(boolean)} instead. - */ - @SuppressWarnings("deprecation") - @Deprecated - void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters); - - /** - * @deprecated Use {@link #getPlaybackSpeed()} or {@link AudioComponent#getSkipSilenceEnabled()} - * instead. - */ - @SuppressWarnings("deprecation") - @Deprecated - PlaybackParameters getPlaybackParameters(); - - /** - * Attempts to set the playback speed. + * 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 speed changes may cause the player to buffer. {@link - * EventListener#onPlaybackSpeedChanged(float)} will be called whenever the currently active - * playback speed change. + *

Playback parameters changes may cause the player to buffer. {@link + * EventListener#onPlaybackParametersChanged(PlaybackParameters)} will be called whenever the + * currently active playback parameters change. * - * @param playbackSpeed The playback speed. + * @param playbackParameters The playback parameters, or {@code null} to use the defaults. */ - void setPlaybackSpeed(float playbackSpeed); + void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters); /** - * Returns the currently active playback speed. + * Returns the currently active playback parameters. * - * @see EventListener#onPlaybackSpeedChanged(float) + * @see EventListener#onPlaybackParametersChanged(PlaybackParameters) */ - float getPlaybackSpeed(); + PlaybackParameters getPlaybackParameters(); /** * Stops playback without resetting the player. Use {@link #pause()} rather than this method if @@ -1150,19 +1182,21 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { * 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); @@ -1186,6 +1220,12 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { */ 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. */ @@ -1199,7 +1239,8 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { /** * 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. @@ -1212,29 +1253,48 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { 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 @@ -1242,7 +1302,11 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { */ 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 be7c7ce973d..7e2cb69bc69 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 synchronized boolean blockUntilDelivered() throws InterruptedException { return isDelivered; } + /** + * Marks the message as processed. Should only be called by a {@link Sender} and may be called + * multiple times. + * + * @param isDelivered Whether the message has been delivered to its target. The message is + * considered as being delivered when this method has been called with {@code isDelivered} set + * to true at least once. + */ + public synchronized void markAsProcessed(boolean isDelivered) { + this.isDelivered |= isDelivered; + isProcessed = true; + notifyAll(); + } + /** * Blocks until after the message has been delivered or the player is no longer able to deliver * the message or the specified waiting time elapses. @@ -309,27 +323,13 @@ public synchronized boolean blockUntilDelivered() throws InterruptedException { * @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 00000000000..3b930413484 --- /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 217060647b6..10ffcc9f9f3 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 @@ */ 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. @@ -148,9 +184,16 @@ public interface Renderer extends PlayerMessage.Target { * Video scaling modes for {@link MediaCodec}-based renderers. One of {@link * #VIDEO_SCALING_MODE_SCALE_TO_FIT} or {@link #VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}. */ + // VIDEO_SCALING_MODE_DEFAULT is an intentionally duplicated constant. + @SuppressWarnings("UniqueConstants") @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef(value = {VIDEO_SCALING_MODE_SCALE_TO_FIT, VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}) + @IntDef( + value = { + VIDEO_SCALING_MODE_DEFAULT, + VIDEO_SCALING_MODE_SCALE_TO_FIT, + VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + }) @interface VideoScalingMode {} /** See {@link MediaCodec#VIDEO_SCALING_MODE_SCALE_TO_FIT}. */ @SuppressWarnings("deprecation") @@ -253,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. @@ -264,6 +308,7 @@ void enable( long positionUs, boolean joining, boolean mayRenderStartOfStream, + long startPositionUs, long offsetUs) throws ExoPlaybackException; @@ -280,17 +325,18 @@ void enable( /** * 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. */ @@ -306,7 +352,7 @@ void replaceStream(Format[] formats, SampleStream stream, long offsetUs) 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. * @@ -379,8 +425,8 @@ default void setOperatingRate(float operatingRate) throws ExoPlaybackException { *

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. @@ -427,13 +473,11 @@ default void setOperatingRate(float operatingRate) throws ExoPlaybackException { /** * 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 67c4b888992..baa1400143d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -38,6 +38,8 @@ import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.device.DeviceInfo; import com.google.android.exoplayer2.device.DeviceListener; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; @@ -99,15 +101,27 @@ public static final class Builder { 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: * @@ -121,14 +135,22 @@ public static final class Builder { * 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()); } /** @@ -141,25 +163,50 @@ public Builder(Context context) { * 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 @@ -168,12 +215,7 @@ public Builder(Context context, RenderersFactory renderersFactory) { * @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, @@ -182,20 +224,22 @@ public Builder( 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; } /** @@ -277,6 +321,111 @@ public Builder setAnalyticsCollector(AnalyticsCollector analyticsCollector) { 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. * @@ -294,6 +443,50 @@ public Builder setUseLazyPreparation(boolean useLazyPreparation) { 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. + * + *

    This method is experimental, and will be renamed or removed in a future release. + * + * @param throwWhenStuckBuffering Whether to throw when the player detects it's stuck buffering. + * @return This builder. + */ + public Builder experimentalSetThrowWhenStuckBuffering(boolean throwWhenStuckBuffering) { + this.throwWhenStuckBuffering = throwWhenStuckBuffering; + return this; + } + /** * Sets the {@link Clock} that will be used by the player. Should only be set for testing * purposes. @@ -312,7 +505,7 @@ public Builder setClock(Clock clock) { /** * 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); @@ -322,11 +515,13 @@ public SimpleExoPlayer build() { } private static final String TAG = "SimpleExoPlayer"; + private static final String WRONG_THREAD_ERROR_MESSAGE = + "Player is accessed on the wrong thread. See " + + "https://exoplayer.dev/issues/player-accessed-on-wrong-thread"; protected final Renderer[] renderers; private final ExoPlayerImpl player; - private final Handler eventHandler; private final ComponentListener componentListener; private final CopyOnWriteArraySet videoListeners; @@ -336,9 +531,7 @@ public SimpleExoPlayer build() { 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; private final StreamVolumeManager streamVolumeManager; @@ -351,7 +544,7 @@ public SimpleExoPlayer build() { @Nullable private VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer; @Nullable private Surface surface; private boolean ownsSurface; - private @C.VideoScalingMode int videoScalingMode; + @Renderer.VideoScalingMode private int videoScalingMode; @Nullable private SurfaceHolder surfaceHolder; @Nullable private TextureView textureView; private int surfaceWidth; @@ -365,43 +558,15 @@ public SimpleExoPlayer build() { private List currentCues; @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; @Nullable private CameraMotionListener cameraMotionListener; + private boolean throwsWhenUsingWrongThread; private boolean hasNotifiedFullWrongThreadWarning; @Nullable private PriorityTaskManager priorityTaskManager; private boolean isPriorityTaskManagerRegistered; private boolean playerReleased; private DeviceInfo deviceInfo; - /** @param builder The {@link Builder} to obtain all construction parameters. */ - protected SimpleExoPlayer(Builder builder) { - this( - builder.context, - builder.renderersFactory, - builder.trackSelector, - builder.mediaSourceFactory, - builder.loadControl, - builder.bandwidthMeter, - builder.analyticsCollector, - builder.useLazyPreparation, - builder.clock, - builder.looper); - } - - /** - * @param context A {@link Context}. - * @param renderersFactory A factory for creating {@link Renderer}s to be used by the instance. - * @param trackSelector The {@link TrackSelector} that will be used by the instance. - * @param loadControl The {@link LoadControl} that will be used by the instance. - * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. - * @param analyticsCollector A factory for creating the {@link AnalyticsCollector} that will - * collect and forward all player events. - * @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 clock The {@link Clock} that will be used by the instance. Should always be {@link - * Clock#DEFAULT}, unless the player is being used from a test. - * @param looper The {@link Looper} which must be used for all calls to the player and which is - * used to call listeners on. - */ + /** @deprecated Use the {@link Builder} and pass it to {@link #SimpleExoPlayer(Builder)}. */ + @Deprecated protected SimpleExoPlayer( Context context, RenderersFactory renderersFactory, @@ -412,9 +577,26 @@ protected SimpleExoPlayer( AnalyticsCollector analyticsCollector, boolean useLazyPreparation, Clock clock, - Looper looper) { - this.bandwidthMeter = bandwidthMeter; - this.analyticsCollector = analyticsCollector; + Looper applicationLooper) { + this( + new Builder(context, renderersFactory) + .setTrackSelector(trackSelector) + .setMediaSourceFactory(mediaSourceFactory) + .setLoadControl(loadControl) + .setBandwidthMeter(bandwidthMeter) + .setAnalyticsCollector(analyticsCollector) + .setUseLazyPreparation(useLazyPreparation) + .setClock(clock) + .setLooper(applicationLooper)); + } + + /** @param builder The {@link Builder} to obtain all construction parameters. */ + protected SimpleExoPlayer(Builder builder) { + analyticsCollector = builder.analyticsCollector; + priorityTaskManager = builder.priorityTaskManager; + audioAttributes = builder.audioAttributes; + videoScalingMode = builder.videoScalingMode; + skipSilenceEnabled = builder.skipSilenceEnabled; componentListener = new ComponentListener(); videoListeners = new CopyOnWriteArraySet<>(); audioListeners = new CopyOnWriteArraySet<>(); @@ -423,9 +605,9 @@ protected SimpleExoPlayer( deviceListeners = new CopyOnWriteArraySet<>(); videoDebugListeners = new CopyOnWriteArraySet<>(); audioDebugListeners = new CopyOnWriteArraySet<>(); - eventHandler = new Handler(looper); + Handler eventHandler = new Handler(builder.looper); renderers = - renderersFactory.createRenderers( + builder.renderersFactory.createRenderers( eventHandler, componentListener, componentListener, @@ -435,38 +617,55 @@ protected SimpleExoPlayer( // Set initial values. audioVolume = 1; audioSessionId = C.AUDIO_SESSION_ID_UNSET; - audioAttributes = AudioAttributes.DEFAULT; - videoScalingMode = Renderer.VIDEO_SCALING_MODE_DEFAULT; currentCues = Collections.emptyList(); + throwsWhenUsingWrongThread = true; // Build the player and associated objects. player = new ExoPlayerImpl( renderers, - trackSelector, - mediaSourceFactory, - loadControl, - bandwidthMeter, + builder.trackSelector, + builder.mediaSourceFactory, + builder.loadControl, + builder.bandwidthMeter, analyticsCollector, - useLazyPreparation, - clock, - looper); - analyticsCollector.setPlayer(player); - player.addListener(analyticsCollector); + builder.useLazyPreparation, + builder.seekParameters, + builder.pauseAtEndOfMediaItems, + builder.clock, + builder.looper); player.addListener(componentListener); videoDebugListeners.add(analyticsCollector); videoListeners.add(analyticsCollector); audioDebugListeners.add(analyticsCollector); audioListeners.add(analyticsCollector); addMetadataOutput(analyticsCollector); - bandwidthMeter.addEventListener(eventHandler, analyticsCollector); + audioBecomingNoisyManager = - new AudioBecomingNoisyManager(context, eventHandler, componentListener); - audioFocusManager = new AudioFocusManager(context, eventHandler, componentListener); - streamVolumeManager = new StreamVolumeManager(context, eventHandler, componentListener); - wakeLockManager = new WakeLockManager(context); - wifiLockManager = new WifiLockManager(context); + 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.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 @@ -505,25 +704,18 @@ public DeviceComponent getDeviceComponent() { *

    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 The video scaling mode. + * @param videoScalingMode The {@link Renderer.VideoScalingMode}. */ @Override - public void setVideoScalingMode(@C.VideoScalingMode int videoScalingMode) { + public void setVideoScalingMode(@Renderer.VideoScalingMode int videoScalingMode) { verifyApplicationThread(); this.videoScalingMode = videoScalingMode; - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_SCALING_MODE) - .setPayload(videoScalingMode) - .send(); - } - } + sendRendererMessage(C.TRACK_TYPE_VIDEO, Renderer.MSG_SET_SCALING_MODE, videoScalingMode); } @Override - public @C.VideoScalingMode int getVideoScalingMode() { + @Renderer.VideoScalingMode + public int getVideoScalingMode() { return videoScalingMode; } @@ -662,11 +854,14 @@ public void clearVideoDecoderOutputBufferRenderer( @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); } @Override public void removeAudioListener(AudioListener listener) { + // Don't verify application thread. We allow calls to this method from any thread. audioListeners.remove(listener); } @@ -683,15 +878,7 @@ public void setAudioAttributes(AudioAttributes audioAttributes, boolean handleAu } if (!Util.areEqual(this.audioAttributes, audioAttributes)) { this.audioAttributes = audioAttributes; - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_AUDIO_ATTRIBUTES) - .setPayload(audioAttributes) - .send(); - } - } + sendRendererMessage(C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_AUDIO_ATTRIBUTES, audioAttributes); streamVolumeManager.setStreamType(Util.getStreamTypeForAudioUsage(audioAttributes.usage)); for (AudioListener audioListener : audioListeners) { audioListener.onAudioAttributesChanged(audioAttributes); @@ -718,15 +905,7 @@ public void setAudioSessionId(int audioSessionId) { return; } this.audioSessionId = audioSessionId; - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_AUDIO_SESSION_ID) - .setPayload(audioSessionId) - .send(); - } - } + sendRendererMessage(C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_AUDIO_SESSION_ID, audioSessionId); if (audioSessionId != C.AUDIO_SESSION_ID_UNSET) { notifyAudioSessionIdSet(); } @@ -740,15 +919,7 @@ public int getAudioSessionId() { @Override public void setAuxEffectInfo(AuxEffectInfo auxEffectInfo) { verifyApplicationThread(); - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_AUX_EFFECT_INFO) - .setPayload(auxEffectInfo) - .send(); - } - } + sendRendererMessage(C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_AUX_EFFECT_INFO, auxEffectInfo); } @Override @@ -787,15 +958,8 @@ public void setSkipSilenceEnabled(boolean skipSilenceEnabled) { return; } this.skipSilenceEnabled = skipSilenceEnabled; - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_SKIP_SILENCE_ENABLED) - .setPayload(skipSilenceEnabled) - .send(); - } - } + sendRendererMessage( + C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_SKIP_SILENCE_ENABLED, skipSilenceEnabled); notifySkipSilenceEnabledChanged(); } @@ -841,7 +1005,8 @@ public AnalyticsCollector getAnalyticsCollector() { * @param listener The listener to be added. */ public void addAnalyticsListener(AnalyticsListener listener) { - verifyApplicationThread(); + // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); analyticsCollector.addListener(listener); } @@ -851,7 +1016,7 @@ public void addAnalyticsListener(AnalyticsListener listener) { * @param listener The listener to be removed. */ public void removeAnalyticsListener(AnalyticsListener listener) { - verifyApplicationThread(); + // Don't verify application thread. We allow calls to this method from any thread. analyticsCollector.removeListener(listener); } @@ -899,19 +1064,23 @@ public void setPriorityTaskManager(@Nullable PriorityTaskManager priorityTaskMan 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. */ @@ -940,11 +1109,14 @@ public DecoderCounters getAudioDecoderCounters() { @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); } @Override public void removeVideoListener(com.google.android.exoplayer2.video.VideoListener listener) { + // Don't verify application thread. We allow calls to this method from any thread. videoListeners.remove(listener); } @@ -952,15 +1124,8 @@ public void removeVideoListener(com.google.android.exoplayer2.video.VideoListene public void setVideoFrameMetadataListener(VideoFrameMetadataListener listener) { verifyApplicationThread(); videoFrameMetadataListener = listener; - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER) - .setPayload(listener) - .send(); - } - } + sendRendererMessage( + C.TRACK_TYPE_VIDEO, Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER, listener); } @Override @@ -969,30 +1134,16 @@ public void clearVideoFrameMetadataListener(VideoFrameMetadataListener listener) if (videoFrameMetadataListener != listener) { return; } - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER) - .setPayload(null) - .send(); - } - } + sendRendererMessage( + C.TRACK_TYPE_VIDEO, Renderer.MSG_SET_VIDEO_FRAME_METADATA_LISTENER, /* payload= */ null); } @Override public void setCameraMotionListener(CameraMotionListener listener) { verifyApplicationThread(); cameraMotionListener = listener; - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_CAMERA_MOTION) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_CAMERA_MOTION_LISTENER) - .setPayload(listener) - .send(); - } - } + sendRendererMessage( + C.TRACK_TYPE_CAMERA_MOTION, Renderer.MSG_SET_CAMERA_MOTION_LISTENER, listener); } @Override @@ -1001,20 +1152,8 @@ public void clearCameraMotionListener(CameraMotionListener listener) { if (cameraMotionListener != listener) { return; } - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_CAMERA_MOTION) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_CAMERA_MOTION_LISTENER) - .setPayload(null) - .send(); - } - } - } - - /** Returns whether skipping silences in the audio stream is enabled. */ - public boolean isSkipSilenceEnabled() { - return skipSilenceEnabled; + sendRendererMessage( + C.TRACK_TYPE_CAMERA_MOTION, Renderer.MSG_SET_CAMERA_MOTION_LISTENER, /* payload= */ null); } /** @@ -1025,7 +1164,7 @@ public boolean isSkipSilenceEnabled() { */ @Deprecated @SuppressWarnings("deprecation") - public void setVideoListener(VideoListener listener) { + public void setVideoListener(@Nullable VideoListener listener) { videoListeners.clear(); if (listener != null) { addVideoListener(listener); @@ -1047,17 +1186,23 @@ public void clearVideoListener(VideoListener listener) { @Override public void addTextOutput(TextOutput listener) { - if (!currentCues.isEmpty()) { - listener.onCues(currentCues); - } + // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); textOutputs.add(listener); } @Override public void removeTextOutput(TextOutput listener) { + // Don't verify application thread. We allow calls to this method from any thread. textOutputs.remove(listener); } + @Override + public List getCurrentCues() { + verifyApplicationThread(); + return currentCues; + } + /** * Sets an output to receive text events, removing all existing outputs. * @@ -1085,11 +1230,14 @@ public void clearTextOutput(TextOutput output) { @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); } @Override public void removeMetadataOutput(MetadataOutput listener) { + // Don't verify application thread. We allow calls to this method from any thread. metadataOutputs.remove(listener); } @@ -1124,7 +1272,7 @@ public void clearMetadataOutput(MetadataOutput output) { */ @Deprecated @SuppressWarnings("deprecation") - public void setVideoDebugListener(VideoRendererEventListener listener) { + public void setVideoDebugListener(@Nullable VideoRendererEventListener listener) { videoDebugListeners.retainAll(Collections.singleton(analyticsCollector)); if (listener != null) { addVideoDebugListener(listener); @@ -1137,6 +1285,7 @@ public void setVideoDebugListener(VideoRendererEventListener listener) { */ @Deprecated public void addVideoDebugListener(VideoRendererEventListener listener) { + Assertions.checkNotNull(listener); videoDebugListeners.add(listener); } @@ -1155,7 +1304,7 @@ public void removeVideoDebugListener(VideoRendererEventListener listener) { */ @Deprecated @SuppressWarnings("deprecation") - public void setAudioDebugListener(AudioRendererEventListener listener) { + public void setAudioDebugListener(@Nullable AudioRendererEventListener listener) { audioDebugListeners.retainAll(Collections.singleton(analyticsCollector)); if (listener != null) { addAudioDebugListener(listener); @@ -1168,6 +1317,7 @@ public void setAudioDebugListener(AudioRendererEventListener listener) { */ @Deprecated public void addAudioDebugListener(AudioRendererEventListener listener) { + Assertions.checkNotNull(listener); audioDebugListeners.add(listener); } @@ -1194,13 +1344,14 @@ public Looper getApplicationLooper() { @Override public void addListener(Player.EventListener listener) { - verifyApplicationThread(); + // Don't verify application thread. We allow calls to this method from any thread. + Assertions.checkNotNull(listener); player.addListener(listener); } @Override public void removeListener(Player.EventListener listener) { - verifyApplicationThread(); + // Don't verify application thread. We allow calls to this method from any thread. player.removeListener(listener); } @@ -1511,39 +1662,18 @@ public void seekTo(int windowIndex, long positionMs) { 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(); @@ -1590,7 +1720,6 @@ public void release() { Assertions.checkNotNull(priorityTaskManager).remove(C.PRIORITY_PLAYBACK); isPriorityTaskManagerRegistered = false; } - bandwidthMeter.removeEventListener(analyticsCollector); currentCues = Collections.emptyList(); playerReleased = true; } @@ -1613,6 +1742,13 @@ public int getRendererType(int index) { return player.getRendererType(index); } + @Override + @Nullable + public TrackSelector getTrackSelector() { + verifyApplicationThread(); + return player.getTrackSelector(); + } + @Override public TrackGroupArray getCurrentTrackGroups() { verifyApplicationThread(); @@ -1735,6 +1871,7 @@ public void setHandleWakeLock(boolean handleWakeLock) { * @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); @@ -1755,11 +1892,14 @@ public void setWakeMode(@C.WakeMode int wakeMode) { @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); } @Override public void removeDeviceListener(DeviceListener listener) { + // Don't verify application thread. We allow calls to this method from any thread. deviceListeners.remove(listener); } @@ -1805,6 +1945,18 @@ public void setDeviceMuted(boolean muted) { streamVolumeManager.setMuted(muted); } + /** + * Sets whether the player should throw an {@link IllegalStateException} when methods are called + * from a thread other than the one associated with {@link #getApplicationLooper()}. + * + *

    The default is {@code true} and this method will be removed in the future. + * + * @param throwsWhenUsingWrongThread Whether to throw when methods are called from a wrong thread. + */ + public void setThrowsWhenUsingWrongThread(boolean throwsWhenUsingWrongThread) { + this.throwsWhenUsingWrongThread = throwsWhenUsingWrongThread; + } + // Internal methods. private void removeSurfaceCallbacks() { @@ -1856,15 +2008,10 @@ private void setVideoSurfaceInternal(@Nullable Surface surface, boolean ownsSurf private void setVideoDecoderOutputBufferRendererInternal( @Nullable VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer) { - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) - .setPayload(videoDecoderOutputBufferRenderer) - .send(); - } - } + sendRendererMessage( + C.TRACK_TYPE_VIDEO, + Renderer.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER, + videoDecoderOutputBufferRenderer); this.videoDecoderOutputBufferRenderer = videoDecoderOutputBufferRenderer; } @@ -1880,15 +2027,7 @@ private void maybeNotifySurfaceSizeChanged(int width, int height) { private void sendVolumeToRenderers() { float scaledVolume = audioVolume * audioFocusManager.getVolumeMultiplier(); - for (Renderer renderer : renderers) { - if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { - player - .createMessage(renderer) - .setType(Renderer.MSG_SET_VOLUME) - .setPayload(scaledVolume) - .send(); - } - } + sendRendererMessage(C.TRACK_TYPE_AUDIO, Renderer.MSG_SET_VOLUME, scaledVolume); } private void notifyAudioSessionIdSet() { @@ -1951,15 +2090,25 @@ private void updateWakeAndWifiLock() { private void verifyApplicationThread() { if (Looper.myLooper() != getApplicationLooper()) { + if (throwsWhenUsingWrongThread) { + throw new IllegalStateException(WRONG_THREAD_ERROR_MESSAGE); + } Log.w( TAG, - "Player is accessed on the wrong thread. See " - + "https://exoplayer.dev/issues/player-accessed-on-wrong-thread", + WRONG_THREAD_ERROR_MESSAGE, hasNotifiedFullWrongThreadWarning ? null : new IllegalStateException()); hasNotifiedFullWrongThreadWarning = true; } } + private void sendRendererMessage(int trackType, int messageType, @Nullable Object payload) { + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == trackType) { + player.createMessage(renderer).setType(messageType).setPayload(payload).send(); + } + } + } + private static DeviceInfo createDeviceInfo(StreamVolumeManager streamVolumeManager) { return new DeviceInfo( DeviceInfo.PLAYBACK_TYPE_LOCAL, @@ -2058,11 +2207,9 @@ public void onVideoDisabled(DecoderCounters counters) { } @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); } } @@ -2103,10 +2250,16 @@ public void onAudioInputFormatChanged(Format format) { } @Override - public void onAudioSinkUnderrun( - int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + public void onAudioPositionAdvancing(long playoutStartSystemTimeMs) { + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioPositionAdvancing(playoutStartSystemTimeMs); + } + } + + @Override + public void onAudioUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { - audioDebugListener.onAudioSinkUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + audioDebugListener.onAudioUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); } } @@ -2260,5 +2413,15 @@ public void onPlayWhenReadyChanged( boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { updateWakeAndWifiLock(); } + + @Override + public void onExperimentalSleepingForOffloadChanged(boolean sleepingForOffload) { + if (sleepingForOffload) { + // The wifi lock is not released to avoid interrupting downloads. + wakeLockManager.setStayAwake(false); + } else { + updateWakeAndWifiLock(); + } + } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java b/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java index 59ab3f16166..66216de8617 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/StreamVolumeManager.java @@ -53,6 +53,7 @@ public interface Listener { @C.StreamType private int streamType; private int volume; private boolean muted; + private boolean released; /** Creates a manager. */ public StreamVolumeManager(Context context, Handler eventHandler, Listener listener) { @@ -158,7 +159,11 @@ public void setMuted(boolean muted) { /** Releases the manager. It must be called when the manager is no longer required. */ public void release() { + if (released) { + return; + } applicationContext.unregisterReceiver(receiver); + released = true; } private void updateVolumeAndNotifyIfChanged() { 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 0d60044221e..e992eb588d9 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 @@ *

    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,14 +136,23 @@ public static final class Window { */ 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}. */ public Object uid; - /** A tag for the window. Not necessarily unique. */ - @Nullable public Object tag; + /** @deprecated Use {@link #mediaItem} instead. */ + @Deprecated @Nullable public Object tag; + + /** The {@link MediaItem} associated to the window. Not necessarily unique. */ + public MediaItem mediaItem; /** The manifest of the window. May be {@code null}. */ @Nullable public Object manifest; @@ -212,12 +234,14 @@ public static final class Window { /** Creates window. */ public Window() { uid = SINGLE_WINDOW_UID; + mediaItem = EMPTY_MEDIA_ITEM; } /** Sets the data held by this window. */ + @SuppressWarnings("deprecation") public Window set( Object uid, - @Nullable Object tag, + @Nullable MediaItem mediaItem, @Nullable Object manifest, long presentationStartTimeMs, long windowStartTimeMs, @@ -231,7 +255,11 @@ public Window set( int lastPeriodIndex, long positionInFirstPeriodUs) { this.uid = uid; - this.tag = tag; + this.mediaItem = mediaItem != null ? mediaItem : EMPTY_MEDIA_ITEM; + this.tag = + mediaItem != null && mediaItem.playbackProperties != null + ? mediaItem.playbackProperties.tag + : null; this.manifest = manifest; this.presentationStartTimeMs = presentationStartTimeMs; this.windowStartTimeMs = windowStartTimeMs; @@ -308,6 +336,7 @@ public long getCurrentUnixTimeMs() { return Util.getNowUnixTimeMs(elapsedRealtimeEpochOffsetMs); } + // Provide backward compatibility for tag. @Override public boolean equals(@Nullable Object obj) { if (this == obj) { @@ -318,7 +347,7 @@ public boolean equals(@Nullable Object obj) { } 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 && windowStartTimeMs == that.windowStartTimeMs @@ -334,11 +363,12 @@ public boolean equals(@Nullable Object obj) { && 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.hashCode(); result = 31 * result + (manifest == null ? 0 : manifest.hashCode()); result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32)); result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32)); @@ -638,7 +668,7 @@ public int hashCode() { result = 31 * result + windowIndex; result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); result = 31 * result + (int) (positionInWindowUs ^ (positionInWindowUs >>> 32)); - result = 31 * result + (adPlaybackState == null ? 0 : adPlaybackState.hashCode()); + 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 2af577fc4be..30321c59728 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -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.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 AnalyticsCollector(Clock clock) { * @param listener The listener to add. */ public void addListener(AnalyticsListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } @@ -116,8 +122,22 @@ public void removeListener(AnalyticsListener listener) { */ 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 final void notifySeekStarted() { /** 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 final void onMetadata(Metadata metadata) { // 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 final void onAudioSinkUnderrun( } } + @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); } } @@ -227,6 +258,14 @@ public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { } } + @Override + public void onAudioSinkError(Exception audioSinkError) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioSinkError(eventTime, audioSinkError); + } + } + @Override public void onVolumeChanged(float audioVolume) { EventTime eventTime = generateReadingMediaPeriodEventTime(); @@ -237,28 +276,34 @@ public void onVolumeChanged(float audioVolume) { // 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 +316,12 @@ public final void onDroppedFrames(int count, long elapsedMs) { } } + @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 +335,10 @@ public final void onRenderedFirstFrame(@Nullable Surface surface) { } @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 +369,6 @@ public void onSurfaceSizeChanged(int width, int height) { // 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 +419,6 @@ public final void onLoadError( } } - @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 +445,22 @@ public final void onDownstreamFormatChanged( @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 +539,10 @@ public final void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { @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 +553,13 @@ public final void onPositionDiscontinuity(@Player.DiscontinuityReason int reason 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 +568,7 @@ public final void onPlaybackParametersChanged(PlaybackParameters playbackParamet } } - @Override - public void onPlaybackSpeedChanged(float playbackSpeed) { - EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); - for (AnalyticsListener listener : listeners) { - listener.onPlaybackSpeedChanged(eventTime, playbackSpeed); - } - } - + @SuppressWarnings("deprecation") @Override public final void onSeekProcessed() { EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); @@ -575,48 +590,49 @@ public final void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { // DefaultDrmSessionManager.EventListener implementation. @Override - public final void onDrmSessionAcquired() { - EventTime eventTime = generateReadingMediaPeriodEventTime(); + public final void onDrmSessionAcquired(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); for (AnalyticsListener listener : listeners) { listener.onDrmSessionAcquired(eventTime); } } @Override - public final void onDrmKeysLoaded() { - EventTime eventTime = generateReadingMediaPeriodEventTime(); + public final void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); for (AnalyticsListener listener : listeners) { listener.onDrmKeysLoaded(eventTime); } } @Override - public final void onDrmSessionManagerError(Exception error) { - EventTime eventTime = generateReadingMediaPeriodEventTime(); + public final void onDrmSessionManagerError( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception error) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); for (AnalyticsListener listener : listeners) { listener.onDrmSessionManagerError(eventTime, error); } } @Override - public final void onDrmKeysRestored() { - EventTime eventTime = generateReadingMediaPeriodEventTime(); + public final void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); for (AnalyticsListener listener : listeners) { listener.onDrmKeysRestored(eventTime); } } @Override - public final void onDrmKeysRemoved() { - EventTime eventTime = generateReadingMediaPeriodEventTime(); + public final void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); for (AnalyticsListener listener : listeners) { listener.onDrmKeysRemoved(eventTime); } } @Override - public final void onDrmSessionReleased() { - EventTime eventTime = generatePlayingMediaPeriodEventTime(); + public final void onDrmSessionReleased(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); for (AnalyticsListener listener : listeners) { listener.onDrmSessionReleased(eventTime); } @@ -624,10 +640,6 @@ public final void onDrmSessionReleased() { // 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") @@ -657,27 +669,37 @@ protected EventTime generateEventTime( 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() { @@ -698,11 +720,12 @@ private EventTime generateLoadingMediaPeriodEventTime() { 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(); @@ -714,161 +737,149 @@ private EventTime generateMediaPeriodEventTime( /** 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); + public MediaPeriodId getLoadingMediaPeriod() { + return mediaPeriodQueue.isEmpty() ? null : Iterables.getLast(mediaPeriodQueue); } - /** Returns the {@link MediaPeriodInfo} for the given {@link MediaPeriodId}. */ + /** + * Returns the most recent {@link Timeline} for the given {@link MediaPeriodId}, or null if no + * timeline is available. + */ @Nullable - public MediaPeriodInfo getMediaPeriodInfo(MediaPeriodId mediaPeriodId) { - return mediaPeriodIdToInfo.get(mediaPeriodId); + public Timeline getMediaPeriodIdTimeline(MediaPeriodId mediaPeriodId) { + return mediaPeriodTimelines.get(mediaPeriodId); } - /** Updates the queue with a reported position discontinuity. */ + /** Updates the queue tracker with a reported position discontinuity. */ public void onPositionDiscontinuity(Player player) { - currentPlayerMediaPeriod = findMatchingMediaPeriodInQueue(player); + currentPlayerMediaPeriod = + findCurrentPlayerMediaPeriodInQueue(player, mediaPeriodQueue, playingMediaPeriod, period); } - /** 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; - } + /** 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 with a released media period. Returns whether the media period was still in - * the queue. - */ - 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); + /** 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 (!mediaPeriodInfoQueue.isEmpty()) { - playingMediaPeriod = mediaPeriodInfoQueue.get(0); + if (currentPlayerMediaPeriod == null) { + currentPlayerMediaPeriod = + findCurrentPlayerMediaPeriodInQueue( + player, mediaPeriodQueue, playingMediaPeriod, period); } - if (currentPlayerMediaPeriod == null && isMatchingPlayingMediaPeriod(player)) { - currentPlayerMediaPeriod = playingMediaPeriod; + 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); + } } - return true; + mediaPeriodTimelines = builder.build(); } - /** 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(). + 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 @@ -881,25 +892,21 @@ private MediaPeriodInfo findMatchingMediaPeriodInQueue(Player player) { .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(), @@ -911,89 +918,23 @@ private MediaPeriodInfo findMatchingMediaPeriodInQueue(Player player) { 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 77bc211ee3e..7e5abbd803e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -20,6 +20,7 @@ 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; @@ -35,6 +36,7 @@ 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 +58,7 @@ final class EventTime { */ 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 +68,8 @@ final class EventTime { 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 +79,27 @@ final class EventTime { 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 +112,27 @@ final class EventTime { /** * @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 +140,9 @@ public EventTime( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long eventPlaybackPositionMs, + Timeline currentTimeline, + int currentWindowIndex, + @Nullable MediaPeriodId currentMediaPeriodId, long currentPlaybackPositionMs, long totalBufferedDurationMs) { this.realtimeMs = realtimeMs; @@ -118,9 +150,48 @@ public EventTime( 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); + } } /** @@ -175,43 +246,47 @@ default void onIsPlayingChanged(EventTime eventTime, boolean isPlaying) {} default void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int reason) {} /** - * Called when a position discontinuity occurred. + * Called when playback transitions to a different media item. * * @param eventTime The event time. - * @param reason The reason for the position discontinuity. + * @param mediaItem The media item. + * @param reason The reason for the media item transition. */ - default void onPositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason) {} + default void onMediaItemTransition( + EventTime eventTime, + @Nullable MediaItem mediaItem, + @Player.MediaItemTransitionReason int reason) {} /** - * Called when a seek operation started. + * Called when a position discontinuity occurred. * * @param eventTime The event time. + * @param reason The reason for the position discontinuity. */ - default void onSeekStarted(EventTime eventTime) {} + default void onPositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason) {} /** - * Called when a seek operation was processed. + * Called when a seek operation started. * * @param eventTime The event time. */ - default void onSeekProcessed(EventTime eventTime) {} + default void onSeekStarted(EventTime eventTime) {} /** - * @deprecated Use {@link #onPlaybackSpeedChanged(EventTime, float)} and {@link - * #onSkipSilenceEnabledChanged(EventTime, boolean)} instead. + * @deprecated Seeks are processed without delay. Use {@link #onPositionDiscontinuity(EventTime, + * int)} with reason {@link Player#DISCONTINUITY_REASON_SEEK} instead. */ - @SuppressWarnings("deprecation") @Deprecated - default void onPlaybackParametersChanged( - EventTime eventTime, PlaybackParameters playbackParameters) {} + default void onSeekProcessed(EventTime eventTime) {} /** - * 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,99 +402,105 @@ default void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaL default void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData) {} /** - * Called when a media source created a media period. + * Called when the bandwidth estimate for the current data source has been updated. * * @param eventTime The event time. + * @param totalLoadTimeMs The total time spend loading this update is based on, in milliseconds. + * @param totalBytesLoaded The total bytes loaded this update is based on. + * @param bitrateEstimate The bandwidth estimate, in bits per second. */ - default void onMediaPeriodCreated(EventTime eventTime) {} + default void onBandwidthEstimate( + EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {} /** - * Called when a media source released a media period. + * Called when there is {@link Metadata} associated with the current playback time. * * @param eventTime The event time. + * @param metadata The metadata. */ - default void onMediaPeriodReleased(EventTime eventTime) {} + default void onMetadata(EventTime eventTime, Metadata metadata) {} + + /** @deprecated Use {@link #onAudioEnabled} and {@link #onVideoEnabled} instead. */ + @Deprecated + default void onDecoderEnabled( + EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} /** - * Called when the player started reading a media period. - * - * @param eventTime The event time. + * @deprecated Use {@link #onAudioDecoderInitialized} and {@link #onVideoDecoderInitialized} + * instead. */ - default void onReadingStarted(EventTime eventTime) {} + @Deprecated + default void onDecoderInitialized( + EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) {} /** - * Called when the bandwidth estimate for the current data source has been updated. - * - * @param eventTime The event time. - * @param totalLoadTimeMs The total time spend loading this update is based on, in milliseconds. - * @param totalBytesLoaded The total bytes loaded this update is based on. - * @param bitrateEstimate The bandwidth estimate, in bits per second. + * @deprecated Use {@link #onAudioInputFormatChanged} and {@link #onVideoInputFormatChanged} + * instead. */ - default void onBandwidthEstimate( - EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {} + @Deprecated + default void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) {} + + /** @deprecated Use {@link #onAudioDisabled} and {@link #onVideoDisabled} instead. */ + @Deprecated + default void onDecoderDisabled( + EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} /** - * Called when the output surface size changed. + * Called when an audio renderer is enabled. * * @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. + * @param counters {@link DecoderCounters} that will be updated by the renderer for as long as it + * remains enabled. */ - default void onSurfaceSizeChanged(EventTime eventTime, int width, int height) {} + default void onAudioEnabled(EventTime eventTime, DecoderCounters counters) {} /** - * Called when there is {@link Metadata} associated with the current playback time. + * Called when an audio renderer creates a decoder. * * @param eventTime The event time. - * @param metadata The metadata. + * @param decoderName The decoder that was created. + * @param initializationDurationMs The time taken to initialize the decoder in milliseconds. */ - default void onMetadata(EventTime eventTime, Metadata metadata) {} + default void onAudioDecoderInitialized( + EventTime eventTime, String decoderName, long initializationDurationMs) {} /** - * Called when an audio or video decoder has been enabled. + * Called when the format of the media being consumed by an audio renderer changes. * * @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. + * @param format The new format. */ - default void onDecoderEnabled( - EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + default void onAudioInputFormatChanged(EventTime eventTime, Format format) {} /** - * Called when an audio or video decoder has been initialized. + * Called when the audio position has increased for the first time since the last pause or + * position reset. * * @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. + * @param playoutStartSystemTimeMs The approximate derived {@link System#currentTimeMillis()} at + * which playout started. */ - default void onDecoderInitialized( - EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) {} + default void onAudioPositionAdvancing(EventTime eventTime, long playoutStartSystemTimeMs) {} /** - * Called when an audio or video decoder input format changed. + * Called when an audio underrun occurs. * * @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. + * @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 onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) {} + default void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} /** - * Called when an audio or video decoder has been disabled. + * Called when an audio renderer is 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. + * @param counters {@link DecoderCounters} that were updated by the renderer. */ - default void onDecoderDisabled( - EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + default void onAudioDisabled(EventTime eventTime, DecoderCounters counters) {} /** * Called when the audio session id is set. @@ -437,6 +518,24 @@ default void onAudioSessionId(EventTime eventTime, int audioSessionId) {} */ 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 {@link AudioSink} has encountered an error. These errors are just for informational + * purposes and the player may recover. + * + * @param eventTime The event time. + * @param audioSinkError Either a {@link AudioSink.InitializationException} or a {@link + * AudioSink.WriteException} describing the error. + */ + default void onAudioSinkError(EventTime eventTime, Exception audioSinkError) {} + /** * Called when the volume changes. * @@ -446,25 +545,31 @@ default void onAudioAttributesChanged(EventTime eventTime, AudioAttributes audio 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. @@ -477,29 +582,41 @@ default void onSkipSilenceEnabledChanged(EventTime eventTime, boolean skipSilenc */ 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 @@ -522,14 +639,15 @@ default void onVideoSizeChanged( 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 e9baef37bac..9746829107c 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.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 synchronized void updateSessions(EventTime eventTime) { 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 synchronized void updateSessions(EventTime eventTime) { } @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 synchronized void handleTimelineUpdate(EventTime eventTime) { } } } - 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 synchronized void handlePositionDiscontinuity( SessionDescriptor currentSessionDescriptor = getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); currentSessionId = currentSessionDescriptor.sessionId; + updateSessions(eventTime); if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd() && (previousSessionDescriptor == null @@ -195,9 +231,21 @@ public synchronized void handlePositionDiscontinuity( 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); + } + } + + @Override + public void finishAllSessions(EventTime eventTime) { + currentSessionId = null; + Iterator iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + iterator.remove(); + if (session.isCreated && listener != null) { + listener.onSessionFinished( + eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); } } } 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 53d63e23fc7..1038f3b6e1c 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,22 +99,40 @@ void onSessionFinished( /** * 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}. + * + *

    Should be called instead of {@link #updateSessions(EventTime)} if a {@link Timeline} change + * occurred. * - * @param eventTime The event time with the timeline change. + * @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 + * Listener#onSessionFinished(EventTime, String, boolean)} callback. + * + * @param eventTime The event time at which sessions are finished. + */ + void finishAllSessions(EventTime eventTime); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackStats.java index 893ecb07c2c..3c6929bdbf6 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; @@ -163,9 +166,8 @@ public int hashCode() { * #PLAYBACK_STATE_JOINING_FOREGROUND}, {@link #PLAYBACK_STATE_JOINING_BACKGROUND}, {@link * #PLAYBACK_STATE_PLAYING}, {@link #PLAYBACK_STATE_PAUSED}, {@link #PLAYBACK_STATE_SEEKING}, * {@link #PLAYBACK_STATE_BUFFERING}, {@link #PLAYBACK_STATE_PAUSED_BUFFERING}, {@link - * #PLAYBACK_STATE_SEEK_BUFFERING}, {@link #PLAYBACK_STATE_SUPPRESSED}, {@link - * #PLAYBACK_STATE_SUPPRESSED_BUFFERING}, {@link #PLAYBACK_STATE_ENDED}, {@link - * #PLAYBACK_STATE_STOPPED}, {@link #PLAYBACK_STATE_FAILED}, {@link + * #PLAYBACK_STATE_SUPPRESSED}, {@link #PLAYBACK_STATE_SUPPRESSED_BUFFERING}, {@link + * #PLAYBACK_STATE_ENDED}, {@link #PLAYBACK_STATE_STOPPED}, {@link #PLAYBACK_STATE_FAILED}, {@link * #PLAYBACK_STATE_INTERRUPTED_BY_AD} or {@link #PLAYBACK_STATE_ABANDONED}. */ @Documented @@ -180,7 +182,6 @@ public int hashCode() { PLAYBACK_STATE_SEEKING, PLAYBACK_STATE_BUFFERING, PLAYBACK_STATE_PAUSED_BUFFERING, - PLAYBACK_STATE_SEEK_BUFFERING, PLAYBACK_STATE_SUPPRESSED, PLAYBACK_STATE_SUPPRESSED_BUFFERING, PLAYBACK_STATE_ENDED, @@ -206,8 +207,6 @@ public int hashCode() { public static final int PLAYBACK_STATE_BUFFERING = 6; /** Playback is buffering while paused. */ public static final int PLAYBACK_STATE_PAUSED_BUFFERING = 7; - /** Playback is buffering after a seek. */ - public static final int PLAYBACK_STATE_SEEK_BUFFERING = 8; /** Playback is suppressed (e.g. due to audio focus loss). */ public static final int PLAYBACK_STATE_SUPPRESSED = 9; /** Playback is suppressed (e.g. due to audio focus loss) while buffering to resume a playback. */ @@ -280,7 +279,7 @@ public static PlaybackStats merge(PlaybackStats... 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; @@ -299,7 +298,7 @@ public static PlaybackStats merge(PlaybackStats... 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; @@ -769,8 +768,7 @@ public long getMeanSingleRebufferTimeMs() { * milliseconds. */ public long getTotalSeekTimeMs() { - return getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING) - + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING); + return getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING); } /** @@ -799,8 +797,7 @@ public long getMeanSingleSeekTimeMs() { public long getTotalWaitTimeMs() { return getPlaybackStateDurationMs(PLAYBACK_STATE_JOINING_FOREGROUND) + getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING) - + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING) - + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING); + + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING); } /** 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 97805da0fd7..ab137f98e19 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; @@ -83,7 +86,7 @@ public interface Callback { @Player.State private int playbackState; private boolean isSuppressed; private float playbackSpeed; - private boolean isSeeking; + private boolean onSeekStartedCalled; /** * Creates listener for playback stats. @@ -150,19 +153,18 @@ public void finishAllSessions() { // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with // an actual EventTime. Should also simplify other cases where the listener needs to be released // separately from the player. - HashMap trackerCopy = new HashMap<>(playbackStatsTrackers); - EventTime dummyEventTime = + sessionManager.finishAllSessions( new EventTime( SystemClock.elapsedRealtime(), Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null, /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, /* currentPlaybackPositionMs= */ 0, - /* totalBufferedDurationMs= */ 0); - for (String session : trackerCopy.keySet()) { - onSessionFinished(dummyEventTime, session, /* automaticTransition= */ false); - } + /* totalBufferedDurationMs= */ 0)); } // PlaybackSessionManager.Listener implementation. @@ -170,7 +172,7 @@ public void finishAllSessions() { @Override public void onSessionCreated(EventTime eventTime, String session) { PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime); - if (isSeeking) { + if (onSeekStartedCalled) { tracker.onSeekStarted(eventTime, /* belongsToPlayback= */ true); } tracker.onPlaybackStateChanged(eventTime, playbackState, /* belongsToPlayback= */ true); @@ -213,6 +215,9 @@ public void onAdPlaybackStarted(EventTime eventTime, String contentSession, Stri 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)) @@ -245,7 +250,7 @@ public void onSessionFinished(EventTime eventTime, String session, boolean autom @Override public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) { playbackState = state; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers @@ -258,7 +263,7 @@ public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) public void onPlayWhenReadyChanged( EventTime eventTime, boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { this.playWhenReady = playWhenReady; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers @@ -269,9 +274,9 @@ public void onPlayWhenReadyChanged( @Override public void onPlaybackSuppressionReasonChanged( - EventTime eventTime, int playbackSuppressionReason) { + EventTime eventTime, @Player.PlaybackSuppressionReason int playbackSuppressionReason) { isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE; - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers @@ -281,50 +286,47 @@ public void onPlaybackSuppressionReasonChanged( } @Override - public void onTimelineChanged(EventTime eventTime, int reason) { - sessionManager.handleTimelineUpdate(eventTime); - sessionManager.updateSessions(eventTime); + public void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason int reason) { + sessionManager.updateSessionsWithTimelineChange(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { - playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); + playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime, /* isSeek= */ false); } } } @Override - public void onPositionDiscontinuity(EventTime eventTime, int reason) { - sessionManager.handlePositionDiscontinuity(eventTime, reason); - sessionManager.updateSessions(eventTime); + public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) { + boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; + if (!isCompletelyIdle) { + sessionManager.updateSessionsWithDiscontinuity(eventTime, reason); + } + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + onSeekStartedCalled = false; + } for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { - playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); + playbackStatsTrackers + .get(session) + .onPositionDiscontinuity( + eventTime, /* isSeek= */ reason == Player.DISCONTINUITY_REASON_SEEK); } } } @Override public void onSeekStarted(EventTime eventTime) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); playbackStatsTrackers.get(session).onSeekStarted(eventTime, belongsToPlayback); } - isSeeking = true; - } - - @Override - public void onSeekProcessed(EventTime eventTime) { - sessionManager.updateSessions(eventTime); - for (String session : playbackStatsTrackers.keySet()) { - boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); - playbackStatsTrackers.get(session).onSeekProcessed(eventTime, belongsToPlayback); - } - isSeeking = false; + onSeekStartedCalled = true; } @Override public void onPlayerError(EventTime eventTime, ExoPlaybackException error) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onFatalError(eventTime, error); @@ -333,9 +335,10 @@ public void onPlayerError(EventTime eventTime, ExoPlaybackException error) { } @Override - public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { - this.playbackSpeed = playbackSpeed; - sessionManager.updateSessions(eventTime); + public void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) { + playbackSpeed = playbackParameters.speed; + maybeAddSession(eventTime); for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); } @@ -344,7 +347,7 @@ public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { @Override public void onTracksChanged( EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections); @@ -355,7 +358,7 @@ public void onTracksChanged( @Override public void onLoadStarted( EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onLoadStarted(eventTime); @@ -365,7 +368,7 @@ public void onLoadStarted( @Override public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData); @@ -380,7 +383,7 @@ public void onVideoSizeChanged( int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height); @@ -391,7 +394,7 @@ public void onVideoSizeChanged( @Override public void onBandwidthEstimate( EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded); @@ -402,7 +405,7 @@ public void onBandwidthEstimate( @Override public void onAudioUnderrun( EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onAudioUnderrun(); @@ -412,7 +415,7 @@ public void onAudioUnderrun( @Override public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames); @@ -427,7 +430,7 @@ public void onLoadError( MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); @@ -437,7 +440,7 @@ public void onLoadError( @Override public void onDrmSessionManagerError(EventTime eventTime, Exception error) { - sessionManager.updateSessions(eventTime); + maybeAddSession(eventTime); for (String session : playbackStatsTrackers.keySet()) { if (sessionManager.belongsToSession(eventTime, session)) { playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); @@ -445,6 +448,13 @@ public void onDrmSessionManagerError(EventTime eventTime, Exception error) { } } + private void maybeAddSession(EventTime eventTime) { + boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; + if (!isCompletelyIdle) { + sessionManager.updateSessions(eventTime); + } + } + /** Tracker for playback stats of a single playback. */ private static final class PlaybackStatsTracker { @@ -544,6 +554,9 @@ public void onPlaybackStateChanged( if (state != Player.STATE_IDLE) { hasFatalError = false; } + if (state != Player.STATE_BUFFERING) { + isSeeking = false; + } if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) { isInterruptedByAd = false; } @@ -582,8 +595,12 @@ public void onIsSuppressedChanged( * Notifies the tracker of a position discontinuity or timeline update for the current playback. * * @param eventTime The {@link EventTime}. + * @param isSeek Whether the position discontinuity is for a seek. */ - public void onPositionDiscontinuity(EventTime eventTime) { + public void onPositionDiscontinuity(EventTime eventTime, boolean isSeek) { + if (isSeek && playerPlaybackState == Player.STATE_IDLE) { + isSeeking = false; + } isInterruptedByAd = false; maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); } @@ -600,18 +617,6 @@ public void onSeekStarted(EventTime eventTime, boolean belongsToPlayback) { maybeUpdatePlaybackState(eventTime, belongsToPlayback); } - /** - * Notifies the tracker that a seek has been processed, including all seeks while the playback - * is not in the foreground. - * - * @param eventTime The {@link EventTime}. - * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback. - */ - public void onSeekProcessed(EventTime eventTime, boolean belongsToPlayback) { - isSeeking = false; - maybeUpdatePlaybackState(eventTime, belongsToPlayback); - } - /** * Notifies the tracker of fatal player error in the current playback. * @@ -791,7 +796,7 @@ public PlaybackStats build(boolean isFinal) { 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); @@ -927,10 +932,6 @@ private void maybeUpdatePlaybackState(EventTime eventTime, boolean belongsToPlay || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) { return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND; } - if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING - || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING) { - return PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING; - } if (!playWhenReady) { return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING; } 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 991ed9ee97d..c9c78a7422a 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 AudioCapabilitiesReceiver(Context context, Listener listener) { 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 7cb05cfa0d4..e51948725b0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioRendererEventListener.java @@ -17,11 +17,14 @@ import static com.google.android.exoplayer2.util.Util.castNonNull; +import android.media.AudioTrack; import android.os.Handler; import android.os.SystemClock; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.util.Assertions; @@ -66,16 +69,23 @@ default void onAudioDecoderInitialized( 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 +101,55 @@ default void onAudioDisabled(DecoderCounters counters) {} */ default void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) {} - /** Dispatches events to a {@link AudioRendererEventListener}. */ + /** + * Called when {@link AudioSink} has encountered an error. + * + *

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

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

    Fatal errors that cannot be recovered will be reported wrapped in a {@link + * ExoPlaybackException} by {@link Player.EventListener#onPlayerError(ExoPlaybackException)}. + * + * @param audioSinkError Either an {@link AudioSink.InitializationException} or a {@link + * AudioSink.WriteException} describing the error. + */ + default void onAudioSinkError(Exception audioSinkError) {} + + /** Dispatches events to an {@link AudioRendererEventListener}. */ final class EventDispatcher { @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 +159,33 @@ public void decoderInitialized(final String decoderName, } } - /** - * 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,20 +196,25 @@ public void disabled(final DecoderCounters counters) { } } - /** - * 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)); } } + + /** Invokes {@link AudioRendererEventListener#onAudioSinkError(Exception)}. */ + public void audioSinkError(Exception audioSinkError) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onAudioSinkError(audioSinkError)); + } + } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java index 725e0a8d394..c3351ffbad3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/AudioSink.java @@ -16,25 +16,30 @@ 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.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; 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 +75,19 @@ interface Listener { */ 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 +104,39 @@ interface Listener { * @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) {} + + /** + * Called when {@link AudioSink} has encountered an error. + * + *

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

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

    Fatal errors that cannot be recovered will be reported wrapped in a {@link + * ExoPlaybackException} by {@link Player.EventListener#onPlayerError(ExoPlaybackException)}. + * + * @param audioSinkError Either an {@link AudioSink.InitializationException} or a {@link + * AudioSink.WriteException} describing the error. + */ + default void onAudioSinkError(Exception audioSinkError) {} } /** @@ -113,34 +160,41 @@ public ConfigurationException(String message) { } - /** - * Thrown when a failure occurs initializing the sink. - */ + /** Thrown when a failure occurs initializing the sink. */ final class InitializationException extends Exception { - /** - * The underlying {@link AudioTrack}'s state, if applicable. - */ + /** The underlying {@link AudioTrack}'s state. */ public final int audioTrackState; + /** If the exception can be recovered by recreating the sink. */ + public final boolean isRecoverable; /** - * @param audioTrackState The underlying {@link AudioTrack}'s state, if applicable. * @param sampleRate The requested sample rate in Hz. * @param channelConfig The requested channel configuration. * @param bufferSize The requested buffer size in bytes. + * @param audioTrackException Exception thrown during the creation of the {@link AudioTrack}. */ - public InitializationException(int audioTrackState, int sampleRate, int channelConfig, - int bufferSize) { - super("AudioTrack init failed: " + audioTrackState + ", Config(" + sampleRate + ", " - + channelConfig + ", " + bufferSize + ")"); + public InitializationException( + int audioTrackState, + int sampleRate, + int channelConfig, + int bufferSize, + boolean isRecoverable, + @Nullable Exception audioTrackException) { + super( + "AudioTrack init failed " + + audioTrackState + + " " + + ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")") + + (isRecoverable ? " (recoverable)" : ""), + audioTrackException); this.audioTrackState = audioTrackState; + this.isRecoverable = isRecoverable; } } - /** - * Thrown when a failure occurs writing to the sink. - */ + /** Thrown when a failure occurs writing to the sink. */ final class WriteException extends Exception { /** @@ -150,20 +204,42 @@ final class WriteException extends Exception { * Otherwise, the meaning of the error code depends on the sink implementation. */ public final int errorCode; + /** If the exception can be recovered by recreating the sink. */ + public final boolean isRecoverable; - /** - * @param errorCode The error value returned from the sink implementation. - */ - public WriteException(int errorCode) { + /** @param errorCode The error value returned from the sink implementation. */ + public WriteException(int errorCode, boolean isRecoverable) { super("AudioTrack write failed: " + errorCode); + this.isRecoverable = isRecoverable; this.errorCode = errorCode; } } /** - * 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,17 +250,25 @@ public WriteException(int errorCode) { void setListener(Listener listener); /** - * Returns whether the sink supports the audio format. + * Returns whether the sink supports a given {@link Format}. + * + * @param format The format. + * @return Whether the sink supports the format. + */ + boolean supportsFormat(Format format); + + /** + * Returns the level of support that the sink provides for a given {@link Format}. * - * @param channelCount The number of channels, 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 The level of support provided. */ - boolean supportsOutput(int channelCount, @C.Encoding int encoding); + @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. + * 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. @@ -194,9 +278,7 @@ public WriteException(int errorCode) { /** * 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 @@ -204,20 +286,9 @@ public WriteException(int errorCode) { * 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; /** @@ -236,8 +307,8 @@ void configure( * *

    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. @@ -269,27 +340,20 @@ boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs, int encodedAcce 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(); /** @@ -345,6 +409,18 @@ boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs, int encodedAcce */ 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 9e870735f24..6b34d7f13d1 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 @@ * *

    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 @@ 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 @@ public AudioTimestampPoller(AudioTrack audioTrack) { * 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 @@ public boolean hasTimestamp() { } /** - * 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 4ee70bd8132..540ee098ee6 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 @@ * 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 @@ void onSystemTimeUsMismatch( *

    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 @@ void onSystemTimeUsMismatch( 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 @@ void onSystemTimeUsMismatch( 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 @@ public AudioTrackPositionTracker(Listener listener) { * 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 @@ public AudioTrackPositionTracker(Listener listener) { */ public void setAudioTrack( AudioTrack audioTrack, + boolean isPassthrough, @C.Encoding int outputEncoding, int outputPcmFrameSize, int bufferSize) { @@ -197,7 +223,7 @@ public void setAudioTrack( 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 @@ public void setAudioTrack( 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 @@ public long getCurrentPositionUs(boolean sourceEnded) { // 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 @@ public long getCurrentPositionUs(boolean sourceEnded) { 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 @@ public boolean mayHandleBuffer(long writtenFrames) { 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 @@ public int getAvailableBufferSize(long writtenBytes) { 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 @@ public boolean pause() { } /** - * 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 @@ private void maybePollAndCheckTimestamp(long systemTimeUs, long playbackPosition 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 @@ private void maybeUpdateLatency(long systemTimeUs) { 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 @@ private void resetSyncParams() { playheadOffsetCount = 0; nextPlayheadOffsetIndex = 0; lastPlayheadSampleTimeUs = 0; + lastSystemTimeUs = 0; + previousModeSystemTimeUs = 0; + notifiedPositionIncreasing = false; } /** @@ -497,7 +575,7 @@ private long getPlaybackHeadPosition() { // 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 968d8acebd5..f3ea686210f 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 b94d972dc55..c064c7e4594 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 @@ * * @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 cc69d5cfb38..c8f3d958d6d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DecoderAudioRenderer.java @@ -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.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 @@ * 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 DecoderAudioRenderer( 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,12 +232,23 @@ public final int supportsFormat(Format format) { 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#supportsFormat(Format) + */ + 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#supportsOutput(int, int) + * @see AudioSink#getFormatSupport(Format) (Format) */ - protected final boolean supportsOutput(int channelCount, @C.Encoding int encoding) { - return audioSink.supportsOutput(channelCount, encoding); + @SinkFormatSupport + protected final int getSinkFormatSupport(Format format) { + return audioSink.getFormatSupport(format); } @Override @@ -226,7 +257,7 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx try { audioSink.playToEndOfStream(); } catch (AudioSink.WriteException e) { - throw createRendererException(e, inputFormat); + throw createRendererException(e, inputFormat, e.isRecoverable); } return; } @@ -243,7 +274,11 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx // 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. @@ -261,11 +296,12 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx while (drainOutputBuffer()) {} while (feedInputBuffer()) {} TraceUtil.endSection(); - } catch (DecoderException - | AudioSink.ConfigurationException - | AudioSink.InitializationException - | AudioSink.WriteException e) { + } catch (DecoderException | AudioSink.ConfigurationException e) { throw createRendererException(e, inputFormat); + } catch (AudioSink.InitializationException e) { + throw createRendererException(e, inputFormat, e.isRecoverable); + } catch (AudioSink.WriteException e) { + throw createRendererException(e, inputFormat, e.isRecoverable); } decoderCounters.ensureUpdated(); } @@ -284,19 +320,10 @@ protected void onAudioSessionId(int audioSessionId) { } /** 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; } /** @@ -308,22 +335,23 @@ protected void onAudioTrackSkipSilenceEnabledChanged(boolean skipSilenceEnabled) * @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. * * @param oldFormat The previous format. * @param newFormat The new format. - * @return True if the existing decoder can be kept. + * @return Whether the existing decoder can be kept. */ protected boolean canKeepCodec(Format oldFormat, Format newFormat) { return false; @@ -353,15 +381,23 @@ private boolean drainOutputBuffer() } else { outputBuffer.release(); outputBuffer = null; - processEndOfStream(); + try { + processEndOfStream(); + } catch (AudioSink.WriteException e) { + throw createRendererException(e, getOutputFormat(decoder), e.isRecoverable); + } } 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; } @@ -398,66 +434,38 @@ private boolean feedInputBuffer() throws DecoderException, ExoPlaybackException 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); - } - - 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); + 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(); } - 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(); @@ -480,7 +488,7 @@ public boolean isEnded() { @Override public boolean isReady() { return audioSink.hasPendingData() - || (inputFormat != null && !waitingForKeys && (isSourceReady() || outputBuffer != null)); + || (inputFormat != null && (isSourceReady() || outputBuffer != null)); } @Override @@ -492,13 +500,13 @@ public long getPositionUs() { } @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 @@ -516,7 +524,12 @@ protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) @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; @@ -542,7 +555,6 @@ protected void onStopped() { protected void onDisabled() { inputFormat = null; audioTrackNeedsConfigure = true; - waitingForKeys = false; try { setSourceDrmSession(null); releaseDecoder(); @@ -643,7 +655,9 @@ private void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackE Format oldFormat = inputFormat; inputFormat = newFormat; - if (!canKeepCodec(oldFormat, inputFormat)) { + if (decoder == null) { + maybeInitDecoder(); + } else if (sourceDrmSession != decoderDrmSession || !canKeepCodec(oldFormat, inputFormat)) { if (decoderReceivedBuffers) { // Signal end of stream and wait for any final output buffers before re-initialization. decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; @@ -657,7 +671,6 @@ private void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackE encoderDelay = inputFormat.encoderDelay; encoderPadding = inputFormat.encoderPadding; - eventDispatcher.inputFormatChanged(inputFormat); } @@ -679,7 +692,7 @@ private void updateCurrentPosition() { currentPositionUs = allowPositionDiscontinuity ? newCurrentPositionUs - : Math.max(currentPositionUs, newCurrentPositionUs); + : max(currentPositionUs, newCurrentPositionUs); allowPositionDiscontinuity = false; } } @@ -694,21 +707,27 @@ public void onAudioSessionId(int audioSessionId) { @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); + } + + @Override + public void onAudioSinkError(Exception audioSinkError) { + eventDispatcher.audioSinkError(audioSinkError); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java index a0aebdfe663..19fbcb6d6a6 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -15,12 +15,19 @@ */ package com.google.android.exoplayer2.audio; -import android.annotation.SuppressLint; +import static java.lang.Math.max; +import static java.lang.Math.min; + 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.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; @@ -29,21 +36,26 @@ 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 interface AudioProcessorChain { 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 static class DefaultAudioProcessorChain implements AudioProcessorChain { /** * 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 DefaultAudioProcessorChain(AudioProcessor... audioProcessors) { /* 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 AudioProcessor[] getAudioProcessors() { 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,52 +194,63 @@ public long getSkippedOutputFrameCount() { } } + /** 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; /** - * @see AudioTrack#ERROR_BAD_VALUE - */ - private static final int ERROR_BAD_VALUE = AudioTrack.ERROR_BAD_VALUE; - /** - * @see AudioTrack#MODE_STATIC - */ - private static final int MODE_STATIC = AudioTrack.MODE_STATIC; - /** - * @see AudioTrack#MODE_STREAM - */ - private static final int MODE_STREAM = AudioTrack.MODE_STREAM; - /** - * @see AudioTrack#STATE_INITIALIZED + * Native error code equivalent of {@link AudioTrack#ERROR_DEAD_OBJECT} to workaround missing + * error code translation on some devices. + * + *

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

    For example: {@link AudioTrack#write(byte[], int, int)} can return -32 instead of {@link + * AudioTrack#ERROR_DEAD_OBJECT}. */ - private static final int STATE_INITIALIZED = AudioTrack.STATE_INITIALIZED; + private static final int ERROR_NATIVE_DEAD_OBJECT = -32; + /** - * @see AudioTrack#WRITE_NON_BLOCKING + * The duration for which failed attempts to initialize or write to the audio track may be retried + * before throwing an exception, in milliseconds. */ - @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 int AUDIO_TRACK_RETRY_DURATION_MS = 100; private static final String TAG = "AudioTrack"; @@ -264,18 +282,27 @@ public long getSkippedOutputFrameCount() { private final ConditionVariable releasingConditionVariable; private final AudioTrackPositionTracker audioTrackPositionTracker; private final ArrayDeque mediaPositionParametersCheckpoints; + private final boolean enableAudioTrackPlaybackParams; + private final boolean enableOffload; + @MonotonicNonNull private StreamEventCallbackV29 offloadStreamEventCallbackV29; + private final PendingExceptionHolder + initializationExceptionPendingExceptionHolder; + private final PendingExceptionHolder writeExceptionPendingExceptionHolder; @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 +313,7 @@ public long getSkippedOutputFrameCount() { private long writtenEncodedFrames; private int framesPerEncodedSample; private boolean startMediaTimeUsNeedsSync; + private boolean startMediaTimeUsNeedsInit; private long startMediaTimeUs; private float volume; @@ -294,7 +322,7 @@ public long getSkippedOutputFrameCount() { @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 +333,8 @@ public long getSkippedOutputFrameCount() { private AuxEffectInfo auxEffectInfo; private boolean tunneling; private long lastFeedElapsedRealtimeMs; + private boolean offloadDisabledUntilNextConfiguration; + private boolean isWaitingForOffloadEndOfStreamHandled; /** * Creates a new default audio sink. @@ -335,7 +365,12 @@ public DefaultAudioSink( @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 DefaultAudioSink( * 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,20 +419,25 @@ public DefaultAudioSink( 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]; mediaPositionParametersCheckpoints = new ArrayDeque<>(); + initializationExceptionPendingExceptionHolder = + new PendingExceptionHolder<>(AUDIO_TRACK_RETRY_DURATION_MS); + writeExceptionPendingExceptionHolder = + new PendingExceptionHolder<>(AUDIO_TRACK_RETRY_DURATION_MS); } // AudioSink implementation. @@ -395,66 +448,86 @@ public void setListener(Listener listener) { } @Override - public boolean supportsOutput(int channelCount, @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; + + @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; + } } - } - - boolean isInputPcm = Util.isEncodingLinearPcm(inputEncoding); - boolean processingEnabled = isInputPcm; - int sampleRate = inputSampleRate; - int channelCount = inputChannelCount; - @C.Encoding int encoding = inputEncoding; - boolean useFloatOutput = - enableFloatOutput - && supportsOutput(inputChannelCount, C.ENCODING_PCM_FLOAT) - && Util.isEncodingHighResolutionPcm(inputEncoding); - AudioProcessor[] availableAudioProcessors = - useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; - if (processingEnabled) { - trimmingAudioProcessor.setTrimFrameCount(trimStartFrames, trimEndFrames); 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 +538,61 @@ && supportsOutput(inputChannelCount, C.ENCODING_PCM_FLOAT) 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 +623,7 @@ private void flushAudioProcessors() { } } - 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 +631,12 @@ private void initialize(long presentationTimeUs) throws InitializationException // 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 +658,9 @@ private void initialize(long presentationTimeUs) throws InitializationException } } - 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 +670,14 @@ private void initialize(long presentationTimeUs) throws InitializationException 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 +711,40 @@ public boolean handleBuffer( // 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); + isWaitingForOffloadEndOfStreamHandled = true; + } } // Re-apply playback parameters. - applyPlaybackSpeedAndSkipSilence(presentationTimeUs); + applyAudioProcessorPlaybackParametersAndSkipSilence(presentationTimeUs); } - if (!isInitialized()) { - initialize(presentationTimeUs); + if (!isAudioTrackInitialized()) { + try { + initializeAudioTrack(); + } catch (InitializationException e) { + if (e.isRecoverable) { + throw e; // Do not delay the exception if it can be recovered at higher level. + } + initializationExceptionPendingExceptionHolder.throwExceptionIfDeadlineIsReached(e); + return false; + } + } + initializationExceptionPendingExceptionHolder.clear(); + + if (startMediaTimeUsNeedsInit) { + startMediaTimeUs = max(0, presentationTimeUs); + startMediaTimeUsNeedsSync = false; + startMediaTimeUsNeedsInit = false; + + if (enableAudioTrackPlaybackParams && Util.SDK_INT >= 23) { + setAudioTrackPlaybackParametersV23(audioTrackPlaybackParameters); + } + applyAudioProcessorPlaybackParametersAndSkipSilence(presentationTimeUs); + if (playing) { play(); } @@ -629,12 +756,13 @@ public boolean handleBuffer( 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 +779,11 @@ public boolean handleBuffer( // 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 +810,13 @@ public boolean handleBuffer( 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 +843,29 @@ public boolean handleBuffer( return false; } + private AudioTrack buildAudioTrack() throws InitializationException { + try { + return Assertions.checkNotNull(configuration) + .buildAudioTrack(tunneling, audioAttributes, audioSessionId); + } catch (InitializationException e) { + maybeDisableOffload(); + if (listener != null) { + listener.onAudioSinkError(e); + } + 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; @@ -766,37 +917,77 @@ private void writeBuffer(ByteBuffer buffer, long avSyncPresentationTimeUs) throw } } int bytesRemaining = buffer.remaining(); - int bytesWritten = 0; - if (Util.SDK_INT < 21) { // isInputPcm == true + int bytesWrittenOrError = 0; // Error if negative + if (Util.SDK_INT < 21) { // outputMode == OUTPUT_MODE_PCM. // Work out how many bytes we can write without the risk of blocking. int bytesToWrite = audioTrackPositionTracker.getAvailableBufferSize(writtenPcmBytes); if (bytesToWrite > 0) { - bytesToWrite = Math.min(bytesRemaining, bytesToWrite); - bytesWritten = audioTrack.write(preV21OutputBuffer, preV21OutputBufferOffset, bytesToWrite); - if (bytesWritten > 0) { - preV21OutputBufferOffset += bytesWritten; - buffer.position(buffer.position() + bytesWritten); + bytesToWrite = min(bytesRemaining, bytesToWrite); + bytesWrittenOrError = + audioTrack.write(preV21OutputBuffer, preV21OutputBufferOffset, bytesToWrite); + if (bytesWrittenOrError > 0) { // No error + preV21OutputBufferOffset += bytesWrittenOrError; + buffer.position(buffer.position() + bytesWrittenOrError); } } } else if (tunneling) { Assertions.checkState(avSyncPresentationTimeUs != C.TIME_UNSET); - bytesWritten = writeNonBlockingWithAvSyncV21(audioTrack, buffer, bytesRemaining, - avSyncPresentationTimeUs); + bytesWrittenOrError = + writeNonBlockingWithAvSyncV21( + audioTrack, buffer, bytesRemaining, avSyncPresentationTimeUs); } else { - bytesWritten = writeNonBlockingV21(audioTrack, buffer, bytesRemaining); + bytesWrittenOrError = writeNonBlockingV21(audioTrack, buffer, bytesRemaining); } lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); - if (bytesWritten < 0) { - throw new WriteException(bytesWritten); + if (bytesWrittenOrError < 0) { + int error = bytesWrittenOrError; + boolean isRecoverable = isAudioTrackDeadObject(error); + if (isRecoverable) { + maybeDisableOffload(); + } + WriteException e = new WriteException(error, isRecoverable); + if (listener != null) { + listener.onAudioSinkError(e); + } + if (e.isRecoverable) { + throw e; // Do not delay the exception if it can be recovered at higher level. + } + writeExceptionPendingExceptionHolder.throwExceptionIfDeadlineIsReached(e); + return; + } + writeExceptionPendingExceptionHolder.clear(); + + int bytesWritten = bytesWrittenOrError; + + if (isOffloadedPlayback(audioTrack)) { + // After calling AudioTrack.setOffloadEndOfStream, the AudioTrack internally stops and + // restarts during which AudioTrack.write will return 0. This situation must be detected to + // prevent reporting the buffer as full even though it is not which could lead ExoPlayer to + // sleep forever waiting for a onDataRequest that will never come. + if (writtenEncodedFrames > 0) { + isWaitingForOffloadEndOfStreamHandled = false; + } + + // Consider the offload buffer as full if the AudioTrack is playing and AudioTrack.write could + // not write all the data provided to it. This relies on the assumption that AudioTrack.write + // always writes as much as possible. + if (playing + && listener != null + && bytesWritten < bytesRemaining + && !isWaitingForOffloadEndOfStreamHandled) { + long pendingDurationMs = + audioTrackPositionTracker.getPendingBufferDurationMs(writtenEncodedFrames); + listener.onOffloadBufferFull(pendingDurationMs); + } } - if (configuration.isInputPcm) { + 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 +999,31 @@ private void writeBuffer(ByteBuffer buffer, long avSyncPresentationTimeUs) throw @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) + || status == ERROR_NATIVE_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 +1052,40 @@ private boolean drainToEndOfStream() throws WriteException { @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 +1161,7 @@ public void setVolume(float volume) { } private void setVolumeInternal() { - if (!isInitialized()) { + if (!isAudioTrackInitialized()) { // Do nothing. } else if (Util.SDK_INT >= 21) { setVolumeInternalV21(audioTrack, volume); @@ -975,41 +1173,22 @@ private void setVolumeInternal() { @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; @@ -1031,6 +1210,41 @@ public void run() { } }.start(); } + writeExceptionPendingExceptionHolder.clear(); + initializationExceptionPendingExceptionHolder.clear(); + } + + @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; + } + + writeExceptionPendingExceptionHolder.clear(); + initializationExceptionPendingExceptionHolder.clear(); + + 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 @@ -1045,10 +1259,39 @@ public void reset() { } audioSessionId = C.AUDIO_SESSION_ID_UNSET; playing = false; + offloadDisabledUntilNextConfiguration = false; } // Internal methods. + private void resetSinkStateForFlush() { + submittedPcmBytes = 0; + submittedEncodedFrames = 0; + writtenPcmBytes = 0; + writtenEncodedFrames = 0; + isWaitingForOffloadEndOfStreamHandled = false; + 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 +1309,41 @@ public void run() { }.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 +1355,10 @@ private void setPlaybackSpeedAndSkipSilence(float playbackSpeed, boolean skipSil } } + private PlaybackParameters getAudioProcessorPlaybackParameters() { + return getMediaPositionParameters().playbackParameters; + } + private MediaPositionParameters getMediaPositionParameters() { // Mask the already set parameters. return afterDrainParameters != null @@ -1097,20 +1368,20 @@ private MediaPositionParameters getMediaPositionParameters() { : 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 +1404,7 @@ private long applyMediaPositionParameters(long positionUs) { long playoutDurationSinceLastCheckpoint = positionUs - mediaPositionParameters.audioTrackPositionUs; - if (mediaPositionParameters.playbackSpeed != 1f) { + if (!mediaPositionParameters.playbackParameters.equals(PlaybackParameters.DEFAULT)) { if (mediaPositionParametersCheckpoints.isEmpty()) { playoutDurationSinceLastCheckpoint = audioProcessorChain.getMediaDuration(playoutDurationSinceLastCheckpoint); @@ -1141,7 +1412,8 @@ private long applyMediaPositionParameters(long positionUs) { // 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 +1424,87 @@ private long applySkipping(long positionUs) { + 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; + } + + /** + * 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 getChannelConfig(int channelCount, boolean isInputPcm) { - if (Util.SDK_INT <= 28 && !isInputPcm) { + private static int getChannelConfigForPassthrough(int channelCount) { + if (Util.SDK_INT <= 28) { // In passthrough mode the channel count used to configure the audio track doesn't affect how // the stream is handled, except that some devices do overly-strict channel configuration // checks. Therefore we override the channel count so that a known-working channel @@ -1190,15 +1516,69 @@ private static int getChannelConfig(int channelCount, boolean isInputPcm) { } } - // 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, + AudioTrack.MODE_STATIC, + audioSessionId); + } + private static int getMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) { switch (encoding) { case C.ENCODING_MP3: @@ -1232,6 +1612,7 @@ private static int getMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) 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: @@ -1242,7 +1623,12 @@ private static int getMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) private static int getFramesPerEncodedSample(@C.Encoding int encoding, ByteBuffer buffer) { switch (encoding) { case C.ENCODING_MP3: - return MpegAudioUtil.parseMpegAudioFrameSampleCount(buffer.get(buffer.position())); + int headerDataInBigEndian = Util.getBigEndianInt(buffer, buffer.position()); + 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: @@ -1273,6 +1659,7 @@ private static int getFramesPerEncodedSample(@C.Encoding int encoding, ByteBuffe 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: @@ -1282,7 +1669,7 @@ private static int getFramesPerEncodedSample(@C.Encoding int encoding, ByteBuffe @RequiresApi(21) private static int writeNonBlockingV21(AudioTrack audioTrack, ByteBuffer buffer, int size) { - return audioTrack.write(buffer, size, WRITE_NON_BLOCKING); + return audioTrack.write(buffer, size, AudioTrack.WRITE_NON_BLOCKING); } @RequiresApi(21) @@ -1290,7 +1677,8 @@ private int writeNonBlockingWithAvSyncV21( AudioTrack audioTrack, ByteBuffer buffer, int size, long presentationTimeUs) { if (Util.SDK_INT >= 26) { // The underlying platform AudioTrack writes AV sync headers directly. - return audioTrack.write(buffer, size, WRITE_NON_BLOCKING, presentationTimeUs * 1000); + return audioTrack.write( + buffer, size, AudioTrack.WRITE_NON_BLOCKING, presentationTimeUs * 1000); } if (avSyncHeader == null) { avSyncHeader = ByteBuffer.allocate(16); @@ -1305,7 +1693,8 @@ private int writeNonBlockingWithAvSyncV21( } int avSyncHeaderBytesRemaining = avSyncHeader.remaining(); if (avSyncHeaderBytesRemaining > 0) { - int result = audioTrack.write(avSyncHeader, avSyncHeaderBytesRemaining, WRITE_NON_BLOCKING); + int result = + audioTrack.write(avSyncHeader, avSyncHeaderBytesRemaining, AudioTrack.WRITE_NON_BLOCKING); if (result < 0) { bytesUntilNextAvSync = 0; return result; @@ -1341,11 +1730,49 @@ private void playPendingData() { } } + @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 == audioTrack); + if (listener != null && playing) { + // Do not signal that the buffer is emptying if not playing as it is a transient state. + listener.onOffloadBufferEmptying(); + } + } + + @Override + public void onTearDown(@NonNull AudioTrack track) { + Assertions.checkState(track == audioTrack); + if (listener != null && playing) { + // The audio track was destroyed while in use. Thus a new AudioTrack needs to be created + // and its buffer filled, which will be done on the next handleBuffer call. + // Request this call explicitly in case ExoPlayer is sleeping waiting for a data request. + listener.onOffloadBufferEmptying(); + } + } + + 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. */ @@ -1354,14 +1781,26 @@ private static final class MediaPositionParameters { 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 @@ -1419,6 +1858,13 @@ public void onInvalidLatency(long latencyUs) { 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) { @@ -1431,51 +1877,54 @@ public void onUnderrun(int bufferSize, long bufferSizeMs) { /** 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) { @@ -1490,96 +1939,196 @@ public AudioTrack buildAudioTrack( 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 | IllegalArgumentException e) { + throw new InitializationException( + AudioTrack.STATE_UNINITIALIZED, + outputSampleRate, + outputChannelConfig, + bufferSize, + /* isRecoverable= */ outputModeIsOffload(), + e); } int state = audioTrack.getState(); - if (state != STATE_INITIALIZED) { + if (state != AudioTrack.STATE_INITIALIZED) { try { audioTrack.release(); } catch (Exception e) { // The track has already failed to initialize, so it wouldn't be that surprising if // release were to fail too. Swallow the exception. } - throw new InitializationException(state, outputSampleRate, outputChannelConfig, bufferSize); + throw new InitializationException( + state, + outputSampleRate, + outputChannelConfig, + bufferSize, + /* isRecoverable= */ outputModeIsOffload(), + /* audioTrackException= */ null); } return audioTrack; } - @RequiresApi(21) - private AudioTrack createAudioTrackV21( + private AudioTrack createAudioTrack( 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(); + if (Util.SDK_INT >= 29) { + return createAudioTrackV29(tunneling, audioAttributes, audioSessionId); + } else if (Util.SDK_INT >= 21) { + return createAudioTrackV21(tunneling, audioAttributes, audioSessionId); } else { - attributes = audioAttributes.getAudioAttributesV21(); + return createAudioTrackV9(audioAttributes, audioSessionId); } - AudioFormat format = - new AudioFormat.Builder() - .setChannelMask(outputChannelConfig) - .setEncoding(outputEncoding) - .setSampleRate(outputSampleRate) - .build(); + } + + @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) { 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); - } - - 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); + AudioTrack.MODE_STREAM, + audioSessionId); + } + + 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, + AudioTrack.MODE_STREAM); } else { - int rate = getMaximumEncodedRateBytesPerSecond(outputEncoding); - if (outputEncoding == C.ENCODING_AC3) { - rate *= AC3_BUFFER_MULTIPLICATION_FACTOR; + // Re-attach to the same audio session. + return new AudioTrack( + streamType, + outputSampleRate, + outputChannelConfig, + outputEncoding, + bufferSize, + AudioTrack.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 != AudioTrack.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; + } + } + + private static final class PendingExceptionHolder { + + private final long throwDelayMs; + + @Nullable private T pendingException; + private long throwDeadlineMs; + + public PendingExceptionHolder(long throwDelayMs) { + this.throwDelayMs = throwDelayMs; + } + + public void throwExceptionIfDeadlineIsReached(T exception) throws T { + long nowMs = SystemClock.elapsedRealtime(); + if (pendingException == null) { + pendingException = exception; + throwDeadlineMs = nowMs + throwDelayMs; + } + if (nowMs >= throwDeadlineMs) { + if (pendingException != exception) { + // All retry exception are probably the same, thus only save the last one to save memory. + pendingException.addSuppressed(exception); } - return (int) (PASSTHROUGH_BUFFER_DURATION_US * rate / C.MICROS_PER_SECOND); + T pendingException = this.pendingException; + clear(); + throw pendingException; } } + + public void clear() { + pendingException = null; + } } } 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 7ab1cdade41..7460d124576 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,6 +16,7 @@ package com.google.android.exoplayer2.audio; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import java.nio.ByteBuffer; @@ -34,8 +35,14 @@ public void setListener(Listener listener) { } @Override - public boolean supportsOutput(int channelCount, int encoding) { - return sink.supportsOutput(channelCount, encoding); + public boolean supportsFormat(Format format) { + return sink.supportsFormat(format); + } + + @Override + @SinkFormatSupport + public int getFormatSupport(Format format) { + return sink.getFormatSupport(format); } @Override @@ -44,23 +51,9 @@ public long getCurrentPositionUs(boolean sourceEnded) { } @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 @@ -95,35 +88,16 @@ public boolean hasPendingData() { 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); @@ -174,6 +148,11 @@ public void flush() { 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 90fb1f81101..e051aa1a3fb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -15,23 +15,32 @@ */ 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.audio.AudioSink.InitializationException; +import com.google.android.exoplayer2.audio.AudioSink.WriteException; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; @@ -82,25 +91,25 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private final AudioSink audioSink; private int codecMaxInputSize; - private boolean passthroughEnabled; private boolean codecNeedsDiscardChannelsWorkaround; private boolean codecNeedsEosBufferTimestampWorkaround; - private android.media.MediaFormat passthroughMediaFormat; - @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 +124,7 @@ public MediaCodecAudioRenderer( MediaCodecSelector mediaCodecSelector, @Nullable Handler eventHandler, @Nullable AudioRendererEventListener eventListener) { - this( - context, - mediaCodecSelector, - eventHandler, - eventListener, - (AudioCapabilities) null); + this(context, mediaCodecSelector, eventHandler, eventListener, (AudioCapabilities) null); } /** @@ -206,24 +210,45 @@ public String getName() { 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) throws DecoderQueryException { - String mimeType = format.sampleMimeType; - if (!MimeTypes.isAudio(mimeType)) { + if (!MimeTypes.isAudio(format.sampleMimeType)) { return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); } @TunnelingSupport int tunnelingSupport = Util.SDK_INT >= 21 ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; + boolean formatHasDrm = format.exoMediaCryptoType != null; boolean supportsFormatDrm = supportsFormatDrm(format); - if (supportsFormatDrm && usePassthrough(format.channelCount, mimeType)) { + // 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 + && audioSink.supportsFormat(format) + && (!formatHasDrm || MediaCodecUtil.getDecryptOnlyDecoderInfo() != null)) { return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); } - if ((MimeTypes.AUDIO_RAW.equals(mimeType) - && !audioSink.supportsOutput(format.channelCount, format.pcmEncoding)) - || !audioSink.supportsOutput(format.channelCount, 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 = @@ -255,8 +280,12 @@ protected List getDecoderInfos( if (mimeType == null) { return Collections.emptyList(); } - if (usePassthrough(format.channelCount, mimeType)) { - return Collections.singletonList(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); + } } List decoderInfos = mediaCodecSelector.getDecoderInfos( @@ -273,59 +302,35 @@ protected List getDecoderInfos( return Collections.unmodifiableList(decoderInfos); } - /** - * Returns whether encoded audio passthrough should be used for playing back the input format. - * - * @param channelCount The number of channels in the input media, or {@link Format#NO_VALUE} if - * not known. - * @param mimeType The type of input media. - * @return Whether passthrough playback is supported. - * @throws DecoderQueryException If there was an error querying the available passthrough - * decoders. - */ - protected boolean usePassthrough(int channelCount, String mimeType) throws DecoderQueryException { - return getPassthroughEncoding(channelCount, mimeType) != C.ENCODING_INVALID - && MediaCodecUtil.getPassthroughDecoderInfo() != null; + @Override + 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); - if (passthroughEnabled) { - // Store the input MIME type if we're using the passthrough codec. - passthroughMediaFormat = mediaFormat; - passthroughMediaFormat.setString(MediaFormat.KEY_MIME, format.sampleMimeType); - } else { - passthroughMediaFormat = 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)) { @@ -374,95 +379,72 @@ protected float getCodecOperatingRateV23( 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; - MediaFormat mediaFormat; - if (passthroughMediaFormat != null) { - mediaFormat = passthroughMediaFormat; - encoding = - getPassthroughEncoding( - mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT), - mediaFormat.getString(MediaFormat.KEY_MIME)); + Format audioSinkInputFormat; + @Nullable int[] channelMap = null; + if (decryptOnlyCodecFormat != null) { // Direct playback with a codec for decryption. + audioSinkInputFormat = decryptOnlyCodecFormat; + } else if (getCodec() == null) { // Direct playback with codec bypass. + audioSinkInputFormat = format; } else { - mediaFormat = outputMediaFormat; - if (outputMediaFormat.containsKey(VIVO_BITS_PER_SAMPLE_KEY)) { - encoding = Util.getPcmEncoding(outputMediaFormat.getInteger(VIVO_BITS_PER_SAMPLE_KEY)); + @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 { - encoding = getPcmEncoding(inputFormat); + // 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; } - } - int channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT); - int sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE); - int[] channelMap; - if (codecNeedsDiscardChannelsWorkaround && channelCount == 6 && inputFormat.channelCount < 6) { - channelMap = new int[inputFormat.channelCount]; - for (int i = 0; i < inputFormat.channelCount; i++) { - channelMap[i] = i; + 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; + } } - } else { - channelMap = null; } - try { - audioSink.configure( - encoding, - channelCount, - sampleRate, - 0, - channelMap, - inputFormat.encoderDelay, - inputFormat.encoderPadding); + audioSink.configure(audioSinkInputFormat, /* specifiedBufferSize= */ 0, channelMap); } catch (AudioSink.ConfigurationException e) { - // TODO(internal: b/145658993) Use outputFormat instead. - throw createRendererException(e, inputFormat); - } - } - - /** - * Returns the {@link C.Encoding} constant to use for passthrough of the given format, or {@link - * C#ENCODING_INVALID} if passthrough is not possible. - */ - @C.Encoding - protected int getPassthroughEncoding(int channelCount, String mimeType) { - 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, C.ENCODING_E_AC3_JOC)) { - return MimeTypes.getEncoding(MimeTypes.AUDIO_E_AC3_JOC); - } - // E-AC3 receivers can decode JOC streams, but in 2-D rather than 3-D, so try to fall back. - mimeType = MimeTypes.AUDIO_E_AC3; - } - - @C.Encoding int encoding = MimeTypes.getEncoding(mimeType); - if (audioSink.supportsOutput(channelCount, encoding)) { - return encoding; - } else { - return C.ENCODING_INVALID; + throw createRendererException(e, format); } } @@ -479,19 +461,10 @@ protected void onAudioSessionId(int audioSessionId) { } /** 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 @@ -510,7 +483,12 @@ protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) @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; @@ -570,13 +548,13 @@ public long getPositionUs() { } @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 @@ -602,8 +580,8 @@ protected void onProcessedStreamChange() { protected boolean processOutputBuffer( long positionUs, long elapsedRealtimeUs, - MediaCodec codec, - ByteBuffer buffer, + @Nullable MediaCodec codec, + @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, int sampleCount, @@ -612,22 +590,27 @@ protected boolean processOutputBuffer( boolean isLastBuffer, Format format) throws ExoPlaybackException { - if (codecNeedsEosBufferTimestampWorkaround + checkNotNull(buffer); + if (codec != null + && codecNeedsEosBufferTimestampWorkaround && bufferPresentationTimeUs == 0 && (bufferFlags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 && getLargestQueuedPresentationTimeUs() != C.TIME_UNSET) { 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; } if (isDecodeOnlyBuffer) { - codec.releaseOutputBuffer(bufferIndex, false); - decoderCounters.skippedOutputBufferCount++; + if (codec != null) { + codec.releaseOutputBuffer(bufferIndex, false); + } + decoderCounters.skippedOutputBufferCount += sampleCount; audioSink.handleDiscontinuity(); return true; } @@ -635,14 +618,17 @@ && getLargestQueuedPresentationTimeUs() != C.TIME_UNSET) { boolean fullyConsumed; try { fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount); - } catch (AudioSink.InitializationException | AudioSink.WriteException e) { - // TODO(internal: b/145658993) Use outputFormat instead. - throw createRendererException(e, inputFormat); + } catch (InitializationException e) { + throw createRendererException(e, format, e.isRecoverable); + } catch (WriteException e) { + throw createRendererException(e, format, e.isRecoverable); } if (fullyConsumed) { - codec.releaseOutputBuffer(bufferIndex, false); - decoderCounters.renderedOutputBufferCount++; + if (codec != null) { + codec.releaseOutputBuffer(bufferIndex, false); + } + decoderCounters.renderedOutputBufferCount += sampleCount; return true; } @@ -654,8 +640,9 @@ protected void renderToEndOfStream() throws ExoPlaybackException { 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(), e.isRecoverable); } } @@ -679,6 +666,9 @@ public void handleMessage(int messageType, @Nullable Object message) throws ExoP 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; @@ -705,7 +695,7 @@ protected int getCodecMaxInputSize( 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; @@ -766,6 +756,12 @@ protected MediaFormat getMediaFormat( // 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; } @@ -775,7 +771,7 @@ private void updateCurrentPosition() { currentPositionUs = allowPositionDiscontinuity ? newCurrentPositionUs - : Math.max(currentPositionUs, newCurrentPositionUs); + : max(currentPositionUs, newCurrentPositionUs); allowPositionDiscontinuity = false; } } @@ -794,15 +790,17 @@ private static boolean deviceDoesntSupportOperatingRate() { /** * 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")); } /** @@ -823,15 +821,6 @@ private static boolean codecNeedsEosBufferTimestampWorkaround(String codecName) || 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 @@ -842,21 +831,41 @@ public void onAudioSessionId(int audioSessionId) { @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); + } + } + + @Override + public void onAudioSinkError(Exception audioSinkError) { + eventDispatcher.audioSinkError(audioSinkError); } } } 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 883f5bcb924..a4d2a1b67ac 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 @@ 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 @@ public void queueInput(ByteBuffer inputBuffer) { // 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 2a98d2fb256..c571ce75000 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,8 +15,11 @@ */ 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; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -30,27 +33,20 @@ 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. - */ - private static final long 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) + * minimumSilenceDurationUs}. */ - private static final long PADDING_SILENCE_US = 20_000; + public static final long DEFAULT_MINIMUM_SILENCE_DURATION_US = 150_000; /** - * The absolute level below which an individual PCM sample is classified as silent. Note: the - * specified value will be rounded so that the threshold check only depends on the more - * significant byte, for efficiency. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * paddingSilenceUs}. */ - private static final short SILENCE_THRESHOLD_LEVEL = 1024; - + public static final long DEFAULT_PADDING_SILENCE_US = 20_000; /** - * Threshold for classifying an individual PCM sample as silent based on its more significant - * byte. This is {@link #SILENCE_THRESHOLD_LEVEL} divided by 256 with rounding. + * The default value for {@link #SilenceSkippingAudioProcessor(long, long, short) + * silenceThresholdLevel}. */ - private static final byte SILENCE_THRESHOLD_LEVEL_MSB = (SILENCE_THRESHOLD_LEVEL + 128) >> 8; + public static final short DEFAULT_SILENCE_THRESHOLD_LEVEL = 1024; /** Trimming states. */ @Documented @@ -68,8 +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; /** @@ -91,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; } @@ -166,11 +187,11 @@ protected void onQueueEndOfStream() { 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]; } @@ -199,7 +220,7 @@ private void processNoisy(ByteBuffer inputBuffer) { 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. @@ -229,7 +250,7 @@ private void processMaybeSilence(ByteBuffer inputBuffer) { 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; @@ -301,7 +322,7 @@ private void output(ByteBuffer data) { * 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, @@ -326,8 +347,8 @@ private int durationUsToFrames(long durationUs) { */ private int findNoisePosition(ByteBuffer buffer) { // The input is in ByteOrder.nativeOrder(), which is little endian on Android. - for (int i = buffer.position() + 1; i < buffer.limit(); i += 2) { - if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + for (int i = buffer.position(); i < buffer.limit(); i += 2) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Round to the start of the frame. return bytesPerFrame * (i / bytesPerFrame); } @@ -341,8 +362,8 @@ private int findNoisePosition(ByteBuffer buffer) { */ private int findNoiseLimit(ByteBuffer buffer) { // The input is in ByteOrder.nativeOrder(), which is little endian on Android. - for (int i = buffer.limit() - 1; i >= buffer.position(); i -= 2) { - if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + for (int i = buffer.limit() - 2; i >= buffer.position(); i -= 2) { + if (Math.abs(buffer.getShort(i)) > silenceThresholdLevel) { // Return the start of the next frame. return bytesPerFrame * (i / bytesPerFrame) + bytesPerFrame; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/audio/Sonic.java b/library/core/src/main/java/com/google/android/exoplayer2/audio/Sonic.java index 50e424003de..ae65eacd130 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 @@ 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 @@ * @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 @@ public void queueInput(ShortBuffer buffer) { * @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 @@ public void getOutput(ShortBuffer buffer) { */ 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 @@ private void copyToOutput(short[] samples, int positionFrames, int frameCount) { } 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 @@ private void changeSpeed(float speed) { 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 48075bac50f..5c3c1db0c74 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 @@ */ 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 SonicAudioProcessor() { * @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 float setSpeed(float speed) { 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 AudioFormat configure(AudioFormat inputAudioFormat) throws UnhandledAudio 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 void flush() { inputAudioFormat.sampleRate, inputAudioFormat.channelCount, speed, + pitch, outputAudioFormat.sampleRate); } else if (sonic != null) { sonic.flush(); @@ -229,6 +234,7 @@ public void flush() { @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 a9afa471988..9ea230d38d5 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 @@ private void writeFileHeader(RandomAccessFile randomAccessFile) throws IOExcepti 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 8d84325d932..ed51726530f 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 @@ public TrimmingAudioProcessor() { * * @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 @@ public void queueInput(ByteBuffer inputBuffer) { } // 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); @@ -155,18 +157,20 @@ protected void onQueueEndOfStream() { @Override protected void onFlush() { if (reconfigurationPending) { - // This is the initial flush after reconfiguration. Prepare to trim bytes from the start/end. + // Flushing activates the new configuration, so prepare to trim bytes from the start/end. reconfigurationPending = false; endBuffer = new byte[trimEndFrames * inputAudioFormat.bytesPerFrame]; pendingTrimStartBytes = trimStartFrames * inputAudioFormat.bytesPerFrame; - } else { - // This is a flush during playback (after the initial flush). We assume this was caused by a - // seek to a non-zero position and clear pending start bytes. This assumption may be wrong (we - // may be seeking to zero), but playing data that should have been trimmed shouldn't be - // noticeable after a seek. Ideally we would check the timestamp of the first input buffer - // queued after flushing to decide whether to trim (see also [Internal: b/77292509]). - pendingTrimStartBytes = 0; } + + // TODO(internal b/77292509): Flushing occurs to activate a configuration (handled above) but + // also when seeking within a stream. This implementation currently doesn't handle seek to start + // (where we need to trim at the start again), nor seeks to non-zero positions before start + // trimming has occurred (where we should set pendingTrimStartBytes to zero). These cases can be + // fixed by trimming in queueInput based on timestamp, once that information is available. + + // Any data in the end buffer should no longer be output if we are playing from a different + // position, so discard it and refill the buffer using new input. endBufferSize = 0; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java b/library/core/src/main/java/com/google/android/exoplayer2/database/VersionTable.java index f1d269ddbfc..e69b9576dd7 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 @@ 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 static void removeVersion( 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 static void removeVersion( 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 static int getVersion(SQLiteDatabase database, @Feature int feature, Stri } } - @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 4552d190c33..c94eb2c38a5 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 @@ * @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 5de4fcb1269..698e329be5e 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 void merge(DecoderCounters other) { 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 c07e646f090..0af3313ea3d 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); } /** + * Creates an instance. + * + * @param cause The cause of this exception, or {@code null}. + */ + public DecoderException(@Nullable Throwable cause) { + super(cause); + } + + /** + * Creates an instance. + * * @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. + * @param cause The cause of this exception, or {@code null}. */ - public DecoderException(String message, Throwable cause) { + 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 8f660c4c243..da69924e031 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 @@ */ @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 final void flush() { 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 ad8a5c98545..bb3ad910f02 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.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 @@ /** 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 @@ public interface ProvisioningManager { 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 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 onSessionReleased(DefaultDrmSession session); + void onReferenceCountDecremented(DefaultDrmSession session, int newReferenceCount); } private static final String TAG = "DefaultDrmSession"; @@ -104,12 +121,12 @@ public interface ReleaseCallback { 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 @@ public interface ReleaseCallback { * @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 @@ public DefaultDrmSession( UUID uuid, ExoMediaDrm mediaDrm, ProvisioningManager provisioningManager, - ReleaseCallback releaseCallback, + ReferenceCountListener referenceCountListener, @Nullable List schemeDatas, @DefaultDrmSessionManager.Mode int mode, boolean playClearSamplesWithoutKeys, @@ -167,7 +184,7 @@ public DefaultDrmSession( } this.uuid = uuid; this.provisioningManager = provisioningManager; - this.releaseCallback = releaseCallback; + this.referenceCountListener = referenceCountListener; this.mediaDrm = mediaDrm; this.mode = mode; this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; @@ -257,32 +274,31 @@ public byte[] getOfflineLicenseKeySetId() { } @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( - (listener, windowIndex, mediaPeriodId) -> listener.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; @@ -299,12 +315,17 @@ public void release(@Nullable MediaSourceEventDispatcher eventDispatcher) { mediaDrm.closeSession(sessionId); sessionId = null; } - releaseCallback.onSessionReleased(this); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmSessionReleased); } - dispatchEvent((listener, windowIndex, mediaPeriodId) -> listener.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. @@ -326,7 +347,7 @@ private boolean openInternal(boolean allowProvisioning) { try { sessionId = mediaDrm.openSession(); mediaCrypto = mediaDrm.createMediaCrypto(sessionId); - dispatchEvent((listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionAcquired()); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmSessionAcquired); state = STATE_OPENED; Assertions.checkNotNull(sessionId); return true; @@ -390,7 +411,7 @@ private void doLicense(boolean allowRetry) { onError(new KeysExpiredException()); } else { state = STATE_OPENED_WITH_KEYS; - dispatchEvent((listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysRestored()); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysRestored); } } break; @@ -402,8 +423,8 @@ private void doLicense(boolean allowRetry) { 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); } @@ -431,7 +452,7 @@ private long getLicenseDurationRemainingSec() { } 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) { @@ -460,7 +481,7 @@ private void onKeyResponse(Object request, Object response) { byte[] responseData = (byte[]) response; if (mode == DefaultDrmSessionManager.MODE_RELEASE) { mediaDrm.provideKeyResponse(Util.castNonNull(offlineLicenseKeySetId), responseData); - dispatchEvent((listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysRestored()); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysRemoved); } else { byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, responseData); if ((mode == DefaultDrmSessionManager.MODE_DOWNLOAD @@ -471,7 +492,7 @@ private void onKeyResponse(Object request, Object response) { offlineLicenseKeySetId = keySetId; } state = STATE_OPENED_WITH_KEYS; - dispatchEvent((listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysLoaded()); + dispatchEvent(DrmSessionEventListener.EventDispatcher::drmKeysLoaded); } } catch (Exception e) { onKeysError(e); @@ -495,7 +516,7 @@ private void onKeysError(Exception e) { private void onError(final Exception e) { lastException = new DrmSessionException(e); - dispatchEvent((listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionManagerError(e)); + dispatchEvent(eventDispatcher -> eventDispatcher.drmSessionManagerError(e)); if (state != STATE_OPENED_WITH_KEYS) { state = STATE_ERROR; } @@ -507,10 +528,9 @@ private boolean isOpen() { 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); } } @@ -551,7 +571,11 @@ public RequestHandler(Looper backgroundLooper) { 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(); } @@ -571,18 +595,22 @@ public void handleMessage(Message msg) { 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; @@ -592,14 +620,24 @@ private boolean maybeRetryRequest(Message originalMsg, Exception e) { > 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; @@ -611,12 +649,14 @@ private boolean maybeRetryRequest(Message originalMsg, Exception e) { 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 dbfde1cc9a9..be02faeba87 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.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 static final class Builder { 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 Builder() { exoMediaDrmProvider = FrameworkMediaDrm.DEFAULT_PROVIDER; loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); useDrmSessionsForClearContentTrackTypes = new int[0]; + sessionKeepaliveMs = DEFAULT_SESSION_KEEPALIVE_MS; } /** @@ -180,6 +188,27 @@ public Builder setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandl 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 DefaultDrmSessionManager build(MediaDrmCallback mediaDrmCallback) { 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 @@ private MissingSchemeDataException(UUID uuid) { 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 @@ private MissingSchemeDataException(UUID uuid) { 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 DefaultDrmSessionManager( * Default is false. * @deprecated Use {@link Builder} instead. */ + @SuppressWarnings("deprecation") @Deprecated public DefaultDrmSessionManager( UUID uuid, @@ -336,7 +373,8 @@ public DefaultDrmSessionManager( multiSession, /* useDrmSessionsForClearContentTrackTypes= */ new int[0], /* playClearSamplesWithoutKeys= */ false, - new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount)); + new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount), + DEFAULT_SESSION_KEEPALIVE_MS); } private DefaultDrmSessionManager( @@ -347,7 +385,8 @@ private DefaultDrmSessionManager( 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 @@ private DefaultDrmSessionManager( 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 @@ private DefaultDrmSessionManager( *

    {@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,96 +443,51 @@ public void setMode(@Mode int mode, @Nullable byte[] offlineLicenseKeySetId) { @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; - } - } - - @Override - public 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; - } + if (--prepareCallsCount != 0) { + return; } - 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; + // 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); } - // 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(error), - DrmSessionEventListener.class); + eventDispatcher.drmSessionManagerError(error); } return new ErrorStateDrmSession(new DrmSessionException(error)); } @@ -512,29 +509,107 @@ public DrmSession acquireSession( 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) { @@ -543,41 +618,77 @@ private void maybeCreateMediaDrmHandler(Looper playbackLooper) { } } - 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; - } - 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(); + 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); } - provisioningSessions.remove(drmSession); + return session; } /** @@ -660,6 +771,50 @@ public void onProvisionError(Exception error) { } } + 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 3f2aae7b307..97bb4b3dd16 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 @@ 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 @@ default boolean playClearSamplesWithoutKeys() { /** * 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 dd306d952f6..0720d9677f7 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,16 +15,34 @@ */ 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 { - /** Called each time a drm session is acquired. */ - default void onDrmSessionAcquired() {} + /** + * Called each time a drm session is acquired. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} associated with the drm session. + */ + default void onDrmSessionAcquired(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {} - /** Called each time keys are loaded. */ - default void onDrmKeysLoaded() {} + /** + * Called each time keys are loaded. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} associated with the drm session. + */ + default void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {} /** * Called when a drm error occurs. @@ -36,16 +54,169 @@ default void onDrmKeysLoaded() {} * such behavior). This method is called to provide the application with an opportunity to log the * error if it wishes to do so. * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} associated with the drm session. * @param error The corresponding exception. */ - default void onDrmSessionManagerError(Exception error) {} + default void onDrmSessionManagerError( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception error) {} + + /** + * Called each time offline keys are restored. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} associated with the drm session. + */ + default void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {} + + /** + * Called each time offline keys are removed. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} associated with the drm session. + */ + default void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {} + + /** + * Called each time a drm session is released. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @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)); + } + } - /** Called each time offline keys are restored. */ - default void onDrmKeysRestored() {} + private static final class ListenerAndHandler { - /** Called each time offline keys are removed. */ - default void onDrmKeysRemoved() {} + public Handler handler; + public DrmSessionEventListener listener; - /** Called each time a drm session is released. */ - default void onDrmSessionReleased() {} + 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 0283470765b..1168884d768 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 @@ 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 @@ static DrmSessionManager getDummyDrmSessionManager() { 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 @@ default void release() { } /** - * Returns whether the manager is capable of acquiring a session for the given - * {@link DrmInitData}. - * - * @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. + * 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. * - *

    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 d8311f67012..9631b764919 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 ExoMediaCrypto createMediaCrypto(byte[] sessionId) { } @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 ff0a861f4bb..4253d3011c6 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 @@ 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 byte[] getOfflineLicenseKeySetId() { } @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 957945fa2a1..6684064f63e 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 @@ byte[] provideKeyResponse(byte[] scope, byte[] response) */ 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 2227738ed59..26fe66e7923 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.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.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 void setOnExpirationUpdateListener(@Nullable OnExpirationUpdateListener l 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 void restoreKeys(byte[] sessionId, byte[] keySetId) { @Override @Nullable - @TargetApi(28) public PersistableBundle getMetrics() { if (Util.SDK_INT < 28) { return null; @@ -310,7 +315,7 @@ private static SchemeData getSchemeData(UUID uuid, List schemeDatas) 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 @@ private static SchemeData getSchemeData(UUID uuid, List schemeDatas) 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 @@ private static SchemeData getSchemeData(UUID uuid, List schemeDatas) // 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 @@ private static byte[] addLaUrlAttributeIfMissing(byte[] data) { 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 @@ private static byte[] addLaUrlAttributeIfMissing(byte[] data) { 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 5e232c1d037..7ab90b023ee 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,10 @@ 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; import java.util.Map; @@ -103,14 +104,19 @@ public void clearAllKeyRequestProperties() { } @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; @@ -135,45 +141,56 @@ private static byte[] executePost( HttpDataSource.Factory dataSourceFactory, String url, @Nullable byte[] httpBody, - @Nullable Map requestProperties) - throws IOException { - HttpDataSource dataSource = dataSourceFactory.createDataSource(); - if (requestProperties != null) { - for (Map.Entry requestProperty : requestProperties.entrySet()) { - dataSource.setRequestProperty(requestProperty.getKey(), requestProperty.getValue()); - } - } - + Map requestProperties) + throws MediaDrmCallbackException { + StatsDataSource dataSource = new StatsDataSource(dataSourceFactory.createDataSource()); int manualRedirectCount = 0; - while (true) { - DataSpec dataSpec = - new DataSpec.Builder() - .setUri(url) - .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 7b9aeca30ae..d141b6c4c1c 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 @@ 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 LocalMediaDrmCallback(byte[] keyResponse) { } @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 5b0ed04f81b..14b817e7130 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 00000000000..37b2e035043 --- /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 6092f3911ff..b218d0cadbf 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,11 +22,12 @@ 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; @@ -34,12 +35,13 @@ @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 @@ -48,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, @@ -72,15 +74,15 @@ public static OfflineLicenseHelper newWidevineInstance( * @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, @@ -99,8 +101,8 @@ public static OfflineLicenseHelper newWidevineInstance( * 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 */ @@ -109,7 +111,7 @@ public static OfflineLicenseHelper newWidevineInstance( boolean forceDefaultLicenseUrl, HttpDataSource.Factory httpDataSourceFactory, @Nullable Map optionalKeyRequestParameters, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher eventDispatcher) { return new OfflineLicenseHelper( new DefaultDrmSessionManager.Builder() .setKeyRequestParameters(optionalKeyRequestParameters) @@ -121,7 +123,7 @@ public static OfflineLicenseHelper newWidevineInstance( /** * @deprecated Use {@link #OfflineLicenseHelper(DefaultDrmSessionManager, - * MediaSourceEventDispatcher)} instead. + * DrmSessionEventListener.EventDispatcher)} instead. */ @Deprecated public OfflineLicenseHelper( @@ -129,7 +131,7 @@ public OfflineLicenseHelper( ExoMediaDrm.Provider mediaDrmProvider, MediaDrmCallback callback, @Nullable Map optionalKeyRequestParameters, - MediaSourceEventDispatcher eventDispatcher) { + DrmSessionEventListener.EventDispatcher eventDispatcher) { this( new DefaultDrmSessionManager.Builder() .setUuidAndExoMediaDrmProvider(uuid, mediaDrmProvider) @@ -142,12 +144,12 @@ public 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"); @@ -156,39 +158,40 @@ public OfflineLicenseHelper( DrmSessionEventListener eventListener = new DrmSessionEventListener() { @Override - public void onDrmKeysLoaded() { + public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { conditionVariable.open(); } @Override - public void onDrmSessionManagerError(Exception e) { + public void onDrmSessionManagerError( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception e) { conditionVariable.open(); } @Override - public void onDrmKeysRestored() { + public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { conditionVariable.open(); } @Override - public void onDrmKeysRemoved() { + public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { 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); } /** @@ -202,7 +205,9 @@ public synchronized byte[] renewLicense(byte[] offlineLicenseKeySetId) 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); } /** @@ -215,7 +220,9 @@ public synchronized void releaseLicense(byte[] offlineLicenseKeySetId) throws DrmSessionException { Assertions.checkNotNull(offlineLicenseKeySetId); blockingKeyRequest( - DefaultDrmSessionManager.MODE_RELEASE, offlineLicenseKeySetId, DUMMY_DRM_INIT_DATA); + DefaultDrmSessionManager.MODE_RELEASE, + offlineLicenseKeySetId, + FORMAT_WITH_EMPTY_DRM_INIT_DATA); } /** @@ -231,7 +238,9 @@ public synchronized Pair getLicenseDurationRemainingSec(byte[] offli 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); @@ -254,11 +263,10 @@ public void release() { } 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); @@ -270,14 +278,15 @@ private byte[] blockingKeyRequest( } 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 040ef340ed9..eb4754c50f0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapter.java @@ -17,132 +17,257 @@ 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 AsynchronousMediaCodecBufferEnqueuer bufferEnqueuer; + + @GuardedBy("lock") + @Nullable + private IllegalStateException internalException; /** - * Create a new {@code AsynchronousMediaCodecAdapter}. + * Creates an instance that wraps the specified {@link MediaCodec}. * * @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, trackType, new HandlerThread(createThreadLabel(trackType))); } @VisibleForTesting - /* package */ AsynchronousMediaCodecAdapter(MediaCodec codec, Looper looper) { - mediaCodecAsyncCallback = new MediaCodecAsyncCallback(); - handler = new Handler(looper); + /* package */ AsynchronousMediaCodecAdapter( + MediaCodec codec, + 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 = new AsynchronousMediaCodecBufferEnqueuer(codec, trackType); + 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; + } + + // Called from the handler thread. + + @Override + public void onInputBufferAvailable(MediaCodec codec, int index) { + synchronized (lock) { + mediaCodecAsyncCallback.onInputBufferAvailable(codec, index); + } } - private void onCompleteFlush() { - flushing = false; + @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 +276,15 @@ private void maybeThrowInternalException() { } } - /** 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 428d64d0b1c..6b2ec4e6996 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuer.java @@ -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; @@ -34,13 +37,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** - * A {@link MediaCodecInputBufferEnqueuer} that defers queueing operations on a background thread. + * Performs {@link MediaCodec} input buffer queueing on a background thread. * *

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

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

    Note: This method behaves as {@link MediaCodec#queueSecureInputBuffer} with the difference + * that {@code info} is of type {@link CryptoInfo} and not {@link + * android.media.MediaCodec.CryptoInfo}. + * + * @see android.media.MediaCodec#queueSecureInputBuffer + */ public void queueSecureInputBuffer( int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { maybeThrowException(); @@ -120,7 +139,7 @@ public void queueSecureInputBuffer( message.sendToTarget(); } - @Override + /** Flushes the instance. */ public void flush() { if (started) { try { @@ -134,7 +153,7 @@ public void flush() { } } - @Override + /** Shut down the instance. Make sure to call this method to release its internal resources. */ public void shutdown() { if (started) { flush(); @@ -143,36 +162,8 @@ public void shutdown() { started = false; } - private void doHandleMessage(Message msg) { - MessageParams params = null; - switch (msg.what) { - case MSG_QUEUE_INPUT_BUFFER: - params = (MessageParams) msg.obj; - doQueueInputBuffer( - params.index, params.offset, params.size, params.presentationTimeUs, params.flags); - break; - case MSG_QUEUE_SECURE_INPUT_BUFFER: - params = (MessageParams) msg.obj; - doQueueSecureInputBuffer( - params.index, - params.offset, - params.cryptoInfo, - params.presentationTimeUs, - params.flags); - break; - case MSG_FLUSH: - conditionVariable.open(); - break; - default: - setPendingRuntimeException(new IllegalStateException(String.valueOf(msg.what))); - } - if (params != null) { - recycleMessageParams(params); - } - } - private void maybeThrowException() { - RuntimeException exception = pendingRuntimeException.getAndSet(null); + @Nullable RuntimeException exception = pendingRuntimeException.getAndSet(null); if (exception != null) { throw exception; } @@ -199,6 +190,34 @@ private void flushHandlerThread() throws InterruptedException { pendingRuntimeException.set(exception); } + private void doHandleMessage(Message msg) { + @Nullable MessageParams params = null; + switch (msg.what) { + case MSG_QUEUE_INPUT_BUFFER: + params = (MessageParams) msg.obj; + doQueueInputBuffer( + params.index, params.offset, params.size, params.presentationTimeUs, params.flags); + break; + case MSG_QUEUE_SECURE_INPUT_BUFFER: + params = (MessageParams) msg.obj; + doQueueSecureInputBuffer( + params.index, + params.offset, + params.cryptoInfo, + params.presentationTimeUs, + params.flags); + break; + case MSG_FLUSH: + conditionVariable.open(); + break; + default: + setPendingRuntimeException(new IllegalStateException(String.valueOf(msg.what))); + } + if (params != null) { + recycleMessageParams(params); + } + } + private void doQueueInputBuffer( int index, int offset, int size, long presentationTimeUs, int flag) { try { @@ -223,13 +242,6 @@ private void doQueueSecureInputBuffer( } } - @VisibleForTesting - /* package */ static int getInstancePoolSize() { - synchronized (MESSAGE_PARAMS_INSTANCE_POOL) { - return MESSAGE_PARAMS_INSTANCE_POOL.size(); - } - } - private static MessageParams getMessageParams() { synchronized (MESSAGE_PARAMS_INSTANCE_POOL) { if (MESSAGE_PARAMS_INSTANCE_POOL.isEmpty()) { @@ -301,8 +313,8 @@ private static void copy( 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 +331,8 @@ private static void copy( * @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 +352,8 @@ private static int[] copy(int[] src, int[] dst) { * @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/BatchBuffer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/BatchBuffer.java new file mode 100644 index 00000000000..3c40fe02d48 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/BatchBuffer.java @@ -0,0 +1,186 @@ +/* + * 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 androidx.annotation.IntRange; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.util.Assertions; +import java.nio.ByteBuffer; + +/** Buffer that stores multiple encoded access units to allow batch processing. */ +/* package */ final class BatchBuffer extends DecoderInputBuffer { + /** Arbitrary limit to the number of access unit in a full batch buffer. */ + public static final int DEFAULT_BATCH_SIZE_ACCESS_UNITS = 32; + /** + * Arbitrary limit to the memory used by a full batch buffer to avoid using too much memory for + * very high bitrate. Equivalent of 75s of mp3 at highest bitrate (320kb/s) and 30s of AAC LC at + * highest bitrate (800kb/s). That limit is ignored for the first access unit to avoid stalling + * stream with huge access units. + */ + private static final int BATCH_SIZE_BYTES = 3 * 1000 * 1024; + + private final DecoderInputBuffer nextAccessUnitBuffer; + private boolean hasPendingAccessUnit; + + private long firstAccessUnitTimeUs; + private int accessUnitCount; + private int maxAccessUnitCount; + + public BatchBuffer() { + super(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); + nextAccessUnitBuffer = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); + clear(); + } + + /** Sets the maximum number of access units the buffer can contain before being full. */ + public void setMaxAccessUnitCount(@IntRange(from = 1) int maxAccessUnitCount) { + Assertions.checkArgument(maxAccessUnitCount > 0); + this.maxAccessUnitCount = maxAccessUnitCount; + } + + /** Gets the maximum number of access units the buffer can contain before being full. */ + public int getMaxAccessUnitCount() { + return maxAccessUnitCount; + } + + /** Resets the state of this object to what it was after construction. */ + @Override + public void clear() { + flush(); + maxAccessUnitCount = DEFAULT_BATCH_SIZE_ACCESS_UNITS; + } + + /** Clear all access units from the BatchBuffer to empty it. */ + public void flush() { + clearMainBuffer(); + nextAccessUnitBuffer.clear(); + hasPendingAccessUnit = false; + } + + /** Clears the state of the batch buffer to be ready to receive a new sequence of access units. */ + public void batchWasConsumed() { + clearMainBuffer(); + if (hasPendingAccessUnit) { + putAccessUnit(nextAccessUnitBuffer); + hasPendingAccessUnit = false; + } + } + + /** + * Gets the buffer to fill-out that will then be append to the batch buffer with {@link + * #commitNextAccessUnit()}. + */ + public DecoderInputBuffer getNextAccessUnitBuffer() { + return nextAccessUnitBuffer; + } + + /** Gets the timestamp of the first access unit in the buffer. */ + public long getFirstAccessUnitTimeUs() { + return firstAccessUnitTimeUs; + } + + /** Gets the timestamp of the last access unit in the buffer. */ + public long getLastAccessUnitTimeUs() { + return timeUs; + } + + /** Gets the number of access units contained in this batch buffer. */ + public int getAccessUnitCount() { + return accessUnitCount; + } + + /** If the buffer contains no access units. */ + public boolean isEmpty() { + return accessUnitCount == 0; + } + + /** If more access units should be added to the batch buffer. */ + public boolean isFull() { + return accessUnitCount >= maxAccessUnitCount + || (data != null && data.position() >= BATCH_SIZE_BYTES) + || hasPendingAccessUnit; + } + + /** + * Appends the staged access unit in this batch buffer. + * + * @throws IllegalStateException If calling this method on a full or end of stream batch buffer. + * @throws IllegalArgumentException If the {@code accessUnit} is encrypted or has + * supplementalData, as batching of those state has not been implemented. + */ + public void commitNextAccessUnit() { + DecoderInputBuffer accessUnit = nextAccessUnitBuffer; + Assertions.checkState(!isFull() && !isEndOfStream()); + Assertions.checkArgument(!accessUnit.isEncrypted() && !accessUnit.hasSupplementalData()); + if (!canBatch(accessUnit)) { + hasPendingAccessUnit = true; // Delay the putAccessUnit until the batch buffer is empty. + return; + } + putAccessUnit(accessUnit); + } + + private boolean canBatch(DecoderInputBuffer accessUnit) { + if (isEmpty()) { + return true; // Batching with an empty batch must always succeed or the stream will stall. + } + if (accessUnit.isDecodeOnly() != isDecodeOnly()) { + return false; // Decode only and non decode only access units can not be batched together. + } + + @Nullable ByteBuffer accessUnitData = accessUnit.data; + if (accessUnitData != null + && this.data != null + && this.data.position() + accessUnitData.limit() >= BATCH_SIZE_BYTES) { + return false; // The batch buffer does not have the capacity to add this access unit. + } + return true; + } + + private void putAccessUnit(DecoderInputBuffer accessUnit) { + @Nullable ByteBuffer accessUnitData = accessUnit.data; + if (accessUnitData != null) { + accessUnit.flip(); + ensureSpaceForWrite(accessUnitData.remaining()); + this.data.put(accessUnitData); + } + + if (accessUnit.isEndOfStream()) { + setFlags(C.BUFFER_FLAG_END_OF_STREAM); + } + if (accessUnit.isDecodeOnly()) { + setFlags(C.BUFFER_FLAG_DECODE_ONLY); + } + if (accessUnit.isKeyFrame()) { + setFlags(C.BUFFER_FLAG_KEY_FRAME); + } + accessUnitCount++; + timeUs = accessUnit.timeUs; + if (accessUnitCount == 1) { // First read of the buffer + firstAccessUnitTimeUs = timeUs; + } + accessUnit.clear(); + } + + private void clearMainBuffer() { + super.clear(); + accessUnitCount = 0; + firstAccessUnitTimeUs = C.TIME_UNSET; + timeUs = C.TIME_UNSET; + } +} 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 00000000000..0c3fe9facf5 --- /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 88e3f56daaf..00000000000 --- 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 1be850c8998..78bdeade81b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecAdapter.java @@ -17,25 +17,36 @@ 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; /** * 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 mode the {@link MediaCodec} is operating in. */ -/* package */ interface MediaCodecAdapter { +public interface MediaCodecAdapter { + + /** + * Configures this adapter and the underlying {@link MediaCodec}. Needs to be called before {@link + * #start()}. + * + * @see MediaCodec#configure(MediaFormat, Surface, MediaCrypto, int) + */ + void configure( + @Nullable MediaFormat mediaFormat, + @Nullable Surface surface, + @Nullable MediaCrypto crypto, + int flags); /** - * Starts this instance. + * Starts this instance. Needs to be called after {@link #configure}. * - * @see MediaCodec#start(). + * @see MediaCodec#start() */ void start(); @@ -82,31 +93,26 @@ *

    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 5be6031356c..65f0c266a9b 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 @@ 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 @@ public int dequeueOutputBufferIndex(MediaCodec.BufferInfo bufferInfo) { *

    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 @@ public MediaFormat getOutputFormat() throws IllegalStateException { public void maybeThrowMediaCodecException() throws IllegalStateException { IllegalStateException exception = mediaCodecException; mediaCodecException = null; - if (exception != null) { throw exception; } @@ -111,6 +110,7 @@ public void maybeThrowMediaCodecException() throws IllegalStateException { * 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 @@ public void flush() { } @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 @@ public void onError(MediaCodec mediaCodec, MediaCodec.CodecException e) { @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 736f9411526..404066e96d8 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 boolean isSeamlessAdaptationSupported(Format format) { 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 boolean isSeamlessAdaptationSupported( || 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 boolean isVideoSizeAndRateSupportedV21(int width, int height, double fram * 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/MediaCodecInputBufferEnqueuer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInputBufferEnqueuer.java deleted file mode 100644 index 34a1ccc6bab..00000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecInputBufferEnqueuer.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import android.media.MediaCodec; -import com.google.android.exoplayer2.decoder.CryptoInfo; - -/** Abstracts operations to enqueue input buffer on a {@link android.media.MediaCodec}. */ -interface MediaCodecInputBufferEnqueuer { - - /** - * Starts this instance. - * - *

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

    Note: This method behaves as {@link MediaCodec#queueSecureInputBuffer} with the difference - * that {@code info} is of type {@link CryptoInfo} and not {@link - * android.media.MediaCodec.CryptoInfo}. - * - * @see android.media.MediaCodec#queueSecureInputBuffer - */ - void queueSecureInputBuffer( - int index, int offset, CryptoInfo info, long presentationTimeUs, int flags); - - /** Flushes the instance. */ - void flush(); - - /** Shut down the instance. Make sure to call this method to release its internal resources. */ - void shutdown(); -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index bb772a70255..ecaa4e64005 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -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.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; @@ -46,16 +48,16 @@ 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.NalUnitUtil; import com.google.android.exoplayer2.util.TimedValueQueue; import com.google.android.exoplayer2.util.TraceUtil; import com.google.android.exoplayer2.util.Util; import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; @@ -65,60 +67,6 @@ */ public abstract class MediaCodecRenderer extends BaseRenderer { - /** - * The modes to operate the {@link MediaCodec}. - * - *

    Allowed values: - * - *

      - *
    • {@link #OPERATION_MODE_SYNCHRONOUS} - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD} - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD} - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK} - *
    - */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @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 {} - - /** 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 { @@ -361,23 +309,27 @@ private static String buildCustomDiagnosticInfo(int errorCode) { private final float assumedMinimumCodecOperatingRate; private final DecoderInputBuffer buffer; private final DecoderInputBuffer flagsOnlyBuffer; + 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; @Nullable private Format inputFormat; - private Format outputFormat; + @Nullable private Format outputFormat; @Nullable private DrmSession codecDrmSession; @Nullable private DrmSession sourceDrmSession; @Nullable private MediaCrypto mediaCrypto; private boolean mediaCryptoRequiresSecureDecoder; private long renderTimeLimitMs; - private float rendererOperatingRate; + 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; @@ -393,14 +345,17 @@ private static String buildCustomDiagnosticInfo(int errorCode) { 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 bypassEnabled; + private boolean bypassDrainAndReinitialize; private boolean codecReconfigured; @ReconfigurationState private int codecReconfigurationState; @DrainState private int codecDrainState; @@ -412,12 +367,12 @@ private static String buildCustomDiagnosticInfo(int errorCode) { 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; + private boolean enableAsynchronousBufferQueueing; + @Nullable private ExoPlaybackException pendingPlaybackException; protected DecoderCounters decoderCounters; + private long outputStreamStartPositionUs; private long outputStreamOffsetUs; private int pendingOutputStreamOffsetCount; @@ -446,12 +401,14 @@ public MediaCodecRenderer( formatQueue = new TimedValueQueue<>(); decodeOnlyPresentationTimestamps = new ArrayList<>(); outputBufferInfo = new MediaCodec.BufferInfo(); - rendererOperatingRate = 1f; + operatingRate = 1f; renderTimeLimitMs = C.TIME_UNSET; - mediaCodecOperationMode = OPERATION_MODE_SYNCHRONOUS; + pendingOutputStreamStartPositionsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; pendingOutputStreamSwitchTimesUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; + outputStreamStartPositionUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET; + bypassBatchBuffer = new BatchBuffer(); resetCodecStateForRelease(); } @@ -459,51 +416,26 @@ public MediaCodecRenderer( * 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. + *

    This method should be called right after creating an instance of this class. * * @param renderTimeLimitMs The render time limit in milliseconds, or {@link C#TIME_UNSET} for no * limit. */ - public void experimental_setRenderTimeLimitMs(long renderTimeLimitMs) { + public void setRenderTimeLimitMs(long renderTimeLimitMs) { this.renderTimeLimitMs = renderTimeLimitMs; } /** - * Set the mode of operation of the underlying {@link MediaCodec}. + * Enable asynchronous input buffer queueing. + * + *

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

    This method is experimental, and will be renamed or removed in a future release. It should * only be called before the renderer is used. - * - * @param mode The mode of the MediaCodec. The supported modes are: - *

      - *
    • {@link #OPERATION_MODE_SYNCHRONOUS}: The {@link MediaCodec} will operate in - * synchronous mode. - *
    • {@link #OPERATION_MODE_ASYNCHRONOUS_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) { - mediaCodecOperationMode = mode; + public void experimentalEnableAsynchronousBufferQueueing(boolean enabled) { + enableAsynchronousBufferQueueing = enabled; } @Override @@ -553,7 +485,7 @@ protected abstract List getDecoderInfos( * 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 @@ -561,14 +493,19 @@ protected abstract List getDecoderInfos( */ protected abstract void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate); - protected final void maybeInitCodec() throws ExoPlaybackException { - if (codec != null || inputFormat == null) { - // We have a codec already, or we don't have a format with which to instantiate one. + protected final void maybeInitCodecOrBypass() throws ExoPlaybackException { + if (codec != null || bypassEnabled || inputFormat == null) { + // We have a codec, are bypassing it, or don't have a format to decide how to render. + return; + } + + if (sourceDrmSession == null && shouldUseBypass(inputFormat)) { + initBypass(inputFormat); return; } @@ -617,6 +554,19 @@ protected final void maybeInitCodec() throws ExoPlaybackException { } } + /** + * 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 playback bypassing {@link MediaCodec} is supported. + */ + protected boolean shouldUseBypass(Format format) { + return false; + } + protected boolean shouldInitCodec(MediaCodecInfo codecInfo) { return true; } @@ -630,22 +580,51 @@ protected boolean getCodecNeedsEosPropagation() { } /** - * 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. */ - @Nullable - protected final Format updateOutputFormatForTime(long presentationTimeUs) { - Format format = formatQueue.pollFloor(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; + outputFormatChanged = true; + } + if (outputFormatChanged || (codecOutputMediaFormatChanged && outputFormat != null)) { + onOutputFormatChanged(outputFormat, codecOutputMediaFormat); + codecOutputMediaFormatChanged = false; } - return format; } @Nullable - protected final Format getCurrentOutputFormat() { + protected Format getInputFormat() { + return inputFormat; + } + + @Nullable + protected final Format getOutputFormat() { return outputFormat; } @@ -654,6 +633,11 @@ protected final MediaCodec getCodec() { return codec; } + @Nullable + protected final MediaFormat getCodecOutputMediaFormat() { + return codecOutputMediaFormat; + } + @Nullable protected final MediaCodecInfo getCodecInfo() { return codecInfo; @@ -666,9 +650,12 @@ protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) } @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( @@ -678,6 +665,7 @@ protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlayba } else { pendingOutputStreamOffsetCount++; } + pendingOutputStreamStartPositionsUs[pendingOutputStreamOffsetCount - 1] = startPositionUs; pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1] = offsetUs; pendingOutputStreamSwitchTimesUs[pendingOutputStreamOffsetCount - 1] = largestQueuedPresentationTimeUs; @@ -689,7 +677,11 @@ protected void onPositionReset(long positionUs, boolean joining) throws ExoPlayb inputStreamEnded = false; outputStreamEnded = false; pendingOutputEndOfStream = false; - flushOrReinitializeCodec(); + if (bypassEnabled) { + bypassBatchBuffer.flush(); + } else { + flushOrReinitializeCodec(); + } // If there is a format change on the input side still pending propagation to the output, we // need to queue a format next time a buffer is read. This is because we may not read a new // input format after the position reset. @@ -699,13 +691,15 @@ protected void onPositionReset(long positionUs, boolean joining) throws ExoPlayb formatQueue.clear(); if (pendingOutputStreamOffsetCount != 0) { outputStreamOffsetUs = pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1]; + outputStreamStartPositionUs = + pendingOutputStreamStartPositionsUs[pendingOutputStreamOffsetCount - 1]; pendingOutputStreamOffsetCount = 0; } } @Override - public final void setOperatingRate(float operatingRate) throws ExoPlaybackException { - rendererOperatingRate = operatingRate; + public void setOperatingRate(float operatingRate) throws ExoPlaybackException { + this.operatingRate = operatingRate; if (codec != null && codecDrainAction != DRAIN_ACTION_REINITIALIZE && getState() != STATE_DISABLED) { @@ -716,6 +710,7 @@ && getState() != STATE_DISABLED) { @Override protected void onDisabled() { inputFormat = null; + outputStreamStartPositionUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET; pendingOutputStreamOffsetCount = 0; if (sourceDrmSession != null || codecDrmSession != null) { @@ -729,12 +724,19 @@ protected void onDisabled() { @Override protected void onReset() { try { + disableBypass(); releaseCodec(); } finally { setSourceDrmSession(null); } } + private void disableBypass() { + bypassDrainAndReinitialize = false; + bypassBatchBuffer.clear(); + bypassEnabled = false; + } + protected void releaseCodec() { try { if (codecAdapter != null) { @@ -775,6 +777,12 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx pendingOutputEndOfStream = false; processEndOfStream(); } + if (pendingPlaybackException != null) { + ExoPlaybackException playbackException = pendingPlaybackException; + pendingPlaybackException = null; + throw playbackException; + } + try { if (outputStreamEnded) { renderToEndOfStream(); @@ -785,8 +793,12 @@ public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx return; } // We have a format. - maybeInitCodec(); - if (codec != null) { + 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) @@ -804,7 +816,7 @@ && shouldContinueRendering(renderStartTimeMs)) {} decoderCounters.ensureUpdated(); } catch (IllegalStateException e) { if (isMediaCodecException(e)) { - throw createRendererException(e, inputFormat); + throw createRendererException(createDecoderException(e, getCodecInfo()), inputFormat); } throw e; } @@ -815,7 +827,7 @@ && shouldContinueRendering(renderStartTimeMs)) {} * This method is a no-op if the codec is {@code null}. * *

    The implementation of this method calls {@link #flushOrReleaseCodec()}, and {@link - * #maybeInitCodec()} 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. @@ -823,7 +835,7 @@ && shouldContinueRendering(renderStartTimeMs)) {} protected final boolean flushOrReinitializeCodec() throws ExoPlaybackException { boolean released = flushOrReleaseCodec(); if (released) { - maybeInitCodec(); + maybeInitCodecOrBypass(); } return released; } @@ -861,15 +873,16 @@ protected void resetCodecStateForFlush() { 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 @@ -889,9 +902,13 @@ protected void resetCodecStateForFlush() { 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; @@ -1016,6 +1033,26 @@ private List getAvailableCodecInfos(boolean mediaCryptoRequiresS return codecInfos; } + /** + * 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 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. + bypassBatchBuffer.setMaxAccessUnitCount(1); + } else { + bypassBatchBuffer.setMaxAccessUnitCount(BatchBuffer.DEFAULT_BATCH_SIZE_ACCESS_UNITS); + } + bypassEnabled = true; + } + private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exception { long codecInitializingTimestamp; long codecInitializedTimestamp; @@ -1025,7 +1062,7 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce float codecOperatingRate = Util.SDK_INT < 23 ? CODEC_OPERATING_RATE_UNSET - : getCodecOperatingRateV23(rendererOperatingRate, inputFormat, getStreamFormats()); + : getCodecOperatingRateV23(operatingRate, inputFormat, getStreamFormats()); if (codecOperatingRate <= assumedMinimumCodecOperatingRate) { codecOperatingRate = CODEC_OPERATING_RATE_UNSET; } @@ -1035,34 +1072,14 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce codecInitializingTimestamp = SystemClock.elapsedRealtime(); TraceUtil.beginSection("createCodec:" + codecName); codec = MediaCodec.createByCodecName(codecName); - if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_PLAYBACK_THREAD - && Util.SDK_INT >= 21) { - codecAdapter = new AsynchronousMediaCodecAdapter(codec); - } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD - && Util.SDK_INT >= 23) { - codecAdapter = new DedicatedThreadAsyncMediaCodecAdapter(codec, getTrackType()); - } else if (mediaCodecOperationMode == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK - && Util.SDK_INT >= 23) { - codecAdapter = new MultiLockAsyncMediaCodecAdapter(codec, getTrackType()); - } else if (mediaCodecOperationMode - == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING - && Util.SDK_INT >= 23) { - codecAdapter = - new DedicatedThreadAsyncMediaCodecAdapter( - codec, /* enableAsynchronousQueueing= */ true, getTrackType()); - } else if (mediaCodecOperationMode - == OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_MULTI_LOCK_ASYNCHRONOUS_QUEUEING - && Util.SDK_INT >= 23) { - codecAdapter = - new MultiLockAsyncMediaCodecAdapter( - codec, /* enableAsynchronousQueueing= */ true, getTrackType()); + if (enableAsynchronousBufferQueueing && Util.SDK_INT >= 23) { + codecAdapter = new AsynchronousMediaCodecAdapter(codec, getTrackType()); } else { codecAdapter = new SynchronousMediaCodecAdapter(codec); } - TraceUtil.endSection(); TraceUtil.beginSection("configureCodec"); - configureCodec(codecInfo, codec, inputFormat, crypto, codecOperatingRate); + configureCodec(codecInfo, codecAdapter, inputFormat, crypto, codecOperatingRate); TraceUtil.endSection(); TraceUtil.beginSection("startCodec"); codecAdapter.start(); @@ -1084,18 +1101,23 @@ private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exce 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; } @@ -1132,6 +1154,7 @@ private ByteBuffer getInputBuffer(int inputIndex) { } } + @Nullable private ByteBuffer getOutputBuffer(int outputIndex) { if (Util.SDK_INT >= 21) { return codec.getOutputBuffer(outputIndex); @@ -1205,25 +1228,20 @@ private boolean feedInputBuffer() throws ExoPlaybackException { 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. @@ -1249,7 +1267,7 @@ private boolean feedInputBuffer() throws ExoPlaybackException { 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; } @@ -1263,7 +1281,12 @@ private boolean feedInputBuffer() throws ExoPlaybackException { // 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) { @@ -1271,20 +1294,25 @@ private boolean feedInputBuffer() throws ExoPlaybackException { } 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); @@ -1293,51 +1321,52 @@ private boolean feedInputBuffer() throws ExoPlaybackException { } codecNeedsDiscardToSpsWorkaround = false; } - 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); + 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 { 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; } /** @@ -1361,19 +1390,29 @@ protected void onCodecInitialized(String name, long initializedTimestampMs, * @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 (bypassEnabled) { + bypassDrainAndReinitialize = true; + return; // Need to drain batch buffer first. + } + if (codec == null) { - maybeInitCodec(); + if (!legacyKeepAvailableCodecInfosWithoutCodec()) { + availableCodecInfos = null; + } + maybeInitCodecOrBypass(); return; } - // We have an existing codec that we may need to reconfigure or re-initialize. If the existing - // codec instance is being kept then its operating rate may need to be updated. + // We have an existing codec that we may need to reconfigure or re-initialize or release it to + // switch to bypass. If the existing codec instance is being kept then its operating rate + // may need to be updated. if ((sourceDrmSession == null && codecDrmSession != null) || (sourceDrmSession != null && codecDrmSession == null) @@ -1388,12 +1427,12 @@ && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) 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(); @@ -1410,9 +1449,9 @@ && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) 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(); @@ -1420,7 +1459,7 @@ && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) } break; case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: - codecFormat = newFormat; + codecInputFormat = newFormat; updateCodecOperatingRate(); if (sourceDrmSession != codecDrmSession) { drainAndUpdateCodecDrmSession(); @@ -1432,15 +1471,25 @@ && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) } /** - * 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. } @@ -1464,8 +1513,9 @@ protected void handleInputBufferSupplementalData(DecoderInputBuffer buffer) *

    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. } @@ -1478,8 +1528,15 @@ protected void onQueueInputBuffer(DecoderInputBuffer buffer) { 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, @@ -1526,13 +1583,22 @@ public boolean isEnded() { @Override public boolean isReady() { return inputFormat != null - && !waitingForKeys && (isSourceReady() || hasOutputBuffer() || (codecHotswapDeadlineMs != C.TIME_UNSET && SystemClock.elapsedRealtime() < codecHotswapDeadlineMs)); } + /** Returns the renderer operating rate, as set by {@link #setOperatingRate}. */ + protected float getOperatingRate() { + 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. @@ -1561,12 +1627,12 @@ private void updateCodecOperatingRate() throws ExoPlaybackException { } float newCodecOperatingRate = - getCodecOperatingRateV23(rendererOperatingRate, 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) { @@ -1649,7 +1715,7 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) if (outputIndex < 0) { if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED /* (-2) */) { - processOutputMediaFormat(); + processOutputMediaFormatChanged(); return true; } else if (outputIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED /* (-3) */) { processOutputBuffersChanged(); @@ -1677,6 +1743,7 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) 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) { @@ -1742,8 +1809,8 @@ private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) 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 @@ -1757,7 +1824,8 @@ private void processOutputMediaFormat() throws ExoPlaybackException { if (codecNeedsMonoChannelCountWorkaround) { mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1); } - onOutputMediaFormatChanged(codec, mediaFormat); + codecOutputMediaFormat = mediaFormat; + codecOutputMediaFormatChanged = true; } /** @@ -1787,8 +1855,11 @@ private void processOutputBuffersChanged() { * 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. - * @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 @@ -1798,14 +1869,14 @@ private void processOutputBuffersChanged() { * 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, - MediaCodec codec, - ByteBuffer buffer, + @Nullable MediaCodec codec, + @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, int sampleCount, @@ -1864,6 +1935,13 @@ protected final long getLargestQueuedPresentationTimeUs() { return largestQueuedPresentationTimeUs; } + /** + * Returns the start position of the output {@link SampleStream}, in renderer time microseconds. + */ + protected final long getOutputStreamStartPositionUs() { + return outputStreamStartPositionUs; + } + /** * Returns the offset that should be subtracted from {@code bufferPresentationTimeUs} in {@link * #processOutputBuffer(long, long, MediaCodec, ByteBuffer, int, int, int, long, boolean, boolean, @@ -1875,7 +1953,7 @@ protected final long getOutputStreamOffsetUs() { /** 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); } @@ -1916,7 +1994,7 @@ private boolean maybeRequiresSecureDecoder(DrmSession drmSession, Format format) private void reinitializeCodec() throws ExoPlaybackException { releaseCodec(); - maybeInitCodec(); + maybeInitCodecOrBypass(); } private boolean isDecodeOnlyBuffer(long presentationTimeUs) { @@ -1982,6 +2060,120 @@ private FrameworkMediaCrypto getFrameworkMediaCrypto(DrmSession drmSession) return (FrameworkMediaCrypto) mediaCrypto; } + /** + * Processes any pending batch of buffers without using a decoder, and drains a new batch of + * buffers from the source. + * + * @param positionUs The current media time in microseconds, measured at the start of the current + * iteration of the rendering loop. + * @param elapsedRealtimeUs {@link SystemClock#elapsedRealtime()} in microseconds, measured at the + * start of the current iteration of the rendering loop. + * @return If more buffers are ready to be rendered. + * @throws ExoPlaybackException If an error occurred while processing a buffer or handling a + * format change. + */ + private boolean bypassRender(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException { + BatchBuffer batchBuffer = bypassBatchBuffer; + + // Let's process the pending buffer if any. + checkState(!outputStreamEnded); + if (!batchBuffer.isEmpty()) { // Optimisation: Do not process buffer if empty. + if (processOutputBuffer( + positionUs, + elapsedRealtimeUs, + /* codec= */ null, + batchBuffer.data, + outputIndex, + /* bufferFlags= */ 0, + batchBuffer.getAccessUnitCount(), + batchBuffer.getFirstAccessUnitTimeUs(), + batchBuffer.isDecodeOnly(), + batchBuffer.isEndOfStream(), + outputFormat)) { + // Buffer completely processed + onProcessedOutputBuffer(batchBuffer.getLastAccessUnitTimeUs()); + } else { + return false; // Could not process buffer, let's try later. + } + } + if (batchBuffer.isEndOfStream()) { + outputStreamEnded = true; + return false; + } + batchBuffer.batchWasConsumed(); + + if (bypassDrainAndReinitialize) { + if (!batchBuffer.isEmpty()) { + return true; // Drain the batch buffer before propagating the format change. + } + 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. + 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); + onOutputFormatChanged(outputFormat, /* mediaFormat= */ null); + waitingForFirstSampleInFormat = false; + } + + if (formatChange) { + onInputFormatChanged(formatHolder); + } + + if (batchBuffer.isEndOfStream()) { + inputStreamEnded = true; + } + + if (batchBuffer.isEmpty()) { + return false; // The buffer could not be filled, there is nothing more to do. + } + batchBuffer.flip(); // Buffer at least partially full, it can now be processed. + // MediaCodec outputs buffers in native endian: + // https://developer.android.com/reference/android/media/MediaCodec#raw-audio-buffers + // and code called from processOutputBuffer expects this endianness. + batchBuffer.data.order(ByteOrder.nativeOrder()); + return true; + } + + /** + * Fills the buffer with multiple access unit from the source. Has otherwise the same semantic as + * {@link #readSource(FormatHolder, DecoderInputBuffer, boolean)}. Will stop early on format + * change, EOS or source starvation. + * + * @return If the format has changed. + */ + private boolean readBatchFromSource(FormatHolder formatHolder, BatchBuffer batchBuffer) { + while (!batchBuffer.isFull() && !batchBuffer.isEndOfStream()) { + @SampleStream.ReadDataResult + int result = + readSource( + formatHolder, batchBuffer.getNextAccessUnitBuffer(), /* formatRequired= */ false); + switch (result) { + case C.RESULT_FORMAT_READ: + return true; + case C.RESULT_NOTHING_READ: + return false; + case C.RESULT_BUFFER_READ: + batchBuffer.commitNextAccessUnit(); + break; + default: + throw new IllegalStateException(); // Unsupported result + } + } + return false; + } + private static boolean isMediaCodecException(IllegalStateException error) { if (Util.SDK_INT >= 21 && isMediaCodecExceptionV21(error)) { return true; @@ -2089,6 +2281,9 @@ private static boolean codecNeedsEosPropagationWorkaround(MediaCodecInfo codecIn 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 db68fb3e898..64eb0bb8374 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.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 @@ private DecoderQueryException(Throwable cause) { // 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 static void warmDecoderInfoCache(String mimeType, boolean secure, boolean } /** - * 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 static int maxH264DecodableFrameSize() throws DecoderQueryException { 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 @@ private static Pair getDolbyVisionProfileAndLevel( 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 @@ private static Pair getHevcProfileAndLevel(String codec, Strin 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 @@ private static Pair getAvcProfileAndLevel(String codec, String 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 @@ private static Pair getVp9ProfileAndLevel(String codec, String 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 @@ private static Pair getAv1ProfileAndLevel( 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 @@ private static Pair getAacCodecProfileAndLevel(String codec, S 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 boolean equals(@Nullable Object obj) { && secure == other.secure && tunneling == other.tunneling; } + } + 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; + } } - 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); - - 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); - - 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); - - 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); - - 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); - - 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); - - 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); + 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; + } + } + + 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; + } + } + 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; + } + } + + @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; + } + } + + @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; + } + } + + @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); - - 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); + 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; + } + } + + 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 118445835ba..0ed58db266b 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 static void maybeSetFloat(MediaFormat format, String key, float value) { * * @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 d51f985ed73..00000000000 --- 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 f50b49e6029..f5138e90f06 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 @@ public SynchronousMediaCodecAdapter(MediaCodec mediaCodec) { 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 @@ public void flush() { @Override public void shutdown() {} + + @Override + public MediaCodec getCodec() { + return codec; + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecBufferEnqueuer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecBufferEnqueuer.java deleted file mode 100644 index f16748f8fc5..00000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecBufferEnqueuer.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.mediacodec; - -import android.media.MediaCodec; -import com.google.android.exoplayer2.decoder.CryptoInfo; - -/** - * A {@link MediaCodecInputBufferEnqueuer} that forwards queueing methods directly to {@link - * MediaCodec}. - */ -class SynchronousMediaCodecBufferEnqueuer implements MediaCodecInputBufferEnqueuer { - private final MediaCodec codec; - - /** - * Creates an instance that queues input buffers on the specified {@link MediaCodec}. - * - * @param codec The {@link MediaCodec} to submit input buffers to. - */ - SynchronousMediaCodecBufferEnqueuer(MediaCodec codec) { - this.codec = codec; - } - - @Override - public void start() {} - - @Override - public void queueInputBuffer( - int index, int offset, int size, long presentationTimeUs, int flags) { - codec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); - } - - @Override - public void queueSecureInputBuffer( - int index, int offset, CryptoInfo info, long presentationTimeUs, int flags) { - codec.queueSecureInputBuffer( - index, offset, info.getFrameworkCryptoInfo(), presentationTimeUs, flags); - } - - @Override - public void flush() {} - - @Override - public void shutdown() {} -} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/metadata/MetadataRenderer.java index 238d515caf7..d2b75635b1f 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 String getName() { 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 void render(long positionUs, long elapsedRealtimeUs) { 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 f533b97d13f..fb16945d82c 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 @@ * 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 @@ private static Metadata parseAit(ParsableBitArray sectionData) { // 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 @@ private static Metadata parseAit(ParsableBitArray sectionData) { } } 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 cd3c1dfb630..8f0254d83ff 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 647e1296a9a..fbcf9da6f39 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 @@ 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 SpliceInfoDecoder() { } @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 c69908c7465..2f7db223262 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 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 @@ /* 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 @@ public DownloadRequest[] load() throws IOException { } 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 @@ private static DownloadRequest readDownloadRequest(DataInputStream input) throws } // 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 @@ private static DownloadRequest readDownloadRequest(DataInputStream input) throws // 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 @@ private static StreamKey readKey(String type, int version, DataInputStream input 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 @@ private static StreamKey readKey(String type, int version, DataInputStream input 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 4437fccd167..d9a060fe2dd 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.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 DownloadCursor getDownloads(@Download.State int... states) throws Databas @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 @@ private void ensureInitialized() throws DatabaseIOException { 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 @@ private void ensureInitialized() throws DatabaseIOException { } } + 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 @@ private Cursor getCursor(String selection, @Nullable String[] selectionArgs) } } + @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 @@ private static String getStateQuery(@Download.State int... states) { } 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 @@ private static Download getDownloadForCurrentRow(Cursor cursor) { 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 d8126d47361..365a4439a13 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,10 +15,15 @@ */ 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; /** * Default {@link DownloaderFactory}, supporting creation of progressive, DASH, HLS and @@ -27,92 +32,122 @@ */ 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; + private static final SparseArray> CONSTRUCTORS = + createDownloaderConstructors(); - static { - 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; - 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; - 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 final CacheDataSource.Factory cacheDataSourceFactory; + private final Executor executor; - private final DownloaderConstructorHelper downloaderConstructorHelper; + /** + * Creates an instance. + * + * @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, /* executor= */ Runnable::run); + } - /** @param downloaderConstructorHelper A helper for instantiating downloaders. */ - public DefaultDownloaderFactory(DownloaderConstructorHelper downloaderConstructorHelper) { - this.downloaderConstructorHelper = downloaderConstructorHelper; + /** + * Creates an instance. + * + * @param cacheDataSourceFactory A {@link CacheDataSource.Factory} for the cache into which + * downloads will be written. + * @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 = 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, downloaderConstructorHelper); - 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, downloaderConstructorHelper); + 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, DownloaderConstructorHelper.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 8e50d70020f..ba8a799381a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -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 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.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 @@ *

    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 interface Callback { /** 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 static class LiveContentUnsupportedException extends IOException {} public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) { Renderer[] renderers = renderersFactory.createRenderers( - Util.createHandler(), + Util.createHandlerForCurrentOrMainLooper(), new VideoRendererEventListener() {}, new AudioRendererEventListener() {}, (cues) -> {}, @@ -178,77 +169,25 @@ public static RendererCapabilities[] getRendererCapabilities(RenderersFactory re 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, - uri, - cacheKey, - /* mediaSource= */ null, - 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); + return forMediaItem( + context, new MediaItem.Builder().setUri(uri).setCustomCacheKey(cacheKey).build()); } /** - * Creates a {@link DownloadHelper} for DASH 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 DASH streams. - * @throws IllegalStateException If the DASH module is missing. + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public static DownloadHelper forDash( Context context, Uri uri, @@ -263,62 +202,30 @@ public static DownloadHelper forDash( } /** - * Creates a {@link DownloadHelper} for DASH 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 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. + * @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 new DownloadHelper( - DownloadRequest.TYPE_DASH, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - DASH_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(), 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); } /** - * Creates a {@link DownloadHelper} for HLS streams. - * - * @param context Any {@link Context}. - * @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. - * @return A {@link DownloadHelper} for HLS streams. - * @throws IllegalStateException If the HLS module is missing. + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public static DownloadHelper forHls( Context context, Uri uri, @@ -333,40 +240,29 @@ public static DownloadHelper forHls( } /** - * 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. + * @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 new DownloadHelper( - DownloadRequest.TYPE_HLS, - uri, - /* cacheKey= */ null, - createMediaSourceInternal( - HLS_FACTORY_CONSTRUCTOR, - uri, - dataSourceFactory, - drmSessionManager, - /* streamKeys= */ null), + return forMediaItem( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_M3U8).build(), trackSelectorParameters, - getRendererCapabilities(renderersFactory)); + renderersFactory, + dataSourceFactory, + drmSessionManager); } - /** @deprecated Use {@link #forSmoothStreaming(Context, Uri, Factory, RenderersFactory)} */ + /** + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. + */ + @SuppressWarnings("deprecation") @Deprecated public static DownloadHelper forSmoothStreaming( Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { @@ -375,20 +271,15 @@ public static DownloadHelper forSmoothStreaming( dataSourceFactory, renderersFactory, /* drmSessionManager= */ null, - DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT); } /** - * 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. + * @deprecated Use {@link #forMediaItem(MediaItem, Parameters, RenderersFactory, + * DataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public static DownloadHelper forSmoothStreaming( Context context, Uri uri, @@ -403,41 +294,139 @@ public static DownloadHelper forSmoothStreaming( } /** - * 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 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. + * @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), + /* renderersFactory= */ null, + /* dataSourceFactory= */ null, + /* drmSessionManager= */ null); + } + + /** + * Creates a {@link DownloadHelper} for the given media item. + * + * @param context The context. + * @param mediaItem A {@link MediaItem}. + * @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. + * @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 forMediaItem( + Context context, + MediaItem mediaItem, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory) { + return forMediaItem( + mediaItem, + getDefaultTrackSelectorParameters(context), + renderersFactory, + dataSourceFactory, + /* drmSessionManager= */ null); + } + + /** + * Creates a {@link DownloadHelper} for the given media item. + * + * @param mediaItem A {@link MediaItem}. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @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 forMediaItem( + MediaItem mediaItem, + DefaultTrackSelector.Parameters trackSelectorParameters, + @Nullable RenderersFactory renderersFactory, + @Nullable DataSource.Factory dataSourceFactory) { + return forMediaItem( + mediaItem, + trackSelectorParameters, + renderersFactory, + dataSourceFactory, + /* drmSessionManager= */ null); + } + + /** + * Creates a {@link DownloadHelper} for the given media item. + * + * @param mediaItem A {@link MediaItem}. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @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. + * @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 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 static MediaSource createMediaSource( 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 static MediaSource createMediaSource( /** * 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 static MediaSource createMediaSource( * 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 void addTrackSelectionForSingleRenderer( * @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 DownloadRequest getDownloadRequest(@Nullable byte[] data) { * @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 DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) { } 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 @@ private void onMediaPrepared() { 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 @@ private TrackSelectorResult runTrackSelection(int periodIndex) { } } - @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 MediaPreparer(MediaSource mediaSource, DownloadHelper 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 @@ private boolean handleDownloadHelperCallbackMessage(Message msg) { 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 void updateSelectedTrack( } } - 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 37247013e35..b6228025cf0 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_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; @@ -40,6 +41,7 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSource.Factory; import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheEvictor; import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; import com.google.android.exoplayer2.util.Assertions; @@ -51,6 +53,7 @@ import java.util.HashMap; import java.util.List; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executor; /** * Manages downloads. @@ -61,7 +64,9 @@ * *

      A download manager instance must be accessed only from the thread that created it, unless that * thread does not have a {@link Looper}. In that case, it must be accessed only from the - * application's main thread. Registered listeners will be called on the same thread. + * application's main thread. Registered listeners will be called on the same thread. In all cases + * the `Looper` of the thread from which the manager must be accessed can be queried using {@link + * #getApplicationLooper()}. */ public final class DownloadManager { @@ -90,8 +95,11 @@ default void onDownloadsPausedChanged( * * @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. @@ -166,7 +174,7 @@ default void onWaitingForRequirementsChanged( private final Context context; private final WritableDownloadIndex downloadIndex; - private final Handler mainHandler; + private final Handler applicationHandler; private final InternalHandler internalHandler; private final RequirementsWatcher.Listener requirementsListener; private final CopyOnWriteArraySet listeners; @@ -191,13 +199,42 @@ default void onWaitingForRequirementsChanged( * 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 DownloaderConstructorHelper(cache, upstreamFactory))); + new DefaultDownloaderFactory( + new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(upstreamFactory), + executor)); } /** @@ -219,8 +256,8 @@ public DownloadManager( listeners = new CopyOnWriteArraySet<>(); @SuppressWarnings("methodref.receiver.bound.invalid") - Handler mainHandler = Util.createHandler(this::handleMainMessage); - this.mainHandler = mainHandler; + Handler mainHandler = Util.createHandlerForCurrentOrMainLooper(this::handleMainMessage); + this.applicationHandler = mainHandler; HandlerThread internalThread = new HandlerThread("ExoPlayer:DownloadManager"); internalThread.start(); internalHandler = @@ -246,6 +283,14 @@ public DownloadManager( .sendToTarget(); } + /** + * Returns the {@link Looper} associated with the application thread that's used to access the + * manager, and on which the manager will call its {@link Listener Listeners}. + */ + public Looper getApplicationLooper() { + return applicationHandler.getLooper(); + } + /** Returns whether the manager has completed initialization. */ public boolean isInitialized() { return initialized; @@ -280,6 +325,7 @@ public boolean isWaitingForRequirements() { * @param listener The listener to be added. */ public void addListener(Listener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } @@ -484,7 +530,7 @@ public void release() { // Restore the interrupted status. Thread.currentThread().interrupt(); } - mainHandler.removeCallbacksAndMessages(/* token= */ null); + applicationHandler.removeCallbacksAndMessages(/* token= */ null); // Reset state. downloads = Collections.emptyList(); pendingMessages = 0; @@ -600,7 +646,7 @@ private void onDownloadUpdate(DownloadUpdate update) { } } else { for (Listener listener : listeners) { - listener.onDownloadChanged(this, updatedDownload); + listener.onDownloadChanged(this, updatedDownload, update.finalException); } } if (waitingForRequirementsChanged) { @@ -730,7 +776,7 @@ public void handleMessage(Message message) { break; case MSG_CONTENT_LENGTH_CHANGED: task = (Task) message.obj; - onContentLengthChanged(task); + onContentLengthChanged(task, Util.toLong(message.arg1, message.arg2)); return; // No need to post back to mainHandler. case MSG_UPDATE_PROGRESS: updateProgress(); @@ -892,7 +938,8 @@ private void removeAllDownloads() { 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(); @@ -1005,7 +1052,7 @@ private void syncRemovingDownload(@Nullable Task activeTask, Download download) // Cancel the downloading task. activeTask.cancel(/* released= */ false); } - // The activeTask is either a remove task, or a downloading task that we just cancelled. In + // The activeTask is either a remove task, or a downloading task that we just canceled. In // the latter case we need to wait for the task to stop before we start a remove task. return; } @@ -1026,9 +1073,8 @@ private void syncRemovingDownload(@Nullable Task activeTask, Download download) // Task event processing. - private void onContentLengthChanged(Task task) { + private void onContentLengthChanged(Task task, long contentLength) { String downloadId = task.request.id; - long contentLength = task.contentLength; Download download = Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false)); if (contentLength == download.contentLength || contentLength == C.LENGTH_UNSET) { @@ -1060,9 +1106,9 @@ private void onTaskStopped(Task task) { 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 = @@ -1070,7 +1116,7 @@ private void onTaskStopped(Task task) { switch (download.state) { case STATE_DOWNLOADING: Assertions.checkState(!isRemove); - onDownloadTaskStopped(download, finalError); + onDownloadTaskStopped(download, finalException); break; case STATE_REMOVING: case STATE_RESTARTING: @@ -1088,16 +1134,16 @@ private void onTaskStopped(Task task) { 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)); @@ -1108,7 +1154,8 @@ private void onDownloadTaskStopped(Download download, @Nullable Throwable finalE 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(); } @@ -1126,7 +1173,11 @@ private void onRemoveTaskStopped(Download download) { 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(); } } @@ -1181,7 +1232,11 @@ private Download putDownload(Download download) { 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; } @@ -1239,7 +1294,7 @@ private static class Task extends Thread implements Downloader.ProgressListener @Nullable private volatile InternalHandler internalHandler; private volatile boolean isCanceled; - @Nullable private Throwable finalError; + @Nullable private Exception finalException; private long contentLength; @@ -1304,8 +1359,10 @@ public void run() { } } } - } catch (Throwable e) { - finalError = e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + finalException = e; } @Nullable Handler internalHandler = this.internalHandler; if (internalHandler != null) { @@ -1321,13 +1378,19 @@ public void onProgress(long contentLength, long bytesDownloaded, float percentDo this.contentLength = contentLength; @Nullable Handler internalHandler = this.internalHandler; if (internalHandler != null) { - internalHandler.obtainMessage(MSG_CONTENT_LENGTH_CHANGED, this).sendToTarget(); + internalHandler + .obtainMessage( + MSG_CONTENT_LENGTH_CHANGED, + (int) (contentLength >> 32), + (int) contentLength, + this) + .sendToTarget(); } } } private static int getRetryDelayMillis(int errorCount) { - return Math.min((errorCount - 1) * 1000, 5000); + return min((errorCount - 1) * 1000, 5000); } } @@ -1336,11 +1399,17 @@ private static final class DownloadUpdate { 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/DownloadProgress.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadProgress.java index 9d946daa286..ba226e60b26 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadProgress.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadProgress.java @@ -21,8 +21,8 @@ public class DownloadProgress { /** The number of bytes that have been downloaded. */ - public long bytesDownloaded; + public volatile long bytesDownloaded; /** The percentage that has been downloaded, or {@link C#PERCENTAGE_UNSET} if unknown. */ - public float percentDownloaded; + public volatile float percentDownloaded; } 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 988b908140d..1fa1655445f 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.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 static class UnsupportedRequestException extends IOException {} /** * @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 DownloadRequest( * @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 DownloadRequest copyWithMergedRequest(DownloadRequest newRequest) { } } 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 boolean equals(@Nullable Object o) { } 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 int describeContents() { @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 51f7fa3765d..527c51ea838 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 int onStartCommand(@Nullable Intent intent, int flags, int startId) { 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 void attachService(DownloadService downloadService) { // 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 void onInitialized(DownloadManager downloadManager) { } @Override - public void onDownloadChanged(DownloadManager downloadManager, Download download) { + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Exception finalException) { if (downloadService != null) { downloadService.notifyDownloadChanged(download); } @@ -1022,7 +1039,7 @@ private void restartService() { try { Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); context.startService(intent); - } catch (IllegalArgumentException e) { + } catch (IllegalStateException e) { // The process is classed as idle by the platform. Starting a background service is not // allowed in this state. Log.w(TAG, "Failed to restart DownloadService (process is idle)."); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/Downloader.java index fa10d5842b3..1059157d34f 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 @@ 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 { @@ -28,6 +29,10 @@ interface ProgressListener { /** * Called when progress is made during a download operation. * + *

      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 this method has finished executing. + * * @param contentLength The length of the content in bytes, or {@link C#LENGTH_UNSET} if * unknown. * @param bytesDownloaded The number of bytes that have been downloaded. @@ -40,21 +45,32 @@ interface ProgressListener { /** * 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 InterruptedException If the thread has been interrupted. - * @throws IOException Thrown when there is an io error while downloading. + * @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 InterruptedException, IOException; - - /** Cancels the download operation and prevents future download operations from running. */ - void cancel(); + throws IOException, InterruptedException; /** - * Removes the content. + * Permanently cancels the downloading by this downloader. The caller should also interrupt the + * downloading thread immediately after calling this method. * - * @throws InterruptedException Thrown if the thread was interrupted. + *

      Once canceled, {@link #download} cannot be called again. */ - void remove() throws InterruptedException; + void cancel(); + + /** Removes the content. */ + void remove(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java deleted file mode 100644 index 0d53b3cde08..00000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java +++ /dev/null @@ -1,170 +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.offline; - -import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.DataSink; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DummyDataSource; -import com.google.android.exoplayer2.upstream.FileDataSource; -import com.google.android.exoplayer2.upstream.PriorityDataSourceFactory; -import com.google.android.exoplayer2.upstream.cache.Cache; -import com.google.android.exoplayer2.upstream.cache.CacheDataSink; -import com.google.android.exoplayer2.upstream.cache.CacheDataSinkFactory; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; -import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; -import com.google.android.exoplayer2.upstream.cache.CacheUtil; -import com.google.android.exoplayer2.util.PriorityTaskManager; - -/** A helper class that holds necessary parameters for {@link Downloader} construction. */ -public final class DownloaderConstructorHelper { - - private final Cache cache; - @Nullable private final CacheKeyFactory cacheKeyFactory; - @Nullable private final PriorityTaskManager priorityTaskManager; - private final CacheDataSourceFactory onlineCacheDataSourceFactory; - private final CacheDataSourceFactory offlineCacheDataSourceFactory; - - /** - * @param cache Cache instance to be used to store downloaded data. - * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for - * downloading data. - */ - public DownloaderConstructorHelper(Cache cache, DataSource.Factory upstreamFactory) { - this( - cache, - upstreamFactory, - /* cacheReadDataSourceFactory= */ null, - /* cacheWriteDataSinkFactory= */ null, - /* priorityTaskManager= */ null); - } - - /** - * @param cache Cache instance to be used to store downloaded data. - * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for - * downloading data. - * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s - * for reading data from the cache. If null then a {@link FileDataSource.Factory} will be - * used. - * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s - * for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used. - * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null, - * downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst - * downloading. - */ - public DownloaderConstructorHelper( - Cache cache, - DataSource.Factory upstreamFactory, - @Nullable DataSource.Factory cacheReadDataSourceFactory, - @Nullable DataSink.Factory cacheWriteDataSinkFactory, - @Nullable PriorityTaskManager priorityTaskManager) { - this( - cache, - upstreamFactory, - cacheReadDataSourceFactory, - cacheWriteDataSinkFactory, - priorityTaskManager, - /* cacheKeyFactory= */ null); - } - - /** - * @param cache Cache instance to be used to store downloaded data. - * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for - * downloading data. - * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s - * for reading data from the cache. If null then a {@link FileDataSource.Factory} will be - * used. - * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s - * for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used. - * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null, - * downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst - * downloading. - * @param cacheKeyFactory An optional factory for cache keys. - */ - public DownloaderConstructorHelper( - Cache cache, - DataSource.Factory upstreamFactory, - @Nullable DataSource.Factory cacheReadDataSourceFactory, - @Nullable DataSink.Factory cacheWriteDataSinkFactory, - @Nullable PriorityTaskManager priorityTaskManager, - @Nullable CacheKeyFactory cacheKeyFactory) { - if (priorityTaskManager != null) { - upstreamFactory = - new PriorityDataSourceFactory(upstreamFactory, priorityTaskManager, C.PRIORITY_DOWNLOAD); - } - DataSource.Factory readDataSourceFactory = - cacheReadDataSourceFactory != null - ? cacheReadDataSourceFactory - : new FileDataSource.Factory(); - if (cacheWriteDataSinkFactory == null) { - cacheWriteDataSinkFactory = - new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE); - } - onlineCacheDataSourceFactory = - new CacheDataSourceFactory( - cache, - upstreamFactory, - readDataSourceFactory, - cacheWriteDataSinkFactory, - CacheDataSource.FLAG_BLOCK_ON_CACHE, - /* eventListener= */ null, - cacheKeyFactory); - offlineCacheDataSourceFactory = - new CacheDataSourceFactory( - cache, - DummyDataSource.FACTORY, - readDataSourceFactory, - null, - CacheDataSource.FLAG_BLOCK_ON_CACHE, - /* eventListener= */ null, - cacheKeyFactory); - this.cache = cache; - this.priorityTaskManager = priorityTaskManager; - this.cacheKeyFactory = cacheKeyFactory; - } - - /** Returns the {@link Cache} instance. */ - public Cache getCache() { - return cache; - } - - /** Returns the {@link CacheKeyFactory}. */ - public CacheKeyFactory getCacheKeyFactory() { - return cacheKeyFactory != null ? cacheKeyFactory : CacheUtil.DEFAULT_CACHE_KEY_FACTORY; - } - - /** Returns a {@link PriorityTaskManager} instance. */ - public PriorityTaskManager getPriorityTaskManager() { - // Return a dummy PriorityTaskManager if none is provided. Create a new PriorityTaskManager - // each time so clients don't affect each other over the dummy PriorityTaskManager instance. - return priorityTaskManager != null ? priorityTaskManager : new PriorityTaskManager(); - } - - /** Returns a new {@link CacheDataSource} instance. */ - public CacheDataSource createCacheDataSource() { - return onlineCacheDataSourceFactory.createDataSource(); - } - - /** - * Returns a new {@link CacheDataSource} instance which accesses cache read-only and throws an - * exception on cache miss. - */ - public CacheDataSource createOfflineCacheDataSource() { - return offlineCacheDataSourceFactory.createDataSource(); - } -} 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 055410c4310..09fa444cf34 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,102 +18,176 @@ 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.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.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.atomic.AtomicBoolean; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -/** - * A downloader for progressive media streams. - * - *

      The downloader attempts to download the entire media bytes referenced by a {@link Uri} into a - * cache as defined by {@link DownloaderConstructorHelper}. Callers can use the constructor to - * specify a custom cache key for the downloaded bytes. - * - *

      The downloader will avoid downloading already-downloaded media bytes. - */ +/** 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 Cache cache; private final CacheDataSource dataSource; - private final CacheKeyFactory cacheKeyFactory; - private final PriorityTaskManager priorityTaskManager; - private final AtomicBoolean isCanceled; + @Nullable private final PriorityTaskManager priorityTaskManager; + + @Nullable private ProgressListener progressListener; + private volatile @MonotonicNonNull RunnableFutureTask downloadRunnable; + private volatile boolean isCanceled; + + /** @deprecated Use {@link #ProgressiveDownloader(MediaItem, CacheDataSource.Factory)} instead. */ + @SuppressWarnings("deprecation") + @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. - * @param constructorHelper A {@link DownloaderConstructorHelper} instance. + * 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( - Uri uri, @Nullable String customCacheKey, DownloaderConstructorHelper constructorHelper) { - this.dataSpec = + 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 + * the future, providing an {@link Executor} that uses multiple threads may speed up the + * download by allowing parts of it to be executed in parallel. + */ + public ProgressiveDownloader( + 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(); - this.cache = constructorHelper.getCache(); - this.dataSource = constructorHelper.createCacheDataSource(); - this.cacheKeyFactory = constructorHelper.getCacheKeyFactory(); - this.priorityTaskManager = constructorHelper.getPriorityTaskManager(); - isCanceled = new AtomicBoolean(); + dataSource = cacheDataSourceFactory.createDataSourceForDownloading(); + priorityTaskManager = cacheDataSourceFactory.getUpstreamPriorityTaskManager(); } @Override public void download(@Nullable ProgressListener progressListener) - throws InterruptedException, IOException { - priorityTaskManager.add(C.PRIORITY_DOWNLOAD); + 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(); + } + }; + } + + if (priorityTaskManager != null) { + priorityTaskManager.add(C.PRIORITY_DOWNLOAD); + } try { - CacheUtil.cache( - dataSpec, - cache, - cacheKeyFactory, - dataSource, - new byte[BUFFER_SIZE_BYTES], - priorityTaskManager, - C.PRIORITY_DOWNLOAD, - progressListener == null ? null : new ProgressForwarder(progressListener), - isCanceled, - /* enableEOFException= */ true); + 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 { - priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); + // 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); + } } } @Override public void cancel() { - isCanceled.set(true); + isCanceled = true; + RunnableFutureTask downloadRunnable = this.downloadRunnable; + if (downloadRunnable != null) { + downloadRunnable.cancel(/* interruptIfRunning= */ true); + } } @Override public void remove() { - CacheUtil.remove(dataSpec, cache, cacheKeyFactory); + 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 299998ea882..7cf31bc030f 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,25 +15,34 @@ */ 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; +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.atomic.AtomicBoolean; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; /** * Base class for multi segment stream downloaders. @@ -67,50 +76,71 @@ public int compareTo(Segment other) { private static final long MAX_MERGED_SEGMENT_START_TIME_DIFF_US = 20 * C.MICROS_PER_SECOND; private final DataSpec manifestDataSpec; + private final Parser manifestParser; + private final ArrayList streamKeys; + private final CacheDataSource.Factory cacheDataSourceFactory; private final Cache cache; - private final CacheDataSource dataSource; - private final CacheDataSource offlineDataSource; private final CacheKeyFactory cacheKeyFactory; - private final PriorityTaskManager priorityTaskManager; - private final ArrayList streamKeys; - private final AtomicBoolean isCanceled; + @Nullable private final PriorityTaskManager priorityTaskManager; + private final Executor executor; /** - * @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 constructorHelper A {@link DownloaderConstructorHelper} instance. + * 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}. */ - public SegmentDownloader( - Uri manifestUri, List streamKeys, DownloaderConstructorHelper constructorHelper) { - this.manifestDataSpec = getCompressibleDataSpec(manifestUri); - this.streamKeys = new ArrayList<>(streamKeys); - this.cache = constructorHelper.getCache(); - this.dataSource = constructorHelper.createCacheDataSource(); - this.offlineDataSource = constructorHelper.createOfflineCacheDataSource(); - this.cacheKeyFactory = constructorHelper.getCacheKeyFactory(); - this.priorityTaskManager = constructorHelper.getPriorityTaskManager(); - isCanceled = new AtomicBoolean(); - } + private final ArrayList> activeRunnables; + + private volatile boolean isCanceled; /** - * Downloads the selected streams in the media. If multiple streams are selected, they are - * downloaded in sync with one another. - * - * @throws IOException Thrown when there is an error downloading. - * @throws InterruptedException If the thread has been interrupted. + * @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. + * Providing an {@link Executor} that uses multiple threads will speed up the download by + * allowing parts of it to be executed in parallel. */ + public SegmentDownloader( + MediaItem mediaItem, + Parser manifestParser, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + checkNotNull(mediaItem.playbackProperties); + this.manifestDataSpec = getCompressibleDataSpec(mediaItem.playbackProperties.uri); + this.manifestParser = manifestParser; + this.streamKeys = new ArrayList<>(mediaItem.playbackProperties.streamKeys); + this.cacheDataSourceFactory = cacheDataSourceFactory; + this.executor = executor; + cache = Assertions.checkNotNull(cacheDataSourceFactory.getCache()); + cacheKeyFactory = cacheDataSourceFactory.getCacheKeyFactory(); + priorityTaskManager = cacheDataSourceFactory.getUpstreamPriorityTaskManager(); + activeRunnables = new ArrayList<>(); + } + @Override public final void download(@Nullable ProgressListener progressListener) throws IOException, InterruptedException { - priorityTaskManager.add(C.PRIORITY_DOWNLOAD); + ArrayDeque pendingSegments = new ArrayDeque<>(); + ArrayDeque recycledRunnables = new ArrayDeque<>(); + if (priorityTaskManager != null) { + priorityTaskManager.add(C.PRIORITY_DOWNLOAD); + } try { + 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); @@ -120,11 +150,18 @@ public final void download(@Nullable ProgressListener progressListener) 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) { @@ -141,96 +178,241 @@ public final void download(@Nullable ProgressListener progressListener) } // Download the segments. - @Nullable ProgressNotifier progressNotifier = null; - if (progressListener != null) { - progressNotifier = - new ProgressNotifier( - progressListener, - contentLength, - totalSegments, - bytesDownloaded, - segmentsDownloaded); - } - byte[] buffer = new byte[BUFFER_SIZE_BYTES]; - for (int i = 0; i < segments.size(); i++) { - CacheUtil.cache( - segments.get(i).dataSpec, - cache, - cacheKeyFactory, - dataSource, - buffer, - priorityTaskManager, - C.PRIORITY_DOWNLOAD, - progressNotifier, - isCanceled, - true); - if (progressNotifier != null) { - progressNotifier.onSegmentDownloaded(); + @Nullable + ProgressNotifier progressNotifier = + progressListener != null + ? new ProgressNotifier( + progressListener, + contentLength, + totalSegments, + bytesDownloaded, + segmentsDownloaded) + : null; + 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 { - priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); + // 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); + } } } @Override public void cancel() { - isCanceled.set(true); + synchronized (activeRunnables) { + isCanceled = true; + for (int i = 0; i < activeRunnables.size(); i++) { + activeRunnables.get(i).cancel(/* interruptIfRunning= */ true); + } + } } @Override - public final void remove() throws InterruptedException { + public final void remove() { + CacheDataSource dataSource = cacheDataSourceFactory.createDataSourceForRemovingDownload(); try { - M manifest = getManifest(offlineDataSource, manifestDataSpec); - List segments = getSegments(offlineDataSource, manifest, true); + M manifest = getManifest(dataSource, manifestDataSpec, /* removing= */ true); + List segments = getSegments(dataSource, manifest, /* removing= */ true); for (int i = 0; i < segments.size(); i++) { - removeDataSpec(segments.get(i).dataSpec); + 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. - removeDataSpec(manifestDataSpec); + cache.removeResource(cacheKeyFactory.buildCacheKey(manifestDataSpec)); } } // Internal methods. /** - * Loads and parses the manifest. + * 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, 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); + } + + /** + * 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 abstract M getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException; + 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. + * 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 InterruptedException Thrown if the thread was interrupted. - * @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 InterruptedException, IOException; - - private void removeDataSpec(DataSpec dataSpec) { - CacheUtil.remove(dataSpec, cache, cacheKeyFactory); - } + 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; @@ -269,7 +451,48 @@ private static boolean canMergeSegments(DataSpec dataSpec1, DataSpec dataSpec2) && 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 c4861abdf34..357fdab9570 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.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 @@ @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 PlatformScheduler(Context context, int jobId) { 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 boolean schedule(Requirements requirements, String servicePackage, String 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 @@ private static JobInfo buildJobInfo( 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 @@ private static JobInfo buildJobInfo( } 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 @@ private static JobInfo buildJobInfo( 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 8919a26720c..7a2946d012d 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 int getRequirements() { 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 boolean isIdleRequired() { 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 int getNotMetRequirements(Context context) { if (isIdleRequired() && !isDeviceIdle(context)) { notMetRequirements |= DEVICE_IDLE; } + if (isStorageNotLowRequired() && !isStorageNotLow(context)) { + notMetRequirements |= DEVICE_STORAGE_NOT_LOW; + } return notMetRequirements; } @@ -129,8 +154,9 @@ private int getNotMetNetworkRequirements(Context context) { } 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 @@ private int getNotMetNetworkRequirements(Context context) { } 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 @@ private boolean isDeviceCharging(Context context) { } 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 797b7f71709..6293cbf36da 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.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 RequirementsWatcher(Context context, Listener listener, Requirements requ this.context = context.getApplicationContext(); this.listener = listener; this.requirements = requirements; - handler = new Handler(Util.getLooper()); + handler = Util.createHandlerForCurrentOrMainLooper(); } /** @@ -104,6 +105,10 @@ public int start() { 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 int start() { /** 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 Requirements getRequirements() { @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 @@ private void registerNetworkCallbackV24() { @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 @@ private void checkRequirements() { } } + /** + * 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 void onReceive(Context context, Intent intent) { @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 void onCapabilitiesChanged(Network network, NetworkCapabilities networkCa 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 @@ private void onNetworkCallback() { } }); } + + 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 b5a6f404247..c34c77b2cf8 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 a10bd038d7a..96ef4b0c6d8 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 @@ -37,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; @@ -45,6 +46,7 @@ public BaseMediaSource() { mediaSourceCallers = new ArrayList<>(/* initialCapacity= */ 1); enabledMediaSourceCallers = new HashSet<>(/* initialCapacity= */ 1); eventDispatcher = new MediaSourceEventListener.EventDispatcher(); + drmEventDispatcher = new DrmSessionEventListener.EventDispatcher(); } /** @@ -126,6 +128,33 @@ protected final MediaSourceEventListener.EventDispatcher createEventDispatcher( 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(); @@ -133,6 +162,8 @@ protected final boolean isEnabled() { @Override public final void addEventListener(Handler handler, MediaSourceEventListener eventListener) { + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); eventDispatcher.addEventListener(handler, eventListener); } @@ -143,12 +174,14 @@ public final void removeEventListener(MediaSourceEventListener eventListener) { @Override public final void addDrmEventListener(Handler handler, DrmSessionEventListener eventListener) { - eventDispatcher.addEventListener(handler, eventListener, DrmSessionEventListener.class); + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); + drmEventDispatcher.addEventListener(handler, eventListener); } @Override public final void removeDrmEventListener(DrmSessionEventListener eventListener) { - eventDispatcher.removeEventListener(eventListener, DrmSessionEventListener.class); + 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 f8764585aa8..7e770d4e39e 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.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.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 @@ */ /* 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 @@ /** * 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 @@ public void init( } 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 c5484a8f456..7bb6a83add2 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 @@ private static boolean shouldKeepInitialDiscontinuity( // 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 d4ede3e59e6..581a0b17e38 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 ClippingMediaSource( 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 @@ protected long getMediaTimeForChildMediaTime(Void id, long mediaTimeMs) { 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 ClippingTimeline(Timeline timeline, long startUs, long endUs) 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 Window getWindow(int windowIndex, Window window, long defaultPositionProj 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 5cc75e8e0b5..5f1464721cc 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 @@ protected CompositeMediaSource() { @CallSuper protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { this.mediaTransferListener = mediaTransferListener; - eventHandler = Util.createHandler(); + eventHandler = Util.createHandlerForCurrentLooper(); } @Override @@ -192,18 +192,6 @@ protected long getMediaTimeForChildMediaTime(@UnknownNull T id, long mediaTimeMs 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 @@ private final class ForwardingEventListener 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 void onLoadStarted( LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadStarted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.loadStarted( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -269,7 +240,8 @@ public void onLoadCompleted( LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCompleted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.loadCompleted( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -280,7 +252,8 @@ public void onLoadCanceled( LoadEventInfo loadEventData, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.loadCanceled(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.loadCanceled( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); } } @@ -293,23 +266,16 @@ public void onLoadError( 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,52 +283,53 @@ public void onUpstreamDiscarded( public void onDownstreamFormatChanged( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { - eventDispatcher.downstreamFormatChanged(maybeUpdateMediaLoadData(mediaLoadData)); + mediaSourceEventDispatcher.downstreamFormatChanged(maybeUpdateMediaLoadData(mediaLoadData)); } } // DrmSessionEventListener implementation @Override - public void onDrmSessionAcquired() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionAcquired(), - DrmSessionEventListener.class); + public void onDrmSessionAcquired(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmSessionAcquired(); + } } @Override - public void onDrmKeysLoaded() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysLoaded(), - DrmSessionEventListener.class); + public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmKeysLoaded(); + } } @Override - public void onDrmSessionManagerError(Exception error) { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionManagerError(error), - DrmSessionEventListener.class); + public void onDrmSessionManagerError( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception error) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmSessionManagerError(error); + } } @Override - public void onDrmKeysRestored() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysRestored(), - DrmSessionEventListener.class); + public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmKeysRestored(); + } } @Override - public void onDrmKeysRemoved() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysRemoved(), - DrmSessionEventListener.class); + public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmKeysRemoved(); + } } @Override - public void onDrmSessionReleased() { - eventDispatcher.dispatch( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmSessionReleased(), - DrmSessionEventListener.class); + public void onDrmSessionReleased(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + drmEventDispatcher.drmSessionReleased(); + } } /** Updates the event dispatcher and returns whether the event should be dispatched. */ @@ -377,11 +344,15 @@ private boolean maybeUpdateEventDispatcher( } } 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 b5837051709..ce5fb868f5a 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 final long getBufferedPositionUs() { 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 final long getNextLoadPositionUs() { 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 8664c4367b3..48305bc9166 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 synchronized void setShuffleOrder( // CompositeMediaSource implementation. @Override - @Nullable - public Object getTag() { - return null; + public MediaItem getMediaItem() { + // This method is actually never called because getInitialTimeline is implemented and hence the + // MaskingMediaSource does not need to create a placeholder timeline for this media source. + return EMPTY_MEDIA_ITEM; } @Override @@ -470,7 +479,7 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star @Nullable MediaSourceHolder holder = mediaSourceByUid.get(mediaSourceHolderUid); if (holder == null) { // Stale event. The media source has already been removed. - holder = new MediaSourceHolder(new DummyMediaSource(), useLazyPreparation); + holder = new MediaSourceHolder(new FakeMediaSource(), useLazyPreparation); holder.isRemoved = true; prepareChildSource(holder, holder.mediaSource); } @@ -798,8 +807,8 @@ private void removeMediaSourceInternal(int index) { } private void moveMediaSourceInternal(int currentIndex, int newIndex) { - int startIndex = Math.min(currentIndex, newIndex); - int endIndex = Math.max(currentIndex, newIndex); + int startIndex = min(currentIndex, newIndex); + int endIndex = max(currentIndex, newIndex); int windowOffset = mediaSourceHolders.get(startIndex).firstWindowIndexInChild; mediaSourceHolders.add(newIndex, mediaSourceHolders.remove(currentIndex)); for (int i = startIndex; i <= endIndex; i++) { @@ -982,8 +991,8 @@ public int getPeriodCount() { } } - /** Dummy media source which does nothing and does not support creating periods. */ - private static final class DummyMediaSource extends BaseMediaSource { + /** A media source which does nothing and does not support creating periods. */ + private static final class FakeMediaSource extends BaseMediaSource { @Override protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { @@ -991,9 +1000,8 @@ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferLis } @Override - @Nullable - public Object getTag() { - return null; + public MediaItem getMediaItem() { + return EMPTY_MEDIA_ITEM; } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java index c3beb0d00e4..3f1c03d3b18 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java @@ -20,27 +20,24 @@ import android.util.SparseArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayerLibraryInfo; -import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.FrameworkMediaDrm; -import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; -import com.google.android.exoplayer2.drm.MediaDrmCallback; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; -import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import java.util.Arrays; import java.util.List; -import java.util.Map; /** * The default {@link MediaSourceFactory} implementation. @@ -49,90 +46,110 @@ * factories: * *

      * - *

      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. + *

      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 { /** - * Creates a new instance with the given {@link Context}. - * - *

      This is functionally equivalent with calling {@code #newInstance(Context, - * DefaultDataSourceFactory)}. - * - * @param context The {@link Context}. + * Provides {@link AdsLoader} instances for media items that have {@link + * MediaItem.PlaybackProperties#adTagUri ad tag URIs}. */ - public static DefaultMediaSourceFactory newInstance(Context context) { - return newInstance( - context, - new DefaultDataSourceFactory( - context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY))); - } + public interface AdsLoaderProvider { - /** - * 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); + /** + * 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 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); } + private static final String TAG = "DefaultMediaSourceFactory"; + + private final MediaSourceDrmHelper mediaSourceDrmHelper; private final DataSource.Factory dataSourceFactory; 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. + * + * @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()); + } - private DefaultMediaSourceFactory(Context context, DataSource.Factory dataSourceFactory) { + /** + * Creates a new instance. + * + * @param dataSourceFactory A {@link DataSource.Factory} to create {@link DataSource} instances + * for requesting media data. + * @param extractorsFactory An {@link ExtractorsFactory} used to extract progressive media from + * its container. + */ + public DefaultMediaSourceFactory( + DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { this.dataSourceFactory = dataSourceFactory; - 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); @@ -140,43 +157,53 @@ private DefaultMediaSourceFactory(Context context, DataSource.Factory dataSource } /** - * 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 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 drmHttpDataSourceFactory The HTTP data source factory or {@code null} to use {@link - * DefaultHttpDataSourceFactory}. + * @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; } @@ -203,73 +230,39 @@ public MediaSource createMediaSource(MediaItem mediaItem) { Assertions.checkNotNull(mediaItem.playbackProperties); @C.ContentType int type = - inferContentType( - mediaItem.playbackProperties.sourceUri, mediaItem.playbackProperties.mimeType); + 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 leafMediaSource = mediaSourceFactory.createMediaSource(mediaItem); + MediaSource mediaSource = mediaSourceFactory.createMediaSource(mediaItem); List subtitles = mediaItem.playbackProperties.subtitles; - if (subtitles.isEmpty()) { - return maybeClipMediaSource(mediaItem, leafMediaSource); - } - - MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1]; - mediaSources[0] = leafMediaSource; - 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); + if (!subtitles.isEmpty()) { + MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1]; + mediaSources[0] = mediaSource; + SingleSampleMediaSource.Factory singleSampleSourceFactory = + new SingleSampleMediaSource.Factory(dataSourceFactory); + for (int i = 0; i < subtitles.size(); i++) { + mediaSources[i + 1] = + singleSampleSourceFactory.createMediaSource( + subtitles.get(i), /* durationUs= */ C.TIME_UNSET); + } + mediaSource = new MergingMediaSource(mediaSources); } - return maybeClipMediaSource(mediaItem, new MergingMediaSource(mediaSources)); + return maybeWrapWithAdsMediaSource(mediaItem, maybeClipMediaSource(mediaItem, mediaSource)); } // 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 @@ -285,8 +278,32 @@ private static MediaSource maybeClipMediaSource(MediaItem mediaItem, MediaSource mediaItem.clippingProperties.relativeToDefaultPosition); } + private MediaSource maybeWrapWithAdsMediaSource(MediaItem mediaItem, MediaSource mediaSource) { + Assertions.checkNotNull(mediaItem.playbackProperties); + if (mediaItem.playbackProperties.adTagUri == null) { + return mediaSource; + } + AdsLoaderProvider adsLoaderProvider = this.adsLoaderProvider; + AdViewProvider adViewProvider = this.adViewProvider; + if (adsLoaderProvider == null || adViewProvider == null) { + Log.w( + TAG, + "Playing media without ads. Configure ad support by calling setAdsLoaderProvider and" + + " setAdViewProvider."); + return mediaSource; + } + @Nullable + AdsLoader adsLoader = adsLoaderProvider.getAdsLoader(mediaItem.playbackProperties.adTagUri); + if (adsLoader == null) { + Log.w(TAG, "Playing media without ads. No AdsLoader for provided adTagUri"); + return mediaSource; + } + return new AdsMediaSource( + mediaSource, /* adMediaSourceFactory= */ this, adsLoader, adViewProvider); + } + private static SparseArray loadDelegates( - DataSource.Factory dataSourceFactory) { + DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { SparseArray factories = new SparseArray<>(); // LINT.IfChange try { @@ -321,23 +338,8 @@ private static SparseArray loadDelegates( // Expected if the app was built without the hls module. } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) - factories.put(C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory)); + factories.put( + C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)); return factories; } - - private static int inferContentType(Uri sourceUri, @Nullable String mimeType) { - if (mimeType == null) { - return Util.inferContentType(sourceUri); - } - switch (mimeType) { - case MimeTypes.APPLICATION_MPD: - return C.TYPE_DASH; - case MimeTypes.APPLICATION_M3U8: - return C.TYPE_HLS; - case MimeTypes.APPLICATION_SS: - return C.TYPE_SS; - default: - return Util.inferContentType(sourceUri); - } - } } 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 a4ed3eebc05..38146c92b24 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.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,12 +169,29 @@ public Factory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManage 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 @Override public ExtractorMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setSourceUri(uri).build()); + return createMediaSource(new MediaItem.Builder().setUri(uri).build()); } /** @@ -187,7 +205,7 @@ public ExtractorMediaSource createMediaSource(Uri uri) { public ExtractorMediaSource createMediaSource(MediaItem mediaItem) { Assertions.checkNotNull(mediaItem.playbackProperties); return new ExtractorMediaSource( - mediaItem.playbackProperties.sourceUri, + mediaItem.playbackProperties.uri, dataSourceFactory, extractorsFactory, loadErrorHandlingPolicy, @@ -197,7 +215,7 @@ public ExtractorMediaSource createMediaSource(MediaItem mediaItem) { } /** - * @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 @@ private ExtractorMediaSource( @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 84d2902c535..285b9f3fef0 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 @@ public IcyDataSource(DataSource upstream, int metadataIntervalBytes, Listener li @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); upstream.addTransferListener(transferListener); } @@ -84,7 +87,7 @@ public int read(byte[] buffer, int offset, int readLength) throws IOException { 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/LoadEventInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/source/LoadEventInfo.java index cfef4eee680..8ae7b02d432 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/LoadEventInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/LoadEventInfo.java @@ -18,6 +18,7 @@ import android.net.Uri; import android.os.SystemClock; import com.google.android.exoplayer2.upstream.DataSpec; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; @@ -33,6 +34,8 @@ public static long getNewId() { return idSource.getAndIncrement(); } + /** Identifies the load task to which this event corresponds. */ + public final long loadTaskId; /** Defines the requested data. */ public final DataSpec dataSpec; /** @@ -50,28 +53,42 @@ public static long getNewId() { /** The number of bytes that were loaded up to the event time. */ public final long bytesLoaded; + /** + * Equivalent to {@link #LoadEventInfo(long, DataSpec, Uri, Map, long, long, long) + * LoadEventInfo(loadTaskId, dataSpec, dataSpec.uri, Collections.emptyMap(), elapsedRealtimeMs, 0, + * 0)}. + */ + public LoadEventInfo(long loadTaskId, DataSpec dataSpec, long elapsedRealtimeMs) { + this( + loadTaskId, + dataSpec, + dataSpec.uri, + Collections.emptyMap(), + elapsedRealtimeMs, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0); + } + /** * Creates load event info. * - * @param dataSpec Defines the requested data. - * @param uri The {@link Uri} from which data is being read. The uri must be identical to the one - * in {@code dataSpec.uri} unless redirection has occurred. If redirection has occurred, this - * is the uri after redirection. - * @param responseHeaders The response headers associated with the load, or an empty map if - * unavailable. - * @param elapsedRealtimeMs The value of {@link SystemClock#elapsedRealtime} at the time of the - * load event. - * @param loadDurationMs The duration of the load up to the event time. - * @param bytesLoaded The number of bytes that were loaded up to the event time. For compressed - * network responses, this is the decompressed size. + * @param loadTaskId See {@link #loadTaskId}. + * @param dataSpec See {@link #dataSpec}. + * @param uri See {@link #uri}. + * @param responseHeaders See {@link #responseHeaders}. + * @param elapsedRealtimeMs See {@link #elapsedRealtimeMs}. + * @param loadDurationMs See {@link #loadDurationMs}. + * @param bytesLoaded See {@link #bytesLoaded}. */ public LoadEventInfo( + long loadTaskId, DataSpec dataSpec, Uri uri, Map> responseHeaders, long elapsedRealtimeMs, long loadDurationMs, long bytesLoaded) { + this.loadTaskId = loadTaskId; this.dataSpec = dataSpec; this.uri = uri; this.responseHeaders = responseHeaders; 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 13f9758a733..6d08147a638 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 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 LoopingMediaSource(MediaSource childSource, int loopCount) { 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 142527af7d1..9514241035b 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 @@ */ 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 interface PrepareErrorListener { @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 MaskingMediaPeriod( } /** - * 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 void onContinueLoadingRequested(MediaPeriod source) { @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 35b3e1848e9..19f5df2aa56 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 MaskingMediaSource(MediaSource mediaSource, boolean useLazyPreparation) { 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 void prepareSourceInternal(@Nullable TransferListener mediaTransferListen } } + /** + * @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 MaskingMediaPeriod createPeriod( // 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 MaskingMediaPeriod createPeriod( public void releasePeriod(MediaPeriod mediaPeriod) { ((MaskingMediaPeriod) mediaPeriod).releasePeriod(); if (mediaPeriod == unpreparedMaskingMediaPeriod) { - Assertions.checkNotNull(unpreparedMaskingMediaPeriodEventDispatcher).mediaPeriodReleased(); - unpreparedMaskingMediaPeriodEventDispatcher = null; unpreparedMaskingMediaPeriod = null; } } @@ -139,7 +145,7 @@ public void releaseSourceInternal() { } @Override - protected synchronized void onChildSourceInfoRefreshed( + protected void onChildSourceInfoRefreshed( Void id, MediaSource mediaSource, Timeline newTimeline) { @Nullable MediaPeriodId idForMaskingPeriodPreparation = null; if (isPrepared) { @@ -154,7 +160,9 @@ protected synchronized void onChildSourceInfoRefreshed( 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 @@ protected synchronized void onChildSourceInfoRefreshed( // 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 @@ protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( 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 @@ private Object getInternalPeriodUid(Object externalPeriodUid) { 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 @@ private void setPreparePositionOverrideToUnpreparedMaskingPeriod(long preparePos 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 @@ private void setPreparePositionOverrideToUnpreparedMaskingPeriod(long preparePos /** * 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 Window getWindow(int windowIndex, Window window, long defaultPositionProj 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 Period getPeriod(int periodIndex, Period period, boolean setIds) { @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 int getIndexOfPeriod(Object uid) { @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 int getWindowCount() { 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 int getPeriodCount() { 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 Period getPeriod(int periodIndex, Period period, boolean setIds) { @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/MediaLoadData.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaLoadData.java index 7d9d5e5969f..0de79e92192 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaLoadData.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaLoadData.java @@ -55,22 +55,28 @@ public final class MediaLoadData { */ public final long mediaEndTimeMs; + /** Creates an instance with the given {@link #dataType}. */ + public MediaLoadData(int dataType) { + this( + dataType, + /* trackType= */ C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeMs= */ C.TIME_UNSET, + /* mediaEndTimeMs= */ C.TIME_UNSET); + } + /** * Creates media load data. * - * @param dataType One of the {@link C} {@code DATA_TYPE_*} constants defining the type of data. - * @param trackType One of the {@link C} {@code TRACK_TYPE_*} constants if the data corresponds to - * media of a specific type. {@link C#TRACK_TYPE_UNKNOWN} otherwise. - * @param trackFormat The format of the track to which the data belongs. Null if the data does not - * belong to a track. - * @param trackSelectionReason One of the {@link C} {@code SELECTION_REASON_*} constants if the - * data belongs to a track. {@link C#SELECTION_REASON_UNKNOWN} otherwise. - * @param trackSelectionData Optional data associated with the selection of the track to which the - * data belongs. Null if the data does not belong to a track. - * @param mediaStartTimeMs The start time of the media, or {@link C#TIME_UNSET} if the data does - * not belong to a specific media period. - * @param mediaEndTimeMs The end time of the media, or {@link C#TIME_UNSET} if the data does not - * belong to a specific media period or the end time is unknown. + * @param dataType See {@link #dataType}. + * @param trackType See {@link #trackType}. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param mediaStartTimeMs See {@link #mediaStartTimeMs}. + * @param mediaEndTimeMs See {@link #mediaEndTimeMs}. */ public MediaLoadData( int dataType, 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 2e2cf9caba0..39b207e2641 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 @@ long selectTracks( * *

      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 479db2adc27..94a9f82030c 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 @@ 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 @@ final class MediaPeriodId { 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 int hashCode() { 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 @@ default boolean isSingleWindow() { 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 00000000000..7859254401f --- /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 61bb55d8d77..39fd6d53a9f 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,41 +15,22 @@ */ package com.google.android.exoplayer2.source; -import android.net.Uri; +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.upstream.DataSpec; 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.Collections; -import java.util.List; -import java.util.Map; +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. * @@ -136,14 +117,6 @@ default void onLoadError( 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. @@ -166,100 +139,103 @@ default void onUpstreamDiscarded( 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); } /** - * Adds a {@link MediaSourceEventListener} to the event dispatcher. - * - *

      This is equivalent to {@link #addEventListener(Handler, Object, Class)} with {@code - * listenerClass = MediaSourceEventListener.class} and is intended to ease the transition to - * using {@link MediaSourceEventDispatcher} everywhere. + * 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) { - addEventListener(handler, eventListener, MediaSourceEventListener.class); + Assertions.checkNotNull(handler); + Assertions.checkNotNull(eventListener); + listenerAndHandlers.add(new ListenerAndHandler(handler, eventListener)); } /** - * Removes a {@link MediaSourceEventListener} from the event dispatcher. - * - *

      This is equivalent to {@link #removeEventListener(Object, Class)} with {@code - * listenerClass = MediaSourceEventListener.class} and is intended to ease the transition to - * using {@link MediaSourceEventDispatcher} everywhere. + * Removes a listener from the event dispatcher. * * @param eventListener The listener to be removed. */ public void removeEventListener(MediaSourceEventListener eventListener) { - removeEventListener(eventListener, MediaSourceEventListener.class); - } - - public void mediaPeriodCreated() { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onMediaPeriodCreated(windowIndex, Assertions.checkNotNull(mediaPeriodId)), - MediaSourceEventListener.class); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + if (listenerAndHandler.listener == eventListener) { + listenerAndHandlers.remove(listenerAndHandler); + } + } } - public void mediaPeriodReleased() { - dispatch( - (listener, windowIndex, mediaPeriodId) -> - listener.onMediaPeriodReleased(windowIndex, Assertions.checkNotNull(mediaPeriodId)), - MediaSourceEventListener.class); - } - - public void loadStarted(DataSpec dataSpec, int dataType, long elapsedRealtimeMs) { + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadStarted(LoadEventInfo loadEventInfo, int dataType) { loadStarted( - dataSpec, + loadEventInfo, dataType, - C.TRACK_TYPE_UNKNOWN, - null, - C.SELECTION_REASON_UNKNOWN, - null, - C.TIME_UNSET, - C.TIME_UNSET, - elapsedRealtimeMs); + /* trackType= */ C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ C.TIME_UNSET, + /* mediaEndTimeUs= */ C.TIME_UNSET); } + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadStarted( - DataSpec dataSpec, + LoadEventInfo loadEventInfo, int dataType, int trackType, @Nullable Format trackFormat, int trackSelectionReason, @Nullable Object trackSelectionData, long mediaStartTimeUs, - long mediaEndTimeUs, - long elapsedRealtimeMs) { + long mediaEndTimeUs) { loadStarted( - new LoadEventInfo( - dataSpec, - dataSpec.uri, - /* responseHeaders= */ Collections.emptyMap(), - elapsedRealtimeMs, - /* loadDurationMs= */ 0, - /* bytesLoaded= */ 0), + loadEventInfo, new MediaLoadData( dataType, trackType, @@ -270,54 +246,41 @@ public void loadStarted( 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)); + } } - public void loadCompleted( - DataSpec dataSpec, - Uri uri, - Map> responseHeaders, - int dataType, - long elapsedRealtimeMs, - long loadDurationMs, - long bytesLoaded) { + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCompleted(LoadEventInfo loadEventInfo, int dataType) { loadCompleted( - dataSpec, - uri, - responseHeaders, + loadEventInfo, dataType, - C.TRACK_TYPE_UNKNOWN, - null, - C.SELECTION_REASON_UNKNOWN, - null, - C.TIME_UNSET, - C.TIME_UNSET, - elapsedRealtimeMs, - loadDurationMs, - bytesLoaded); + /* trackType= */ C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ C.TIME_UNSET, + /* mediaEndTimeUs= */ C.TIME_UNSET); } + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCompleted( - DataSpec dataSpec, - Uri uri, - Map> responseHeaders, + LoadEventInfo loadEventInfo, int dataType, int trackType, @Nullable Format trackFormat, int trackSelectionReason, @Nullable Object trackSelectionData, long mediaStartTimeUs, - long mediaEndTimeUs, - long elapsedRealtimeMs, - long loadDurationMs, - long bytesLoaded) { + long mediaEndTimeUs) { loadCompleted( - new LoadEventInfo( - dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + loadEventInfo, new MediaLoadData( dataType, trackType, @@ -328,54 +291,42 @@ public void loadCompleted( 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)); + } } - public void loadCanceled( - DataSpec dataSpec, - Uri uri, - Map> responseHeaders, - int dataType, - long elapsedRealtimeMs, - long loadDurationMs, - long bytesLoaded) { + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCanceled(LoadEventInfo loadEventInfo, int dataType) { loadCanceled( - dataSpec, - uri, - responseHeaders, + loadEventInfo, dataType, - C.TRACK_TYPE_UNKNOWN, - null, - C.SELECTION_REASON_UNKNOWN, - null, - C.TIME_UNSET, - C.TIME_UNSET, - elapsedRealtimeMs, - loadDurationMs, - bytesLoaded); + /* trackType= */ C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ C.TIME_UNSET, + /* mediaEndTimeUs= */ C.TIME_UNSET); } + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ public void loadCanceled( - DataSpec dataSpec, - Uri uri, - Map> responseHeaders, + LoadEventInfo loadEventInfo, int dataType, int trackType, @Nullable Format trackFormat, int trackSelectionReason, @Nullable Object trackSelectionData, long mediaStartTimeUs, - long mediaEndTimeUs, - long elapsedRealtimeMs, - long loadDurationMs, - long bytesLoaded) { + long mediaEndTimeUs) { loadCanceled( - new LoadEventInfo( - dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + loadEventInfo, new MediaLoadData( dataType, trackType, @@ -386,45 +337,42 @@ public void loadCanceled( 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( - DataSpec dataSpec, - Uri uri, - Map> responseHeaders, - int dataType, - long elapsedRealtimeMs, - long loadDurationMs, - long bytesLoaded, - IOException error, - boolean wasCanceled) { + LoadEventInfo loadEventInfo, int dataType, IOException error, boolean wasCanceled) { loadError( - dataSpec, - uri, - responseHeaders, + loadEventInfo, dataType, - C.TRACK_TYPE_UNKNOWN, - null, - C.SELECTION_REASON_UNKNOWN, - null, - C.TIME_UNSET, - C.TIME_UNSET, - elapsedRealtimeMs, - loadDurationMs, - bytesLoaded, + /* trackType= */ C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + /* trackSelectionReason= */ C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ C.TIME_UNSET, + /* mediaEndTimeUs= */ C.TIME_UNSET, error, wasCanceled); } + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ public void loadError( - DataSpec dataSpec, - Uri uri, - Map> responseHeaders, + LoadEventInfo loadEventInfo, int dataType, int trackType, @Nullable Format trackFormat, @@ -432,14 +380,10 @@ public void loadError( @Nullable Object trackSelectionData, long mediaStartTimeUs, long mediaEndTimeUs, - long elapsedRealtimeMs, - long loadDurationMs, - long bytesLoaded, IOException error, boolean wasCanceled) { loadError( - new LoadEventInfo( - dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + loadEventInfo, new MediaLoadData( dataType, trackType, @@ -452,25 +396,26 @@ public void loadError( 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( @@ -483,14 +428,18 @@ public void upstreamDiscarded(int trackType, long mediaStartTimeUs, long mediaEn 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, @@ -508,15 +457,30 @@ public void downstreamFormatChanged( /* 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 e1c52c097b1..204220e334e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceFactory.java @@ -19,13 +19,35 @@ 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 @@ default MediaSourceFactory setStreamKeys(@Nullable List streamKeys) { } /** - * 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 2bba84a7541..0dae1ad6f94 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; @@ -96,8 +98,6 @@ public TrackGroupArray getTrackGroups() { return Assertions.checkNotNull(trackGroups); } - // unboxing a possibly-null reference streamPeriodIndices.get(streams[i]) - @SuppressWarnings("nullness:unboxing.of.nullable") @Override public long selectTracks( @NullableType TrackSelection[] selections, @@ -109,8 +109,8 @@ public long selectTracks( int[] streamChildIndices = new int[selections.length]; int[] selectionChildIndices = new int[selections.length]; for (int i = 0; i < selections.length; i++) { - streamChildIndices[i] = streams[i] == null ? C.INDEX_UNSET - : streamPeriodIndices.get(streams[i]); + Integer streamChildIndex = streams[i] == null ? null : streamPeriodIndices.get(streams[i]); + streamChildIndices[i] = streamChildIndex == null ? C.INDEX_UNSET : streamChildIndex; selectionChildIndices[i] = C.INDEX_UNSET; if (selections[i] != null) { TrackGroup trackGroup = selections[i].getTrackGroup(); @@ -160,8 +160,7 @@ public long selectTracks( // Copy the new streams back into the streams array. System.arraycopy(newStreams, 0, streams, 0, newStreams.length); // Update the local state. - enabledPeriods = new MediaPeriod[enabledPeriodsList.size()]; - enabledPeriodsList.toArray(enabledPeriods); + enabledPeriods = enabledPeriodsList.toArray(new MediaPeriod[0]); compositeSequenceableLoader = compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(enabledPeriods); return positionUs; @@ -445,7 +444,7 @@ public int readData( 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 d69c037a5a5..8df7a639c6f 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 @@ 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 IllegalMergeException(@Reason int reason) { } 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 MergingMediaSource( 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 6cc7c91232a..9efe6acba18 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.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 @@ * * @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 @@ * @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 2cbfeb86603..121eeb940d3 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.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,13 +39,13 @@ 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; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; import com.google.android.exoplayer2.upstream.Loader.Loadable; @@ -88,7 +93,7 @@ interface Listener { * 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(); @@ -99,7 +104,8 @@ interface Listener { 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; @@ -127,7 +133,6 @@ interface Listener { private boolean seenFirstTrackSelection; private boolean notifyDiscontinuity; - private boolean notifiedReadingStarted; private int enabledTrackCount; private long length; @@ -142,9 +147,12 @@ interface Listener { /** * @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 @@ -160,10 +168,11 @@ interface Listener { 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, @@ -171,14 +180,17 @@ public ProgressiveMediaPeriod( 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 = new BundledExtractorsAdapter(extractors); + ProgressiveMediaExtractor progressiveMediaExtractor = + new BundledExtractorsAdapter(extractorsFactory); + this.progressiveMediaExtractor = progressiveMediaExtractor; loadCondition = new ConditionVariable(); maybeFinishPrepareRunnable = this::maybeFinishPrepare; onContinueLoadingRequestedRunnable = @@ -188,14 +200,13 @@ public ProgressiveMediaPeriod( .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() { @@ -210,7 +221,6 @@ public void release() { handler.removeCallbacksAndMessages(null); callback = null; released = true; - eventDispatcher.mediaPeriodReleased(); } @Override @@ -364,10 +374,6 @@ public long getNextLoadPositionUs() { @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } if (notifyDiscontinuity && (loadingFinished || getExtractedSamplesCount() > extractedSamplesCountAtStartOfLoad)) { notifyDiscontinuity = false; @@ -391,8 +397,8 @@ public long getBufferedPositionUs() { 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()); } } } @@ -476,8 +482,7 @@ public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParame } 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); } @@ -490,12 +495,8 @@ public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParame } 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); } @@ -507,7 +508,7 @@ private void maybeNotifyDownstreamFormat(int track) { 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, @@ -552,20 +553,26 @@ public void onLoadCompleted( : largestQueuedTimestampUs + DEFAULT_LAST_SAMPLE_DURATION_US; listener.onSourceInfoRefreshed(durationUs, isSeekable, isLive); } - eventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.dataSource.getLastOpenedUri(), - loadable.dataSource.getLastResponseHeaders(), + StatsDataSource dataSource = loadable.dataSource; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + dataSource.getLastOpenedUri(), + dataSource.getLastResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + dataSource.getBytesRead()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + mediaSourceEventDispatcher.loadCompleted( + loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, /* trackFormat= */ null, C.SELECTION_REASON_UNKNOWN, /* trackSelectionData= */ null, /* mediaStartTimeUs= */ loadable.seekTimeUs, - durationUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.dataSource.getBytesRead()); + durationUs); copyLengthFromLoader(loadable); loadingFinished = true; Assertions.checkNotNull(callback).onContinueLoadingRequested(this); @@ -574,20 +581,26 @@ public void onLoadCompleted( @Override public void onLoadCanceled( ExtractingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) { - eventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.dataSource.getLastOpenedUri(), - loadable.dataSource.getLastResponseHeaders(), + StatsDataSource dataSource = loadable.dataSource; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + dataSource.getLastOpenedUri(), + dataSource.getLastResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + dataSource.getBytesRead()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + mediaSourceEventDispatcher.loadCanceled( + loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, /* trackFormat= */ null, C.SELECTION_REASON_UNKNOWN, /* trackSelectionData= */ null, /* mediaStartTimeUs= */ loadable.seekTimeUs, - durationUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.dataSource.getBytesRead()); + durationUs); if (!released) { copyLengthFromLoader(loadable); for (SampleQueue sampleQueue : sampleQueues) { @@ -607,9 +620,29 @@ public LoadErrorAction onLoadError( IOException error, int errorCount) { copyLengthFromLoader(loadable); + StatsDataSource dataSource = loadable.dataSource; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + dataSource.getLastOpenedUri(), + dataSource.getLastResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + dataSource.getBytesRead()); + MediaLoadData mediaLoadData = + new MediaLoadData( + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeMs= */ C.usToMs(loadable.seekTimeUs), + C.usToMs(durationUs)); LoadErrorAction loadErrorAction; long retryDelayMs = - loadErrorHandlingPolicy.getRetryDelayMsFor(dataType, loadDurationMs, error, errorCount); + loadErrorHandlingPolicy.getRetryDelayMsFor( + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount)); if (retryDelayMs == C.TIME_UNSET) { loadErrorAction = Loader.DONT_RETRY_FATAL; } else /* the load should be retried */ { @@ -621,10 +654,9 @@ public LoadErrorAction onLoadError( : Loader.DONT_RETRY; } - eventDispatcher.loadError( - loadable.dataSpec, - loadable.dataSource.getLastOpenedUri(), - loadable.dataSource.getLastResponseHeaders(), + boolean wasCanceled = !loadErrorAction.isRetry(); + mediaSourceEventDispatcher.loadError( + loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, /* trackFormat= */ null, @@ -632,11 +664,11 @@ public LoadErrorAction onLoadError( /* trackSelectionData= */ null, /* mediaStartTimeUs= */ loadable.seekTimeUs, durationUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.dataSource.getBytesRead(), error, - !loadErrorAction.isRetry()); + wasCanceled); + if (wasCanceled) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } return loadErrorAction; } @@ -680,7 +712,12 @@ private TrackOutput prepareTrackOutput(TrackId id) { return sampleQueues[i]; } } - SampleQueue trackOutput = new SampleQueue(allocator, drmSessionManager, eventDispatcher); + SampleQueue trackOutput = + new SampleQueue( + allocator, + /* playbackLooper= */ handler.getLooper(), + drmSessionManager, + drmEventDispatcher); trackOutput.setUpstreamFormatChangeListener(this); @NullableType TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1); @@ -694,13 +731,13 @@ private TrackOutput prepareTrackOutput(TrackId id) { 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() { @@ -743,6 +780,9 @@ private void maybeFinishPrepare() { 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); @@ -770,22 +810,25 @@ private void startLoading() { loadable.setLoadPosition( Assertions.checkNotNull(seekMap).getSeekPoints(pendingResetPositionUs).first.position, pendingResetPositionUs); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.setStartTimeUs(pendingResetPositionUs); + } pendingResetPositionUs = C.TIME_UNSET; } extractedSamplesCountAtStartOfLoad = getExtractedSamplesCount(); long elapsedRealtimeMs = loader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(dataType)); - eventDispatcher.loadStarted( - loadable.dataSpec, + DataSpec dataSpec = loadable.dataSpec; + mediaSourceEventDispatcher.loadStarted( + new LoadEventInfo(loadable.loadTaskId, dataSpec, elapsedRealtimeMs), C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, /* trackFormat= */ null, C.SELECTION_REASON_UNKNOWN, /* trackSelectionData= */ null, /* mediaStartTimeUs= */ loadable.seekTimeUs, - durationUs, - elapsedRealtimeMs); + durationUs); } /** @@ -866,8 +909,8 @@ private int getExtractedSamplesCount() { 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; } @@ -917,6 +960,7 @@ public int skipData(long positionUs) { /** Loads the media stream and extracts sample data from it. */ /* package */ final class ExtractingLoadable implements Loadable, IcyDataSource.Listener { + private final long loadTaskId; private final Uri uri; private final StatsDataSource dataSource; private final ProgressiveMediaExtractor progressiveMediaExtractor; @@ -948,6 +992,7 @@ public ExtractingLoadable( this.positionHolder = new PositionHolder(); this.pendingExtractorSeek = true; this.length = C.LENGTH_UNSET; + loadTaskId = LoadEventInfo.getNewId(); dataSpec = buildDataSpec(/* position= */ 0); } @@ -977,7 +1022,12 @@ public void load() throws IOException { icyTrackOutput.format(ICY_FORMAT); } progressiveMediaExtractor.init( - extractorDataSource, uri, position, length, extractorOutput); + extractorDataSource, + uri, + dataSource.getResponseHeaders(), + position, + length, + extractorOutput); if (icyHeaders != null) { progressiveMediaExtractor.disableSeekingOnMp3Streams(); @@ -1018,8 +1068,7 @@ public void load() throws IOException { 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 ef9a965e76d..4d7230cc3ae 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.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 Factory(DataSource.Factory dataSourceFactory) { 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 Factory setContinueLoadingCheckIntervalBytes(int continueLoadingCheckInte 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; } @@ -166,7 +171,7 @@ public Factory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManage @Deprecated @Override public ProgressiveMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setSourceUri(uri).build()); + return createMediaSource(new MediaItem.Builder().setUri(uri).build()); } /** @@ -178,18 +183,24 @@ public ProgressiveMediaSource createMediaSource(Uri uri) { */ @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.sourceUri, + 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 int[] getSupportedTypes() { */ 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 int[] getSupportedTypes() { // 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 MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star 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 @@ private void notifySourceInfoRefreshed() { /* 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 7fd95df34f7..797b5ad30bb 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 @@ public void readToBuffer(DecoderInputBuffer buffer, SampleExtrasHolder extrasHol 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 @@ private void readEncryptionData(DecoderInputBuffer buffer, SampleExtrasHolder ex // 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 @@ private void readEncryptionData(DecoderInputBuffer buffer, SampleExtrasHolder ex 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 @@ private void readEncryptionData(DecoderInputBuffer buffer, SampleExtrasHolder ex 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 @@ private void readData(long absolutePosition, ByteBuffer target, int length) { advanceReadTo(absolutePosition); int remaining = length; while (remaining > 0) { - int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + 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 @@ private void readData(long absolutePosition, byte[] target, int length) { advanceReadTo(absolutePosition); int remaining = length; while (remaining > 0) { - int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + int toCopy = min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); Allocation allocation = readAllocationNode.allocation; System.arraycopy( allocation.data, @@ -392,7 +394,7 @@ private int preAppend(int length) { 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 484aca5defd..20d9f445624 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.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,11 +56,13 @@ public interface UpstreamFormatChangedListener { } @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; @@ -76,6 +82,7 @@ public interface UpstreamFormatChangedListener { private int relativeFirstIndex; private int readPosition; + private long startTimeUs; private long largestDiscardedTimestampUs; private long largestQueuedTimestampUs; private boolean isLastSampleQueued; @@ -86,6 +93,8 @@ public interface UpstreamFormatChangedListener { @Nullable private Format upstreamFormat; @Nullable private Format upstreamCommittedFormat; private int upstreamSourceId; + private boolean upstreamAllSamplesAreSyncSamples; + private boolean loggedUnexpectedNonSyncSample; private long sampleOffsetUs; private boolean pendingSplice; @@ -94,18 +103,21 @@ public interface UpstreamFormatChangedListener { * Creates a sample queue. * * @param allocator An {@link Allocator} from which allocations for sample data can be obtained. + * @param playbackLooper The looper associated with the media playback thread. * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} * from. The created instance does not take ownership of this {@link DrmSessionManager}. - * @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) { - sampleDataQueue = new SampleDataQueue(allocator); + 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; sourceIds = new int[capacity]; @@ -115,6 +127,7 @@ public SampleQueue( 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; @@ -151,6 +164,7 @@ public void reset(boolean resetUpstreamFormat) { relativeFirstIndex = 0; readPosition = 0; upstreamKeyframeRequired = true; + startTimeUs = Long.MIN_VALUE; largestDiscardedTimestampUs = Long.MIN_VALUE; largestQueuedTimestampUs = Long.MIN_VALUE; isLastSampleQueued = false; @@ -162,6 +176,16 @@ public void reset(boolean resetUpstreamFormat) { } } + /** + * 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. * @@ -191,6 +215,22 @@ public final void discardUpstreamSamples(int discardFromIndex) { 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. */ @@ -254,6 +294,16 @@ public final synchronized long getLargestQueuedTimestampUs() { 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 @@ -303,13 +353,7 @@ public synchronized boolean isReady(boolean loadingFinished) { * 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 @@ -321,8 +365,6 @@ public synchronized boolean isReady(boolean loadingFinished) { * 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}. */ @@ -331,11 +373,9 @@ public int read( 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); } @@ -353,6 +393,7 @@ public final synchronized boolean seekTo(int sampleIndex) { if (sampleIndex < absoluteFirstIndex || sampleIndex > absoluteFirstIndex + length) { return false; } + startTimeUs = Long.MIN_VALUE; readPosition = sampleIndex - absoluteFirstIndex; return true; } @@ -378,39 +419,45 @@ public final synchronized boolean seekTo(long timeUs, boolean allowTimeBeyondBuf 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; } /** @@ -477,13 +524,15 @@ public final void format(Format unadjustedUpstreamFormat) { } @Override - public final int sampleData(DataReader input, int length, boolean allowEndOfInput) + public final int sampleData( + DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart) throws IOException { return sampleDataQueue.sampleData(input, length, allowEndOfInput); } @Override - public final void sampleData(ParsableByteArray buffer, int length) { + public final void sampleData( + ParsableByteArray buffer, int length, @SampleDataPart int sampleDataPart) { sampleDataQueue.sampleData(buffer, length); } @@ -497,13 +546,39 @@ public void sampleMetadata( 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); } @@ -552,25 +627,9 @@ private synchronized int readSampleMetadata( 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; @@ -582,6 +641,7 @@ private synchronized int readSampleMetadata( } } + int relativeReadIndex = getRelativeIndex(readPosition); if (formatRequired || formats[relativeReadIndex] != downstreamFormat) { onFormatResult(formats[relativeReadIndex], formatHolder); return C.RESULT_FORMAT_READ; @@ -594,7 +654,7 @@ private synchronized int readSampleMetadata( 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()) { @@ -615,16 +675,19 @@ private synchronized boolean setUpstreamFormat(Format format) { // 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( @@ -656,7 +719,7 @@ private synchronized long discardSampleMetadataToEnd() { 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 @@ -670,16 +733,15 @@ private synchronized void commitSample( 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; @@ -741,29 +803,19 @@ private synchronized boolean attemptSplice(long timeUs) { 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); @@ -784,11 +836,13 @@ private boolean hasNextSample() { * @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. @@ -797,16 +851,12 @@ private void onFormatResult(Format newFormat, FormatHolder outputFormatHolder) { // Ensure we acquire the new session before releasing the previous one in case the same session // is being used for both DrmInitData. @Nullable DrmSession previousSession = currentDrmSession; - Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); currentDrmSession = - newDrmInitData != null - ? drmSessionManager.acquireSession(playbackLooper, eventDispatcher, newDrmInitData) - : drmSessionManager.acquirePlaceholderSession( - playbackLooper, MimeTypes.getTrackType(newFormat.sampleMimeType)); + drmSessionManager.acquireSession(playbackLooper, drmEventDispatcher, newFormat); outputFormatHolder.drmSession = currentDrmSession; if (previousSession != null) { - previousSession.release(eventDispatcher); + previousSession.release(drmEventDispatcher); } } @@ -853,6 +903,26 @@ private int findSampleBefore(int relativeStartIndex, int length, long timeUs, bo 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. * @@ -861,7 +931,7 @@ private int findSampleBefore(int relativeStartIndex, int length, long timeUs, bo */ private long discardSamples(int discardCount) { largestDiscardedTimestampUs = - Math.max(largestDiscardedTimestampUs, getLargestTimestamp(discardCount)); + max(largestDiscardedTimestampUs, getLargestTimestamp(discardCount)); length -= discardCount; absoluteFirstIndex += discardCount; relativeFirstIndex += discardCount; @@ -895,7 +965,7 @@ private long getLargestTimestamp(int length) { 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 189c13ef0f6..fb6af1136aa 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 @@ interface Callback { /** * 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 f4fb3762484..26b783f9700 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 static final class Factory { @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 Factory setDurationUs(long durationUs) { /** * 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 Factory setTag(@Nullable Object tag) { 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 SilenceMediaSource createMediaSource() { .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 SilenceMediaSource createMediaSource() { * @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 @@ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferLis /* isDynamic= */ false, /* isLive= */ false, /* manifest= */ null, - tag)); + mediaItem)); } @Override @@ -123,6 +148,22 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star @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 int readData( 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 5b47398dd59..54230a8b4f9 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 @@ 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,20 +46,33 @@ 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. + * @deprecated Use {@link #SinglePeriodTimeline(long, boolean, boolean, boolean, Object, + * MediaItem)} instead. */ + // Provide backwards compatibility. + @SuppressWarnings("deprecation") + @Deprecated public SinglePeriodTimeline( - long durationUs, boolean isSeekable, boolean isDynamic, boolean isLive) { - this(durationUs, isSeekable, isDynamic, isLive, /* manifest= */ null, /* tag= */ null); + long durationUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this( + durationUs, + durationUs, + /* windowPositionInPeriodUs= */ 0, + /* windowDefaultStartPositionUs= */ 0, + isSeekable, + isDynamic, + isLive, + manifest, + tag); } /** @@ -61,7 +83,7 @@ public SinglePeriodTimeline( * @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}. + * @param mediaItem A media item used for {@link Window#mediaItem}. */ public SinglePeriodTimeline( long durationUs, @@ -69,7 +91,7 @@ public SinglePeriodTimeline( boolean isDynamic, boolean isLive, @Nullable Object manifest, - @Nullable Object tag) { + MediaItem mediaItem) { this( durationUs, durationUs, @@ -79,6 +101,38 @@ public SinglePeriodTimeline( 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, + long windowPositionInPeriodUs, + long windowDefaultStartPositionUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this( + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, + periodDurationUs, + windowDurationUs, + windowPositionInPeriodUs, + windowDefaultStartPositionUs, + isSeekable, + isDynamic, + isLive, + manifest, tag); } @@ -96,7 +150,7 @@ public SinglePeriodTimeline( * @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 periodDurationUs, @@ -107,7 +161,7 @@ public SinglePeriodTimeline( boolean isDynamic, boolean isLive, @Nullable Object manifest, - @Nullable Object tag) { + MediaItem mediaItem) { this( /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, @@ -120,7 +174,40 @@ public SinglePeriodTimeline( isDynamic, isLive, manifest, - tag); + 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()); } /** @@ -144,7 +231,7 @@ public SinglePeriodTimeline( * @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 SinglePeriodTimeline( 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 SinglePeriodTimeline( this.isDynamic = isDynamic; this.isLive = isLive; this.manifest = manifest; - this.tag = tag; + this.mediaItem = checkNotNull(mediaItem); } @Override @@ -178,6 +265,7 @@ public int getWindowCount() { 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 Window getWindow(int windowIndex, Window window, long defaultPositionProj } return window.set( Window.SINGLE_WINDOW_UID, - tag, + mediaItem, manifest, presentationStartTimeMs, windowStartTimeMs, @@ -219,7 +307,7 @@ public int getPeriodCount() { @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 8fb5d3887b4..352785d37d9 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 @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; import com.google.android.exoplayer2.upstream.Loader.Loadable; @@ -65,7 +66,6 @@ /* package */ final Format format; /* package */ final boolean treatLoadErrorsAsEndOfStream; - /* package */ boolean notifiedReadingStarted; /* package */ boolean loadingFinished; /* package */ byte @MonotonicNonNull [] sampleData; /* package */ int sampleSize; @@ -90,12 +90,10 @@ public SingleSampleMediaPeriod( tracks = new TrackGroupArray(new TrackGroup(format)); sampleStreams = new ArrayList<>(); loader = new Loader("Loader:SingleSampleMediaPeriod"); - eventDispatcher.mediaPeriodCreated(); } public void release() { loader.release(); - eventDispatcher.mediaPeriodReleased(); } @Override @@ -154,21 +152,21 @@ public boolean continueLoading(long positionUs) { if (transferListener != null) { dataSource.addTransferListener(transferListener); } + SourceLoadable loadable = new SourceLoadable(dataSpec, dataSource); long elapsedRealtimeMs = loader.startLoading( - new SourceLoadable(dataSpec, dataSource), + loadable, /* callback= */ this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA)); eventDispatcher.loadStarted( - dataSpec, + new LoadEventInfo(loadable.loadTaskId, dataSpec, elapsedRealtimeMs), C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, format, C.SELECTION_REASON_UNKNOWN, /* trackSelectionData= */ null, /* mediaStartTimeUs= */ 0, - durationUs, - elapsedRealtimeMs); + durationUs); return true; } @@ -179,10 +177,6 @@ public boolean isLoading() { @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } @@ -217,39 +211,51 @@ public void onLoadCompleted( sampleSize = (int) loadable.dataSource.getBytesRead(); sampleData = Assertions.checkNotNull(loadable.sampleData); loadingFinished = true; + StatsDataSource dataSource = loadable.dataSource; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + dataSource.getLastOpenedUri(), + dataSource.getLastResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + sampleSize); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); eventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.dataSource.getLastOpenedUri(), - loadable.dataSource.getLastResponseHeaders(), + loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, format, C.SELECTION_REASON_UNKNOWN, /* trackSelectionData= */ null, /* mediaStartTimeUs= */ 0, - durationUs, - elapsedRealtimeMs, - loadDurationMs, - sampleSize); + durationUs); } @Override public void onLoadCanceled( SourceLoadable loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) { + StatsDataSource dataSource = loadable.dataSource; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + dataSource.getLastOpenedUri(), + dataSource.getLastResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + dataSource.getBytesRead()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); eventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.dataSource.getLastOpenedUri(), - loadable.dataSource.getLastResponseHeaders(), + loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, /* trackFormat= */ null, C.SELECTION_REASON_UNKNOWN, /* trackSelectionData= */ null, /* mediaStartTimeUs= */ 0, - durationUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.dataSource.getBytesRead()); + durationUs); } @Override @@ -259,9 +265,28 @@ public LoadErrorAction onLoadError( long loadDurationMs, IOException error, int errorCount) { + StatsDataSource dataSource = loadable.dataSource; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + dataSource.getLastOpenedUri(), + dataSource.getLastResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + dataSource.getBytesRead()); + MediaLoadData mediaLoadData = + new MediaLoadData( + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeMs= */ 0, + C.usToMs(durationUs)); long retryDelay = loadErrorHandlingPolicy.getRetryDelayMsFor( - C.DATA_TYPE_MEDIA, loadDurationMs, error, errorCount); + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount)); boolean errorCanBePropagated = retryDelay == C.TIME_UNSET || errorCount @@ -277,10 +302,9 @@ public LoadErrorAction onLoadError( ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelay) : Loader.DONT_RETRY_FATAL; } + boolean wasCanceled = !action.isRetry(); eventDispatcher.loadError( - loadable.dataSpec, - loadable.dataSource.getLastOpenedUri(), - loadable.dataSource.getLastResponseHeaders(), + loadEventInfo, C.DATA_TYPE_MEDIA, C.TRACK_TYPE_UNKNOWN, format, @@ -288,11 +312,11 @@ public LoadErrorAction onLoadError( /* trackSelectionData= */ null, /* mediaStartTimeUs= */ 0, durationUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.dataSource.getBytesRead(), error, - /* wasCanceled= */ !action.isRetry()); + wasCanceled); + if (wasCanceled) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } return action; } @@ -377,6 +401,7 @@ private void maybeNotifyDownstreamFormat() { /* package */ static final class SourceLoadable implements Loadable { + public final long loadTaskId; public final DataSpec dataSpec; private final StatsDataSource dataSource; @@ -384,6 +409,7 @@ private void maybeNotifyDownstreamFormat() { @Nullable private byte[] sampleData; public SourceLoadable(DataSpec dataSpec, DataSource dataSource) { + this.loadTaskId = LoadEventInfo.getNewId(); this.dataSpec = dataSpec; this.dataSource = new StatsDataSource(dataSource); } 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 4365c8fda5c..ab63ed83e6c 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.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 static final class Factory { 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 static final class Factory { * 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 Factory setTag(@Nullable Object tag) { * * @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 Factory setMinLoadableRetryCount(int minLoadableRetryCount) { * * @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 Factory setLoadErrorHandlingPolicy( * 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 Factory setTreatLoadErrorsAsEndOfStream(boolean treatLoadErrorsAsEndOfStr /** * 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 SingleSampleMediaSource createMediaSource( 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 SingleSampleMediaSource( 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 SingleSampleMediaSource( 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 SingleSampleMediaSource( 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 SingleSampleMediaSource( } 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 @@ private SingleSampleMediaSource( /* 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 @@ private static final class EventListenerWrapper implements MediaSourceEventListe 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 dee63d819e2..9493746669c 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 int hashCode() { 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 AdGroup withAdCount(int count) { /** * 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 AdGroup withAllAdsSkipped() { @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 AdGroup withAllAdsSkipped() { @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 @@ private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int 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 int getAdGroupIndexAfterPositionUs(long positionUs, long periodDurationUs 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 int hashCode() { 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 11947218a33..f1c17c10935 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 @@ 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 @@ default void onAdClicked() {} 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 @@ interface AdViewProvider { */ 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 d1b5e84fb48..62c3e2ed173 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -18,9 +18,11 @@ import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +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; @@ -44,11 +46,9 @@ import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * A {@link MediaSource} that inserts ads linearly with a provided content media source. This source @@ -121,7 +121,7 @@ public RuntimeException getRuntimeExceptionForUnexpected() { } // 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; @@ -129,15 +129,13 @@ public RuntimeException getRuntimeExceptionForUnexpected() { private final AdsLoader adsLoader; private final AdsLoader.AdViewProvider adViewProvider; private final Handler mainHandler; - private final Map> maskingMediaPeriodByAdMediaSource; private final Timeline.Period period; // Accessed on the player thread. @Nullable private ComponentListener componentListener; @Nullable private Timeline contentTimeline; @Nullable private AdPlaybackState adPlaybackState; - private @NullableType MediaSource[][] adGroupMediaSources; - private @NullableType Timeline[][] adGroupTimelines; + private @NullableType AdMediaSourceHolder[][] adMediaSourceHolders; /** * Constructs a new source that inserts ads linearly with the content specified by {@code @@ -179,25 +177,33 @@ public AdsMediaSource( this.adsLoader = adsLoader; this.adViewProvider = adViewProvider; mainHandler = new Handler(Looper.getMainLooper()); - maskingMediaPeriodByAdMediaSource = new HashMap<>(); period = new Timeline.Period(); - adGroupMediaSources = new MediaSource[0][]; - adGroupTimelines = new Timeline[0][]; + adMediaSourceHolders = new AdMediaSourceHolder[0][]; adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes()); } + /** + * @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)); } @@ -209,36 +215,22 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star int adIndexInAdGroup = id.adIndexInAdGroup; Uri adUri = Assertions.checkNotNull(adPlaybackState.adGroups[adGroupIndex].uris[adIndexInAdGroup]); - if (adGroupMediaSources[adGroupIndex].length <= adIndexInAdGroup) { + if (adMediaSourceHolders[adGroupIndex].length <= adIndexInAdGroup) { int adCount = adIndexInAdGroup + 1; - adGroupMediaSources[adGroupIndex] = - Arrays.copyOf(adGroupMediaSources[adGroupIndex], adCount); - adGroupTimelines[adGroupIndex] = Arrays.copyOf(adGroupTimelines[adGroupIndex], adCount); - } - MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup]; - if (mediaSource == null) { - mediaSource = adMediaSourceFactory.createMediaSource(adUri); - adGroupMediaSources[adGroupIndex][adIndexInAdGroup] = mediaSource; - maskingMediaPeriodByAdMediaSource.put(mediaSource, new ArrayList<>()); - prepareChildSource(id, mediaSource); + adMediaSourceHolders[adGroupIndex] = + Arrays.copyOf(adMediaSourceHolders[adGroupIndex], adCount); } - MaskingMediaPeriod maskingMediaPeriod = - new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs); - maskingMediaPeriod.setPrepareErrorListener( - new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup)); - List mediaPeriods = maskingMediaPeriodByAdMediaSource.get(mediaSource); - if (mediaPeriods == null) { - Object periodUid = - Assertions.checkNotNull(adGroupTimelines[adGroupIndex][adIndexInAdGroup]) - .getUidOfPeriod(/* periodIndex= */ 0); - MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); - maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); - } else { - // Keep track of the masking media period so it can be populated with the real media period - // when the source's info becomes available. - mediaPeriods.add(maskingMediaPeriod); + @Nullable + AdMediaSourceHolder adMediaSourceHolder = + adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]; + if (adMediaSourceHolder == null) { + MediaSource adMediaSource = + adMediaSourceFactory.createMediaSource(MediaItem.fromUri(adUri)); + adMediaSourceHolder = new AdMediaSourceHolder(adMediaSource); + adMediaSourceHolders[adGroupIndex][adIndexInAdGroup] = adMediaSourceHolder; + prepareChildSource(id, adMediaSource); } - return maskingMediaPeriod; + return adMediaSourceHolder.createMediaPeriod(adUri, id, allocator, startPositionUs); } else { MaskingMediaPeriod mediaPeriod = new MaskingMediaPeriod(contentMediaSource, id, allocator, startPositionUs); @@ -250,12 +242,18 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star @Override public void releasePeriod(MediaPeriod mediaPeriod) { MaskingMediaPeriod maskingMediaPeriod = (MaskingMediaPeriod) mediaPeriod; - List mediaPeriods = - maskingMediaPeriodByAdMediaSource.get(maskingMediaPeriod.mediaSource); - if (mediaPeriods != null) { - mediaPeriods.remove(maskingMediaPeriod); + MediaPeriodId id = maskingMediaPeriod.id; + if (id.isAd()) { + AdMediaSourceHolder adMediaSourceHolder = + Assertions.checkNotNull(adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup]); + adMediaSourceHolder.releaseMediaPeriod(maskingMediaPeriod); + if (adMediaSourceHolder.isInactive()) { + releaseChildSource(id); + adMediaSourceHolders[id.adGroupIndex][id.adIndexInAdGroup] = null; + } + } else { + maskingMediaPeriod.releasePeriod(); } - maskingMediaPeriod.releasePeriod(); } @Override @@ -263,11 +261,9 @@ protected void releaseSourceInternal() { super.releaseSourceInternal(); Assertions.checkNotNull(componentListener).release(); componentListener = null; - maskingMediaPeriodByAdMediaSource.clear(); contentTimeline = null; adPlaybackState = null; - adGroupMediaSources = new MediaSource[0][]; - adGroupTimelines = new Timeline[0][]; + adMediaSourceHolders = new AdMediaSourceHolder[0][]; mainHandler.post(adsLoader::stop); } @@ -277,17 +273,20 @@ protected void onChildSourceInfoRefreshed( if (mediaPeriodId.isAd()) { int adGroupIndex = mediaPeriodId.adGroupIndex; int adIndexInAdGroup = mediaPeriodId.adIndexInAdGroup; - onAdSourceInfoRefreshed(mediaSource, adGroupIndex, adIndexInAdGroup, timeline); + Assertions.checkNotNull(adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]) + .handleSourceInfoRefresh(timeline); } else { - onContentSourceInfoRefreshed(timeline); + Assertions.checkArgument(timeline.getPeriodCount() == 1); + contentTimeline = timeline; } + maybeUpdateSourceInfo(); } @Override - protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( MediaPeriodId childId, MediaPeriodId mediaPeriodId) { - // The child id for the content period is just DUMMY_CONTENT_MEDIA_PERIOD_ID. That's why we need - // to forward the reported mediaPeriodId in this case. + // 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; } @@ -295,42 +294,17 @@ protected void onChildSourceInfoRefreshed( private void onAdPlaybackState(AdPlaybackState adPlaybackState) { if (this.adPlaybackState == null) { - adGroupMediaSources = new MediaSource[adPlaybackState.adGroupCount][]; - Arrays.fill(adGroupMediaSources, new MediaSource[0]); - adGroupTimelines = new Timeline[adPlaybackState.adGroupCount][]; - Arrays.fill(adGroupTimelines, new Timeline[0]); + adMediaSourceHolders = new AdMediaSourceHolder[adPlaybackState.adGroupCount][]; + Arrays.fill(adMediaSourceHolders, new AdMediaSourceHolder[0]); } this.adPlaybackState = adPlaybackState; maybeUpdateSourceInfo(); } - private void onContentSourceInfoRefreshed(Timeline timeline) { - Assertions.checkArgument(timeline.getPeriodCount() == 1); - contentTimeline = timeline; - maybeUpdateSourceInfo(); - } - - private void onAdSourceInfoRefreshed(MediaSource mediaSource, int adGroupIndex, - int adIndexInAdGroup, Timeline timeline) { - Assertions.checkArgument(timeline.getPeriodCount() == 1); - adGroupTimelines[adGroupIndex][adIndexInAdGroup] = timeline; - List mediaPeriods = maskingMediaPeriodByAdMediaSource.remove(mediaSource); - if (mediaPeriods != null) { - Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); - for (int i = 0; i < mediaPeriods.size(); i++) { - MaskingMediaPeriod mediaPeriod = mediaPeriods.get(i); - MediaPeriodId adSourceMediaPeriodId = - new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); - mediaPeriod.createPeriod(adSourceMediaPeriodId); - } - } - maybeUpdateSourceInfo(); - } - private void maybeUpdateSourceInfo() { - Timeline contentTimeline = this.contentTimeline; + @Nullable Timeline contentTimeline = this.contentTimeline; if (adPlaybackState != null && contentTimeline != null) { - adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurations(adGroupTimelines, period)); + adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurationsUs()); Timeline timeline = adPlaybackState.adGroupCount == 0 ? contentTimeline @@ -339,19 +313,16 @@ private void maybeUpdateSourceInfo() { } } - private static long[][] getAdDurations( - @NullableType Timeline[][] adTimelines, Timeline.Period period) { - long[][] adDurations = new long[adTimelines.length][]; - for (int i = 0; i < adTimelines.length; i++) { - adDurations[i] = new long[adTimelines[i].length]; - for (int j = 0; j < adTimelines[i].length; j++) { - adDurations[i][j] = - adTimelines[i][j] == null - ? C.TIME_UNSET - : adTimelines[i][j].getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + private long[][] getAdDurationsUs() { + long[][] adDurationsUs = new long[adMediaSourceHolders.length][]; + for (int i = 0; i < adMediaSourceHolders.length; i++) { + adDurationsUs[i] = new long[adMediaSourceHolders[i].length]; + for (int j = 0; j < adMediaSourceHolders[i].length; j++) { + @Nullable AdMediaSourceHolder holder = adMediaSourceHolders[i][j]; + adDurationsUs[i][j] = holder == null ? C.TIME_UNSET : holder.getDurationUs(); } } - return adDurations; + return adDurationsUs; } /** Listener for component events. All methods are called on the main thread. */ @@ -366,7 +337,7 @@ private final class ComponentListener implements AdsLoader.EventListener { * events on the external event listener thread. */ public ComponentListener() { - playerHandler = Util.createHandler(); + playerHandler = Util.createHandlerForCurrentLooper(); } /** Releases the component listener. */ @@ -396,45 +367,103 @@ public void onAdLoadError(final AdLoadException error, DataSpec dataSpec) { } createEventDispatcher(/* mediaPeriodId= */ null) .loadError( - dataSpec, - dataSpec.uri, - /* responseHeaders= */ Collections.emptyMap(), + new LoadEventInfo( + LoadEventInfo.getNewId(), + dataSpec, + /* elapsedRealtimeMs= */ SystemClock.elapsedRealtime()), C.DATA_TYPE_AD, - C.TRACK_TYPE_UNKNOWN, - /* loadDurationMs= */ 0, - /* bytesLoaded= */ 0, error, /* wasCanceled= */ true); } } - 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 DataSpec(adUri), - adUri, - /* responseHeaders= */ Collections.emptyMap(), + new LoadEventInfo( + LoadEventInfo.getNewId(), + new DataSpec(adUri), + /* elapsedRealtimeMs= */ SystemClock.elapsedRealtime()), C.DATA_TYPE_AD, - C.TRACK_TYPE_UNKNOWN, - /* loadDurationMs= */ 0, - /* bytesLoaded= */ 0, AdLoadException.createForAd(exception), /* wasCanceled= */ true); mainHandler.post( - () -> adsLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception)); + () -> + adsLoader.handlePrepareError( + mediaPeriodId.adGroupIndex, mediaPeriodId.adIndexInAdGroup, exception)); + } + } + + private final class AdMediaSourceHolder { + + private final MediaSource adMediaSource; + private final List activeMediaPeriods; + + private @MonotonicNonNull Timeline timeline; + + public AdMediaSourceHolder(MediaSource adMediaSource) { + this.adMediaSource = adMediaSource; + activeMediaPeriods = new ArrayList<>(); + } + + public MediaPeriod createMediaPeriod( + Uri adUri, MediaPeriodId id, Allocator allocator, long startPositionUs) { + MaskingMediaPeriod maskingMediaPeriod = + new MaskingMediaPeriod(adMediaSource, id, allocator, startPositionUs); + maskingMediaPeriod.setPrepareListener(new AdPrepareListener(adUri)); + activeMediaPeriods.add(maskingMediaPeriod); + if (timeline != null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); + maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); + } + return maskingMediaPeriod; + } + + public void handleSourceInfoRefresh(Timeline timeline) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + if (this.timeline == null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + for (int i = 0; i < activeMediaPeriods.size(); i++) { + MaskingMediaPeriod mediaPeriod = activeMediaPeriods.get(i); + MediaPeriodId adSourceMediaPeriodId = + new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); + mediaPeriod.createPeriod(adSourceMediaPeriodId); + } + } + this.timeline = timeline; + } + + public long getDurationUs() { + return timeline == null + ? C.TIME_UNSET + : timeline.getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + } + + public void releaseMediaPeriod(MaskingMediaPeriod maskingMediaPeriod) { + activeMediaPeriods.remove(maskingMediaPeriod); + maskingMediaPeriod.releasePeriod(); + } + + public boolean isInactive() { + return activeMediaPeriods.isEmpty(); } } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java index b5167dc1736..cc82510a29f 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 SinglePeriodAdTimeline(Timeline contentTimeline, AdPlaybackState adPlayba @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 50c37f8b311..961d1f8db66 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 @@ 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 70% 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 76a4665d779..f02329d5d5e 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 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 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 interface TrackOutputProvider { 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 void init( } } + @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 @@ private static final class BindingTrackOutput implements TrackOutput { 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 BindingTrackOutput(int id, int type, @Nullable Format manifestFormat) { 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; @@ -203,13 +187,14 @@ public void format(Format format) { } @Override - public int sampleData(DataReader input, int length, boolean allowEndOfInput) + public int sampleData( + DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart) throws IOException { return castNonNull(trackOutput).sampleData(input, length, allowEndOfInput); } @Override - public void sampleData(ParsableByteArray data, int length) { + public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) { castNonNull(trackOutput).sampleData(data, length); } @@ -221,7 +206,7 @@ public void sampleMetadata( 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/Chunk.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/Chunk.java index 25877d12d64..2d2d74718b9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/Chunk.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/Chunk.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.LoadEventInfo; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.Loader.Loadable; @@ -33,9 +34,9 @@ */ public abstract class Chunk implements Loadable { - /** - * The {@link DataSpec} that defines the data to be loaded. - */ + /** Identifies the load task for this loadable. */ + public final long loadTaskId; + /** The {@link DataSpec} that defines the data to be loaded. */ public final DataSpec dataSpec; /** * The type of the chunk. One of the {@code DATA_TYPE_*} constants defined in {@link C}. For @@ -95,6 +96,7 @@ public Chunk( this.trackSelectionData = trackSelectionData; this.startTimeUs = startTimeUs; this.endTimeUs = endTimeUs; + loadTaskId = LoadEventInfo.getNewId(); } /** 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 00000000000..6bfe9590db6 --- /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 fe7c583c71e..1a451cb0c34 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,11 @@ */ 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; import com.google.android.exoplayer2.Format; @@ -22,13 +27,17 @@ 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.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; +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; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; import com.google.android.exoplayer2.util.Assertions; @@ -67,7 +76,7 @@ public interface ReleaseCallback { 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; @@ -77,13 +86,14 @@ public interface ReleaseCallback { 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; /** @@ -99,8 +109,10 @@ public interface ReleaseCallback { * @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, @@ -111,14 +123,15 @@ public ChunkSampleStream( 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(); @@ -131,14 +144,22 @@ public ChunkSampleStream( int[] trackTypes = new int[1 + embeddedTrackCount]; SampleQueue[] sampleQueues = new SampleQueue[1 + embeddedTrackCount]; - primarySampleQueue = new SampleQueue(allocator, drmSessionManager, eventDispatcher); + primarySampleQueue = + new SampleQueue( + allocator, + /* playbackLooper= */ checkNotNull(Looper.myLooper()), + drmSessionManager, + drmEventDispatcher); trackTypes[0] = primaryTrackType; sampleQueues[0] = primarySampleQueue; for (int i = 0; i < embeddedTrackCount; i++) { SampleQueue sampleQueue = new SampleQueue( - allocator, DrmSessionManager.getDummyDrmSessionManager(), eventDispatcher); + allocator, + /* playbackLooper= */ checkNotNull(Looper.myLooper()), + DrmSessionManager.getDummyDrmSessionManager(), + drmEventDispatcher); embeddedSampleQueues[i] = sampleQueue; sampleQueues[i + 1] = sampleQueue; trackTypes[i + 1] = this.embeddedTrackTypes[i]; @@ -220,9 +241,9 @@ public long getBufferedPositionUs() { 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()); } } @@ -270,14 +291,12 @@ public void seekToUs(long positionUs) { 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) { @@ -299,10 +318,7 @@ public void seekToUs(long positionUs) { loader.cancelLoading(); } else { loader.clearFatalError(); - primarySampleQueue.reset(); - for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { - embeddedSampleQueue.reset(); - } + resetSampleQueues(); } } } @@ -342,6 +358,7 @@ public void onLoaderReleased() { for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { embeddedSampleQueue.release(); } + chunkSource.release(); if (releaseCallback != null) { releaseCallback.onSampleStreamReleased(this); } @@ -369,10 +386,16 @@ public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, 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 @@ -380,12 +403,16 @@ public int skipData(long positionUs) { 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; } @@ -394,45 +421,63 @@ public int skipData(long positionUs) { @Override public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + loadingChunk = null; chunkSource.onChunkLoadCompleted(loadable); - eventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + mediaSourceEventDispatcher.loadCompleted( + loadEventInfo, loadable.type, primaryTrackType, loadable.trackFormat, loadable.trackSelectionReason, loadable.trackSelectionData, loadable.startTimeUs, - loadable.endTimeUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + loadable.endTimeUs); callback.onContinueLoadingRequested(this); } @Override public void onLoadCanceled( Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) { - eventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), + loadingChunk = null; + canceledMediaChunk = null; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + mediaSourceEventDispatcher.loadCanceled( + loadEventInfo, loadable.type, primaryTrackType, loadable.trackFormat, loadable.trackSelectionReason, loadable.trackSelectionData, loadable.startTimeUs, - loadable.endTimeUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + 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); } @@ -450,13 +495,33 @@ public LoadErrorAction onLoadError( int lastChunkIndex = mediaChunks.size() - 1; boolean cancelable = bytesLoaded == 0 || !isMediaChunk || !haveReadFromMediaChunk(lastChunkIndex); - long blacklistDurationMs = + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded); + MediaLoadData mediaLoadData = + new MediaLoadData( + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + C.usToMs(loadable.startTimeUs), + C.usToMs(loadable.endTimeUs)); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); + + long exclusionDurationMs = cancelable - ? loadErrorHandlingPolicy.getBlacklistDurationMsFor( - loadable.type, loadDurationMs, error, errorCount) + ? 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) { @@ -473,9 +538,7 @@ public LoadErrorAction onLoadError( if (loadErrorAction == null) { // The load was not cancelled. Either the load must be retried or the error propagated. - long retryDelayMs = - loadErrorHandlingPolicy.getRetryDelayMsFor( - loadable.type, loadDurationMs, error, errorCount); + long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo); loadErrorAction = retryDelayMs != C.TIME_UNSET ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs) @@ -483,10 +546,8 @@ public LoadErrorAction onLoadError( } boolean canceled = !loadErrorAction.isRetry(); - eventDispatcher.loadError( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), + mediaSourceEventDispatcher.loadError( + loadEventInfo, loadable.type, primaryTrackType, loadable.trackFormat, @@ -494,12 +555,11 @@ public LoadErrorAction onLoadError( loadable.trackSelectionData, loadable.startTimeUs, loadable.endTimeUs, - elapsedRealtimeMs, - loadDurationMs, - bytesLoaded, error, canceled); if (canceled) { + loadingChunk = null; + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); callback.onContinueLoadingRequested(this); } return loadErrorAction; @@ -538,12 +598,20 @@ public boolean continueLoading(long positionUs) { 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); @@ -554,16 +622,15 @@ public boolean continueLoading(long positionUs) { long elapsedRealtimeMs = loader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); - eventDispatcher.loadStarted( - loadable.dataSpec, + mediaSourceEventDispatcher.loadStarted( + new LoadEventInfo(loadable.loadTaskId, loadable.dataSpec, elapsedRealtimeMs), loadable.type, primaryTrackType, loadable.trackFormat, loadable.trackSelectionReason, loadable.trackSelectionData, loadable.startTimeUs, - loadable.endTimeUs, - elapsedRealtimeMs); + loadable.endTimeUs); return true; } @@ -583,24 +650,46 @@ public long getNextLoadPositionUs() { @Override public void reevaluateBuffer(long positionUs) { - if (loader.isLoading() || loader.hasFatalError() || isPendingReset()) { + if (loader.hasFatalError() || isPendingReset()) { return; } - int currentQueueSize = mediaChunks.size(); - int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); - if (currentQueueSize <= preferredQueueSize) { + 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 newQueueSize = currentQueueSize; + 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 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; } @@ -610,15 +699,21 @@ public void reevaluateBuffer(long positionUs) { 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); @@ -642,7 +737,7 @@ private void discardDownstreamMediaChunks(int discardToSampleIndex) { 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; @@ -663,8 +758,11 @@ private void maybeNotifyPrimaryTrackFormatChanged(int mediaChunkReadIndex) { 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; @@ -706,7 +804,7 @@ private BaseMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) { 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)); @@ -742,12 +840,18 @@ public int skipData(long positionUs) { 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; } @@ -763,13 +867,15 @@ public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, 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() { @@ -779,7 +885,7 @@ public void release() { 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 b119cad5b06..52756b378f0 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 @@ void getNextChunk( * 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 @@ void getNextChunk( * 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 1b43af2084c..b8938deac4a 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.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 @@ */ 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 ContainerMediaChunk( long chunkIndex, int chunkCount, long sampleOffsetUs, - ChunkExtractorWrapper extractorWrapper) { + ChunkExtractor chunkExtractor) { super( dataSource, dataSpec, @@ -90,7 +86,7 @@ public ContainerMediaChunk( chunkIndex); this.chunkCount = chunkCount; this.sampleOffsetUs = sampleOffsetUs; - this.extractorWrapper = extractorWrapper; + this.chunkExtractor = chunkExtractor; } @Override @@ -117,7 +113,7 @@ public final void load() throws IOException { // 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 final void load() throws IOException { 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 eedcad30725..944b25395a2 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.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,11 +33,9 @@ */ public final class InitializationChunk extends Chunk { - private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + private final ChunkExtractor chunkExtractor; - private final ChunkExtractorWrapper extractorWrapper; - - @MonotonicNonNull private TrackOutputProvider trackOutputProvider; + private @MonotonicNonNull TrackOutputProvider trackOutputProvider; private long nextLoadPosition; private volatile boolean loadCanceled; @@ -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 InitializationChunk( 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 void cancelLoad() { @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 void load() throws IOException { 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 e63bf574a8a..268133ad401 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 @@ 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 Cue(CharSequence text) { * @param size See {@link #size}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue( CharSequence text, @@ -346,6 +342,7 @@ public Cue( * @param textSize See {@link #textSize}. * @deprecated Use {@link Builder}. */ + @SuppressWarnings("deprecation") @Deprecated public Cue( CharSequence text, @@ -460,6 +457,11 @@ private Cue( this.verticalType = verticalType; } + /** Returns a new {@link Cue.Builder} initialized with the same values as this Cue. */ + public Builder buildUpon() { + return new Cue.Builder(this); + } + /** A builder for {@link Cue} objects. */ public static final class Builder { @Nullable private CharSequence text; @@ -496,6 +498,24 @@ public Builder() { verticalType = TYPE_UNSET; } + private Builder(Cue cue) { + text = cue.text; + bitmap = cue.bitmap; + textAlignment = cue.textAlignment; + line = cue.line; + lineType = cue.lineType; + lineAnchor = cue.lineAnchor; + position = cue.position; + positionAnchor = cue.positionAnchor; + textSizeType = cue.textSizeType; + textSize = cue.textSize; + size = cue.size; + bitmapHeight = cue.bitmapHeight; + windowColorSet = cue.windowColorSet; + windowColor = cue.windowColor; + verticalType = cue.verticalType; + } + /** * Sets the cue text. * @@ -561,41 +581,8 @@ public Alignment getTextAlignment() { } /** - * 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 @@ -629,10 +616,6 @@ public int getLineType() { /** * 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) { @@ -654,10 +637,6 @@ public int getLineAnchor() { * 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) { @@ -678,10 +657,6 @@ public float getPosition() { /** * 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) { @@ -784,6 +759,12 @@ public Builder setWindowColor(@ColorInt int windowColor) { 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 b2357063701..7de577f18cc 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 SubtitleDecoderException(String message) { 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/SubtitleDecoderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java index 927ee8be5ea..bd652c65863 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java @@ -108,7 +108,10 @@ public SubtitleDecoder createDecoder(Format format) { return new Tx3gDecoder(format.initializationData); case MimeTypes.APPLICATION_CEA608: case MimeTypes.APPLICATION_MP4CEA608: - return new Cea608Decoder(mimeType, format.accessibilityChannel); + return new Cea608Decoder( + mimeType, + format.accessibilityChannel, + Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS); case MimeTypes.APPLICATION_CEA708: return new Cea708Decoder(format.accessibilityChannel, format.initializationData); case MimeTypes.APPLICATION_DVBSUBS: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/TextOutput.java b/library/core/src/main/java/com/google/android/exoplayer2/text/TextOutput.java index aa3b4e55579..a039255fa9a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/TextOutput.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/TextOutput.java @@ -23,9 +23,9 @@ public interface TextOutput { /** - * Called when there is a change in the {@link Cue}s. + * Called when there is a change in the {@link Cue Cues}. * - * @param cues The {@link Cue}s. May be empty. + * @param cues The {@link Cue Cues}. May be empty. */ void onCues(List cues); } 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 b8b4d7de6e0..6c140c74d17 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.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 TextRenderer(TextOutput output, @Nullable Looper outputLooper) { 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 String getName() { 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 int supportsFormat(Format format) { } @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 void render(long positionUs, long elapsedRealtimeUs) { } 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 void render(long positionUs, long elapsedRealtimeUs) { 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 void render(long positionUs, long elapsedRealtimeUs) { 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 void render(long positionUs, long elapsedRealtimeUs) { 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 void render(long positionUs, long elapsedRealtimeUs) { 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 @@ private void releaseBuffers() { 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 @@ private void invokeUpdateOutputInternal(List cues) { */ 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 cce1bf62703..97890d7ee52 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; @@ -26,25 +28,33 @@ import android.text.style.UnderlineSpan; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.Subtitle; import com.google.android.exoplayer2.text.SubtitleDecoder; +import com.google.android.exoplayer2.text.SubtitleDecoderException; import com.google.android.exoplayer2.text.SubtitleInputBuffer; +import com.google.android.exoplayer2.text.SubtitleOutputBuffer; 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; import java.util.List; import org.checkerframework.checker.nullness.compatqual.NullableType; -/** - * A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608"). - */ +/** A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608"). */ public final class Cea608Decoder extends CeaDecoder { + /** + * The minimum value for the {@code validDataChannelTimeoutMs} constructor parameter permitted by + * ANSI/CTA-608-E R-2014 Annex C.9. + */ + public static final long MIN_DATA_CHANNEL_TIMEOUT_MS = 16_000; + private static final String TAG = "Cea608Decoder"; private static final int CC_VALID_FLAG = 0x04; @@ -237,6 +247,7 @@ public final class Cea608Decoder extends CeaDecoder { private final int packetLength; private final int selectedField; private final int selectedChannel; + private final long validDataChannelTimeoutUs; private final ArrayList cueBuilders; private CueBuilder currentCueBuilder; @@ -257,11 +268,26 @@ public final class Cea608Decoder extends CeaDecoder { // service bytes and drops the rest. private boolean isInCaptionService; - public Cea608Decoder(String mimeType, int accessibilityChannel) { + private long lastCueUpdateUs; + + /** + * Constructs an instance. + * + * @param mimeType The MIME type of the CEA-608 data. + * @param accessibilityChannel The Accessibility channel, or {@link + * com.google.android.exoplayer2.Format#NO_VALUE} if unknown. + * @param validDataChannelTimeoutMs The timeout (in milliseconds) permitted by ANSI/CTA-608-E + * R-2014 Annex C.9 to clear "stuck" captions where no removal control code is received. The + * timeout should be at least {@link #MIN_DATA_CHANNEL_TIMEOUT_MS} or {@link C#TIME_UNSET} for + * no timeout. + */ + public Cea608Decoder(String mimeType, int accessibilityChannel, long validDataChannelTimeoutMs) { ccData = new ParsableByteArray(); cueBuilders = new ArrayList<>(); currentCueBuilder = new CueBuilder(CC_MODE_UNKNOWN, DEFAULT_CAPTIONS_ROW_COUNT); currentChannel = NTSC_CC_CHANNEL_1; + this.validDataChannelTimeoutUs = + validDataChannelTimeoutMs > 0 ? validDataChannelTimeoutMs * 1000 : C.TIME_UNSET; packetLength = MimeTypes.APPLICATION_MP4CEA608.equals(mimeType) ? 2 : 3; switch (accessibilityChannel) { case 1: @@ -289,6 +315,7 @@ public Cea608Decoder(String mimeType, int accessibilityChannel) { setCaptionMode(CC_MODE_UNKNOWN); resetCueBuilders(); isInCaptionService = true; + lastCueUpdateUs = C.TIME_UNSET; } @Override @@ -310,6 +337,7 @@ public void flush() { repeatableControlCc2 = 0; currentChannel = NTSC_CC_CHANNEL_1; isInCaptionService = true; + lastCueUpdateUs = C.TIME_UNSET; } @Override @@ -317,6 +345,26 @@ public void release() { // Do nothing } + @Nullable + @Override + public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderException { + SubtitleOutputBuffer outputBuffer = super.dequeueOutputBuffer(); + if (outputBuffer != null) { + return outputBuffer; + } + if (shouldClearStuckCaptions()) { + outputBuffer = getAvailableOutputBuffer(); + if (outputBuffer != null) { + cues = Collections.emptyList(); + lastCueUpdateUs = C.TIME_UNSET; + Subtitle subtitle = createSubtitle(); + outputBuffer.setContent(getPositionUs(), subtitle, Format.OFFSET_SAMPLE_RELATIVE); + return outputBuffer; + } + } + return null; + } + @Override protected boolean isNewSubtitleDataAvailable() { return cues != lastCues; @@ -423,6 +471,7 @@ protected void decode(SubtitleInputBuffer inputBuffer) { if (captionDataProcessed) { if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { cues = getDisplayCues(); + lastCueUpdateUs = getPositionUs(); } } } @@ -582,7 +631,7 @@ private List getDisplayCues() { @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); } } @@ -822,14 +871,18 @@ public void backspace() { } 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); } @@ -837,14 +890,17 @@ public void rollUp() { @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. @@ -852,8 +908,6 @@ public Cue build(@Cue.AnchorType int forcedPositionAnchor) { } 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; @@ -891,31 +945,29 @@ public Cue build(@Cue.AnchorType int forcedPositionAnchor) { 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() { @@ -1018,4 +1070,12 @@ public CueStyle(int style, boolean underline, int start) { } + /** See ANSI/CTA-608-E R-2014 Annex C.9 for Caption Erase Logic. */ + private boolean shouldClearStuckCaptions() { + if (validDataChannelTimeoutUs == C.TIME_UNSET || lastCueUpdateUs == C.TIME_UNSET) { + return false; + } + long elapsedUs = getPositionUs() - lastCueUpdateUs; + return elapsedUs >= validDataChannelTimeoutUs; + } } 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 182fe7a2fec..8bd46fabdc6 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 Cea708CueInfo( 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 03a72555382..81ef58a7129 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 @@ public void queueInputBuffer(SubtitleInputBuffer inputBuffer) throws SubtitleDec 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 @@ public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderExceptio 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 @@ public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderExceptio 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 @@ public void flush() { @Override public void release() { - // Do nothing + // Do nothing. } /** @@ -179,6 +170,15 @@ public void release() { */ protected abstract void decode(SubtitleInputBuffer inputBuffer); + @Nullable + protected final SubtitleOutputBuffer getAvailableOutputBuffer() { + return availableOutputBuffers.pollFirst(); + } + + protected final long getPositionUs() { + return playbackPositionUs; + } + private static final class CeaInputBuffer extends SubtitleInputBuffer implements Comparable { 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 55666718da4..5cdbfdf72ec 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 @@ public List decode(byte[] data, int limit) { + 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 fe8bf12d476..163cc6b12bf 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 @@ private void maybeInflateData(ParsableByteArray buffer) { 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 @@ private void parseBitmapSection(ParsableByteArray buffer, int sectionLength) { 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 b963b604792..f44db4924f8 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; @@ -262,8 +263,9 @@ private void parseDialogueLine( SsaStyle.Overrides styleOverrides = SsaStyle.Overrides.parseFromDialogue(rawText); String text = SsaStyle.Overrides.stripStyleOverrides(rawText) - .replaceAll("\\\\N", "\n") - .replaceAll("\\\\n", "\n"); + .replace("\\N", "\n") + .replace("\\n", "\n") + .replace("\\h", "\u00A0"); Cue cue = createCue(text, style, styleOverrides, screenWidth, screenHeight); int startTimeIndex = addCuePlacerholderByTime(startTimeUs, cueTimesUs, cues); @@ -299,6 +301,8 @@ private static Cue createCue( 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 +311,22 @@ private static Cue createCue( } 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 51f59732615..efbf3ab64fb 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 @@ protected Subtitle decode(byte[] bytes, int length, boolean reset) { continue; } - // Parse the index line as a sanity check. + // Parse and check the index line. try { Integer.parseInt(currentLine); } catch (NumberFormatException e) { @@ -135,8 +135,7 @@ protected Subtitle decode(byte[] bytes, int length, boolean reset) { cues.add(Cue.EMPTY); } - Cue[] cuesArray = new Cue[cues.size()]; - cues.toArray(cuesArray); + Cue[] cuesArray = cues.toArray(new Cue[0]); long[] cueTimesUsArray = cueTimesUs.toArray(); return new SubripSubtitle(cuesArray, cueTimesUsArray); } @@ -174,61 +173,54 @@ private String processLine(String line, ArrayList tags) { * @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 80009d4aacb..611eb7ff2f0 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 @@ -184,7 +184,7 @@ protected Subtitle decode(byte[] bytes, int length, boolean reset) } } - private FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser) + private static FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser) throws SubtitleDecoderException { int frameRate = DEFAULT_FRAME_RATE; String frameRateString = xmlParser.getAttributeValue(TTP, "frameRate"); @@ -218,8 +218,8 @@ private FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser) return new FrameAndTickRate(frameRate * frameRateMultiplier, subFrameRate, tickRate); } - private CellResolution parseCellResolution(XmlPullParser xmlParser, CellResolution defaultValue) - throws SubtitleDecoderException { + private static CellResolution parseCellResolution( + XmlPullParser xmlParser, CellResolution defaultValue) throws SubtitleDecoderException { String cellResolution = xmlParser.getAttributeValue(TTP, "cellResolution"); if (cellResolution == null) { return defaultValue; @@ -244,7 +244,7 @@ private CellResolution parseCellResolution(XmlPullParser xmlParser, CellResoluti } @Nullable - private TtsExtent parseTtsExtent(XmlPullParser xmlParser) { + private static TtsExtent parseTtsExtent(XmlPullParser xmlParser) { @Nullable String ttsExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); if (ttsExtent == null) { @@ -266,7 +266,7 @@ private TtsExtent parseTtsExtent(XmlPullParser xmlParser) { } } - private Map parseHeader( + private static Map parseHeader( XmlPullParser xmlParser, Map globalStyles, CellResolution cellResolution, @@ -301,7 +301,7 @@ private Map parseHeader( return globalStyles; } - private void parseMetadata(XmlPullParser xmlParser, Map imageMap) + private static void parseMetadata(XmlPullParser xmlParser, Map imageMap) throws IOException, XmlPullParserException { do { xmlParser.next(); @@ -324,7 +324,7 @@ private void parseMetadata(XmlPullParser xmlParser, Map imageMap * returned. */ @Nullable - private TtmlRegion parseRegionAttributes( + private static TtmlRegion parseRegionAttributes( XmlPullParser xmlParser, CellResolution cellResolution, @Nullable TtsExtent ttsExtent) { @Nullable String regionId = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_ID); if (regionId == null) { @@ -444,6 +444,26 @@ private TtmlRegion parseRegionAttributes( } 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,16 +473,17 @@ private TtmlRegion parseRegionAttributes( width, height, /* textSizeType= */ Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING, - /* textSize= */ regionTextHeight); + /* textSize= */ regionTextHeight, + verticalType); } - private String[] parseStyleIds(String parentStyleIds) { + private static String[] parseStyleIds(String parentStyleIds) { parentStyleIds = parentStyleIds.trim(); return parentStyleIds.isEmpty() ? new String[0] : Util.split(parentStyleIds, "\\s+"); } - @PolyNull - private TtmlStyle parseStyleAttributes(XmlPullParser parser, @PolyNull TtmlStyle style) { + private static @PolyNull TtmlStyle parseStyleAttributes( + XmlPullParser parser, @PolyNull TtmlStyle style) { int attributeCount = parser.getAttributeCount(); for (int i = 0; i < attributeCount; i++) { String attributeValue = parser.getAttributeValue(i); @@ -588,21 +609,6 @@ private TtmlStyle parseStyleAttributes(XmlPullParser parser, @PolyNull TtmlStyle 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; @@ -611,11 +617,11 @@ private TtmlStyle parseStyleAttributes(XmlPullParser parser, @PolyNull TtmlStyle return style; } - private TtmlStyle createIfNull(@Nullable TtmlStyle style) { + private static TtmlStyle createIfNull(@Nullable TtmlStyle style) { return style == null ? new TtmlStyle() : style; } - private TtmlNode parseNode( + private static TtmlNode parseNode( XmlPullParser parser, @Nullable TtmlNode parent, Map regionMap, 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 c8e9ed7ce0a..8e516dedf18 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 @@ -120,7 +120,7 @@ private final HashMap nodeStartsByRegion; private final HashMap nodeEndsByRegion; - @MonotonicNonNull private List children; + private @MonotonicNonNull List children; public static TtmlNode buildTextNode(String text) { return new TtmlNode( @@ -268,6 +268,7 @@ public List getCues( .setLineAnchor(region.lineAnchor) .setSize(region.width) .setBitmapHeight(region.height) + .setVerticalType(region.verticalType) .build()); } @@ -281,6 +282,7 @@ public List getCues( 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 @@ private void applyStyleToOutput( 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 3cbc25d4b24..36c862568f3 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 @@ 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 @@ public TtmlRegion(String id) { /* 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 @@ public TtmlRegion( 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 @@ public TtmlRegion( 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 e5ba2c9c1cf..13f3fe2b163 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 @@ public static TtmlStyle resolveStyle( } 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 @@ public static void applyStylesToSpan( 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 @@ public static void applyStylesToSpan( // 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 @@ public static void applyStylesToSpan( } @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 @@ private static TtmlNode findRubyTextNode(TtmlNode rubyContainerNode) { } @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 928af3620c5..3ca519660d2 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.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 @@ @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 @@ public TtmlStyle() { rubyType = UNSPECIFIED; rubyPosition = RubySpan.POSITION_UNKNOWN; textCombine = UNSPECIFIED; - verticalType = Cue.TYPE_UNSET; } /** @@ -249,9 +245,6 @@ private TtmlStyle inherit(@Nullable TtmlStyle ancestor, boolean chaining) { 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 @@ public TtmlStyle setFontSizeUnit(int fontSizeUnit) { 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 c8f2979c58e..4ce0ea8df52 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.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 @@ protected Subtitle decode(byte[] bytes, int length, boolean reset) 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 @@ private static String readSubtitleText(ParsableByteArray parsableByteArray) 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 5efe378a9bc..40fb1fcbb2b 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 @@ 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 @@ 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 @@ public List parseBlock(ParsableByteArray input) { 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 @@ private static String readCueTarget(ParsableByteArray input) { 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 @@ private static void parseStyleDeclaration(ParsableByteArray input, WebvttCssStyl 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 @@ private static boolean maybeSkipWhitespace(ParsableByteArray input) { } private static char peekCharAtPosition(ParsableByteArray input, int position) { - return (char) input.data[position]; + return (char) input.getData()[position]; } @Nullable @@ -286,7 +297,7 @@ private static String parsePropertyValue(ParsableByteArray input, StringBuilder 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 @@ private static String parseIdentifier(ParsableByteArray input, StringBuilder str 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 82023e6c586..caaa7869ee2 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 @@ private static Cue parseVttCueBox(ParsableByteArray sampleData, int remainingCue 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 cd08ad18cfa..eeb3392e542 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 void reset() { bold = UNSPECIFIED; italic = UNSPECIFIED; fontSizeUnit = UNSPECIFIED; - textAlign = null; + rubyPosition = RubySpan.POSITION_UNKNOWN; combineUpright = false; } @@ -131,7 +124,7 @@ public void setTargetTagName(String targetTag) { } 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 void setTargetVoice(String targetVoice) { * @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 int getSpecificityScore( 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 boolean hasBackgroundColor() { 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 float getFontSize() { 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 7bd96b23e9d..ed95f6b4e0d 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.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.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 static WebvttCueInfo parseCue(ParsableByteArray webvttData, 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 static WebvttCueInfo parseCue(ParsableByteArray webvttData, 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, @@ -531,7 +544,7 @@ private static void applySpansForTag( 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); @@ -546,33 +559,45 @@ private static void applySpansForTag( 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); @@ -582,6 +607,36 @@ private static void applyRubySpans( } } + @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); @@ -645,15 +700,6 @@ private static void applyStyleToText(SpannableStringBuilder spannedText, WebvttC 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( @@ -701,20 +747,18 @@ private static String getTagName(String tagExpression) { 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 { @@ -769,7 +813,7 @@ public Cue.Builder toCueBuilder() { .setLineAnchor(lineAnchor) .setPosition(position) .setPositionAnchor(positionAnchor) - .setSize(Math.min(size, deriveMaxSize(positionAnchor, position))) + .setSize(min(size, deriveMaxSize(positionAnchor, position))) .setVerticalType(verticalType); if (text != null) { @@ -877,21 +921,19 @@ public StyleMatch(int score, WebvttCssStyle style) { @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; @@ -911,17 +953,19 @@ public static StartTag buildStartTag(String fullTagExpression, int position) { } 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 620e1ef491d..4a8f5a5471a 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 @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.text.webvtt; -import android.text.SpannableStringBuilder; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.Subtitle; @@ -23,6 +22,7 @@ import com.google.android.exoplayer2.util.Util; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; /** @@ -30,17 +30,16 @@ */ /* package */ final class WebvttSubtitle implements Subtitle { - private final List cues; + private final List cueInfos; private final long[] cueTimesUs; private final long[] sortedCueTimesUs; /** Constructs a new WebvttSubtitle from a list of {@link WebvttCueInfo}s. */ public WebvttSubtitle(List cueInfos) { - this.cues = new ArrayList<>(cueInfos.size()); + this.cueInfos = Collections.unmodifiableList(new ArrayList<>(cueInfos)); cueTimesUs = new long[2 * cueInfos.size()]; for (int cueIndex = 0; cueIndex < cueInfos.size(); cueIndex++) { WebvttCueInfo cueInfo = cueInfos.get(cueIndex); - this.cues.add(cueInfo.cue); int arrayIndex = cueIndex * 2; cueTimesUs[arrayIndex] = cueInfo.startTimeUs; cueTimesUs[arrayIndex + 1] = cueInfo.endTimeUs; @@ -69,53 +68,25 @@ public long getEventTime(int index) { @Override public List getCues(long timeUs) { - List list = new ArrayList<>(); - Cue firstNormalCue = null; - SpannableStringBuilder normalCueTextBuilder = null; - - for (int i = 0; i < cues.size(); i++) { + List currentCues = new ArrayList<>(); + List cuesWithUnsetLine = new ArrayList<>(); + for (int i = 0; i < cueInfos.size(); i++) { if ((cueTimesUs[i * 2] <= timeUs) && (timeUs < cueTimesUs[i * 2 + 1])) { - Cue cue = cues.get(i); - // TODO(ibaker): Replace this with a closer implementation of the WebVTT spec (keeping - // individual cues, but tweaking their `line` value): - // https://www.w3.org/TR/webvtt1/#cue-computed-line - if (isNormal(cue)) { - // We want to merge all of the normal cues into a single cue to ensure they are drawn - // correctly (i.e. don't overlap) and to emulate roll-up, but only if there are multiple - // normal cues, otherwise we can just append the single normal cue. - if (firstNormalCue == null) { - firstNormalCue = cue; - } else if (normalCueTextBuilder == null) { - normalCueTextBuilder = new SpannableStringBuilder(); - normalCueTextBuilder - .append(Assertions.checkNotNull(firstNormalCue.text)) - .append("\n") - .append(Assertions.checkNotNull(cue.text)); - } else { - normalCueTextBuilder.append("\n").append(Assertions.checkNotNull(cue.text)); - } + WebvttCueInfo cueInfo = cueInfos.get(i); + if (cueInfo.cue.line == Cue.DIMEN_UNSET) { + cuesWithUnsetLine.add(cueInfo); } else { - list.add(cue); + currentCues.add(cueInfo.cue); } } } - if (normalCueTextBuilder != null) { - // There were multiple normal cues, so create a new cue with all of the text. - list.add(WebvttCueParser.newCueForText(normalCueTextBuilder)); - } else if (firstNormalCue != null) { - // There was only a single normal cue, so just add it to the list. - list.add(firstNormalCue); + // Steps 4 - 10 of https://www.w3.org/TR/webvtt1/#cue-computed-line + // (steps 1 - 3 are handled by WebvttCueParser#computeLine(float, int)) + 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).build()); } - return list; - } - - /** - * Returns whether or not this cue should be placed in the default position and rolled-up with the - * other "normal" cues. - * - * @return Whether this cue should be placed in the default position. - */ - private static boolean isNormal(Cue cue) { - return (cue.line == Cue.DIMEN_UNSET && cue.position == WebvttCueParser.DEFAULT_POSITION); + 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 9a599279ec1..3173188cac3 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.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 Factory() { 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 Factory( 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 Factory( * 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 Factory( for (int i = 0; i < adaptiveSelections.size(); i++) { adaptiveSelections .get(i) - .experimental_setBandwidthAllocationCheckpoints(bandwidthCheckpoints[i]); + .experimentalSetBandwidthAllocationCheckpoints(bandwidthCheckpoints[i]); } } return selections; @@ -278,30 +199,30 @@ protected AdaptiveTrackSelection createAdaptiveTrackSelection( 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 AdaptiveTrackSelection(TrackGroup group, int[] tracks, 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 AdaptiveTrackSelection(TrackGroup group, int[] tracks, * 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 AdaptiveTrackSelection( long minDurationToRetainAfterDiscardMs, float bandwidthFraction, float bufferedFractionToLiveEdgeForQualityIncrease, - long minTimeBetweenBufferReevaluationMs, Clock clock) { this( group, @@ -374,7 +289,6 @@ public AdaptiveTrackSelection( maxDurationForQualityDecreaseMs, minDurationToRetainAfterDiscardMs, bufferedFractionToLiveEdgeForQualityIncrease, - minTimeBetweenBufferReevaluationMs, clock); } @@ -386,7 +300,6 @@ private AdaptiveTrackSelection( long maxDurationForQualityDecreaseMs, long minDurationToRetainAfterDiscardMs, float bufferedFractionToLiveEdgeForQualityIncrease, - long minTimeBetweenBufferReevaluationMs, Clock clock) { super(group, tracks); this.bandwidthProvider = bandwidthProvider; @@ -395,7 +308,6 @@ private AdaptiveTrackSelection( this.minDurationToRetainAfterDiscardUs = minDurationToRetainAfterDiscardMs * 1000L; this.bufferedFractionToLiveEdgeForQualityIncrease = bufferedFractionToLiveEdgeForQualityIncrease; - this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; this.clock = clock; playbackSpeed = 1f; reason = C.SELECTION_REASON_UNKNOWN; @@ -408,14 +320,23 @@ private AdaptiveTrackSelection( * @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 void updateSelectedTrack( 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 Object getSelectionData() { @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 @@ protected boolean canSelectFormat( * 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 @@ protected long getMinDurationToRetainAfterDiscardUs() { * 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 @@ private static final class DefaultBandwidthProvider implements BandwidthProvider @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 long getAllocatedBandwidth() { 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 dc0b3f6747e..4be4bf7075c 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.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 BaseTrackSelection(TrackGroup group, int... tracks) { 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 int evaluateQueueSize(long playbackPositionUs, List } @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 boolean equals(@Nullable Object obj) { 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 668202993a6..c9f0e290c99 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.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 static final class ParametersBuilder extends TrackSelectionParameters.Bui 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 @@ private ParametersBuilder(Parameters initialValues) { 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 ParametersBuilder setMaxVideoBitrate(int maxVideoBitrate) { } /** - * 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 ParametersBuilder setPreferredAudioLanguage(@Nullable String preferredAud 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 ParametersBuilder setPreferredTextLanguage(@Nullable String preferredText 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 Parameters build() { maxVideoHeight, maxVideoFrameRate, maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate, exceedVideoConstraintsIfNecessary, allowVideoMixedMimeTypeAdaptiveness, allowVideoNonSeamlessAdaptiveness, @@ -718,7 +780,7 @@ public Parameters build() { viewportHeight, viewportOrientationMayChange, // Audio - preferredAudioLanguage, + preferredAudioLanguages, maxAudioChannelCount, maxAudioBitrate, exceedAudioConstraintsIfNecessary, @@ -726,7 +788,7 @@ public Parameters build() { allowAudioMixedSampleRateAdaptiveness, allowAudioMixedChannelCountAdaptiveness, // Text - preferredTextLanguage, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags, @@ -836,6 +898,17 @@ public static Parameters getDefaults(Context context) { * 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 static Parameters getDefaults(Context context) { 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 static Parameters getDefaults(Context context) { int viewportHeight, boolean viewportOrientationMayChange, // Audio - @Nullable String preferredAudioLanguage, + ImmutableList preferredAudioLanguages, int maxAudioChannelCount, int maxAudioBitrate, boolean exceedAudioConstraintsIfNecessary, @@ -959,7 +1036,7 @@ public static Parameters getDefaults(Context context) { 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 static Parameters getDefaults(Context context) { SparseArray> selectionOverrides, SparseBooleanArray rendererDisabledFlags) { super( - preferredAudioLanguage, - preferredTextLanguage, + preferredAudioLanguages, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags); @@ -982,6 +1059,10 @@ public static Parameters getDefaults(Context context) { 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 static Parameters getDefaults(Context context) { 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 boolean equals(@Nullable Object obj) { && 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 int hashCode() { 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 void writeToParcel(Parcel dest, int flags) { 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 SelectionOverride[] newArray(int size) { */ 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 SelectionOverride[] newArray(int size) { /** @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 ParametersBuilder buildUponParameters() { * *

      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 void experimental_allowMultipleAdaptiveSelections() { } } - 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 void experimental_allowMultipleAdaptiveSelections() { } } - 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 void experimental_allowMultipleAdaptiveSelections() { // Already done. Do nothing. break; case C.TRACK_TYPE_TEXT: + @Nullable Pair textSelection = selectTextTrack( mappedTrackInfo.getTrackGroups(i), @@ -1694,8 +1789,8 @@ public void experimental_allowMultipleAdaptiveSelections() { * {@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 void experimental_allowMultipleAdaptiveSelections() { @Nullable protected TrackSelection.Definition selectVideoTrack( TrackGroupArray groups, - @Capabilities int[][] formatSupports, + @Capabilities int[][] formatSupport, @AdaptiveSupport int mixedMimeTypeAdaptationSupports, Parameters params, boolean enableAdaptiveTrackSelection) @@ -1717,10 +1812,10 @@ protected TrackSelection.Definition selectVideoTrack( && !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 @@ private static TrackSelection.Definition selectAdaptiveVideoTrack( params.maxVideoHeight, params.maxVideoFrameRate, params.maxVideoBitrate, + params.minVideoWidth, + params.minVideoHeight, + params.minVideoFrameRate, + params.minVideoBitrate, params.viewportWidth, params.viewportHeight, params.viewportOrientationMayChange); @@ -1769,6 +1868,10 @@ private static int[] getAdaptiveVideoTracksForGroup( int maxVideoHeight, int maxVideoFrameRate, int maxVideoBitrate, + int minVideoWidth, + int minVideoHeight, + int minVideoFrameRate, + int minVideoBitrate, int viewportWidth, int viewportHeight, boolean viewportOrientationMayChange) { @@ -1801,6 +1904,10 @@ private static int[] getAdaptiveVideoTracksForGroup( maxVideoHeight, maxVideoFrameRate, maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate, selectedTrackIndices); if (countForMimeType > selectedMimeTypeTrackCount) { selectedMimeType = sampleMimeType; @@ -1820,9 +1927,13 @@ private static int[] getAdaptiveVideoTracksForGroup( 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 @@ private static int getAdaptiveVideoTrackCountForMimeType( 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 @@ private static int getAdaptiveVideoTrackCountForMimeType( maxVideoWidth, maxVideoHeight, maxVideoFrameRate, - maxVideoBitrate)) { + maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate)) { adaptiveTrackCount++; } } @@ -1862,6 +1981,10 @@ private static void filterAdaptiveVideoTrackCountForMimeType( 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 @@ private static void filterAdaptiveVideoTrackCountForMimeType( maxVideoWidth, maxVideoHeight, maxVideoFrameRate, - maxVideoBitrate)) { + maxVideoBitrate, + minVideoWidth, + minVideoHeight, + minVideoFrameRate, + minVideoBitrate)) { selectedTrackIndices.remove(i); } } @@ -1887,33 +2014,43 @@ private static boolean isSupportedAdaptiveVideoTrack( 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 @@ private static TrackSelection.Definition selectFixedVideoTrack( } 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 @@ private static TrackSelection.Definition selectFixedVideoTrack( * {@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 @@ private static TrackSelection.Definition selectFixedVideoTrack( @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 @@ protected Pair selectAudioTrack( int[] adaptiveTracks = getAdaptiveAudioTracks( selectedGroup, - formatSupports[selectedGroupIndex], + formatSupport[selectedGroupIndex], selectedTrackIndex, params.maxAudioBitrate, params.allowAudioMixedMimeTypeAdaptiveness, @@ -2111,8 +2221,8 @@ private static boolean isSupportedAdaptiveAudioTrack( * {@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 @@ protected Pair selectTextTrack( 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 @@ protected Pair selectTextTrack( * * @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 @@ protected Pair selectTextTrack( 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 @@ protected TrackSelection.Definition selectOtherTrack( 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 @@ private static void maybeConfigureRenderersForTunneling( /** * 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 @@ private static boolean rendererSupportsTunneling( 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 @@ private static Point getMaxVideoSizeInViewport(boolean orientationMayChange, int } } - /** - * 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 @@ protected static final class AudioTrackScore implements Comparable 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 AudioTrackScore(Format format, Parameters parameters, @Capabilities int f */ @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 @@ protected static final class TextTrackScore implements Comparable 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 TextTrackScore( */ @Override public int compareTo(TextTrackScore other) { - if (this.isWithinRendererCapabilities != other.isWithinRendererCapabilities) { - return this.isWithinRendererCapabilities ? 1 : -1; - } - 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); + 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 (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 59d50af405a..9949a370ede 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 int getRendererSupport(int rendererIndex) { default: throw new IllegalStateException(); } - bestRendererSupport = Math.max(bestRendererSupport, trackRendererSupport); + bestRendererSupport = max(bestRendererSupport, trackRendererSupport); } } return bestRendererSupport; @@ -218,7 +221,7 @@ public int getTypeSupport(int trackType) { @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 int getAdaptiveSupport(int rendererIndex, int groupIndex, int[] trackIndi 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 @@ private static int findRenderer( 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 f35e7ec755a..4b9b72715a4 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 void updateSelectedTrack( 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 ad1a6ef1f2b..5e703438f86 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 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 @@ TrackSelection[] createTrackSelections( /** * 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 @@ TrackSelection[] createTrackSelections( /** * 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 @@ default void onDiscontinuity() {} /** * 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 @@ void updateSelectedTrack( 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. + * + *

      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 may only be called when the selection is enabled. + *

      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 3871a31a3be..be1f8f67336 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 Builder(Context context) { */ @Deprecated public Builder() { - preferredAudioLanguage = null; - preferredTextLanguage = null; + preferredAudioLanguages = ImmutableList.of(); + preferredTextLanguages = ImmutableList.of(); preferredTextRoleFlags = 0; selectUndeterminedTextLanguage = false; disabledTextTrackSelectionFlags = 0; @@ -71,8 +74,8 @@ public Builder() { * the builder are obtained. */ /* package */ Builder(TrackSelectionParameters initialValues) { - preferredAudioLanguage = initialValues.preferredAudioLanguage; - preferredTextLanguage = initialValues.preferredTextLanguage; + preferredAudioLanguages = initialValues.preferredAudioLanguages; + preferredTextLanguages = initialValues.preferredTextLanguages; preferredTextRoleFlags = initialValues.preferredTextRoleFlags; selectUndeterminedTextLanguage = initialValues.selectUndeterminedTextLanguage; disabledTextTrackSelectionFlags = initialValues.disabledTextTrackSelectionFlags; @@ -86,7 +89,25 @@ public Builder() { * @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 Builder setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings( * @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 Builder setPreferredTextRoleFlags(@C.RoleFlags int preferredTextRoleFlags /** * Sets whether a text track with undetermined language should be selected if no track with - * {@link #setPreferredTextLanguage(String)} is available, or if the preferred language is - * 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 Builder setDisabledTextTrackSelectionFlags( public TrackSelectionParameters build() { return new TrackSelectionParameters( // Audio - preferredAudioLanguage, + preferredAudioLanguages, // Text - preferredTextLanguage, + preferredTextLanguages, preferredTextRoleFlags, selectUndeterminedTextLanguage, disabledTextTrackSelectionFlags); @@ -185,7 +224,7 @@ private void setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettingsV19( preferredTextRoleFlags = C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; Locale preferredLocale = captioningManager.getLocale(); if (preferredLocale != null) { - preferredTextLanguage = Util.getLocaleLanguageTag(preferredLocale); + preferredTextLanguages = ImmutableList.of(Util.getLocaleLanguageTag(preferredLocale)); } } } @@ -218,17 +257,18 @@ public static TrackSelectionParameters getDefaults(Context context) { } /** - * 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 static TrackSelectionParameters getDefaults(Context context) { @C.RoleFlags public final int preferredTextRoleFlags; /** * Whether a text track with undetermined language should be selected if no track with {@link - * #preferredTextLanguage} is available, or if {@link #preferredTextLanguage} is unset. The + * #preferredTextLanguages} is available, or if {@link #preferredTextLanguages} is unset. The * default value is {@code false}. */ public final boolean selectUndeterminedTextLanguage; @@ -249,23 +289,27 @@ public static TrackSelectionParameters getDefaults(Context context) { @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 boolean equals(@Nullable Object obj) { 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 boolean equals(@Nullable Object obj) { @Override public int hashCode() { int result = 1; - result = 31 * result + (preferredAudioLanguage == null ? 0 : preferredAudioLanguage.hashCode()); - result = 31 * result + (preferredTextLanguage == null ? 0 : preferredTextLanguage.hashCode()); + result = 31 * result + 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 int describeContents() { @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 d48c140ac89..8ee9d29d3d2 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 @@ * 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 3c92b039cc8..e529e288469 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 int read(byte[] buffer, int offset, int readLength) throws AssetDataSourc 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 853a9af5267..d520fcfa60f 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 @@ 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 @@ interface EventListener { * @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 80687db31f8..ce6243eda00 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 @@ protected BaseDataSource(boolean isNetwork) { @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 ed5ba9064b6..17e9073128f 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 int read(byte[] buffer, int offset, int readLength) { 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 baaa677127e..b659c5ca986 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 long open(DataSpec dataSpec) throws ContentDataSourceException { // 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 int read(byte[] buffer, int offset, int readLength) throws ContentDataSou 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 55c580ead23..680ebbb2b1a 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 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 long open(DataSpec dataSpec) throws IOException { } } 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 int read(byte[] buffer, int offset, int readLength) { 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 ca9cca255d9..bb4ce1d0c14 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 synchronized Allocation allocate() { 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 synchronized void release(Allocation allocation) { @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 synchronized void release(Allocation[] allocations) { @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 synchronized void trim() { 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 synchronized void trim() { } } // 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 2491cc93a91..8a5c4447c33 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.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_700_000, 3_500_000, 2_000_000, 1_100_000, 470_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[] {200_000, 148_000, 132_000, 115_000, 95_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_300_000, 970_000, 810_000, 490_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[] {5_300_000, 3_200_000, 2_000_000, 1_400_000, 690_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 static final class Builder { @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 Builder setSlidingWindowMaxWeight(int slidingWindowMaxWeight) { * @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 DefaultBandwidthMeter build() { 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 static synchronized DefaultBandwidthMeter getSingletonInstance(Context co 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 static synchronized DefaultBandwidthMeter getSingletonInstance(Context co 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 DefaultBandwidthMeter() { 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 TransferListener getTransferListener() { @Override public void addEventListener(Handler eventHandler, EventListener eventListener) { + Assertions.checkNotNull(eventHandler); + Assertions.checkNotNull(eventListener); eventDispatcher.addListener(eventHandler, eventListener); } @@ -406,8 +437,7 @@ private void maybeNotifyBandwidthSample( return; } lastReportedBitrateEstimate = bitrateEstimate; - eventDispatcher.dispatch( - listener -> listener.onBandwidthSample(elapsedMs, bytesTransferred, bitrateEstimate)); + eventDispatcher.bandwidthSample(elapsedMs, bytesTransferred, bitrateEstimate); } private long getInitialBitrateEstimateForNetworkType(@C.NetworkType int networkType) { @@ -489,246 +519,248 @@ private void removeClearedReferences() { } } - private static Map createInitialBitrateCountryGroupAssignment() { - HashMap countryGroupAssignment = new HashMap<>(); - countryGroupAssignment.put("AD", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("AE", new int[] {1, 4, 4, 4}); - countryGroupAssignment.put("AF", new int[] {4, 4, 3, 3}); - countryGroupAssignment.put("AG", new int[] {3, 1, 0, 1}); - countryGroupAssignment.put("AI", new int[] {1, 0, 0, 3}); - countryGroupAssignment.put("AL", new int[] {1, 2, 0, 1}); - countryGroupAssignment.put("AM", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("AO", new int[] {3, 4, 2, 0}); - countryGroupAssignment.put("AR", new int[] {2, 3, 2, 2}); - countryGroupAssignment.put("AS", new int[] {3, 0, 4, 2}); - countryGroupAssignment.put("AT", new int[] {0, 3, 0, 0}); - countryGroupAssignment.put("AU", new int[] {0, 3, 0, 1}); - countryGroupAssignment.put("AW", new int[] {1, 1, 0, 3}); - countryGroupAssignment.put("AX", new int[] {0, 3, 0, 2}); - countryGroupAssignment.put("AZ", new int[] {3, 3, 3, 3}); - countryGroupAssignment.put("BA", new int[] {1, 1, 0, 1}); - countryGroupAssignment.put("BB", new int[] {0, 2, 0, 0}); - countryGroupAssignment.put("BD", new int[] {2, 1, 3, 3}); - countryGroupAssignment.put("BE", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("BF", new int[] {4, 4, 4, 1}); - countryGroupAssignment.put("BG", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("BH", new int[] {2, 1, 3, 4}); - countryGroupAssignment.put("BI", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("BJ", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("BL", new int[] {1, 0, 2, 2}); - countryGroupAssignment.put("BM", new int[] {1, 2, 0, 0}); - countryGroupAssignment.put("BN", new int[] {4, 1, 3, 2}); - countryGroupAssignment.put("BO", new int[] {1, 2, 3, 2}); - countryGroupAssignment.put("BQ", new int[] {1, 1, 2, 4}); - countryGroupAssignment.put("BR", new int[] {2, 3, 3, 2}); - countryGroupAssignment.put("BS", new int[] {2, 1, 1, 4}); - countryGroupAssignment.put("BT", new int[] {3, 0, 3, 1}); - countryGroupAssignment.put("BW", new int[] {4, 4, 1, 2}); - countryGroupAssignment.put("BY", new int[] {0, 1, 1, 2}); - countryGroupAssignment.put("BZ", new int[] {2, 2, 2, 1}); - countryGroupAssignment.put("CA", new int[] {0, 3, 1, 3}); - countryGroupAssignment.put("CD", new int[] {4, 4, 2, 2}); - countryGroupAssignment.put("CF", new int[] {4, 4, 3, 0}); - countryGroupAssignment.put("CG", new int[] {3, 4, 2, 4}); - countryGroupAssignment.put("CH", new int[] {0, 0, 1, 0}); - countryGroupAssignment.put("CI", new int[] {3, 4, 3, 3}); - countryGroupAssignment.put("CK", new int[] {2, 4, 1, 0}); - countryGroupAssignment.put("CL", new int[] {1, 2, 2, 3}); - countryGroupAssignment.put("CM", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("CN", new int[] {2, 0, 2, 3}); - countryGroupAssignment.put("CO", new int[] {2, 3, 2, 2}); - countryGroupAssignment.put("CR", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("CU", new int[] {4, 4, 3, 1}); - countryGroupAssignment.put("CV", new int[] {2, 3, 1, 2}); - countryGroupAssignment.put("CW", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("CY", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("CZ", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("DE", new int[] {0, 1, 1, 3}); - countryGroupAssignment.put("DJ", new int[] {4, 3, 4, 1}); - countryGroupAssignment.put("DK", new int[] {0, 0, 1, 1}); - countryGroupAssignment.put("DM", new int[] {1, 0, 1, 3}); - countryGroupAssignment.put("DO", new int[] {3, 3, 4, 4}); - countryGroupAssignment.put("DZ", new int[] {3, 3, 4, 4}); - countryGroupAssignment.put("EC", new int[] {2, 3, 4, 3}); - countryGroupAssignment.put("EE", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("EG", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("EH", new int[] {2, 0, 3, 3}); - countryGroupAssignment.put("ER", new int[] {4, 2, 2, 0}); - countryGroupAssignment.put("ES", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("ET", new int[] {4, 4, 4, 0}); - countryGroupAssignment.put("FI", new int[] {0, 0, 1, 0}); - countryGroupAssignment.put("FJ", new int[] {3, 0, 3, 3}); - countryGroupAssignment.put("FK", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("FM", new int[] {4, 0, 4, 0}); - countryGroupAssignment.put("FO", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("FR", new int[] {1, 0, 3, 1}); - countryGroupAssignment.put("GA", new int[] {3, 3, 2, 2}); - countryGroupAssignment.put("GB", new int[] {0, 1, 3, 3}); - countryGroupAssignment.put("GD", new int[] {2, 0, 4, 4}); - countryGroupAssignment.put("GE", new int[] {1, 1, 1, 4}); - countryGroupAssignment.put("GF", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("GG", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("GH", new int[] {3, 3, 2, 2}); - countryGroupAssignment.put("GI", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("GL", new int[] {2, 2, 0, 2}); - countryGroupAssignment.put("GM", new int[] {4, 4, 3, 4}); - countryGroupAssignment.put("GN", new int[] {3, 4, 4, 2}); - countryGroupAssignment.put("GP", new int[] {2, 1, 1, 4}); - countryGroupAssignment.put("GQ", new int[] {4, 4, 3, 0}); - countryGroupAssignment.put("GR", new int[] {1, 1, 0, 2}); - countryGroupAssignment.put("GT", new int[] {3, 3, 3, 3}); - countryGroupAssignment.put("GU", new int[] {1, 2, 4, 4}); - countryGroupAssignment.put("GW", new int[] {4, 4, 4, 1}); - countryGroupAssignment.put("GY", new int[] {3, 2, 1, 1}); - countryGroupAssignment.put("HK", new int[] {0, 2, 3, 4}); - countryGroupAssignment.put("HN", new int[] {3, 2, 3, 2}); - countryGroupAssignment.put("HR", new int[] {1, 1, 0, 1}); - countryGroupAssignment.put("HT", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("HU", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("ID", new int[] {3, 2, 3, 4}); - countryGroupAssignment.put("IE", new int[] {1, 0, 1, 1}); - countryGroupAssignment.put("IL", new int[] {0, 0, 2, 3}); - countryGroupAssignment.put("IM", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("IN", new int[] {2, 2, 4, 4}); - countryGroupAssignment.put("IO", new int[] {4, 2, 2, 2}); - countryGroupAssignment.put("IQ", new int[] {3, 3, 4, 2}); - countryGroupAssignment.put("IR", new int[] {3, 0, 2, 2}); - countryGroupAssignment.put("IS", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("IT", new int[] {1, 0, 1, 2}); - countryGroupAssignment.put("JE", new int[] {1, 0, 0, 1}); - countryGroupAssignment.put("JM", new int[] {2, 3, 3, 1}); - countryGroupAssignment.put("JO", new int[] {1, 2, 1, 2}); - countryGroupAssignment.put("JP", new int[] {0, 2, 1, 1}); - countryGroupAssignment.put("KE", new int[] {3, 4, 4, 3}); - countryGroupAssignment.put("KG", new int[] {1, 1, 2, 2}); - countryGroupAssignment.put("KH", new int[] {1, 0, 4, 4}); - countryGroupAssignment.put("KI", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("KM", new int[] {4, 3, 2, 3}); - countryGroupAssignment.put("KN", new int[] {1, 0, 1, 3}); - countryGroupAssignment.put("KP", new int[] {4, 2, 4, 2}); - countryGroupAssignment.put("KR", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("KW", new int[] {2, 3, 1, 1}); - countryGroupAssignment.put("KY", new int[] {1, 1, 0, 1}); - countryGroupAssignment.put("KZ", new int[] {1, 2, 2, 3}); - countryGroupAssignment.put("LA", new int[] {2, 2, 1, 1}); - countryGroupAssignment.put("LB", new int[] {3, 2, 0, 0}); - countryGroupAssignment.put("LC", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("LI", new int[] {0, 0, 2, 4}); - countryGroupAssignment.put("LK", new int[] {2, 1, 2, 3}); - countryGroupAssignment.put("LR", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("LS", new int[] {3, 3, 2, 0}); - countryGroupAssignment.put("LT", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("LU", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("LV", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("LY", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("MA", new int[] {2, 1, 2, 1}); - countryGroupAssignment.put("MC", new int[] {0, 0, 0, 1}); - countryGroupAssignment.put("MD", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("ME", new int[] {1, 2, 1, 2}); - countryGroupAssignment.put("MF", new int[] {1, 1, 1, 1}); - countryGroupAssignment.put("MG", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("MH", new int[] {4, 0, 2, 4}); - countryGroupAssignment.put("MK", new int[] {1, 0, 0, 0}); - countryGroupAssignment.put("ML", new int[] {4, 4, 2, 0}); - countryGroupAssignment.put("MM", new int[] {3, 3, 1, 2}); - countryGroupAssignment.put("MN", new int[] {2, 3, 2, 3}); - countryGroupAssignment.put("MO", new int[] {0, 0, 4, 4}); - countryGroupAssignment.put("MP", new int[] {0, 2, 4, 4}); - countryGroupAssignment.put("MQ", new int[] {2, 1, 1, 4}); - countryGroupAssignment.put("MR", new int[] {4, 2, 4, 2}); - countryGroupAssignment.put("MS", new int[] {1, 2, 3, 3}); - countryGroupAssignment.put("MT", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("MU", new int[] {2, 2, 3, 4}); - countryGroupAssignment.put("MV", new int[] {4, 3, 0, 2}); - countryGroupAssignment.put("MW", new int[] {3, 2, 1, 0}); - countryGroupAssignment.put("MX", new int[] {2, 4, 4, 3}); - countryGroupAssignment.put("MY", new int[] {2, 2, 3, 3}); - countryGroupAssignment.put("MZ", new int[] {3, 3, 2, 1}); - countryGroupAssignment.put("NA", new int[] {3, 3, 2, 1}); - countryGroupAssignment.put("NC", new int[] {2, 0, 3, 3}); - countryGroupAssignment.put("NE", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("NF", new int[] {1, 2, 2, 2}); - countryGroupAssignment.put("NG", new int[] {3, 4, 3, 1}); - countryGroupAssignment.put("NI", new int[] {3, 3, 4, 4}); - countryGroupAssignment.put("NL", new int[] {0, 2, 3, 3}); - countryGroupAssignment.put("NO", new int[] {0, 1, 1, 0}); - countryGroupAssignment.put("NP", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("NR", new int[] {4, 0, 3, 1}); - countryGroupAssignment.put("NZ", new int[] {0, 0, 1, 2}); - countryGroupAssignment.put("OM", new int[] {3, 2, 1, 3}); - countryGroupAssignment.put("PA", new int[] {1, 3, 3, 4}); - countryGroupAssignment.put("PE", new int[] {2, 3, 4, 4}); - countryGroupAssignment.put("PF", new int[] {2, 2, 0, 1}); - countryGroupAssignment.put("PG", new int[] {4, 3, 3, 1}); - countryGroupAssignment.put("PH", new int[] {3, 0, 3, 4}); - countryGroupAssignment.put("PK", new int[] {3, 3, 3, 3}); - countryGroupAssignment.put("PL", new int[] {1, 0, 1, 3}); - countryGroupAssignment.put("PM", new int[] {0, 2, 2, 0}); - countryGroupAssignment.put("PR", new int[] {1, 2, 3, 3}); - countryGroupAssignment.put("PS", new int[] {3, 3, 2, 4}); - countryGroupAssignment.put("PT", new int[] {1, 1, 0, 0}); - countryGroupAssignment.put("PW", new int[] {2, 1, 2, 0}); - countryGroupAssignment.put("PY", new int[] {2, 0, 2, 3}); - countryGroupAssignment.put("QA", new int[] {2, 2, 1, 2}); - countryGroupAssignment.put("RE", new int[] {1, 0, 2, 2}); - countryGroupAssignment.put("RO", new int[] {0, 1, 1, 2}); - countryGroupAssignment.put("RS", new int[] {1, 2, 0, 0}); - countryGroupAssignment.put("RU", new int[] {0, 1, 1, 1}); - countryGroupAssignment.put("RW", new int[] {4, 4, 2, 4}); - countryGroupAssignment.put("SA", new int[] {2, 2, 2, 1}); - countryGroupAssignment.put("SB", new int[] {4, 4, 3, 0}); - countryGroupAssignment.put("SC", new int[] {4, 2, 0, 1}); - countryGroupAssignment.put("SD", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("SE", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("SG", new int[] {0, 2, 3, 3}); - countryGroupAssignment.put("SH", new int[] {4, 4, 2, 3}); - countryGroupAssignment.put("SI", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("SJ", new int[] {2, 0, 2, 4}); - countryGroupAssignment.put("SK", new int[] {0, 1, 0, 0}); - countryGroupAssignment.put("SL", new int[] {4, 3, 3, 3}); - countryGroupAssignment.put("SM", new int[] {0, 0, 2, 4}); - countryGroupAssignment.put("SN", new int[] {3, 4, 4, 2}); - countryGroupAssignment.put("SO", new int[] {3, 4, 4, 3}); - countryGroupAssignment.put("SR", new int[] {2, 2, 1, 0}); - countryGroupAssignment.put("SS", new int[] {4, 3, 4, 3}); - countryGroupAssignment.put("ST", new int[] {3, 4, 2, 2}); - countryGroupAssignment.put("SV", new int[] {2, 3, 3, 4}); - countryGroupAssignment.put("SX", new int[] {2, 4, 1, 0}); - countryGroupAssignment.put("SY", new int[] {4, 3, 2, 1}); - countryGroupAssignment.put("SZ", new int[] {4, 4, 3, 4}); - countryGroupAssignment.put("TC", new int[] {1, 2, 1, 1}); - countryGroupAssignment.put("TD", new int[] {4, 4, 4, 2}); - countryGroupAssignment.put("TG", new int[] {3, 3, 1, 0}); - countryGroupAssignment.put("TH", new int[] {1, 3, 4, 4}); - countryGroupAssignment.put("TJ", new int[] {4, 4, 4, 4}); - countryGroupAssignment.put("TL", new int[] {4, 2, 4, 4}); - countryGroupAssignment.put("TM", new int[] {4, 1, 2, 2}); - countryGroupAssignment.put("TN", new int[] {2, 2, 1, 2}); - countryGroupAssignment.put("TO", new int[] {3, 3, 3, 1}); - countryGroupAssignment.put("TR", new int[] {2, 2, 1, 2}); - countryGroupAssignment.put("TT", new int[] {1, 3, 1, 2}); - countryGroupAssignment.put("TV", new int[] {4, 2, 2, 4}); - countryGroupAssignment.put("TW", new int[] {0, 0, 0, 0}); - countryGroupAssignment.put("TZ", new int[] {3, 3, 4, 3}); - countryGroupAssignment.put("UA", new int[] {0, 2, 1, 2}); - countryGroupAssignment.put("UG", new int[] {4, 3, 3, 2}); - countryGroupAssignment.put("US", new int[] {1, 1, 3, 3}); - countryGroupAssignment.put("UY", new int[] {2, 2, 1, 1}); - countryGroupAssignment.put("UZ", new int[] {2, 2, 2, 2}); - countryGroupAssignment.put("VA", new int[] {1, 2, 4, 2}); - countryGroupAssignment.put("VC", new int[] {2, 0, 2, 4}); - countryGroupAssignment.put("VE", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("VG", new int[] {3, 0, 1, 3}); - countryGroupAssignment.put("VI", new int[] {1, 1, 4, 4}); - countryGroupAssignment.put("VN", new int[] {0, 2, 4, 4}); - countryGroupAssignment.put("VU", new int[] {4, 1, 3, 1}); - countryGroupAssignment.put("WS", new int[] {3, 3, 3, 2}); - countryGroupAssignment.put("XK", new int[] {1, 2, 1, 0}); - countryGroupAssignment.put("YE", new int[] {4, 4, 4, 3}); - countryGroupAssignment.put("YT", new int[] {2, 2, 2, 3}); - countryGroupAssignment.put("ZA", new int[] {2, 4, 2, 2}); - countryGroupAssignment.put("ZM", new int[] {3, 2, 2, 1}); - countryGroupAssignment.put("ZW", new int[] {3, 3, 2, 1}); - 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 98026c4677d..12fea3898cb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSource.java @@ -15,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 @@ *
    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 DefaultDataSource(Context context, DataSource baseDataSource) { @Override public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); baseDataSource.addTransferListener(transferListener); transferListeners.add(transferListener); maybeAddListenerToDataSource(fileDataSource, transferListener); @@ -166,9 +188,9 @@ public long open(DataSpec dataSpec) throws IOException { dataSource = getRtmpDataSource(); } else if (SCHEME_UDP.equals(scheme)) { dataSource = getUdpDataSource(); - } else if (DataSchemeDataSource.SCHEME_DATA.equals(scheme)) { + } else if (SCHEME_DATA.equals(scheme)) { dataSource = getDataSchemeDataSource(); - } else if (SCHEME_RAW.equals(scheme)) { + } else if (SCHEME_RAW.equals(scheme) || SCHEME_ANDROID_RESOURCE.equals(scheme)) { dataSource = getRawResourceDataSource(); } else { dataSource = baseDataSource; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java index 6b1131a3bd4..68ce25c47fc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java @@ -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 DefaultDataSourceFactory(Context context, String userAgent) { } /** + * Creates an instance. + * * @param context A context. * @param userAgent The User-Agent string that should be used. * @param listener An optional listener. @@ -48,6 +63,8 @@ public DefaultDataSourceFactory( } /** + * Creates an instance. + * * @param context A context. * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} * for {@link DefaultDataSource}. @@ -58,6 +75,8 @@ public DefaultDataSourceFactory(Context context, DataSource.Factory baseDataSour } /** + * Creates an instance. + * * @param context A context. * @param listener An optional listener. * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} 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 17f8427dd13..d15804fd51d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java @@ -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 DefaultHttpDataSource(String userAgent, int connectTimeoutMillis, int rea } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is * interpreted as an infinite timeout. Pass {@link #DEFAULT_CONNECT_TIMEOUT_MILLIS} to use the @@ -143,6 +163,8 @@ public DefaultHttpDataSource( } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -150,6 +172,7 @@ public DefaultHttpDataSource( * @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 DefaultHttpDataSource(String userAgent, @Nullable Predicate conte } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -188,6 +213,8 @@ public DefaultHttpDataSource( } /** + * Creates an instance. + * * @param userAgent The User-Agent string that should be used. * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link @@ -296,9 +323,20 @@ public long open(DataSpec dataSpec) throws HttpDataSourceException { // 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 long open(DataSpec dataSpec) throws HttpDataSourceException { // 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 @@ private HttpURLConnection makeConnection( 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 @@ private static long getContentLength(HttpURLConnection connection) { // 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 @@ private void skipInternal() throws IOException { } 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 @@ private int readInternal(byte[] buffer, int offset, int readLength) throws IOExc 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 f5d7dbd24c0..0a0650a4b1c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.upstream; +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.DEFAULT_USER_AGENT; + import androidx.annotation.Nullable; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; import com.google.android.exoplayer2.upstream.HttpDataSource.Factory; @@ -30,10 +32,18 @@ public final class DefaultHttpDataSourceFactory extends BaseFactory { private final boolean allowCrossProtocolRedirects; /** - * Constructs a DefaultHttpDataSourceFactory. Sets {@link - * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. + */ + public DefaultHttpDataSourceFactory() { + this(DEFAULT_USER_AGENT); + } + + /** + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. * * @param userAgent The User-Agent string that should be used. */ @@ -42,10 +52,9 @@ public DefaultHttpDataSourceFactory(String userAgent) { } /** - * Constructs a DefaultHttpDataSourceFactory. Sets {@link - * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link - * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables - * cross-protocol redirects. + * Creates an instance. Sets {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the + * connection timeout, {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read + * timeout and disables cross-protocol redirects. * * @param userAgent The User-Agent string that should be used. * @param listener An optional listener. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java index 435f4bf5782..366bd6509e9 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 DefaultLoadErrorHandlingPolicy(int minimumLoadableRetryCount) { } /** - * 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 long getBlacklistDurationMsFor( * {@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 4124a2531ff..5303e7f6eb7 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 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 void addTransferListener(TransferListener transferListener) { @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 93c1ce9adf7..d34e43eb461 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 int read(byte[] buffer, int offset, int readLength) throws FileDataSource } 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 37922db0dad..0102ea78713 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 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 LoadErrorInfo( } } - /** - * 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 @@ default long getBlacklistDurationMsFor(LoadErrorInfo loadErrorInfo) { 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, @@ -136,6 +115,14 @@ default long getRetryDelayMsFor(LoadErrorInfo loadErrorInfo) { loadErrorInfo.errorCount); } + /** + * Called once {@code loadTaskId} will not be associated with any more load errors. + * + *

      Implementations should clean up any resources associated with {@code loadTaskId} when this + * method is called. + */ + default void onLoadTaskConcluded(long loadTaskId) {} + /** * Returns the minimum number of times to retry a load in the case of a load error, before * propagating the error. 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 4ff58b108cb..cab9d003c4e 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.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 interface Loadable { /** * 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 @@ private final class LoadTask extends Handler implements Runn 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 @@ private final class LoadTask extends Handler implements Runn @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 void cancel(boolean released) { 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 void cancel(boolean released) { @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 void run() { 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 void handleMessage(Message msg) { 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 @@ private void finish() { } 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/ParsingLoadable.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ParsingLoadable.java index a60d35e3a70..c9701ed9c91 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/ParsingLoadable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/ParsingLoadable.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.upstream.Loader.Loadable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; @@ -87,9 +88,9 @@ public static T load( return Assertions.checkNotNull(loadable.getResult()); } - /** - * The {@link DataSpec} that defines the data to be loaded. - */ + /** Identifies the load task for this loadable. */ + public final long loadTaskId; + /** The {@link DataSpec} that defines the data to be loaded. */ public final DataSpec dataSpec; /** * The type of the data. One of the {@code DATA_TYPE_*} constants defined in {@link C}. For @@ -128,6 +129,7 @@ public ParsingLoadable(DataSource dataSource, DataSpec dataSpec, int type, this.dataSpec = dataSpec; this.type = type; this.parser = parser; + loadTaskId = LoadEventInfo.getNewId(); } /** Returns the loaded object, or null if an object has not been loaded. */ 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 767b6d78a37..e52e1db376c 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 PriorityDataSource(DataSource upstream, PriorityTaskManager priorityTaskM @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 fbfd6986108..7538cc67a49 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/RawResourceDataSource.java @@ -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 @@ /** * 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 static Uri buildRawResourceUri(int rawResourceId) { public static final String RAW_RESOURCE_SCHEME = "rawresource"; private final Resources resources; + private final String packageName; @Nullable private Uri uri; @Nullable private AssetFileDescriptor assetFileDescriptor; @@ -79,33 +93,55 @@ public static Uri buildRawResourceUri(int rawResourceId) { public RawResourceDataSource(Context context) { super(/* isNetwork= */ false); this.resources = context.getResources(); + this.packageName = context.getPackageName(); } @Override public long open(DataSpec dataSpec) throws RawResourceDataSourceException { - try { - Uri uri = dataSpec.uri; - this.uri = uri; - if (!TextUtils.equals(RAW_RESOURCE_SCHEME, uri.getScheme())) { - throw new RawResourceDataSourceException("URI must use scheme " + RAW_RESOURCE_SCHEME); - } - - int resourceId; + Uri uri = dataSpec.uri; + this.uri = uri; + + int resourceId; + if (TextUtils.equals(RAW_RESOURCE_SCHEME, uri.getScheme()) + || (TextUtils.equals(ContentResolver.SCHEME_ANDROID_RESOURCE, uri.getScheme()) + && uri.getPathSegments().size() == 1 + && Assertions.checkNotNull(uri.getLastPathSegment()).matches("\\d+"))) { try { resourceId = Integer.parseInt(Assertions.checkNotNull(uri.getLastPathSegment())); } catch (NumberFormatException e) { throw new RawResourceDataSourceException("Resource identifier must be an integer."); } - - transferInitializing(dataSpec); - AssetFileDescriptor assetFileDescriptor = resources.openRawResourceFd(resourceId); - this.assetFileDescriptor = assetFileDescriptor; - if (assetFileDescriptor == null) { - throw new RawResourceDataSourceException("Resource is compressed: " + uri); + } else if (TextUtils.equals(ContentResolver.SCHEME_ANDROID_RESOURCE, uri.getScheme())) { + String path = Assertions.checkNotNull(uri.getPath()); + if (path.startsWith("/")) { + path = path.substring(1); + } + @Nullable String host = uri.getHost(); + String resourceName = (TextUtils.isEmpty(host) ? "" : (host + ":")) + path; + resourceId = + resources.getIdentifier( + resourceName, /* defType= */ "raw", /* defPackage= */ packageName); + if (resourceId == 0) { + throw new RawResourceDataSourceException("Resource not found."); } - FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); - this.inputStream = inputStream; + } else { + throw new RawResourceDataSourceException( + "URI must either use scheme " + + RAW_RESOURCE_SCHEME + + " or " + + ContentResolver.SCHEME_ANDROID_RESOURCE); + } + + transferInitializing(dataSpec); + AssetFileDescriptor assetFileDescriptor = resources.openRawResourceFd(resourceId); + this.assetFileDescriptor = assetFileDescriptor; + if (assetFileDescriptor == null) { + throw new RawResourceDataSourceException("Resource is compressed: " + uri); + } + FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); + this.inputStream = inputStream; + try { inputStream.skip(assetFileDescriptor.getStartOffset()); long skipped = inputStream.skip(dataSpec.position); if (skipped < dataSpec.position) { @@ -113,18 +149,21 @@ public long open(DataSpec dataSpec) throws RawResourceDataSourceException { // skip beyond the end of the data. throw new EOFException(); } - if (dataSpec.length != C.LENGTH_UNSET) { - bytesRemaining = dataSpec.length; - } else { - long assetFileDescriptorLength = assetFileDescriptor.getLength(); - // If the length is UNKNOWN_LENGTH then the asset extends to the end of the file. - bytesRemaining = assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH - ? C.LENGTH_UNSET : (assetFileDescriptorLength - dataSpec.position); - } } catch (IOException e) { throw new RawResourceDataSourceException(e); } + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + long assetFileDescriptorLength = assetFileDescriptor.getLength(); + // If the length is UNKNOWN_LENGTH then the asset extends to the end of the file. + bytesRemaining = + assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH + ? C.LENGTH_UNSET + : (assetFileDescriptorLength - dataSpec.position); + } + opened = true; transferStarted(dataSpec); @@ -141,8 +180,8 @@ public int read(byte[] buffer, int offset, int readLength) throws RawResourceDat 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 f5fb67e40e8..958780cbc33 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 ResolvingDataSource(DataSource upstreamDataSource, Resolver resolver) { @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 6cdc381ba2d..4340169f451 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 Map> getLastResponseHeaders() { @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 f56f19a6ca3..689273d3883 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 TeeDataSource(DataSource upstream, DataSink dataSink) { @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 4d9b375334d..e2b8ba1b31a 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 int read(byte[] buffer, int offset, int readLength) throws UdpDataSourceE } 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 1d504159e6b..c917929111b 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.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 CacheException(String message, Throwable cause) { 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 CacheException(String message, Throwable cause) { * *

      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 CacheException(String message, Throwable cause) { /** * 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 CacheException(String message, Throwable cause) { 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 @@ void applyContentMetadataMutations(String key, ContentMetadataMutations mutation 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 92dff8b394c..76a833ddb5a 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; @@ -39,6 +43,78 @@ */ public final class CacheDataSink implements DataSink { + /** {@link DataSink.Factory} for {@link CacheDataSink} instances. */ + public static final class Factory implements DataSink.Factory { + + private @MonotonicNonNull Cache cache; + private long fragmentSize; + private int bufferSize; + + /** Creates an instance. */ + public Factory() { + fragmentSize = CacheDataSink.DEFAULT_FRAGMENT_SIZE; + bufferSize = CacheDataSink.DEFAULT_BUFFER_SIZE; + } + + /** + * Sets the cache to which data will be written. + * + *

      Must be called before the factory is used. + * + * @param cache The cache to which data will be written. + * @return This factory. + */ + public Factory setCache(Cache cache) { + this.cache = cache; + return this; + } + + /** + * Sets the cache file fragment size. For requests that should be fragmented into multiple cache + * files, this is the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} + * then no fragmentation will occur. Using a small value allows for finer-grained cache eviction + * policies, at the cost of increased overhead both on the cache implementation and the file + * system. Values under {@code (2 * 1024 * 1024)} are not recommended. + * + *

      The default value is {@link CacheDataSink#DEFAULT_FRAGMENT_SIZE}. + * + * @param fragmentSize The fragment size in bytes, or {@link C#LENGTH_UNSET} to disable + * fragmentation. + * @return This factory. + */ + public Factory setFragmentSize(long fragmentSize) { + this.fragmentSize = fragmentSize; + return this; + } + + /** + * Sets the size of an in-memory buffer used when writing to a cache file. A zero or negative + * value disables buffering. + * + *

      The default value is {@link CacheDataSink#DEFAULT_BUFFER_SIZE}. + * + * @param bufferSize The buffer size in bytes. + * @return This factory. + */ + public Factory setBufferSize(int bufferSize) { + this.bufferSize = bufferSize; + return this; + } + + @Override + public DataSink createDataSink() { + return new CacheDataSink(checkNotNull(cache), fragmentSize, bufferSize); + } + } + + /** Thrown when an {@link IOException} is encountered when writing data to the sink. */ + public static final class CacheDataSinkException extends CacheException { + + public CacheDataSinkException(IOException cause) { + super(cause); + } + } + /** Default {@code fragmentSize} recommended for caching use cases. */ public static final long DEFAULT_FRAGMENT_SIZE = 5 * 1024 * 1024; /** Default buffer size in bytes. */ @@ -59,17 +135,6 @@ public final class CacheDataSink implements DataSink { private long dataSpecBytesWritten; private @MonotonicNonNull ReusableBufferedOutputStream bufferedOutputStream; - /** - * Thrown when IOException is encountered when writing data into sink. - */ - public static class CacheDataSinkException extends CacheException { - - public CacheDataSinkException(IOException cause) { - super(cause); - } - - } - /** * Constructs an instance using {@link #DEFAULT_BUFFER_SIZE}. * @@ -105,13 +170,14 @@ public CacheDataSink(Cache cache, long fragmentSize, int bufferSize) { + 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; @@ -122,7 +188,7 @@ public void open(DataSpec dataSpec) throws CacheDataSinkException { dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) ? fragmentSize : Long.MAX_VALUE; dataSpecBytesWritten = 0; try { - openNextOutputStream(); + openNextOutputStream(dataSpec); } catch (IOException e) { throw new CacheDataSinkException(e); } @@ -130,6 +196,7 @@ public void open(DataSpec dataSpec) throws CacheDataSinkException { @Override public void write(byte[] buffer, int offset, int length) throws CacheDataSinkException { + @Nullable DataSpec dataSpec = this.dataSpec; if (dataSpec == null) { return; } @@ -138,11 +205,11 @@ public void write(byte[] buffer, int offset, int length) throws CacheDataSinkExc 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; @@ -164,12 +231,14 @@ public void close() throws CacheDataSinkException { } } - 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) { @@ -197,7 +266,7 @@ private void closeCurrentOutputStream() throws IOException { } 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/CacheDataSinkFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java index ce9735badd6..effb5f213e3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java @@ -17,9 +17,8 @@ import com.google.android.exoplayer2.upstream.DataSink; -/** - * A {@link DataSink.Factory} that produces {@link CacheDataSink}. - */ +/** @deprecated Use {@link CacheDataSink.Factory}. */ +@Deprecated public final class CacheDataSinkFactory implements DataSink.Factory { private final Cache cache; 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 5142f2428df..c2bbdbb893e 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; @@ -23,11 +27,14 @@ 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.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.PriorityDataSource; import com.google.android.exoplayer2.upstream.TeeDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.PriorityTaskManager; import java.io.IOException; import java.io.InterruptedIOException; import java.lang.annotation.Documented; @@ -36,6 +43,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * A {@link DataSource} that reads and writes a {@link Cache}. Requests are fulfilled from the cache @@ -44,6 +52,274 @@ */ public final class CacheDataSource implements DataSource { + /** {@link DataSource.Factory} for {@link CacheDataSource} instances. */ + public static final class Factory implements DataSource.Factory { + + private @MonotonicNonNull Cache cache; + private DataSource.Factory cacheReadDataSourceFactory; + @Nullable private DataSink.Factory cacheWriteDataSinkFactory; + private CacheKeyFactory cacheKeyFactory; + private boolean cacheIsReadOnly; + @Nullable private DataSource.Factory upstreamDataSourceFactory; + @Nullable private PriorityTaskManager upstreamPriorityTaskManager; + private int upstreamPriority; + @CacheDataSource.Flags private int flags; + @Nullable private CacheDataSource.EventListener eventListener; + + public Factory() { + cacheReadDataSourceFactory = new FileDataSource.Factory(); + cacheKeyFactory = CacheKeyFactory.DEFAULT; + } + + /** + * Sets the cache that will be used. + * + *

      Must be called before the factory is used. + * + * @param cache The cache that will be used. + * @return This factory. + */ + public Factory setCache(Cache cache) { + this.cache = cache; + return this; + } + + /** + * Returns the cache that will be used, or {@code null} if {@link #setCache} has yet to be + * called. + */ + @Nullable + public Cache getCache() { + return cache; + } + + /** + * Sets the {@link DataSource.Factory} for {@link DataSource DataSources} for reading from the + * cache. + * + *

      The default is a {@link FileDataSource.Factory} in its default configuration. + * + * @param cacheReadDataSourceFactory The {@link DataSource.Factory} for reading from the cache. + * @return This factory. + */ + public Factory setCacheReadDataSourceFactory(DataSource.Factory cacheReadDataSourceFactory) { + this.cacheReadDataSourceFactory = cacheReadDataSourceFactory; + return this; + } + + /** + * Sets the {@link DataSink.Factory} for generating {@link DataSink DataSinks} for writing data + * to the cache. Passing {@code null} causes the cache to be read-only. + * + *

      The default is a {@link CacheDataSink.Factory} in its default configuration. + * + * @param cacheWriteDataSinkFactory The {@link DataSink.Factory} for generating {@link DataSink + * DataSinks} for writing data to the cache, or {@code null} to disable writing. + * @return This factory. + */ + public Factory setCacheWriteDataSinkFactory( + @Nullable DataSink.Factory cacheWriteDataSinkFactory) { + this.cacheWriteDataSinkFactory = cacheWriteDataSinkFactory; + this.cacheIsReadOnly = cacheWriteDataSinkFactory == null; + return this; + } + + /** + * Sets the {@link CacheKeyFactory}. + * + *

      The default is {@link CacheKeyFactory#DEFAULT}. + * + * @param cacheKeyFactory The {@link CacheKeyFactory}. + * @return This factory. + */ + public Factory setCacheKeyFactory(CacheKeyFactory cacheKeyFactory) { + this.cacheKeyFactory = cacheKeyFactory; + return this; + } + + /** Returns the {@link CacheKeyFactory} that will be used. */ + public CacheKeyFactory getCacheKeyFactory() { + return cacheKeyFactory; + } + + /** + * Sets the {@link DataSource.Factory} for upstream {@link DataSource DataSources}, which are + * used to read data in the case of a cache miss. + * + *

      The default is {@code null}, and so this method must be called before the factory is used + * in order for data to be read from upstream in the case of a cache miss. + * + * @param upstreamDataSourceFactory The upstream {@link DataSource} for reading data not in the + * cache, or {@code null} to cause failure in the case of a cache miss. + * @return This factory. + */ + public Factory setUpstreamDataSourceFactory( + @Nullable DataSource.Factory upstreamDataSourceFactory) { + this.upstreamDataSourceFactory = upstreamDataSourceFactory; + return this; + } + + /** + * Sets an optional {@link PriorityTaskManager} to use when requesting data from upstream. + * + *

      If set, reads from the upstream {@link DataSource} will only be allowed to proceed if + * there are no higher priority tasks registered to the {@link PriorityTaskManager}. If there + * exists a higher priority task then {@link PriorityTaskManager.PriorityTooLowException} will + * be thrown instead. + * + *

      Note that requests to {@link CacheDataSource} instances are intended to be used as parts + * of (possibly larger) tasks that are registered with the {@link PriorityTaskManager}, and + * hence {@link CacheDataSource} does not register a task by itself. This must be done + * by the surrounding code that uses the {@link CacheDataSource} instances. + * + *

      The default is {@code null}. + * + * @param upstreamPriorityTaskManager The upstream {@link PriorityTaskManager}. + * @return This factory. + */ + public Factory setUpstreamPriorityTaskManager( + @Nullable PriorityTaskManager upstreamPriorityTaskManager) { + this.upstreamPriorityTaskManager = upstreamPriorityTaskManager; + return this; + } + + /** + * Returns the {@link PriorityTaskManager} that will bs used when requesting data from upstream, + * or {@code null} if there is none. + */ + @Nullable + public PriorityTaskManager getUpstreamPriorityTaskManager() { + return upstreamPriorityTaskManager; + } + + /** + * Sets the priority to use when requesting data from upstream. The priority is only used if a + * {@link PriorityTaskManager} is set by calling {@link #setUpstreamPriorityTaskManager}. + * + *

      The default is {@link C#PRIORITY_PLAYBACK}. + * + * @param upstreamPriority The priority to use when requesting data from upstream. + * @return This factory. + */ + public Factory setUpstreamPriority(int upstreamPriority) { + this.upstreamPriority = upstreamPriority; + return this; + } + + /** + * Sets the {@link CacheDataSource.Flags}. + * + *

      The default is {@code 0}. + * + * @param flags The {@link CacheDataSource.Flags}. + * @return This factory. + */ + public Factory setFlags(@CacheDataSource.Flags int flags) { + this.flags = flags; + return this; + } + + /** + * Sets the {link EventListener} to which events are delivered. + * + *

      The default is {@code null}. + * + * @param eventListener The {@link EventListener}. + * @return This factory. + */ + public Factory setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener; + return this; + } + + @Override + public CacheDataSource createDataSource() { + return createDataSourceInternal( + upstreamDataSourceFactory != null ? upstreamDataSourceFactory.createDataSource() : null, + flags, + upstreamPriority); + } + + /** + * Returns an instance suitable for downloading content. The created instance is equivalent to + * one that would be created by {@link #createDataSource()}, except: + * + *

        + *
      • The {@link #FLAG_BLOCK_ON_CACHE} is always set. + *
      • The task priority is overridden to be {@link C#PRIORITY_DOWNLOAD}. + *
      + * + * @return An instance suitable for downloading content. + */ + public CacheDataSource createDataSourceForDownloading() { + return createDataSourceInternal( + upstreamDataSourceFactory != null ? upstreamDataSourceFactory.createDataSource() : null, + flags | FLAG_BLOCK_ON_CACHE, + C.PRIORITY_DOWNLOAD); + } + + /** + * Returns an instance suitable for reading cached content as part of removing a download. The + * created instance is equivalent to one that would be created by {@link #createDataSource()}, + * except: + * + *
        + *
      • The upstream is overridden to be {@code null}, since when removing content we don't + * want to request anything that's not already cached. + *
      • The {@link #FLAG_BLOCK_ON_CACHE} is always set. + *
      • The task priority is overridden to be {@link C#PRIORITY_DOWNLOAD}. + *
      + * + * @return An instance suitable for reading cached content as part of removing a download. + */ + public CacheDataSource createDataSourceForRemovingDownload() { + return createDataSourceInternal( + /* upstreamDataSource= */ null, flags | FLAG_BLOCK_ON_CACHE, C.PRIORITY_DOWNLOAD); + } + + private CacheDataSource createDataSourceInternal( + @Nullable DataSource upstreamDataSource, @Flags int flags, int upstreamPriority) { + Cache cache = checkNotNull(this.cache); + @Nullable DataSink cacheWriteDataSink; + if (cacheIsReadOnly || upstreamDataSource == null) { + cacheWriteDataSink = null; + } else if (cacheWriteDataSinkFactory != null) { + cacheWriteDataSink = cacheWriteDataSinkFactory.createDataSink(); + } else { + cacheWriteDataSink = new CacheDataSink.Factory().setCache(cache).createDataSink(); + } + return new CacheDataSource( + cache, + upstreamDataSource, + cacheReadDataSourceFactory.createDataSource(), + cacheWriteDataSink, + cacheKeyFactory, + flags, + upstreamPriorityTaskManager, + upstreamPriority, + eventListener); + } + } + + /** Listener of {@link CacheDataSource} events. */ + public interface EventListener { + + /** + * Called when bytes have been read from the cache. + * + * @param cacheSizeBytes Current cache size in bytes. + * @param cachedBytesRead Total bytes read from the cache since this method was last called. + */ + void onCachedBytesRead(long cacheSizeBytes, long cachedBytesRead); + + /** + * Called when the current request ignores cache. + * + * @param reason Reason cache is bypassed. + */ + void onCacheIgnored(@CacheIgnoredReason int reason); + } + /** * Flags controlling the CacheDataSource's behavior. Possible flag values are {@link * #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} and {@link @@ -96,27 +372,6 @@ public final class CacheDataSource implements DataSource { /** Cache ignored due to a request with an unset length. */ public static final int CACHE_IGNORED_REASON_UNSET_LENGTH = 1; - /** - * Listener of {@link CacheDataSource} events. - */ - public interface EventListener { - - /** - * Called when bytes have been read from the cache. - * - * @param cacheSizeBytes Current cache size in bytes. - * @param cachedBytesRead Total bytes read from the cache since this method was last called. - */ - void onCachedBytesRead(long cacheSizeBytes, long cachedBytesRead); - - /** - * Called when the current request ignores cache. - * - * @param reason Reason cache is bypassed. - */ - void onCacheIgnored(@CacheIgnoredReason int reason); - } - /** Minimum number of bytes to read before checking cache for availability. */ private static final long MIN_READ_BEFORE_CHECKING_CACHE = 100 * 1024; @@ -148,10 +403,11 @@ public interface EventListener { * reading and writing the cache. * * @param cache The cache. - * @param upstream A {@link DataSource} for reading data not in the cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. */ - public CacheDataSource(Cache cache, DataSource upstream) { - this(cache, upstream, /* flags= */ 0); + public CacheDataSource(Cache cache, @Nullable DataSource upstreamDataSource) { + this(cache, upstreamDataSource, /* flags= */ 0); } /** @@ -159,14 +415,15 @@ public CacheDataSource(Cache cache, DataSource upstream) { * reading and writing the cache. * * @param cache The cache. - * @param upstream A {@link DataSource} for reading data not in the cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. */ - public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags) { + public CacheDataSource(Cache cache, @Nullable DataSource upstreamDataSource, @Flags int flags) { this( cache, - upstream, + upstreamDataSource, new FileDataSource(), new CacheDataSink(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), flags, @@ -179,7 +436,8 @@ public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags) { * before it is written to disk. * * @param cache The cache. - * @param upstream A {@link DataSource} for reading data not in the cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. * @param cacheReadDataSource A {@link DataSource} for reading data from the cache. * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is * accessed read-only. @@ -189,14 +447,14 @@ public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags) { */ public CacheDataSource( Cache cache, - DataSource upstream, + @Nullable DataSource upstreamDataSource, DataSource cacheReadDataSource, @Nullable DataSink cacheWriteDataSink, @Flags int flags, @Nullable EventListener eventListener) { this( cache, - upstream, + upstreamDataSource, cacheReadDataSource, cacheWriteDataSink, flags, @@ -210,7 +468,8 @@ public CacheDataSource( * before it is written to disk. * * @param cache The cache. - * @param upstream A {@link DataSource} for reading data not in the cache. + * @param upstreamDataSource A {@link DataSource} for reading data not in the cache. If null, + * reading will fail if a cache miss occurs. * @param cacheReadDataSource A {@link DataSource} for reading data from the cache. * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is * accessed read-only. @@ -221,31 +480,72 @@ public CacheDataSource( */ public CacheDataSource( Cache cache, - DataSource upstream, + @Nullable DataSource upstreamDataSource, DataSource cacheReadDataSource, @Nullable DataSink cacheWriteDataSink, @Flags int flags, @Nullable EventListener eventListener, @Nullable CacheKeyFactory cacheKeyFactory) { + this( + cache, + upstreamDataSource, + cacheReadDataSource, + cacheWriteDataSink, + cacheKeyFactory, + flags, + /* upstreamPriorityTaskManager= */ null, + /* upstreamPriority= */ C.PRIORITY_PLAYBACK, + eventListener); + } + + private CacheDataSource( + Cache cache, + @Nullable DataSource upstreamDataSource, + DataSource cacheReadDataSource, + @Nullable DataSink cacheWriteDataSink, + @Nullable CacheKeyFactory cacheKeyFactory, + @Flags int flags, + @Nullable PriorityTaskManager upstreamPriorityTaskManager, + int upstreamPriority, + @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.upstreamDataSource = upstream; - if (cacheWriteDataSink != null) { - this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink); + if (upstreamDataSource != null) { + if (upstreamPriorityTaskManager != null) { + upstreamDataSource = + new PriorityDataSource( + upstreamDataSource, upstreamPriorityTaskManager, upstreamPriority); + } + this.upstreamDataSource = upstreamDataSource; + this.cacheWriteDataSource = + cacheWriteDataSink != null + ? new TeeDataSource(upstreamDataSource, cacheWriteDataSink) + : null; } else { + this.upstreamDataSource = DummyDataSource.INSTANCE; this.cacheWriteDataSource = null; } this.eventListener = eventListener; } + /** Returns the {@link Cache} used by this instance. */ + public Cache getCache() { + return cache; + } + + /** Returns the {@link CacheKeyFactory} used by this instance. */ + public CacheKeyFactory getCacheKeyFactory() { + return cacheKeyFactory; + } + @Override public void addTransferListener(TransferListener transferListener) { + checkNotNull(transferListener); cacheReadDataSource.addTransferListener(transferListener); upstreamDataSource.addTransferListener(transferListener); } @@ -254,7 +554,8 @@ public void addTransferListener(TransferListener transferListener) { 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; @@ -275,7 +576,7 @@ public long open(DataSpec dataSpec) throws IOException { } } } - openNextSource(false); + openNextSource(requestDataSpec, false); return bytesRemaining; } catch (Throwable e) { handleBeforeThrow(e); @@ -285,6 +586,7 @@ public long open(DataSpec dataSpec) throws IOException { @Override public int read(byte[] buffer, int offset, int readLength) throws IOException { + DataSpec requestDataSpec = checkNotNull(this.requestDataSpec); if (readLength == 0) { return 0; } @@ -293,9 +595,9 @@ public int read(byte[] buffer, int offset, int readLength) throws IOException { } 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; @@ -305,16 +607,16 @@ public int read(byte[] buffer, int offset, int readLength) throws IOException { 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); @@ -364,23 +666,24 @@ public void close() throws IOException { * 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; @@ -393,12 +696,12 @@ private void openNextSource(boolean checkCache) throws IOException { 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 @@ -417,7 +720,7 @@ private void openNextSource(boolean checkCache) throws IOException { } else { length = nextSpan.length; if (bytesRemaining != C.LENGTH_UNSET) { - length = Math.min(length, bytesRemaining); + length = min(length, bytesRemaining); } } nextDataSpec = @@ -445,7 +748,7 @@ private void openNextSource(boolean checkCache) throws IOException { 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); } @@ -467,7 +770,7 @@ private void openNextSource(boolean checkCache) throws IOException { 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); } @@ -476,12 +779,12 @@ private void openNextSource(boolean checkCache) throws IOException { } } - 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 21758bdcebf..2c51da8a8d4 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 @@ -20,7 +20,8 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.FileDataSource; -/** A {@link DataSource.Factory} that produces {@link CacheDataSource}. */ +/** @deprecated Use {@link CacheDataSource.Factory}. */ +@Deprecated public final class CacheDataSourceFactory implements DataSource.Factory { private final Cache cache; @@ -50,7 +51,7 @@ public CacheDataSourceFactory( cache, upstreamFactory, new FileDataSource.Factory(), - new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), + new CacheDataSink.Factory().setCache(cache), flags, /* eventListener= */ null); } 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 3401d6f5751..69e9b73fdde 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 @@ /** 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}. + * + *

      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 data being cached. - * @return The cache key. + * @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 bf51a692401..492681e7fcd 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 @@ */ 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 CacheSpan(String key, long position, long length) { /** * 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 int compareTo(CacheSpan another) { 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 8da2fb1defb..00000000000 --- a/library/core/src/main/java/com/google/android/exoplayer2/upstream/cache/CacheUtil.java +++ /dev/null @@ -1,432 +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.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. - * - *

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

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

      This method may be slow and shouldn't normally be called on the main thread. - * - * @param dataSpec Defines the data to be cached. - * @param cache A {@link Cache} to store the data. - * @param cacheKeyFactory An optional factory for cache keys. - * @param dataSource A {@link CacheDataSource} that works on the {@code cache}. - * @param buffer The buffer to be used while caching. - * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with - * caching. - * @param priority The priority of this task. Used with {@code priorityTaskManager}. - * @param progressListener A listener to receive progress updates, or {@code null}. - * @param isCanceled An optional flag that will interrupt caching if set to true. - * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been - * reached unexpectedly. - * @throws IOException If an error occurs reading from the source. - * @throws InterruptedException If the thread was interrupted directly or via {@code isCanceled}. - */ - @WorkerThread - public static void cache( - DataSpec dataSpec, - Cache cache, - @Nullable CacheKeyFactory cacheKeyFactory, - CacheDataSource dataSource, - byte[] buffer, - @Nullable PriorityTaskManager priorityTaskManager, - int priority, - @Nullable ProgressListener progressListener, - @Nullable AtomicBoolean isCanceled, - boolean enableEOFException) - throws IOException, InterruptedException { - Assertions.checkNotNull(dataSource); - Assertions.checkNotNull(buffer); - - 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) { - throwExceptionIfInterruptedOrCancelled(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, - buffer, - priorityTaskManager, - priority, - progressNotifier, - isLastBlock, - isCanceled); - 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 DataSource} to read the data from. - * @param buffer The buffer to be used while downloading. - * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with - * caching. - * @param priority The priority of this task. - * @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 isCanceled An optional flag that will interrupt caching if set to true. - * @return Number of read bytes, or 0 if no data is available because the end of the opened range - * has been reached. - */ - private static long readAndDiscard( - DataSpec dataSpec, - long position, - long length, - DataSource dataSource, - byte[] buffer, - @Nullable PriorityTaskManager priorityTaskManager, - int priority, - @Nullable ProgressNotifier progressNotifier, - boolean isLastBlock, - @Nullable AtomicBoolean isCanceled) - throws IOException, InterruptedException { - long positionOffset = position - dataSpec.position; - long initialPositionOffset = positionOffset; - long endOffset = length != C.LENGTH_UNSET ? positionOffset + length : C.POSITION_UNSET; - while (true) { - if (priorityTaskManager != null) { - // Wait for any other thread with higher priority to finish its job. - priorityTaskManager.proceed(priority); - } - throwExceptionIfInterruptedOrCancelled(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) { - throwExceptionIfInterruptedOrCancelled(isCanceled); - int bytesRead = - dataSource.read( - buffer, - 0, - endOffset != C.POSITION_UNSET - ? (int) Math.min(buffer.length, endOffset - positionOffset) - : buffer.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 throwExceptionIfInterruptedOrCancelled(@Nullable AtomicBoolean isCanceled) - throws InterruptedException { - if (Thread.interrupted() || (isCanceled != null && isCanceled.get())) { - throw new InterruptedException(); - } - } - - 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 00000000000..8ea2b4e2803 --- /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 7abb9b3896d..4c3f58f2c62 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 @@ public CachedContent(int id, String key, DefaultContentMetadata metadata) { 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 @@ public boolean applyMetadataMutations(ContentMetadataMutations mutations) { 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(); + } + + /** + * 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; } - /** Sets the locked state of the content. */ - public void setLocked(boolean locked) { - this.locked = locked; + /** + * 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 @@ public TreeSet getSpans() { } /** - * 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 @@ public long getCachedBytesLength(long position, long length) { } // 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 @@ public long getCachedBytesLength(long position, long length) { */ 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 @@ public boolean isEmpty() { /** 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 @@ public boolean equals(@Nullable Object o) { && 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 43bf691701a..850ac59f048 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.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.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 @@ public CachedContentIndex( @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 @@ public CachedContentIndex( legacyStorageEncrypt) : null; if (databaseStorage == null || (legacyStorage != null && preferLegacyStorage)) { - storage = legacyStorage; + storage = castNonNull(legacyStorage); previousStorage = databaseStorage; } else { storage = databaseStorage; @@ -223,31 +232,35 @@ public void store() throws IOException { } /** - * 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 @@ public String getKeyForId(int id) { 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 @@ public void maybeRemove(String key) { } } - /** 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 @@ public void applyContentMetadataMutations(String key, ContentMetadataMutations m /** 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 @@ private static Cipher getCipher() throws NoSuchPaddingException, NoSuchAlgorithm * 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 @@ private static DefaultContentMetadata readContentMetadata(DataInputStream input) // 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 @@ private static class LegacyStorage implements Storage { @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 @@ public void delete() { @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 @@ private boolean readFile( return true; } - DataInputStream input = null; + @Nullable DataInputStream input = null; try { InputStream inputStream = new BufferedInputStream(atomicFile.openRead()); input = new DataInputStream(inputStream); @@ -595,7 +613,7 @@ private boolean readFile( 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 @@ private void writeFile(HashMap content) throws IOExceptio } else { bufferedOutputStream.reset(outputStream); } + ReusableBufferedOutputStream bufferedOutputStream = this.bufferedOutputStream; output = new DataOutputStream(bufferedOutputStream); output.writeInt(VERSION); @@ -644,11 +663,12 @@ private void writeFile(HashMap content) throws IOExceptio 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 @@ private static final class DatabaseStorage implements Storage { + " 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 @@ public boolean exists() throws DatabaseIOException { 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 @@ public void storeIncremental(HashMap content) throws IOEx 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 @@ private Cursor getCursor() { return databaseProvider .getReadableDatabase() .query( - tableName, + checkNotNull(tableName), COLUMNS, /* selection= */ null, /* selectionArgs= */ null, @@ -906,13 +927,17 @@ private Cursor getCursor() { 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 @@ private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cache 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 c3f06252e41..706fa0d2c3b 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 byte[] get(String name, @Nullable byte[] defaultValue) { 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 @@ private static byte[] getBytes(Object value) { 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 c88e2643d8a..fb461813aef 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 void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) { 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 721dac4d4e2..29c09ff4868 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 static void delete(File cacheDir, @Nullable DatabaseProvider databaseProv * @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 synchronized void release() { @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 synchronized long getCacheSpace() { } @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 synchronized CacheSpan startReadWrite(String key, long position) @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 synchronized CacheSpan startReadWriteNonBlocking(String key, long positio } 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 synchronized File startFile(String key, long position, long length) throw 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 synchronized void commitFile(File file, long length) throws CacheExceptio 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 synchronized void commitFile(File file, long length) throws CacheExceptio 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 synchronized boolean isCached(String key, long position, long length) { @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 @@ private SimpleCacheSpan touchSpan(String key, SimpleCacheSpan span) { } /** - * 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 d8a0671469b..d02f7c0988b 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.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 @@ * * @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 @@ public static File getCacheFile(File cacheDir, int id, long position, long times /** * 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 @@ public static SimpleCacheSpan createLookup(String key, long position) { } /** - * 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. - */ - 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. + * @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 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 @@ private static File upgradeFile(File file, CachedContentIndex index) { } /** - * @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 95295035e70..c1118c01a93 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 void write(byte[] data, int offset, int length) throws IOException { // 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 665a47191e4..5abe42b9376 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 AesCipherDataSource(byte[] secretKey, DataSource upstream) { @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 184ecc382fe..bbdfd23d8c2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ConditionVariable.java @@ -16,13 +16,39 @@ package com.google.android.exoplayer2.util; /** - * An interruptible condition variable whose {@link #open()} and {@link #close()} methods return - * whether they resulted in a change of state. + * An interruptible condition variable. This class provides a number of benefits over {@link + * android.os.ConditionVariable}: + * + *

        + *
      • Consistent use of ({@link Clock#elapsedRealtime()} for timing {@link #block(long)} timeout + * intervals. {@link android.os.ConditionVariable} used {@link System#currentTimeMillis()} + * prior to Android 10, which is not a correct clock to use for interval timing because it's + * not guaranteed to be monotonic. + *
      • Support for injecting a custom {@link Clock}. + *
      • The ability to query the variable's current state, by calling {@link #isOpen()}. + *
      • {@link #open()} and {@link #close()} return whether they changed the variable's state. + *
      */ public class ConditionVariable { + private final Clock clock; private boolean isOpen; + /** Creates an instance using {@link Clock#DEFAULT}. */ + public ConditionVariable() { + this(Clock.DEFAULT); + } + + /** + * 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. + */ + public ConditionVariable(Clock clock) { + this.clock = clock; + } + /** * Opens the condition and releases all threads that are blocked. * @@ -60,22 +86,51 @@ public synchronized void block() throws InterruptedException { } /** - * Blocks until the condition is opened or until {@code timeout} milliseconds have passed. + * Blocks until the condition is opened or until {@code timeoutMs} have passed. * - * @param timeout The maximum time to wait in milliseconds. + * @param timeoutMs The maximum time to wait in milliseconds. If {@code timeoutMs <= 0} then the + * call will return immediately without blocking. * @return True if the condition was opened, false if the call returns because of the timeout. * @throws InterruptedException If the thread is interrupted. */ - public synchronized boolean block(long timeout) throws InterruptedException { - long now = android.os.SystemClock.elapsedRealtime(); - long end = now + timeout; - while (!isOpen && now < end) { - wait(end - now); - now = android.os.SystemClock.elapsedRealtime(); + public synchronized boolean block(long timeoutMs) throws InterruptedException { + if (timeoutMs <= 0) { + return isOpen; + } + long nowMs = clock.elapsedRealtime(); + long endMs = nowMs + timeoutMs; + if (endMs < nowMs) { + // timeoutMs is large enough for (nowMs + timeoutMs) to rollover. Block indefinitely. + block(); + } else { + while (!isOpen && nowMs < endMs) { + wait(endMs - nowMs); + nowMs = clock.elapsedRealtime(); + } } return isOpen; } + /** + * 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 07f278c8084..00000000000 --- 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 3136556f2c1..a441e81bc4a 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 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 void onSeekStarted(EventTime eventTime) { @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 void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason + 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 void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason 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 void onMetadata(EventTime eventTime, Metadata metadata) { } @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 void onVolumeChanged(EventTime eventTime, float volume) { } @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 @@ -365,13 +384,8 @@ public void onDroppedVideoFrames(EventTime eventTime, int count, long elapsedMs) } @Override - public void onVideoSizeChanged( - EventTime eventTime, - int width, - int height, - int unappliedRotationDegrees, - float pixelWidthHeightRatio) { - logd(eventTime, "videoSize", width + ", " + height); + public void onVideoDisabled(EventTime eventTime, DecoderCounters counters) { + logd(eventTime, "videoDisabled"); } @Override @@ -380,13 +394,13 @@ public void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) } @Override - public void onMediaPeriodCreated(EventTime eventTime) { - logd(eventTime, "mediaPeriodCreated"); - } - - @Override - public void onMediaPeriodReleased(EventTime eventTime) { - logd(eventTime, "mediaPeriodReleased"); + public void onVideoSizeChanged( + EventTime eventTime, + int width, + int height, + int unappliedRotationDegrees, + float pixelWidthHeightRatio) { + logd(eventTime, "videoSize", width + ", " + height); } @Override @@ -417,11 +431,6 @@ public void onLoadCompleted( // 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 @@ private String getEventTimeString(EventTime eventTime) { return "eventTime=" + getTimeString(eventTime.realtimeMs - startTimeMs) + ", mediaPos=" - + getTimeString(eventTime.currentPlaybackPositionMs) + + getTimeString(eventTime.eventPlaybackPositionMs) + ", " + windowPeriodString; } @@ -648,6 +657,22 @@ private static String getTimelineChangeReasonString(@Player.TimelineChangeReason } } + 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 e90d133334f..f38fd61cafc 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 void bind() { 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 static boolean isProtectedContentExtensionSupported(Context context) { } /** - * 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 3277d042edc..5deb5f2b606 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 @@ 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 44c3c5e7fae..df335908c09 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 c58221a12cd..00000000000 --- 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 756494f9d0e..6c2b3373442 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 static void createNotificationChannel( @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 static void createNotificationChannel( */ 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 2ebda60821d..bf03c5f2297 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 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 00000000000..9da5f096290 --- /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 fa2edf253de..03336fdeba8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/SntpClient.java @@ -27,6 +27,7 @@ import java.net.DatagramSocket; import java.net.InetAddress; import java.util.Arrays; +import java.util.ConcurrentModificationException; /** * Static utility to retrieve the device time offset using SNTP. @@ -37,6 +38,9 @@ */ public final class SntpClient { + /** The default NTP host address used to retrieve {@link #getElapsedRealtimeOffsetMs()}. */ + public static final String DEFAULT_NTP_HOST = "time.android.com"; + /** Callback for calls to {@link #initialize(Loader, InitializationCallback)}. */ public interface InitializationCallback { @@ -51,7 +55,6 @@ public interface InitializationCallback { void onInitializationFailed(IOException error); } - private static final String NTP_HOST = "pool.ntp.org"; private static final int TIMEOUT_MS = 10_000; private static final int ORIGINATE_TIME_OFFSET = 24; @@ -80,8 +83,37 @@ public interface InitializationCallback { @GuardedBy("valueLock") private static long elapsedRealtimeOffsetMs; + @GuardedBy("valueLock") + private static String ntpHost = DEFAULT_NTP_HOST; + private SntpClient() {} + /** Returns the NTP host address used to retrieve {@link #getElapsedRealtimeOffsetMs()}. */ + public static String getNtpHost() { + synchronized (valueLock) { + return ntpHost; + } + } + + /** + * Sets the NTP host address used to retrieve {@link #getElapsedRealtimeOffsetMs()}. + * + *

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

      If the new host address is different from the previous one, the NTP client will be {@link + * #isInitialized()} uninitialized} again. + * + * @param ntpHost The NTP host address. + */ + public static void setNtpHost(String ntpHost) { + synchronized (valueLock) { + if (!SntpClient.ntpHost.equals(ntpHost)) { + SntpClient.ntpHost = ntpHost; + isInitialized = false; + } + } + } + /** * Returns whether the device time offset has already been loaded. * @@ -129,7 +161,7 @@ public static void initialize( } private static long loadNtpTimeOffsetMs() throws IOException { - InetAddress address = InetAddress.getByName(NTP_HOST); + InetAddress address = InetAddress.getByName(getNtpHost()); try (DatagramSocket socket = new DatagramSocket()) { socket.setSoTimeout(TIMEOUT_MS); byte[] buffer = new byte[NTP_PACKET_SIZE]; @@ -160,7 +192,7 @@ private static long loadNtpTimeOffsetMs() throws IOException { 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 @@ -282,9 +314,14 @@ public NtpTimeCallback(@Nullable InitializationCallback callback) { @Override public void onLoadCompleted(Loadable loadable, long elapsedRealtimeMs, long loadDurationMs) { - Assertions.checkState(SntpClient.isInitialized()); if (callback != null) { - callback.onInitialized(); + if (!SntpClient.isInitialized()) { + // This may happen in the unlikely edge case of someone calling setNtpHost between the end + // of the load method and this callback. + callback.onInitializationFailed(new IOException(new ConcurrentModificationException())); + } else { + callback.onInitialized(); + } } } 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 e1df77a2002..87970d3c003 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 long getPositionUs() { 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/SystemClock.java b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java index a094e810bf2..89e1c60d7a0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/SystemClock.java @@ -21,9 +21,12 @@ import androidx.annotation.Nullable; /** - * The standard implementation of {@link Clock}. + * The standard implementation of {@link Clock}, an instance of which is available via {@link + * SystemClock#DEFAULT}. */ -/* package */ final class SystemClock implements Clock { +public class SystemClock implements Clock { + + protected SystemClock() {} @Override public long currentTimeMillis() { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/TimedValueQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/util/TimedValueQueue.java index da5d9bafeb5..d49b37224c2 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 synchronized int size() { 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 synchronized int size() { * 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 synchronized int size() { * @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 synchronized int size() { */ @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 @@ private V poll(long timestamp, boolean onlyOlder) { 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 @@ private void doubleCapacityIfFull() { } 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 @@ private void addUnchecked(long timestamp, V value) { } @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 72c4ac2956d..6fda6d2e9c9 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 boolean isEnded() { @Override public boolean isReady() { - if (waitingForKeys) { - return false; - } if (inputFormat != null && (isSourceReady() || outputBuffer != null) && (renderedFirstFrameAfterReset || !hasOutput())) { @@ -293,7 +291,6 @@ protected void onStopped() { @Override protected void onDisabled() { inputFormat = null; - waitingForKeys = false; clearReportedVideoSize(); clearRenderedFirstFrame(); try { @@ -305,12 +302,13 @@ protected void onDisabled() { } @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 @@ protected void onDecoderInitialized( */ @CallSuper protected void flushDecoder() throws ExoPlaybackException { - waitingForKeys = false; buffersInCodecCount = 0; if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { releaseDecoder(); @@ -379,9 +376,12 @@ protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybac waitingForFirstSampleInFormat = true; Format newFormat = Assertions.checkNotNull(formatHolder.format); setSourceDrmSession(formatHolder.drmSession); + Format oldFormat = inputFormat; inputFormat = newFormat; - if (sourceDrmSession != decoderDrmSession) { + if (decoder == null) { + maybeInitDecoder(); + } else if (sourceDrmSession != decoderDrmSession || !canKeepCodec(oldFormat, inputFormat)) { if (decoderReceivedBuffers) { // Signal end of stream and wait for any final output buffers before re-initialization. decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; @@ -507,7 +507,7 @@ protected void updateDroppedBufferCounters(int droppedBufferCount) { droppedFrames += droppedBufferCount; consecutiveDroppedFrameCount += droppedBufferCount; decoderCounters.maxConsecutiveDroppedBufferCount = - Math.max(consecutiveDroppedFrameCount, decoderCounters.maxConsecutiveDroppedBufferCount); + max(consecutiveDroppedFrameCount, decoderCounters.maxConsecutiveDroppedBufferCount); if (maxDroppedFramesToNotify > 0 && droppedFrames >= maxDroppedFramesToNotify) { maybeNotifyDroppedFrames(); } @@ -640,6 +640,17 @@ protected final void setOutputBufferRenderer( */ protected abstract void setDecoderOutputMode(@C.VideoOutputMode int outputMode); + /** + * Returns whether the existing decoder can be kept for a new format. + * + * @param oldFormat The previous format. + * @param newFormat The new format. + * @return Whether the existing decoder can be kept. + */ + protected boolean canKeepCodec(Format oldFormat, Format newFormat) { + return false; + } + // Internal methods. private void setSourceDrmSession(@Nullable DrmSession session) { @@ -712,46 +723,36 @@ private boolean feedInputBuffer() throws DecoderException, ExoPlaybackException 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); - } - - 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; + 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 (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; } /** @@ -889,19 +890,6 @@ private void onOutputReset() { 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 a5dd9cfefb3..a68a64b28d2 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 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 static synchronized boolean isSecureSupported(Context context) { /** * 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 @@ private static int getSecureMode(Context context) { } } - 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 794bc5f7e4e..5b265882440 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.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; @@ -55,6 +60,7 @@ import com.google.android.exoplayer2.util.TraceUtil; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoRendererEventListener.EventDispatcher; +import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.util.Collections; import java.util.List; @@ -98,6 +104,24 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { /** Magic frame render timestamp that indicates the EOS in tunneling mode. */ private static final long TUNNELING_EOS_PRESENTATION_TIME_US = Long.MAX_VALUE; + // TODO: Remove reflection once we target API level 30. + @Nullable private static final Method surfaceSetFrameRateMethod; + + static { + @Nullable Method setFrameRateMethod = null; + if (Util.SDK_INT >= 30) { + try { + setFrameRateMethod = Surface.class.getMethod("setFrameRate", float.class, int.class); + } catch (NoSuchMethodException e) { + // Do nothing. + } + } + surfaceSetFrameRateMethod = setFrameRateMethod; + } + // TODO: Remove these constants and use those defined by Surface once we target API level 30. + private static final int SURFACE_FRAME_RATE_COMPATIBILITY_DEFAULT = 0; + private static final int SURFACE_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE = 1; + private static boolean evaluatedDeviceNeedsSetOutputSurfaceWorkaround; private static boolean deviceNeedsSetOutputSurfaceWorkaround; @@ -113,7 +137,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private boolean codecHandlesHdr10PlusOutOfBandMetadata; private Surface surface; + private float surfaceFrameRate; private Surface dummySurface; + private boolean haveReportedFirstFrameRenderedForCurrentSurface; @VideoScalingMode private int scalingMode; private boolean renderedFirstFrameAfterReset; private boolean mayRenderFirstFrameAfterEnableIfNotStarted; @@ -128,13 +154,11 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { private long totalVideoFrameProcessingOffsetUs; private int videoFrameProcessingOffsetCount; - private int pendingRotationDegrees; - private float pendingPixelWidthHeightRatio; - @Nullable private MediaFormat currentMediaFormat; private int currentWidth; private int currentHeight; private int currentUnappliedRotationDegrees; private float currentPixelWidthHeightRatio; + private float currentFrameRate; private int reportedWidth; private int reportedHeight; private int reportedUnappliedRotationDegrees; @@ -235,7 +259,6 @@ public MediaCodecVideoRenderer( currentWidth = Format.NO_VALUE; currentHeight = Format.NO_VALUE; currentPixelWidthHeightRatio = Format.NO_VALUE; - pendingPixelWidthHeightRatio = Format.NO_VALUE; scalingMode = VIDEO_SCALING_MODE_DEFAULT; clearReportedVideoSize(); } @@ -408,6 +431,7 @@ protected void onStarted() { lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; totalVideoFrameProcessingOffsetUs = 0; videoFrameProcessingOffsetCount = 0; + updateSurfaceFrameRate(/* isNewSurface= */ false); } @Override @@ -415,14 +439,15 @@ protected void onStopped() { joiningDeadlineMs = C.TIME_UNSET; maybeNotifyDroppedFrames(); maybeNotifyVideoFrameProcessingOffset(); + clearSurfaceFrameRate(); super.onStopped(); } @Override protected void onDisabled() { - currentMediaFormat = null; clearReportedVideoSize(); clearRenderedFirstFrame(); + haveReportedFirstFrameRenderedForCurrentSurface = false; frameReleaseTimeHelper.disable(); tunnelingOnFrameRenderedListener = null; try { @@ -479,7 +504,11 @@ private void setSurface(Surface surface) throws ExoPlaybackException { } // We only need to update the codec if the surface has changed. if (this.surface != surface) { + clearSurfaceFrameRate(); this.surface = surface; + haveReportedFirstFrameRenderedForCurrentSurface = false; + updateSurfaceFrameRate(/* isNewSurface= */ true); + @State int state = getState(); MediaCodec codec = getCodec(); if (codec != null) { @@ -487,7 +516,7 @@ private void setSurface(Surface surface) throws ExoPlaybackException { setOutputSurfaceV23(codec, surface); } else { releaseCodec(); - maybeInitCodec(); + maybeInitCodecOrBypass(); } } if (surface != null && surface != dummySurface) { @@ -525,7 +554,7 @@ protected boolean getCodecNeedsEosPropagation() { @Override protected void configureCodec( MediaCodecInfo codecInfo, - MediaCodec codec, + MediaCodecAdapter codecAdapter, Format format, @Nullable MediaCrypto crypto, float codecOperatingRate) { @@ -548,9 +577,9 @@ protected void configureCodec( } 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()); } } @@ -576,6 +605,12 @@ protected void resetCodecStateForFlush() { buffersInCodecCount = 0; } + @Override + public void setOperatingRate(float operatingRate) throws ExoPlaybackException { + super.setOperatingRate(operatingRate); + updateSurfaceFrameRate(/* isNewSurface= */ false); + } + @Override protected float getCodecOperatingRateV23( float operatingRate, Format format, Format[] streamFormats) { @@ -585,7 +620,7 @@ protected float getCodecOperatingRateV23( 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); @@ -603,20 +638,20 @@ protected void onCodecInitialized(String name, long initializedTimestampMs, @Override protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { super.onInputFormatChanged(formatHolder); - Format newFormat = formatHolder.format; - eventDispatcher.inputFormatChanged(newFormat); - pendingPixelWidthHeightRatio = newFormat.pixelWidthHeightRatio; - pendingRotationDegrees = newFormat.rotationDegrees; + eventDispatcher.inputFormatChanged(formatHolder.format); } /** * 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) { @@ -630,27 +665,48 @@ protected void onQueueInputBuffer(DecoderInputBuffer buffer) { } @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); - int mediaFormatWidth = - hasCrop - ? outputMediaFormat.getInteger(KEY_CROP_RIGHT) - - outputMediaFormat.getInteger(KEY_CROP_LEFT) - + 1 - : outputMediaFormat.getInteger(MediaFormat.KEY_WIDTH); - int mediaFormatHeight = - hasCrop - ? outputMediaFormat.getInteger(KEY_CROP_BOTTOM) - - outputMediaFormat.getInteger(KEY_CROP_TOP) - + 1 - : outputMediaFormat.getInteger(MediaFormat.KEY_HEIGHT); - processOutputFormat(codec, mediaFormatWidth, mediaFormatHeight); - maybeNotifyVideoFrameProcessingOffset(); + 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); + } + 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 (format.rotationDegrees == 90 || format.rotationDegrees == 270) { + int rotatedHeight = currentWidth; + currentWidth = currentHeight; + currentHeight = rotatedHeight; + currentPixelWidthHeightRatio = 1 / currentPixelWidthHeightRatio; + } + } else { + // On API level 20 and below the decoder does not apply the rotation. + currentUnappliedRotationDegrees = format.rotationDegrees; + } + currentFrameRate = format.frameRate; + updateSurfaceFrameRate(/* isNewSurface= */ false); } @Override @@ -688,8 +744,8 @@ protected void handleInputBufferSupplementalData(DecoderInputBuffer buffer) protected boolean processOutputBuffer( long positionUs, long elapsedRealtimeUs, - MediaCodec codec, - ByteBuffer buffer, + @Nullable MediaCodec codec, + @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, int sampleCount, @@ -698,6 +754,8 @@ protected boolean processOutputBuffer( boolean isLastBuffer, Format format) throws ExoPlaybackException { + Assertions.checkNotNull(codec); // Can not render video without codec + if (initialPositionUs == C.TIME_UNSET) { initialPositionUs = positionUs; } @@ -715,7 +773,7 @@ protected boolean processOutputBuffer( // 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; @@ -736,13 +794,13 @@ protected boolean processOutputBuffer( || (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; } @@ -775,17 +833,16 @@ && maybeDropBuffersToKeyframe( } 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 { @@ -802,10 +859,9 @@ && maybeDropBuffersToKeyframe( return false; } } - notifyFrameMetadataListener( - presentationTimeUs, adjustedReleaseTimeNs, format, currentMediaFormat); + notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format); renderOutputBuffer(codec, bufferIndex, presentationTimeUs); - decoderCounters.addVideoFrameProcessingOffsetSample(earlyUs); + updateVideoFrameProcessingOffsetCounters(earlyUs); return true; } } @@ -814,42 +870,17 @@ && maybeDropBuffersToKeyframe( return false; } - private void processOutputFormat(MediaCodec codec, int width, int height) { - currentWidth = width; - currentHeight = height; - currentPixelWidthHeightRatio = pendingPixelWidthHeightRatio; - 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 (pendingRotationDegrees == 90 || pendingRotationDegrees == 270) { - int rotatedHeight = currentWidth; - currentWidth = currentHeight; - currentHeight = rotatedHeight; - currentPixelWidthHeightRatio = 1 / currentPixelWidthHeightRatio; - } - } else { - // On API level 20 and below the decoder does not apply the rotation. - currentUnappliedRotationDegrees = pendingRotationDegrees; - } - // Must be applied each time the output MediaFormat changes. - codec.setVideoScalingMode(scalingMode); - } - 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) { - @Nullable Format format = updateOutputFormatForTime(presentationTimeUs); - if (format != null) { - processOutputFormat(getCodec(), format.width, format.height); - } + protected void onProcessedTunneledBuffer(long presentationTimeUs) throws ExoPlaybackException { + updateOutputFormatForTime(presentationTimeUs); maybeNotifyVideoSizeChanged(); decoderCounters.renderedOutputBufferCount++; maybeNotifyRenderedFirstFrame(); @@ -986,8 +1017,8 @@ protected boolean maybeDropBuffersToKeyframe( } /** - * 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. */ @@ -995,13 +1026,24 @@ protected void updateDroppedBufferCounters(int droppedBufferCount) { 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. @@ -1043,6 +1085,52 @@ protected void renderOutputBufferV21( maybeNotifyRenderedFirstFrame(); } + /** + * Updates the frame-rate of the current {@link #surface} based on the renderer operating rate, + * frame-rate of the content, and whether the renderer is started. + * + * @param isNewSurface Whether the current {@link #surface} is new. + */ + private void updateSurfaceFrameRate(boolean isNewSurface) { + if (Util.SDK_INT < 30 || surface == null || surface == dummySurface) { + return; + } + boolean shouldSetFrameRate = getState() == STATE_STARTED && currentFrameRate != Format.NO_VALUE; + float surfaceFrameRate = shouldSetFrameRate ? currentFrameRate * getOperatingRate() : 0; + // We always set the frame-rate if we have a new surface, since we have no way of knowing what + // it might have been set to previously. + if (this.surfaceFrameRate == surfaceFrameRate && !isNewSurface) { + return; + } + this.surfaceFrameRate = surfaceFrameRate; + setSurfaceFrameRateV30(surface, surfaceFrameRate); + } + + /** Clears the frame-rate of the current {@link #surface}. */ + private void clearSurfaceFrameRate() { + if (Util.SDK_INT < 30 || surface == null || surface == dummySurface || surfaceFrameRate == 0) { + return; + } + surfaceFrameRate = 0; + setSurfaceFrameRateV30(surface, /* frameRate= */ 0); + } + + @RequiresApi(30) + private void setSurfaceFrameRateV30(Surface surface, float frameRate) { + if (surfaceSetFrameRateMethod == null) { + Log.e(TAG, "Failed to call Surface.setFrameRate (method does not exist)"); + } + int compatibility = + frameRate == 0 + ? SURFACE_FRAME_RATE_COMPATIBILITY_DEFAULT + : SURFACE_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE; + try { + surfaceSetFrameRateMethod.invoke(surface, frameRate, compatibility); + } catch (Exception e) { + Log.e(TAG, "Failed to call Surface.setFrameRate", e); + } + } + private boolean shouldUseDummySurface(MediaCodecInfo codecInfo) { return Util.SDK_INT >= 23 && !tunneling @@ -1075,11 +1163,12 @@ private void clearRenderedFirstFrame() { if (!renderedFirstFrameAfterReset) { renderedFirstFrameAfterReset = true; eventDispatcher.renderedFirstFrame(surface); + haveReportedFirstFrameRenderedForCurrentSurface = true; } } private void maybeRenotifyRenderedFirstFrame() { - if (renderedFirstFrameAfterReset) { + if (haveReportedFirstFrameRenderedForCurrentSurface) { eventDispatcher.renderedFirstFrame(surface); } } @@ -1123,18 +1212,11 @@ private void maybeNotifyDroppedFrames() { } 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; } } @@ -1156,7 +1238,7 @@ private static void setHdr10PlusInfoV29(MediaCodec codec, byte[] hdr10PlusInfo) } @RequiresApi(23) - private static void setOutputSurfaceV23(MediaCodec codec, Surface surface) { + protected void setOutputSurfaceV23(MediaCodec codec, Surface surface) { codec.setOutputSurface(surface); } @@ -1257,7 +1339,7 @@ protected CodecMaxValues getCodecMaxValues( 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); @@ -1268,19 +1350,19 @@ protected CodecMaxValues getCodecMaxValues( 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); @@ -1348,7 +1430,7 @@ private static Point getCodecMaxSize(MediaCodecInfo codecInfo, Format format) { * @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. @@ -1529,6 +1611,8 @@ protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { case "ELUGA_Prim": case "ELUGA_Ray_X": case "EverStar_S": + case "F02H": + case "F03H": case "F3111": case "F3113": case "F3116": @@ -1674,7 +1758,7 @@ private final class OnFrameRenderedListenerV23 private final Handler handler; public OnFrameRenderedListenerV23(MediaCodec codec) { - handler = Util.createHandler(/* callback= */ this); + handler = Util.createHandlerForCurrentLooper(/* callback= */ this); codec.setOnFrameRenderedListener(/* listener= */ this, handler); } @@ -1719,7 +1803,11 @@ private void handleFrameRendered(long presentationTimeUs) { 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/VideoDecoderRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLFrameRenderer.java similarity index 81% rename from library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderRenderer.java rename to library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLFrameRenderer.java index cb9c4eb59bc..18453bae9bc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLFrameRenderer.java @@ -20,16 +20,19 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.GlUtil; +import java.nio.ByteBuffer; import java.nio.FloatBuffer; import java.util.concurrent.atomic.AtomicReference; import javax.microedition.khronos.egl.EGLConfig; import javax.microedition.khronos.opengles.GL10; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * GLSurfaceView.Renderer implementation that can render YUV Frames returned by a video decoder * after decoding. It does the YUV to RGB color conversion in the Fragment Shader. */ -/* package */ class VideoDecoderRenderer +/* package */ class VideoDecoderGLFrameRenderer implements GLSurfaceView.Renderer, VideoDecoderOutputBufferRenderer { private static final float[] kColorConversion601 = { @@ -86,7 +89,8 @@ GlUtil.createBuffer(new float[] {-1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f, -1.0f}); private final GLSurfaceView surfaceView; private final int[] yuvTextures = new int[3]; - private final AtomicReference pendingOutputBufferReference; + private final AtomicReference<@NullableType VideoDecoderOutputBuffer> + pendingOutputBufferReference; // Kept in field rather than a local variable in order not to get garbage collected before // glDrawArrays uses it. @@ -98,10 +102,10 @@ private int[] previousWidths; private int[] previousStrides; - @Nullable - private VideoDecoderOutputBuffer renderedOutputBuffer; // Accessed only from the GL thread. + // Accessed only from the GL thread. + private @MonotonicNonNull VideoDecoderOutputBuffer renderedOutputBuffer; - public VideoDecoderRenderer(GLSurfaceView surfaceView) { + public VideoDecoderGLFrameRenderer(GLSurfaceView surfaceView) { this.surfaceView = surfaceView; pendingOutputBufferReference = new AtomicReference<>(); textureCoords = new FloatBuffer[3]; @@ -119,7 +123,13 @@ public void onSurfaceCreated(GL10 unused, EGLConfig config) { GLES20.glUseProgram(program); int posLocation = GLES20.glGetAttribLocation(program, "in_pos"); GLES20.glEnableVertexAttribArray(posLocation); - GLES20.glVertexAttribPointer(posLocation, 2, GLES20.GL_FLOAT, false, 0, TEXTURE_VERTICES); + GLES20.glVertexAttribPointer( + posLocation, + 2, + GLES20.GL_FLOAT, + /* normalized= */ false, + /* stride= */ 0, + TEXTURE_VERTICES); texLocations[0] = GLES20.glGetAttribLocation(program, "in_tc_y"); GLES20.glEnableVertexAttribArray(texLocations[0]); texLocations[1] = GLES20.glGetAttribLocation(program, "in_tc_u"); @@ -140,7 +150,9 @@ public void onSurfaceChanged(GL10 unused, int width, int height) { @Override public void onDrawFrame(GL10 unused) { - VideoDecoderOutputBuffer pendingOutputBuffer = pendingOutputBufferReference.getAndSet(null); + @Nullable + VideoDecoderOutputBuffer pendingOutputBuffer = + pendingOutputBufferReference.getAndSet(/* newValue= */ null); if (pendingOutputBuffer == null && renderedOutputBuffer == null) { // There is no output buffer to render at the moment. return; @@ -151,7 +163,9 @@ public void onDrawFrame(GL10 unused) { } renderedOutputBuffer = pendingOutputBuffer; } - VideoDecoderOutputBuffer outputBuffer = renderedOutputBuffer; + + VideoDecoderOutputBuffer outputBuffer = Assertions.checkNotNull(renderedOutputBuffer); + // Set color matrix. Assume BT709 if the color space is unknown. float[] colorConversion = kColorConversion709; switch (outputBuffer.colorspace) { @@ -163,9 +177,18 @@ public void onDrawFrame(GL10 unused) { break; case VideoDecoderOutputBuffer.COLORSPACE_BT709: default: - break; // Do nothing + // Do nothing. + break; } - GLES20.glUniformMatrix3fv(colorMatrixLocation, 1, false, colorConversion, 0); + GLES20.glUniformMatrix3fv( + colorMatrixLocation, + /* color= */ 1, + /* transpose= */ false, + colorConversion, + /* offset= */ 0); + + int[] yuvStrides = Assertions.checkNotNull(outputBuffer.yuvStrides); + ByteBuffer[] yuvPlanes = Assertions.checkNotNull(outputBuffer.yuvPlanes); for (int i = 0; i < 3; i++) { int h = (i == 0) ? outputBuffer.height : (outputBuffer.height + 1) / 2; @@ -174,14 +197,14 @@ public void onDrawFrame(GL10 unused) { GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, 1); GLES20.glTexImage2D( GLES20.GL_TEXTURE_2D, - 0, + /* level= */ 0, GLES20.GL_LUMINANCE, - outputBuffer.yuvStrides[i], + yuvStrides[i], h, - 0, + /* border= */ 0, GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, - outputBuffer.yuvPlanes[i]); + yuvPlanes[i]); } int[] widths = new int[3]; @@ -192,28 +215,34 @@ public void onDrawFrame(GL10 unused) { widths[1] = widths[2] = (widths[0] + 1) / 2; for (int i = 0; i < 3; i++) { // Set cropping of stride if either width or stride has changed. - if (previousWidths[i] != widths[i] || previousStrides[i] != outputBuffer.yuvStrides[i]) { - Assertions.checkState(outputBuffer.yuvStrides[i] != 0); - float widthRatio = (float) widths[i] / outputBuffer.yuvStrides[i]; + if (previousWidths[i] != widths[i] || previousStrides[i] != yuvStrides[i]) { + Assertions.checkState(yuvStrides[i] != 0); + float widthRatio = (float) widths[i] / yuvStrides[i]; // These buffers are consumed during each call to glDrawArrays. They need to be member // variables rather than local variables in order not to get garbage collected. textureCoords[i] = GlUtil.createBuffer( new float[] {0.0f, 0.0f, 0.0f, 1.0f, widthRatio, 0.0f, widthRatio, 1.0f}); GLES20.glVertexAttribPointer( - texLocations[i], 2, GLES20.GL_FLOAT, false, 0, textureCoords[i]); + texLocations[i], + /* size= */ 2, + GLES20.GL_FLOAT, + /* normalized= */ false, + /* stride= */ 0, + textureCoords[i]); previousWidths[i] = widths[i]; - previousStrides[i] = outputBuffer.yuvStrides[i]; + previousStrides[i] = yuvStrides[i]; } } GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); - GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, /* first= */ 0, /* count= */ 4); GlUtil.checkGlError(); } @Override public void setOutputBuffer(VideoDecoderOutputBuffer outputBuffer) { + @Nullable VideoDecoderOutputBuffer oldPendingOutputBuffer = pendingOutputBufferReference.getAndSet(outputBuffer); if (oldPendingOutputBuffer != null) { @@ -224,7 +253,7 @@ public void setOutputBuffer(VideoDecoderOutputBuffer outputBuffer) { } private void setupTextures() { - GLES20.glGenTextures(3, yuvTextures, 0); + GLES20.glGenTextures(3, yuvTextures, /* offset= */ 0); for (int i = 0; i < 3; i++) { GLES20.glUniform1i(GLES20.glGetUniformLocation(program, TEXTURE_UNIFORMS[i]), i); GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + i); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java index 99f3d07b65b..b9d016b886a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java @@ -30,7 +30,7 @@ */ public class VideoDecoderGLSurfaceView extends GLSurfaceView { - private final VideoDecoderRenderer renderer; + private final VideoDecoderGLFrameRenderer renderer; /** @param context A {@link Context}. */ public VideoDecoderGLSurfaceView(Context context) { @@ -41,9 +41,14 @@ public VideoDecoderGLSurfaceView(Context context) { * @param context A {@link Context}. * @param attrs Custom attributes. */ + @SuppressWarnings({ + "nullness:assignment.type.incompatible", + "nullness:argument.type.incompatible", + "nullness:method.invocation.invalid" + }) public VideoDecoderGLSurfaceView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); - renderer = new VideoDecoderRenderer(this); + renderer = new VideoDecoderGLFrameRenderer(/* surfaceView= */ this); setPreserveEGLContextOnPause(true); setEGLContextClientVersion(2); setRenderer(renderer); 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 360279c11c9..c496dbabde7 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 e78a511e5a5..899f1a8d473 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 @@ 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; @@ -33,7 +34,7 @@ public class VideoDecoderOutputBuffer extends OutputBuffer { // ../../../../../../../../../../extensions/vp9/src/main/jni/vpx_jni.cc // ) - /** Decoder private data. */ + /** Decoder private data. Used from native code. */ public int decoderPrivate; /** Output mode. */ @@ -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 2134772d9c4..01b296e747e 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 @@ 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 948c388c301..589371cde5d 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 @@ default void onVideoSizeChanged( 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 671d66c31c4..992a262dabd 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 @@ default void onDroppedFrames(int count, long elapsedMs) {} * @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 @@ default void onVideoSizeChanged( 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 @@ final class EventDispatcher { @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 void droppedFrames(int droppedFrameCount, long elapsedMs) { } /** 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 abf08f3b4e4..75902c0f142 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 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 void handleMessage(int messageType, @Nullable Object message) throws ExoP } @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 @@ protected void onDisabled() { } @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 void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackEx 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 @@ private float[] parseMetadata(ByteBuffer data) { } 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 eadc617ea73..9f7f2362e5b 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 @@ private static boolean isProj(ParsableByteArray input) { 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 2b9f476c61b..b13b7fe5b1a 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_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.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 void updateAudioFocus_pausedToPlaying_withTransientDuck_setsPlayerCommand 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 void updateAudioFocus_abandonFocusWhenDucked_restoresFullVolume() { 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 void release_doesNotCallPlayerControlToRestoreVolume() { 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 void onAudioFocusChange_withDuckEnabled_reducesAndRestoresVolume() { 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 void onAudioFocusChange_withPausedWhenDucked_sendsCommandWaitForCallback( 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 void onAudioFocusChange_withTransientLoss_sendsCommandWaitForCallback() { .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 void onAudioFocusChange_withFocusLoss_sendsDoNotPlayAndAbandonsFocus() { 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 void onAudioFocusChange_withFocusLoss_sendsDoNotPlayAndAbandonsFocus_v26( 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 8ed0b2174f3..b00da4390a4 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,11 +47,18 @@ public void setUp() throws Exception { @Test public void shouldContinueLoading_untilMaxBufferExceeded() { - createDefaultLoadControl(); + build(); - assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isTrue(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US - 1, SPEED)).isTrue(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED)) + .isTrue(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, MAX_BUFFER_US - 1, SPEED)) + .isTrue(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) + .isFalse(); } @Test @@ -61,12 +68,20 @@ public void shouldNotContinueLoadingOnceBufferingStopped_untilBelowMinBuffer() { /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US - 1, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US - 1, SPEED)).isTrue(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) + .isFalse(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, MAX_BUFFER_US - 1, SPEED)) + .isFalse(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED)) + .isFalse(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, MIN_BUFFER_US - 1, SPEED)) + .isTrue(); } @Test @@ -76,11 +91,16 @@ public void continueLoadingOnceBufferingStopped_andBufferAlmostEmpty_evenIfMinBu /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(5 * C.MICROS_PER_SECOND, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(500L, SPEED)).isTrue(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) + .isFalse(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, 5 * C.MICROS_PER_SECOND, SPEED)) + .isFalse(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, 500L, SPEED)) + .isTrue(); } @Test @@ -91,29 +111,48 @@ public void shouldContinueLoadingWithTargetBufferBytesReached_untilMinBufferReac /* maxBufferMs= */ (int) C.usToMs(MAX_BUFFER_US), /* bufferForPlaybackMs= */ 0, /* bufferForPlaybackAfterRebufferMs= */ 0); - createDefaultLoadControl(); + build(); makeSureTargetBufferBytesReached(); - assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isTrue(); - assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US - 1, SPEED)).isTrue(); - assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED)) + .isTrue(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, MIN_BUFFER_US - 1, SPEED)) + .isTrue(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED)) + .isFalse(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) + .isFalse(); } @Test public void shouldContinueLoading_withTargetBufferBytesReachedAndNotPrioritizeTimeOverSize_returnsTrueAsSoonAsTargetBufferReached() { builder.setPrioritizeTimeOverSizeThresholds(false); - createDefaultLoadControl(); + build(); // Put loadControl in buffering state. - assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isTrue(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED)) + .isTrue(); makeSureTargetBufferBytesReached(); - assertThat(loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US - 1, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, SPEED)).isFalse(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, SPEED)).isFalse(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, SPEED)) + .isFalse(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, MIN_BUFFER_US - 1, SPEED)) + .isFalse(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED)) + .isFalse(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MAX_BUFFER_US, SPEED)) + .isFalse(); } @Test @@ -123,25 +162,31 @@ public void shouldContinueLoadingWithMinBufferReached_inFastPlayback() { /* 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(MIN_BUFFER_US, SPEED)).isFalse(); + assertThat(loadControl.shouldContinueLoading(/* playbackPositionUs= */ 0, MIN_BUFFER_US, SPEED)) + .isFalse(); // At double playback speed, we continue loading. - assertThat(loadControl.shouldContinueLoading(MIN_BUFFER_US, /* playbackSpeed= */ 2f)).isTrue(); + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, MIN_BUFFER_US, /* playbackSpeed= */ 2f)) + .isTrue(); } @Test public void shouldNotContinueLoadingWithMaxBufferReached_inFastPlayback() { - createDefaultLoadControl(); + build(); - assertThat(loadControl.shouldContinueLoading(MAX_BUFFER_US, /* playbackSpeed= */ 100f)) + assertThat( + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, MAX_BUFFER_US, /* playbackSpeed= */ 100f)) .isFalse(); } @Test public void startsPlayback_whenMinBufferSizeReached() { - createDefaultLoadControl(); + build(); assertThat(loadControl.shouldStartPlayback(MIN_BUFFER_US, SPEED, /* rebuffering= */ false)) .isTrue(); @@ -149,17 +194,18 @@ public void startsPlayback_whenMinBufferSizeReached() { @Test public void shouldContinueLoading_withNoSelectedTracks_returnsTrue() { - loadControl = builder.createDefaultLoadControl(); + loadControl = builder.build(); loadControl.onTracksSelected(new Renderer[0], TrackGroupArray.EMPTY, new TrackSelectionArray()); assertThat( - loadControl.shouldContinueLoading(/* bufferedDurationUs= */ 0, /* playbackSpeed= */ 1f)) + loadControl.shouldContinueLoading( + /* playbackPositionUs= */ 0, /* bufferedDurationUs= */ 0, /* playbackSpeed= */ 1f)) .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 217df762f64..867857cbe5d 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.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 void standaloneStartAndStop_shouldNotTriggerCallback() throws Exception { } @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 void enableRendererMediaClock_shouldOverwriteRendererPlaybackSpeedIfPossi 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 void enableRendererMediaClockWithFixedButSamePlaybackSpeed_shouldNotTrigg @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 void disableRendererMediaClock_standaloneShouldBeSynced() throws ExoPlayb 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 void enableOtherRendererClock_shouldThrow() 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 @@ private void assertClockIsStopped() { @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 long getPositionUs() { } @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 b4101dca643..a1b3b9014d9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -15,18 +15,37 @@ */ 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.runUntilSleepingForOffload; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilTimelineChanged; +import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; 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; 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.ViewGroup; import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -35,6 +54,8 @@ 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; @@ -43,11 +64,15 @@ 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; @@ -67,14 +92,19 @@ 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; import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.Loader; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -86,16 +116,18 @@ 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"; @@ -105,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()); } /** @@ -123,20 +157,30 @@ public void setUp() { @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(); @@ -145,24 +189,32 @@ public void playEmptyTimeline() throws Exception { /** 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(); @@ -173,20 +225,28 @@ public void playSinglePeriodTimeline() throws Exception { 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, @@ -203,20 +263,28 @@ public void playShortDurationPeriods() throws Exception { 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(); @@ -254,7 +322,6 @@ public void readAheadToEndDoesNotResetRenderer() throws Exception { 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 @@ -265,11 +332,11 @@ public long getPositionUs() { } @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 @@ -277,21 +344,31 @@ public boolean isEnded() { 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(); @@ -303,77 +380,62 @@ public void resettingMediaSourcesGivesFreshSourceInfo() throws Exception { 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(); } @@ -381,49 +443,41 @@ public synchronized void prepareSourceInternal( 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(); } @@ -472,7 +526,7 @@ public void shuffleModeEnabledChanges() throws Exception { 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 = @@ -482,7 +536,11 @@ public void adGroupWithLoadErrorIsSkipped() throws Exception { /* 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 = @@ -492,7 +550,11 @@ public void adGroupWithLoadErrorIsSkipped() throws Exception { /* 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); @@ -588,9 +650,18 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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; } @@ -623,9 +694,15 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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; } @@ -641,7 +718,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( @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 @@ -649,11 +726,18 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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; } }; @@ -830,7 +914,7 @@ public void dynamicTimelineChangeReason() throws Exception { .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, @@ -879,7 +963,7 @@ public void resetMediaSourcesWithPositionResetAndShufflingUsesFirstPeriod() thro } @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); @@ -892,11 +976,19 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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]; } @@ -914,7 +1006,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( } }) // 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(); @@ -938,11 +1030,19 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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]; } @@ -991,149 +1091,256 @@ public void run(SimpleExoPlayer player) { } @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 { + public void stop_withReset_releasesMediaSource() 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) + .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 stopWithResetReleasesMediaSource() throws Exception { + 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) - .stop(/* reset= */ true) + .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(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS); - mediaSource.assertReleased(); - testRunner.blockUntilEnded(TIMEOUT_MS); - } + + 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 public void settingNewStartPositionPossibleAfterStopWithReset() throws Exception { @@ -1177,7 +1384,7 @@ public void run(SimpleExoPlayer player) { Player.STATE_READY, Player.STATE_ENDED); testRunner.assertTimelinesSame( - dummyTimeline, + placeholderTimeline, timeline, Timeline.EMPTY, new FakeMediaSource.InitialTimeline(secondTimeline), @@ -1200,13 +1407,15 @@ public void resetPlaylistWithPreviousPosition() throws Exception { 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 = @@ -1251,14 +1460,16 @@ public void resetPlaylistStartsFromDefaultPosition() throws Exception { 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 = @@ -1288,7 +1499,10 @@ public void run(SimpleExoPlayer player) { .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, @@ -1303,14 +1517,16 @@ public void resetPlaylistWithoutResettingPositionStartsFromOldPosition() throws 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 = @@ -1340,7 +1556,10 @@ public void run(SimpleExoPlayer player) { .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, @@ -1366,7 +1585,7 @@ public void stopDuringPreparationOverwritesPreparation() throws Exception { .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); @@ -1392,7 +1611,7 @@ public void stopAndSeekAfterStopDoesNotResetTimeline() throws Exception { .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); @@ -1420,7 +1639,7 @@ public void reprepareAfterPlaybackError() throws Exception { } 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); @@ -1469,7 +1688,7 @@ public void run(SimpleExoPlayer player) { .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); @@ -1698,8 +1917,17 @@ public void playbackErrorAndReprepareWithPositionResetKeepsWindowSequenceNumber( 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); } @@ -1748,7 +1976,7 @@ public void playbackErrorTwiceStillKeepsTimeline() throws Exception { } 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, @@ -1830,9 +2058,7 @@ public void sendMessagesFromStartPositionOnlyOnce() throws Exception { .waitForTimelineChanged() .pause() .sendMessage( - (messageType, payload) -> { - counter.getAndIncrement(); - }, + (messageType, payload) -> counter.getAndIncrement(), /* windowIndex= */ 0, /* positionMs= */ 2000, /* deleteAfterDelivery= */ false) @@ -2306,6 +2532,43 @@ public void run(SimpleExoPlayer player) { assertThat(target.messageCount).isEqualTo(1); } + @Test + public void sendMessages_withMediaRemoval_triggersCorrectMessagesAndDoesNotThrow() + throws Exception { + ExoPlayer player = new TestExoPlayer.Builder(context).build(); + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); + player.addMediaSources(Arrays.asList(mediaSource, mediaSource)); + player + .createMessage((messageType, payload) -> {}) + .setPosition(/* windowIndex= */ 0, /* positionMs= */ 0) + .setDeleteAfterDelivery(false) + .send(); + PlayerMessage.Target secondMediaItemTarget = mock(PlayerMessage.Target.class); + player + .createMessage(secondMediaItemTarget) + .setPosition(/* windowIndex= */ 1, /* positionMs= */ 0) + .setDeleteAfterDelivery(false) + .send(); + + // Play through media once to trigger all messages. This ensures any internally saved message + // indices are non-zero. + player.prepare(); + player.play(); + 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. + // After removal, any internally saved message indices are invalid and will throw + // IndexOutOfBoundsException if used without updating. + // See https://github.com/google/ExoPlayer/issues/7278. + player.removeMediaItem(/* index= */ 0); + player.seekTo(/* positionMs= */ 0); + runUntilPlaybackState(player, Player.STATE_ENDED); + + assertThat(player.getPlayerError()).isNull(); + verify(secondMediaItemTarget, times(2)).handleMessage(anyInt(), any()); + } + @Test public void setAndSwitchSurface() throws Exception { final List rendererMessages = new ArrayList<>(); @@ -2325,7 +2588,7 @@ public void handleMessage(int what, @Nullable Object object) throws ExoPlaybackE .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 @@ -2393,6 +2656,56 @@ public void timelineUpdateDropsPrebufferedPeriods() throws Exception { .isGreaterThan(mediaSource.getCreatedMediaPeriods().get(1).windowSequenceNumber); } + @Test + public void timelineUpdateWithNewMidrollAdCuePoint_dropsPrebufferedPeriod() throws Exception { + Timeline timeline1 = + new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 0)); + AdPlaybackState adPlaybackStateWithMidroll = + FakeTimeline.createAdPlaybackState( + /* adsPerAdGroup= */ 1, + /* adGroupTimesUs...= */ TimelineWindowDefinition + .DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + + 5 * C.MICROS_PER_SECOND); + Timeline timeline2 = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000, + adPlaybackStateWithMidroll)); + FakeMediaSource mediaSource = new FakeMediaSource(timeline1, ExoPlayerTestRunner.VIDEO_FORMAT); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline2)) + .waitForTimelineChanged( + timeline2, /* expectedReason= */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .play() + .build(); + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + testRunner.assertPlayedPeriodIndices(0); + assertThat(mediaSource.getCreatedMediaPeriods()).hasSize(4); + assertThat(mediaSource.getCreatedMediaPeriods().get(0).nextAdGroupIndex) + .isEqualTo(C.INDEX_UNSET); + assertThat(mediaSource.getCreatedMediaPeriods().get(1).nextAdGroupIndex).isEqualTo(0); + assertThat(mediaSource.getCreatedMediaPeriods().get(2).adGroupIndex).isEqualTo(0); + assertThat(mediaSource.getCreatedMediaPeriods().get(3).adGroupIndex).isEqualTo(C.INDEX_UNSET); + } + @Test public void repeatedSeeksToUnpreparedPeriodInSameWindowKeepsWindowSequenceNumber() throws Exception { @@ -2781,87 +3094,6 @@ public void onTracksChanged( .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 = @@ -3132,23 +3364,37 @@ public void run(SimpleExoPlayer player) { } @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) @@ -3159,11 +3405,12 @@ public void onPlaybackSpeedChanged(float playbackSpeed) { .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) { @@ -3173,28 +3420,28 @@ public long getPositionUs() { } @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) @@ -3207,7 +3454,11 @@ public void onPlaybackSpeedChanged(float playbackSpeed) { .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(); } @@ -3313,6 +3564,11 @@ public boolean isSingleWindow() { return false; } + @Override + public MediaItem getMediaItem() { + return underlyingSource.getMediaItem(); + } + @Override @Nullable public Timeline getInitialTimeline() { @@ -3353,33 +3609,41 @@ public void run(SimpleExoPlayer player) { 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(); @@ -3391,7 +3655,13 @@ public void run(SimpleExoPlayer player) { .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 @@ -3400,26 +3670,34 @@ public void seekTo_windowIndexIsReset() throws Exception { 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(); @@ -3431,135 +3709,1025 @@ public void run(SimpleExoPlayer player) { .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 becomingNoisyIgnoredIfBecomingNoisyHandlingIsDisabled() throws Exception { - CountDownLatch becomingNoisyHandlingDisabled = new CountDownLatch(1); - CountDownLatch becomingNoisyDelivered = new CountDownLatch(1); - PlayerStateGrabber playerStateGrabber = new PlayerStateGrabber(); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.setHandleAudioBecomingNoisy(false); - becomingNoisyHandlingDisabled.countDown(); + 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}; - // Wait for the broadcast to be delivered from the main thread. - try { - becomingNoisyDelivered.await(); - } catch (InterruptedException e) { - throw new IllegalStateException(e); - } - } - }) - .delay(1) // Handle pending messages on the playback thread. - .executeRunnable(playerStateGrabber) - .build(); + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(9000); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context).setActionSchedule(actionSchedule).build().start(); - becomingNoisyHandlingDisabled.await(); - deliverBroadcast(new Intent(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); - becomingNoisyDelivered.countDown(); + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(9000); + assertThat(bufferedPositions[0]).isEqualTo(9200); + assertThat(totalBufferedDuration[0]).isEqualTo(200); - testRunner.blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); - assertThat(playerStateGrabber.playWhenReady).isTrue(); + 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 pausesWhenBecomingNoisyIfBecomingNoisyHandlingIsEnabled() throws Exception { - CountDownLatch becomingNoisyHandlingEnabled = new CountDownLatch(1); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.setHandleAudioBecomingNoisy(true); - becomingNoisyHandlingEnabled.countDown(); - } - }) - .waitForPlayWhenReady(false) // Becoming noisy should set playWhenReady = false - .play() - .build(); + 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}; - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context).setActionSchedule(actionSchedule).build().start(); - becomingNoisyHandlingEnabled.await(); - deliverBroadcast(new Intent(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + runPositionMaskingCapturingActionSchedule( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(9200); + } + }, + /* pauseWindowIndex= */ 0, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); - // If the player fails to handle becoming noisy, blockUntilActionScheduleFinished will time out - // and throw, causing the test to fail. - testRunner.blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); + 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); } - // Disabled until the flag to throw exceptions for [internal: b/144538905] is enabled by default. - @Ignore @Test - public void loadControlNeverWantsToLoad_throwsIllegalStateException() throws Exception { - LoadControl neverLoadingLoadControl = - new DefaultLoadControl() { + 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 boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) { - return false; + 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 boolean shouldStartPlayback( - long bufferedDurationUs, float playbackSpeed, boolean rebuffering) { - return true; + public void run(SimpleExoPlayer player) { + player.seekTo(0, 1000); } - }; - - // Use chunked data to ensure the player actually needs to continue loading and playing. - FakeAdaptiveDataSet.Factory dataSetFactory = - new FakeAdaptiveDataSet.Factory( - /* chunkDurationUs= */ 500_000, /* bitratePercentStdDev= */ 10.0); - MediaSource chunkedMediaSource = - new FakeAdaptiveMediaSource( - new FakeTimeline(/* windowCount= */ 1), - new TrackGroupArray(new TrackGroup(ExoPlayerTestRunner.VIDEO_FORMAT)), - new FakeChunkSource.Factory(dataSetFactory, new FakeDataSource.Factory())); + }, + /* pauseWindowIndex= */ 1, + windowIndex, + positionMs, + bufferedPositions, + totalBufferedDuration, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + createPartiallyBufferedMediaSource(/* maxBufferedPositionMs= */ 9200)); - ExoPlaybackException exception = - assertThrows( - ExoPlaybackException.class, - () -> - new ExoPlayerTestRunner.Builder(context) - .setLoadControl(neverLoadingLoadControl) - .setMediaSources(chunkedMediaSource) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS)); - assertThat(exception.type).isEqualTo(ExoPlaybackException.TYPE_UNEXPECTED); - assertThat(exception.getUnexpectedException()).isInstanceOf(IllegalStateException.class); + assertThat(windowIndex[0]).isEqualTo(0); + assertThat(positionMs[0]).isEqualTo(1000); + assertThat(bufferedPositions[0]).isEqualTo(1000); + assertThat(totalBufferedDuration[0]).isEqualTo(0); } @Test - public void loadControlNeverWantsToPlay_playbackDoesNotGetStuck() throws Exception { - LoadControl neverLoadingOrPlayingLoadControl = - new DefaultLoadControl() { + 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 boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) { - return true; + 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 boolean shouldStartPlayback( - long bufferedDurationUs, float playbackSpeed, boolean rebuffering) { - return false; + 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))); - // Use chunked data to ensure the player actually needs to continue loading and playing. - FakeAdaptiveDataSet.Factory dataSetFactory = - new FakeAdaptiveDataSet.Factory( - /* chunkDurationUs= */ 500_000, /* bitratePercentStdDev= */ 10.0); - MediaSource chunkedMediaSource = + 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 + public void becomingNoisyIgnoredIfBecomingNoisyHandlingIsDisabled() throws Exception { + CountDownLatch becomingNoisyHandlingDisabled = new CountDownLatch(1); + CountDownLatch becomingNoisyDelivered = new CountDownLatch(1); + PlayerStateGrabber playerStateGrabber = new PlayerStateGrabber(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setHandleAudioBecomingNoisy(false); + becomingNoisyHandlingDisabled.countDown(); + + // Wait for the broadcast to be delivered from the main thread. + try { + becomingNoisyDelivered.await(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + } + }) + .delay(1) // Handle pending messages on the playback thread. + .executeRunnable(playerStateGrabber) + .build(); + + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder(context).setActionSchedule(actionSchedule).build().start(); + becomingNoisyHandlingDisabled.await(); + deliverBroadcast(new Intent(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + becomingNoisyDelivered.countDown(); + + testRunner.blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); + assertThat(playerStateGrabber.playWhenReady).isTrue(); + } + + @Test + public void pausesWhenBecomingNoisyIfBecomingNoisyHandlingIsEnabled() throws Exception { + CountDownLatch becomingNoisyHandlingEnabled = new CountDownLatch(1); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setHandleAudioBecomingNoisy(true); + becomingNoisyHandlingEnabled.countDown(); + } + }) + .waitForPlayWhenReady(false) // Becoming noisy should set playWhenReady = false + .play() + .build(); + + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder(context).setActionSchedule(actionSchedule).build().start(); + becomingNoisyHandlingEnabled.await(); + deliverBroadcast(new Intent(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + + // If the player fails to handle becoming noisy, blockUntilActionScheduleFinished will time out + // and throw, causing the test to fail. + testRunner.blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); + } + + @Test + public void loadControlNeverWantsToLoad_throwsIllegalStateException() { + LoadControl neverLoadingLoadControl = + new DefaultLoadControl() { + @Override + public boolean shouldContinueLoading( + long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) { + return false; + } + + @Override + public boolean shouldStartPlayback( + long bufferedDurationUs, float playbackSpeed, boolean rebuffering) { + return true; + } + }; + + // Use chunked data to ensure the player actually needs to continue loading and playing. + FakeAdaptiveDataSet.Factory dataSetFactory = + new FakeAdaptiveDataSet.Factory( + /* chunkDurationUs= */ 500_000, /* bitratePercentStdDev= */ 10.0); + MediaSource chunkedMediaSource = + new FakeAdaptiveMediaSource( + new FakeTimeline(/* windowCount= */ 1), + new TrackGroupArray(new TrackGroup(ExoPlayerTestRunner.VIDEO_FORMAT)), + new FakeChunkSource.Factory(dataSetFactory, new FakeDataSource.Factory())); + + ExoPlaybackException exception = + assertThrows( + ExoPlaybackException.class, + () -> + new ExoPlayerTestRunner.Builder(context) + .setLoadControl(neverLoadingLoadControl) + .setMediaSources(chunkedMediaSource) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS)); + assertThat(exception.type).isEqualTo(ExoPlaybackException.TYPE_UNEXPECTED); + assertThat(exception.getUnexpectedException()).isInstanceOf(IllegalStateException.class); + } + + @Test + public void + nextLoadPositionExceedingLoadControlMaxBuffer_whileCurrentLoadInProgress_doesNotThrowException() + throws Exception { + long maxBufferUs = 2 * C.MICROS_PER_SECOND; + LoadControl loadControlWithMaxBufferUs = + new DefaultLoadControl() { + @Override + public boolean shouldContinueLoading( + long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) { + return bufferedDurationUs < maxBufferUs; + } + + @Override + public boolean shouldStartPlayback( + long bufferedDurationUs, float playbackSpeed, boolean rebuffering) { + return true; + } + }; + MediaSource mediaSourceWithLoadInProgress = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), 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, + TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US, + mediaSourceEventDispatcher) { + @Override + public long getBufferedPositionUs() { + // Pretend not to have buffered data yet. + return 0; + } + + @Override + public long getNextLoadPositionUs() { + // Set next load position beyond the maxBufferUs configured in the LoadControl. + return Long.MAX_VALUE; + } + + @Override + public boolean isLoading() { + return true; + } + }; + } + }; + FakeRenderer rendererWaitingForData = + new FakeRenderer(C.TRACK_TYPE_VIDEO) { + @Override + public boolean isReady() { + return false; + } + }; + + ExoPlayer player = + new TestExoPlayer.Builder(context) + .setRenderers(rendererWaitingForData) + .setLoadControl(loadControlWithMaxBufferUs) + .build(); + player.setMediaSource(mediaSourceWithLoadInProgress); + player.prepare(); + + // Wait until the MediaSource is prepared, i.e. returned its timeline, and at least one + // iteration of doSomeWork after this was run. + TestExoPlayer.runUntilTimelineChanged(player); + TestExoPlayer.runUntilPendingCommandsAreFullyHandled(player); + + assertThat(player.getPlayerError()).isNull(); + } + + @Test + public void loadControlNeverWantsToPlay_playbackDoesNotGetStuck() throws Exception { + LoadControl neverLoadingOrPlayingLoadControl = + new DefaultLoadControl() { + @Override + public boolean shouldContinueLoading( + long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) { + return true; + } + + @Override + public boolean shouldStartPlayback( + long bufferedDurationUs, float playbackSpeed, boolean rebuffering) { + return false; + } + }; + + // Use chunked data to ensure the player actually needs to continue loading and playing. + FakeAdaptiveDataSet.Factory dataSetFactory = + new FakeAdaptiveDataSet.Factory( + /* chunkDurationUs= */ 500_000, /* bitratePercentStdDev= */ 10.0); + MediaSource chunkedMediaSource = new FakeAdaptiveMediaSource( new FakeTimeline(/* windowCount= */ 1), new TrackGroupArray(new TrackGroup(ExoPlayerTestRunner.VIDEO_FORMAT)), @@ -3594,7 +4762,7 @@ public void moveMediaItem() throws Exception { 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)); @@ -3621,7 +4789,7 @@ public void moveMediaItem() throws Exception { Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); exoPlayerTestRunner.assertTimelinesSame( - expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterMove); + expectedPlaceholderTimeline, expectedRealTimeline, expectedRealTimelineAfterMove); } @Test @@ -3667,7 +4835,7 @@ public void removeMediaItem() throws Exception { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - Timeline expectedDummyTimeline = + Timeline expectedPlaceholderTimeline = new FakeTimeline( TimelineWindowDefinition.createDummy(/* tag= */ 1), TimelineWindowDefinition.createDummy(/* tag= */ 2), @@ -3681,7 +4849,7 @@ public void removeMediaItem() throws Exception { Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); exoPlayerTestRunner.assertTimelinesSame( - expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + expectedPlaceholderTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); } @Test @@ -3727,7 +4895,7 @@ public void removeMediaItems() throws Exception { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - Timeline expectedDummyTimeline = + Timeline expectedPlaceholderTimeline = new FakeTimeline( TimelineWindowDefinition.createDummy(/* tag= */ 1), TimelineWindowDefinition.createDummy(/* tag= */ 2), @@ -3740,7 +4908,7 @@ public void removeMediaItems() throws Exception { Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); exoPlayerTestRunner.assertTimelinesSame( - expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + expectedPlaceholderTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); } @Test @@ -3764,7 +4932,7 @@ public void clearMediaItems() throws Exception { 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 */, @@ -3794,7 +4962,7 @@ public void multipleModificationWithRecursiveListenerInvocations() throws Except .blockUntilEnded(TIMEOUT_MS); exoPlayerTestRunner.assertTimelinesSame( - dummyTimeline, + placeholderTimeline, timeline, Timeline.EMPTY, new FakeMediaSource.InitialTimeline(secondTimeline), @@ -3818,7 +4986,8 @@ public void modifyPlaylistUnprepared_remainsInIdle_needsPrepareForBuffering() th 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() @@ -3867,7 +5036,7 @@ public void run(SimpleExoPlayer player) { 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)); @@ -3886,10 +5055,10 @@ public void run(SimpleExoPlayer player) { /* isDynamic= */ false, /* durationUs= */ 10_000_000)); exoPlayerTestRunner.assertTimelinesSame( - dummyTimeline, + placeholderTimeline, Timeline.EMPTY, - dummyTimeline, - expectedSecondDummyTimeline, + placeholderTimeline, + expectedSecondPlaceholderTimeline, expectedSecondRealTimeline); assertArrayEquals(new int[] {Player.STATE_IDLE}, maskingPlaybackState); } @@ -3933,13 +5102,13 @@ public void modifyPlaylistPrepared_remainsInEnded_needsSeekForBuffering() throws 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) */, @@ -3996,7 +5165,7 @@ public void stopWithNoReset_modifyingPlaylistRemainsInIdleState_needsPrepareForB 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 */ @@ -4052,7 +5221,7 @@ public void prepareWhenAlreadyPreparedIsANoop() throws Exception { 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 */); @@ -4280,29 +5449,215 @@ public void run(SimpleExoPlayer player) { 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); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) - .playUntilPosition(0, 5000) - .setShuffleOrder(new FakeShuffleOrder(/* length= */ 1)) + .playUntilPosition(0, 5000) + .setShuffleOrder(new FakeShuffleOrder(/* length= */ 1)) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + positionAfterSetShuffleOrder.set(player.getCurrentPosition()); + } + }) + .play() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + assertThat(positionAfterSetShuffleOrder.get()).isAtLeast(5000); + } + + @Test + public void setMediaSources_secondAdMediaSource_throws() throws Exception { + AdsMediaSource adsMediaSource = + new AdsMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + new DefaultDataSourceFactory(context), + new FakeAdsLoader(), + new FakeAdViewProvider()); + Exception[] exception = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + try { + player.setMediaSource(adsMediaSource); + player.addMediaSource(adsMediaSource); + } catch (Exception e) { + exception[0] = e; + } + player.prepare(); + } + }) + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(exception[0]).isInstanceOf(IllegalStateException.class); + } + + @Test + public void setMediaSources_multipleMediaSourcesWithAd_throws() throws Exception { + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + mediaSource, + new DefaultDataSourceFactory(context), + new FakeAdsLoader(), + new FakeAdViewProvider()); + final Exception[] exception = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + try { + List sources = new ArrayList<>(); + sources.add(mediaSource); + sources.add(adsMediaSource); + player.setMediaSources(sources); + } catch (Exception e) { + exception[0] = e; + } + player.prepare(); + } + }) + .build(); + + new ExoPlayerTestRunner.Builder(context) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(exception[0]).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void setMediaSources_addingMediaSourcesWithAdToNonEmptyPlaylist_throws() throws Exception { + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + mediaSource, + new DefaultDataSourceFactory(context), + new FakeAdsLoader(), + new FakeAdViewProvider()); + final Exception[] exception = {null}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - positionAfterSetShuffleOrder.set(player.getCurrentPosition()); + try { + player.addMediaSource(adsMediaSource); + } catch (Exception e) { + exception[0] = e; + } } }) - .play() .build(); + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build() .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - assertThat(positionAfterSetShuffleOrder.get()).isAtLeast(5000); + assertThat(exception[0]).isInstanceOf(IllegalArgumentException.class); } @Test @@ -4496,13 +5851,14 @@ public void run(SimpleExoPlayer player) { } @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. @@ -4513,9 +5869,13 @@ public void setMediaSources_whenEmpty_validInitialSeek_correctMaskingWindowIndex @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() @@ -4525,11 +5885,13 @@ public void run(SimpleExoPlayer player) { @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() @@ -4537,16 +5899,19 @@ public void run(SimpleExoPlayer player) { .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. @@ -4557,9 +5922,13 @@ public void setMediaSources_whenEmpty_invalidInitialSeek_correctMaskingWindowInd @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() @@ -4569,12 +5938,14 @@ public void run(SimpleExoPlayer player) { @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() @@ -4582,6 +5953,8 @@ public void run(SimpleExoPlayer player) { .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 @@ -5172,10 +6545,47 @@ public void run(SimpleExoPlayer player) { } @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. @@ -5186,6 +6596,9 @@ public void addMediaSources_skipSettingMediaItems_validInitialSeek_correctMaskin @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( @@ -5196,26 +6609,39 @@ public void run(SimpleExoPlayer player) { /* 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 @@ -5414,13 +6840,14 @@ public void run(SimpleExoPlayer player) { } @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) @@ -5431,9 +6858,11 @@ public void run(SimpleExoPlayer player) { // 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(); @@ -5447,7 +6876,9 @@ public void run(SimpleExoPlayer player) { .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 @@ -5464,6 +6895,10 @@ public void removeMediaItems_currentItemRemovedThatIsTheLast_correctMasking() th 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) @@ -5473,12 +6908,16 @@ public void removeMediaItems_currentItemRemovedThatIsTheLast_correctMasking() th 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) @@ -5494,6 +6933,8 @@ public void run(SimpleExoPlayer player) { currentWindowIndices[3] = player.getCurrentWindowIndex(); // Remains in ENDED. maskingPlaybackStates[1] = player.getPlaybackState(); + currentPositions[2] = player.getCurrentPosition(); + bufferedPositions[2] = player.getBufferedPosition(); } }) .waitForTimelineChanged() @@ -5562,6 +7003,12 @@ public void run(SimpleExoPlayer player) { }, // 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 @@ -5599,700 +7046,1266 @@ public void clearMediaItems_correctMasking() throws Exception { 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(); + } + }) + .build(); + new ExoPlayerTestRunner.Builder(context) + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(firstMediaSource, secondMediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .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); + } + + @Test + public void clearMediaItems_unprepared_correctMaskingWindowIndex_notEnded() 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 int[] currentStates = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_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) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentStates[0] = player.getPlaybackState(); + player.clearMediaItems(); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentStates[1] = player.getPlaybackState(); + } + }) + .prepare() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Transitions to ended when prepared with zero media items. + currentStates[2] = player.getPlaybackState(); + } + }) + .build(); + new ExoPlayerTestRunner.Builder(context) + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(firstMediaSource, secondMediaSource) + .setActionSchedule(actionSchedule) + .build() + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals( + new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_ENDED}, currentStates); + assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + } + + @Test + public void errorThrownDuringPlaylistUpdate_keepsConsistentPlayerState() { + 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); + AtomicInteger audioRendererEnableCount = new AtomicInteger(0); + 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 { + if (audioRendererEnableCount.incrementAndGet() == 2) { + // Fail when enabling the renderer for the second time during the playlist update. + throw createRendererException( + new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); + } + } + }; + AtomicReference timelineAfterError = new AtomicReference<>(); + 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) { + timelineAfterError.set(player.getCurrentTimeline()); + trackGroupsAfterError.set(player.getCurrentTrackGroups()); + trackSelectionsAfterError.set(player.getCurrentTrackSelections()); + windowIndexAfterError.set(player.getCurrentWindowIndex()); + } + }); + } + }) + .pause() + // Wait until fully buffered so that the new renderer can be enabled immediately. + .waitForIsLoading(true) + .waitForIsLoading(false) + .waitForIsLoading(true) + .waitForIsLoading(false) + .removeMediaItem(0) + .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(timelineAfterError.get().getWindowCount()).isEqualTo(1); + assertThat(windowIndexAfterError.get()).isEqualTo(0); + 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 seekToCurrentPosition_inEndedState_switchesToBufferingStateAndContinuesPlayback() + throws Exception { + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount = */ 1)); + AtomicInteger windowIndexAfterFinalEndedState = new AtomicInteger(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_ENDED) + .addMediaSources(mediaSource) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.seekTo(player.getCurrentPosition()); + } + }) + .waitForPlaybackState(Player.STATE_READY) + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndexAfterFinalEndedState.set(player.getCurrentWindowIndex()); + } + }) + .build(); + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(windowIndexAfterFinalEndedState.get()).isEqualTo(1); + } + + @Test + public void pauseAtEndOfMediaItems_pausesPlaybackBeforeTransitioningToTheNextItem() + throws Exception { + TimelineWindowDefinition timelineWindowDefinition = + new TimelineWindowDefinition( + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10 * C.MICROS_PER_SECOND); + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(timelineWindowDefinition)); + AtomicInteger playbackStateAfterPause = new AtomicInteger(C.INDEX_UNSET); + AtomicLong positionAfterPause = new AtomicLong(C.TIME_UNSET); + AtomicInteger windowIndexAfterPause = new AtomicInteger(C.INDEX_UNSET); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) - .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlayWhenReady(true) + .waitForPlayWhenReady(false) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - currentWindowIndices[0] = player.getCurrentWindowIndex(); - player.clearMediaItems(); - currentWindowIndices[1] = player.getCurrentWindowIndex(); - maskingPlaybackState[0] = player.getPlaybackState(); + playbackStateAfterPause.set(player.getPlaybackState()); + windowIndexAfterPause.set(player.getCurrentWindowIndex()); + positionAfterPause.set(player.getContentPosition()); } }) + .play() .build(); new ExoPlayerTestRunner.Builder(context) - .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .setMediaSources(firstMediaSource, secondMediaSource) + .setPauseAtEndOfMediaItems(true) + .setMediaSources(mediaSource, mediaSource) .setActionSchedule(actionSchedule) .build() .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - assertArrayEquals(new int[] {1, 0}, currentWindowIndices); - assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackState); + + assertThat(playbackStateAfterPause.get()).isEqualTo(Player.STATE_READY); + assertThat(windowIndexAfterPause.get()).isEqualTo(0); + assertThat(positionAfterPause.get()).isEqualTo(10_000); } @Test - public void clearMediaItems_unprepared_correctMaskingWindowIndex_notEnded() 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 int[] currentStates = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + public void pauseAtEndOfMediaItems_pausesPlaybackWhenEnded() throws Exception { + TimelineWindowDefinition timelineWindowDefinition = + new TimelineWindowDefinition( + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10 * C.MICROS_PER_SECOND); + MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(timelineWindowDefinition)); + AtomicInteger playbackStateAfterPause = new AtomicInteger(C.INDEX_UNSET); + AtomicLong positionAfterPause = new AtomicLong(C.TIME_UNSET); + AtomicInteger windowIndexAfterPause = new AtomicInteger(C.INDEX_UNSET); ActionSchedule actionSchedule = new ActionSchedule.Builder(TAG) - // Wait for initial seek to be fully handled by internal player. - .waitForPositionDiscontinuity() - .waitForPendingPlayerCommands() + .waitForPlayWhenReady(true) + .waitForPlayWhenReady(false) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - currentWindowIndices[0] = player.getCurrentWindowIndex(); - currentStates[0] = player.getPlaybackState(); - player.clearMediaItems(); - currentWindowIndices[1] = player.getCurrentWindowIndex(); - currentStates[1] = player.getPlaybackState(); + playbackStateAfterPause.set(player.getPlaybackState()); + windowIndexAfterPause.set(player.getCurrentWindowIndex()); + positionAfterPause.set(player.getContentPosition()); } }) - .prepare() + .build(); + new ExoPlayerTestRunner.Builder(context) + .setPauseAtEndOfMediaItems(true) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(playbackStateAfterPause.get()).isEqualTo(Player.STATE_ENDED); + assertThat(windowIndexAfterPause.get()).isEqualTo(0); + assertThat(positionAfterPause.get()).isEqualTo(10_000); + } + + @Test + public void + infiniteLoading_withSmallAllocations_oomIsPreventedByLoadControl_andThrowsStuckBufferingIllegalStateException() { + DefaultLoadControl loadControl = + new DefaultLoadControl.Builder() + .setTargetBufferBytes(10 * C.DEFAULT_BUFFER_SEGMENT_SIZE) + .build(); + // Return no end of stream signal to prevent playback from ending. + FakeMediaPeriod.TrackDataFactory trackDataWithoutEos = (format, periodId) -> ImmutableList.of(); + MediaSource continuouslyAllocatingMediaSource = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) { + @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, + trackDataWithoutEos, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false) { + + private final List allocations = new ArrayList<>(); + + private Callback callback; + + @Override + public synchronized void prepare(Callback callback, long positionUs) { + this.callback = callback; + super.prepare(callback, positionUs); + } + + @Override + public long getBufferedPositionUs() { + // Pretend not to make loading progress, so that continueLoading keeps being called. + return 0; + } + + @Override + public long getNextLoadPositionUs() { + // Pretend not to make loading progress, so that continueLoading keeps being called. + return 0; + } + + @Override + public boolean continueLoading(long positionUs) { + allocations.add(allocator.allocate()); + callback.onContinueLoadingRequested(this); + return true; + } + }; + } + }; + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(continuouslyAllocatingMediaSource) + .setLoadControl(loadControl) + .build(); + + ExoPlaybackException exception = + assertThrows( + ExoPlaybackException.class, () -> testRunner.start().blockUntilEnded(TIMEOUT_MS)); + assertThat(exception.type).isEqualTo(ExoPlaybackException.TYPE_UNEXPECTED); + assertThat(exception.getUnexpectedException()).isInstanceOf(IllegalStateException.class); + } + + @Test + public void loading_withLargeAllocationCausingOom_playsRemainingMediaAndThenThrows() { + Loader.Loadable loadable = + new Loader.Loadable() { + @SuppressWarnings("UnusedVariable") + @Override + public void load() throws IOException { + @SuppressWarnings("unused") // This test needs the allocation to cause an OOM. + byte[] largeBuffer = new byte[Integer.MAX_VALUE]; + } + + @Override + public void cancelLoad() {} + }; + MediaSource largeBufferAllocatingMediaSource = + new FakeMediaSource( + new FakeTimeline(/* windowCount= */ 1), 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, + 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 FakeLoaderCallback(), /* defaultMinRetryCount= */ 1); + return true; + } + + @Override + protected SampleStream createSampleStream( + 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(), + ImmutableList.of( + oneByteSample(positionUs), + oneByteSample(positionUs), + oneByteSample(positionUs))) { + + @Override + public void maybeThrowError() throws IOException { + loader.maybeThrowError(); + } + }; + } + }; + } + }; + FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(largeBufferAllocatingMediaSource) + .setRenderers(renderer) + .build(); + + ExoPlaybackException exception = + assertThrows( + ExoPlaybackException.class, () -> testRunner.start().blockUntilEnded(TIMEOUT_MS)); + assertThat(exception.type).isEqualTo(ExoPlaybackException.TYPE_SOURCE); + assertThat(exception.getSourceException()).isInstanceOf(Loader.UnexpectedLoaderException.class); + assertThat(exception.getSourceException().getCause()).isInstanceOf(OutOfMemoryError.class); + + assertThat(renderer.sampleBufferReadCount).isEqualTo(3); + } + + @Test + public void seekTo_whileReady_callsOnIsPlayingChanged() throws Exception { + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .seek(/* positionMs= */ 0) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + List onIsPlayingChanges = new ArrayList<>(); + Player.EventListener eventListener = + new Player.EventListener() { + @Override + public void onIsPlayingChanged(boolean isPlaying) { + onIsPlayingChanges.add(isPlaying); + } + }; + new ExoPlayerTestRunner.Builder(context) + .setEventListener(eventListener) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertThat(onIsPlayingChanges).containsExactly(true, false, true, false).inOrder(); + } + + @Test + public void multipleListenersAndMultipleCallbacks_callbacksAreOrderedByType() throws Exception { + String playWhenReadyChange1 = "playWhenReadyChange1"; + String playWhenReadyChange2 = "playWhenReadyChange2"; + String isPlayingChange1 = "isPlayingChange1"; + String isPlayingChange2 = "isPlayingChange2"; + ArrayList events = new ArrayList<>(); + Player.EventListener eventListener1 = + new Player.EventListener() { + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + events.add(playWhenReadyChange1); + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + events.add(isPlayingChange1); + } + }; + Player.EventListener eventListener2 = + new Player.EventListener() { + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + events.add(playWhenReadyChange2); + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + events.add(isPlayingChange2); + } + }; + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() .executeRunnable( new PlayerRunnable() { @Override - public void run(SimpleExoPlayer player) { - // Transitions to ended when prepared with zero media items. - currentStates[2] = player.getPlaybackState(); + public void run(SimpleExoPlayer player) { + player.addListener(eventListener1); + player.addListener(eventListener2); } }) + .waitForPlaybackState(Player.STATE_READY) + .play() + .waitForPlaybackState(Player.STATE_ENDED) .build(); new ExoPlayerTestRunner.Builder(context) - .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .setMediaSources(firstMediaSource, secondMediaSource) .setActionSchedule(actionSchedule) .build() - .start(/* doPrepare= */ false) + .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - assertArrayEquals( - new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_ENDED}, currentStates); - assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + + assertThat(events) + .containsExactly( + playWhenReadyChange1, + playWhenReadyChange2, + isPlayingChange1, + isPlayingChange2, + isPlayingChange1, + isPlayingChange2) + .inOrder(); } - // TODO(b/150584930): Fix reporting of renderer errors. - @Ignore + /** + * 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 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) { + 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 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); + 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); } }; - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); + 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) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); + playerHolder[0] = player; } }) + .waitForPlaybackState(Player.STATE_READY) + .seek(/* positionMs= */ 0) + .waitForPlaybackState(Player.STATE_ENDED) .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) - .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .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); + } + } - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); + @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(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. + 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 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(); + 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) - .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()); - } - }); - } - }) + .waitForPlaybackState(Player.STATE_READY) + .setMediaSources(mediaSource) + .waitForPlaybackState(Player.STATE_READY) .build(); - ExoPlayerTestRunner testRunner = + + ExoPlayerTestRunner exoPlayerTestRunner = new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .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); + } - 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. + @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); } - // 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(); + 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) - .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()); - } - }); - } - }) + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 2000) + .clearMediaItems() .build(); - ExoPlayerTestRunner testRunner = + + ExoPlayerTestRunner exoPlayerTestRunner = new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) + .setMediaSources(mediaSource1, mediaSource2) .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .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); + } - 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 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 errorThrownDuringPlaylistUpdate_keepsConsistentPlayerState() { - 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); - AtomicInteger audioRendererEnableCount = new AtomicInteger(0); - 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 { - if (audioRendererEnableCount.incrementAndGet() == 2) { - // Fail when enabling the renderer for the second time during the playlist update. - throw createRendererException( - new IllegalStateException(), ExoPlayerTestRunner.AUDIO_FORMAT); - } - } - }; - AtomicReference timelineAfterError = new AtomicReference<>(); - AtomicReference trackGroupsAfterError = new AtomicReference<>(); - AtomicReference trackSelectionsAfterError = new AtomicReference<>(); - AtomicInteger windowIndexAfterError = new AtomicInteger(); + 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) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addAnalyticsListener( - new AnalyticsListener() { - @Override - public void onPlayerError( - EventTime eventTime, ExoPlaybackException error) { - timelineAfterError.set(player.getCurrentTimeline()); - trackGroupsAfterError.set(player.getCurrentTrackGroups()); - trackSelectionsAfterError.set(player.getCurrentTrackSelections()); - windowIndexAfterError.set(player.getCurrentWindowIndex()); - } - }); - } - }) .pause() - // Wait until fully buffered so that the new renderer can be enabled immediately. - .waitForIsLoading(true) - .waitForIsLoading(false) - .waitForIsLoading(true) - .waitForIsLoading(false) - .removeMediaItem(0) + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .seek(/* windowIndex= */ 0, /* positionMs= */ 20_000) + .stop() .build(); - ExoPlayerTestRunner testRunner = + + ExoPlayerTestRunner exoPlayerTestRunner = new ExoPlayerTestRunner.Builder(context) - .setMediaSources(source1, source2) + .setMediaSources(mediaSource1, mediaSource2) .setActionSchedule(actionSchedule) - .setRenderers(videoRenderer, audioRenderer) - .build(); - - assertThrows( - ExoPlaybackException.class, - () -> - testRunner - .start(/* doPrepare= */ true) - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS)); + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - assertThat(timelineAfterError.get().getWindowCount()).isEqualTo(1); - assertThat(windowIndexAfterError.get()).isEqualTo(0); - 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. + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); } @Test - public void seekToCurrentPosition_inEndedState_switchesToBufferingStateAndContinuesPlayback() - throws Exception { - MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount = */ 1)); - AtomicInteger windowIndexAfterFinalEndedState = new AtomicInteger(); + 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) - .waitForPlaybackState(Player.STATE_ENDED) - .addMediaSources(mediaSource) + .pause() + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - player.seekTo(player.getCurrentPosition()); + player.setRepeatMode(Player.REPEAT_MODE_ONE); } }) - .waitForPlaybackState(Player.STATE_READY) - .waitForPlaybackState(Player.STATE_ENDED) + .play() + .waitForPositionDiscontinuity() + .waitForPositionDiscontinuity() .executeRunnable( new PlayerRunnable() { @Override public void run(SimpleExoPlayer player) { - windowIndexAfterFinalEndedState.set(player.getCurrentWindowIndex()); + player.setRepeatMode(Player.REPEAT_MODE_OFF); } }) .build(); - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(mediaSource) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - assertThat(windowIndexAfterFinalEndedState.get()).isEqualTo(1); + 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 pauseAtEndOfMediaItems_pausesPlaybackBeforeTransitioningToTheNextItem() - throws Exception { - TimelineWindowDefinition timelineWindowDefinition = - new TimelineWindowDefinition( - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs= */ 10 * C.MICROS_PER_SECOND); - MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(timelineWindowDefinition)); - AtomicInteger playbackStateAfterPause = new AtomicInteger(C.INDEX_UNSET); - AtomicLong positionAfterPause = new AtomicLong(C.TIME_UNSET); - AtomicInteger windowIndexAfterPause = new AtomicInteger(C.INDEX_UNSET); + 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) - .waitForPlayWhenReady(true) - .waitForPlayWhenReady(false) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - playbackStateAfterPause.set(player.getPlaybackState()); - windowIndexAfterPause.set(player.getCurrentWindowIndex()); - positionAfterPause.set(player.getContentPosition()); - } - }) - .play() + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ true) .build(); - new ExoPlayerTestRunner.Builder(context) - .setPauseAtEndOfMediaItems(true) - .setMediaSources(mediaSource, mediaSource) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - assertThat(playbackStateAfterPause.get()).isEqualTo(Player.STATE_READY); - assertThat(windowIndexAfterPause.get()).isEqualTo(0); - assertThat(positionAfterPause.get()).isEqualTo(10_000); + 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 pauseAtEndOfMediaItems_pausesPlaybackWhenEnded() throws Exception { - TimelineWindowDefinition timelineWindowDefinition = - new TimelineWindowDefinition( - /* isSeekable= */ true, - /* isDynamic= */ false, - /* durationUs= */ 10 * C.MICROS_PER_SECOND); - MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(timelineWindowDefinition)); - AtomicInteger playbackStateAfterPause = new AtomicInteger(C.INDEX_UNSET); - AtomicLong positionAfterPause = new AtomicLong(C.TIME_UNSET); - AtomicInteger windowIndexAfterPause = new AtomicInteger(C.INDEX_UNSET); + 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) - .waitForPlayWhenReady(true) - .waitForPlayWhenReady(false) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - playbackStateAfterPause.set(player.getPlaybackState()); - windowIndexAfterPause.set(player.getCurrentWindowIndex()); - positionAfterPause.set(player.getContentPosition()); - } - }) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ false) .build(); - new ExoPlayerTestRunner.Builder(context) - .setPauseAtEndOfMediaItems(true) - .setMediaSources(mediaSource) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); - assertThat(playbackStateAfterPause.get()).isEqualTo(Player.STATE_ENDED); - assertThat(windowIndexAfterPause.get()).isEqualTo(0); - assertThat(positionAfterPause.get()).isEqualTo(10_000); + 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); } - // Disabled until the flag to throw exceptions for [internal: b/144538905] is enabled by default. - @Ignore @Test - public void - infiniteLoading_withSmallAllocations_oomIsPreventedByLoadControl_andThrowsStuckBufferingIllegalStateException() { - MediaSource continuouslyAllocatingMediaSource = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) { - @Override - protected FakeMediaPeriod createFakeMediaPeriod( - MediaPeriodId id, - TrackGroupArray trackGroupArray, - Allocator allocator, - EventDispatcher eventDispatcher, - @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { - - private final List allocations = new ArrayList<>(); - - private Callback callback; - - @Override - public synchronized void prepare(Callback callback, long positionUs) { - this.callback = callback; - super.prepare(callback, positionUs); - } - - @Override - public long getBufferedPositionUs() { - // Pretend not to make loading progress, so that continueLoading keeps being called. - return 0; - } - - @Override - public long getNextLoadPositionUs() { - // Pretend not to make loading progress, so that continueLoading keeps being called. - return 0; - } - - @Override - public boolean continueLoading(long positionUs) { - allocations.add(allocator.allocate()); - callback.onContinueLoadingRequested(this); - return true; - } - }; - } - }; + 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) - // Prevent player from ever assuming it finished playing. - .setRepeatMode(Player.REPEAT_MODE_ALL) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .waitForPlayWhenReady(false) + .executeRunnable( + () -> { + mediaSource.setNewSourceInfo(newTimeline); + }) + .play() .build(); - ExoPlayerTestRunner testRunner = + + ExoPlayerTestRunner exoPlayerTestRunner = new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) - .setMediaSources(continuouslyAllocatingMediaSource) - .build(); + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - ExoPlaybackException exception = - assertThrows( - ExoPlaybackException.class, () -> testRunner.start().blockUntilEnded(TIMEOUT_MS)); - assertThat(exception.type).isEqualTo(ExoPlaybackException.TYPE_UNEXPECTED); - assertThat(exception.getUnexpectedException()).isInstanceOf(IllegalStateException.class); + exoPlayerTestRunner.assertTimelinesSame(placeholderTimeline, timeline, newTimeline); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertMediaItemsTransitionedSame(initialMediaItem); } @Test - public void loading_withLargeAllocationCausingOom_playsRemainingMediaAndThenThrows() { - Loader.Loadable loadable = - new Loader.Loadable() { - @SuppressWarnings("UnusedVariable") + 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 load() throws IOException { - @SuppressWarnings("unused") // This test needs the allocation to cause an OOM. - byte[] largeBuffer = new byte[Integer.MAX_VALUE]; + public void maybeThrowSourceInfoRefreshError() throws IOException { + throw new IOException(); } + }); - @Override - public void cancelLoad() {} - }; - MediaSource largeBufferAllocatingMediaSource = - new FakeMediaSource( - new FakeTimeline(/* windowCount= */ 1), ExoPlayerTestRunner.VIDEO_FORMAT) { + 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, - EventDispatcher eventDispatcher, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, @Nullable TransferListener transferListener) { - return new FakeMediaPeriod(trackGroupArray, eventDispatcher) { - private Loader loader = new Loader("oomLoader"); - + return new FakeMediaPeriod( + trackGroupArray, + /* singleSampleTimeUs= */ 0, + mediaSourceEventDispatcher, + DrmSessionManager.DUMMY, + drmEventDispatcher, + /* deferOnPrepared= */ true) { @Override - public boolean continueLoading(long positionUs) { - loader.startLoading( - loadable, new DummyLoaderCallback(), /* defaultMinRetryCount= */ 1); - return true; + 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, EventDispatcher eventDispatcher) { - // Create 3 samples without end of stream signal to test that all 3 samples are - // still played before the exception is thrown. + long positionUs, + TrackSelection selection, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher) { return new FakeSampleStream( + mediaSourceEventDispatcher, + DrmSessionManager.DUMMY, + 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})) { - + /* fakeSampleStreamItems= */ ImmutableList.of()) { @Override public void maybeThrowError() throws IOException { - loader.maybeThrowError(); + throw new IOException(); } }; } }; } - }; - FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setMediaSources(largeBufferAllocatingMediaSource) - .setRenderers(renderer) - .build(); + }); - ExoPlaybackException exception = - assertThrows( - ExoPlaybackException.class, () -> testRunner.start().blockUntilEnded(TIMEOUT_MS)); - assertThat(exception.type).isEqualTo(ExoPlaybackException.TYPE_SOURCE); - assertThat(exception.getSourceException()).isInstanceOf(Loader.UnexpectedLoaderException.class); - assertThat(exception.getSourceException().getCause()).isInstanceOf(OutOfMemoryError.class); + player.prepare(); + player.play(); + ExoPlaybackException error = TestExoPlayer.runUntilError(player); - assertThat(renderer.sampleBufferReadCount).isEqualTo(3); + 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 seekTo_whileReady_callsOnIsPlayingChanged() throws Exception { - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .waitForPlaybackState(Player.STATE_READY) - .seek(/* positionMs= */ 0) - .waitForPlaybackState(Player.STATE_ENDED) - .build(); - List onIsPlayingChanges = new ArrayList<>(); - Player.EventListener eventListener = - new Player.EventListener() { - @Override - public void onIsPlayingChanged(boolean isPlaying) { - onIsPlayingChanges.add(isPlaying); - } - }; - new ExoPlayerTestRunner.Builder(context) - .setEventListener(eventListener) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); + 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(); - assertThat(onIsPlayingChanges).containsExactly(true, false, true, false).inOrder(); + 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 multipleListenersAndMultipleCallbacks_callbacksAreOrderedByType() throws Exception { - String playWhenReadyChange1 = "playWhenReadyChange1"; - String playWhenReadyChange2 = "playWhenReadyChange2"; - String isPlayingChange1 = "isPlayingChange1"; - String isPlayingChange2 = "isPlayingChange2"; - ArrayList events = new ArrayList<>(); - Player.EventListener eventListener1 = - new Player.EventListener() { - @Override - public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { - events.add(playWhenReadyChange1); - } + public void enableOffloadSchedulingWhileIdle_isToggled_isReported() throws Exception { + SimpleExoPlayer player = new TestExoPlayer.Builder(context).build(); - @Override - public void onIsPlayingChanged(boolean isPlaying) { - events.add(isPlayingChange1); - } - }; - Player.EventListener eventListener2 = - new Player.EventListener() { - @Override - public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { - events.add(playWhenReadyChange2); - } + player.experimentalSetOffloadSchedulingEnabled(true); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isTrue(); - @Override - public void onIsPlayingChanged(boolean isPlaying) { - events.add(isPlayingChange2); - } - }; - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.addListener(eventListener1); - player.addListener(eventListener2); - } - }) - .waitForPlaybackState(Player.STATE_READY) - .play() - .waitForPlaybackState(Player.STATE_ENDED) - .build(); - new ExoPlayerTestRunner.Builder(context) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilActionScheduleFinished(TIMEOUT_MS) - .blockUntilEnded(TIMEOUT_MS); + player.experimentalSetOffloadSchedulingEnabled(false); + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } - assertThat(events) - .containsExactly( - playWhenReadyChange1, - playWhenReadyChange2, - isPlayingChange1, - isPlayingChange2, - isPlayingChange1, - isPlayingChange2) - .inOrder(); + @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); + player.prepare(); + player.play(); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); + + player.experimentalSetOffloadSchedulingEnabled(false); + + assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + } + @Test + public void enableOffloadScheduling_isEnable_playerSleeps() throws Exception { + FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO); + 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(); + + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); + } + + @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(); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); + + player.experimentalSetOffloadSchedulingEnabled(false); // Force the player to exit offload sleep + + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ false); + 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(); + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); + + sleepRenderer.wakeup(); + + runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ false); + runUntilPlaybackState(player, Player.STATE_ENDED); } // Internal methods. @@ -6324,6 +8337,48 @@ private static void deliverBroadcast(Intent intent) { // Internal classes. + /* {@link FakeRenderer} that can sleep and be woken-up. */ + private static class FakeSleepRenderer extends FakeRenderer { + private static final long WAKEUP_DEADLINE_MS = 60 * C.MICROS_PER_SECOND; + private final AtomicBoolean sleepOnNextRender; + private final AtomicReference wakeupListenerReceiver; + + public FakeSleepRenderer(int trackType) { + super(trackType); + sleepOnNextRender = 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; + } + + @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); + } + } + } + private static final class CountingMessageTarget implements PlayerMessage.Target { public int messageCount; @@ -6401,7 +8456,7 @@ public void run(SimpleExoPlayer player) { } } - 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) {} @@ -6420,4 +8475,53 @@ public Loader.LoadErrorAction onLoadError( return Loader.RETRY; } } + + private static class FakeAdsLoader implements AdsLoader { + + @Override + public void setPlayer(@Nullable Player player) {} + + @Override + public void release() {} + + @Override + public void setSupportedContentTypes(int... contentTypes) {} + + @Override + public void start(AdsLoader.EventListener eventListener, AdViewProvider adViewProvider) {} + + @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 FakeAdViewProvider implements AdsLoader.AdViewProvider { + + @Override + public ViewGroup getAdViewGroup() { + return null; + } + + @Override + 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 ccc5156015a..ff4cdb7340f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -18,30 +18,30 @@ 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 @@ private void setupAdTimeline(long... adGroupTimesUs) { 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,12 @@ private void setupTimeline(Timeline timeline) { /* 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, + /* sleepingForOffload= */ false); } private void advance() { @@ -499,6 +495,21 @@ private void setAdGroupFailedToLoad(int adGroupIndex) { 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 7ece4f32592..b3ff5e5c551 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.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.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 @@ 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 void emptyMediaSourceList_expectConstantTimelineInstanceEMPTY() { @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 void setMediaSources_mediaSourceListUnprepared_notUsingLazyPreparation() 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 void setMediaSources_mediaSourceListUnprepared_notUsingLazyPreparation() } // 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 void setMediaSources_mediaSourceListPrepared_notUsingLazyPreparation() { 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 void setMediaSources_mediaSourceListPrepared_notUsingLazyPreparation() { 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 void setMediaSources_mediaSourceListPrepared_notUsingLazyPreparation() { @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 void addMediaSources_mediaSourceListUnprepared_notUsingLazyPreparation_ex @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 void moveMediaSources() { @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 void removeMediaSources_whenUnprepared_expectNoRelease() { @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 void removeMediaSources_whenPrepared_expectRelease() { @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 void release_mediaSourceListUnprepared_expectSourcesNotReleased() { @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 void clearMediaSourceList_expectSourcesReleasedAndRemoved() { 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 00000000000..e666ec979d1 --- /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; + +/** Tests for {@link MetadataRetriever}. */ +@RunWith(AndroidJUnit4.class) +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()) { + // TODO: update once [Internal: b/168084145] is implemented. + // Advance SystemClock so that messages that are sent with a delay to the MetadataRetriever + // looper 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 874a8c5a5af..490cc520fe7 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.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 void tearDown() { } @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 void experimental_blockUntilDelivered_markAsProcessedWhileBlocked_succeed }); 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 00000000000..e3a625a3ceb --- /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 9a28f0be582..65b01193544 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; @@ -63,11 +64,12 @@ public void multiPeriodTimeline() { @Test public void windowEquals() { + MediaItem mediaItem = new MediaItem.Builder().setUri("uri").setTag(new Object()).build(); Timeline.Window window = new Timeline.Window(); assertThat(window).isEqualTo(new Timeline.Window()); Timeline.Window otherWindow = new Timeline.Window(); - otherWindow.tag = new Object(); + otherWindow.mediaItem = mediaItem; assertThat(window).isNotEqualTo(otherWindow); otherWindow = new Timeline.Window(); @@ -118,23 +120,11 @@ public void windowEquals() { otherWindow.positionInFirstPeriodUs = C.TIME_UNSET; assertThat(window).isNotEqualTo(otherWindow); - window.uid = new Object(); - window.tag = new Object(); - window.manifest = new Object(); - window.presentationStartTimeMs = C.TIME_UNSET; - window.windowStartTimeMs = C.TIME_UNSET; - window.isSeekable = true; - window.isDynamic = true; - window.isLive = true; - window.defaultPositionUs = C.TIME_UNSET; - window.durationUs = C.TIME_UNSET; - window.firstPeriodIndex = 1; - window.lastPeriodIndex = 1; - window.positionInFirstPeriodUs = C.TIME_UNSET; + window = populateWindow(mediaItem, mediaItem.playbackProperties.tag); otherWindow = otherWindow.set( window.uid, - window.tag, + window.mediaItem, window.manifest, window.presentationStartTimeMs, window.windowStartTimeMs, @@ -156,9 +146,9 @@ public void windowHashCode() { Timeline.Window otherWindow = new Timeline.Window(); assertThat(window.hashCode()).isEqualTo(otherWindow.hashCode()); - window.tag = new Object(); + window.mediaItem = new MediaItem.Builder().setMediaId("mediaId").setTag(new Object()).build(); assertThat(window.hashCode()).isNotEqualTo(otherWindow.hashCode()); - otherWindow.tag = window.tag; + otherWindow.mediaItem = window.mediaItem; assertThat(window.hashCode()).isEqualTo(otherWindow.hashCode()); } @@ -209,4 +199,25 @@ public void periodHashCode() { otherPeriod.windowIndex = period.windowIndex; assertThat(period.hashCode()).isEqualTo(otherPeriod.hashCode()); } + + @SuppressWarnings("deprecation") // Populates the deprecated window.tag property. + private static Timeline.Window populateWindow( + @Nullable MediaItem mediaItem, @Nullable Object tag) { + Timeline.Window window = new Timeline.Window(); + window.uid = new Object(); + window.tag = tag; + window.mediaItem = mediaItem; + window.manifest = new Object(); + window.presentationStartTimeMs = C.TIME_UNSET; + window.windowStartTimeMs = C.TIME_UNSET; + window.isSeekable = true; + window.isDynamic = true; + window.isLive = true; + window.defaultPositionUs = C.TIME_UNSET; + window.durationUs = C.TIME_UNSET; + window.firstPeriodIndex = 1; + window.lastPeriodIndex = 1; + window.positionInFirstPeriodUs = C.TIME_UNSET; + return 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 1d22984f84d..1238831cbca 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 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.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.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 TIMEOUT_MS = 10000; + 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 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 void emptyTimeline() throws Exception { 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 void singlePeriod() throws Exception { 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 void automaticPeriodTransition() throws Exception { 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 void periodTransitionWithRendererChange() throws Exception { 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 void seekToOtherPeriod() throws Exception { 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 void seekBackAfterReadingAhead() throws Exception { 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 void prepareNewSource() throws Exception { 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 void prepareNewSource() throws Exception { 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 void reprepareAfterError() throws Exception { 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 void reprepareAfterError() throws Exception { 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 void dynamicTimelineChange() throws Exception { 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 void playlistOperations() throws Exception { 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 void playlistOperations() throws Exception { @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 void adPlayback() throws Exception { 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 void onPositionDiscontinuity( adPlaybackState .get() .withPlayedAd( - playedAdCount.getAndIncrement(), + /* adGroupIndex= */ playedAdCount.getAndIncrement(), /* adIndexInAdGroup= */ 0)); fakeMediaSource.setNewSourceInfo( new FakeTimeline( @@ -815,8 +1044,9 @@ public void onPositionDiscontinuity( /* id= */ 0, /* isSeekable= */ true, /* isDynamic= */ false, - /* durationUs =*/ 10 * C.MICROS_PER_SECOND, - adPlaybackState.get()))); + contentDurationsUs, + adPlaybackState.get())), + /* sendManifestLoadEvents= */ false); } } }); @@ -840,7 +1070,9 @@ public void onPositionDiscontinuity( // 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 void onPositionDiscontinuity( 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 void onPositionDiscontinuity( 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,45 +1184,49 @@ public void onPositionDiscontinuity( midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); - assertThat(listener.getEvents(EVENT_MEDIA_PERIOD_CREATED)) + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_ENABLED)).containsExactly(prerollAd); + assertThat(listener.getEvents(EVENT_DECODER_INIT)) .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)) + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) .containsExactly( prerollAd, contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); - assertThat(listener.getEvents(EVENT_DECODER_ENABLED)).containsExactly(prerollAd); - assertThat(listener.getEvents(EVENT_DECODER_INIT)) + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_ENABLED)).containsExactly(prerollAd); + assertThat(listener.getEvents(EVENT_VIDEO_DECODER_INIT)) .containsExactly( prerollAd, contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); - assertThat(listener.getEvents(EVENT_DECODER_FORMAT_CHANGED)) + contentAfterPostroll) + .inOrder(); + assertThat(listener.getEvents(EVENT_VIDEO_INPUT_FORMAT_CHANGED)) .containsExactly( prerollAd, contentAfterPreroll, midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); - assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll); + 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 void onPositionDiscontinuity( midrollAd, contentAfterMidroll, postrollAd, - contentAfterPostroll); + contentAfterPostroll) + .inOrder(); assertThat(listener.getEvents(EVENT_RENDERED_FIRST_FRAME)) .containsExactly( prerollAd, @@ -1005,14 +1243,18 @@ public void onPositionDiscontinuity( 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 void seekAfterMidroll() throws Exception { 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 void seekAfterMidroll() throws Exception { 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 void seekAfterMidroll() throws Exception { 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 void seekAfterMidroll() throws Exception { 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 void run(SimpleExoPlayer player) { 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 @@ private static TestAnalyticsListener runAnalyticsTest( 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 boolean equals(@Nullable Object other) { @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 void assertNoMoreEvents() { 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 void onSeekStarted(EventTime eventTime) { 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 void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData 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 void onMetadata(EventTime eventTime, Metadata metadata) { 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 void onVideoSizeChanged( 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 void onDrmSessionReleased(EventTime eventTime) { 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 ReportedEvent(int eventType, EventTime eventTime) { @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 d0a9a496f1b..5f97ad78f23 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,7 +20,9 @@ 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; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -38,6 +40,7 @@ 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; @@ -159,26 +162,44 @@ public void updateSessions_ofSameWindow_withAd_afterWithoutMediaPeriodId_creates @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 @@ -349,18 +370,6 @@ public void updateSessions_ofSameWindow_withNewWindowSequenceNumber_createsNewSe 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); @@ -398,6 +407,81 @@ public void updateSessions_withoutMediaPeriodId_afterSessionForMediaPeriodId_ret 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 = @@ -464,28 +548,38 @@ public void belongsToSession_withOtherWindowSequenceNumber_returnsFalse() { @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 @@ -500,8 +594,7 @@ public void initialTimelineUpdate_finishesAllSessionsOutsideTimeline() { 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); @@ -544,8 +637,7 @@ public void dynamicTimelineUpdate_resolvesWindowIndices() { 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 = @@ -577,7 +669,7 @@ public void dynamicTimelineUpdate_resolvesWindowIndices() { timelineUpdate.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 2)); - sessionManager.handleTimelineUpdate(eventForTimelineUpdateId100); + sessionManager.updateSessionsWithTimelineChange(eventForTimelineUpdateId100); String updatedSessionId100 = sessionManager.getSessionForMediaPeriodId( timelineUpdate, eventForTimelineUpdateId100.mediaPeriodId); @@ -631,7 +723,7 @@ public void timelineUpdate_withContent_doesNotFinishFuturePostrollAd() { sessionManager.updateSessions(contentEventTime); sessionManager.updateSessions(adEventTime); - sessionManager.handleTimelineUpdate(contentEventTime); + sessionManager.updateSessionsWithTimelineChange(contentEventTime); verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); } @@ -652,13 +744,11 @@ public void positionDiscontinuity_withinWindow_doesNotFinishSession() { /* 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()); @@ -680,17 +770,15 @@ public void positionDiscontinuity_toNewWindow_withPeriodTransitionReason_finishe /* 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); @@ -716,16 +804,14 @@ public void positionDiscontinuity_toNewWindow_withSeekTransitionReason_finishesS /* 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); @@ -747,12 +833,10 @@ public void positionDiscontinuity_toSameWindow_withoutMediaPeriodId_doesNotFinis 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()); } @@ -784,7 +868,7 @@ public void positionDiscontinuity_toNewWindow_finishesOnlyPastSessions() { /* 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); @@ -794,8 +878,7 @@ public void positionDiscontinuity_toNewWindow_finishesOnlyPastSessions() { 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); @@ -841,7 +924,20 @@ public void positionDiscontinuity_fromAdToContent_finishesAd() { /* 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, @@ -849,25 +945,31 @@ public void positionDiscontinuity_fromAdToContent_finishesAd() { 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); - - sessionManager.handlePositionDiscontinuity( - contentEventTime, Player.DISCONTINUITY_REASON_AD_INSERTION); - sessionManager.updateSessions(contentEventTime); - - verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); - verify(mockListener).onSessionActive(adEventTime1, adSessionId1); - verify(mockListener).onSessionCreated(eq(adEventTime2), anyString()); - verify(mockListener) + String contentSessionId = + sessionManager.getSessionForMediaPeriodId( + adTimeline, contentEventTimeDuringPreroll.mediaPeriodId); + + sessionManager.updateSessionsWithDiscontinuity( + contentEventTimeBetweenAds, Player.DISCONTINUITY_REASON_AD_INSERTION); + + 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 @@ -910,14 +1012,12 @@ public void positionDiscontinuity_fromContentToAd_doesNotFinishSessions() { 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()); } @@ -961,8 +1061,7 @@ public void positionDiscontinuity_fromAdToAd_finishesPastAds_andNotifiesAdPlayba 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 = @@ -972,11 +1071,9 @@ public void positionDiscontinuity_fromAdToAd_finishesPastAds_andNotifiesAdPlayba 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,69 +1090,28 @@ public void positionDiscontinuity_fromAdToAd_finishesPastAds_andNotifiesAdPlayba } @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); + public void finishAllSessions_callsOnSessionFinishedForAllCreatedSessions() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 4); + EventTime eventTimeWindow0 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTimeWindow2 = + createEventTime(timeline, /* windowIndex= */ 2, /* mediaPeriodId= */ null); + // Actually create sessions for window 0 and 2. + sessionManager.updateSessions(eventTimeWindow0); + sessionManager.updateSessions(eventTimeWindow2); + // Query information about session for window 1, but don't create it. + sessionManager.getSessionForMediaPeriodId( + timeline, + new MediaPeriodId( + timeline.getPeriod(/* periodIndex= */ 1, new Timeline.Period(), /* setIds= */ true).uid, + /* windowSequenceNumber= */ 123)); + verify(mockListener, times(2)).onSessionCreated(any(), anyString()); - sessionManager.updateSessions(adEventTime2); + EventTime finishEventTime = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + sessionManager.finishAllSessions(finishEventTime); - verify(mockListener, never()).onSessionActive(any(), eq(adSessionId2)); + verify(mockListener, times(2)).onSessionFinished(eq(finishEventTime), anyString(), eq(false)); } private static EventTime createEventTime( @@ -1066,6 +1122,27 @@ private static EventTime createEventTime( 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 f08d63400dd..1f19c2af588 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/PlaybackStatsListenerTest.java @@ -16,11 +16,21 @@ package com.google.android.exoplayer2.analytics; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.testutil.FakeTimeline; import org.junit.Test; import org.junit.runner.RunWith; @@ -28,15 +38,74 @@ @RunWith(AndroidJUnit4.class) public final class PlaybackStatsListenerTest { - private static final AnalyticsListener.EventTime TEST_EVENT_TIME = + private static final AnalyticsListener.EventTime EMPTY_TIMELINE_EVENT_TIME = new AnalyticsListener.EventTime( /* realtimeMs= */ 500, Timeline.EMPTY, /* 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, + TEST_MEDIA_PERIOD_ID, + /* eventPlaybackPositionMs= */ 123, + TEST_TIMELINE, + /* currentWindowIndex= */ 0, + TEST_MEDIA_PERIOD_ID, + /* currentPlaybackPositionMs= */ 123, + /* totalBufferedDurationMs= */ 456); + + @Test + public void events_duringInitialIdleState_dontCreateNewPlaybackStats() { + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); + + playbackStatsListener.onPositionDiscontinuity( + EMPTY_TIMELINE_EVENT_TIME, Player.DISCONTINUITY_REASON_SEEK); + playbackStatsListener.onPlaybackParametersChanged( + EMPTY_TIMELINE_EVENT_TIME, new PlaybackParameters(/* speed= */ 2.0f)); + playbackStatsListener.onPlayWhenReadyChanged( + EMPTY_TIMELINE_EVENT_TIME, + /* playWhenReady= */ true, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST); + + assertThat(playbackStatsListener.getPlaybackStats()).isNull(); + } + + @Test + public void stateChangeEvent_toNonIdle_createsInitialPlaybackStats() { + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); + + playbackStatsListener.onPlaybackStateChanged(EMPTY_TIMELINE_EVENT_TIME, Player.STATE_BUFFERING); + + assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); + } + + @Test + public void timelineChangeEvent_toNonEmpty_createsInitialPlaybackStats() { + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); + + playbackStatsListener.onTimelineChanged( + TEST_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + + assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); + } @Test public void playback_withKeepHistory_updatesStats() { @@ -65,4 +134,74 @@ public void playback_withoutKeepHistory_updatesStats() { assertThat(playbackStats).isNotNull(); assertThat(playbackStats.endedCount).isEqualTo(1); } + + @Test + public void finishedSession_callsCallback() { + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + + // Create session with an event and finish it by simulating removal from playlist. + playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING); + verify(callback, never()).onPlaybackStatsReady(any(), any()); + playbackStatsListener.onTimelineChanged( + EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + + verify(callback).onPlaybackStatsReady(eq(TEST_EVENT_TIME), any()); + } + + @Test + public void finishAllSessions_callsAllPendingCallbacks() { + AnalyticsListener.EventTime eventTimeWindow0 = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 0, + Timeline.EMPTY, + /* windowIndex= */ 0, + /* mediaPeriodId= */ null, + /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 0, + /* currentMediaPeriodId= */ null, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + AnalyticsListener.EventTime eventTimeWindow1 = + new AnalyticsListener.EventTime( + /* realtimeMs= */ 0, + Timeline.EMPTY, + /* windowIndex= */ 1, + /* mediaPeriodId= */ null, + /* eventPlaybackPositionMs= */ 0, + Timeline.EMPTY, + /* currentWindowIndex= */ 1, + /* currentMediaPeriodId= */ null, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + playbackStatsListener.onPlaybackStateChanged(eventTimeWindow0, Player.STATE_BUFFERING); + playbackStatsListener.onPlaybackStateChanged(eventTimeWindow1, Player.STATE_BUFFERING); + + playbackStatsListener.finishAllSessions(); + + verify(callback, times(2)).onPlaybackStatsReady(any(), any()); + verify(callback).onPlaybackStatsReady(eq(eventTimeWindow0), any()); + verify(callback).onPlaybackStatsReady(eq(eventTimeWindow1), any()); + } + + @Test + public void finishAllSessions_doesNotCallCallbackAgainWhenSessionWouldBeAutomaticallyFinished() { + PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); + PlaybackStatsListener playbackStatsListener = + new PlaybackStatsListener(/* keepHistory= */ true, callback); + playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING); + SystemClock.setCurrentTimeMillis(TEST_EVENT_TIME.realtimeMs + 100); + + playbackStatsListener.finishAllSessions(); + // Simulate removing the playback item to ensure the session would finish if it hadn't already. + playbackStatsListener.onTimelineChanged( + EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + + verify(callback).onPlaybackStatsReady(any(), any()); + } } 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 bfc657aaf40..7e9126be986 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.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.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 @@ protected int supportsFormatInternal(Format format) { } @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 void immediatelyReadEndOfStreamPlaysAudioSinkToEndOfStream() throws Excep 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 9689a326e72..2f86988d424 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.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 void setUp() { new DefaultAudioSink( AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, new DefaultAudioSink.DefaultAudioProcessorChain(teeAudioProcessor), - /* enableConvertHighResIntPcmToFloat= */ false); + /* enableFloatOutput= */ false, + /* enableAudioTrackPlaybackParams= */ false, + /* enableOffload= */ false); } @Test @@ -87,8 +89,8 @@ public void handlesBufferAfterReset() throws Exception { } @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 void handlesBufferAfterReset_withPlaybackParameters() throws Exception { 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 void handlesBufferAfterReset_withFormatChange() throws Exception { } @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 void handlesBufferAfterReset_withFormatChangeAndPlaybackParameters() thro 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,18 +201,110 @@ public void getCurrentPosition_returnsPositionFromFirstBuffer() throws Exception .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, 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, 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 { @@ -217,14 +313,16 @@ private void configureDefaultAudioSink(int channelCount) throws AudioSink.Config 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 00000000000..d3423485fb2 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/MediaCodecAudioRendererTest.java @@ -0,0 +1,330 @@ +/* + * 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.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.media.MediaFormat; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +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.ArgumentCaptor; +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; + @Mock private AudioRendererEventListener audioRendererEventListener; + + @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)); + + Handler eventHandler = new Handler(Looper.getMainLooper()); + + mediaCodecAudioRenderer = + new MediaCodecAudioRenderer( + ApplicationProvider.getApplicationContext(), + mediaCodecSelector, + /* enableDecoderFallback= */ false, + eventHandler, + audioRendererEventListener, + 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); + } + + @Test + public void + render_callsAudioRendererEventListener_whenAudioSinkListenerOnAudioSessionIdIsCalled() { + final ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AudioSink.Listener.class); + verify(audioSink, atLeastOnce()).setListener(listenerCaptor.capture()); + AudioSink.Listener audioSinkListener = listenerCaptor.getValue(); + + int audioSessionId = 2; + audioSinkListener.onAudioSessionId(audioSessionId); + + shadowOf(Looper.getMainLooper()).idle(); + verify(audioRendererEventListener).onAudioSessionId(audioSessionId); + } + + @Test + public void + render_callsAudioRendererEventListener_whenAudioSinkListenerOnAudioSinkErrorIsCalled() { + final ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AudioSink.Listener.class); + verify(audioSink, atLeastOnce()).setListener(listenerCaptor.capture()); + AudioSink.Listener audioSinkListener = listenerCaptor.getValue(); + + Exception error = new AudioSink.WriteException(/* errorCode= */ 1, /* isRecoverable= */ true); + audioSinkListener.onAudioSinkError(error); + + shadowOf(Looper.getMainLooper()).idle(); + verify(audioRendererEventListener).onAudioSinkError(error); + } + + private static Format getAudioSinkFormat(Format inputFormat) { + return new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .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 fac1c4e3229..9c14c37587b 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 void skipWithLargerInputBufferSize_hasCorrectOutputAndSkippedFrameCounts( 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 ByteBuffer getNextInputBuffer(int sizeBytes) { 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/audio/TrimmingAudioProcessorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java new file mode 100644 index 00000000000..19a1ad19c30 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/audio/TrimmingAudioProcessorTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.audio; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.audio.AudioProcessor.AudioFormat; +import java.nio.ByteBuffer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link TrimmingAudioProcessor}. */ +@RunWith(AndroidJUnit4.class) +public final class TrimmingAudioProcessorTest { + + private static final AudioFormat AUDIO_FORMAT = + new AudioFormat(/* sampleRate= */ 44100, /* channelCount= */ 2, C.ENCODING_PCM_16BIT); + private static final int TRACK_ONE_UNTRIMMED_FRAME_COUNT = 1024; + private static final int TRACK_ONE_TRIM_START_FRAME_COUNT = 64; + private static final int TRACK_ONE_TRIM_END_FRAME_COUNT = 32; + private static final int TRACK_TWO_TRIM_START_FRAME_COUNT = 128; + private static final int TRACK_TWO_TRIM_END_FRAME_COUNT = 16; + + private static final int TRACK_ONE_BUFFER_SIZE_BYTES = + AUDIO_FORMAT.bytesPerFrame * TRACK_ONE_UNTRIMMED_FRAME_COUNT; + private static final int TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES = + TRACK_ONE_BUFFER_SIZE_BYTES + - AUDIO_FORMAT.bytesPerFrame + * (TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT); + + private TrimmingAudioProcessor trimmingAudioProcessor; + + @Before + public void setUp() { + trimmingAudioProcessor = new TrimmingAudioProcessor(); + } + + @After + public void tearDown() { + trimmingAudioProcessor.reset(); + } + + @Test + public void flushTwice_trimsStartAndEnd() throws Exception { + trimmingAudioProcessor.setTrimFrameCount( + TRACK_ONE_TRIM_START_FRAME_COUNT, TRACK_ONE_TRIM_END_FRAME_COUNT); + trimmingAudioProcessor.configure(AUDIO_FORMAT); + trimmingAudioProcessor.flush(); + trimmingAudioProcessor.flush(); + + int outputSizeBytes = feedAndDrainAudioProcessorToEndOfTrackOne(); + + assertThat(trimmingAudioProcessor.getTrimmedFrameCount()) + .isEqualTo(TRACK_ONE_TRIM_START_FRAME_COUNT + TRACK_ONE_TRIM_END_FRAME_COUNT); + assertThat(outputSizeBytes).isEqualTo(TRACK_ONE_TRIMMED_BUFFER_SIZE_BYTES); + } + + /** + * Feeds and drains the audio processor up to the end of track one, returning the total output + * size in bytes. + */ + private int feedAndDrainAudioProcessorToEndOfTrackOne() throws Exception { + // Feed and drain the processor, simulating a gapless transition to another track. + ByteBuffer inputBuffer = ByteBuffer.allocate(TRACK_ONE_BUFFER_SIZE_BYTES); + int outputSize = 0; + while (!trimmingAudioProcessor.isEnded()) { + if (inputBuffer.hasRemaining()) { + trimmingAudioProcessor.queueInput(inputBuffer); + if (!inputBuffer.hasRemaining()) { + // Reconfigure for a next track then begin draining. + trimmingAudioProcessor.setTrimFrameCount( + TRACK_TWO_TRIM_START_FRAME_COUNT, TRACK_TWO_TRIM_END_FRAME_COUNT); + trimmingAudioProcessor.configure(AUDIO_FORMAT); + trimmingAudioProcessor.queueEndOfStream(); + } + } + ByteBuffer outputBuffer = trimmingAudioProcessor.getOutput(); + outputSize += outputBuffer.remaining(); + outputBuffer.clear(); + } + trimmingAudioProcessor.reset(); + return outputSize; + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java b/library/core/src/test/java/com/google/android/exoplayer2/database/VersionTableTest.java index 2d74175265a..f74b0ada918 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 void removeVersion_removesSetVersion() throws DatabaseIOException { .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 00000000000..a700350b0b2 --- /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 c36c6cff38f..ae579b1b7cf 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 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.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 void setUp() throws Exception { 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 void downloadRenewReleaseKey() throws Exception { 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 void downloadRenewReleaseKey() throws Exception { } @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 void downloadLicenseFailsIfNoKeySetIdIsReturned() throws Exception { setStubLicenseAndPlaybackDurationValues(1000, 200); try { - offlineLicenseHelper.downloadLicense(newDrmInitData()); + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); fail(); } catch (Exception e) { // Expected. @@ -113,7 +112,8 @@ public void downloadLicenseFailsIfNoKeySetIdIsReturned() throws Exception { public void downloadLicenseDoesNotFailIfDurationNotAvailable() throws Exception { setDefaultStubKeySetId(); - byte[] offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(newDrmInitData()); + byte[] offlineLicenseKeySetId = + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); assertThat(offlineLicenseKeySetId).isNotNull(); } @@ -125,7 +125,8 @@ public void getLicenseDurationRemainingSec() throws Exception { setStubLicenseAndPlaybackDurationValues(licenseDuration, playbackDuration); setDefaultStubKeySetId(); - byte[] offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(newDrmInitData()); + byte[] offlineLicenseKeySetId = + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); Pair licenseDurationRemainingSec = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId); @@ -141,7 +142,8 @@ public void getLicenseDurationRemainingSecExpiredLicense() throws Exception { setStubLicenseAndPlaybackDurationValues(licenseDuration, playbackDuration); setDefaultStubKeySetId(); - byte[] offlineLicenseKeySetId = offlineLicenseHelper.downloadLicense(newDrmInitData()); + byte[] offlineLicenseKeySetId = + offlineLicenseHelper.downloadLicense(newFormatWithDrmInitData()); Pair licenseDurationRemainingSec = offlineLicenseHelper.getLicenseDurationRemainingSec(offlineLicenseKeySetId); @@ -176,8 +178,11 @@ private void setStubLicenseAndPlaybackDurationValues( 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 00000000000..f37610d982c --- /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 00000000000..d57f06ff52a --- /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 00000000000..f9c32d34b56 --- /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 00000000000..6d7f23107e7 --- /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 00000000000..a14787e959a --- /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 f816d1d11be..0c023d38415 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecAdapterTest.java @@ -16,55 +16,57 @@ 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, + /* 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 +74,257 @@ public void dequeueInputBufferIndex_withoutInputBuffer_returnsTryAgainLater() { @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(); + + adapter.flush(); + // Progress the looper to complete flush(): the adapter should call codec.start(), triggering + // the ShadowMediaCodec to offer input buffer 0. + shadowLooper.idle(); - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThat(adapter.dequeueInputBufferIndex()).isEqualTo(1); + 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.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(); + + int index = adapter.dequeueInputBufferIndex(); + adapter.queueInputBuffer(index, 0, 0, 0, 0); + // Progress the looper so that the ShadowMediaCodec processes the input buffer. + shadowLooper.idle(); - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)).isEqualTo(0); - assertBufferInfosEqual(bufferInfo, outBufferInfo); + // 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()); + + assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); } @Test - public void dequeueOutputBufferIndex_afterFlushCompletesWithError_throwsException() { - AtomicInteger calls = new AtomicInteger(0); - adapter.setCodecStartRunnable( - () -> { - if (calls.incrementAndGet() == 2) { - throw new RuntimeException("codec#start() exception"); - } - }); + public void dequeueOutputBufferIndex_afterShutdown_returnsTryAgainLater() { + adapter.configure( + createMediaFormat("foo"), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); adapter.start(); - adapter.flush(); + // After start(), the ShadowMediaCodec offers input buffer 0, which is available only if we + // progress the adapter's looper. + 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(); - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThrows(IllegalStateException.class, () -> adapter.dequeueOutputBufferIndex(bufferInfo)); + assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) + .isEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); } @Test - public void getOutputFormat_withMultipleFormats_returnsFormatsInCorrectOrder() { + 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(); - MediaFormat[] formats = new MediaFormat[10]; - MediaCodec.Callback mediaCodecCallback = adapter.getMediaCodecCallback(); - for (int i = 0; i < formats.length; i++) { - formats[i] = new MediaFormat(); - mediaCodecCallback.onOutputFormatChanged(codec, formats[i]); - } - for (MediaFormat format : formats) { - assertThat(adapter.dequeueOutputBufferIndex(bufferInfo)) - .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); - assertThat(adapter.getOutputFormat()).isEqualTo(format); - // Call it again to ensure same format is returned - assertThat(adapter.getOutputFormat()).isEqualTo(format); - } - // Obtain next output buffer + 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); - // Format should remain as is - assertThat(adapter.getOutputFormat()).isEqualTo(formats[formats.length - 1]); } @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); + // 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(); + adapter.dequeueOutputBufferIndex(bufferInfo); + MediaFormat outputFormat = adapter.getOutputFormat(); + // Flush the adapter and progress the looper so that flush is completed. adapter.flush(); + shadowLooper.idle(); - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThat(adapter.getOutputFormat()).isEqualTo(format); + 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; + } + + /** 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"); + } - // Wait until all tasks have been handled. - Shadows.shadowOf(looper).idle(); - assertThat(onCodecStartCalled.get()).isEqualTo(1); + 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 c7020b41694..e27c428a941 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java @@ -19,15 +19,17 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doAnswer; +import static org.robolectric.Shadows.shadowOf; import android.media.MediaCodec; +import android.media.MediaFormat; 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; import com.google.android.exoplayer2.util.ConditionVariable; import java.io.IOException; +import java.nio.ByteBuffer; import java.util.concurrent.atomic.AtomicLong; import org.junit.After; import org.junit.Before; @@ -37,21 +39,22 @@ 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,10 +63,38 @@ public void setUp() throws IOException { @After public void tearDown() { enqueuer.shutdown(); - + codec.stop(); + codec.release(); assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); } + @Test + public void queueInputBuffer_queuesInputBufferOnMediaCodec() { + enqueuer.start(); + int inputBufferIndex = codec.dequeueInputBuffer(0); + assertThat(inputBufferIndex).isAtLeast(0); + byte[] inputData = new byte[] {0, 1, 2, 3}; + codec.getInputBuffer(inputBufferIndex).put(inputData); + + enqueuer.queueInputBuffer( + inputBufferIndex, + /* offset= */ 0, + /* size= */ 4, + /* presentationTimeUs= */ 0, + /* flags= */ 0); + shadowOf(handlerThread.getLooper()).idle(); + + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + assertThat(codec.dequeueOutputBuffer(bufferInfo, 0)) + .isEqualTo(MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + assertThat(codec.dequeueOutputBuffer(bufferInfo, 0)).isEqualTo(inputBufferIndex); + ByteBuffer outputBuffer = codec.getOutputBuffer(inputBufferIndex); + assertThat(outputBuffer.limit()).isEqualTo(4); + byte[] outputData = new byte[4]; + outputBuffer.get(outputData); + assertThat(outputData).isEqualTo(inputData); + } + @Test public void queueInputBuffer_withPendingCryptoExceptionSet_throwsCryptoException() { enqueuer.setPendingRuntimeException( @@ -96,29 +127,6 @@ public void queueInputBuffer_withPendingIllegalStateExceptionSet_throwsIllegalSt /* 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( @@ -132,7 +140,7 @@ public void queueSecureInputBuffer_withPendingCryptoException_throwsCryptoExcept enqueuer.queueSecureInputBuffer( /* index= */ 0, /* offset= */ 0, - /* info= */ info, + info, /* presentationTimeUs= */ 0, /* flags= */ 0)); } @@ -154,30 +162,6 @@ public void queueSecureInputBuffer_codecThrewIllegalStateException_throwsIllegal /* 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 new file mode 100644 index 00000000000..6579e8ee06e --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/BatchBufferTest.java @@ -0,0 +1,251 @@ +/* + * 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 static org.junit.Assert.assertThrows; + +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; + +/** Unit tests for {@link BatchBuffer}. */ +@RunWith(AndroidJUnit4.class) +public final class BatchBufferTest { + + /** 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 final BatchBuffer batchBuffer = new BatchBuffer(); + + @Test + public void newBatchBuffer_isEmpty() { + assertIsCleared(batchBuffer); + } + + @Test + public void clear_empty_isEmpty() { + batchBuffer.clear(); + + assertIsCleared(batchBuffer); + } + + @Test + public void clear_afterInsertingAccessUnit_isEmpty() { + batchBuffer.commitNextAccessUnit(); + + batchBuffer.clear(); + + assertIsCleared(batchBuffer); + } + + @Test + public void commitNextAccessUnit_addsAccessUnit() { + batchBuffer.commitNextAccessUnit(); + + assertThat(batchBuffer.getAccessUnitCount()).isEqualTo(1); + } + + @Test + public void commitNextAccessUnit_untilFull_isFullAndNotEmpty() { + fillBatchBuffer(batchBuffer); + + assertThat(batchBuffer.isEmpty()).isFalse(); + assertThat(batchBuffer.isFull()).isTrue(); + } + + @Test + public void commitNextAccessUnit_whenFull_throws() { + batchBuffer.setMaxAccessUnitCount(1); + batchBuffer.commitNextAccessUnit(); + + assertThrows(IllegalStateException.class, batchBuffer::commitNextAccessUnit); + } + + @Test + public void commitNextAccessUnit_whenAccessUnitIsDecodeOnly_isDecodeOnly() { + batchBuffer.getNextAccessUnitBuffer().setFlags(C.BUFFER_FLAG_DECODE_ONLY); + + batchBuffer.commitNextAccessUnit(); + + assertThat(batchBuffer.isDecodeOnly()).isTrue(); + } + + @Test + public void commitNextAccessUnit_whenAccessUnitIsEndOfStream_isEndOfSteam() { + batchBuffer.getNextAccessUnitBuffer().setFlags(C.BUFFER_FLAG_END_OF_STREAM); + + batchBuffer.commitNextAccessUnit(); + + assertThat(batchBuffer.isEndOfStream()).isTrue(); + } + + @Test + public void commitNextAccessUnit_whenAccessUnitIsKeyFrame_isKeyFrame() { + batchBuffer.getNextAccessUnitBuffer().setFlags(C.BUFFER_FLAG_KEY_FRAME); + + batchBuffer.commitNextAccessUnit(); + + assertThat(batchBuffer.isKeyFrame()).isTrue(); + } + + @Test + public void commitNextAccessUnit_withData_dataIsCopiedInTheBatch() { + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); + batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); + + batchBuffer.commitNextAccessUnit(); + batchBuffer.flip(); + + assertThat(batchBuffer.getAccessUnitCount()).isEqualTo(1); + assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(TEST_ACCESS_UNIT)); + } + + @Test + public void commitNextAccessUnit_nextAccessUnit_isClear() { + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); + batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); + batchBuffer.getNextAccessUnitBuffer().setFlags(C.BUFFER_FLAG_KEY_FRAME); + + batchBuffer.commitNextAccessUnit(); + + DecoderInputBuffer nextAccessUnit = batchBuffer.getNextAccessUnitBuffer(); + assertThat(nextAccessUnit.data).isNotNull(); + assertThat(nextAccessUnit.data.position()).isEqualTo(0); + assertThat(nextAccessUnit.isKeyFrame()).isFalse(); + } + + @Test + public void commitNextAccessUnit_twice_bothAccessUnitAreConcatenated() { + // Commit TEST_ACCESS_UNIT + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); + batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); + batchBuffer.commitNextAccessUnit(); + // Commit TEST_ACCESS_UNIT again + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); + batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); + + batchBuffer.commitNextAccessUnit(); + batchBuffer.flip(); + + byte[] expected = Bytes.concat(TEST_ACCESS_UNIT, TEST_ACCESS_UNIT); + assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(expected)); + } + + @Test + public void commitNextAccessUnit_whenAccessUnitIsHugeAndBatchBufferNotEmpty_isMarkedPending() { + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); + batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); + batchBuffer.commitNextAccessUnit(); + 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(hugeAccessUnit)); + } + + @Test + public void batchWasConsumed_whenNotEmpty_isEmpty() { + batchBuffer.commitNextAccessUnit(); + + batchBuffer.batchWasConsumed(); + + assertIsCleared(batchBuffer); + } + + @Test + public void batchWasConsumed_whenFull_isEmpty() { + fillBatchBuffer(batchBuffer); + + batchBuffer.batchWasConsumed(); + + assertIsCleared(batchBuffer); + } + + @Test + public void getMaxAccessUnitCount_whenSetToAPositiveValue_returnsIt() { + batchBuffer.setMaxAccessUnitCount(20); + + assertThat(batchBuffer.getMaxAccessUnitCount()).isEqualTo(20); + } + + @Test + public void setMaxAccessUnitCount_whenSetToNegative_throws() { + assertThrows(IllegalArgumentException.class, () -> batchBuffer.setMaxAccessUnitCount(-19)); + } + + @Test + public void setMaxAccessUnitCount_whenSetToZero_throws() { + assertThrows(IllegalArgumentException.class, () -> batchBuffer.setMaxAccessUnitCount(0)); + } + + @Test + public void setMaxAccessUnitCount_whenSetToTheNumberOfAccessUnitInTheBatch_isFull() { + batchBuffer.commitNextAccessUnit(); + + batchBuffer.setMaxAccessUnitCount(1); + + assertThat(batchBuffer.isFull()).isTrue(); + } + + @Test + public void batchWasConsumed_whenAccessUnitIsPending_pendingAccessUnitIsInTheBatch() { + batchBuffer.commitNextAccessUnit(); + batchBuffer.getNextAccessUnitBuffer().setFlags(C.BUFFER_FLAG_DECODE_ONLY); + batchBuffer.getNextAccessUnitBuffer().ensureSpaceForWrite(TEST_ACCESS_UNIT.length); + batchBuffer.getNextAccessUnitBuffer().data.put(TEST_ACCESS_UNIT); + batchBuffer.commitNextAccessUnit(); + + batchBuffer.batchWasConsumed(); + batchBuffer.flip(); + + assertThat(batchBuffer.getAccessUnitCount()).isEqualTo(1); + assertThat(batchBuffer.isDecodeOnly()).isTrue(); + assertThat(batchBuffer.data).isEqualTo(ByteBuffer.wrap(TEST_ACCESS_UNIT)); + } + + private static void fillBatchBuffer(BatchBuffer batchBuffer) { + int maxAccessUnit = batchBuffer.getMaxAccessUnitCount(); + while (!batchBuffer.isFull()) { + assertThat(maxAccessUnit--).isNotEqualTo(0); + batchBuffer.commitNextAccessUnit(); + } + } + + private static void assertIsCleared(BatchBuffer batchBuffer) { + assertThat(batchBuffer.getFirstAccessUnitTimeUs()).isEqualTo(C.TIME_UNSET); + assertThat(batchBuffer.getLastAccessUnitTimeUs()).isEqualTo(C.TIME_UNSET); + assertThat(batchBuffer.getAccessUnitCount()).isEqualTo(0); + assertThat(batchBuffer.isEmpty()).isTrue(); + assertThat(batchBuffer.isFull()).isFalse(); + } +} 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 00000000000..1108b882e46 --- /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 7ea55b1d826..00000000000 --- 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 0161b541f18..7cf3f323916 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 void dequeOutputBufferIndex_afterFlushAndNewOutputBuffers_returnsEnqueueB 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 cfe9cf29002..00000000000 --- 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 4d1b4f601bc..796f56becff 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 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.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 @@ 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 @@ private static List runRenderer(byte[] input) throws ExoPlaybackExcept 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 @@ private static List runRenderer(byte[] input) throws ExoPlaybackExcept */ 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 @@ private static byte[] encodeTxxxId3Frame(String description, String value) { 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 39de14b893d..f6256ef6ab3 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 @@ @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 49cca0367d2..d16941b021f 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 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 void decode() { 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 90c2e7d3863..dcb1f634c9b 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 @@ private Metadata feedInputBuffer(byte[] data, long timeUs, long subsampleOffset) } 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 cec0d07688e..02ff4bba5e7 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 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 void tearDown() throws Exception { @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 void loadNoDataThrowsIOException() throws Exception { @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 void loadIncompleteHeaderThrowsIOException() throws Exception { @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 void loadZeroActions() throws Exception { @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 void loadOneAction() throws Exception { @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 void loadTwoActions() throws Exception { @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 @@ private ActionFile getActionFile(String fileName) throws IOException { } 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 17c1b57f37c..05c0bcc780e 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.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,66 @@ public void tearDown() { } @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); } + DownloadRequest expectedRequest1 = + 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.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(); - StreamKey expectedStreamKey1 = - new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5); - StreamKey expectedStreamKey2 = - new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2); + 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_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( - "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/manifest.mpd", + Uri.parse("http://www.test.com/1/manifest.mpd")) + .setMimeType(MimeTypes.APPLICATION_MPD) + .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= */ "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, @@ -96,23 +125,101 @@ public void upgradeAndDelete_createsDownloads() throws IOException { /* 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 void mergeRequest_existingDownload_createsMergedDownload() throws IOExcep 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 void mergeRequest_addNewDownloadAsCompleted() throws IOException { 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 @@ private void assertDownloadIndexContainsRequest(DownloadRequest request, int sta 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 cc1ae4b71bd..988b5127ec5 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 void addAndGetDownload_existingId_returnsUpdatedDownload() throws Databas 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 void addAndGetDownload_existingId_returnsUpdatedDownload() throws Databas 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 void getDownloads_withStates_returnsAllDownloadStatusWithTheSameStates() 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 void downloadIndex_versionDowngradeWipesData() throws DatabaseIOException .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 void setStopReason_setReasonToNone() throws Exception { 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 void setStopReason_setReason() throws Exception { 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 void setStopReason_setReason() throws Exception { @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 void setStatesToRemoving_setsStateAndClearsFailureReason() throws Excepti 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 void setStatesToRemoving_setsStateAndClearsFailureReason() throws Excepti 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 void setSingleDownloadStopReason_setReasonToNone() throws Exception { 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 void setSingleDownloadStopReason_setReason() throws Exception { 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 void setSingleDownloadStopReason_setReason() throws Exception { @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 @@ private static void assertEqual(Download download, Download that) { 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 c3d23c7d220..9cf52ce568f 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 @@ -21,7 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.upstream.DummyDataSource; import com.google.android.exoplayer2.upstream.cache.Cache; -import java.util.Collections; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; @@ -32,19 +32,17 @@ public final class DefaultDownloaderFactoryTest { @Test public void createProgressiveDownloader() throws Exception { - DownloaderConstructorHelper constructorHelper = - new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper); + CacheDataSource.Factory cacheDataSourceFactory = + new CacheDataSource.Factory() + .setCache(Mockito.mock(Cache.class)) + .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); + 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 5fa9ae082fc..76f92674307 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.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 void setUp() { 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 void getDownloadRequest_createsDownloadRequest_withAllSelectedTracks() th 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 void onPrepareError(DownloadHelper helper, IOException e) { 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 MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star 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 19108b8a6b6..d5959584ad9 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,474 +16,575 @@ 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; 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.offline.Download.State; import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.testutil.DownloadBuilder; import com.google.android.exoplayer2.testutil.DummyMainThread; import com.google.android.exoplayer2.testutil.DummyMainThread.TestRunnable; import com.google.android.exoplayer2.testutil.TestDownloadManagerListener; import com.google.android.exoplayer2.testutil.TestUtil; +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.Collections; -import java.util.HashMap; import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.MockitoAnnotations; -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 { - /** Used to check if condition becomes true in this time interval. */ - private static final int ASSERT_TRUE_TIMEOUT = 10000; - /** Used to check if condition stays false for this time interval. */ - private static final int ASSERT_FALSE_TIME = 1000; - /** Maximum retry delay in DownloadManager. */ - private static final int MAX_RETRY_DELAY = 5000; - /** Maximum number of times a downloader can be restarted before doing a released check. */ - private static final int MAX_STARTS_BEFORE_RELEASED = 1; - /** A stop reason. */ + /** Timeout to use when blocking on conditions that we expect to become unblocked. */ + private static final int TIMEOUT_MS = 10_000; + /** An application provided stop reason. */ private static final int APP_STOP_REASON = 1; - /** The minimum number of times a task must be retried before failing. */ + /** 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 Uri uri1; - private Uri uri2; - private Uri uri3; - private DummyMainThread dummyMainThread; - private DefaultDownloadIndex downloadIndex; - private TestDownloadManagerListener downloadManagerListener; - private FakeDownloaderFactory downloaderFactory; + private static final String ID1 = "id1"; + private static final String ID2 = "id2"; + private static final String ID3 = "id3"; + + @GuardedBy("downloaders") + private final List downloaders = new ArrayList<>(); + private DownloadManager downloadManager; + private TestDownloadManagerListener downloadManagerListener; + private DummyMainThread testThread; @Before public void setUp() throws Exception { ShadowLog.stream = System.out; - MockitoAnnotations.initMocks(this); - uri1 = Uri.parse("http://abc.com/media1"); - uri2 = Uri.parse("http://abc.com/media2"); - uri3 = Uri.parse("http://abc.com/media3"); - dummyMainThread = new DummyMainThread(); - downloadIndex = new DefaultDownloadIndex(TestUtil.getInMemoryDatabaseProvider()); - downloaderFactory = new FakeDownloaderFactory(); - setUpDownloadManager(100); + testThread = new DummyMainThread(); + setupDownloadManager(/* maxParallelDownloads= */ 100); } @After public void tearDown() throws Exception { releaseDownloadManager(); - dummyMainThread.release(); + testThread.release(); } @Test - public void downloadRunner_multipleInstancePerContent_throwsException() { - boolean exceptionThrown = false; - try { - new DownloadRunner(uri1); - new DownloadRunner(uri1); - // can't put fail() here as it would be caught in the catch below. - } catch (Throwable e) { - exceptionThrown = true; - } - assertThat(exceptionThrown).isTrue(); - } + public void downloadRequest_downloads() throws Throwable { + postDownloadRequest(ID1); + assertDownloading(ID1); - @Test - public void multipleRequestsForTheSameContent_executedOnTheSameTask() { - // Two download requests on first task - new DownloadRunner(uri1).postDownloadRequest().postDownloadRequest(); - // One download, one remove requests on second task - new DownloadRunner(uri2).postDownloadRequest().postRemoveRequest(); - // Two remove requests on third task - new DownloadRunner(uri3).postRemoveRequest().postRemoveRequest(); + FakeDownloader downloader = getDownloaderAt(0); + downloader.assertId(ID1); + downloader.assertDownloadStarted(); + downloader.finish(); + assertCompleted(ID1); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(1); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void requestsForDifferentContent_executedOnDifferentTasks() { - TaskWrapper task1 = new DownloadRunner(uri1).postDownloadRequest().getTask(); - TaskWrapper task2 = new DownloadRunner(uri2).postDownloadRequest().getTask(); - TaskWrapper task3 = new DownloadRunner(uri3).postRemoveRequest().getTask(); + public void removeRequest_cancelsAndRemovesDownload() throws Throwable { + postDownloadRequest(ID1); - assertThat(task1).isNoneOf(task2, task3); - assertThat(task2).isNotEqualTo(task3); - } + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); + assertDownloading(ID1); - @Test - public void postDownloadRequest_downloads() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - TaskWrapper task = runner.postDownloadRequest().getTask(); - task.assertDownloading(); - runner.getDownloader(0).unblock().assertReleased().assertStartCount(1); - task.assertCompleted(); - runner.assertCreatedDownloaderCount(1); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); - assertThat(downloadManager.getCurrentDownloads()).isEmpty(); - } + // The download will be canceled by the remove request. + postRemoveRequest(ID1); + downloader0.assertCanceled(); + assertRemoving(ID1); - @Test - public void postRemoveRequest_removes() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - TaskWrapper task = runner.postDownloadRequest().postRemoveRequest().getTask(); - task.assertRemoving(); - runner.getDownloader(1).unblock().assertReleased().assertStartCount(1); - task.assertRemoved(); - runner.assertCreatedDownloaderCount(2); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); - assertThat(downloadManager.getCurrentDownloads()).isEmpty(); + // The download will be removed. + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID1); + downloader1.assertRemoveStarted(); + downloader1.finish(); + assertRemoved(ID1); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(0); + assertCurrentDownloadCount(0); } @Test - public void downloadFails_retriesThenTaskFails() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - runner.postDownloadRequest(); - FakeDownloader downloader = runner.getDownloader(0); + public void download_retryUntilMinRetryCount_withoutProgress_thenFails() throws Throwable { + postDownloadRequest(ID1); + FakeDownloader downloader = getDownloaderAt(0); + downloader.assertId(ID1); for (int i = 0; i <= MIN_RETRY_COUNT; i++) { - downloader.assertStarted(MAX_RETRY_DELAY).fail(); + downloader.assertDownloadStarted(); + downloader.fail(); } + assertFailed(ID1); - downloader.assertReleased().assertStartCount(MIN_RETRY_COUNT + 1); - runner.getTask().assertFailed(); - downloadManagerListener.blockUntilTasksComplete(); - assertThat(downloadManager.getCurrentDownloads()).isEmpty(); + downloadManagerListener.blockUntilIdle(); + assertDownloaderCount(1); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void downloadFails_retries() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - runner.postDownloadRequest(); - FakeDownloader downloader = runner.getDownloader(0); + public void download_retryUntilMinRetryCountMinusOne_thenSucceeds() throws Throwable { + postDownloadRequest(ID1); + FakeDownloader downloader = getDownloaderAt(0); + downloader.assertId(ID1); for (int i = 0; i < MIN_RETRY_COUNT; i++) { - downloader.assertStarted(MAX_RETRY_DELAY).fail(); + downloader.assertDownloadStarted(); + downloader.fail(); } - downloader.assertStarted(MAX_RETRY_DELAY).unblock(); + downloader.assertDownloadStarted(); + downloader.finish(); + assertCompleted(ID1); - downloader.assertReleased().assertStartCount(MIN_RETRY_COUNT + 1); - runner.getTask().assertCompleted(); - downloadManagerListener.blockUntilTasksComplete(); - assertThat(downloadManager.getCurrentDownloads()).isEmpty(); + downloadManagerListener.blockUntilIdle(); + assertDownloaderCount(1); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void downloadProgressOnRetry_retryCountResets() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - runner.postDownloadRequest(); - FakeDownloader downloader = runner.getDownloader(0); - - int tooManyRetries = MIN_RETRY_COUNT + 10; - for (int i = 0; i < tooManyRetries; i++) { - downloader.incrementBytesDownloaded(); - downloader.assertStarted(MAX_RETRY_DELAY).fail(); + public void download_retryMakesProgress_resetsRetryCount() throws Throwable { + postDownloadRequest(ID1); + + FakeDownloader downloader = getDownloaderAt(0); + downloader.assertId(ID1); + for (int i = 0; i <= MIN_RETRY_COUNT; i++) { + downloader.assertDownloadStarted(); + downloader.incrementBytesDownloaded(); // Make some progress. + downloader.fail(); + } + // Since previous attempts all made progress the current error count should be 1. Therefore we + // should be able to fail (MIN_RETRY_COUNT - 1) more times and then still complete the download + // successfully. + for (int i = 0; i < MIN_RETRY_COUNT - 1; i++) { + downloader.assertDownloadStarted(); + downloader.fail(); } - downloader.assertStarted(MAX_RETRY_DELAY).unblock(); + downloader.assertDownloadStarted(); + downloader.finish(); + assertCompleted(ID1); - downloader.assertReleased().assertStartCount(tooManyRetries + 1); - runner.getTask().assertCompleted(); - downloadManagerListener.blockUntilTasksComplete(); + downloadManagerListener.blockUntilIdle(); + assertDownloaderCount(1); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void removeCancelsDownload() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - FakeDownloader downloader1 = runner.getDownloader(0); + public void download_retryMakesProgress_resetsRetryCount_thenFails() throws Throwable { + postDownloadRequest(ID1); - runner.postDownloadRequest(); - downloader1.assertStarted(); - runner.postRemoveRequest(); + FakeDownloader downloader = getDownloaderAt(0); + downloader.assertId(ID1); + for (int i = 0; i <= MIN_RETRY_COUNT; i++) { + downloader.assertDownloadStarted(); + downloader.incrementBytesDownloaded(); // Make some progress. + downloader.fail(); + } + // Since previous attempts all made progress the current error count should be 1. Therefore we + // should fail after MIN_RETRY_COUNT more attempts without making any progress. + for (int i = 0; i < MIN_RETRY_COUNT; i++) { + downloader.assertDownloadStarted(); + downloader.fail(); + } + assertFailed(ID1); - downloader1.assertCanceled().assertStartCount(1); - runner.getDownloader(1).unblock().assertNotCanceled(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdle(); + assertDownloaderCount(1); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void downloadNotCancelRemove() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - FakeDownloader downloader1 = runner.getDownloader(1); + public void download_WhenRemovalInProgress_doesNotCancelRemoval() throws Throwable { + postDownloadRequest(ID1); + postRemoveRequest(ID1); + assertRemoving(ID1); + + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID1); + downloader1.assertRemoveStarted(); + + postDownloadRequest(ID1); + // The removal should still complete. + downloader1.finish(); - runner.postDownloadRequest().postRemoveRequest(); - downloader1.assertStarted(); - runner.postDownloadRequest(); + // The download should then start and complete. + FakeDownloader downloader2 = getDownloaderAt(2); + downloader2.assertId(ID1); + downloader2.assertDownloadStarted(); + downloader2.finish(); + assertCompleted(ID1); - downloader1.unblock().assertNotCanceled(); - runner.getDownloader(2).unblock().assertNotCanceled(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(3); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void secondSameRemoveRequestIgnored() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - FakeDownloader downloader1 = runner.getDownloader(1); + public void remove_WhenRemovalInProgress_doesNothing() throws Throwable { + postDownloadRequest(ID1); + postRemoveRequest(ID1); + assertRemoving(ID1); - runner.postDownloadRequest().postRemoveRequest(); - downloader1.assertStarted(); - runner.postRemoveRequest(); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID1); + downloader1.assertRemoveStarted(); - downloader1.unblock().assertNotCanceled(); - runner.getTask().assertRemoved(); - runner.assertCreatedDownloaderCount(2); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + postRemoveRequest(ID1); + // The existing removal should still complete. + downloader1.finish(); + assertRemoved(ID1); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(0); + assertCurrentDownloadCount(0); } @Test public void removeAllDownloads_removesAllDownloads() throws Throwable { - // Finish one download and keep one running. - DownloadRunner runner1 = new DownloadRunner(uri1); - DownloadRunner runner2 = new DownloadRunner(uri2); - runner1.postDownloadRequest(); - runner1.getDownloader(0).unblock(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); - runner2.postDownloadRequest(); - - runner1.postRemoveAllRequest(); - runner1.getDownloader(1).unblock(); - runner2.getDownloader(1).unblock(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); - - runner1.getTask().assertRemoved(); - runner2.getTask().assertRemoved(); - assertThat(downloadManager.getCurrentDownloads()).isEmpty(); - assertThat(downloadIndex.getDownloads().getCount()).isEqualTo(0); + // Finish one download. + postDownloadRequest(ID1); + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); + downloader0.finish(); + assertCompleted(ID1); + + // Start a second download. + postDownloadRequest(ID2); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID2); + downloader1.assertDownloadStarted(); + + postRemoveAllRequest(); + // Both downloads should be removed. + FakeDownloader downloader2 = getDownloaderAt(2); + FakeDownloader downloader3 = getDownloaderAt(3); + downloader2.assertId(ID1); + downloader3.assertId(ID2); + downloader2.assertRemoveStarted(); + downloader3.assertRemoveStarted(); + downloader2.finish(); + downloader3.finish(); + assertRemoved(ID1); + assertRemoved(ID2); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(4); + assertDownloadIndexSize(0); + assertCurrentDownloadCount(0); } @Test - public void differentDownloadRequestsMerged() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1); - FakeDownloader downloader1 = runner.getDownloader(0); - + public void downloads_withSameIdsAndDifferentStreamKeys_areMerged() throws Throwable { StreamKey streamKey1 = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0); - StreamKey streamKey2 = new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1); - - runner.postDownloadRequest(streamKey1); - downloader1.assertStarted(); - runner.postDownloadRequest(streamKey2); - - downloader1.assertCanceled(); + postDownloadRequest(ID1, streamKey1); + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); - FakeDownloader downloader2 = runner.getDownloader(1); - downloader2.assertStarted(); - assertThat(downloader2.request.streamKeys).containsExactly(streamKey1, streamKey2); - downloader2.unblock(); - - runner.getTask().assertCompleted(); - runner.assertCreatedDownloaderCount(2); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + StreamKey streamKey2 = new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1); + postDownloadRequest(ID1, streamKey2); + // The request for streamKey2 will cause the downloader for streamKey1 to be canceled and + // replaced with a new downloader for both keys. + downloader0.assertCanceled(); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID1); + downloader1.assertStreamKeys(streamKey1, streamKey2); + downloader1.assertDownloadStarted(); + downloader1.finish(); + assertCompleted(ID1); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void requestsForDifferentContent_executedInParallel() throws Throwable { - DownloadRunner runner1 = new DownloadRunner(uri1).postDownloadRequest(); - DownloadRunner runner2 = new DownloadRunner(uri2).postDownloadRequest(); - FakeDownloader downloader1 = runner1.getDownloader(0); - FakeDownloader downloader2 = runner2.getDownloader(0); - - downloader1.assertStarted(); - downloader2.assertStarted(); - downloader1.unblock(); - downloader2.unblock(); - - runner1.getTask().assertCompleted(); - runner2.getTask().assertCompleted(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + public void downloads_withDifferentIds_executeInParallel() throws Throwable { + postDownloadRequest(ID1); + postDownloadRequest(ID2); + + FakeDownloader downloader0 = getDownloaderAt(0); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader0.assertId(ID1); + downloader1.assertId(ID2); + downloader0.assertDownloadStarted(); + downloader1.assertDownloadStarted(); + downloader0.finish(); + downloader1.finish(); + assertCompleted(ID1); + assertCompleted(ID2); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(2); + assertCurrentDownloadCount(0); } @Test - public void requestsForDifferentContent_ifMaxDownloadIs1_executedSequentially() throws Throwable { - setUpDownloadManager(1); - DownloadRunner runner1 = new DownloadRunner(uri1).postDownloadRequest(); - DownloadRunner runner2 = new DownloadRunner(uri2).postDownloadRequest(); - FakeDownloader downloader1 = runner1.getDownloader(0); - FakeDownloader downloader2 = runner2.getDownloader(0); - - downloader1.assertStarted(); - downloader2.assertDoesNotStart(); - runner2.getTask().assertQueued(); - downloader1.unblock(); - downloader2.assertStarted(); - downloader2.unblock(); - - runner1.getTask().assertCompleted(); - runner2.getTask().assertCompleted(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + public void downloads_withDifferentIds_maxDownloadsIsOne_executedSequentially() throws Throwable { + setupDownloadManager(/* maxParallelDownloads= */ 1); + postDownloadRequest(ID1); + postDownloadRequest(ID2); + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); + + // The second download should be queued and the first one should be able to complete. + assertNoDownloaderAt(1); + assertQueued(ID2); + downloader0.finish(); + assertCompleted(ID1); + + // The second download can start once the first one has completed. + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID2); + downloader1.assertDownloadStarted(); + downloader1.finish(); + assertCompleted(ID2); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(2); + assertCurrentDownloadCount(0); } @Test - public void removeRequestForDifferentContent_ifMaxDownloadIs1_executedInParallel() + public void downloadAndRemove_withDifferentIds_maxDownloadsIsOne_executeInParallel() throws Throwable { - setUpDownloadManager(1); - DownloadRunner runner1 = new DownloadRunner(uri1).postDownloadRequest(); - DownloadRunner runner2 = new DownloadRunner(uri2).postDownloadRequest().postRemoveRequest(); - FakeDownloader downloader1 = runner1.getDownloader(0); - FakeDownloader downloader2 = runner2.getDownloader(0); - - downloader1.assertStarted(); - downloader2.assertStarted(); - downloader1.unblock(); - downloader2.unblock(); - - runner1.getTask().assertCompleted(); - runner2.getTask().assertRemoved(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + setupDownloadManager(/* maxParallelDownloads= */ 1); + + // Complete a download so that we can remove it. + postDownloadRequest(ID1); + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); + downloader0.finish(); + + // Request removal of the first download, and downloading of a second download. + postRemoveRequest(ID1); + postDownloadRequest(ID2); + + // The removal and download should proceed in parallel. + FakeDownloader downloader1 = getDownloaderAt(1); + FakeDownloader downloader2 = getDownloaderAt(2); + downloader1.assertId(ID1); + downloader2.assertId(ID2); + downloader1.assertRemoveStarted(); + downloader2.assertDownloadStarted(); + downloader1.finish(); + downloader2.finish(); + assertRemoved(ID1); + assertCompleted(ID2); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(3); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void downloadRequestFollowingRemove_ifMaxDownloadIs1_isNotStarted() throws Throwable { - setUpDownloadManager(1); - DownloadRunner runner1 = new DownloadRunner(uri1).postDownloadRequest(); - DownloadRunner runner2 = new DownloadRunner(uri2).postDownloadRequest().postRemoveRequest(); - runner2.postDownloadRequest(); - FakeDownloader downloader1 = runner1.getDownloader(0); - FakeDownloader downloader2 = runner2.getDownloader(0); - FakeDownloader downloader3 = runner2.getDownloader(1); - - downloader1.assertStarted(); - downloader2.assertStarted(); - downloader2.unblock(); - downloader3.assertDoesNotStart(); - downloader1.unblock(); - downloader3.assertStarted(); - downloader3.unblock(); - - runner1.getTask().assertCompleted(); - runner2.getTask().assertCompleted(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + public void downloadAfterRemove_maxDownloadIsOne_isNotStarted() throws Throwable { + setupDownloadManager(/* maxParallelDownloads= */ 1); + postDownloadRequest(ID1); + postDownloadRequest(ID2); + postRemoveRequest(ID2); + postDownloadRequest(ID2); + + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); + + // The second download shouldn't have been started, so the second downloader is for removal. + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID2); + downloader1.assertRemoveStarted(); + downloader1.finish(); + // A downloader to re-download the second download should not be started. + assertNoDownloaderAt(2); + // The first download should be able to complete. + downloader0.finish(); + assertCompleted(ID1); + + // Now the first download has completed, the second download should start. + FakeDownloader downloader2 = getDownloaderAt(2); + downloader2.assertId(ID2); + downloader2.assertDownloadStarted(); + downloader2.finish(); + assertCompleted(ID2); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(3); + assertDownloadIndexSize(2); + assertCurrentDownloadCount(0); } @Test - public void getCurrentDownloads_returnsCurrentDownloads() { - TaskWrapper task1 = new DownloadRunner(uri1).postDownloadRequest().getTask(); - TaskWrapper task2 = new DownloadRunner(uri2).postDownloadRequest().getTask(); - TaskWrapper task3 = - new DownloadRunner(uri3).postDownloadRequest().postRemoveRequest().getTask(); + public void pauseAndResume_pausesAndResumesDownload() throws Throwable { + postDownloadRequest(ID1); + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); - task3.assertRemoving(); - List downloads = downloadManager.getCurrentDownloads(); + postPauseDownloads(); + downloader0.assertCanceled(); + assertQueued(ID1); - assertThat(downloads).hasSize(3); - String[] taskIds = {task1.taskId, task2.taskId, task3.taskId}; - String[] downloadIds = { - downloads.get(0).request.id, downloads.get(1).request.id, downloads.get(2).request.id - }; - assertThat(downloadIds).isEqualTo(taskIds); + postResumeDownloads(); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID1); + downloader1.assertDownloadStarted(); + downloader1.finish(); + assertCompleted(ID1); + + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void pauseAndResume() throws Throwable { - DownloadRunner runner1 = new DownloadRunner(uri1); - DownloadRunner runner2 = new DownloadRunner(uri2); - DownloadRunner runner3 = new DownloadRunner(uri3); + public void pause_doesNotCancelRemove() throws Throwable { + postDownloadRequest(ID1); + postRemoveRequest(ID1); + FakeDownloader downloader = getDownloaderAt(1); + downloader.assertId(ID1); + downloader.assertRemoveStarted(); - runner1.postDownloadRequest().getTask().assertDownloading(); - runner2.postDownloadRequest().postRemoveRequest().getTask().assertRemoving(); - runner2.postDownloadRequest(); + postPauseDownloads(); + downloader.finish(); + assertRemoved(ID1); - runOnMainThread(() -> downloadManager.pauseDownloads()); - - runner1.getTask().assertQueued(); - - // remove requests aren't stopped. - runner2.getDownloader(1).unblock().assertReleased(); - runner2.getTask().assertQueued(); - // Although remove2 is finished, download2 doesn't start. - runner2.getDownloader(2).assertDoesNotStart(); - - // When a new remove request is added, it cancels stopped download requests with the same media. - runner1.postRemoveRequest(); - runner1.getDownloader(1).assertStarted().unblock(); - runner1.getTask().assertRemoved(); - - // New download requests can be added but they don't start. - runner3.postDownloadRequest().getDownloader(0).assertDoesNotStart(); - - runOnMainThread(() -> downloadManager.resumeDownloads()); - - runner2.getDownloader(2).assertStarted().unblock(); - runner3.getDownloader(0).assertStarted().unblock(); - - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(0); + assertCurrentDownloadCount(0); } @Test - public void setAndClearSingleDownloadStopReason() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1).postDownloadRequest(); - TaskWrapper task = runner.getTask(); - - task.assertDownloading(); + public void setAndClearStopReason_stopsAndRestartsDownload() throws Throwable { + postDownloadRequest(ID1); + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); - runOnMainThread(() -> downloadManager.setStopReason(task.taskId, APP_STOP_REASON)); + postSetStopReason(ID1, APP_STOP_REASON); + downloader0.assertCanceled(); + assertStopped(ID1); - task.assertStopped(); + postSetStopReason(ID1, Download.STOP_REASON_NONE); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID1); + downloader1.assertDownloadStarted(); + downloader1.finish(); - runOnMainThread(() -> downloadManager.setStopReason(task.taskId, Download.STOP_REASON_NONE)); - - runner.getDownloader(1).assertStarted().unblock(); - - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(1); + assertCurrentDownloadCount(0); } @Test - public void setSingleDownloadStopReasonThenRemove_removesDownload() throws Throwable { - DownloadRunner runner = new DownloadRunner(uri1).postDownloadRequest(); - TaskWrapper task = runner.getTask(); - - task.assertDownloading(); + public void setStopReason_doesNotStopOtherDownload() throws Throwable { + postDownloadRequest(ID1); + postDownloadRequest(ID2); - runOnMainThread(() -> downloadManager.setStopReason(task.taskId, APP_STOP_REASON)); + FakeDownloader downloader0 = getDownloaderAt(0); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader0.assertId(ID1); + downloader1.assertId(ID2); + downloader0.assertDownloadStarted(); + downloader1.assertDownloadStarted(); - task.assertStopped(); + postSetStopReason(ID1, APP_STOP_REASON); + downloader0.assertCanceled(); + assertStopped(ID1); - runner.postRemoveRequest(); - runner.getDownloader(1).assertStarted().unblock(); - task.assertRemoved(); + // The second download should still complete. + downloader1.finish(); + assertCompleted(ID2); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(2); + assertCurrentDownloadCount(1); } @Test - public void setSingleDownloadStopReason_doesNotAffectOtherDownloads() throws Throwable { - DownloadRunner runner1 = new DownloadRunner(uri1); - DownloadRunner runner2 = new DownloadRunner(uri2); - DownloadRunner runner3 = new DownloadRunner(uri3); + public void remove_removesStoppedDownload() throws Throwable { + postDownloadRequest(ID1); + FakeDownloader downloader0 = getDownloaderAt(0); + downloader0.assertId(ID1); + downloader0.assertDownloadStarted(); - runner1.postDownloadRequest().getTask().assertDownloading(); - runner2.postDownloadRequest().postRemoveRequest().getTask().assertRemoving(); + postSetStopReason(ID1, APP_STOP_REASON); + downloader0.assertCanceled(); + assertStopped(ID1); - runOnMainThread(() -> downloadManager.setStopReason(runner1.getTask().taskId, APP_STOP_REASON)); + postRemoveRequest(ID1); + FakeDownloader downloader1 = getDownloaderAt(1); + downloader1.assertId(ID1); + downloader1.assertRemoveStarted(); + downloader1.finish(); + assertRemoved(ID1); - runner1.getTask().assertStopped(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); + assertDownloaderCount(2); + assertDownloadIndexSize(0); + assertCurrentDownloadCount(0); + } - // Other downloads aren't affected. - runner2.getDownloader(1).unblock().assertReleased(); + @Test + public void getCurrentDownloads_returnsCurrentDownloads() throws Throwable { + setupDownloadManager(/* maxParallelDownloads= */ 1); + postDownloadRequest(ID1); + postDownloadRequest(ID2); + postDownloadRequest(ID3); + postRemoveRequest(ID3); - // New download requests can be added and they start. - runner3.postDownloadRequest().getDownloader(0).assertStarted().unblock(); + assertRemoving(ID3); // Blocks until the downloads will be visible. - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + List downloads = postGetCurrentDownloads(); + assertThat(downloads).hasSize(3); + Download download0 = downloads.get(0); + assertThat(download0.request.id).isEqualTo(ID1); + assertThat(download0.state).isEqualTo(Download.STATE_DOWNLOADING); + Download download1 = downloads.get(1); + assertThat(download1.request.id).isEqualTo(ID2); + assertThat(download1.state).isEqualTo(Download.STATE_QUEUED); + Download download2 = downloads.get(2); + assertThat(download2.request.id).isEqualTo(ID3); + assertThat(download2.state).isEqualTo(Download.STATE_REMOVING); } @Test public void mergeRequest_removing_becomesRestarting() { - DownloadRequest downloadRequest = createDownloadRequest(); + DownloadRequest downloadRequest = createDownloadRequest(ID1); DownloadBuilder downloadBuilder = new DownloadBuilder(downloadRequest).setState(Download.STATE_REMOVING); Download download = downloadBuilder.build(); @@ -498,7 +599,7 @@ public void mergeRequest_removing_becomesRestarting() { @Test public void mergeRequest_failed_becomesQueued() { - DownloadRequest downloadRequest = createDownloadRequest(); + DownloadRequest downloadRequest = createDownloadRequest(ID1); DownloadBuilder downloadBuilder = new DownloadBuilder(downloadRequest) .setState(Download.STATE_FAILED) @@ -519,7 +620,7 @@ public void mergeRequest_failed_becomesQueued() { @Test public void mergeRequest_stopped_staysStopped() { - DownloadRequest downloadRequest = createDownloadRequest(); + DownloadRequest downloadRequest = createDownloadRequest(ID1); DownloadBuilder downloadBuilder = new DownloadBuilder(downloadRequest) .setState(Download.STATE_STOPPED) @@ -534,7 +635,7 @@ public void mergeRequest_stopped_staysStopped() { @Test public void mergeRequest_completedWithStopReason_becomesStopped() { - DownloadRequest downloadRequest = createDownloadRequest(); + DownloadRequest downloadRequest = createDownloadRequest(ID1); DownloadBuilder downloadBuilder = new DownloadBuilder(downloadRequest) .setState(Download.STATE_COMPLETED) @@ -549,7 +650,7 @@ public void mergeRequest_completedWithStopReason_becomesStopped() { assertEqualIgnoringUpdateTime(mergedDownload, expectedDownload); } - private void setUpDownloadManager(final int maxParallelDownloads) throws Exception { + private void setupDownloadManager(int maxParallelDownloads) throws Exception { if (downloadManager != null) { releaseDownloadManager(); } @@ -558,15 +659,16 @@ private void setUpDownloadManager(final int maxParallelDownloads) throws Excepti () -> { downloadManager = new DownloadManager( - ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory); + ApplicationProvider.getApplicationContext(), + new DefaultDownloadIndex(TestUtil.getInMemoryDatabaseProvider()), + new FakeDownloaderFactory()); downloadManager.setMaxParallelDownloads(maxParallelDownloads); downloadManager.setMinRetryCount(MIN_RETRY_COUNT); downloadManager.setRequirements(new Requirements(0)); downloadManager.resumeDownloads(); - downloadManagerListener = - new TestDownloadManagerListener(downloadManager, dummyMainThread); + downloadManagerListener = new TestDownloadManagerListener(downloadManager); }); - downloadManagerListener.waitUntilInitialized(); + downloadManagerListener.blockUntilInitialized(); } catch (Throwable throwable) { throw new Exception(throwable); } @@ -580,292 +682,261 @@ private void releaseDownloadManager() throws Exception { } } - private void runOnMainThread(TestRunnable r) { - dummyMainThread.runTestOnMainThread(r); + private void postRemoveRequest(String id) { + runOnMainThread(() -> downloadManager.removeDownload(id)); } - private static void assertEqualIgnoringUpdateTime(Download download, Download that) { - assertThat(download.request).isEqualTo(that.request); - assertThat(download.state).isEqualTo(that.state); - assertThat(download.startTimeMs).isEqualTo(that.startTimeMs); - assertThat(download.contentLength).isEqualTo(that.contentLength); - assertThat(download.failureReason).isEqualTo(that.failureReason); - assertThat(download.stopReason).isEqualTo(that.stopReason); - assertThat(download.getPercentDownloaded()).isEqualTo(that.getPercentDownloaded()); - assertThat(download.getBytesDownloaded()).isEqualTo(that.getBytesDownloaded()); + private void postRemoveAllRequest() { + runOnMainThread(() -> downloadManager.removeAllDownloads()); } - private static DownloadRequest createDownloadRequest() { - return new DownloadRequest( - "id", - DownloadRequest.TYPE_DASH, - Uri.parse("https://www.test.com/download"), - Collections.emptyList(), - /* customCacheKey= */ null, - /* data= */ null); - } - - private final class DownloadRunner { - - private final Uri uri; - private final String id; - private final ArrayList downloaders; - private int createdDownloaderCount = 0; - private FakeDownloader downloader; - private final TaskWrapper taskWrapper; - - private DownloadRunner(Uri uri) { - this.uri = uri; - id = uri.toString(); - downloaders = new ArrayList<>(); - downloader = addDownloader(); - downloaderFactory.registerDownloadRunner(this); - taskWrapper = new TaskWrapper(id); - } - - private DownloadRunner postRemoveRequest() { - runOnMainThread(() -> downloadManager.removeDownload(id)); - return this; - } + private void postPauseDownloads() { + runOnMainThread(() -> downloadManager.pauseDownloads()); + } - private DownloadRunner postRemoveAllRequest() { - runOnMainThread(() -> downloadManager.removeAllDownloads()); - return this; - } + private void postResumeDownloads() { + runOnMainThread(() -> downloadManager.resumeDownloads()); + } - private DownloadRunner postDownloadRequest(StreamKey... keys) { - DownloadRequest downloadRequest = - new DownloadRequest( - id, - DownloadRequest.TYPE_PROGRESSIVE, - uri, - Arrays.asList(keys), - /* customCacheKey= */ null, - /* data= */ null); - runOnMainThread(() -> downloadManager.addDownload(downloadRequest)); - return this; - } + private void postSetStopReason(String id, int reason) { + runOnMainThread(() -> downloadManager.setStopReason(id, reason)); + } - private synchronized FakeDownloader addDownloader() { - FakeDownloader fakeDownloader = new FakeDownloader(); - downloaders.add(fakeDownloader); - return fakeDownloader; - } + private void postDownloadRequest(String id, StreamKey... keys) { + runOnMainThread(() -> downloadManager.addDownload(createDownloadRequest(id, keys))); + } - private synchronized FakeDownloader getDownloader(int index) { - while (downloaders.size() <= index) { - addDownloader(); - } - return downloaders.get(index); - } + private List postGetCurrentDownloads() { + AtomicReference> currentDownloadsReference = new AtomicReference<>(); + runOnMainThread(() -> currentDownloadsReference.set(downloadManager.getCurrentDownloads())); + return currentDownloadsReference.get(); + } - private synchronized Downloader createDownloader(DownloadRequest request) { - downloader = getDownloader(createdDownloaderCount++); - downloader.request = request; - return downloader; - } + private DownloadIndex postGetDownloadIndex() { + AtomicReference downloadIndexReference = new AtomicReference<>(); + runOnMainThread(() -> downloadIndexReference.set(downloadManager.getDownloadIndex())); + return downloadIndexReference.get(); + } - private TaskWrapper getTask() { - return taskWrapper; - } + private void runOnMainThread(TestRunnable r) { + testThread.runTestOnMainThread(r); + } - private void assertCreatedDownloaderCount(int count) { - assertThat(createdDownloaderCount).isEqualTo(count); - } + private FakeDownloader getDownloaderAt(int index) throws InterruptedException { + return Assertions.checkNotNull(getDownloaderInternal(index, TIMEOUT_MS)); } - private final class TaskWrapper { - private final String taskId; + private void assertNoDownloaderAt(int index) throws InterruptedException { + // We use a timeout shorter than TIMEOUT_MS because timing out is expected in this case. + assertThat(getDownloaderInternal(index, /* timeoutMs= */ 1_000)).isNull(); + } - private TaskWrapper(String taskId) { - this.taskId = taskId; - } + private void assertDownloading(String id) { + downloadManagerListener.assertState(id, Download.STATE_DOWNLOADING); + } - private TaskWrapper assertDownloading() { - return assertState(Download.STATE_DOWNLOADING); - } + private void assertCompleted(String id) { + downloadManagerListener.assertState(id, Download.STATE_COMPLETED); + } - private TaskWrapper assertCompleted() { - return assertState(Download.STATE_COMPLETED); - } + private void assertRemoving(String id) { + downloadManagerListener.assertState(id, Download.STATE_REMOVING); + } - private TaskWrapper assertRemoving() { - return assertState(Download.STATE_REMOVING); - } + private void assertFailed(String id) { + downloadManagerListener.assertState(id, Download.STATE_FAILED); + } - private TaskWrapper assertFailed() { - return assertState(Download.STATE_FAILED); - } + private void assertQueued(String id) { + downloadManagerListener.assertState(id, Download.STATE_QUEUED); + } - private TaskWrapper assertQueued() { - return assertState(Download.STATE_QUEUED); - } + private void assertStopped(String id) { + downloadManagerListener.assertState(id, Download.STATE_STOPPED); + } - private TaskWrapper assertStopped() { - return assertState(Download.STATE_STOPPED); - } + private void assertRemoved(String id) { + downloadManagerListener.assertRemoved(id); + } - private TaskWrapper assertState(@State int expectedState) { - downloadManagerListener.assertState(taskId, expectedState, ASSERT_TRUE_TIMEOUT); - return this; + private void assertDownloaderCount(int expectedCount) { + synchronized (downloaders) { + assertThat(downloaders).hasSize(expectedCount); } + } - private TaskWrapper assertRemoved() { - downloadManagerListener.assertRemoved(taskId, ASSERT_TRUE_TIMEOUT); - return this; - } + private void assertCurrentDownloadCount(int expectedCount) { + assertThat(postGetCurrentDownloads()).hasSize(expectedCount); + } - @Override - public boolean equals(@Nullable Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - return taskId.equals(((TaskWrapper) o).taskId); - } + private void assertDownloadIndexSize(int expectedSize) throws IOException { + assertThat(postGetDownloadIndex().getDownloads().getCount()).isEqualTo(expectedSize); + } - @Override - public int hashCode() { - return taskId.hashCode(); - } + private static void assertEqualIgnoringUpdateTime(Download download, Download that) { + assertThat(download.request).isEqualTo(that.request); + assertThat(download.state).isEqualTo(that.state); + assertThat(download.startTimeMs).isEqualTo(that.startTimeMs); + assertThat(download.contentLength).isEqualTo(that.contentLength); + assertThat(download.failureReason).isEqualTo(that.failureReason); + assertThat(download.stopReason).isEqualTo(that.stopReason); + assertThat(download.getPercentDownloaded()).isEqualTo(that.getPercentDownloaded()); + assertThat(download.getBytesDownloaded()).isEqualTo(that.getBytesDownloaded()); } - private static final class FakeDownloaderFactory implements DownloaderFactory { + private static DownloadRequest createDownloadRequest(String id, StreamKey... keys) { + return new DownloadRequest.Builder(id, Uri.parse("http://abc.com/ " + id)) + .setStreamKeys(asList(keys)) + .build(); + } - private final HashMap downloaders; + // Internal methods. - public FakeDownloaderFactory() { - downloaders = new HashMap<>(); + @Nullable + private FakeDownloader getDownloaderInternal(int index, long timeoutMs) + throws InterruptedException { + long nowMs = System.currentTimeMillis(); + long endMs = nowMs + timeoutMs; + synchronized (downloaders) { + while (downloaders.size() <= index && nowMs < endMs) { + downloaders.wait(endMs - nowMs); + nowMs = System.currentTimeMillis(); + } + return downloaders.size() <= index ? null : downloaders.get(index); } + } - public void registerDownloadRunner(DownloadRunner downloadRunner) { - assertThat(downloaders.put(downloadRunner.uri, downloadRunner)).isNull(); - } + private final class FakeDownloaderFactory implements DownloaderFactory { @Override public Downloader createDownloader(DownloadRequest request) { - return downloaders.get(request.uri).createDownloader(request); + FakeDownloader fakeDownloader = new FakeDownloader(request); + synchronized (downloaders) { + downloaders.add(fakeDownloader); + downloaders.notifyAll(); + } + return fakeDownloader; } } private static final class FakeDownloader implements Downloader { - private final com.google.android.exoplayer2.util.ConditionVariable blocker; + private final DownloadRequest request; + private final ConditionVariable downloadStarted; + private final ConditionVariable removeStarted; + private final ConditionVariable finished; + private final ConditionVariable blocker; + private final AtomicInteger startCount; + private final AtomicInteger bytesDownloaded; - private DownloadRequest request; - private CountDownLatch started; - private volatile boolean interrupted; - private volatile boolean cancelled; + private volatile boolean canceled; private volatile boolean enableDownloadIOException; - private volatile int startCount; - private volatile int bytesDownloaded; - - private FakeDownloader() { - this.started = new CountDownLatch(1); - this.blocker = new com.google.android.exoplayer2.util.ConditionVariable(); - } - @SuppressWarnings({"NonAtomicOperationOnVolatileField", "NonAtomicVolatileUpdate"}) - @Override - public void download(ProgressListener listener) throws InterruptedException, IOException { - // It's ok to update this directly as no other thread will update it. - startCount++; - started.countDown(); - block(); - if (bytesDownloaded > 0) { - listener.onProgress(C.LENGTH_UNSET, bytesDownloaded, C.PERCENTAGE_UNSET); - } - if (enableDownloadIOException) { - enableDownloadIOException = false; - throw new IOException(); - } + private FakeDownloader(DownloadRequest request) { + this.request = request; + downloadStarted = TestUtil.createRobolectricConditionVariable(); + removeStarted = TestUtil.createRobolectricConditionVariable(); + finished = TestUtil.createRobolectricConditionVariable(); + blocker = TestUtil.createRobolectricConditionVariable(); + startCount = new AtomicInteger(); + bytesDownloaded = new AtomicInteger(); } @Override public void cancel() { - cancelled = true; + canceled = true; + blocker.open(); } - @SuppressWarnings({"NonAtomicOperationOnVolatileField", "NonAtomicVolatileUpdate"}) @Override - public void remove() throws InterruptedException { - // It's ok to update this directly as no other thread will update it. - startCount++; - started.countDown(); - block(); + public void download(ProgressListener listener) throws IOException { + startCount.incrementAndGet(); + downloadStarted.open(); + try { + block(); + if (canceled) { + return; + } + int bytesDownloaded = this.bytesDownloaded.get(); + if (listener != null && bytesDownloaded > 0) { + listener.onProgress(C.LENGTH_UNSET, bytesDownloaded, C.PERCENTAGE_UNSET); + } + if (enableDownloadIOException) { + enableDownloadIOException = false; + throw new IOException(); + } + } finally { + finished.open(); + } } - private void block() throws InterruptedException { + @Override + public void remove() { + startCount.incrementAndGet(); + removeStarted.open(); try { - blocker.block(); - } catch (InterruptedException e) { - interrupted = true; - throw e; + block(); } finally { - blocker.close(); + finished.open(); } } - private FakeDownloader assertStarted() throws InterruptedException { - return assertStarted(ASSERT_TRUE_TIMEOUT); + /** Finishes the {@link #download} or {@link #remove} without an error. */ + public void finish() throws InterruptedException { + blocker.open(); + blockUntilFinished(); } - private FakeDownloader assertStarted(int timeout) throws InterruptedException { - assertThat(started.await(timeout, TimeUnit.MILLISECONDS)).isTrue(); - started = new CountDownLatch(1); - return this; + /** Fails {@link #download} or {@link #remove} with an error. */ + public void fail() throws InterruptedException { + enableDownloadIOException = true; + blocker.open(); + blockUntilFinished(); } - private FakeDownloader assertStartCount(int count) { - assertThat(startCount).isEqualTo(count); - return this; + /** Increments the number of bytes that the fake downloader has downloaded. */ + public void incrementBytesDownloaded() { + bytesDownloaded.incrementAndGet(); } - private FakeDownloader assertReleased() throws InterruptedException { - int count = 0; - while (started.await(ASSERT_TRUE_TIMEOUT, TimeUnit.MILLISECONDS)) { - if (count++ >= MAX_STARTS_BEFORE_RELEASED) { - fail(); - } - started = new CountDownLatch(1); - } - return this; + public void assertId(String id) { + assertThat(request.id).isEqualTo(id); } - private FakeDownloader assertCanceled() throws InterruptedException { - assertReleased(); - assertThat(interrupted).isTrue(); - assertThat(cancelled).isTrue(); - return this; + public void assertStreamKeys(StreamKey... streamKeys) { + assertThat(request.streamKeys).containsExactlyElementsIn(streamKeys); } - private FakeDownloader assertNotCanceled() throws InterruptedException { - assertReleased(); - assertThat(interrupted).isFalse(); - assertThat(cancelled).isFalse(); - return this; + public void assertDownloadStarted() throws InterruptedException { + assertThat(downloadStarted.block(TIMEOUT_MS)).isTrue(); + downloadStarted.close(); } - private FakeDownloader unblock() { - blocker.open(); - return this; + public void assertRemoveStarted() throws InterruptedException { + assertThat(removeStarted.block(TIMEOUT_MS)).isTrue(); + removeStarted.close(); } - private FakeDownloader fail() { - enableDownloadIOException = true; - return unblock(); + public void assertCanceled() throws InterruptedException { + blockUntilFinished(); + assertThat(canceled).isTrue(); } - private void assertDoesNotStart() throws InterruptedException { - Thread.sleep(ASSERT_FALSE_TIME); - assertThat(started.getCount()).isEqualTo(1); + // Internal methods. + + private void block() { + try { + blocker.block(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); // Never happens. + } finally { + blocker.close(); + } } - @SuppressWarnings({"NonAtomicOperationOnVolatileField", "NonAtomicVolatileUpdate"}) - private void incrementBytesDownloaded() { - bytesDownloaded++; + private void blockUntilFinished() throws InterruptedException { + assertThat(finished.block(TIMEOUT_MS)).isTrue(); + finished.close(); } } } 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 c5b00b02d6f..c4a101e946f 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 void setUp() { @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 void mergeRequests_withOverlappingStreamKeys() { @Test public void mergeRequests_withDifferentFields() { - byte[] data1 = new byte[] {0, 1, 2}; - byte[] data2 = new byte[] {3, 4, 5}; + byte[] keySetId1 = new byte[] {0, 1, 2}; + byte[] keySetId2 = new byte[] {3, 4, 5}; + byte[] data1 = new byte[] {6, 7, 8}; + byte[] data2 = new byte[] {9, 10, 11}; + DownloadRequest request1 = - new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, - uri1, - /* streamKeys= */ Collections.emptyList(), - "key1", - /* data= */ data1); + new DownloadRequest.Builder(/* id= */ "id1", uri1) + .setKeySetId(keySetId1) + .setCustomCacheKey("key1") + .setData(data1) + .build(); DownloadRequest request2 = - new DownloadRequest( - "id1", - TYPE_PROGRESSIVE, - uri2, - /* streamKeys= */ Collections.emptyList(), - "key2", - /* data= */ data2); - - // uri, customCacheKey and data should be from the request being merged. + 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 void parcelable() { 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 @@ private static void assertNotEqual(DownloadRequest request1, DownloadRequest req 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 ae0c431bd37..8fce3b25acd 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 @@ 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 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 void noClipping() throws IOException { 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 void clippingUnseekableWindowThrows() throws IOException { 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 void clippingUnseekableWindowWithUnknownDurationThrows() throws IOExcepti /* 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 void clippingStart() throws IOException { 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 void clippingEnd() throws IOException { 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 void clippingStartAndEndInitial() throws IOException { // 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 void clippingToEndOfSourceWithDurationSetsDuration() throws IOException { /* 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 void clippingToEndOfSourceWithUnsetDurationDoesNotSetDuration() throws IO /* 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 void clippingStartAndEnd() throws IOException { 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 void clippingFromDefaultPosition() throws IOException { /* 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 void allowDynamicUpdatesWithOverlappingLiveWindow() throws IOException { /* 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 void allowDynamicUpdatesWithOverlappingLiveWindow() throws IOException { /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -309,7 +325,7 @@ public void allowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOException /* 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 void allowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOException /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -360,7 +376,7 @@ public void disallowDynamicUpdatesWithOverlappingLiveWindow() throws IOException /* 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 void disallowDynamicUpdatesWithOverlappingLiveWindow() throws IOException /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -412,7 +428,7 @@ public void disallowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOExcept /* 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 void disallowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOExcept /* isDynamic= */ true, /* isLive= */ true, /* manifest= */ null, - /* tag= */ null); + MediaItem.fromUri(Uri.EMPTY)); Timeline[] clippedTimelines = getClippedTimelines( @@ -540,7 +556,9 @@ private static MediaLoadData getClippingMediaSourceMediaLoadData( 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 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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 @@ protected FakeMediaPeriod createFakeMediaPeriod( 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 cf2e3e879d7..7ba0cc02e5a 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.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 void illegalArguments() { 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 void customCallbackBeforePreparationAddSingle() throws Exception { 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 void customCallbackBeforePreparationAddMultiple() throws Exception { 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 void customCallbackBeforePreparationAddSingleWithIndex() throws Exception 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 void customCallbackBeforePreparationAddMultipleWithIndex() throws Excepti 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 void customCallbackBeforePreparationRemove() throws Exception { 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 void customCallbackAfterPreparationMove() throws Exception { 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 void customCallbackIsCalledAfterRelease() throws Exception { 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 void duplicateNestedMediaSources() throws IOException, InterruptedExcepti @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 void clear() throws Exception { 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 void setShuffleOrderAfterPreparation() throws Exception { 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 void run() { } 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 3c9d5182f87..d02f04d0977 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java @@ -16,12 +16,16 @@ package com.google.android.exoplayer2.source; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import android.content.Context; 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.MediaItem; +import com.google.android.exoplayer2.source.ads.AdsLoader; +import com.google.android.exoplayer2.source.ads.AdsMediaSource; import com.google.android.exoplayer2.util.MimeTypes; import java.util.Arrays; import java.util.Collections; @@ -36,11 +40,22 @@ 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()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA).build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -48,11 +63,12 @@ public void createMediaSource_withoutMimeType_progressiveSource() { } @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()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA).setTag(tag).build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setTag(tag).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -62,8 +78,8 @@ public void createMediaSource_withTag_tagInSource() { @Test public void createMediaSource_withPath_progressiveSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + "/file.mp3").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mp3").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -73,8 +89,8 @@ public void createMediaSource_withPath_progressiveSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA).build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).build(); MediaSource mediaSource = defaultMediaSourceFactory @@ -89,14 +105,13 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void createMediaSource_withSubtitle_isMergingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); List subtitles = Arrays.asList( new MediaItem.Subtitle(Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "en"), new MediaItem.Subtitle( Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "de", C.SELECTION_FLAG_DEFAULT)); - MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_MEDIA).setSubtitles(subtitles).build(); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setSubtitles(subtitles).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -104,14 +119,15 @@ public void createMediaSource_withSubtitle_isMergingMediaSource() { } @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() .setTag(tag) - .setSourceUri(URI_MEDIA) + .setUri(URI_MEDIA) .setSubtitles( Collections.singletonList( new MediaItem.Subtitle(Uri.parse(URI_TEXT), MimeTypes.APPLICATION_TTML, "en"))) @@ -125,9 +141,9 @@ public void createMediaSource_withSubtitle_hasTag() { @Test public void createMediaSource_withStartPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_MEDIA).setClipStartPositionMs(1000L).build(); + new MediaItem.Builder().setUri(URI_MEDIA).setClipStartPositionMs(1000L).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -137,9 +153,9 @@ public void createMediaSource_withStartPosition_isClippingMediaSource() { @Test public void createMediaSource_withEndPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = - new MediaItem.Builder().setSourceUri(URI_MEDIA).setClipEndPositionMs(1000L).build(); + new MediaItem.Builder().setUri(URI_MEDIA).setClipEndPositionMs(1000L).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -149,12 +165,9 @@ public void createMediaSource_withEndPosition_isClippingMediaSource() { @Test public void createMediaSource_relativeToDefaultPosition_isClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = - new MediaItem.Builder() - .setSourceUri(URI_MEDIA) - .setClipRelativeToDefaultPosition(true) - .build(); + new MediaItem.Builder().setUri(URI_MEDIA).setClipRelativeToDefaultPosition(true).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -164,10 +177,10 @@ public void createMediaSource_relativeToDefaultPosition_isClippingMediaSource() @Test public void createMediaSource_defaultToEnd_isNotClippingMediaSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_MEDIA) + .setUri(URI_MEDIA) .setClipEndPositionMs(C.TIME_END_OF_SOURCE) .build(); @@ -179,9 +192,47 @@ public void createMediaSource_defaultToEnd_isNotClippingMediaSource() { @Test public void getSupportedTypes_coreModule_onlyOther() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER); } + + @Test + public void createMediaSource_withAdTagUri_callsAdsLoader() { + Uri adTagUri = Uri.parse(URI_MEDIA); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(adTagUri).build(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) + .setAdsLoaderProvider(ignoredAdTagUri -> mock(AdsLoader.class)) + .setAdViewProvider(mock(AdsLoader.AdViewProvider.class)); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isInstanceOf(AdsMediaSource.class); + } + + @Test + public void createMediaSource_withAdTagUri_adProvidersNotSet_playsWithoutAdNoException() { + MediaItem mediaItem = + new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class); + } + + @Test + public void createMediaSource_withAdTagUriProvidersNull_playsWithoutAdNoException() { + Context applicationContext = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + new MediaItem.Builder().setUri(URI_MEDIA).setAdTagUri(Uri.parse(URI_MEDIA)).build(); + + MediaSource mediaSource = + new DefaultMediaSourceFactory(applicationContext).createMediaSource(mediaItem); + + assertThat(mediaSource).isNotInstanceOf(AdsMediaSource.class); + } } 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 f938ffe3700..9c883a149a4 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 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 00000000000..45384f05ec5 --- /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 d201782b532..e28af160c38 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.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 void getTrackGroups_returnsAllChildTrackGroups() throws Exception { 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 void selectTracks_createsSampleStreamsFromChildPeriods() throws Exception 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 void selectTracks_createsSampleStreamsFromChildPeriods() throws Exception 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 @@ private MergingMediaPeriod prepareMergingPeriod(MergingPeriodDefinition... defin 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 @@ private static final class FakeMediaPeriodWithSelectTracksPosition extends FakeM 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 long selectTracks( 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 4d91b7a34cd..c66a5cff741 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 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 00000000000..ecdb43f1509 --- /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 9a4524819ee..241834fab5e 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,11 +21,13 @@ 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; import static org.mockito.Mockito.when; +import android.os.Looper; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -34,13 +36,17 @@ 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.MediaSourceEventDispatcher; +import com.google.android.exoplayer2.util.Assertions; +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; @@ -49,7 +55,6 @@ 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}. */ @@ -64,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); /* @@ -119,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; @@ -133,13 +140,15 @@ 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(); - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher); + mockDrmSessionManager = new MockDrmSessionManager(mockDrmSession); + eventDispatcher = new DrmSessionEventListener.EventDispatcher(); + sampleQueue = + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager, + eventDispatcher); formatHolder = new FormatHolder(); inputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); } @@ -172,6 +181,7 @@ public void capacityIncreases() { assertReadSample( /* timeUs= */ i * 1000, /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, /* sampleData= */ new byte[1], /* offset= */ 0, @@ -218,9 +228,23 @@ public void multipleFormatsDeduplicated() { 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); @@ -229,7 +253,14 @@ public void multipleFormatsDeduplicated() { 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 @@ -252,7 +283,14 @@ public void readSingleSamples() { // 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(); @@ -269,7 +307,14 @@ public void readSingleSamples() { // 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(); @@ -283,7 +328,14 @@ public void readSingleSamples() { // 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(); @@ -346,7 +398,7 @@ public void isReadyWithUpstreamFormatOnlyReturnsTrue() { @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(); @@ -356,7 +408,12 @@ public void isReadyReturnsTrueForValidDrmSession() { public void isReadyReturnsTrueForClearSampleAndPlayClearSamplesWithoutKeysIsTrue() { when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true); // We recreate the queue to ensure the mock DRM session manager flags are taken into account. - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher); + sampleQueue = + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager, + eventDispatcher); writeTestDataWithEncryptedSections(); assertThat(sampleQueue.isReady(/* loadingFinished= */ false)).isTrue(); } @@ -366,7 +423,7 @@ public void readEncryptedSectionsWaitsForKeys() { 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); @@ -381,11 +438,7 @@ public void readEncryptedSectionsPopulatesDrmSession() { 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); @@ -394,21 +447,13 @@ public void readEncryptedSectionsPopulatesDrmSession() { 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); } @@ -418,18 +463,12 @@ public void allowPlaceholderSessionPopulatesDrmSession() { 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); @@ -438,21 +477,13 @@ public void allowPlaceholderSessionPopulatesDrmSession() { 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); @@ -463,15 +494,13 @@ public void trailingCryptoInfoInitializationVectorBytesZeroed() { 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). }, @@ -482,11 +511,7 @@ public void trailingCryptoInfoInitializationVectorBytesZeroed() { 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 @@ -496,11 +521,7 @@ public void trailingCryptoInfoInitializationVectorBytesZeroed() { 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 @@ -514,7 +535,7 @@ public void readWithErrorSessionReadsNothingAndThrows() throws IOException { 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); @@ -534,11 +555,16 @@ public void readWithErrorSessionReadsNothingAndThrows() throws IOException { public void allowPlayClearSamplesWithoutKeysReadsClearSamples() { when(mockDrmSession.playClearSamplesWithoutKeys()).thenReturn(true); // We recreate the queue to ensure the mock DRM session manager flags are taken into account. - sampleQueue = new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher); + sampleQueue = + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager, + eventDispatcher); 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); } @@ -560,9 +586,10 @@ public void seekAfterDiscard() { } @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); @@ -574,10 +601,11 @@ public void advanceToEnd() { } @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 @@ -590,7 +618,14 @@ public void advanceToEndRetainsUnassignedData() { 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(); @@ -598,42 +633,48 @@ public void advanceToEndRetainsUnassignedData() { } @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); } @@ -663,7 +704,12 @@ public void seekToEndOfBuffer() { 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); } @@ -683,7 +729,12 @@ public void seekToAfterBufferAllowed() { 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); } @@ -693,7 +744,13 @@ public void seekToEndAndBackToStart() { 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); @@ -703,6 +760,51 @@ public void seekToEndAndBackToStart() { 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(); @@ -727,7 +829,7 @@ public void discardToStopAtReadPosition() { 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); @@ -776,6 +878,118 @@ public void discardToDontStopAtReadPosition() { 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(); @@ -817,7 +1031,7 @@ public void discardUpstreamBeforeRead() { writeTestData(); sampleQueue.discardUpstreamSamples(4); assertAllocationCount(4); - assertReadTestData(null, 0, 4); + assertReadTestData(/* startFormat= */ null, 0, 4); assertReadFormat(false, FORMAT_2); assertNoSamplesToRead(FORMAT_2); } @@ -825,7 +1039,7 @@ public void discardUpstreamBeforeRead() { @Test public void discardUpstreamAfterRead() { writeTestData(); - assertReadTestData(null, 0, 3); + assertReadTestData(/* startFormat= */ null, 0, 3); sampleQueue.discardUpstreamSamples(8); assertAllocationCount(10); sampleQueue.discardToRead(); @@ -884,13 +1098,54 @@ public void largestQueuedTimestampWithRead() { 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); } @@ -913,6 +1168,7 @@ public void setSampleOffsetBetweenSamples() { assertReadSample( unadjustedTimestampUs + sampleOffsetUs, /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -924,7 +1180,11 @@ public void setSampleOffsetBetweenSamples() { public void adjustUpstreamFormat() { String label = "label"; sampleQueue = - new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher) { + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager, + eventDispatcher) { @Override public Format getAdjustedUpstreamFormat(Format format) { return super.getAdjustedUpstreamFormat(copyWithLabel(format, label)); @@ -940,7 +1200,11 @@ public Format getAdjustedUpstreamFormat(Format format) { public void invalidateUpstreamFormatAdjustment() { AtomicReference label = new AtomicReference<>("label1"); sampleQueue = - new SampleQueue(allocator, mockDrmSessionManager, eventDispatcher) { + new SampleQueue( + allocator, + /* playbackLooper= */ Assertions.checkNotNull(Looper.myLooper()), + mockDrmSessionManager, + eventDispatcher) { @Override public Format getAdjustedUpstreamFormat(Format format) { return super.getAdjustedUpstreamFormat(copyWithLabel(format, label.get())); @@ -960,6 +1224,7 @@ public Format getAdjustedUpstreamFormat(Format format) { assertReadSample( /* timeUs= */ 0, /* isKeyFrame= */ true, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -968,6 +1233,7 @@ public Format getAdjustedUpstreamFormat(Format format) { assertReadSample( /* timeUs= */ 1, /* isKeyFrame= */ false, + /* isDecodeOnly= */ false, /* isEncrypted= */ false, DATA, /* offset= */ 0, @@ -983,16 +1249,23 @@ public void splice() { 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]; @@ -1002,14 +1275,21 @@ public void spliceAfterRead() { 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); } @@ -1023,14 +1303,23 @@ public void spliceWithSampleOffset() { 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. /** @@ -1069,7 +1358,7 @@ private void writeTestData(byte[] data, int[] sampleSizes, int[] sampleOffsets, 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); } } @@ -1086,14 +1375,14 @@ private void writeSample(byte[] data, long timestampUs, int sampleFlags) { 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); } /** @@ -1123,7 +1412,12 @@ private void assertReadTestData(Format startFormat, int firstSampleIndex) { * @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); } /** @@ -1135,7 +1429,11 @@ private void assertReadTestData(Format startFormat, int firstSampleIndex, int sa * @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 @@ -1149,9 +1447,11 @@ private void assertReadTestData( // 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], @@ -1195,12 +1495,7 @@ private void assertNoSamplesToRead(Format endFormat) { 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(); @@ -1218,12 +1513,7 @@ private void assertReadNothing(boolean formatRequired) { 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(); @@ -1244,12 +1534,7 @@ private void assertReadEndOfStream(boolean formatRequired) { 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); @@ -1266,6 +1551,7 @@ private void assertReadEncryptedSample(int sampleIndex) { assertReadSample( ENCRYPTED_SAMPLE_TIMESTAMPS[sampleIndex], isKeyFrame, + /* isDecodeOnly= */ false, isEncrypted, sampleData, /* offset= */ 0, @@ -1278,6 +1564,7 @@ private void assertReadEncryptedSample(int sampleIndex) { * * @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. @@ -1286,6 +1573,7 @@ private void assertReadEncryptedSample(int sampleIndex) { private void assertReadSample( long timeUs, boolean isKeyFrame, + boolean isDecodeOnly, boolean isEncrypted, byte[] sampleData, int offset, @@ -1293,18 +1581,14 @@ private void assertReadSample( 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); @@ -1357,4 +1641,33 @@ private static Format buildFormat(String id) { 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 00000000000..d8a7727953a --- /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 fe4255c6319..4fce17e336b 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 @@ 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 void setUp() throws Exception { 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 void getPeriodPositionDynamicWindowKnownDuration() { /* 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 void getPeriodPositionDynamicWindowKnownDuration() { } @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 void setNullTag_returnsNullTag_butUsesDefaultUid() { /* 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 void setNullTag_returnsNullTag_butUsesDefaultUid() { } @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 void getWindow_setsTag() { /* 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 void getIndexOfPeriod_returnsPeriod() { /* 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 5b7713a8350..3a253b29761 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 void setAdErrorBeforeAdCount() { assertThat(state.adGroups[0].uris[0]).isNull(); assertThat(state.adGroups[0].states[0]).isEqualTo(AdPlaybackState.AD_STATE_ERROR); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)).isTrue(); assertThat(state.adGroups[0].states[1]).isEqualTo(AdPlaybackState.AD_STATE_UNAVAILABLE); + assertThat(state.isAdInErrorState(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 1)).isFalse(); } @Test diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java new file mode 100644 index 00000000000..8395fcb1f4e --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/AdsMediaSourceTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.ads; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import 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; +import com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; +import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.ads.AdsLoader.AdViewProvider; +import com.google.android.exoplayer2.source.ads.AdsLoader.EventListener; +import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.upstream.Allocator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link AdsMediaSource}. */ +@RunWith(AndroidJUnit4.class) +public final class AdsMediaSourceTest { + + private static final long PREROLL_AD_DURATION_US = 10 * C.MICROS_PER_SECOND; + private static final Timeline PREROLL_AD_TIMELINE = + new SinglePeriodTimeline( + PREROLL_AD_DURATION_US, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* 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, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); + private static final Object CONTENT_PERIOD_UID = + CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); + + private static final AdPlaybackState AD_PLAYBACK_STATE = + new AdPlaybackState(/* adGroupTimesUs...= */ 0) + .withContentDurationUs(CONTENT_DURATION_US) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, Uri.EMPTY) + .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0) + .withAdResumePositionUs(/* adResumePositionUs= */ 0); + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private FakeMediaSource contentMediaSource; + private FakeMediaSource prerollAdMediaSource; + @Mock private MediaSourceCaller mockMediaSourceCaller; + private AdsMediaSource adsMediaSource; + + @Before + public void setUp() { + // Set up content and ad media sources, passing a null timeline so tests can simulate setting it + // later. + contentMediaSource = new FakeMediaSource(/* timeline= */ null); + prerollAdMediaSource = new FakeMediaSource(/* timeline= */ null); + MediaSourceFactory adMediaSourceFactory = mock(MediaSourceFactory.class); + when(adMediaSourceFactory.createMediaSource(any(MediaItem.class))) + .thenReturn(prerollAdMediaSource); + + // Prepare the AdsMediaSource and capture its ads loader listener. + AdsLoader mockAdsLoader = mock(AdsLoader.class); + AdViewProvider mockAdViewProvider = mock(AdViewProvider.class); + ArgumentCaptor eventListenerArgumentCaptor = + ArgumentCaptor.forClass(AdsLoader.EventListener.class); + adsMediaSource = + new AdsMediaSource( + contentMediaSource, adMediaSourceFactory, mockAdsLoader, mockAdViewProvider); + adsMediaSource.prepareSource(mockMediaSourceCaller, /* mediaTransferListener= */ null); + shadowOf(Looper.getMainLooper()).idle(); + verify(mockAdsLoader).start(eventListenerArgumentCaptor.capture(), eq(mockAdViewProvider)); + + // Simulate loading a preroll ad. + AdsLoader.EventListener adsLoaderEventListener = eventListenerArgumentCaptor.getValue(); + adsLoaderEventListener.onAdPlaybackState(AD_PLAYBACK_STATE); + shadowOf(Looper.getMainLooper()).idle(); + } + + @Test + public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfo() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(prerollAdMediaSource.isPrepared()).isTrue(); + verify(mockMediaSourceCaller) + .onSourceInfoRefreshed( + adsMediaSource, new SinglePeriodAdTimeline(CONTENT_TIMELINE, AD_PLAYBACK_STATE)); + } + + @Test + public void createPeriod_preparesChildAdMediaSourceAndRefreshesSourceInfoWithAdMediaSourceInfo() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mockMediaSourceCaller) + .onSourceInfoRefreshed( + adsMediaSource, + new SinglePeriodAdTimeline( + CONTENT_TIMELINE, + AD_PLAYBACK_STATE.withAdDurationsUs(new long[][] {{PREROLL_AD_DURATION_US}}))); + } + + @Test + public void createPeriod_createsChildPrerollAdMediaPeriod() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE); + shadowOf(Looper.getMainLooper()).idle(); + + prerollAdMediaSource.assertMediaPeriodCreated( + new MediaPeriodId(PREROLL_AD_PERIOD_UID, /* windowSequenceNumber= */ 0)); + } + + @Test + public void createPeriod_createsChildContentMediaPeriod() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); + shadowOf(Looper.getMainLooper()).idle(); + adsMediaSource.createPeriod( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + + contentMediaSource.assertMediaPeriodCreated( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0)); + } + + @Test + public void releasePeriod_releasesChildMediaPeriodsAndSources() { + contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); + MediaPeriod prerollAdMediaPeriod = + adsMediaSource.createPeriod( + new MediaPeriodId( + CONTENT_PERIOD_UID, + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + prerollAdMediaSource.setNewSourceInfo(PREROLL_AD_TIMELINE); + shadowOf(Looper.getMainLooper()).idle(); + MediaPeriod contentMediaPeriod = + adsMediaSource.createPeriod( + new MediaPeriodId(CONTENT_PERIOD_UID, /* windowSequenceNumber= */ 0), + mock(Allocator.class), + /* startPositionUs= */ 0); + adsMediaSource.releasePeriod(prerollAdMediaPeriod); + + prerollAdMediaSource.assertReleased(); + + adsMediaSource.releasePeriod(contentMediaPeriod); + adsMediaSource.releaseSource(mockMediaSourceCaller); + shadowOf(Looper.getMainLooper()).idle(); + prerollAdMediaSource.assertReleased(); + contentMediaSource.assertReleased(); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/CueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/CueTest.java index f014775a85a..c16cb928b1e 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 @@ -22,6 +22,7 @@ import android.graphics.Bitmap; import android.graphics.Color; import android.text.Layout; +import android.text.SpannedString; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -31,10 +32,10 @@ public class CueTest { @Test - public void buildSucceeds() { + public void buildAndBuildUponWorkAsExpected() { Cue cue = new Cue.Builder() - .setText("text") + .setText(SpannedString.valueOf("text")) .setTextAlignment(Layout.Alignment.ALIGN_CENTER) .setLine(5, Cue.LINE_TYPE_NUMBER) .setLineAnchor(Cue.ANCHOR_TYPE_END) @@ -46,7 +47,9 @@ public void buildSucceeds() { .setVerticalType(Cue.VERTICAL_TYPE_RL) .build(); - assertThat(cue.text).isEqualTo("text"); + Cue modifiedCue = cue.buildUpon().build(); + + assertThat(cue.text.toString()).isEqualTo("text"); assertThat(cue.textAlignment).isEqualTo(Layout.Alignment.ALIGN_CENTER); assertThat(cue.line).isEqualTo(5); assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); @@ -58,6 +61,27 @@ public void buildSucceeds() { assertThat(cue.windowColor).isEqualTo(Color.CYAN); assertThat(cue.windowColorSet).isTrue(); assertThat(cue.verticalType).isEqualTo(Cue.VERTICAL_TYPE_RL); + + assertThat(modifiedCue.text).isSameInstanceAs(cue.text); + assertThat(modifiedCue.textAlignment).isEqualTo(cue.textAlignment); + assertThat(modifiedCue.line).isEqualTo(cue.line); + assertThat(modifiedCue.lineType).isEqualTo(cue.lineType); + assertThat(modifiedCue.position).isEqualTo(cue.position); + assertThat(modifiedCue.positionAnchor).isEqualTo(cue.positionAnchor); + assertThat(modifiedCue.textSize).isEqualTo(cue.textSize); + assertThat(modifiedCue.textSizeType).isEqualTo(cue.textSizeType); + assertThat(modifiedCue.size).isEqualTo(cue.size); + assertThat(modifiedCue.windowColor).isEqualTo(cue.windowColor); + assertThat(modifiedCue.windowColorSet).isEqualTo(cue.windowColorSet); + 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 @@ -71,7 +95,7 @@ public void buildWithBothTextAndBitmapFails() { RuntimeException.class, () -> new Cue.Builder() - .setText("foo") + .setText(SpannedString.valueOf("text")) .setBitmap(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) .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 379e189db92..c7833fab04a 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 @@ @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 e233d8d1b55..c868cc9a70e 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 @@ @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 071d34e5d04..dac21f3628a 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 @@ @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 void inheritGlobalAndParent() throws IOException, SubtitleDecoderExceptio 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 void inheritGlobalAndParent() throws IOException, SubtitleDecoderExceptio 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 void multipleRegions() throws IOException, SubtitleDecoderException { // 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 void bitmapUnsupportedRegion() throws IOException, SubtitleDecoderExcepti 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 void rubies() throws IOException, SubtitleDecoderException { 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 4f75c50b12f..a3ad1ba5993 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.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 void inheritStyle() { 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 void chainStyle() { .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 143326583c2..58b9a853e70 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 @@ @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 7dc41eda82e..797a0b5d94c 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 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 void parseMethodMultipleRulesInBlockInput() { @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 void multiplePropertiesInBlock() { @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 void getNextToken() { 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 @@ private void assertParserProduces(String styleBlock, WebvttCssStyle... expectedS 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 f5000298858..778820b451e 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 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 void parseStrictValidUnsupportedTagsStrippedOut() throws Exception { 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 48f13400853..9b7db097a71 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,11 +26,13 @@ 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; import com.google.common.truth.Expect; import java.io.IOException; +import java.util.List; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -39,20 +41,25 @@ @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 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"; + 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 = + "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(); @@ -184,18 +191,19 @@ public void decodeWithTags() throws Exception { public void decodeWithPositioning() throws Exception { WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_POSITIONING_FILE); - assertThat(subtitle.getEventTimeCount()).isEqualTo(12); + assertThat(subtitle.getEventTimeCount()).isEqualTo(16); assertThat(subtitle.getEventTime(0)).isEqualTo(0L); assertThat(subtitle.getEventTime(1)).isEqualTo(1_234_000L); Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0))); assertThat(firstCue.text.toString()).isEqualTo("This is the first subtitle."); - assertThat(firstCue.position).isEqualTo(0.1f); - assertThat(firstCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_START); + assertThat(firstCue.position).isEqualTo(0.6f); + assertThat(firstCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); assertThat(firstCue.textAlignment).isEqualTo(Alignment.ALIGN_NORMAL); assertThat(firstCue.size).isEqualTo(0.35f); + // Unspecified values should use WebVTT defaults - assertThat(firstCue.line).isEqualTo(Cue.DIMEN_UNSET); + assertThat(firstCue.line).isEqualTo(-1f); assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_NUMBER); assertThat(firstCue.verticalType).isEqualTo(Cue.TYPE_UNSET); @@ -222,7 +230,7 @@ public void decodeWithPositioning() throws Exception { 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`: @@ -246,6 +254,63 @@ public void decodeWithPositioning() throws Exception { // Derived from `align:center`: assertThat(sixthCue.position).isEqualTo(0.5f); assertThat(sixthCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_MIDDLE); + + assertThat(subtitle.getEventTime(12)).isEqualTo(12_000_000L); + assertThat(subtitle.getEventTime(13)).isEqualTo(13_000_000L); + Cue seventhCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(12))); + assertThat(seventhCue.text.toString()).isEqualTo("This is the seventh subtitle."); + assertThat(seventhCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_START); + + assertThat(subtitle.getEventTime(14)).isEqualTo(14_000_000L); + assertThat(subtitle.getEventTime(15)).isEqualTo(15_000_000L); + Cue eighthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(14))); + assertThat(eighthCue.text.toString()).isEqualTo("This is the eighth subtitle."); + assertThat(eighthCue.positionAnchor).isEqualTo(Cue.ANCHOR_TYPE_END); + } + + @Test + public void decodeWithOverlappingTimestamps() throws Exception { + WebvttSubtitle subtitle = getSubtitleForTestAsset(WITH_OVERLAPPING_TIMESTAMPS_FILE); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(8); + + Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0))); + 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); + + List firstAndSecondCue = subtitle.getCues(subtitle.getEventTime(1)); + assertThat(firstAndSecondCue).hasSize(2); + assertThat(firstAndSecondCue.get(0).text.toString()) + .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(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); + + 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); + + List thirdAndFourthCue = subtitle.getCues(subtitle.getEventTime(5)); + assertThat(thirdAndFourthCue).hasSize(2); + assertThat(thirdAndFourthCue.get(0).text.toString()) + .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(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); + + 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); } @Test @@ -273,6 +338,51 @@ public void decodeWithVertical() throws Exception { 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/text/webvtt/WebvttSubtitleTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitleTest.java index af44120bc18..a9b8c32e5b8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitleTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/webvtt/WebvttSubtitleTest.java @@ -21,6 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.text.Cue; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -33,8 +34,6 @@ public class WebvttSubtitleTest { private static final String FIRST_SUBTITLE_STRING = "This is the first subtitle."; private static final String SECOND_SUBTITLE_STRING = "This is the second subtitle."; - private static final String FIRST_AND_SECOND_SUBTITLE_STRING = - FIRST_SUBTITLE_STRING + "\n" + SECOND_SUBTITLE_STRING; private static final WebvttSubtitle emptySubtitle = new WebvttSubtitle(Collections.emptyList()); @@ -84,161 +83,225 @@ public void eventCount() { @Test public void simpleSubtitleEventTimes() { - testSubtitleEventTimesHelper(simpleSubtitle); + assertThat(simpleSubtitle.getEventTime(0)).isEqualTo(1_000_000); + assertThat(simpleSubtitle.getEventTime(1)).isEqualTo(2_000_000); + assertThat(simpleSubtitle.getEventTime(2)).isEqualTo(3_000_000); + assertThat(simpleSubtitle.getEventTime(3)).isEqualTo(4_000_000); } @Test public void simpleSubtitleEventIndices() { - testSubtitleEventIndicesHelper(simpleSubtitle); + // Test first event + assertThat(simpleSubtitle.getNextEventTimeIndex(0)).isEqualTo(0); + assertThat(simpleSubtitle.getNextEventTimeIndex(500_000)).isEqualTo(0); + assertThat(simpleSubtitle.getNextEventTimeIndex(999_999)).isEqualTo(0); + + // Test second event + assertThat(simpleSubtitle.getNextEventTimeIndex(1_000_000)).isEqualTo(1); + assertThat(simpleSubtitle.getNextEventTimeIndex(1_500_000)).isEqualTo(1); + assertThat(simpleSubtitle.getNextEventTimeIndex(1_999_999)).isEqualTo(1); + + // Test third event + assertThat(simpleSubtitle.getNextEventTimeIndex(2_000_000)).isEqualTo(2); + assertThat(simpleSubtitle.getNextEventTimeIndex(2_500_000)).isEqualTo(2); + assertThat(simpleSubtitle.getNextEventTimeIndex(2_999_999)).isEqualTo(2); + + // Test fourth event + assertThat(simpleSubtitle.getNextEventTimeIndex(3_000_000)).isEqualTo(3); + assertThat(simpleSubtitle.getNextEventTimeIndex(3_500_000)).isEqualTo(3); + assertThat(simpleSubtitle.getNextEventTimeIndex(3_999_999)).isEqualTo(3); + + // Test null event (i.e. look for events after the last event) + assertThat(simpleSubtitle.getNextEventTimeIndex(4_000_000)).isEqualTo(INDEX_UNSET); + assertThat(simpleSubtitle.getNextEventTimeIndex(4_500_000)).isEqualTo(INDEX_UNSET); + assertThat(simpleSubtitle.getNextEventTimeIndex(MAX_VALUE)).isEqualTo(INDEX_UNSET); } @Test public void simpleSubtitleText() { // Test before first subtitle - assertSingleCueEmpty(simpleSubtitle.getCues(0)); - assertSingleCueEmpty(simpleSubtitle.getCues(500_000)); - assertSingleCueEmpty(simpleSubtitle.getCues(999_999)); + assertThat(simpleSubtitle.getCues(0)).isEmpty(); + assertThat(simpleSubtitle.getCues(500_000)).isEmpty(); + assertThat(simpleSubtitle.getCues(999_999)).isEmpty(); // Test first subtitle - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, simpleSubtitle.getCues(1_000_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, simpleSubtitle.getCues(1_500_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, simpleSubtitle.getCues(1_999_999)); + assertThat(getCueTexts(simpleSubtitle.getCues(1_000_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(simpleSubtitle.getCues(1_500_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(simpleSubtitle.getCues(1_999_999))) + .containsExactly(FIRST_SUBTITLE_STRING); // Test after first subtitle, before second subtitle - assertSingleCueEmpty(simpleSubtitle.getCues(2_000_000)); - assertSingleCueEmpty(simpleSubtitle.getCues(2_500_000)); - assertSingleCueEmpty(simpleSubtitle.getCues(2_999_999)); + assertThat(simpleSubtitle.getCues(2_000_000)).isEmpty(); + assertThat(simpleSubtitle.getCues(2_500_000)).isEmpty(); + assertThat(simpleSubtitle.getCues(2_999_999)).isEmpty(); // Test second subtitle - assertSingleCueTextEquals(SECOND_SUBTITLE_STRING, simpleSubtitle.getCues(3_000_000)); - assertSingleCueTextEquals(SECOND_SUBTITLE_STRING, simpleSubtitle.getCues(3_500_000)); - assertSingleCueTextEquals(SECOND_SUBTITLE_STRING, simpleSubtitle.getCues(3_999_999)); + assertThat(getCueTexts(simpleSubtitle.getCues(3_000_000))) + .containsExactly(SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(simpleSubtitle.getCues(3_500_000))) + .containsExactly(SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(simpleSubtitle.getCues(3_999_999))) + .containsExactly(SECOND_SUBTITLE_STRING); // Test after second subtitle - assertSingleCueEmpty(simpleSubtitle.getCues(4_000_000)); - assertSingleCueEmpty(simpleSubtitle.getCues(4_500_000)); - assertSingleCueEmpty(simpleSubtitle.getCues(Long.MAX_VALUE)); + assertThat(simpleSubtitle.getCues(4_000_000)).isEmpty(); + assertThat(simpleSubtitle.getCues(4_500_000)).isEmpty(); + assertThat(simpleSubtitle.getCues(Long.MAX_VALUE)).isEmpty(); } @Test public void overlappingSubtitleEventTimes() { - testSubtitleEventTimesHelper(overlappingSubtitle); + assertThat(overlappingSubtitle.getEventTime(0)).isEqualTo(1_000_000); + assertThat(overlappingSubtitle.getEventTime(1)).isEqualTo(2_000_000); + assertThat(overlappingSubtitle.getEventTime(2)).isEqualTo(3_000_000); + assertThat(overlappingSubtitle.getEventTime(3)).isEqualTo(4_000_000); } @Test public void overlappingSubtitleEventIndices() { - testSubtitleEventIndicesHelper(overlappingSubtitle); + // Test first event + assertThat(overlappingSubtitle.getNextEventTimeIndex(0)).isEqualTo(0); + assertThat(overlappingSubtitle.getNextEventTimeIndex(500_000)).isEqualTo(0); + assertThat(overlappingSubtitle.getNextEventTimeIndex(999_999)).isEqualTo(0); + + // Test second event + assertThat(overlappingSubtitle.getNextEventTimeIndex(1_000_000)).isEqualTo(1); + assertThat(overlappingSubtitle.getNextEventTimeIndex(1_500_000)).isEqualTo(1); + assertThat(overlappingSubtitle.getNextEventTimeIndex(1_999_999)).isEqualTo(1); + + // Test third event + assertThat(overlappingSubtitle.getNextEventTimeIndex(2_000_000)).isEqualTo(2); + assertThat(overlappingSubtitle.getNextEventTimeIndex(2_500_000)).isEqualTo(2); + assertThat(overlappingSubtitle.getNextEventTimeIndex(2_999_999)).isEqualTo(2); + + // Test fourth event + assertThat(overlappingSubtitle.getNextEventTimeIndex(3_000_000)).isEqualTo(3); + assertThat(overlappingSubtitle.getNextEventTimeIndex(3_500_000)).isEqualTo(3); + assertThat(overlappingSubtitle.getNextEventTimeIndex(3_999_999)).isEqualTo(3); + + // Test null event (i.e. look for events after the last event) + assertThat(overlappingSubtitle.getNextEventTimeIndex(4_000_000)).isEqualTo(INDEX_UNSET); + assertThat(overlappingSubtitle.getNextEventTimeIndex(4_500_000)).isEqualTo(INDEX_UNSET); + assertThat(overlappingSubtitle.getNextEventTimeIndex(MAX_VALUE)).isEqualTo(INDEX_UNSET); } @Test public void overlappingSubtitleText() { // Test before first subtitle - assertSingleCueEmpty(overlappingSubtitle.getCues(0)); - assertSingleCueEmpty(overlappingSubtitle.getCues(500_000)); - assertSingleCueEmpty(overlappingSubtitle.getCues(999_999)); + assertThat(overlappingSubtitle.getCues(0)).isEmpty(); + assertThat(overlappingSubtitle.getCues(500_000)).isEmpty(); + assertThat(overlappingSubtitle.getCues(999_999)).isEmpty(); // Test first subtitle - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, overlappingSubtitle.getCues(1_000_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, overlappingSubtitle.getCues(1_500_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, overlappingSubtitle.getCues(1_999_999)); + assertThat(getCueTexts(overlappingSubtitle.getCues(1_000_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(overlappingSubtitle.getCues(1_500_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(overlappingSubtitle.getCues(1_999_999))) + .containsExactly(FIRST_SUBTITLE_STRING); // Test after first and second subtitle - assertSingleCueTextEquals( - FIRST_AND_SECOND_SUBTITLE_STRING, overlappingSubtitle.getCues(2_000_000)); - assertSingleCueTextEquals( - FIRST_AND_SECOND_SUBTITLE_STRING, overlappingSubtitle.getCues(2_500_000)); - assertSingleCueTextEquals( - FIRST_AND_SECOND_SUBTITLE_STRING, overlappingSubtitle.getCues(2_999_999)); + assertThat(getCueTexts(overlappingSubtitle.getCues(2_000_000))) + .containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(overlappingSubtitle.getCues(2_500_000))) + .containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(overlappingSubtitle.getCues(2_999_999))) + .containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING); // Test second subtitle - assertSingleCueTextEquals(SECOND_SUBTITLE_STRING, overlappingSubtitle.getCues(3_000_000)); - assertSingleCueTextEquals(SECOND_SUBTITLE_STRING, overlappingSubtitle.getCues(3_500_000)); - assertSingleCueTextEquals(SECOND_SUBTITLE_STRING, overlappingSubtitle.getCues(3_999_999)); + assertThat(getCueTexts(overlappingSubtitle.getCues(3_000_000))) + .containsExactly(SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(overlappingSubtitle.getCues(3_500_000))) + .containsExactly(SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(overlappingSubtitle.getCues(3_999_999))) + .containsExactly(SECOND_SUBTITLE_STRING); // Test after second subtitle - assertSingleCueEmpty(overlappingSubtitle.getCues(4_000_000)); - assertSingleCueEmpty(overlappingSubtitle.getCues(4_500_000)); - assertSingleCueEmpty(overlappingSubtitle.getCues(Long.MAX_VALUE)); + assertThat(overlappingSubtitle.getCues(4_000_000)).isEmpty(); + assertThat(overlappingSubtitle.getCues(4_500_000)).isEmpty(); + assertThat(overlappingSubtitle.getCues(Long.MAX_VALUE)).isEmpty(); } @Test public void nestedSubtitleEventTimes() { - testSubtitleEventTimesHelper(nestedSubtitle); + assertThat(nestedSubtitle.getEventTime(0)).isEqualTo(1_000_000); + assertThat(nestedSubtitle.getEventTime(1)).isEqualTo(2_000_000); + assertThat(nestedSubtitle.getEventTime(2)).isEqualTo(3_000_000); + assertThat(nestedSubtitle.getEventTime(3)).isEqualTo(4_000_000); } @Test public void nestedSubtitleEventIndices() { - testSubtitleEventIndicesHelper(nestedSubtitle); + // Test first event + assertThat(nestedSubtitle.getNextEventTimeIndex(0)).isEqualTo(0); + assertThat(nestedSubtitle.getNextEventTimeIndex(500_000)).isEqualTo(0); + assertThat(nestedSubtitle.getNextEventTimeIndex(999_999)).isEqualTo(0); + + // Test second event + assertThat(nestedSubtitle.getNextEventTimeIndex(1_000_000)).isEqualTo(1); + assertThat(nestedSubtitle.getNextEventTimeIndex(1_500_000)).isEqualTo(1); + assertThat(nestedSubtitle.getNextEventTimeIndex(1_999_999)).isEqualTo(1); + + // Test third event + assertThat(nestedSubtitle.getNextEventTimeIndex(2_000_000)).isEqualTo(2); + assertThat(nestedSubtitle.getNextEventTimeIndex(2_500_000)).isEqualTo(2); + assertThat(nestedSubtitle.getNextEventTimeIndex(2_999_999)).isEqualTo(2); + + // Test fourth event + assertThat(nestedSubtitle.getNextEventTimeIndex(3_000_000)).isEqualTo(3); + assertThat(nestedSubtitle.getNextEventTimeIndex(3_500_000)).isEqualTo(3); + assertThat(nestedSubtitle.getNextEventTimeIndex(3_999_999)).isEqualTo(3); + + // Test null event (i.e. look for events after the last event) + assertThat(nestedSubtitle.getNextEventTimeIndex(4_000_000)).isEqualTo(INDEX_UNSET); + assertThat(nestedSubtitle.getNextEventTimeIndex(4_500_000)).isEqualTo(INDEX_UNSET); + assertThat(nestedSubtitle.getNextEventTimeIndex(MAX_VALUE)).isEqualTo(INDEX_UNSET); } @Test public void nestedSubtitleText() { // Test before first subtitle - assertSingleCueEmpty(nestedSubtitle.getCues(0)); - assertSingleCueEmpty(nestedSubtitle.getCues(500_000)); - assertSingleCueEmpty(nestedSubtitle.getCues(999_999)); + assertThat(nestedSubtitle.getCues(0)).isEmpty(); + assertThat(nestedSubtitle.getCues(500_000)).isEmpty(); + assertThat(nestedSubtitle.getCues(999_999)).isEmpty(); // Test first subtitle - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getCues(1_000_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getCues(1_500_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getCues(1_999_999)); + assertThat(getCueTexts(nestedSubtitle.getCues(1_000_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(nestedSubtitle.getCues(1_500_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(nestedSubtitle.getCues(1_999_999))) + .containsExactly(FIRST_SUBTITLE_STRING); // Test after first and second subtitle - assertSingleCueTextEquals(FIRST_AND_SECOND_SUBTITLE_STRING, nestedSubtitle.getCues(2_000_000)); - assertSingleCueTextEquals(FIRST_AND_SECOND_SUBTITLE_STRING, nestedSubtitle.getCues(2_500_000)); - assertSingleCueTextEquals(FIRST_AND_SECOND_SUBTITLE_STRING, nestedSubtitle.getCues(2_999_999)); + assertThat(getCueTexts(nestedSubtitle.getCues(2_000_000))) + .containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(nestedSubtitle.getCues(2_500_000))) + .containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING); + assertThat(getCueTexts(nestedSubtitle.getCues(2_999_999))) + .containsExactly(FIRST_SUBTITLE_STRING, SECOND_SUBTITLE_STRING); // Test first subtitle - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getCues(3_000_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getCues(3_500_000)); - assertSingleCueTextEquals(FIRST_SUBTITLE_STRING, nestedSubtitle.getCues(3_999_999)); + assertThat(getCueTexts(nestedSubtitle.getCues(3_000_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(nestedSubtitle.getCues(3_500_000))) + .containsExactly(FIRST_SUBTITLE_STRING); + assertThat(getCueTexts(nestedSubtitle.getCues(3_999_999))) + .containsExactly(FIRST_SUBTITLE_STRING); // Test after second subtitle - assertSingleCueEmpty(nestedSubtitle.getCues(4_000_000)); - assertSingleCueEmpty(nestedSubtitle.getCues(4_500_000)); - assertSingleCueEmpty(nestedSubtitle.getCues(Long.MAX_VALUE)); - } - - private void testSubtitleEventTimesHelper(WebvttSubtitle subtitle) { - assertThat(subtitle.getEventTime(0)).isEqualTo(1_000_000); - assertThat(subtitle.getEventTime(1)).isEqualTo(2_000_000); - assertThat(subtitle.getEventTime(2)).isEqualTo(3_000_000); - assertThat(subtitle.getEventTime(3)).isEqualTo(4_000_000); - } - - private void testSubtitleEventIndicesHelper(WebvttSubtitle subtitle) { - // Test first event - assertThat(subtitle.getNextEventTimeIndex(0)).isEqualTo(0); - assertThat(subtitle.getNextEventTimeIndex(500_000)).isEqualTo(0); - assertThat(subtitle.getNextEventTimeIndex(999_999)).isEqualTo(0); - - // Test second event - assertThat(subtitle.getNextEventTimeIndex(1_000_000)).isEqualTo(1); - assertThat(subtitle.getNextEventTimeIndex(1_500_000)).isEqualTo(1); - assertThat(subtitle.getNextEventTimeIndex(1_999_999)).isEqualTo(1); - - // Test third event - assertThat(subtitle.getNextEventTimeIndex(2_000_000)).isEqualTo(2); - assertThat(subtitle.getNextEventTimeIndex(2_500_000)).isEqualTo(2); - assertThat(subtitle.getNextEventTimeIndex(2_999_999)).isEqualTo(2); - - // Test fourth event - assertThat(subtitle.getNextEventTimeIndex(3_000_000)).isEqualTo(3); - assertThat(subtitle.getNextEventTimeIndex(3_500_000)).isEqualTo(3); - assertThat(subtitle.getNextEventTimeIndex(3_999_999)).isEqualTo(3); - - // Test null event (i.e. look for events after the last event) - assertThat(subtitle.getNextEventTimeIndex(4_000_000)).isEqualTo(INDEX_UNSET); - assertThat(subtitle.getNextEventTimeIndex(4_500_000)).isEqualTo(INDEX_UNSET); - assertThat(subtitle.getNextEventTimeIndex(MAX_VALUE)).isEqualTo(INDEX_UNSET); - } - - private void assertSingleCueEmpty(List cues) { - assertThat(cues).isEmpty(); + assertThat(nestedSubtitle.getCues(4_000_000)).isEmpty(); + assertThat(nestedSubtitle.getCues(4_500_000)).isEmpty(); + assertThat(nestedSubtitle.getCues(Long.MAX_VALUE)).isEmpty(); } - private void assertSingleCueTextEquals(String expected, List cues) { - assertThat(cues).hasSize(1); - assertThat(cues.get(0).text.toString()).isEqualTo(expected); + private static List getCueTexts(List cues) { + List cueTexts = new ArrayList<>(); + for (int i = 0; i < cues.size(); i++) { + cueTexts.add(cues.get(i).text.toString()); + } + return cueTexts; } } 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 b14e4b123eb..a7a8e5a4c1c 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.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 void selectInitialIndexUseMaxInitialBitrateIfNoBandwidthEstimate() { 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 void selectInitialIndexUseBandwidthEstimateIfAvailable() { 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 void updateSelectedTrackDoNotSwitchUpIfNotBufferedEnough() { // 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 void updateSelectedTrackSwitchUpIfBufferedEnough() { // 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 void updateSelectedTrackDoNotSwitchDownIfBufferedEnough() { // 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 void updateSelectedTrackSwitchDownIfNotBufferedEnough() { // 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 void evaluateQueueSizeReturnQueueSizeIfBandwidthIsNotImproved() { 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 void evaluateQueueSizeDoNotReevaluateUntilAfterMinTimeBetweenBufferReeval 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 void evaluateQueueSizeRetainMoreThanMinimumDurationAfterDiscard() { 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 void evaluateQueueSizeRetainMoreThanMinimumDurationAfterDiscard() { 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 @@ private AdaptiveTrackSelection adaptiveTrackSelectionWithMinDurationForQualityIn 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 @@ private AdaptiveTrackSelection adaptiveTrackSelectionWithMaxDurationForQualityDe 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 @@ private AdaptiveTrackSelection adaptiveTrackSelectionWithMinTimeBetweenBufferRee 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 4304c9af9ae..ef58cb2801c 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.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.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.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 void setUp() { trackSelector.init(invalidationListener, bandwidthMeter); } - /** 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); + public void parameters_buildUponThenBuild_isEqual() { + Parameters parameters = buildParametersForEqualsTest(); + assertThat(parameters.buildUpon().build()).isEqualTo(parameters); + } - 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); + /** Tests {@link Parameters} {@link android.os.Parcelable} implementation. */ + @Test + public void parameters_parcelAndUnParcelable() { + Parameters parametersToParcel = buildParametersForEqualsTest(); Parcel parcel = Parcel.obtain(); parametersToParcel.writeToParcel(parcel, 0); @@ -1353,7 +1322,10 @@ public void selectTracksWithMultipleVideoTracks() throws Exception { @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 @@ private static Format buildAudioFormatWithConfiguration( .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 1f4790b8c5d..67ca415a53f 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 @@ @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 27cf243030c..564973f51c2 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 @@ private void readTestData(byte[] testData, int dataOffset, int dataLength, int o 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 d8d22a7b2f7..23f5a17e93f 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 void setUp() { 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 @@ private static long[] simulateTransfers(DefaultBandwidthMeter bandwidthMeter, Fa 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 405fe6c5ee8..8d5a7479e58 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 { - - /* - * 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 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); - + public void open_withSpecifiedRequestParameters_usesCorrectParameters() throws Exception { + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse()); + + 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 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); - - Mockito.when(mockHttpUrlConnection.getInputStream()) - .thenReturn(new ByteArrayInputStream(new byte[128])); - - Mockito.when(mockHttpUrlConnection.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + @Test + public void open_invalidResponseCode() throws Exception { + DefaultHttpDataSource defaultHttpDataSource = + new DefaultHttpDataSource( + /* userAgent= */ "testAgent", + /* connectTimeoutMillis= */ 1000, + /* readTimeoutMillis= */ 1000, + /* allowCrossProtocolRedirects= */ false, + /* defaultRequestProperties= */ null); + + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(404) + .setBody(new Buffer().write(TestUtil.createByteArray(1, 2, 3)))); - Mockito.when(mockHttpUrlConnection.getResponseCode()).thenReturn(200); - Mockito.when(mockHttpUrlConnection.getResponseMessage()).thenReturn("OK"); + DataSpec dataSpec = + new DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build(); - 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()); + HttpDataSource.InvalidResponseCodeException exception = + assertThrows( + HttpDataSource.InvalidResponseCodeException.class, + () -> defaultHttpDataSource.open(dataSpec)); - 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 8840abfcdc8..50b06c14db4 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 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 @@ @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 void getRetryDelayMsFor_successiveRetryDelays() { 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 707ea929b7b..cadd9e43aba 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 @@ 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 void setUp() throws Exception { 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,13 +359,14 @@ public void switchToCacheSourceWithReadOnlyCacheDataSource() throws Exception { .newDefaultData() .appendReadData(1024 * 1024) .endData()); - CacheUtil.cache( - unboundedDataSpec, - cache, - /* cacheKeyFactory= */ null, - 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); @@ -382,7 +385,7 @@ public void switchToCacheSourceWithNonBlockingCacheDataSource() throws Exception .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(); @@ -406,13 +409,14 @@ public void switchToCacheSourceWithNonBlockingCacheDataSource() throws Exception .newDefaultData() .appendReadData(1024 * 1024) .endData()); - CacheUtil.cache( - unboundedDataSpec, - cache, - /* cacheKeyFactory= */ null, - 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); @@ -430,13 +434,14 @@ public void deleteCachedWhileReadingFromUpstreamWithReadOnlyCacheDataSourceDoesN // Cache the latter half of the data. int halfDataLength = 512; DataSpec dataSpec = buildDataSpec(halfDataLength, C.LENGTH_UNSET); - CacheUtil.cache( - dataSpec, - cache, - /* cacheKeyFactory= */ null, - 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 = @@ -447,7 +452,7 @@ public void deleteCachedWhileReadingFromUpstreamWithReadOnlyCacheDataSourceDoesN 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. @@ -466,13 +471,14 @@ public void deleteCachedWhileReadingFromUpstreamWithBlockingCacheDataSourceDoesN // Cache the latter half of the data. int halfDataLength = 512; DataSpec dataSpec = buildDataSpec(/* position= */ 0, halfDataLength); - CacheUtil.cache( - dataSpec, - cache, - /* cacheKeyFactory= */ null, - 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 = @@ -545,7 +551,7 @@ private void assertReadData( 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 8702e887f85..e6b44e9aa86 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 @@ 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.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 @@ private static FakeDataSource buildFakeUpstreamSource() { 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 @@ private static CacheDataSource buildCacheDataSource(Context context, DataSource 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 00000000000..3c6542b90f3 --- /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 54% 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 65c9c4d9e55..6064783e08c 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.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 @@ private void init() { @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 void setUp() throws Exception { mockCache.init(); tempFolder = Util.createTempDirectory(ApplicationProvider.getApplicationContext(), "ExoPlayerTest"); - cache = new SimpleCache(tempFolder, new NoOpCacheEvictor()); + cache = + new SimpleCache(tempFolder, new NoOpCacheEvictor(), TestUtil.getInMemoryDatabaseProvider()); } @After @@ -104,109 +106,21 @@ public void tearDown() { 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( - new DataSpec(Uri.parse("test_data")), - cache, - /* cacheKeyFactory= */ null, - 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); @@ -220,19 +134,27 @@ public void cacheSetOffsetAndLength() throws Exception { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, 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( - new DataSpec(testUri), - cache, - /* cacheKeyFactory= */ null, - 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); @@ -247,8 +169,15 @@ public void cacheUnknownLength() throws Exception { DataSpec dataSpec = new DataSpec(Uri.parse("test_data")); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, 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); @@ -264,19 +193,27 @@ public void cacheUnknownLengthPartialCaching() throws Exception { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 10, /* length= */ 20); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, 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( - new DataSpec(testUri), - cache, - /* cacheKeyFactory= */ null, - 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); @@ -290,10 +227,17 @@ public void cacheLengthExceedsActualDataLength() throws Exception { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000); CachingCounters counters = new CachingCounters(); - CacheUtil.cache( - dataSpec, cache, /* cacheKeyFactory= */ null, 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); } @@ -305,22 +249,18 @@ public void cacheThrowEOFException() throws Exception { Uri testUri = Uri.parse("test_data"); DataSpec dataSpec = new DataSpec(testUri, /* position= */ 0, /* length= */ 1000); - try { - CacheUtil.cache( - dataSpec, - cache, - /* cacheKeyFactory= */ null, - new CacheDataSource(cache, dataSource), - new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES], - /* priorityTaskManager= */ null, - /* priority= */ 0, - /* progressListener= */ null, - /* isCanceled= */ null, - /* enableEOFException= */ true); - 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 @@ -337,52 +277,20 @@ public void cachePolling() throws Exception { .endData(); FakeDataSource dataSource = new FakeDataSource(fakeDataSet); - CacheUtil.cache( - new DataSpec(Uri.parse("test_data")), - cache, - /* cacheKeyFactory= */ null, - 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( - dataSpec, - cache, - /* cacheKeyFactory= */ null, - // 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), - new byte[CacheUtil.DEFAULT_BUFFER_SIZE_BYTES], - /* priorityTaskManager= */ null, - /* priority= */ 0, - /* progressListener= */ null, - /* isCanceled= */ null, - true); - 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 bbb372b5e2e..1237d3a3127 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 void cantRemoveNotEmptyCachedContent() throws Exception { 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 14222f144d9..482c95bfd21 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 @@ 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.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 void cacheInitialization() { 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,237 +101,551 @@ public void cacheInitializationError() throws IOException { } @Test - public void committingOneFile() throws Exception { - SimpleCache simpleCache = getSimpleCache(); + @SuppressWarnings("deprecation") // Testing deprecated behaviour + public void newInstance_withExistingCacheDirectory_withoutDatabase_loadsCachedData() + throws Exception { + SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); - 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 = new SimpleCache(cacheDir, new NoOpCacheEvictor()); - NavigableSet cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans.isEmpty()).isTrue(); - assertThat(simpleCache.getCacheSpace()).isEqualTo(0); - assertNoCacheFiles(cacheDir); + // Read the cached data and metadata back. + CacheSpan fileSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertCachedDataReadCorrect(fileSpan); + assertThat(ContentMetadata.getRedirectedUri(simpleCache.getContentMetadata(KEY_1))) + .isEqualTo(Uri.parse("https://redirect.google.com")); + } + + @Test + public void newInstance_withExistingCacheDirectory_withDatabase_loadsCachedData() + throws Exception { + SimpleCache simpleCache = getSimpleCache(); + // Write some data and metadata to the cache. + 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(); - Set cachedKeys = simpleCache.getKeys(); - assertThat(cachedKeys).containsExactly(KEY_1); - cachedSpans = simpleCache.getCachedSpans(KEY_1); - assertThat(cachedSpans).contains(cacheSpan1); - assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + // Create a new instance pointing to the same directory. + simpleCache = getSimpleCache(); - simpleCache.releaseHoleSpan(cacheSpan1); + // 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(); - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertThat(cacheSpan2.isCached).isTrue(); - assertThat(cacheSpan2.isOpenEnded()).isFalse(); - assertThat(cacheSpan2.length).isEqualTo(15); - assertCachedDataReadCorrect(cacheSpan2); + // Instantiation should fail because the directory is locked by the first instance. + assertThrows(IllegalStateException.class, this::getSimpleCache); } @Test - public void readCacheWithoutReleasingWriteCacheSpan() throws Exception { - SimpleCache simpleCache = getSimpleCache(); + @SuppressWarnings("deprecation") // Testing deprecated behaviour + public void newInstance_withExistingCacheDirectory_withoutDatabase_resolvesInconsistentState() + throws Exception { + SimpleCache simpleCache = new SimpleCache(testDir, new NoOpCacheEvictor()); - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); - simpleCache.releaseHoleSpan(cacheSpan1); + 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 setGetContentMetadata() throws Exception { - SimpleCache simpleCache = getSimpleCache(); + public void newInstance_withExistingCacheDirectory_withDatabase_resolvesInconsistentState() + throws Exception { + SimpleCache simpleCache = new SimpleCache(testDir, new NoOpCacheEvictor(), databaseProvider); - assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) - .isEqualTo(LENGTH_UNSET); + 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()); - ContentMetadataMutations mutations = new ContentMetadataMutations(); - ContentMetadataMutations.setContentLength(mutations, 15); - simpleCache.applyContentMetadataMutations(KEY_1, mutations); - assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) - .isEqualTo(15); + // 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(); + } - simpleCache.startReadWrite(KEY_1, 0); + @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(); - mutations = new ContentMetadataMutations(); - ContentMetadataMutations.setContentLength(mutations, 150); - simpleCache.applyContentMetadataMutations(KEY_1, mutations); - assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) - .isEqualTo(150); + // Create a new instance pointing to the same directory. + simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); - addCache(simpleCache, KEY_1, 140, 10); + // 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(); - // Check if values are kept after cache is reloaded. - SimpleCache simpleCache2 = getSimpleCache(); - assertThat(ContentMetadata.getContentLength(simpleCache2.getContentMetadata(KEY_1))) - .isEqualTo(150); + // Create a new instance pointing to the same directory, with a different key. + simpleCache = getEncryptedSimpleCache(Util.getUtf8Bytes("Foo12345Foo12345")); - // 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); + // Cache should be cleared. + assertThat(simpleCache.getKeys()).isEmpty(); + assertNoCacheFiles(cacheDir); } @Test - public void reloadCache() throws Exception { - SimpleCache simpleCache = getSimpleCache(); + @SuppressWarnings("deprecation") // Encrypted index is deprecated + public void newInstance_withEncryptedIndexAndNoKey_clearsCache() throws Exception { + SimpleCache simpleCache = getEncryptedSimpleCache(ENCRYPTED_INDEX_KEY); - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + // Write data. + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); + simpleCache.releaseHoleSpan(holeSpan); simpleCache.release(); - // Reload cache + // Create a new instance pointing to the same directory, with no key. simpleCache = getSimpleCache(); - // read data back - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); + // Cache should be cleared. + assertThat(simpleCache.getKeys()).isEmpty(); + assertNoCacheFiles(cacheDir); } @Test - public void reloadCacheWithoutRelease() throws Exception { + public void write_oneLock_oneFile_thenRead() throws Exception { SimpleCache simpleCache = getSimpleCache(); - // Write data for KEY_1. - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(holeSpan.isCached).isFalse(); + assertThat(holeSpan.isOpenEnded()).isTrue(); addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - // Write and remove data for KEY_2. - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_2, 0); + + CacheSpan readSpan = simpleCache.startReadWrite(KEY_1, 0, LENGTH_UNSET); + assertThat(readSpan.position).isEqualTo(0); + assertThat(readSpan.length).isEqualTo(15); + assertCachedDataReadCorrect(readSpan); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); + + simpleCache.releaseHoleSpan(holeSpan); + } + + @Test + public void write_oneLock_twoFiles_thenRead() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, 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 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); - 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); + CacheSpan readSpan = simpleCache.startReadWrite(KEY_2, 0, LENGTH_UNSET); + assertThat(readSpan.length).isEqualTo(15); + assertCachedDataReadCorrect(readSpan); + assertThat(simpleCache.getCacheSpace()).isEqualTo(15); - // Reload the cache from its new location. - simpleCache = new SimpleCache(cacheDir2, new NoOpCacheEvictor()); + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); + } + + @Test + public void write_oneLock_fileExceedsLock_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); - // Read data back for KEY_1. - CacheSpan cacheSpan3 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan3); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 0, 10); - // Check the entry for KEY_2 was removed when the cache was reloaded. - assertThat(simpleCache.getCachedSpans(KEY_2)).isEmpty(); + assertThrows(IllegalStateException.class, () -> addCache(simpleCache, KEY_1, 0, 11)); - Util.recursiveDelete(cacheDir2); + simpleCache.releaseHoleSpan(holeSpan); } @Test - public void encryptedIndex() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); + public void write_twoLocks_oneFileSpanningBothLocks_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); + CacheSpan holeSpan1 = simpleCache.startReadWrite(KEY_1, 0, 7); + CacheSpan holeSpan2 = simpleCache.startReadWrite(KEY_1, 7, 8); - // Reload cache - simpleCache = getEncryptedSimpleCache(key); + assertThrows(IllegalStateException.class, () -> addCache(simpleCache, KEY_1, 0, 15)); - // read data back - CacheSpan cacheSpan2 = simpleCache.startReadWrite(KEY_1, 0); - assertCachedDataReadCorrect(cacheSpan2); + simpleCache.releaseHoleSpan(holeSpan1); + simpleCache.releaseHoleSpan(holeSpan2); } @Test - public void encryptedIndexWrongKey() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); + public void write_unboundedRangeLocked_lockingOverlappingRange_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 50, LENGTH_UNSET); - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); + // 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(); - // Reload cache - byte[] key2 = Util.getUtf8Bytes("Foo12345Foo12345"); // 128 bit key - simpleCache = getEncryptedSimpleCache(key2); + simpleCache.releaseHoleSpan(holeSpan); + } - // Cache should be cleared - assertThat(simpleCache.getKeys()).isEmpty(); - assertNoCacheFiles(cacheDir); + @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 encryptedIndexLostKey() throws Exception { - byte[] key = Util.getUtf8Bytes("Bar12345Bar12345"); // 128 bit key - SimpleCache simpleCache = getEncryptedSimpleCache(key); + public void write_boundedRangeLocked_lockingOverlappingRange_fails() throws Exception { + SimpleCache simpleCache = getSimpleCache(); + CacheSpan holeSpan = simpleCache.startReadWrite(KEY_1, 50, 50); - // write data - CacheSpan cacheSpan1 = simpleCache.startReadWrite(KEY_1, 0); - addCache(simpleCache, KEY_1, 0, 15); - simpleCache.releaseHoleSpan(cacheSpan1); - simpleCache.release(); + // 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(); - // Reload cache - simpleCache = getSimpleCache(); + simpleCache.releaseHoleSpan(holeSpan); + } - // Cache should be cleared - assertThat(simpleCache.getKeys()).isEmpty(); - assertNoCacheFiles(cacheDir); + @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 getCachedLength() throws Exception { + public void applyContentMetadataMutations_setsContentLength() throws Exception { SimpleCache simpleCache = getSimpleCache(); - CacheSpan cacheSpan = simpleCache.startReadWrite(KEY_1, 0); + assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) + .isEqualTo(LENGTH_UNSET); - // No cached bytes, returns -'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(-100); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setContentLength(mutations, 15); + simpleCache.applyContentMetadataMutations(KEY_1, mutations); + assertThat(ContentMetadata.getContentLength(simpleCache.getContentMetadata(KEY_1))) + .isEqualTo(15); + } - // Position value doesn't affect the return value - assertThat(simpleCache.getCachedLength(KEY_1, 20, 100)).isEqualTo(-100); + @Test + 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); + } - addCache(simpleCache, KEY_1, 0, 15); + @Test + public void getCachedLength_noCachedContent_returnsNegativeMaxHoleLength() { + SimpleCache simpleCache = getSimpleCache(); - // Returns the length of a single span - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(15); + 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); + } - // Value is capped by the 'length' - assertThat(simpleCache.getCachedLength(KEY_1, 0, 10)).isEqualTo(10); + @Test + 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); + } - addCache(simpleCache, KEY_1, 15, 35); + @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); + } - // Returns the length of two adjacent spans - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); + @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); + } - addCache(simpleCache, KEY_1, 60, 10); + @Test + public void getCachedBytes_noCachedContent_returnsZero() { + SimpleCache simpleCache = getSimpleCache(); - // Not adjacent span doesn't affect return value - assertThat(simpleCache.getCachedLength(KEY_1, 0, 100)).isEqualTo(50); + 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); + } - // Returns length of hole up to the next cached span - assertThat(simpleCache.getCachedLength(KEY_1, 55, 100)).isEqualTo(-5); + @Test + public void getCachedBytes_withMultipleAdjacentSpans_returnsCachedBytes() 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.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); + } - simpleCache.releaseHoleSpan(cacheSpan); + @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); + + 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 void exceptionDuringEvictionByLeastRecentlyUsedCacheEvictorNotHang() thro 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 void exceptionDuringEvictionByLeastRecentlyUsedCacheEvictorNotHang() thro .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. - } - - simpleCache.releaseHoleSpan(cacheSpan); + // Adding more content should evict previous content. + assertThrows(CacheException.class, () -> addCache(simpleCache, KEY_1, 15, 15)); + simpleCache.releaseHoleSpan(holeSpan); - // 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); - } - - @Test - public void usingReleasedSimpleCacheThrowsException() throws Exception { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); - simpleCache.release(); - - try { - simpleCache.startReadWriteNonBlocking(KEY_1, 0); - assertWithMessage("Exception was expected").fail(); - } catch (RuntimeException e) { - // Expected. Do nothing. - } - } - - @Test - public void multipleSimpleCacheWithSameCacheDirThrowsException() throws Exception { - new SimpleCache(cacheDir, new NoOpCacheEvictor()); - - try { - new SimpleCache(cacheDir, new NoOpCacheEvictor()); - assertWithMessage("Exception was expected").fail(); - } catch (IllegalStateException e) { - // Expected. Do nothing. - } + CacheSpan fileSpan = cachedSpans.first(); + assertThat(fileSpan.position).isEqualTo(15); + assertThat(fileSpan.length).isEqualTo(15); } @Test - public void multipleSimpleCacheWithSameCacheDirDoesNotThrowsExceptionAfterRelease() - throws Exception { - SimpleCache simpleCache = new SimpleCache(cacheDir, new NoOpCacheEvictor()); + public void usingReleasedCache_throwsException() { + SimpleCache simpleCache = getSimpleCache(); 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 @@ private static void assertNoCacheFiles(File dir) { 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 17e69db26bc..9af0710e9ba 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 void aligned() { 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 void aligned() { 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 void unAligned() { 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 void unAligned() { 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 void midJoin() { 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 void midJoin() { // 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 new file mode 100644 index 00000000000..e7e0d8911a2 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ConditionVariableTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.util; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ConditionVariableTest}. */ +@RunWith(AndroidJUnit4.class) +public class ConditionVariableTest { + + @Test + public void initialState_isClosed() { + ConditionVariable conditionVariable = buildTestConditionVariable(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void blockWithTimeout_timesOut() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + assertThat(conditionVariable.block(1)).isFalse(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void blockWithTimeout_blocksForAtLeastTimeout() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + long startTimeMs = System.currentTimeMillis(); + assertThat(conditionVariable.block(/* timeoutMs= */ 500)).isFalse(); + long endTimeMs = System.currentTimeMillis(); + assertThat(endTimeMs - startTimeMs).isAtLeast(500); + } + + @Test + public void blockWithMaxTimeout_blocks_thenThrowsWhenInterrupted() throws InterruptedException { + ConditionVariable conditionVariable = buildTestConditionVariable(); + + AtomicBoolean blockReturned = new AtomicBoolean(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(/* timeoutMs= */ Long.MAX_VALUE); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + blockingThread.interrupt(); + blockingThread.join(); + assertThat(blockWasInterrupted.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isFalse(); + } + + @Test + public void 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(); + AtomicBoolean blockWasInterrupted = new AtomicBoolean(); + Thread blockingThread = + new Thread( + () -> { + try { + conditionVariable.block(); + blockReturned.set(true); + } catch (InterruptedException e) { + blockWasInterrupted.set(true); + } + }); + + blockingThread.start(); + Thread.sleep(500); + assertThat(blockReturned.get()).isFalse(); + + conditionVariable.open(); + blockingThread.join(); + assertThat(blockReturned.get()).isTrue(); + assertThat(conditionVariable.isOpen()).isTrue(); + } + + @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() { + @Override + public long elapsedRealtime() { + // elapsedRealtime() does not advance during Robolectric test execution, so use + // currentTimeMillis() instead. This is technically unsafe because this clock is not + // guaranteed to be monotonic, but in practice it will work provided the clock of the + // host machine does not change during test execution. + return Clock.DEFAULT.currentTimeMillis(); + } + }); + } +} diff --git a/library/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 5e5a6be7c69..00000000000 --- a/library/core/src/test/java/com/google/android/exoplayer2/util/MediaSourceEventDispatcherTest.java +++ /dev/null @@ -1,222 +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( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysLoaded(), - DrmSessionEventListener.class); - - verify(mediaAndDrmEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - verify(mediaAndDrmEventListener).onDrmKeysLoaded(); - } - - // 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( - (listener, windowIndex, mediaPeriodId) -> listener.onDrmKeysLoaded(), - DrmSessionEventListener.class); - - verify(mediaAndDrmEventListener).onMediaPeriodCreated(WINDOW_INDEX, MEDIA_PERIOD_ID); - verify(mediaAndDrmEventListener, never()).onDrmKeysLoaded(); - } - - @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 00000000000..9a8aac30201 --- /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 8f1949f96e6..0334027234e 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 void setUp() throws Exception { 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 void timeDiscontinuityClearsValues() { 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 void timeDiscontinuityOnFullBufferClearsValues() { 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 f4aee42f256..57cc7cb9b0a 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.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.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 void setUp() { 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 @@ protected void renderOutputBufferToSurface( @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 @@ protected void onQueueInputBuffer(VideoDecoderInputBuffer buffer) { 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 @@ protected DecoderException decode( VideoDecoderOutputBuffer outputBuffer, boolean reset) { outputBuffer.init(inputBuffer.timeUs, outputMode, /* supplementalData= */ null); - synchronized (pendingDecodeCallLock) { - pendingDecodeCalls--; - pendingDecodeCallLock.notify(); - } return null; } @@ -178,15 +170,25 @@ public String getName() { 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 void enable_withMayRenderStartOfStream_rendersFirstFrameBeforeStart() thr /* 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 void enable_withoutMayRenderStartOfStream_doesNotRenderFirstFrameBeforeSt 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 void enable_withoutMayRenderStartOfStream_doesNotRenderFirstFrameBeforeSt /* 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 void enable_withoutMayRenderStartOfStream_doesNotRenderFirstFrameBeforeSt 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 void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() t /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ false, + /* startPositionUs= */ 0, /* offsetUs */ 0); renderer.start(); for (int i = 0; i < 10; i++) { @@ -267,20 +272,20 @@ public void enable_withoutMayRenderStartOfStream_rendersFirstFrameAfterStart() t 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 void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exce /* positionUs= */ 0, /* joining= */ false, /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, /* offsetUs */ 0); renderer.start(); @@ -295,7 +301,11 @@ public void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exce 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 void replaceStream_whenStarted_rendersFirstFrameOfNewStream() throws Exce 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 void replaceStream_whenNotStarted_doesNotRenderFirstFrameOfNewStream() th /* 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 00000000000..4ba5eb34b1f --- /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 b9559816d70..8cabd85fadc 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 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 @@ private static void testSubMesh(Projection.Mesh leftMesh) { 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 1076716684c..2ae77c41aad 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 0ffbc718f06..e6cb20d9334 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 2f2cc26623c..81d72b61f3c 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,19 +15,23 @@ */ 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; 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.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; @@ -50,6 +54,7 @@ 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; @@ -68,7 +73,11 @@ 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; @@ -84,7 +93,8 @@ 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; @@ -93,7 +103,6 @@ private DashManifest manifest; private int periodIndex; private List eventStreams; - private boolean notifiedReadingStarted; public DashMediaPeriod( int id, @@ -102,8 +111,9 @@ public DashMediaPeriod( DashChunkSource.Factory chunkSourceFactory, @Nullable TransferListener transferListener, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, - EventDispatcher eventDispatcher, + EventDispatcher mediaSourceEventDispatcher, long elapsedRealtimeOffsetMs, LoaderErrorThrower manifestLoaderErrorThrower, Allocator allocator, @@ -115,8 +125,9 @@ public DashMediaPeriod( 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; @@ -133,7 +144,6 @@ public DashMediaPeriod( buildTrackGroups(drmSessionManager, period.adaptationSets, eventStreams); trackGroups = result.first; trackGroupInfos = result.second; - eventDispatcher.mediaPeriodCreated(); } /** @@ -172,7 +182,6 @@ public void release() { sampleStream.release(this); } callback = null; - eventDispatcher.mediaPeriodReleased(); } // ChunkSampleStream.ReleaseCallback implementation. @@ -309,10 +318,6 @@ public long getNextLoadPositionUs() { @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } @@ -487,14 +492,14 @@ private static Pair buildTrackGroups( 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]; @@ -507,7 +512,7 @@ private static Pair buildTrackGroups( groupedAdaptationSetIndices, primaryGroupCount, primaryGroupHasEventMessageTrackFlags, - primaryGroupCea608TrackFormats, + primaryGroupClosedCaptionTrackFormats, trackGroups, trackGroupInfos); @@ -516,51 +521,94 @@ private static Pair buildTrackGroups( return Pair.create(new TrackGroupArray(trackGroups), trackGroupInfos); } + /** + * Groups adaptation sets. Two adaptations sets belong to the same group if either: + * + *
        + *
      • One is a trick-play adaptation set and uses a {@code + * http://dashif.org/guidelines/trickmode} essential or supplemental property to indicate + * that the other is the main adaptation set to which it corresponds. + *
      • The two adaptation sets are marked as safe for switching using {@code + * urn:mpeg:dash:adaptation-set-switching:2016} supplemental properties. + *
      + * + * @param adaptationSets The adaptation sets to merge. + * @return An array of groups, where each group is an array of adaptation set indices. + */ private static int[][] getGroupedAdaptationSetIndices(List adaptationSets) { int adaptationSetCount = adaptationSets.size(); - SparseIntArray idToIndexMap = new SparseIntArray(adaptationSetCount); + SparseIntArray adaptationSetIdToIndex = new SparseIntArray(adaptationSetCount); + List> adaptationSetGroupedIndices = new ArrayList<>(adaptationSetCount); + SparseArray> adaptationSetIndexToGroupedIndices = + new SparseArray<>(adaptationSetCount); + + // Initially make each adaptation set belong to its own group. Also build the + // adaptationSetIdToIndex map. for (int i = 0; i < adaptationSetCount; i++) { - idToIndexMap.put(adaptationSets.get(i).id, i); + adaptationSetIdToIndex.put(adaptationSets.get(i).id, i); + List initialGroup = new ArrayList<>(); + initialGroup.add(i); + adaptationSetGroupedIndices.add(initialGroup); + adaptationSetIndexToGroupedIndices.put(i, initialGroup); } - int[][] groupedAdaptationSetIndices = new int[adaptationSetCount][]; - boolean[] adaptationSetUsedFlags = new boolean[adaptationSetCount]; - - int groupCount = 0; + // Merge adaptation set groups. for (int i = 0; i < adaptationSetCount; i++) { - if (adaptationSetUsedFlags[i]) { - // This adaptation set has already been included in a group. - continue; - } - adaptationSetUsedFlags[i] = true; + int mergedGroupIndex = i; + AdaptationSet adaptationSet = adaptationSets.get(i); + + // Trick-play adaptation sets are merged with their corresponding main adaptation sets. @Nullable - Descriptor adaptationSetSwitchingProperty = - findAdaptationSetSwitchingProperty(adaptationSets.get(i).supplementalProperties); - if (adaptationSetSwitchingProperty == null) { - groupedAdaptationSetIndices[groupCount++] = new int[] {i}; - } else { - String[] extraAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ","); - int[] adaptationSetIndices = new int[1 + extraAdaptationSetIds.length]; - adaptationSetIndices[0] = i; - int outputIndex = 1; - for (String adaptationSetId : extraAdaptationSetIds) { - int extraIndex = - idToIndexMap.get(Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1); - if (extraIndex != -1) { - adaptationSetUsedFlags[extraIndex] = true; - adaptationSetIndices[outputIndex] = extraIndex; - outputIndex++; - } + Descriptor trickPlayProperty = findTrickPlayProperty(adaptationSet.essentialProperties); + if (trickPlayProperty == null) { + // Trick-play can also be specified using a supplemental property. + trickPlayProperty = findTrickPlayProperty(adaptationSet.supplementalProperties); + } + if (trickPlayProperty != null) { + int mainAdaptationSetId = Integer.parseInt(trickPlayProperty.value); + int mainAdaptationSetIndex = + adaptationSetIdToIndex.get(mainAdaptationSetId, /* valueIfKeyNotFound= */ -1); + if (mainAdaptationSetIndex != -1) { + mergedGroupIndex = mainAdaptationSetIndex; } - if (outputIndex < adaptationSetIndices.length) { - adaptationSetIndices = Arrays.copyOf(adaptationSetIndices, outputIndex); + } + + // Adaptation sets that are safe for switching are merged, using the smallest index for the + // merged group. + if (mergedGroupIndex == i) { + @Nullable + Descriptor adaptationSetSwitchingProperty = + findAdaptationSetSwitchingProperty(adaptationSet.supplementalProperties); + if (adaptationSetSwitchingProperty != null) { + String[] otherAdaptationSetIds = Util.split(adaptationSetSwitchingProperty.value, ","); + for (String adaptationSetId : otherAdaptationSetIds) { + int otherAdaptationSetId = + adaptationSetIdToIndex.get( + Integer.parseInt(adaptationSetId), /* valueIfKeyNotFound= */ -1); + if (otherAdaptationSetId != -1) { + mergedGroupIndex = min(mergedGroupIndex, otherAdaptationSetId); + } + } } - groupedAdaptationSetIndices[groupCount++] = adaptationSetIndices; + } + + // Merge the groups if necessary. + if (mergedGroupIndex != i) { + List thisGroup = adaptationSetIndexToGroupedIndices.get(i); + List mergedGroup = adaptationSetIndexToGroupedIndices.get(mergedGroupIndex); + mergedGroup.addAll(thisGroup); + adaptationSetIndexToGroupedIndices.put(i, mergedGroup); + adaptationSetGroupedIndices.remove(thisGroup); } } - return groupCount < adaptationSetCount - ? Arrays.copyOf(groupedAdaptationSetIndices, groupCount) : groupedAdaptationSetIndices; + int[][] groupedAdaptationSetIndices = new int[adaptationSetGroupedIndices.size()][]; + for (int i = 0; i < groupedAdaptationSetIndices.length; i++) { + groupedAdaptationSetIndices[i] = Ints.toArray(adaptationSetGroupedIndices.get(i)); + // Restore the original adaptation set order within each group. + Arrays.sort(groupedAdaptationSetIndices[i]); + } + return groupedAdaptationSetIndices; } /** @@ -572,8 +620,8 @@ private static int[][] getGroupedAdaptationSetIndices(List adapta * 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( @@ -581,16 +629,16 @@ private static int identifyEmbeddedTracks( 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++; } } @@ -603,7 +651,7 @@ private static int buildPrimaryAndEmbeddedTrackGroupInfos( int[][] groupedAdaptationSetIndices, int primaryGroupCount, boolean[] primaryGroupHasEventMessageTrackFlags, - Format[][] primaryGroupCea608TrackFormats, + Format[][] primaryGroupClosedCaptionTrackFormats, TrackGroup[] trackGroups, TrackGroupInfo[] trackGroupInfos) { int trackGroupCount = 0; @@ -616,21 +664,16 @@ private static int buildPrimaryAndEmbeddedTrackGroupInfos( 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] = @@ -639,7 +682,7 @@ private static int buildPrimaryAndEmbeddedTrackGroupInfos( adaptationSetIndices, primaryTrackGroupIndex, eventMessageTrackGroupIndex, - cea608TrackGroupIndex); + closedCaptionTrackGroupIndex); if (eventMessageTrackGroupIndex != C.INDEX_UNSET) { Format format = new Format.Builder() @@ -650,10 +693,11 @@ private static int buildPrimaryAndEmbeddedTrackGroupInfos( 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; @@ -684,11 +728,13 @@ private ChunkSampleStream buildSampleStream(TrackGroupInfo trac 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]; @@ -699,12 +745,12 @@ private ChunkSampleStream buildSampleStream(TrackGroupInfo trac 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++; } } @@ -723,7 +769,7 @@ private ChunkSampleStream buildSampleStream(TrackGroupInfo trac trackGroupInfo.trackType, elapsedRealtimeOffsetMs, enableEventMessageTrack, - embeddedCea608TrackFormats, + embeddedClosedCaptionTrackFormats, trackPlayerEmsgHandler, transferListener); ChunkSampleStream stream = @@ -736,8 +782,9 @@ private ChunkSampleStream buildSampleStream(TrackGroupInfo trac 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); @@ -747,9 +794,19 @@ private ChunkSampleStream buildSampleStream(TrackGroupInfo trac @Nullable private static Descriptor findAdaptationSetSwitchingProperty(List descriptors) { + return findDescriptor(descriptors, "urn:mpeg:dash:adaptation-set-switching:2016"); + } + + @Nullable + private static Descriptor findTrickPlayProperty(List descriptors) { + return findDescriptor(descriptors, "http://dashif.org/guidelines/trickmode"); + } + + @Nullable + private static Descriptor findDescriptor(List descriptors, String schemeIdUri) { for (int i = 0; i < descriptors.size(); i++) { Descriptor descriptor = descriptors.get(i); - if ("urn:mpeg:dash:adaptation-set-switching:2016".equals(descriptor.schemeIdUri)) { + if (schemeIdUri.equals(descriptor.schemeIdUri)) { return descriptor; } } @@ -770,7 +827,7 @@ private static boolean hasEventMessageTrack(List adaptationSets, return false; } - private static Format[] getCea608TrackFormats( + private static Format[] getClosedCaptionTrackFormats( List adaptationSets, int[] adaptationSetIndices) { for (int i : adaptationSetIndices) { AdaptationSet adaptationSet = adaptationSets.get(i); @@ -778,49 +835,52 @@ private static Format[] getCea608TrackFormats( 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. @@ -862,21 +922,21 @@ private static final class TrackGroupInfo { 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); } @@ -892,8 +952,8 @@ public static TrackGroupInfo embeddedEmsgTrack(int[] adaptationSetIndices, /* 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, @@ -921,14 +981,14 @@ private TrackGroupInfo( 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 919997e0416..bd6824e5599 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -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,15 +30,18 @@ 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; import com.google.android.exoplayer2.source.BaseMediaSource; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory; +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; @@ -43,11 +50,15 @@ import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; +import com.google.android.exoplayer2.source.dash.manifest.Period; +import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataSource; 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; import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; import com.google.android.exoplayer2.upstream.LoaderErrorThrower; @@ -55,13 +66,16 @@ 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 com.google.common.math.LongMath; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.nio.charset.Charset; +import java.math.RoundingMode; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Collections; @@ -82,9 +96,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; @@ -115,9 +130,9 @@ public Factory(DataSource.Factory dataSourceFactory) { 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(); @@ -146,19 +161,22 @@ public Factory setStreamKeys(@Nullable List streamKeys) { 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; } @@ -257,22 +275,56 @@ public Factory setCompositeSequenceableLoaderFactory( * @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); } /** @@ -292,9 +344,10 @@ public DashMediaSource createMediaSource( } /** - * @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, @@ -312,7 +365,12 @@ public DashMediaSource createMediaSource( @Deprecated @Override public DashMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setSourceUri(uri).build()); + return createMediaSource( + new MediaItem.Builder() + .setUri(uri) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(tag) + .build()); } /** @@ -324,30 +382,40 @@ public DashMediaSource createMediaSource(Uri uri) { */ @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.sourceUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, compositeSequenceableLoaderFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, livePresentationDelayMs, - livePresentationDelayOverridesManifest, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + livePresentationDelayOverridesManifest); } @Override @@ -360,7 +428,7 @@ public int[] getSupportedTypes() { * 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 = @@ -368,16 +436,20 @@ public int[] getSupportedTypes() { /** @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 * Timeline} is changing dynamically (for example, for incomplete live streams). */ - private static final int NOTIFY_MANIFEST_INTERVAL_MS = 5000; + private static final long DEFAULT_NOTIFY_MANIFEST_INTERVAL_MS = 5000; /** * The minimum default start position for live streams, relative to the start of the live window. */ - 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"; @@ -398,7 +470,8 @@ public int[] getSupportedTypes() { 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; @@ -407,8 +480,8 @@ public int[] getSupportedTypes() { private IOException manifestFatalError; private Handler handler; - private Uri initialManifestUri; private Uri manifestUri; + private Uri initialManifestUri; private DashManifest manifest; private boolean manifestLoadPending; private long manifestLoadStartTimestampMs; @@ -420,15 +493,7 @@ public int[] getSupportedTypes() { 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( @@ -444,16 +509,7 @@ public DashMediaSource( 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, @@ -462,8 +518,12 @@ public DashMediaSource( @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, @@ -471,25 +531,13 @@ public DashMediaSource( 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( @@ -508,23 +556,7 @@ public DashMediaSource( 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( @@ -546,24 +578,7 @@ public DashMediaSource( 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( @@ -576,8 +591,8 @@ public DashMediaSource( @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { this( + new MediaItem.Builder().setUri(manifestUri).setMimeType(MimeTypes.APPLICATION_MPD).build(), /* manifest= */ null, - manifestUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, @@ -587,16 +602,15 @@ public DashMediaSource( 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, @@ -604,11 +618,12 @@ private DashMediaSource( 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; @@ -617,7 +632,6 @@ private DashMediaSource( this.livePresentationDelayMs = livePresentationDelayMs; this.livePresentationDelayOverridesManifest = livePresentationDelayOverridesManifest; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; - this.tag = tag; sideloadedManifest = manifest != null; manifestEventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); manifestUriLock = new Object(); @@ -653,10 +667,20 @@ public void replaceManifestUri(Uri manifestUri) { // 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 @@ -668,7 +692,7 @@ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferLis } else { dataSource = manifestDataSourceFactory.createDataSource(); loader = new Loader("Loader:DashMediaSource"); - handler = Util.createHandler(); + handler = Util.createHandlerForCurrentLooper(); startLoadingManifest(); } } @@ -682,8 +706,9 @@ public void maybeThrowSourceInfoRefreshError() throws IOException { 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, @@ -692,6 +717,7 @@ public MediaPeriod createPeriod( chunkSourceFactory, mediaTransferListener, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, periodEventDispatcher, elapsedRealtimeOffsetMs, @@ -753,14 +779,17 @@ protected void releaseSourceInternal() { /* package */ void onManifestLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { - manifestEventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + manifestEventDispatcher.loadCompleted(loadEventInfo, loadable.type); DashManifest newManifest = loadable.getResult(); int oldPeriodCount = manifest == null ? 0 : manifest.getPeriodCount(); @@ -848,36 +877,44 @@ protected void releaseSourceInternal() { long loadDurationMs, IOException error, int errorCount) { - long retryDelayMs = - loadErrorHandlingPolicy.getRetryDelayMsFor( - C.DATA_TYPE_MANIFEST, loadDurationMs, error, errorCount); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + MediaLoadData mediaLoadData = new MediaLoadData(loadable.type); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); + long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo); LoadErrorAction loadErrorAction = retryDelayMs == C.TIME_UNSET ? Loader.DONT_RETRY_FATAL : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs); - manifestEventDispatcher.loadError( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded(), - error, - !loadErrorAction.isRetry()); + boolean wasCanceled = !loadErrorAction.isRetry(); + manifestEventDispatcher.loadError(loadEventInfo, loadable.type, error, wasCanceled); + if (wasCanceled) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } return loadErrorAction; } /* package */ void onUtcTimestampLoadCompleted(ParsingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { - manifestEventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + manifestEventDispatcher.loadCompleted(loadEventInfo, loadable.type); onUtcTimestampResolved(loadable.getResult() - elapsedRealtimeMs); } @@ -887,29 +924,35 @@ protected void releaseSourceInternal() { long loadDurationMs, IOException error) { manifestEventDispatcher.loadError( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()), loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded(), error, - true); + /* wasCanceled= */ true); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); onUtcTimestampResolutionError(error); return Loader.DONT_RETRY; } /* package */ void onLoadCanceled(ParsingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { - manifestEventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + manifestEventDispatcher.loadCanceled(loadEventInfo, loadable.type); } // Internal methods. @@ -989,21 +1032,22 @@ private void processManifest(boolean scheduleRefresh) { // Update the window. boolean windowChangingImplicitly = false; int lastPeriodIndex = manifest.getPeriodCount() - 1; - PeriodSeekInfo firstPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo(manifest.getPeriod(0), - manifest.getPeriodDurationUs(0)); - PeriodSeekInfo lastPeriodSeekInfo = PeriodSeekInfo.createPeriodSeekInfo( - manifest.getPeriod(lastPeriodIndex), manifest.getPeriodDurationUs(lastPeriodIndex)); + Period lastPeriod = manifest.getPeriod(lastPeriodIndex); + long lastPeriodDurationUs = manifest.getPeriodDurationUs(lastPeriodIndex); + long nowUnixTimeUs = C.msToUs(Util.getNowUnixTimeMs(elapsedRealtimeOffsetMs)); + PeriodSeekInfo firstPeriodSeekInfo = + PeriodSeekInfo.createPeriodSeekInfo( + manifest.getPeriod(0), manifest.getPeriodDurationUs(0), nowUnixTimeUs); + PeriodSeekInfo lastPeriodSeekInfo = + PeriodSeekInfo.createPeriodSeekInfo(lastPeriod, lastPeriodDurationUs, nowUnixTimeUs); // Get the period-relative start/end times. long currentStartTimeUs = firstPeriodSeekInfo.availableStartTimeUs; long currentEndTimeUs = lastPeriodSeekInfo.availableEndTimeUs; if (manifest.dynamic && !lastPeriodSeekInfo.isIndexExplicit) { // The manifest describes an incomplete live stream. Update the start/end times to reflect the // live stream duration and the manifest's time shift buffer depth. - long nowUnixTimeUs = C.msToUs(Util.getNowUnixTimeMs(elapsedRealtimeOffsetMs)); - long liveStreamDurationUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs); - long liveStreamEndPositionInLastPeriodUs = liveStreamDurationUs - - C.msToUs(manifest.getPeriod(lastPeriodIndex).startMs); - currentEndTimeUs = Math.min(liveStreamEndPositionInLastPeriodUs, currentEndTimeUs); + long liveStreamEndPositionInLastPeriodUs = currentEndTimeUs - C.msToUs(lastPeriod.startMs); + currentEndTimeUs = min(liveStreamEndPositionInLastPeriodUs, currentEndTimeUs); if (manifest.timeShiftBufferDepthMs != C.TIME_UNSET) { long timeShiftBufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs); long offsetInPeriodUs = currentEndTimeUs - timeShiftBufferDepthUs; @@ -1012,7 +1056,7 @@ private void processManifest(boolean scheduleRefresh) { 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? @@ -1038,8 +1082,8 @@ private void processManifest(boolean scheduleRefresh) { // 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; @@ -1055,11 +1099,11 @@ private void processManifest(boolean scheduleRefresh) { windowStartTimeMs, elapsedRealtimeOffsetMs, firstPeriodId, - currentStartTimeUs, + /* offsetInFirstPeriodUs= */ currentStartTimeUs, windowDurationUs, windowDefaultStartPositionUs, manifest, - tag); + mediaItem); refreshSourceInfo(timeline); if (!sideloadedManifest) { @@ -1067,7 +1111,10 @@ private void processManifest(boolean scheduleRefresh) { handler.removeCallbacks(simulateManifestRefreshRunnable); // If the window is changing implicitly, post a simulated manifest refresh to update it. if (windowChangingImplicitly) { - handler.postDelayed(simulateManifestRefreshRunnable, NOTIFY_MANIFEST_INTERVAL_MS); + handler.postDelayed( + simulateManifestRefreshRunnable, + getIntervalUntilNextManifestRefreshMs( + manifest, Util.getNowUnixTimeMs(elapsedRealtimeOffsetMs))); } if (manifestLoadPending) { startLoadingManifest(); @@ -1084,8 +1131,7 @@ private void processManifest(boolean scheduleRefresh) { minUpdatePeriodMs = 5000; } long nextLoadTimestampMs = manifestLoadStartTimestampMs + minUpdatePeriodMs; - long delayUntilNextLoadMs = - Math.max(0, nextLoadTimestampMs - SystemClock.elapsedRealtime()); + long delayUntilNextLoadMs = max(0, nextLoadTimestampMs - SystemClock.elapsedRealtime()); scheduleManifestRefresh(delayUntilNextLoadMs); } } @@ -1116,19 +1162,53 @@ private void startLoadingManifest() { } private long getManifestLoadRetryDelayMillis() { - return Math.min((staleManifestReloadAttempt - 1) * 1000, 5000); + return min((staleManifestReloadAttempt - 1) * 1000, 5000); } private void startLoading(ParsingLoadable loadable, Loader.Callback> callback, int minRetryCount) { long elapsedRealtimeMs = loader.startLoading(loadable, callback, minRetryCount); - manifestEventDispatcher.loadStarted(loadable.dataSpec, loadable.type, elapsedRealtimeMs); + manifestEventDispatcher.loadStarted( + new LoadEventInfo(loadable.loadTaskId, loadable.dataSpec, elapsedRealtimeMs), + loadable.type); + } + + private static long getIntervalUntilNextManifestRefreshMs( + DashManifest manifest, long nowUnixTimeMs) { + int periodIndex = manifest.getPeriodCount() - 1; + Period period = manifest.getPeriod(periodIndex); + long periodStartUs = C.msToUs(period.startMs); + long periodDurationUs = manifest.getPeriodDurationUs(periodIndex); + long nowUnixTimeUs = C.msToUs(nowUnixTimeMs); + long availabilityStartTimeUs = C.msToUs(manifest.availabilityStartTimeMs); + long intervalUs = C.msToUs(DEFAULT_NOTIFY_MANIFEST_INTERVAL_MS); + for (int i = 0; i < period.adaptationSets.size(); i++) { + List representations = period.adaptationSets.get(i).representations; + if (representations.isEmpty()) { + continue; + } + @Nullable DashSegmentIndex index = representations.get(0).getIndex(); + if (index != null) { + long nextSegmentShiftUnixTimeUs = + availabilityStartTimeUs + + periodStartUs + + index.getNextSegmentAvailableTimeUs(periodDurationUs, nowUnixTimeUs); + long requiredIntervalUs = nextSegmentShiftUnixTimeUs - nowUnixTimeUs; + // Avoid multiple refreshes within a very small amount of time. + if (requiredIntervalUs < intervalUs - 100_000 + || (requiredIntervalUs > intervalUs && requiredIntervalUs < intervalUs + 100_000)) { + intervalUs = requiredIntervalUs; + } + } + } + // Round up to compensate for a potential loss in the us to ms conversion. + return LongMath.divide(intervalUs, 1000, RoundingMode.CEILING); } private static final class PeriodSeekInfo { public static PeriodSeekInfo createPeriodSeekInfo( - com.google.android.exoplayer2.source.dash.manifest.Period period, long durationUs) { + Period period, long periodDurationUs, long nowUnixTimeUs) { int adaptationSetCount = period.adaptationSets.size(); long availableStartTimeUs = 0; long availableEndTimeUs = Long.MAX_VALUE; @@ -1146,32 +1226,37 @@ public static PeriodSeekInfo createPeriodSeekInfo( for (int i = 0; i < adaptationSetCount; i++) { AdaptationSet adaptationSet = period.adaptationSets.get(i); + List representations = adaptationSet.representations; // Exclude text adaptation sets from duration calculations, if we have at least one audio // or video adaptation set. See: https://github.com/google/ExoPlayer/issues/4029 - if (haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) { + if ((haveAudioVideoAdaptationSets && adaptationSet.type == C.TRACK_TYPE_TEXT) + || representations.isEmpty()) { continue; } - DashSegmentIndex index = adaptationSet.representations.get(0).getIndex(); + @Nullable DashSegmentIndex index = representations.get(0).getIndex(); if (index == null) { - return new PeriodSeekInfo(true, 0, durationUs); + return new PeriodSeekInfo( + /* isIndexExplicit= */ true, + /* availableStartTimeUs= */ 0, + /* availableEndTimeUs= */ periodDurationUs); } isIndexExplicit |= index.isExplicit(); - int segmentCount = index.getSegmentCount(durationUs); - if (segmentCount == 0) { + int availableSegmentCount = index.getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + if (availableSegmentCount == 0) { seenEmptyIndex = true; availableStartTimeUs = 0; availableEndTimeUs = 0; } else if (!seenEmptyIndex) { - long firstSegmentNum = index.getFirstSegmentNum(); - long adaptationSetAvailableStartTimeUs = index.getTimeUs(firstSegmentNum); - availableStartTimeUs = Math.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); - } + long firstAvailableSegmentNum = + index.getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + long adaptationSetAvailableStartTimeUs = index.getTimeUs(firstAvailableSegmentNum); + availableStartTimeUs = max(availableStartTimeUs, adaptationSetAvailableStartTimeUs); + long lastAvailableSegmentNum = firstAvailableSegmentNum + availableSegmentCount - 1; + long adaptationSetAvailableEndTimeUs = + index.getTimeUs(lastAvailableSegmentNum) + + index.getDurationUs(lastAvailableSegmentNum, periodDurationUs); + availableEndTimeUs = min(availableEndTimeUs, adaptationSetAvailableEndTimeUs); } } return new PeriodSeekInfo(isIndexExplicit, availableStartTimeUs, availableEndTimeUs); @@ -1201,7 +1286,7 @@ private static final class DashTimeline extends Timeline { 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, @@ -1212,7 +1297,7 @@ public DashTimeline( long windowDurationUs, long windowDefaultStartPositionUs, DashManifest manifest, - @Nullable Object windowTag) { + MediaItem mediaItem) { this.presentationStartTimeMs = presentationStartTimeMs; this.windowStartTimeMs = windowStartTimeMs; this.elapsedRealtimeEpochOffsetMs = elapsedRealtimeEpochOffsetMs; @@ -1221,7 +1306,7 @@ public DashTimeline( this.windowDurationUs = windowDurationUs; this.windowDefaultStartPositionUs = windowDefaultStartPositionUs; this.manifest = manifest; - this.windowTag = windowTag; + this.mediaItem = mediaItem; } @Override @@ -1251,7 +1336,7 @@ public Window getWindow(int windowIndex, Window window, long defaultPositionProj defaultPositionProjectionUs); return window.set( Window.SINGLE_WINDOW_UID, - windowTag, + mediaItem, manifest, presentationStartTimeMs, windowStartTimeMs, @@ -1420,8 +1505,7 @@ public Long parse(Uri uri, InputStream inputStream) throws IOException { @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/DashSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java index 9d45bc726ee..527ed6ce82b 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashSegmentIndex.java @@ -64,38 +64,66 @@ public interface DashSegmentIndex { */ RangedUri getSegmentUrl(long segmentNum); + /** Returns the segment number of the first defined segment in the index. */ + long getFirstSegmentNum(); + /** - * Returns the segment number of the first segment. + * Returns the segment number of the first available segment in the index. * - * @return The segment number of the first segment. + * @param periodDurationUs The duration of the enclosing period in microseconds, or {@link + * C#TIME_UNSET} if the period's duration is not yet known. + * @param nowUnixTimeUs The current time in milliseconds since the Unix epoch. + * @return The number of the first available segment. */ - long getFirstSegmentNum(); + long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs); /** - * Returns the number of segments in the index, or {@link #INDEX_UNBOUNDED}. - *

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

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

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

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

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

      If false is returned then segment information was derived from properties such as a fixed * segment duration. If the presentation is dynamic, it's possible that only a subset of the * segments are available. * * @return Whether segments are defined explicitly by the index. */ boolean isExplicit(); - } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java index 6d440b96df1..1aee832a37f 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashUtil.java @@ -19,12 +19,12 @@ 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; @@ -50,14 +50,18 @@ public final class DashUtil { * * @param representation The {@link Representation} to which the request belongs. * @param requestUri The {@link RangedUri} of the data to request. + * @param flags Flags to be set on the returned {@link DataSpec}. See {@link + * DataSpec.Builder#setFlags(int)}. * @return The {@link DataSpec}. */ - public static DataSpec buildDataSpec(Representation representation, RangedUri requestUri) { + public static DataSpec buildDataSpec( + Representation representation, RangedUri requestUri, int flags) { return new DataSpec.Builder() .setUri(requestUri.resolveUri(representation.baseUrl)) .setPosition(requestUri.start) .setLength(requestUri.length) .setKey(representation.getCacheKey()) + .setFlags(flags) .build(); } @@ -74,15 +78,15 @@ public static DashManifest loadManifest(DataSource dataSource, Uri uri) throws I } /** - * 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 +100,8 @@ public static DrmInitData loadDrmInitData(DataSource dataSource, Period period) 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 +117,16 @@ public static DrmInitData loadDrmInitData(DataSource dataSource, Period period) @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 +144,80 @@ public static Format loadSampleFormat( @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); + DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri, /* flags= */ 0); + 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/DashWrappingSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java index 3eca7892c45..4c771cdcbf8 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashWrappingSegmentIndex.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.dash; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.extractor.ChunkIndex; import com.google.android.exoplayer2.source.dash.manifest.RangedUri; @@ -41,11 +42,26 @@ public long getFirstSegmentNum() { return 0; } + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + return 0; + } + @Override public int getSegmentCount(long periodDurationUs) { return chunkIndex.length; } + @Override + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + return chunkIndex.length; + } + + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return C.TIME_UNSET; + } + @Override public long getTimeUs(long segmentNum) { return chunkIndex.timesUs[(int) segmentNum] - timeOffsetUs; diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index e03ade2d48c..60161289cc5 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.dash; +import static java.lang.Math.min; + import android.net.Uri; import android.os.SystemClock; import androidx.annotation.CheckResult; @@ -24,15 +26,15 @@ 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 +246,15 @@ public int getPreferredQueueSize(long playbackPositionUs, List queue) { + if (fatalError != null) { + return false; + } + return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, queue); + } + @Override public void getNextChunk( long playbackPositionUs, @@ -276,9 +287,9 @@ public void getNextChunk( chunkIterators[i] = MediaChunkIterator.EMPTY; } else { long firstAvailableSegmentNum = - representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); + representationHolder.getFirstAvailableSegmentNum(nowUnixTimeUs); long lastAvailableSegmentNum = - representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); + representationHolder.getLastAvailableSegmentNum(nowUnixTimeUs); long segmentNum = getSegmentNum( representationHolder, @@ -291,7 +302,7 @@ public void getNextChunk( } else { chunkIterators[i] = new RepresentationSegmentIterator( - representationHolder, segmentNum, lastAvailableSegmentNum); + representationHolder, segmentNum, lastAvailableSegmentNum, nowUnixTimeUs); } } } @@ -302,11 +313,11 @@ public void getNextChunk( 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) { @@ -330,10 +341,8 @@ public void getNextChunk( return; } - long firstAvailableSegmentNum = - representationHolder.getFirstAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); - long lastAvailableSegmentNum = - representationHolder.getLastAvailableSegmentNum(manifest, periodIndex, nowUnixTimeUs); + long firstAvailableSegmentNum = representationHolder.getFirstAvailableSegmentNum(nowUnixTimeUs); + long lastAvailableSegmentNum = representationHolder.getLastAvailableSegmentNum(nowUnixTimeUs); updateLiveEdgeTimeUs(representationHolder, lastAvailableSegmentNum); @@ -363,8 +372,7 @@ public void getNextChunk( 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) @@ -386,7 +394,8 @@ public void getNextChunk( trackSelection.getSelectionData(), segmentNum, maxSegmentCount, - seekTimeUs); + seekTimeUs, + nowUnixTimeUs); } @Override @@ -399,13 +408,12 @@ public void onChunkLoadCompleted(Chunk chunk) { // 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 +424,7 @@ public void onChunkLoadCompleted(Chunk chunk) { @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 +447,18 @@ public boolean onChunkLoadError( } } } - 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. @@ -499,9 +517,14 @@ protected Chunk newInitializationChunk( } else { requestUri = indexUri; } - DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri); - return new InitializationChunk(dataSource, dataSpec, trackFormat, - trackSelectionReason, trackSelectionData, representationHolder.extractorWrapper); + DataSpec dataSpec = DashUtil.buildDataSpec(representation, requestUri, /* flags= */ 0); + return new InitializationChunk( + dataSource, + dataSpec, + trackFormat, + trackSelectionReason, + trackSelectionData, + representationHolder.chunkExtractor); } protected Chunk newMediaChunk( @@ -513,14 +536,19 @@ protected Chunk newMediaChunk( Object trackSelectionData, long firstSegmentNum, int maxSegmentCount, - long seekTimeUs) { + long seekTimeUs, + long nowUnixTimeUs) { Representation representation = representationHolder.representation; long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); String baseUrl = representation.baseUrl; - if (representationHolder.extractorWrapper == null) { + if (representationHolder.chunkExtractor == null) { long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum); - DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri); + int flags = + representationHolder.isSegmentAvailableAtFullNetworkSpeed(firstSegmentNum, nowUnixTimeUs) + ? 0 + : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; + DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri, flags); return new SingleSampleMediaChunk(dataSource, dataSpec, trackFormat, trackSelectionReason, trackSelectionData, startTimeUs, endTimeUs, firstSegmentNum, trackType, trackFormat); } else { @@ -535,13 +563,18 @@ protected Chunk newMediaChunk( segmentUri = mergedSegmentUri; segmentCount++; } - long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum + segmentCount - 1); + long segmentNum = firstSegmentNum + segmentCount - 1; + long endTimeUs = representationHolder.getSegmentEndTimeUs(segmentNum); long periodDurationUs = representationHolder.periodDurationUs; long clippedEndTimeUs = periodDurationUs != C.TIME_UNSET && periodDurationUs <= endTimeUs ? periodDurationUs : C.TIME_UNSET; - DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri); + int flags = + representationHolder.isSegmentAvailableAtFullNetworkSpeed(segmentNum, nowUnixTimeUs) + ? 0 + : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; + DataSpec dataSpec = DashUtil.buildDataSpec(representation, segmentUri, flags); long sampleOffsetUs = -representation.presentationTimeOffsetUs; return new ContainerMediaChunk( dataSource, @@ -556,7 +589,7 @@ protected Chunk newMediaChunk( firstSegmentNum, segmentCount, sampleOffsetUs, - representationHolder.extractorWrapper); + representationHolder.chunkExtractor); } } @@ -566,6 +599,7 @@ protected Chunk newMediaChunk( protected static final class RepresentationSegmentIterator extends BaseMediaChunkIterator { private final RepresentationHolder representationHolder; + private final long currentUnixTimeUs; /** * Creates iterator. @@ -573,20 +607,29 @@ protected static final class RepresentationSegmentIterator extends BaseMediaChun * @param representation The {@link RepresentationHolder} to wrap. * @param firstAvailableSegmentNum The number of the first available segment. * @param lastAvailableSegmentNum The number of the last available segment. + * @param currentUnixTimeUs The current time in microseconds since the epoch used for + * calculating if segments are available at full network speed. */ public RepresentationSegmentIterator( RepresentationHolder representation, long firstAvailableSegmentNum, - long lastAvailableSegmentNum) { + long lastAvailableSegmentNum, + long currentUnixTimeUs) { super(/* fromIndex= */ firstAvailableSegmentNum, /* toIndex= */ lastAvailableSegmentNum); this.representationHolder = representation; + this.currentUnixTimeUs = currentUnixTimeUs; } @Override public DataSpec getDataSpec() { checkInBounds(); - RangedUri segmentUri = representationHolder.getSegmentUrl(getCurrentIndex()); - return DashUtil.buildDataSpec(representationHolder.representation, segmentUri); + long currentIndex = getCurrentIndex(); + RangedUri segmentUri = representationHolder.getSegmentUrl(currentIndex); + int flags = + representationHolder.isSegmentAvailableAtFullNetworkSpeed(currentIndex, currentUnixTimeUs) + ? 0 + : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; + return DashUtil.buildDataSpec(representationHolder.representation, segmentUri, flags); } @Override @@ -605,7 +648,7 @@ public long getChunkEndTimeUs() { /** 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 +666,7 @@ protected static final class RepresentationHolder { this( periodDurationUs, representation, - createExtractorWrapper( + createChunkExtractor( trackType, representation, enableEventMessageTrack, @@ -636,13 +679,13 @@ protected static final class RepresentationHolder { 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 +699,20 @@ private RepresentationHolder( 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,19 +744,24 @@ private RepresentationHolder( - 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() { return segmentIndex.getFirstSegmentNum() + segmentNumShift; } + public long getFirstAvailableSegmentNum(long nowUnixTimeUs) { + return segmentIndex.getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs) + + segmentNumShift; + } + public int getSegmentCount() { return segmentIndex.getSegmentCount(periodDurationUs); } @@ -735,50 +783,25 @@ public RangedUri getSegmentUrl(long segmentNum) { return segmentIndex.getSegmentUrl(segmentNum - segmentNumShift); } - public long getFirstAvailableSegmentNum( - DashManifest manifest, int periodIndex, long nowUnixTimeUs) { - if (getSegmentCount() == DashSegmentIndex.INDEX_UNBOUNDED - && manifest.timeShiftBufferDepthMs != C.TIME_UNSET) { - // The index is itself unbounded. We need to use the current time to calculate the range of - // available segments. - long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs); - long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs); - long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs; - long bufferDepthUs = C.msToUs(manifest.timeShiftBufferDepthMs); - return Math.max( - getFirstSegmentNum(), getSegmentNum(liveEdgeTimeInPeriodUs - bufferDepthUs)); - } - return getFirstSegmentNum(); - } - - public long getLastAvailableSegmentNum( - DashManifest manifest, int periodIndex, long nowUnixTimeUs) { - int availableSegmentCount = getSegmentCount(); - if (availableSegmentCount == DashSegmentIndex.INDEX_UNBOUNDED) { - // The index is itself unbounded. We need to use the current time to calculate the range of - // available segments. - long liveEdgeTimeUs = nowUnixTimeUs - C.msToUs(manifest.availabilityStartTimeMs); - long periodStartUs = C.msToUs(manifest.getPeriod(periodIndex).startMs); - long liveEdgeTimeInPeriodUs = liveEdgeTimeUs - periodStartUs; - // getSegmentNum(liveEdgeTimeInPeriodUs) will not be completed yet, so subtract one to get - // the index of the last completed segment. - return getSegmentNum(liveEdgeTimeInPeriodUs) - 1; - } - return getFirstSegmentNum() + availableSegmentCount - 1; + public long getLastAvailableSegmentNum(long nowUnixTimeUs) { + return getFirstAvailableSegmentNum(nowUnixTimeUs) + + segmentIndex.getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs) + - 1; } - private static boolean mimeTypeIsWebm(String mimeType) { - return mimeType.startsWith(MimeTypes.VIDEO_WEBM) || mimeType.startsWith(MimeTypes.AUDIO_WEBM) - || mimeType.startsWith(MimeTypes.APPLICATION_WEBM); + public boolean isSegmentAvailableAtFullNetworkSpeed(long segmentNum, long nowUnixTimeUs) { + return getSegmentEndTimeUs(segmentNum) <= nowUnixTimeUs; } - 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 +811,7 @@ private static boolean mimeTypeIsWebm(String mimeType) { // 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 +826,7 @@ private static boolean mimeTypeIsWebm(String mimeType) { 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 6e67be6ec5a..66fcd280c6a 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 @@ public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, } 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 504b2f4c27e..2185b52f933 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.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.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 PlayerEmsgHandler( 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; @@ -288,8 +288,9 @@ public final class PlayerTrackEmsgHandler implements TrackOutput { this.sampleQueue = new SampleQueue( allocator, + /* playbackLooper= */ handler.getLooper(), DrmSessionManager.getDummyDrmSessionManager(), - new MediaSourceEventDispatcher()); + new DrmSessionEventListener.EventDispatcher()); formatHolder = new FormatHolder(); buffer = new MetadataInputBuffer(); } @@ -300,13 +301,14 @@ public void format(Format format) { } @Override - public int sampleData(DataReader input, int length, boolean allowEndOfInput) + public int sampleData( + DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart) throws IOException { return sampleQueue.sampleData(input, length, allowEndOfInput); } @Override - public void sampleData(ParsableByteArray data, int length) { + public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) { sampleQueue.sampleData(data, length); } @@ -359,12 +361,15 @@ public void release() { 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); @@ -378,11 +383,7 @@ private MetadataInputBuffer dequeueSample() { 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 23f264e64b3..19fcc321cbb 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -39,11 +39,12 @@ 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; @@ -69,6 +70,16 @@ public class DashManifestParser extends DefaultHandler private static final Pattern CEA_708_ACCESSIBILITY_PATTERN = Pattern.compile("([1-9]|[1-5][0-9]|6[0-3])=.*"); + /** + * Maps the value attribute of an AudioElementConfiguration with schemeIdUri + * "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1, to a channel + * count. + */ + private static final int[] MPEG_CHANNEL_CONFIGURATION_MAPPING = + new int[] { + Format.NO_VALUE, 1, 2, 3, 4, 5, 6, 8, 2, 3, 4, 7, 8, 24, 8, 12, 10, 12, 14, 12, 14 + }; + private final XmlPullParserFactory xmlParserFactory; public DashManifestParser() { @@ -114,6 +125,7 @@ protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, ProgramInformation programInformation = null; UtcTimingElement utcTiming = null; Uri location = null; + long baseUrlAvailabilityTimeOffsetUs = dynamic ? 0 : C.TIME_UNSET; List periods = new ArrayList<>(); long nextPeriodStartMs = dynamic ? C.TIME_UNSET : 0; @@ -123,6 +135,8 @@ protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -133,7 +147,14 @@ protected DashManifest parseMediaPresentationDescription(XmlPullParser xpp, } else if (XmlPullParserUtil.isStartTag(xpp, "Location")) { location = Uri.parse(xpp.nextText()); } else if (XmlPullParserUtil.isStartTag(xpp, "Period") && !seenEarlyAccessPeriod) { - Pair periodWithDurationMs = parsePeriod(xpp, baseUrl, nextPeriodStartMs); + Pair periodWithDurationMs = + parsePeriod( + xpp, + baseUrl, + nextPeriodStartMs, + baseUrlAvailabilityTimeOffsetUs, + availabilityStartTime, + timeShiftBufferDepthMs); Period period = periodWithDurationMs.first; if (period.startMs == C.TIME_UNSET) { if (dynamic) { @@ -220,33 +241,74 @@ protected UtcTimingElement buildUtcTimingElement(String schemeIdUri, String valu return new UtcTimingElement(schemeIdUri, value); } - protected Pair parsePeriod(XmlPullParser xpp, String baseUrl, long defaultStartMs) + protected Pair parsePeriod( + XmlPullParser xpp, + String baseUrl, + long defaultStartMs, + long baseUrlAvailabilityTimeOffsetUs, + long availabilityStartTimeMs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { @Nullable String id = xpp.getAttributeValue(null, "id"); long startMs = parseDuration(xpp, "start", defaultStartMs); + long periodStartUnixTimeMs = + availabilityStartTimeMs != C.TIME_UNSET ? availabilityStartTimeMs + startMs : C.TIME_UNSET; long durationMs = parseDuration(xpp, "duration", C.TIME_UNSET); @Nullable SegmentBase segmentBase = null; @Nullable Descriptor assetIdentifier = null; 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, + periodStartUnixTimeMs, + timeShiftBufferDepthMs)); } 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, + periodStartUnixTimeMs, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } 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(), + periodStartUnixTimeMs, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "AssetIdentifier")) { assetIdentifier = parseDescriptor(xpp, "AssetIdentifier"); } else { @@ -270,7 +332,14 @@ protected Period buildPeriod( // AdaptationSet parsing. protected AdaptationSet parseAdaptationSet( - XmlPullParser xpp, String baseUrl, @Nullable SegmentBase segmentBase, long periodDurationMs) + XmlPullParser xpp, + String baseUrl, + @Nullable SegmentBase segmentBase, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { int id = parseInt(xpp, "id", AdaptationSet.ID_UNSET); int contentType = parseContentType(xpp); @@ -298,6 +367,8 @@ protected AdaptationSet parseAdaptationSet( xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -340,7 +411,11 @@ protected AdaptationSet parseAdaptationSet( essentialProperties, supplementalProperties, segmentBase, - periodDurationMs); + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); contentType = checkContentTypeConsistency( contentType, MimeTypes.getTrackType(representationInfo.format.sampleMimeType)); @@ -348,11 +423,30 @@ protected AdaptationSet parseAdaptationSet( } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase, periodDurationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); segmentBase = parseSegmentTemplate( - xpp, (SegmentTemplate) segmentBase, supplementalProperties, periodDurationMs); + xpp, + (SegmentTemplate) segmentBase, + supplementalProperties, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); } else if (XmlPullParserUtil.isStartTag(xpp, "Label")) { @@ -513,7 +607,11 @@ protected RepresentationInfo parseRepresentation( List adaptationSetEssentialProperties, List adaptationSetSupplementalProperties, @Nullable SegmentBase segmentBase, - long periodDurationMs) + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { String id = xpp.getAttributeValue(null, "id"); int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); @@ -537,6 +635,8 @@ protected RepresentationInfo parseRepresentation( xpp.next(); if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); baseUrl = parseBaseUrl(xpp, baseUrl); seenFirstBaseUrl = true; } @@ -545,14 +645,30 @@ protected RepresentationInfo parseRepresentation( } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { - segmentBase = parseSegmentList(xpp, (SegmentList) segmentBase, periodDurationMs); + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); segmentBase = parseSegmentTemplate( xpp, (SegmentTemplate) segmentBase, adaptationSetSupplementalProperties, - periodDurationMs); + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { Pair contentProtection = parseContentProtection(xpp); if (contentProtection.first != null) { @@ -717,7 +833,13 @@ protected SingleSegmentBase buildSingleSegmentBase(RangedUri initialization, lon } protected SegmentList parseSegmentList( - XmlPullParser xpp, @Nullable SegmentList parent, long periodDurationMs) + XmlPullParser xpp, + @Nullable SegmentList parent, + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); @@ -725,6 +847,9 @@ protected SegmentList parseSegmentList( parent != null ? parent.presentationTimeOffset : 0); long duration = parseLong(xpp, "duration", parent != null ? parent.duration : C.TIME_UNSET); long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); RangedUri initialization = null; List timeline = null; @@ -752,8 +877,17 @@ protected SegmentList parseSegmentList( segments = segments != null ? segments : parent.mediaSegments; } - return buildSegmentList(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, segments); + return buildSegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); } protected SegmentList buildSegmentList( @@ -763,16 +897,32 @@ protected SegmentList buildSegmentList( long startNumber, long duration, @Nullable List timeline, - @Nullable List segments) { - return new SegmentList(initialization, timescale, presentationTimeOffset, - startNumber, duration, timeline, segments); + long availabilityTimeOffsetUs, + @Nullable List segments, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { + return new SegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments, + C.msToUs(timeShiftBufferDepthMs), + C.msToUs(periodStartUnixTimeMs)); } protected SegmentTemplate parseSegmentTemplate( XmlPullParser xpp, @Nullable SegmentTemplate parent, List adaptationSetSupplementalProperties, - long periodDurationMs) + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) throws XmlPullParserException, IOException { long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", @@ -781,6 +931,9 @@ protected SegmentTemplate parseSegmentTemplate( long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); long endNumber = parseLastSegmentNumberSupplementalProperty(adaptationSetSupplementalProperties); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); UrlTemplate mediaTemplate = parseUrlTemplate(xpp, "media", parent != null ? parent.mediaTemplate : null); @@ -814,8 +967,11 @@ protected SegmentTemplate parseSegmentTemplate( endNumber, duration, timeline, + availabilityTimeOffsetUs, initializationTemplate, - mediaTemplate); + mediaTemplate, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); } protected SegmentTemplate buildSegmentTemplate( @@ -826,8 +982,11 @@ protected SegmentTemplate buildSegmentTemplate( long endNumber, long duration, List timeline, + long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, - @Nullable UrlTemplate mediaTemplate) { + @Nullable UrlTemplate mediaTemplate, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { return new SegmentTemplate( initialization, timescale, @@ -836,14 +995,16 @@ protected SegmentTemplate buildSegmentTemplate( endNumber, duration, timeline, + availabilityTimeOffsetUs, initializationTemplate, - mediaTemplate); + mediaTemplate, + C.msToUs(timeShiftBufferDepthMs), + C.msToUs(periodStartUnixTimeMs)); } /** - * /** * 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 +1095,7 @@ protected byte[] parseEventObject(XmlPullParser xpp, ByteArrayOutputStream scrat 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,18 +1312,48 @@ protected String parseBaseUrl(XmlPullParser xpp, String parentBaseUrl) return UriUtil.resolve(parentBaseUrl, parseText(xpp, "BaseURL")); } + /** + * Parses the availabilityTimeOffset value and returns the parsed value or the parent value if it + * doesn't exist. + * + * @param xpp The parser from which to read. + * @param parentAvailabilityTimeOffsetUs The availability time offset of a parent element in + * microseconds. + * @return The parsed availabilityTimeOffset in microseconds. + */ + protected long parseAvailabilityTimeOffsetUs( + XmlPullParser xpp, long parentAvailabilityTimeOffsetUs) { + String value = xpp.getAttributeValue(/* namespace= */ null, "availabilityTimeOffset"); + if (value == null) { + return parentAvailabilityTimeOffsetUs; + } + if ("INF".equals(value)) { + return Long.MAX_VALUE; + } + return (long) (Float.parseFloat(value) * C.MICROS_PER_SECOND); + } + // AudioChannelConfiguration parsing. protected int parseAudioChannelConfiguration(XmlPullParser xpp) throws XmlPullParserException, IOException { String schemeIdUri = parseString(xpp, "schemeIdUri", null); - int audioChannels = - "urn:mpeg:dash:23003:3:audio_channel_configuration:2011".equals(schemeIdUri) - ? parseInt(xpp, "value", Format.NO_VALUE) - : ("tag:dolby.com,2014:dash:audio_channel_configuration:2011".equals(schemeIdUri) - || "urn:dolby:dash:audio_channel_configuration:2011".equals(schemeIdUri) - ? parseDolbyChannelConfiguration(xpp) - : Format.NO_VALUE); + int audioChannels; + switch (schemeIdUri) { + case "urn:mpeg:dash:23003:3:audio_channel_configuration:2011": + audioChannels = parseInt(xpp, "value", Format.NO_VALUE); + break; + case "urn:mpeg:mpegB:cicp:ChannelConfiguration": + audioChannels = parseMpegChannelConfiguration(xpp); + break; + case "tag:dolby.com,2014:dash:audio_channel_configuration:2011": + case "urn:dolby:dash:audio_channel_configuration:2011": + audioChannels = parseDolbyChannelConfiguration(xpp); + break; + default: + audioChannels = Format.NO_VALUE; + break; + } do { xpp.next(); } while (!XmlPullParserUtil.isEndTag(xpp, "AudioChannelConfiguration")); @@ -1528,6 +1719,21 @@ protected static String parseString(XmlPullParser xpp, String name, String defau return value == null ? defaultValue : value; } + /** + * Parses the number of channels from the value attribute of an AudioElementConfiguration with + * schemeIdUri "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1. + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseMpegChannelConfiguration(XmlPullParser xpp) { + int index = parseInt(xpp, "value", C.INDEX_UNSET); + return 0 <= index && index < MPEG_CHANNEL_CONFIGURATION_MAPPING.length + ? MPEG_CHANNEL_CONFIGURATION_MAPPING[index] + : Format.NO_VALUE; + } + /** * Parses the number of channels from the value attribute of an AudioElementConfiguration with * schemeIdUri "tag:dolby.com,2014:dash:audio_channel_configuration:2011", as defined by table E.5 @@ -1569,6 +1775,20 @@ protected static long parseLastSegmentNumberSupplementalProperty( return C.INDEX_UNSET; } + private static long getFinalAvailabilityTimeOffset( + long baseUrlAvailabilityTimeOffsetUs, long segmentBaseAvailabilityTimeOffsetUs) { + long availabilityTimeOffsetUs = segmentBaseAvailabilityTimeOffsetUs; + if (availabilityTimeOffsetUs == C.TIME_UNSET) { + // Fall back to BaseURL values if no SegmentBase specifies an offset. + availabilityTimeOffsetUs = baseUrlAvailabilityTimeOffsetUs; + } + if (availabilityTimeOffsetUs == Long.MAX_VALUE) { + // Replace INF value with TIME_UNSET to specify that all segments are available immediately. + availabilityTimeOffsetUs = C.TIME_UNSET; + } + return availabilityTimeOffsetUs; + } + /** A parsed Representation element. */ protected static final class RepresentationInfo { diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java index 80ad15cd8f5..c0b1dceec53 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/Representation.java @@ -17,6 +17,7 @@ import android.net.Uri; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; @@ -90,7 +91,12 @@ public static Representation newInstance( SegmentBase segmentBase, @Nullable List inbandEventStreams) { return newInstance( - revisionId, format, baseUrl, segmentBase, inbandEventStreams, /* cacheKey= */ null); + revisionId, + format, + baseUrl, + segmentBase, + inbandEventStreams, + /* cacheKey= */ null); } /** @@ -275,9 +281,11 @@ public String getCacheKey() { public static class MultiSegmentRepresentation extends Representation implements DashSegmentIndex { - private final MultiSegmentBase segmentBase; + @VisibleForTesting /* package */ final MultiSegmentBase segmentBase; /** + * Creates the multi-segment Representation. + * * @param revisionId Identifies the revision of the content. * @param format The format of the representation. * @param baseUrl The base URL of the representation. @@ -338,11 +346,26 @@ public long getFirstSegmentNum() { return segmentBase.getFirstSegmentNum(); } + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + return segmentBase.getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + } + @Override public int getSegmentCount(long periodDurationUs) { return segmentBase.getSegmentCount(periodDurationUs); } + @Override + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + return segmentBase.getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + } + + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return segmentBase.getNextSegmentAvailableTimeUs(periodDurationUs, nowUnixTimeUs); + } + @Override public boolean isExplicit() { return segmentBase.isExplicit(); 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 b5ca31c151c..495f288805c 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBase.java @@ -15,7 +15,12 @@ */ package com.google.android.exoplayer2.source.dash.manifest; +import static com.google.android.exoplayer2.source.dash.DashSegmentIndex.INDEX_UNBOUNDED; +import static java.lang.Math.max; +import static java.lang.Math.min; + import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; import com.google.android.exoplayer2.util.Util; @@ -117,6 +122,17 @@ public abstract static class MultiSegmentBase extends SegmentBase { /* package */ final long startNumber; /* package */ final long duration; @Nullable /* package */ final List segmentTimeline; + private final long timeShiftBufferDepthUs; + private final long periodStartUnixTimeUs; + + /** + * Offset to the current realtime at which segments become available, in microseconds, or {@link + * C#TIME_UNSET} if all segments are available immediately. + * + *

      Segments will be available once their end time ≤ currentRealTime + + * availabilityTimeOffset. + */ + @VisibleForTesting /* package */ final long availabilityTimeOffsetUs; /** * @param initialization A {@link RangedUri} corresponding to initialization data, if such data @@ -131,6 +147,11 @@ public abstract static class MultiSegmentBase extends SegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. */ public MultiSegmentBase( @Nullable RangedUri initialization, @@ -138,14 +159,20 @@ public MultiSegmentBase( long presentationTimeOffset, long startNumber, long duration, - @Nullable List segmentTimeline) { + @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { super(initialization, timescale, presentationTimeOffset); this.startNumber = startNumber; this.duration = duration; this.segmentTimeline = segmentTimeline; + this.availabilityTimeOffsetUs = availabilityTimeOffsetUs; + this.timeShiftBufferDepthUs = timeShiftBufferDepthUs; + this.periodStartUnixTimeUs = periodStartUnixTimeUs; } - /** @see DashSegmentIndex#getSegmentNum(long, long) */ + /** See {@link DashSegmentIndex#getSegmentNum(long, long)}. */ public long getSegmentNum(long timeUs, long periodDurationUs) { final long firstSegmentNum = getFirstSegmentNum(); final long segmentCount = getSegmentCount(periodDurationUs); @@ -157,9 +184,11 @@ public long getSegmentNum(long timeUs, long periodDurationUs) { 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 == INDEX_UNBOUNDED + ? segmentNum + : min(segmentNum, firstSegmentNum + segmentCount - 1); } else { // The index cannot be unbounded. Identify the segment using binary search. long lowIndex = firstSegmentNum; @@ -179,21 +208,21 @@ public long getSegmentNum(long timeUs, long periodDurationUs) { } } - /** @see DashSegmentIndex#getDurationUs(long, long) */ + /** See {@link DashSegmentIndex#getDurationUs(long, long)}. */ public final long getSegmentDurationUs(long sequenceNumber, long periodDurationUs) { if (segmentTimeline != null) { long duration = segmentTimeline.get((int) (sequenceNumber - startNumber)).duration; return (duration * C.MICROS_PER_SECOND) / timescale; } else { int segmentCount = getSegmentCount(periodDurationUs); - return segmentCount != DashSegmentIndex.INDEX_UNBOUNDED - && sequenceNumber == (getFirstSegmentNum() + segmentCount - 1) + return segmentCount != INDEX_UNBOUNDED + && sequenceNumber == (getFirstSegmentNum() + segmentCount - 1) ? (periodDurationUs - getSegmentTimeUs(sequenceNumber)) : ((duration * C.MICROS_PER_SECOND) / timescale); } } - /** @see DashSegmentIndex#getTimeUs(long) */ + /** See {@link DashSegmentIndex#getTimeUs(long)}. */ public final long getSegmentTimeUs(long sequenceNumber) { long unscaledSegmentTime; if (segmentTimeline != null) { @@ -210,27 +239,66 @@ public final long getSegmentTimeUs(long sequenceNumber) { * Returns a {@link RangedUri} defining the location of a segment for the given index in the * given representation. * - * @see DashSegmentIndex#getSegmentUrl(long) + *

      See {@link DashSegmentIndex#getSegmentUrl(long)}. */ public abstract RangedUri getSegmentUrl(Representation representation, long index); - /** @see DashSegmentIndex#getFirstSegmentNum() */ + /** See {@link DashSegmentIndex#getFirstSegmentNum()}. */ public long getFirstSegmentNum() { return startNumber; } - /** - * @see DashSegmentIndex#getSegmentCount(long) - */ - public abstract int getSegmentCount(long periodDurationUs); + /** See {@link DashSegmentIndex#getFirstAvailableSegmentNum(long, long)}. */ + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + long segmentCount = getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED || timeShiftBufferDepthUs == C.TIME_UNSET) { + return getFirstSegmentNum(); + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + long timeShiftBufferStartInPeriodUs = liveEdgeTimeInPeriodUs - timeShiftBufferDepthUs; + long timeShiftBufferStartSegmentNum = + getSegmentNum(timeShiftBufferStartInPeriodUs, periodDurationUs); + return max(getFirstSegmentNum(), timeShiftBufferStartSegmentNum); + } - /** - * @see DashSegmentIndex#isExplicit() - */ + /** See {@link DashSegmentIndex#getAvailableSegmentCount(long, long)}. */ + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + int segmentCount = getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED) { + return segmentCount; + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + long availabilityTimeOffsetUs = liveEdgeTimeInPeriodUs + this.availabilityTimeOffsetUs; + // getSegmentNum(availabilityTimeOffsetUs) will not be completed yet. + long firstIncompleteSegmentNum = getSegmentNum(availabilityTimeOffsetUs, periodDurationUs); + long firstAvailableSegmentNum = getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + return (int) (firstIncompleteSegmentNum - firstAvailableSegmentNum); + } + + /** See {@link DashSegmentIndex#getNextSegmentAvailableTimeUs(long, long)}. */ + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + if (segmentTimeline != null) { + return C.TIME_UNSET; + } + long firstIncompleteSegmentNum = + getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs) + + getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + return getSegmentTimeUs(firstIncompleteSegmentNum) + + getSegmentDurationUs(firstIncompleteSegmentNum, periodDurationUs) + - availabilityTimeOffsetUs; + } + + /** See {@link DashSegmentIndex#isExplicit()} */ public boolean isExplicit() { return segmentTimeline != null; } + /** See {@link DashSegmentIndex#getSegmentCount(long)}. */ + public abstract int getSegmentCount(long periodDurationUs); } /** A {@link MultiSegmentBase} that uses a SegmentList to define its segments. */ @@ -251,7 +319,12 @@ public static final class SegmentList extends MultiSegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. * @param mediaSegments A list of {@link RangedUri}s indicating the locations of the segments. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. */ public SegmentList( RangedUri initialization, @@ -260,9 +333,20 @@ public SegmentList( long startNumber, long duration, @Nullable List segmentTimeline, - @Nullable List mediaSegments) { - super(initialization, timescale, presentationTimeOffset, startNumber, duration, - segmentTimeline); + long availabilityTimeOffsetUs, + @Nullable List mediaSegments, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { + super( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + segmentTimeline, + availabilityTimeOffsetUs, + timeShiftBufferDepthUs, + periodStartUnixTimeUs); this.mediaSegments = mediaSegments; } @@ -307,10 +391,15 @@ public static final class SegmentTemplate extends MultiSegmentBase { * @param segmentTimeline A segment timeline corresponding to the segments. If null, then * segments are assumed to be of fixed duration as specified by the {@code duration} * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. * @param initializationTemplate A template defining the location of initialization data, if * such data exists. If non-null then the {@code initialization} parameter is ignored. If * null then {@code initialization} will be used. * @param mediaTemplate A template defining the location of each media segment. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. */ public SegmentTemplate( RangedUri initialization, @@ -320,15 +409,21 @@ public SegmentTemplate( long endNumber, long duration, @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, @Nullable UrlTemplate initializationTemplate, - @Nullable UrlTemplate mediaTemplate) { + @Nullable UrlTemplate mediaTemplate, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { super( initialization, timescale, presentationTimeOffset, startNumber, duration, - segmentTimeline); + segmentTimeline, + availabilityTimeOffsetUs, + timeShiftBufferDepthUs, + periodStartUnixTimeUs); this.initializationTemplate = initializationTemplate; this.mediaTemplate = mediaTemplate; this.endNumber = endNumber; @@ -369,7 +464,7 @@ public int getSegmentCount(long periodDurationUs) { long durationUs = (duration * C.MICROS_PER_SECOND) / timescale; return (int) Util.ceilDivide(periodDurationUs, durationUs); } else { - return DashSegmentIndex.INDEX_UNBOUNDED; + return INDEX_UNBOUNDED; } } } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java index a56a11fe50e..523bc2d0719 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/SingleSegmentIndex.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.dash.manifest; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; /** @@ -56,11 +57,26 @@ public long getFirstSegmentNum() { return 0; } + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + return 0; + } + @Override public int getSegmentCount(long periodDurationUs) { return 1; } + @Override + public int getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + return 1; + } + + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return C.TIME_UNSET; + } + @Override public boolean isExplicit() { return true; 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 7f76e65a429..7b99d55fd93 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,9 +18,9 @@ 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.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.SegmentDownloader; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; @@ -34,10 +34,14 @@ 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; +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. @@ -46,44 +50,100 @@ * *

      {@code
        * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
      - * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
      - * DownloaderConstructorHelper constructorHelper =
      - *     new DownloaderConstructorHelper(cache, factory);
      + * CacheDataSource.Factory cacheDataSourceFactory =
      + *     new CacheDataSource.Factory()
      + *         .setCache(cache)
      + *         .setUpstreamDataSourceFactory(new DefaultHttpDataSourceFactory(userAgent));
        * // Create a downloader for the first representation of the first adaptation set of the first
        * // period.
        * DashDownloader dashDownloader =
        *     new DashDownloader(
      - *         manifestUrl, Collections.singletonList(new StreamKey(0, 0, 0)), constructorHelper);
      + *         new MediaItem.Builder()
      + *             .setUri(manifestUrl)
      + *             .setStreamKeys(Collections.singletonList(new StreamKey(0, 0, 0)))
      + *             .build(),
      + *         cacheDataSourceFactory);
        * // Perform the download.
        * dashDownloader.download(progressListener);
      - * // Access downloaded data using CacheDataSource
      - * CacheDataSource cacheDataSource =
      - *     new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE);
      + * // Use the downloaded data for playback.
      + * DashMediaSource mediaSource =
      + *     new DashMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem);
        * }
      */ public final class DashDownloader extends SegmentDownloader { + /** @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. - * @param constructorHelper A {@link DownloaderConstructorHelper} instance. + * 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, DownloaderConstructorHelper constructorHelper) { - super(manifestUri, streamKeys, constructorHelper); + Uri manifestUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(manifestUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); } - @Override - protected DashManifest getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException { - return ParsingLoadable.load( - dataSource, new DashManifestParser(), dataSpec, C.DATA_TYPE_MANIFEST); + /** + * 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. + * 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, 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(mediaItem, manifestParser, cacheDataSourceFactory, executor); } @Override protected List getSegments( - DataSource dataSource, DashManifest manifest, boolean allowIncompleteList) - throws InterruptedException, 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); @@ -92,36 +152,31 @@ protected List getSegments( 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, 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. @@ -157,17 +212,24 @@ private static void addSegment( out.add(new Segment(startTimeUs, dataSpec)); } - private static @Nullable DashSegmentIndex getSegmentIndex( - DataSource dataSource, int trackType, Representation representation) + @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 e9e5f3030c6..a21e73b0abf 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,42 +18,39 @@ 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.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory; +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 getSteamKeys_isCompatibleWithDashManifestFilter() { + public void getStreamKeys_isCompatibleWithDashManifestFilter() throws IOException { // Test manifest which covers various edge cases: // - Multiple periods. // - Single and multiple representations per adaptation set. @@ -61,175 +58,165 @@ public void getSteamKeys_isCompatibleWithDashManifestFilter() { // - Embedded track groups. // All cases are deliberately combined in one test to catch potential indexing problems which // only occur in combination. - DashManifest testManifest = - createDashManifest( - createPeriod( - createAdaptationSet( - /* id= */ 0, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ null, - createVideoRepresentation(/* bitrate= */ 1000000))), - createPeriod( - createAdaptationSet( - /* id= */ 100, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids...= */ 103, 104), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 200000), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 400000), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 600000)), - createAdaptationSet( - /* id= */ 101, - /* trackType= */ C.TRACK_TYPE_AUDIO, - /* descriptor= */ createSwitchDescriptor(/* ids...= */ 102), - createAudioRepresentation(/* bitrate= */ 48000), - createAudioRepresentation(/* bitrate= */ 96000)), - createAdaptationSet( - /* id= */ 102, - /* trackType= */ C.TRACK_TYPE_AUDIO, - /* descriptor= */ createSwitchDescriptor(/* ids...= */ 101), - createAudioRepresentation(/* bitrate= */ 256000)), - createAdaptationSet( - /* id= */ 103, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids...= */ 100, 104), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 800000), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 1000000)), - createAdaptationSet( - /* id= */ 104, - /* trackType= */ C.TRACK_TYPE_VIDEO, - /* descriptor= */ createSwitchDescriptor(/* ids...= */ 100, 103), - createVideoRepresentationWithInbandEventStream(/* bitrate= */ 2000000)), - createAdaptationSet( - /* id= */ 105, - /* trackType= */ C.TRACK_TYPE_TEXT, - /* descriptor= */ null, - createTextRepresentation(/* language= */ "eng")), - createAdaptationSet( - /* id= */ 105, - /* trackType= */ C.TRACK_TYPE_TEXT, - /* descriptor= */ null, - createTextRepresentation(/* language= */ "ger")))); - FilterableManifestMediaPeriodFactory mediaPeriodFactory = - (manifest, periodIndex) -> - new DashMediaPeriod( - /* id= */ periodIndex, - manifest, - periodIndex, - mock(DashChunkSource.Factory.class), - mock(TransferListener.class), - DrmSessionManager.getDummyDrmSessionManager(), - mock(LoadErrorHandlingPolicy.class), - new EventDispatcher() - .withParameters( - /* windowIndex= */ 0, - /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), - /* mediaTimeOffsetMs= */ 0), - /* elapsedRealtimeOffsetMs= */ 0, - mock(LoaderErrorThrower.class), - mock(Allocator.class), - mock(CompositeSequenceableLoaderFactory.class), - mock(PlayerEmsgCallback.class)); + 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( - mediaPeriodFactory, - testManifest, + DashMediaPeriodTest::createDashMediaPeriod, + manifest, /* periodIndex= */ 1, /* ignoredMimeType= */ "application/x-emsg"); } - 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)); - } + @Test + 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; - private static Period createPeriod(AdaptationSet... adaptationSets) { - return new Period(/* id= */ null, /* startMs= */ 0, Arrays.asList(adaptationSets)); - } + // We expect the three adaptation sets with the switch descriptor to be merged, retaining the + // representations in their original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format), + new TrackGroup(adaptationSets.get(1).representations.get(0).format)); - 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)); + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); } - private static Representation createVideoRepresentation(int bitrate) { - return Representation.newInstance( - /* revisionId= */ 0, - createVideoFormat(bitrate), - /* baseUrl= */ "", - new SingleSegmentBase()); - } + @Test + 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; + + // We expect the trick play adaptation sets to be merged with the ones to which they refer, + // retaining representations in their original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(1).representations.get(0).format), + new TrackGroup( + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format)); - private static Representation createVideoRepresentationWithInbandEventStream(int bitrate) { - return Representation.newInstance( - /* revisionId= */ 0, - createVideoFormat(bitrate), - /* baseUrl= */ "", - new SingleSegmentBase(), - Collections.singletonList(getInbandEventDescriptor())); + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); } - private static Format createVideoFormat(int bitrate) { - return new Format.Builder() - .setContainerMimeType(MimeTypes.VIDEO_MP4) - .setSampleMimeType(MimeTypes.VIDEO_H264) - .setPeakBitrate(bitrate) - .build(); + @Test + 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; + + // We expect all adaptation sets to be merged into one group, retaining representations in their + // original order. + TrackGroupArray expectedTrackGroups = + new TrackGroupArray( + new TrackGroup( + adaptationSets.get(0).representations.get(0).format, + adaptationSets.get(0).representations.get(1).format, + adaptationSets.get(1).representations.get(0).format, + adaptationSets.get(2).representations.get(0).format, + adaptationSets.get(2).representations.get(1).format, + adaptationSets.get(3).representations.get(0).format)); + + MediaPeriodAsserts.assertTrackGroups(dashMediaPeriod, expectedTrackGroups); } - private static 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()); + @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); } - 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()); + @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 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 DashMediaPeriod createDashMediaPeriod(DashManifest manifest, int periodIndex) { + MediaPeriodId mediaPeriodId = new MediaPeriodId(/* periodUid= */ new Object()); + return new DashMediaPeriod( + /* id= */ periodIndex, + manifest, + periodIndex, + mock(DashChunkSource.Factory.class), + mock(TransferListener.class), + DrmSessionManager.getDummyDrmSessionManager(), + new DrmSessionEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId), + mock(LoadErrorHandlingPolicy.class), + new MediaSourceEventListener.EventDispatcher() + .withParameters(/* windowIndex= */ 0, mediaPeriodId, /* mediaTimeOffsetMs= */ 0), + /* elapsedRealtimeOffsetMs= */ 0, + mock(LoaderErrorThrower.class), + mock(Allocator.class), + mock(CompositeSequenceableLoaderFactory.class), + mock(PlayerEmsgCallback.class)); } - 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 3c8952fd621..aa65237095c 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 @@ 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 void iso8601ParserParseMissingTimezone() throws IOException { } } + // 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 3176b06865d..188d1b2a180 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 073bd82c657..ab7f456c551 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultMediaSourceFactoryTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -36,12 +37,9 @@ 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() - .setSourceUri(URI_MEDIA) - .setMimeType(MimeTypes.APPLICATION_MPD) - .build(); + new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_MPD).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -52,24 +50,24 @@ public void createMediaSource_withMimeType_dashSource() { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_MEDIA) + .setUri(URI_MEDIA) .setMimeType(MimeTypes.APPLICATION_MPD) .setTag(tag) .build(); 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()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + "/file.mpd").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mpd").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -79,8 +77,8 @@ public void createMediaSource_withPath_dashSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + "/file.mpd").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.mpd").build(); MediaSource mediaSource = defaultMediaSourceFactory @@ -95,7 +93,7 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void getSupportedTypes_dashModule_containsTypeDash() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_DASH); diff --git a/library/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 47087472ae8..496dd9575d5 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java @@ -28,9 +28,9 @@ 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 @@ @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 void parseMediaPresentationDescription_eventStream() throws IOException { 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 void parsePeriodAssetIdentifier() throws IOException { assertThat(assetIdentifier.id).isEqualTo("uniqueId"); } + @Test + public void availabilityTimeOffset_staticManifest_setToTimeUnset() throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), SAMPLE_MPD_TEXT)); + + assertThat(manifest.getPeriodCount()).isEqualTo(1); + List adaptationSets = manifest.getPeriod(0).adaptationSets; + assertThat(adaptationSets).hasSize(3); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(0))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(1))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(2))).isEqualTo(C.TIME_UNSET); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInBaseUrl_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_BASE_URL)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(5_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(4_321_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(9_876_543); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(0); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInSegmentTemplate_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_TEMPLATE)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(2_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(3_210_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(1_230_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(100_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(9_999_000); + } + + @Test + public void availabilityTimeOffset_dynamicManifest_valuesInSegmentList_setsCorrectValues() + throws IOException { + DashManifestParser parser = new DashManifestParser(); + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), + SAMPLE_MPD_AVAILABILITY_TIME_OFFSET_SEGMENT_LIST)); + + assertThat(manifest.getPeriodCount()).isEqualTo(2); + List adaptationSets0 = manifest.getPeriod(0).adaptationSets; + List adaptationSets1 = manifest.getPeriod(1).adaptationSets; + assertThat(adaptationSets0).hasSize(4); + assertThat(adaptationSets1).hasSize(1); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(0))).isEqualTo(2_000_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(1))).isEqualTo(3_210_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(2))).isEqualTo(1_230_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets0.get(3))).isEqualTo(100_000); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets1.get(0))).isEqualTo(9_999_000); + } + private static List buildCea608AccessibilityDescriptors(String value) { return Collections.singletonList(new Descriptor("urn:scte:dash:cc:cea-608:2015", value, null)); } @@ -485,4 +573,13 @@ private static void assertNextTag(XmlPullParser xpp) throws Exception { assertThat(xpp.getEventType()).isEqualTo(XmlPullParser.START_TAG); assertThat(xpp.getName()).isEqualTo(NEXT_TAG_NAME); } + + private static long getAvailabilityTimeOffsetUs(AdaptationSet adaptationSet) { + assertThat(adaptationSet.representations).isNotEmpty(); + Representation representation = adaptationSet.representations.get(0); + assertThat(representation).isInstanceOf(Representation.MultiSegmentRepresentation.class); + SegmentBase.MultiSegmentBase segmentBase = + ((Representation.MultiSegmentRepresentation) representation).segmentBase; + return segmentBase.availabilityTimeOffsetUs; + } } diff --git a/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 b260bf2cee7..a1b971068dd 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 @@ @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 @@ private static Representation[][][] newRepresentations( } 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 @@ private static DashManifest newDashManifest(int duration, Period... periods) { /* 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/manifest/SegmentBaseTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBaseTest.java new file mode 100644 index 00000000000..dd442a91f45 --- /dev/null +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/SegmentBaseTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.source.dash.manifest; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link SegmentBase}. */ +@RunWith(AndroidJUnit4.class) +public final class SegmentBaseTest { + + @Test + public void getFirstAvailableSegmentNum_unboundedSegmentTemplate() { + long periodStartUnixTimeUs = 123_000_000_000_000L; + SegmentBase.SegmentTemplate segmentTemplate = + new SegmentBase.SegmentTemplate( + /* initialization= */ null, + /* timescale= */ 1000, + /* presentationTimeOffset= */ 0, + /* startNumber= */ 42, + /* endNumber= */ C.INDEX_UNSET, + /* duration= */ 2000, + /* segmentTimeline= */ null, + /* availabilityTimeOffsetUs= */ 500_000, + /* initializationTemplate= */ null, + /* mediaTemplate= */ null, + /* timeShiftBufferDepthUs= */ 6_000_000, + /* periodStartUnixTimeUs= */ periodStartUnixTimeUs); + + assertThat( + segmentTemplate.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) + .isEqualTo(42); + assertThat( + segmentTemplate.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) + .isEqualTo(42); + assertThat( + segmentTemplate.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_999_999)) + .isEqualTo(42); + assertThat( + segmentTemplate.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 8_000_000)) + .isEqualTo(43); + assertThat( + segmentTemplate.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 9_999_999)) + .isEqualTo(43); + assertThat( + segmentTemplate.getFirstAvailableSegmentNum( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 10_000_000)) + .isEqualTo(44); + } + + @Test + public void getAvailableSegmentCount_unboundedSegmentTemplate() { + long periodStartUnixTimeUs = 123_000_000_000_000L; + SegmentBase.SegmentTemplate segmentTemplate = + new SegmentBase.SegmentTemplate( + /* initialization= */ null, + /* timescale= */ 1000, + /* presentationTimeOffset= */ 0, + /* startNumber= */ 42, + /* endNumber= */ C.INDEX_UNSET, + /* duration= */ 2000, + /* segmentTimeline= */ null, + /* availabilityTimeOffsetUs= */ 500_000, + /* initializationTemplate= */ null, + /* mediaTemplate= */ null, + /* timeShiftBufferDepthUs= */ 6_000_000, + /* periodStartUnixTimeUs= */ periodStartUnixTimeUs); + + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) + .isEqualTo(0); + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) + .isEqualTo(0); + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_499_999)) + .isEqualTo(0); + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_500_000)) + .isEqualTo(1); + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_499_999)) + .isEqualTo(3); + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_500_000)) + .isEqualTo(4); + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 7_999_999)) + .isEqualTo(4); + assertThat( + segmentTemplate.getAvailableSegmentCount( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 8_000_000)) + .isEqualTo(3); + } + + @Test + public void getNextSegmentShiftTimeUse_unboundedSegmentTemplate() { + long periodStartUnixTimeUs = 123_000_000_000_000L; + SegmentBase.SegmentTemplate segmentTemplate = + new SegmentBase.SegmentTemplate( + /* initialization= */ null, + /* timescale= */ 1000, + /* presentationTimeOffset= */ 0, + /* startNumber= */ 42, + /* endNumber= */ C.INDEX_UNSET, + /* duration= */ 2000, + /* segmentTimeline= */ null, + /* availabilityTimeOffsetUs= */ 500_000, + /* initializationTemplate= */ null, + /* mediaTemplate= */ null, + /* timeShiftBufferDepthUs= */ 6_000_000, + /* periodStartUnixTimeUs= */ periodStartUnixTimeUs); + + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs - 10_000_000)) + .isEqualTo(1_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, /* nowUnixTimeUs= */ periodStartUnixTimeUs)) + .isEqualTo(1_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_499_999)) + .isEqualTo(1_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 1_500_000)) + .isEqualTo(3_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 17_499_999)) + .isEqualTo(17_500_000); + assertThat( + segmentTemplate.getNextSegmentAvailableTimeUs( + /* periodDurationUs= */ C.TIME_UNSET, + /* nowUnixTimeUs= */ periodStartUnixTimeUs + 17_500_000)) + .isEqualTo(19_500_000); + } +} 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 71f7c9a187b..95b460a4cff 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 @@ + " \n" + " \n" + "") - .getBytes(Charset.forName(C.UTF8_NAME)); + .getBytes(Charsets.UTF_8); byte[] TEST_MPD_NO_INDEX = ("\n" @@ -100,5 +99,5 @@ + " \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 fc99e208988..d835b857258 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,23 +28,24 @@ 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; import com.google.android.exoplayer2.offline.Downloader; -import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.DownloaderFactory; import com.google.android.exoplayer2.offline.StreamKey; 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.FakeDataSource.Factory; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.DataSpec; 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.io.IOException; @@ -70,7 +71,8 @@ public void setUp() throws Exception { 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(); } @@ -81,19 +83,21 @@ public void tearDown() { @Test public void createWithDefaultDownloaderFactory() { - DownloaderConstructorHelper constructorHelper = - new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper); + CacheDataSource.Factory cacheDataSourceFactory = + new CacheDataSource.Factory() + .setCache(Mockito.mock(Cache.class)) + .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); + 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); } @@ -184,7 +188,7 @@ public void progressiveDownload() throws Exception { .setRandomData("text_segment_2", 2) .setRandomData("text_segment_3", 3); FakeDataSource fakeDataSource = new FakeDataSource(fakeDataSet); - Factory factory = mock(Factory.class); + FakeDataSource.Factory factory = mock(FakeDataSource.Factory.class); when(factory.createDataSource()).thenReturn(fakeDataSource); DashDownloader dashDownloader = @@ -216,7 +220,7 @@ public void progressiveDownloadSeparatePeriods() throws Exception { .setRandomData("period_2_segment_2", 2) .setRandomData("period_2_segment_3", 3); FakeDataSource fakeDataSource = new FakeDataSource(fakeDataSet); - Factory factory = mock(Factory.class); + FakeDataSource.Factory factory = mock(FakeDataSource.Factory.class); when(factory.createDataSource()).thenReturn(fakeDataSource); DashDownloader dashDownloader = @@ -327,12 +331,18 @@ public void representationWithoutIndex() throws Exception { } private DashDownloader getDashDownloader(FakeDataSet fakeDataSet, StreamKey... keys) { - return getDashDownloader(new Factory().setFakeDataSet(fakeDataSet), keys); + return getDashDownloader(new FakeDataSource.Factory().setFakeDataSet(fakeDataSet), keys); } - private DashDownloader getDashDownloader(Factory factory, StreamKey... keys) { + private DashDownloader getDashDownloader( + FakeDataSource.Factory upstreamDataSourceFactory, StreamKey... keys) { + CacheDataSource.Factory cacheDataSourceFactory = + new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(upstreamDataSourceFactory); return new DashDownloader( - TEST_MPD_URI, keysList(keys), new DownloaderConstructorHelper(cache, factory)); + 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 5ecdba11eb3..b2fae93bcaf 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 89426f753da..2993bb4442a 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.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; @@ -29,7 +30,6 @@ import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; import com.google.android.exoplayer2.offline.DownloadManager; import com.google.android.exoplayer2.offline.DownloadRequest; -import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.scheduler.Requirements; import com.google.android.exoplayer2.testutil.CacheAsserts.RequestSet; @@ -40,26 +40,25 @@ import com.google.android.exoplayer2.testutil.TestDownloadManagerListener; import com.google.android.exoplayer2.testutil.TestUtil; 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 void setUp() throws Exception { public void tearDown() { runOnMainThread(() -> downloadManager.release()); Util.recursiveDelete(tempFolder); - dummyMainThread.release(); + testThread.release(); } // Disabled due to flakiness. @@ -144,17 +145,17 @@ public void saveAndLoadActionFile() throws Throwable { // Revert fakeDataSet to normal. fakeDataSet.setData(TEST_MPD_URI, TEST_MPD); - dummyMainThread.runOnMainThread(this::createDownloadManager); + testThread.runOnMainThread(this::createDownloadManager); // Block on the test thread. - blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCachedData(cache, fakeDataSet); } @Test public void handleDownloadRequest_downloadSuccess() throws Throwable { handleDownloadRequest(fakeStreamKey1, fakeStreamKey2); - blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCachedData(cache, new RequestSet(fakeDataSet).useBoundedDataSpecFor("audio_init_data")); } @@ -162,7 +163,7 @@ public void handleDownloadRequest_downloadSuccess() throws Throwable { public void handleDownloadRequest_withRequest_downloadSuccess() throws Throwable { handleDownloadRequest(fakeStreamKey1); handleDownloadRequest(fakeStreamKey2); - blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCachedData(cache, new RequestSet(fakeDataSet).useBoundedDataSpecFor("audio_init_data")); } @@ -173,23 +174,17 @@ public void handleDownloadRequest_withInferringRequest_success() throws Throwabl .appendReadAction(() -> handleDownloadRequest(fakeStreamKey2)) .appendReadData(TestUtil.buildTestData(5)) .endData(); - handleDownloadRequest(fakeStreamKey1); - - blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCachedData(cache, new RequestSet(fakeDataSet).useBoundedDataSpecFor("audio_init_data")); } @Test public void handleRemoveAction_blockUntilTaskCompleted_noDownloadedData() throws Throwable { handleDownloadRequest(fakeStreamKey1); - - blockUntilTasksCompleteAndThrowAnyDownloadError(); - + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); handleRemoveAction(); - - blockUntilTasksCompleteAndThrowAnyDownloadError(); - + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCacheEmpty(cache); } @@ -197,9 +192,7 @@ public void handleRemoveAction_blockUntilTaskCompleted_noDownloadedData() throws public void handleRemoveAction_beforeDownloadFinish_noDownloadedData() throws Throwable { handleDownloadRequest(fakeStreamKey1); handleRemoveAction(); - - blockUntilTasksCompleteAndThrowAnyDownloadError(); - + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCacheEmpty(cache); } @@ -211,23 +204,14 @@ public void handleRemoveAction_withInterfering_noDownloadedData() throws Throwab .appendReadAction(downloadInProgressLatch::countDown) .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(); - - blockUntilTasksCompleteAndThrowAnyDownloadError(); - + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCacheEmpty(cache); } - private void blockUntilTasksCompleteAndThrowAnyDownloadError() throws Throwable { - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); - } - private void handleDownloadRequest(StreamKey... keys) { DownloadRequest request = getDownloadRequest(keys); runOnMainThread(() -> downloadManager.addDownload(request)); @@ -236,13 +220,10 @@ private void handleDownloadRequest(StreamKey... keys) { 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() { @@ -253,22 +234,22 @@ private void createDownloadManager() { runOnMainThread( () -> { Factory fakeDataSourceFactory = new FakeDataSource.Factory().setFakeDataSet(fakeDataSet); + DefaultDownloaderFactory downloaderFactory = + new DefaultDownloaderFactory( + new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(fakeDataSourceFactory), + /* executor= */ Runnable::run); downloadManager = new DownloadManager( - ApplicationProvider.getApplicationContext(), - downloadIndex, - new DefaultDownloaderFactory( - new DownloaderConstructorHelper(cache, fakeDataSourceFactory))); + ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory); downloadManager.setRequirements(new Requirements(0)); - - downloadManagerListener = - new TestDownloadManagerListener( - downloadManager, dummyMainThread, /* timeoutMs= */ 3000); + downloadManagerListener = new TestDownloadManagerListener(downloadManager); downloadManager.resumeDownloads(); }); } 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 0bf50891171..6b528cdd824 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 @@ -33,7 +33,6 @@ 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.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.scheduler.Scheduler; import com.google.android.exoplayer2.testutil.DummyMainThread; @@ -42,9 +41,11 @@ import com.google.android.exoplayer2.testutil.TestDownloadManagerListener; import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.upstream.DataSource; +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.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,18 +109,20 @@ public void setUp() throws IOException { fakeStreamKey1 = new StreamKey(0, 0, 0); fakeStreamKey2 = new StreamKey(0, 1, 0); - dummyMainThread.runTestOnMainThread( + testThread.runTestOnMainThread( () -> { DefaultDownloadIndex downloadIndex = new DefaultDownloadIndex(TestUtil.getInMemoryDatabaseProvider()); + DefaultDownloaderFactory downloaderFactory = + new DefaultDownloaderFactory( + new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(fakeDataSourceFactory), + /* executor= */ Runnable::run); final DownloadManager dashDownloadManager = new DownloadManager( - ApplicationProvider.getApplicationContext(), - downloadIndex, - new DefaultDownloaderFactory( - new DownloaderConstructorHelper(cache, fakeDataSourceFactory))); - downloadManagerListener = - new TestDownloadManagerListener(dashDownloadManager, dummyMainThread); + ApplicationProvider.getApplicationContext(), downloadIndex, downloaderFactory); + downloadManagerListener = new TestDownloadManagerListener(dashDownloadManager); dashDownloadManager.resumeDownloads(); dashDownloadService = @@ -147,9 +149,9 @@ protected Notification getForegroundNotification(List downloads) { @After public void tearDown() { - dummyMainThread.runOnMainThread(() -> dashDownloadService.onDestroy()); + testThread.runOnMainThread(() -> dashDownloadService.onDestroy()); Util.recursiveDelete(tempFolder); - dummyMainThread.release(); + testThread.release(); } @Ignore // b/78877092 @@ -158,7 +160,7 @@ public void multipleDownloadRequest() throws Throwable { downloadKeys(fakeStreamKey1); downloadKeys(fakeStreamKey2); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCachedData(cache, fakeDataSet); } @@ -168,11 +170,11 @@ public void multipleDownloadRequest() throws Throwable { public void removeAction() throws Throwable { downloadKeys(fakeStreamKey1, fakeStreamKey2); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); removeAll(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCacheEmpty(cache); } @@ -185,13 +187,13 @@ public void removeBeforeDownloadComplete() throws Throwable { removeAll(); - downloadManagerListener.blockUntilTasksCompleteAndThrowAnyDownloadError(); + downloadManagerListener.blockUntilIdleAndThrowAnyFailure(); assertCacheEmpty(cache); } private void removeAll() { - dummyMainThread.runOnMainThread( + testThread.runOnMainThread( () -> { Intent startIntent = DownloadService.buildRemoveDownloadIntent( @@ -204,14 +206,12 @@ private void downloadKeys(StreamKey... keys) { 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( @@ -219,5 +219,4 @@ private void downloadKeys(StreamKey... keys) { dashDownloadService.onStartCommand(startIntent, 0, 0); }); } - } diff --git a/library/extractor/build.gradle b/library/extractor/build.gradle index 26b38705ee7..82c2309c5ff 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 + 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:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion - implementation project(modulePrefix + 'library-common') testImplementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') testImplementation project(modulePrefix + 'testdata') diff --git a/library/extractor/proguard-rules.txt b/library/extractor/proguard-rules.txt index 5f97a491cb3..d79f79a4a16 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 4c3f97975eb..525b335f130 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 abce01b5ef0..4db7edf6854 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 long getTimeUsAtPosition(long position) { * @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 4ab306a2344..38844f61c70 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 void readFully(byte[] target, int offset, int length) throws IOException 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 int skip(int length) throws IOException { 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 int peek(byte[] target, int offset, int length) throws IOException { } 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 @@ private void ensureSpaceForPeek(int length) { * @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 @@ private int readFromPeekBuffer(byte[] target, int offset, int length) { 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 9306a146d57..2eba1b1cca0 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.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 @@ *
    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 @@ */ 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 synchronized DefaultExtractorsFactory setAmrExtractorFlags(@AmrExtractor. } /** - * 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 synchronized DefaultExtractorsFactory setTsExtractorFlags( @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); + 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); } - } 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 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 f1994935006..51fc59fd24f 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 7ef308ef464..94c4a9af94e 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 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 @@ -43,8 +43,10 @@ public void format(Format format) { } @Override - public int sampleData(DataReader input, int length, boolean allowEndOfInput) throws IOException { - int bytesToSkipByReading = Math.min(readBuffer.length, length); + public int sampleData( + DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart) + throws IOException { + int bytesToSkipByReading = min(readBuffer.length, length); int bytesSkipped = input.read(readBuffer, /* offset= */ 0, bytesToSkipByReading); if (bytesSkipped == C.RESULT_END_OF_INPUT) { if (allowEndOfInput) { @@ -56,7 +58,7 @@ public int sampleData(DataReader input, int length, boolean allowEndOfInput) thr } @Override - public void sampleData(ParsableByteArray data, int length) { + public void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart) { data.skipBytes(length); } 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 d1371d56b6b..c3920ca7da4 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 a59cb1d1f24..95b1daeb6e9 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 @@ */ 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 ee29f376a1e..97ae74b9d23 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ExtractorsFactory.java @@ -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 264c6d7b0d6..fc1b1213260 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 static boolean checkFrameHeaderFromPeek( 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 static long getFirstSampleNumber( 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 @@ private static boolean checkAndReadCrc(ParsableByteArray data, int frameStartPos 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 65e65c401e8..922ef0f3da2 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.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 static Metadata peekId3Metadata(ExtractorInput input, boolean parseData) */ 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 static Metadata readId3Metadata(ExtractorInput input, boolean parseData) */ 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 static FlacStreamMetadata.SeekTable readSeekTableMetadataBlock(ParsableBy 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 static FlacStreamMetadata.SeekTable readSeekTableMetadataBlock(ParsableBy 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 @@ private static FlacStreamMetadata readStreamInfoBlock(ExtractorInput input) thro 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 @@ private static List readVorbisCommentMetadataBlock(ExtractorInput input, 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 cda6a805f55..3c78f7a7ddf 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 Metadata peekId3Data( @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 Metadata peekId3Data( 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 3e95fab2093..b071237cf53 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.extractor; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -22,6 +23,9 @@ import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.EOFException; import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Arrays; /** @@ -94,6 +98,54 @@ public int hashCode() { } + /** Defines the part of the sample data to which a call to {@link #sampleData} corresponds. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({SAMPLE_DATA_PART_MAIN, SAMPLE_DATA_PART_ENCRYPTION, SAMPLE_DATA_PART_SUPPLEMENTAL}) + @interface SampleDataPart {} + + /** Main media sample data. */ + int SAMPLE_DATA_PART_MAIN = 0; + /** + * Sample encryption data. + * + *

        The format for encryption information is: + * + *

          + *
        • (1 byte) {@code encryption_signal_byte}: Most significant bit signals whether the + * encryption data contains subsample encryption data. The remaining bits contain {@code + * initialization_vector_size}. + *
        • ({@code initialization_vector_size} bytes) Initialization vector. + *
        • If subsample encryption data is present, as per {@code encryption_signal_byte}, the + * encryption data also contains: + *
            + *
          • (2 bytes) {@code subsample_encryption_data_length}. + *
          • ({@code subsample_encryption_data_length * 6} bytes) Subsample encryption data + * (repeated {@code subsample_encryption_data_length} times: + *
              + *
            • (2 bytes) Size of a clear section in sample. + *
            • (4 bytes) Size of an encryption section in sample. + *
            + *
          + *
        + */ + int SAMPLE_DATA_PART_ENCRYPTION = 1; + /** + * 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; + /** * Called when the {@link Format} of the track has been extracted from the stream. * @@ -101,6 +153,22 @@ public int hashCode() { */ void format(Format format); + /** + * Equivalent to {@link #sampleData(DataReader, int, boolean, int) sampleData(input, length, + * allowEndOfInput, SAMPLE_DATA_PART_MAIN)}. + */ + default int sampleData(DataReader input, int length, boolean allowEndOfInput) throws IOException { + return sampleData(input, length, allowEndOfInput, SAMPLE_DATA_PART_MAIN); + } + + /** + * Equivalent to {@link #sampleData(ParsableByteArray, int, int)} sampleData(data, length, + * SAMPLE_DATA_PART_MAIN)}. + */ + default void sampleData(ParsableByteArray data, int length) { + sampleData(data, length, SAMPLE_DATA_PART_MAIN); + } + /** * Called to write sample data to the output. * @@ -109,18 +177,22 @@ public int hashCode() { * @param allowEndOfInput True if encountering the end of the input having read no data is * allowed, and should result in {@link C#RESULT_END_OF_INPUT} being returned. False if it * should be considered an error, causing an {@link EOFException} to be thrown. + * @param sampleDataPart The part of the sample data to which this call corresponds. * @return The number of bytes appended. * @throws IOException If an error occurred reading from the input. */ - int sampleData(DataReader input, int length, boolean allowEndOfInput) throws IOException; + int sampleData( + DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart) + throws IOException; /** * Called to write sample data to the output. * * @param data A {@link ParsableByteArray} from which to read the sample data. * @param length The number of bytes to read, starting from {@code data.getPosition()}. + * @param sampleDataPart The part of the sample data to which this call corresponds. */ - void sampleData(ParsableByteArray data, int length); + void sampleData(ParsableByteArray data, int length, @SampleDataPart int sampleDataPart); /** * Called when metadata associated with a sample has been extracted from the stream. 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 b498be4a334..7ec9c93832f 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 boolean readBit() { */ 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 67d469b759d..ede2ab39e99 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 static VorbisIdHeader readVorbisIdentificationHeader(ParsableByteArray he 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 static Mode[] readVorbisModes(ParsableByteArray headerData, int channels) 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 03fd1e792ad..70c8395131c 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 @@ public FlacBinarySearchSeeker( /* floorBytePosition= */ firstFramePosition, /* ceilingBytePosition= */ inputLength, /* approxBytesPerFrame= */ flacStreamMetadata.getApproxBytesPerFrame(), - /* minimumSearchRange= */ Math.max( + /* minimumSearchRange= */ max( FlacConstants.MIN_FRAME_HEADER_SIZE, flacStreamMetadata.minFrameSize)); } @@ -81,7 +83,7 @@ public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targe 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 f0da2656a1c..48fc13a7355 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 void seek(long position, long timeUs) { } currentFrameFirstSampleNumber = timeUs == 0 ? 0 : SAMPLE_NUMBER_UNKNOWN; currentFrameBytesWritten = 0; - buffer.reset(); + buffer.reset(/* limit= */ 0); } @Override @@ -218,7 +226,7 @@ private void readMetadataBlocks(ExtractorInput input) throws IOException { } 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 @@ private void getFrameStartMarker(ExtractorInput input) throws IOException { 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 @@ private void getFrameStartMarker(ExtractorInput input) throws IOException { // 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 @@ private void getFrameStartMarker(ExtractorInput input) throws IOException { // 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 98c5fa73a49..a90410c02dc 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.Extractor; @@ -98,21 +100,21 @@ public FlvExtractor() { @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(); @@ -120,7 +122,7 @@ public boolean sniff(ExtractorInput input) throws IOException { 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; @@ -182,7 +184,7 @@ public int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce */ @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; } @@ -228,7 +230,7 @@ private void skipToTagHeader(ExtractorInput input) throws IOException { * @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; } @@ -284,12 +286,12 @@ private boolean readTagData(ExtractorInput input) throws IOException { 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 806cc9fad44..54594ed50fc 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 @@ 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; @@ -65,11 +64,11 @@ protected boolean parseHeader(ParsableByteArray data) { } @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)) { @@ -126,7 +125,7 @@ private static String readAmfString(ParsableByteArray data) { 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 891b228dbb8..c91f6ce0371 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 @@ protected boolean parsePayload(ParsableByteArray data, long timeUs) throws Parse // 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 @@ protected boolean parsePayload(ParsableByteArray data, long timeUs) throws Parse // 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 @@ protected boolean parsePayload(ParsableByteArray data, long timeUs) throws Parse 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 6e66049d132..660605ebe5d 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; @@ -23,6 +26,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; 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.MpegAudioUtil; import com.google.android.exoplayer2.drm.DrmInitData; @@ -44,6 +48,7 @@ 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; @@ -54,8 +59,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.UUID; import org.checkerframework.checker.nullness.compatqual.NullableType; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -164,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; @@ -228,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; @@ -243,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". */ @@ -278,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,". */ @@ -296,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. */ @@ -319,6 +340,18 @@ public class MatroskaExtractor implements Extractor { */ private static final UUID WAVE_SUBFORMAT_PCM = new UUID(0x0100000000001000L, 0x800000AA00389B71L); + /** Some HTC devices signal rotation in track names. */ + private static final Map TRACK_NAME_TO_ROTATION_DEGREES; + + static { + Map trackNameToRotationDegrees = new HashMap<>(); + trackNameToRotationDegrees.put("htc_video_rotA-000", 0); + trackNameToRotationDegrees.put("htc_video_rotA-090", 90); + trackNameToRotationDegrees.put("htc_video_rotA-180", 180); + trackNameToRotationDegrees.put("htc_video_rotA-270", 270); + TRACK_NAME_TO_ROTATION_DEGREES = Collections.unmodifiableMap(trackNameToRotationDegrees); + } + private final EbmlReader reader; private final VarintReader varintReader; private final SparseArray tracks; @@ -483,6 +516,7 @@ protected int getElementType(int id) { 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: @@ -517,6 +551,7 @@ protected int getElementType(int id) { 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: @@ -544,6 +579,7 @@ protected int getElementType(int id) { 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: @@ -796,6 +832,9 @@ protected void integerElement(int id, long value) throws ParserException { 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; @@ -1053,11 +1092,14 @@ protected void stringElement(int id, String value) throws ParserException { 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); @@ -1089,7 +1131,7 @@ protected void binaryElement(int id, int contentSize, ExtractorInput input) thro blockTrackNumberLength = varintReader.getLastLength(); blockDurationUs = C.TIME_UNSET; blockState = BLOCK_STATE_HEADER; - scratch.reset(); + scratch.reset(/* limit= */ 0); } Track track = tracks.get(blockTrackNumber); @@ -1104,7 +1146,7 @@ protected void binaryElement(int id, int contentSize, ExtractorInput input) thro 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); @@ -1112,7 +1154,7 @@ protected void binaryElement(int id, int contentSize, ExtractorInput input) thro } 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 = @@ -1126,7 +1168,7 @@ protected void binaryElement(int id, int contentSize, ExtractorInput input) thro 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]; @@ -1139,20 +1181,20 @@ protected void binaryElement(int id, int contentSize, ExtractorInput input) thro 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) { @@ -1179,13 +1221,12 @@ protected void binaryElement(int id, int contentSize, ExtractorInput input) thro } } - 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; } @@ -1227,13 +1268,25 @@ protected void binaryElement(int id, int contentSize, ExtractorInput input) thro } } + 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); @@ -1251,7 +1304,7 @@ private void commitSampleToOutput( } 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()); @@ -1267,7 +1320,8 @@ private void commitSampleToOutput( } else { // Append supplemental data. int blockAdditionalSize = blockAdditionalData.limit(); - track.output.sampleData(blockAdditionalData, blockAdditionalSize); + track.output.sampleData( + blockAdditionalData, blockAdditionalSize, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL); size += blockAdditionalSize; } } @@ -1285,10 +1339,11 @@ private void readScratch(ExtractorInput input, int requiredLength) throws IOExce 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); } @@ -1317,12 +1372,12 @@ private int writeSampleData(ExtractorInput input, Track track, int size) throws // 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; @@ -1330,22 +1385,26 @@ private int writeSampleData(ExtractorInput input, Track track, int size) throws 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); + output.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); sampleBytesWritten++; // Write the IV. encryptionInitializationVector.setPosition(0); - output.sampleData(encryptionInitializationVector, ENCRYPTION_IV_SIZE); + output.sampleData( + encryptionInitializationVector, + ENCRYPTION_IV_SIZE, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); sampleBytesWritten += ENCRYPTION_IV_SIZE; } if (hasSubsampleEncryption) { if (!samplePartitionCountRead) { - input.readFully(scratch.data, 0, 1); + input.readFully(scratch.getData(), 0, 1); sampleBytesRead++; scratch.setPosition(0); samplePartitionCount = scratch.readUnsignedByte(); @@ -1353,7 +1412,7 @@ private int writeSampleData(ExtractorInput input, Track track, int size) throws } 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; @@ -1388,7 +1447,10 @@ private int writeSampleData(ExtractorInput input, Track track, int size) throws encryptionSubsampleDataBuffer.putInt(0); } encryptionSubsampleData.reset(encryptionSubsampleDataBuffer.array(), subsampleDataSize); - output.sampleData(encryptionSubsampleData, subsampleDataSize); + output.sampleData( + encryptionSubsampleData, + subsampleDataSize, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); sampleBytesWritten += subsampleDataSize; } } @@ -1399,15 +1461,15 @@ private int writeSampleData(ExtractorInput input, Track track, int size) throws 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); - output.sampleData(scratch, 4); + 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; } @@ -1420,7 +1482,7 @@ private int writeSampleData(ExtractorInput input, Track track, int size) throws // 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; @@ -1497,7 +1559,7 @@ private void resetWriteSampleData() { samplePartitionCount = 0; sampleSignalByte = (byte) 0; sampleInitializationVectorRead = false; - sampleStrippedBytes.reset(); + sampleStrippedBytes.reset(/* limit= */ 0); } private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, int size) @@ -1506,11 +1568,11 @@ private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, 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. @@ -1577,7 +1639,7 @@ private static byte[] formatSubtitleTimecode( */ 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); @@ -1593,7 +1655,7 @@ private int writeToOutput(ExtractorInput input, TrackOutput output, int length) 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); @@ -1724,7 +1786,7 @@ private static int[] ensureArrayCapacity(int[] array, int length) { 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)]; } } @@ -1839,7 +1901,7 @@ public void outputPendingSampleMetadata(Track track) { 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. */ @@ -1857,6 +1919,7 @@ private static final class Track { public int type; public int defaultSampleDurationNs; public int maxBlockAdditionId; + private int blockAddIdType; public boolean hasContentEncryption; public byte[] sampleStrippedBytes; public TrackOutput.CryptoData cryptoData; @@ -1895,6 +1958,7 @@ private static final class Track { 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; @@ -1919,6 +1983,7 @@ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserE int maxInputSize = Format.NO_VALUE; @C.PcmEncoding int pcmEncoding = Format.NO_VALUE; @Nullable List initializationData = null; + @Nullable String codecs = null; switch (codecId) { case CODEC_ID_VP8: mimeType = MimeTypes.VIDEO_VP8; @@ -1980,6 +2045,12 @@ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserE case CODEC_ID_AAC: mimeType = MimeTypes.AUDIO_AAC; initializationData = Collections.singletonList(codecPrivate); + AacUtil.Config aacConfig = AacUtil.parseAudioSpecificConfig(codecPrivate); + // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, + // which is more reliable. See [Internal: b/10903778]. + sampleRate = aacConfig.sampleRateHz; + channelCount = aacConfig.channelCount; + codecs = aacConfig.codecs; break; case CODEC_ID_MP2: mimeType = MimeTypes.AUDIO_MPEG_L2; @@ -2058,6 +2129,16 @@ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserE 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; @@ -2088,15 +2169,9 @@ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserE colorInfo = new ColorInfo(colorSpace, colorRange, colorTransfer, hdrStaticInfo); } int rotationDegrees = Format.NO_VALUE; - // Some HTC devices signal rotation in track names. - if ("htc_video_rotA-000".equals(name)) { - rotationDegrees = 0; - } else if ("htc_video_rotA-090".equals(name)) { - rotationDegrees = 90; - } else if ("htc_video_rotA-180".equals(name)) { - rotationDegrees = 180; - } else if ("htc_video_rotA-270".equals(name)) { - rotationDegrees = 270; + + if (TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + rotationDegrees = TRACK_NAME_TO_ROTATION_DEGREES.get(name); } if (projectionType == C.PROJECTION_RECTANGULAR && Float.compare(projectionPoseYaw, 0f) == 0 @@ -2136,6 +2211,10 @@ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserE throw new ParserException("Unexpected MIME type."); } + if (!TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + formatBuilder.setLabel(name); + } + Format format = formatBuilder .setId(trackId) @@ -2144,6 +2223,7 @@ public void initializeOutput(ExtractorOutput output, int trackId) throws ParserE .setLanguage(language) .setSelectionFlags(selectionFlags) .setInitializationData(initializationData) + .setCodecs(codecs) .setDrmInitData(drmInitData) .build(); @@ -2217,7 +2297,7 @@ private byte[] getHdrStaticInfo() { // 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 d380fa47c71..415d3d45460 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 @@ public boolean sniff(ExtractorInput input) throws 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 @@ public boolean sniff(ExtractorInput input) throws 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 @@ private long readUint(ExtractorInput input) throws 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/MlltSeeker.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java index 1b627483f08..f30b8302497 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java @@ -29,9 +29,11 @@ * * @param firstFramePosition The position of the start of the first frame in the stream. * @param mlltFrame The MLLT frame with seeking metadata. + * @param durationUs The stream duration in microseconds, or {@link C#TIME_UNSET} if it is + * unknown. * @return An {@link MlltSeeker} for seeking in the stream. */ - public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame) { + public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame, long durationUs) { int referenceCount = mlltFrame.bytesDeviations.length; long[] referencePositions = new long[1 + referenceCount]; long[] referenceTimesMs = new long[1 + referenceCount]; @@ -45,19 +47,22 @@ public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame) { referencePositions[i] = position; referenceTimesMs[i] = timeMs; } - return new MlltSeeker(referencePositions, referenceTimesMs); + return new MlltSeeker(referencePositions, referenceTimesMs, durationUs); } private final long[] referencePositions; private final long[] referenceTimesMs; private final long durationUs; - private MlltSeeker(long[] referencePositions, long[] referenceTimesMs) { + private MlltSeeker(long[] referencePositions, long[] referenceTimesMs, long durationUs) { this.referencePositions = referencePositions; this.referenceTimesMs = referenceTimesMs; - // Use the last reference point as the duration, as extrapolating variable bitrate at the end of - // the stream may give a large error. - durationUs = C.msToUs(referenceTimesMs[referenceTimesMs.length - 1]); + // Use the last reference point as the duration if it is unknown, as extrapolating variable + // bitrate at the end of the stream may give a large error. + this.durationUs = + durationUs != C.TIME_UNSET + ? durationUs + : C.msToUs(referenceTimesMs[referenceTimesMs.length - 1]); } @Override diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java index b9613f38f5d..c2aba6d7bd6 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -35,6 +35,7 @@ import com.google.android.exoplayer2.metadata.id3.Id3Decoder; import com.google.android.exoplayer2.metadata.id3.Id3Decoder.FramePredicate; import com.google.android.exoplayer2.metadata.id3.MlltFrame; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; @@ -109,7 +110,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 +415,7 @@ private boolean peekEndOfStreamOrHeader(ExtractorInput extractorInput) throws IO } 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; } @@ -432,7 +433,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException { @Nullable Seeker resultSeeker = null; if ((flags & FLAG_ENABLE_INDEX_SEEKING) != 0) { - long durationUs = C.TIME_UNSET; + long durationUs; long dataEndPosition = C.POSITION_UNSET; if (metadataSeeker != null) { durationUs = metadataSeeker.getDurationUs(); @@ -440,6 +441,8 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException { } else if (seekFrameSeeker != null) { durationUs = seekFrameSeeker.getDurationUs(); dataEndPosition = seekFrameSeeker.getDataEndPosition(); + } else { + durationUs = getId3TlenUs(metadata); } resultSeeker = new IndexSeeker( @@ -471,7 +474,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException { @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 +486,7 @@ private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException { // 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 +508,7 @@ private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException { /** 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); @@ -554,10 +557,24 @@ private static MlltSeeker maybeHandleSeekMetadata( for (int i = 0; i < length; i++) { Metadata.Entry entry = metadata.get(i); if (entry instanceof MlltFrame) { - return MlltSeeker.create(firstFramePosition, (MlltFrame) entry); + return MlltSeeker.create(firstFramePosition, (MlltFrame) entry, getId3TlenUs(metadata)); } } } return null; } + + private static long getId3TlenUs(@Nullable Metadata metadata) { + if (metadata != null) { + int length = metadata.length(); + for (int i = 0; i < length; i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof TextInformationFrame + && ((TextInformationFrame) entry).id.equals("TLEN")) { + return C.msToUs(Long.parseLong(((TextInformationFrame) entry).value)); + } + } + } + return C.TIME_UNSET; + } } 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 29584e7be76..daf5265ddda 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 @@ public static VbriSeeker create( 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 9f31fba25ec..d95721be5d7 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 @@ public static XingSeeker create( 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/Atom.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java index e86a873ed56..325dc24aeca 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -115,6 +115,9 @@ @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_mp4a = 0x6d703461; + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE__mp2 = 0x2e6d7032; + @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE__mp3 = 0x2e6d7033; @@ -274,9 +277,6 @@ @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_TTML = 0x54544d4c; - @SuppressWarnings("ConstantCaseForConstants") - public static final int TYPE_vmhd = 0x766d6864; - @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_mp4v = 0x6d703476; @@ -358,6 +358,9 @@ @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_camm = 0x63616d6d; + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mett = 0x6d657474; + @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_alac = 0x616c6163; diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java index 3cf858558a4..573451ef6a2 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -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.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.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 @@ 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 @@ * @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 @@ public static Track parseTrak( 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 @@ public static Track parseTrak( } else { durationUs = Util.scaleLargeTimestamp(duration, C.MICROS_PER_SECOND, movieTimescale); } - Atom.ContainerAtom stbl = 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); + Atom.ContainerAtom stbl = + checkNotNull( + checkNotNull(mdia.getContainerAtomOfType(Atom.TYPE_minf)) + .getContainerAtomOfType(Atom.TYPE_stbl)); + + 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 @@ public static Track parseTrak( } /** - * 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 @@ public static Track parseTrak( * @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 @@ public static TrackSampleTable parseStbl( /* 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 @@ public static TrackSampleTable parseStbl( @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 @@ public static TrackSampleTable parseStbl( 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 @@ public static TrackSampleTable parseStbl( 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 @@ public static TrackSampleTable parseStbl( flags[i] = C.BUFFER_FLAG_KEY_FRAME; remainingSynchronizationSamples--; if (remainingSynchronizationSamples > 0) { - nextSynchronizationSampleIndex = stss.readUnsignedIntToInt() - 1; + nextSynchronizationSampleIndex = checkNotNull(stss).readUnsignedIntToInt() - 1; } } @@ -308,7 +479,7 @@ public static TrackSampleTable parseStbl( 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 @@ public static TrackSampleTable parseStbl( // 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 @@ public static TrackSampleTable parseStbl( + 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 @@ public static TrackSampleTable parseStbl( 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 @@ public static TrackSampleTable parseStbl( // 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 @@ public static TrackSampleTable parseStbl( 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 @@ public static TrackSampleTable parseStbl( 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 @@ public static TrackSampleTable parseStbl( 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 @@ private static Metadata parseIlst(ParsableByteArray ilst, int limit) { } /** - * 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 @@ private static long parseMvhd(ParsableByteArray mvhd) { } /** - * 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 @@ private static TkhdData parseTkhd(ParsableByteArray tkhd) { 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 @@ private static int getTrackTypeForHdlr(int hdlr) { } /** - * 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 @@ private static Pair parseMdhd(ParsableByteArray mdhd) { } /** - * 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. @@ -805,6 +878,7 @@ private static StsdData parseStsd( || childAtomType == Atom.TYPE_lpcm || childAtomType == Atom.TYPE_sowt || childAtomType == Atom.TYPE_twos + || childAtomType == Atom.TYPE__mp2 || childAtomType == Atom.TYPE__mp3 || childAtomType == Atom.TYPE_alac || childAtomType == Atom.TYPE_alaw @@ -818,6 +892,8 @@ private static StsdData parseStsd( || childAtomType == Atom.TYPE_c608) { parseTextSampleEntry(stsd, childAtomType, childStartPosition, childAtomSize, trackId, language, out); + } else if (childAtomType == Atom.TYPE_mett) { + parseMetaDataSampleEntry(stsd, childAtomType, childStartPosition, trackId, out); } else if (childAtomType == Atom.TYPE_camm) { out.format = new Format.Builder() @@ -841,7 +917,7 @@ private static void parseTextSampleEntry( 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 +928,7 @@ private static void parseTextSampleEntry( 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 +1046,7 @@ private static void parseVideoSampleEntry( 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); @@ -1024,8 +1100,20 @@ private static void parseVideoSampleEntry( .build(); } + private static void parseMetaDataSampleEntry( + ParsableByteArray parent, int atomType, int position, int trackId, StsdData out) { + parent.setPosition(position + Atom.HEADER_SIZE + StsdData.STSD_HEADER_SIZE); + if (atomType == Atom.TYPE_mett) { + parent.readNullTerminatedString(); // Skip optional content_encoding + @Nullable String mimeType = parent.readNullTerminatedString(); + if (mimeType != null) { + out.format = new Format.Builder().setId(trackId).setSampleMimeType(mimeType).build(); + } + } + } + /** - * Parses the edts atom (defined in 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 @@ -1156,7 +1244,7 @@ private static void parseAudioSampleEntry( } else if (atomType == Atom.TYPE_twos) { mimeType = MimeTypes.AUDIO_RAW; pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN; - } else if (atomType == Atom.TYPE__mp3) { + } else if (atomType == Atom.TYPE__mp2 || atomType == Atom.TYPE__mp3) { mimeType = MimeTypes.AUDIO_MPEG; } else if (atomType == Atom.TYPE_alac) { mimeType = MimeTypes.AUDIO_ALAC; @@ -1170,7 +1258,7 @@ private static void parseAudioSampleEntry( 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 +1271,17 @@ private static void parseAudioSampleEntry( 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 +1310,32 @@ private static void parseAudioSampleEntry( // 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 +1349,7 @@ private static void parseAudioSampleEntry( .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) - .setInitializationData( - initializationData == null ? null : Collections.singletonList(initializationData)) + .setInitializationData(initializationData) .setDrmInitData(drmInitData) .setLanguage(language) .build(); @@ -1287,7 +1379,7 @@ private static int findEsdsPosition(ParsableByteArray parent, int position, int 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 +1395,13 @@ private static int findEsdsPosition(ParsableByteArray parent, int position, int 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 +1433,9 @@ private static Pair parseSampleEntryEncryptionData( 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 +1534,14 @@ private static byte[] parseProjFromParent(ParsableByteArray parent, int position 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 536f70048c1..5ebc0a3587d 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 @@ public static Results rechunk(int fixedSampleSize, long[] chunkOffsets, int[] ch 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 359ccc13dc8..859ce49b26f 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.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.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.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 FragmentedMp4Extractor( 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 boolean sniff(ExtractorInput input) throws IOException { @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 void init(ExtractorOutput output) { 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 @@ private void enterReadingAtomHeaderState() { 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 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { 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 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { } 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 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { 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 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { 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 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { 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 @@ private void onContainerAtomRead(ContainerAtom container) throws ParserException } 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 @@ private void onMoovContainerAtomRead(ContainerAtom moov) throws ParserException } } - // 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 @@ private DefaultSampleValues getDefaultSampleValues( // 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 @@ private void onMoofContainerAtomRead(ContainerAtom moof) throws ParserException } } - 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 @@ private void onEmsgLeafAtomRead(ParsableByteArray atom) { 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 @@ private void onEmsgLeafAtomRead(ParsableByteArray atom) { 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 @@ private static void parseMoof(ContainerAtom moof, SparseArray track */ 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 @@ private static void parseTraf(ContainerAtom traf, SparseArray track 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 @@ private static void parseTraf(ContainerAtom traf, SparseArray track } } - 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 @@ private static void parseTruns( 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 @@ private static void parseSaiz(TrackEncryptionBox encryptionBox, ParsableByteArra 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 @@ private static void parseSaiz(TrackEncryptionBox encryptionBox, ParsableByteArra 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 @@ private static long parseTfdt(ParsableByteArray tfdt) { * @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 @@ private static long parseTfdt(ParsableByteArray tfdt) { private static int parseTrun( TrackBundle trackBundle, int index, - long decodeTime, @Flags int flags, ParsableByteArray trun, int trackRunStart) @@ -941,9 +967,9 @@ private static int parseTrun( 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; @@ -965,20 +991,20 @@ private static int parseTrun( // Offset to the entire video timeline. In the presence of B-frames this is usually used to // ensure that the first frame's presentation timestamp is zero. - long edtsOffset = 0; + long edtsOffsetUs = 0; // Currently we only support a single edit that moves the entire media timeline (indicated by // duration == 0). Other uses of edit lists are uncommon and unsupported. if (track.editListDurations != null && track.editListDurations.length == 1 && track.editListDurations[0] == 0) { - edtsOffset = + edtsOffsetUs = Util.scaleLargeTimestamp( - track.editListMediaTimes[0], C.MILLIS_PER_SECOND, track.timescale); + castNonNull(track.editListMediaTimes)[0], C.MICROS_PER_SECOND, track.timescale); } int[] sampleSizeTable = fragment.sampleSizeTable; - int[] sampleCompositionTimeOffsetTable = fragment.sampleCompositionTimeOffsetTable; - long[] sampleDecodingTimeTable = fragment.sampleDecodingTimeTable; + int[] sampleCompositionTimeOffsetUsTable = fragment.sampleCompositionTimeOffsetUsTable; + long[] sampleDecodingTimeUsTable = fragment.sampleDecodingTimeUsTable; boolean[] sampleIsSyncFrameTable = fragment.sampleIsSyncFrameTable; boolean workaroundEveryVideoFrameIsSyncFrame = track.type == C.TRACK_TYPE_VIDEO @@ -986,15 +1012,17 @@ private static int parseTrun( 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 @@ -1002,13 +1030,16 @@ private static int parseTrun( // here, because unsigned integers will still be parsed correctly (unless their top bit is // set, which is never true in practice because sample offsets are always small). int sampleOffset = trun.readInt(); - sampleCompositionTimeOffsetTable[i] = - (int) ((sampleOffset * C.MILLIS_PER_SECOND) / timescale); + sampleCompositionTimeOffsetUsTable[i] = + (int) ((sampleOffset * C.MICROS_PER_SECOND) / timescale); } else { - sampleCompositionTimeOffsetTable[i] = 0; + sampleCompositionTimeOffsetUsTable[i] = 0; + } + sampleDecodingTimeUsTable[i] = + Util.scaleLargeTimestamp(cumulativeTime, C.MICROS_PER_SECOND, timescale) - edtsOffsetUs; + if (!fragment.nextFragmentDecodeTimeIncludesMoov) { + sampleDecodingTimeUsTable[i] += trackBundle.moovSampleTable.durationUs; } - sampleDecodingTimeTable[i] = - Util.scaleLargeTimestamp(cumulativeTime, C.MILLIS_PER_SECOND, timescale) - edtsOffset; sampleSizeTable[i] = sampleSize; sampleIsSyncFrameTable[i] = ((sampleFlags >> 16) & 0x1) == 0 && (!workaroundEveryVideoFrameIsSyncFrame || i == 0); @@ -1058,8 +1089,16 @@ private static void parseSenc(ParsableByteArray senc, int offset, TrackFragment 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 @@ private static void parseSenc(ParsableByteArray senc, int offset, TrackFragment 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 @@ private static void parseSgpd( 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 @@ private static void parseSgpd( 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 @@ private static Pair parseSidx(ParsableByteArray atom, long inp } 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 @@ private void readEncryptionData(ExtractorInput input) throws IOException { * @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.getSamplePresentationTime(sampleIndex) * 1000L; + 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 @@ private boolean readSample(ExtractorInput input) throws IOException { 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 @@ private boolean readSample(ExtractorInput input) throws IOException { 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 @@ private boolean readSample(ExtractorInput input) throws IOException { } } - @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 @@ private boolean readSample(ExtractorInput input) throws IOException { // 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 @@ private void outputPendingMetadataSamples(long sampleTimeUs) { } /** - * 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 @@ private static DrmInitData getDrmInitDataFromAtoms(List leafChild 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 @@ private static DrmInitData getDrmInitDataFromAtoms(List leafChild /** 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 @@ private static final class TrackBundle { 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 @@ private static final class TrackBundle { 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; } /** @@ -1545,10 +1628,9 @@ public void reset() { * @param timeUs The seek time, in microseconds. */ public void seek(long timeUs) { - long timeMs = C.usToMs(timeUs); int searchIndex = currentSampleIndex; while (searchIndex < fragment.sampleCount - && fragment.getSamplePresentationTime(searchIndex) < timeMs) { + && fragment.getSamplePresentationTimeUs(searchIndex) < timeUs) { if (fragment.sampleIsSyncFrameTable[searchIndex]) { firstSampleToOutputIndex = searchIndex; } @@ -1556,16 +1638,58 @@ public void seek(long timeUs) { } } + /** 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. + * + *

        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 the next sample is in the same track run as the previous one. + * @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++; @@ -1578,6 +1702,8 @@ public boolean next() { /** * 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 @@ -1585,7 +1711,7 @@ public boolean next() { * @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; } @@ -1597,7 +1723,7 @@ public int outputSampleEncryptionData(int sampleSize, int clearHeaderSize) { 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; @@ -1608,12 +1734,13 @@ public int outputSampleEncryptionData(int sampleSize, int clearHeaderSize) { 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); + output.sampleData(encryptionSignalByte, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); // Write the vector. - output.sampleData(initializationVectorData, vectorSize); + output.sampleData( + initializationVectorData, vectorSize, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); if (!writeSubsampleEncryptionData) { return 1 + vectorSize; @@ -1625,17 +1752,21 @@ public int outputSampleEncryptionData(int sampleSize, int clearHeaderSize) { // 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); - // encryptedDataSize = sampleSize (unsigned short) - 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); - output.sampleData(scratch, SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH); + data[2] = (byte) ((clearHeaderSize >> 8) & 0xFF); + data[3] = (byte) (clearHeaderSize & 0xFF); + // encryptedDataSize = sampleSize (unsigned int) + 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, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); return 1 + vectorSize + SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH; } @@ -1648,22 +1779,27 @@ public int outputSampleEncryptionData(int sampleSize, int clearHeaderSize) { // 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; } - output.sampleData(subsampleEncryptionData, subsampleDataLength); + output.sampleData( + subsampleEncryptionData, subsampleDataLength, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION); 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; @@ -1679,13 +1815,17 @@ private void skipSampleEncryptionData() { } @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 365e336e65d..d29b54a5e58 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 @@ private static Id3Frame parseUint8Attribute( 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 48c7e3e122e..f9e70915bca 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.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 long getDurationUs() { @Override public SeekPoints getSeekPoints(long timeUs) { - if (tracks.length == 0) { + if (checkNotNull(tracks).length == 0) { return new SeekPoints(SeekPoint.START); } @@ -272,7 +279,7 @@ private void enterReadingAtomHeaderState() { 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 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { 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 @@ private boolean readAtomHeader(ExtractorInput input) throws IOException { // 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 @@ private boolean readAtomPayload(ExtractorInput input, PositionHolder positionHol 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 @@ private void processMoovAtom(ContainerAtom moov) throws ParserException { } 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 @@ private void processMoovAtom(ContainerAtom moov) throws ParserException { 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 @@ private int readSample(ExtractorInput input, PositionHolder positionHolder) thro 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 @@ private int readSample(ExtractorInput input, PositionHolder positionHolder) thro 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 @@ private int getTrackIndexOfNextReadSample(long inputPosition) { 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 @@ private int getTrackIndexOfNextReadSample(long inputPosition) { /** * 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 @@ private void maybeSkipRemainingMetaAtomHeaderBytes(ExtractorInput input) throws // (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 @@ private static long maybeAdjustSeekOffset( 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 c661e7be07d..00acb299066 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 @@ 0x71742020, // qt[space][space], Apple QuickTime 0x4d534e56, // MSNV, Sony PSP 0x64627931, // dby1, Dolby Vision + 0x69736d6c, // isml + 0x70696666, // piff }; /** @@ -97,13 +99,19 @@ private static boolean sniffInternal(ExtractorInput input, boolean fragmented) // 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 @@ private static boolean sniffInternal(ExtractorInput input, boolean fragmented) 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 456cd503978..92ce551f488 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 @@ -60,14 +60,10 @@ * The size of each sample in the fragment. */ public int[] sampleSizeTable; - /** - * The composition time offset of each sample in the fragment. - */ - public int[] sampleCompositionTimeOffsetTable; - /** - * The decoding time of each sample in the fragment. - */ - public long[] sampleDecodingTimeTable; + /** The composition time offset of each sample in the fragment, in microseconds. */ + public int[] sampleCompositionTimeOffsetUsTable; + /** The decoding time of each sample in the fragment, in microseconds. */ + public long[] sampleDecodingTimeUsTable; /** * Indicates which samples are sync frames. */ @@ -93,16 +89,23 @@ */ 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]; trunLength = new int[0]; sampleSizeTable = new int[0]; - sampleCompositionTimeOffsetTable = new int[0]; - sampleDecodingTimeTable = new long[0]; + sampleCompositionTimeOffsetUsTable = new int[0]; + sampleDecodingTimeUsTable = new long[0]; sampleIsSyncFrameTable = new boolean[0]; sampleHasSubsampleEncryptionTable = new boolean[0]; sampleEncryptionData = new ParsableByteArray(); @@ -118,6 +121,7 @@ public TrackFragment() { public void reset() { trunCount = 0; nextFragmentDecodeTime = 0; + nextFragmentDecodeTimeIncludesMoov = false; definesEncryptionData = false; sampleEncryptionDataNeedsFill = false; trackEncryptionBox = null; @@ -143,8 +147,8 @@ public void initTables(int trunCount, int sampleCount) { // likely. The choice of 25% is relatively arbitrary. int tableSize = (sampleCount * 125) / 100; sampleSizeTable = new int[tableSize]; - sampleCompositionTimeOffsetTable = new int[tableSize]; - sampleDecodingTimeTable = new long[tableSize]; + sampleCompositionTimeOffsetUsTable = new int[tableSize]; + sampleDecodingTimeUsTable = new long[tableSize]; sampleIsSyncFrameTable = new boolean[tableSize]; sampleHasSubsampleEncryptionTable = new boolean[tableSize]; } @@ -170,7 +174,7 @@ public void initEncryptionData(int length) { * @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; } @@ -181,13 +185,19 @@ public void fillEncryptionData(ExtractorInput input) throws IOException { * @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; } - public long getSamplePresentationTime(int index) { - return sampleDecodingTimeTable[index] + sampleCompositionTimeOffsetTable[index]; + /** + * Returns the sample presentation timestamp in microseconds. + * + * @param index The sample index. + * @return The presentation timestamps of this sample in microseconds. + */ + public long getSamplePresentationTimeUs(int index) { + return sampleDecodingTimeUsTable[index] + sampleCompositionTimeOffsetUsTable[index]; } /** Returns whether the sample at the given index has a subsample encryption table. */ diff --git a/library/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 59ea3863354..ca500b29318 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 @@ 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 1d73a1b66a2..b7a80394896 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 @@ /** 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 @@ private long getNextSeekPosition(ExtractorInput input) throws 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 @@ private long getNextSeekPosition(ExtractorInput input) throws 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 @@ private boolean skipToNextPage(ExtractorInput input, long limit) throws IOExcept */ @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 1d6f0da9a1b..e64e6b1dc20 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 @@ private static boolean isAudioPacket(byte[] data) { @Override protected long preparePayload(ParsableByteArray packet) { - if (!isAudioPacket(packet.data)) { + if (!isAudioPacket(packet.getData())) { return -1; } return getFlacFrameBlockSize(packet); @@ -69,7 +69,7 @@ protected long preparePayload(ParsableByteArray packet) { @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 @@ protected boolean readHeaders(ParsableByteArray packet, long position, SetupData } 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 9aaa3332cea..0dfcc4ef91f 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 @@ private boolean sniffInternal(ExtractorInput input) throws IOException { 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 2ee65f01121..450bff4a369 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 @@ */ public void reset() { pageHeader.reset(); - packetArray.reset(); + packetArray.reset(/* limit= */ 0); currentSegmentIndex = C.INDEX_UNSET; populated = false; } @@ -61,13 +63,13 @@ public boolean populate(ExtractorInput input) throws IOException { 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 @@ public boolean populate(ExtractorInput input) throws IOException { 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 @@ public ParsableByteArray getPayload() { * 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 d96aaa45686..3fa5f880205 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 @@ 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 @@ 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 @@ public void reset() { 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 @@ public void reset() { * @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 @@ public boolean populate(ExtractorInput input, boolean quiet) throws 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 @@ public boolean populate(ExtractorInput input, boolean quiet) throws 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 018fd949b38..8144af7b660 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 @@ */ /* 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 @@ protected void reset(boolean headerData) { @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 @@ protected boolean readHeaders(ParsableByteArray packet, long position, SetupData 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 d6faa909279..7cc193e698b 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 @@ protected void onSeekEnd(long currentGranule) { @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 @@ protected boolean readHeaders(ParsableByteArray packet, long position, SetupData // 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 @@ protected boolean readHeaders(ParsableByteArray packet, long position, SetupData 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 ae30231a501..44e67c955c3 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 RawCcExtractor(Format format) { 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 void release() { } 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 @@ private boolean parseHeader(ExtractorInput input) throws IOException { } 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 @@ private boolean parseTimestampAndSampleCount(ExtractorInput input) throws IOExce @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 f0cb8ca1f72..75839e09177 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 boolean sniff(ExtractorInput input) throws IOException { 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 boolean sniff(ExtractorInput input) throws IOException { 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 boolean sniff(ExtractorInput input) throws IOException { 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 void release() { @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 b025be95e32..bfb828415cb 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 void consume(ParsableByteArray data) { 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 void consume(ParsableByteArray data) { } 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 void packetFinished() { * @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 c493d1d0bdf..996ae2f69b4 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 boolean sniff(ExtractorInput input) throws IOException { 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 boolean sniff(ExtractorInput input) throws IOException { 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 boolean sniff(ExtractorInput input) throws IOException { 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 void release() { @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 517a233530b..0f088836d1a 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 void consume(ParsableByteArray data) { 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 void consume(ParsableByteArray data) { } 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 void packetFinished() { * @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 f870527284d..54a6a20b361 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 AdtsExtractor(@Flags int flags) { 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 boolean sniff(ExtractorInput input) throws IOException { 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 boolean sniff(ExtractorInput input) throws IOException { } // 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 int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce 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 int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce 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 @@ private void calculateAverageFrameSize(ExtractorInput input) throws IOException 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 @@ private void calculateAverageFrameSize(ExtractorInput input) throws IOException } 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 59ab6599b08..5a024b0a153 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 void consume(ParsableByteArray data) throws ParserException { 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 @@ private void resetSync() { * @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 @@ private void setCheckingAdtsHeaderState() { * @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 @@ private void checkAdtsHeader(ParsableByteArray buffer) { 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 @@ private boolean checkSyncPositionValid(ParsableByteArray pesBuffer, int syncPosi // 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 @@ private void parseAdtsHeader() throws ParserException { /** 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 c48c790fbf7..c74b70fdecc 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.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 DefaultTsPayloadReaderFactory() { * 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 TsPayloadReader createPayloadReader(int streamType, EsInfo esInfo) { 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 a201fb72d7c..f4f9e62975f 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 void consume(ParsableByteArray data) { } 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 void consume(ParsableByteArray data) { } 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 void packetFinished() { * @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 @@ private boolean skipToNextSync(ParsableByteArray pesBuffer) { 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 @@ private boolean skipToNextSync(ParsableByteArray pesBuffer) { /** 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 012de812970..898084013fd 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.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 void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { @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 void consume(ParsableByteArray data) { } // 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 void consume(ParsableByteArray data) { 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 void consume(ParsableByteArray data) { 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 void packetFinished() { * 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 00000000000..4db898553cc --- /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 55f5fb34c61..d0bf2067c92 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 @@ 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.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 void consume(ParsableByteArray data) { 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 dcf64d91985..ea23e1ef7a5 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 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; /** @@ -47,6 +49,7 @@ public final class H265Reader implements ElementaryStreamReader { private static final int VPS_NUT = 32; private static final int SPS_NUT = 33; private static final int PPS_NUT = 34; + private static final int AUD_NUT = 35; private static final int PREFIX_SEI_NUT = 39; private static final int SUFFIX_SEI_NUT = 40; @@ -65,7 +68,7 @@ public final class H265Reader implements ElementaryStreamReader { private final NalUnitTargetBuffer sps; private final NalUnitTargetBuffer pps; private final NalUnitTargetBuffer prefixSei; - private final NalUnitTargetBuffer suffixSei; // TODO: Are both needed? + private final NalUnitTargetBuffer suffixSei; private long totalBytesWritten; // Per packet state that gets reset at the start of each packet. @@ -124,7 +127,7 @@ public void consume(ParsableByteArray data) { 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(); @@ -353,7 +356,7 @@ private static void skipScalingList(ParsableNalUnitBitArray bitArray) { // 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(); @@ -424,17 +427,17 @@ private static final class SampleReader { private final TrackOutput output; // Per NAL unit state. A sample consists of one or more NAL units. - private long nalUnitStartPosition; + private long nalUnitPosition; private boolean nalUnitHasKeyframeData; private int nalUnitBytesRead; private long nalUnitTimeUs; private boolean lookingForFirstSliceFlag; private boolean isFirstSlice; - private boolean isFirstParameterSet; + private boolean isFirstPrefixNalUnit; // Per sample state that gets reset at the start of each sample. private boolean readingSample; - private boolean writingParameterSets; + private boolean readingPrefix; private long samplePosition; private long sampleTimeUs; private boolean sampleIsKeyframe; @@ -446,35 +449,33 @@ public SampleReader(TrackOutput output) { public void reset() { lookingForFirstSliceFlag = false; isFirstSlice = false; - isFirstParameterSet = false; + isFirstPrefixNalUnit = false; readingSample = false; - writingParameterSets = false; + readingPrefix = false; } public void startNalUnit( long position, int offset, int nalUnitType, long pesTimeUs, boolean hasOutputFormat) { isFirstSlice = false; - isFirstParameterSet = false; + isFirstPrefixNalUnit = false; nalUnitTimeUs = pesTimeUs; nalUnitBytesRead = 0; - nalUnitStartPosition = position; + nalUnitPosition = position; - if (nalUnitType >= VPS_NUT) { - if (!writingParameterSets && readingSample) { - // This is a non-VCL NAL unit, so flush the previous sample. + if (!isVclBodyNalUnit(nalUnitType)) { + if (readingSample && !readingPrefix) { if (hasOutputFormat) { outputSample(offset); } readingSample = false; } - if (nalUnitType <= PPS_NUT) { - // This sample will have parameter sets at the start. - isFirstParameterSet = !writingParameterSets; - writingParameterSets = true; + if (isPrefixNalUnit(nalUnitType)) { + isFirstPrefixNalUnit = !readingPrefix; + readingPrefix = true; } } - // Look for the flag if this NAL unit contains a slice_segment_layer_rbsp. + // Look for the first slice flag if this NAL unit contains a slice_segment_layer_rbsp. nalUnitHasKeyframeData = (nalUnitType >= BLA_W_LP && nalUnitType <= CRA_NUT); lookingForFirstSliceFlag = nalUnitHasKeyframeData || nalUnitType <= RASL_R; } @@ -492,30 +493,38 @@ public void readNalUnitData(byte[] data, int offset, int limit) { } public void endNalUnit(long position, int offset, boolean hasOutputFormat) { - if (writingParameterSets && isFirstSlice) { + if (readingPrefix && isFirstSlice) { // This sample has parameter sets. Reset the key-frame flag based on the first slice. sampleIsKeyframe = nalUnitHasKeyframeData; - writingParameterSets = false; - } else if (isFirstParameterSet || isFirstSlice) { + readingPrefix = false; + } else if (isFirstPrefixNalUnit || isFirstSlice) { // This NAL unit is at the start of a new sample (access unit). if (hasOutputFormat && readingSample) { // Output the sample ending before this NAL unit. - int nalUnitLength = (int) (position - nalUnitStartPosition); + int nalUnitLength = (int) (position - nalUnitPosition); outputSample(offset + nalUnitLength); } - samplePosition = nalUnitStartPosition; + samplePosition = nalUnitPosition; sampleTimeUs = nalUnitTimeUs; - readingSample = true; sampleIsKeyframe = nalUnitHasKeyframeData; + readingSample = true; } } private void outputSample(int offset) { @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; - int size = (int) (nalUnitStartPosition - samplePosition); + int size = (int) (nalUnitPosition - samplePosition); output.sampleMetadata(sampleTimeUs, flags, size, offset, null); } - } + /** Returns whether a NAL unit type is one that occurs before any VCL NAL units in a sample. */ + private static boolean isPrefixNalUnit(int nalUnitType) { + return (VPS_NUT <= nalUnitType && nalUnitType <= AUD_NUT) || nalUnitType == PREFIX_SEI_NUT; + } + /** Returns whether a NAL unit type is one that occurs in the VLC body of a sample. */ + private static boolean isVclBodyNalUnit(int nalUnitType) { + return nalUnitType < VPS_NUT || nalUnitType == SUFFIX_SEI_NUT; + } + } } diff --git a/library/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 28c54892c45..a50e36b51c0 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 @@ 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 void consume(ParsableByteArray data) { 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 void consume(ParsableByteArray data) { } } // 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 3465d89318c..da477e88e5b 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 void consume(ParsableByteArray data) throws ParserException { 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 @@ private void parsePayloadMux(ParsableBitArray data, int muxLengthBytes) { } 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 @@ private void parsePayloadMux(ParsableBitArray data, int muxLengthBytes) { 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 44870c30256..c89d61df2c6 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.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 MpegAudioReader(@Nullable String language) { 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 void packetFinished() { * @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 @@ private void findHeader(ParsableByteArray source) { 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 @@ private void findHeader(ParsableByteArray source) { */ @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 @@ private void readHeaderRemainder(ParsableByteArray source) { */ @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 f84d323f963..0764087b592 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 void consume(ParsableByteArray data, @Flags int flags) throws Parse } 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 @@ private void setState(int state) { */ 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 09cf9b3f009..3616a0c354b 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 @@ 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 @@ private PsScrSeeker(TimestampAdjuster scrTimestampAdjuster) { 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 @@ private TimestampSearchResult searchForScrValueInBuffer( 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 @@ private static void skipToEndOfCurrentPack(ParsableByteArray packetBuffer) { 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 @@ private static void skipToEndOfCurrentPack(ParsableByteArray packetBuffer) { // 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 @@ private static void skipToEndOfCurrentPack(ParsableByteArray packetBuffer) { } 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 4748b832dea..55218c31f27 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 @@ */ /* 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 @@ private int finishReadDuration(ExtractorInput input) { 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 @@ private int readFirstScrValue(ExtractorInput input, PositionHolder seekPositionH 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 @@ private long readFirstScrValueFromBuffer(ParsableByteArray packetBuffer) { 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 @@ private long readFirstScrValueFromBuffer(ParsableByteArray packetBuffer) { 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 @@ private int readLastScrValue(ExtractorInput input, PositionHolder seekPositionHo 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 @@ private long readLastScrValueFromBuffer(ParsableByteArray packetBuffer) { 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 96bdc226315..4ead98febbf 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 int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce 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 int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce 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 int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce 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 int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce } // 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 int read(ExtractorInput input, PositionHolder seekPosition) throws IOExce } 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 bc590c9d4c9..8d935ad5f3e 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 void consume(ParsableByteArray data, @Flags int flags) { 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 void consume(ParsableByteArray data, @Flags int flags) { (((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 6d8cb0da8c8..9fff73315c7 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.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 8a1d2b2fdf2..82861897806 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 @@ public TsPcrSeeker(int pcrPid, TimestampAdjuster pcrTimestampAdjuster) { 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 @@ private TimestampSearchResult searchForPcrValueInBuffer( 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 a60d3fcb824..5020f4c76da 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 @@ private int finishReadDuration(ExtractorInput input) { 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 @@ private int readFirstPcrValue(ExtractorInput input, PositionHolder seekPositionH 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 @@ private long readFirstPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcr 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 @@ private long readFirstPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcr private int readLastPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) throws IOException { long inputLength = input.getLength(); - int bytesToSearch = (int) 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 @@ private int readLastPcrValue(ExtractorInput input, PositionHolder seekPositionHo 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 @@ private long readLastPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcrP 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 5e85a80a5d8..2fcfd422a01 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 TsExtractor( @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 void seek(long position, long timeUs) { 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 @@ private void maybeOutputSeekMap(long inputLength) { } 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 @@ private boolean fillBufferWithAtLeastOnePacket(ExtractorInput input) throws IOEx 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 void consume(ParsableByteArray sectionData) { // 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 void consume(ParsableByteArray sectionData) { 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 void consume(ParsableByteArray sectionData) { 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 void consume(ParsableByteArray sectionData) { 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 @@ private EsInfo readEsInfo(ParsableByteArray data, int length) { 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 @@ private EsInfo readEsInfo(ParsableByteArray data, int length) { 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 1d7b6b9c6e9..acb06063b57 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 PassthroughOutputWriter( 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 void init(int dataStartPosition, long dataEndPosition) { 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 ImaAdPcmOutputWriter( 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 boolean sampleData(ExtractorInput input, long bytesLeft) throws IOExcepti // 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 @@ private void writeSampleMetadata(int sampleFrames) { 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 @@ private void decodeBlockForChannel( // 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 bcc229f3e93..4387993f509 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 @@ public static WavHeader peek(ExtractorInput input) throws 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 @@ public static WavHeader peek(ExtractorInput input) throws 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 @@ private ChunkHeader(int id, long size) { */ 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 b24c76d2621..ba10f56a513 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 @@ 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.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 @@ 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<>(); - for (Extractor extractor : extractors) { - listCreatedExtractorClasses.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 - }; + 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); + } - assertThat(listCreatedExtractorClasses).containsNoDuplicates(); - assertThat(listCreatedExtractorClasses).containsExactlyElementsIn(expectedExtractorClassses); + @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) { + extractorClasses.add(extractor.getClass()); + } + 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 c95804c2979..caf184a1198 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 9150493ea33..75ef1a201e8 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 void checkAndReadFrameHeader_validData_updatesPosition() throws Exception 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 void checkAndReadFrameHeader_validData_isTrue() throws Exception { 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 void checkAndReadFrameHeader_validData_writesSampleNumber() throws Except 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 void checkAndReadFrameHeader_invalidData_isFalse() throws Exception { 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 void checkAndReadFrameHeader_invalidData_isFalse() throws Exception { @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 void checkFrameHeaderFromPeek_validData_isTrue() throws Exception { 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 void checkFrameHeaderFromPeek_validData_writesSampleNumber() throws Excep 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 void checkFrameHeaderFromPeek_invalidData_isFalse() throws Exception { 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 void checkFrameHeaderFromPeek_invalidData_isFalse() throws Exception { @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 void getFirstSampleNumber_doesNotUpdateReadPositionAndAlignsPeekPosition( 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 void getFirstSampleNumber_returnsSampleNumber() throws Exception { 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 void readFrameBlockSizeSamplesFromKey_keyBetween2and5_returnsCorrectBlock @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 a6a2cd35b68..1648d548d28 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 void peekId3Metadata_updatesPeekPosition() throws Exception { @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 void peekId3Metadata_parseData_returnsNonEmptyMetadata() throws Exception @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 void peekId3Metadata_doNotParseData_returnsNull() throws Exception { @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 void peekId3Metadata_noId3Metadata_returnsNull() throws Exception { @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 void checkAndPeekStreamMarker_updatesPeekPosition() throws Exception { @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 void checkAndPeekStreamMarker_validData_isTrue() throws Exception { @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 void checkAndPeekStreamMarker_invalidData_isFalse() throws Exception { @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 void readId3Metadata_updatesReadPositionAndAlignsPeekPosition() throws Ex @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 void readId3Metadata_parseData_returnsNonEmptyMetadata() throws Exception @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 void readId3Metadata_doNotParseData_returnsNull() throws Exception { @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 void readId3Metadata_noId3Metadata_returnsNull() throws Exception { @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 void readStreamMarker_updatesReadPosition() throws Exception { @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 void readMetadataBlock_updatesReadPositionAndAlignsPeekPosition() throws @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 void readMetadataBlock_lastMetadataBlock_isTrue() throws Exception { @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 void readMetadataBlock_notLastMetadataBlock_isFalse() throws Exception { @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 void readMetadataBlock_streamInfoBlock_setsStreamMetadata() throws Except @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 void readMetadataBlock_seekTableBlock_updatesStreamMetadata() throws Exce @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 void readMetadataBlock_vorbisCommentBlock_updatesStreamMetadata() throws @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 void readMetadataBlock_pictureBlock_updatesStreamMetadata() throws Except @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 void readMetadataBlock_blockToSkip_updatesReadPosition() throws Exception @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 void readMetadataBlock_nonStreamInfoBlockWithNullStreamMetadata_throwsExc @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 void readSeekTableMetadataBlock_updatesPosition() throws Exception { @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 void readSeekTableMetadataBlock_returnsCorrectSeekPoints() throws Excepti @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 void readSeekTableMetadataBlock_ignoresPlaceholders() throws IOException @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 void getFrameStartMarker_doesNotUpdateReadPositionAndAlignsPeekPosition() @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 void getFrameStartMarker_returnsCorrectFrameStartMarker() throws Exceptio @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 482781e615e..9c6b63ee0ab 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 2c7d7ad722d..e0cf957a38d 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 void peekId3Data_returnId3Tag_ifId3TagPresent() throws IOException { 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 void peekId3Data_returnId3TagAccordingToGivenPredicate_ifId3TagPresent() 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 67ac6bd1cca..9f0d6e0ff2b 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 void iLog_returnsHighestSetBit() { 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 void readIdHeader() throws Exception { 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 void readCommentHeader() throws IOException { 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/AmrExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorNonParameterizedTest.java similarity index 86% rename from library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorTest.java rename to library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorNonParameterizedTest.java index 03b6fcb394f..65b8122c4ab 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorNonParameterizedTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018 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. @@ -12,6 +12,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. + * */ package com.google.android.exoplayer2.extractor.amr; @@ -35,9 +36,14 @@ import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link AmrExtractor}. */ +/** + * Tests for {@link AmrExtractor} that test specific behaviours and don't need to be parameterized. + * + *

        For parameterized tests using {@link ExtractorAsserts} see {@link + * AmrExtractorParameterizedTest}. + */ @RunWith(AndroidJUnit4.class) -public final class AmrExtractorTest { +public final class AmrExtractorNonParameterizedTest { private static final Random RANDOM = new Random(1234); @@ -169,30 +175,6 @@ public void read_amrWb_returnParserException_forInvalidFrameHeader() throws IOEx } } - @Test - public void extractingNarrowBandSamples() throws Exception { - ExtractorAsserts.assertBehavior( - createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_nb.amr"); - } - - @Test - public void extractingWideBandSamples() throws Exception { - ExtractorAsserts.assertBehavior( - createAmrExtractorFactory(/* withSeeking= */ false), "amr/sample_wb.amr"); - } - - @Test - public void extractingNarrowBandSamples_withSeeking() throws Exception { - ExtractorAsserts.assertBehavior( - createAmrExtractorFactory(/* withSeeking= */ true), "amr/sample_nb_cbr.amr"); - } - - @Test - public void extractingWideBandSamples_withSeeking() throws Exception { - ExtractorAsserts.assertBehavior( - createAmrExtractorFactory(/* withSeeking= */ true), "amr/sample_wb_cbr.amr"); - } - private byte[] newWideBandAmrFrameWithType(int frameType) { byte frameHeader = (byte) ((frameType << 3) & (0b01111100)); int frameContentInBytes = frameSizeBytesByTypeWb(frameType) - 1; @@ -237,14 +219,4 @@ private static AmrExtractor setupAmrExtractorWithOutput() { private static FakeExtractorInput fakeExtractorInputWithData(byte[] data) { return new FakeExtractorInput.Builder().setData(data).build(); } - - private static ExtractorAsserts.ExtractorFactory createAmrExtractorFactory(boolean withSeeking) { - return () -> { - if (!withSeeking) { - return new AmrExtractor(); - } else { - return new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); - } - }; - } } 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 new file mode 100644 index 00000000000..53913e07cc0 --- /dev/null +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorParameterizedTest.java @@ -0,0 +1,84 @@ +/* + * 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.extractor.amr; + +import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; + +/** + * Unit tests for {@link AmrExtractor} that use parameterization to test a range of behaviours. + * + *

        For non-parameterized tests see {@link AmrExtractorSeekTest} and {@link + * AmrExtractorNonParameterizedTest}. + */ +@RunWith(ParameterizedRobolectricTestRunner.class) +public final class AmrExtractorParameterizedTest { + + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + + @Test + public void extractingNarrowBandSamples() throws Exception { + ExtractorAsserts.assertBehavior( + createAmrExtractorFactory(/* withSeeking= */ false), + "media/amr/sample_nb.amr", + simulationConfig); + } + + @Test + public void extractingWideBandSamples() throws Exception { + ExtractorAsserts.assertBehavior( + createAmrExtractorFactory(/* withSeeking= */ false), + "media/amr/sample_wb.amr", + simulationConfig); + } + + @Test + public void extractingNarrowBandSamples_withSeeking() throws Exception { + ExtractorAsserts.assertBehavior( + createAmrExtractorFactory(/* withSeeking= */ true), + "media/amr/sample_nb_cbr.amr", + simulationConfig); + } + + @Test + public void extractingWideBandSamples_withSeeking() throws Exception { + ExtractorAsserts.assertBehavior( + createAmrExtractorFactory(/* withSeeking= */ true), + "media/amr/sample_wb_cbr.amr", + simulationConfig); + } + + + private static ExtractorAsserts.ExtractorFactory createAmrExtractorFactory(boolean withSeeking) { + return () -> { + if (!withSeeking) { + return new AmrExtractor(); + } else { + return new AmrExtractor(AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING); + } + }; + } +} 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 850321ef5d1..534cb2572f1 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/amr/AmrExtractorSeekTest.java @@ -33,16 +33,16 @@ import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link AmrExtractor}. */ +/** Unit tests for {@link AmrExtractor} seeking behaviour. */ @RunWith(AndroidJUnit4.class) 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 99cf464f68d..16f92e2b4bd 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/flac/FlacExtractorSeekTest.java @@ -37,16 +37,16 @@ @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 fab950ac997..500cdd4e86f 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 @@ -15,103 +15,131 @@ */ package com.google.android.exoplayer2.extractor.flac; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import com.google.android.exoplayer2.testutil.ExtractorAsserts.AssertionConfig; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit tests for {@link FlacExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public class FlacExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void sample() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_flac"); + "media/flac/bear.flac", + new AssertionConfig.Builder().setDumpFilesPrefix("extractordumps/flac/bear_flac").build(), + simulationConfig); } @Test public void sampleWithId3HeaderAndId3Enabled() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_with_id3.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_with_id3_enabled_flac"); + "media/flac/bear_with_id3.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_with_id3_enabled_flac") + .build(), + simulationConfig); } @Test public void sampleWithId3HeaderAndId3Disabled() throws Exception { ExtractorAsserts.assertBehavior( () -> new FlacExtractor(FlacExtractor.FLAG_DISABLE_ID3_METADATA), - /* file= */ "flac/bear_with_id3.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_with_id3_disabled_flac"); + "media/flac/bear_with_id3.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_with_id3_disabled_flac") + .build(), + simulationConfig); } @Test public void sampleUnseekable() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_no_seek_table_no_num_samples.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_no_seek_table_no_num_samples_flac"); + "media/flac/bear_no_seek_table_no_num_samples.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_no_seek_table_no_num_samples_flac") + .build(), + simulationConfig); } @Test public void sampleWithVorbisComments() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_with_vorbis_comments.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_with_vorbis_comments_flac"); + "media/flac/bear_with_vorbis_comments.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_with_vorbis_comments_flac") + .build(), + simulationConfig); } @Test public void sampleWithPicture() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_with_picture.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_with_picture_flac"); + "media/flac/bear_with_picture.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_with_picture_flac") + .build(), + simulationConfig); } @Test public void oneMetadataBlock() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_one_metadata_block.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_one_metadata_block_flac"); + "media/flac/bear_one_metadata_block.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_one_metadata_block_flac") + .build(), + simulationConfig); } @Test public void noMinMaxFrameSize() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_no_min_max_frame_size.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_no_min_max_frame_size_flac"); + "media/flac/bear_no_min_max_frame_size.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_no_min_max_frame_size_flac") + .build(), + simulationConfig); } @Test public void noNumSamples() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_no_num_samples.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_no_num_samples_flac"); + "media/flac/bear_no_num_samples.flac", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/flac/bear_no_num_samples_flac") + .build(), + simulationConfig); } @Test public void uncommonSampleRate() throws Exception { ExtractorAsserts.assertBehavior( FlacExtractor::new, - /* file= */ "flac/bear_uncommon_sample_rate.flac", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "flac/bear_uncommon_sample_rate_flac"); + "media/flac/bear_uncommon_sample_rate.flac", + new AssertionConfig.Builder() + .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 52b6a041620..06678ae912c 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 @@ -15,17 +15,27 @@ */ package com.google.android.exoplayer2.extractor.flv; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link FlvExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class FlvExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void sample() throws Exception { - ExtractorAsserts.assertBehavior(FlvExtractor::new, "flv/sample.flv"); + 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 761815cb56d..8e22aace8a3 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 @@ -15,33 +15,60 @@ */ package com.google.android.exoplayer2.extractor.mkv; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Tests for {@link MatroskaExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class MatroskaExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void mkvSample() throws Exception { - ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/sample.mkv"); + ExtractorAsserts.assertBehavior( + MatroskaExtractor::new, "media/mkv/sample.mkv", simulationConfig); + } + + @Test + public void mkvSample_withSubripSubtitles() throws Exception { + ExtractorAsserts.assertBehavior( + MatroskaExtractor::new, "media/mkv/sample_with_srt.mkv", simulationConfig); + } + + @Test + public void mkvSample_withHtcRotationInfoInTrackName() throws Exception { + ExtractorAsserts.assertBehavior( + 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"); + ExtractorAsserts.assertBehavior( + MatroskaExtractor::new, "media/mkv/full_blocks.mkv", simulationConfig); } @Test public void webmSubsampleEncryption() throws Exception { ExtractorAsserts.assertBehavior( - MatroskaExtractor::new, "mkv/subsample_encrypted_noaltref.webm"); + MatroskaExtractor::new, "media/mkv/subsample_encrypted_noaltref.webm", simulationConfig); } @Test public void webmSubsampleEncryptionWithAltrefFrames() throws Exception { - ExtractorAsserts.assertBehavior(MatroskaExtractor::new, "mkv/subsample_encrypted_altref.webm"); + ExtractorAsserts.assertBehavior( + 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 8ff5e84d69e..e3137a106dd 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeekerTest.java @@ -39,9 +39,9 @@ @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 void setUp() throws Exception { extractor = new Mp3Extractor(); extractorOutput = new FakeExtractorOutput(); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java index 0e5c2636440..24530c12f17 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/IndexSeekerTest.java @@ -40,7 +40,7 @@ @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 void setUp() throws Exception { extractor = new Mp3Extractor(FLAG_ENABLE_INDEX_SEEKING); extractorOutput = new FakeExtractorOutput(); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/mp3/Mp3ExtractorTest.java index 56221374488..f59e3e77a86 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 @@ -15,54 +15,73 @@ */ package com.google.android.exoplayer2.extractor.mp3; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import com.google.android.exoplayer2.testutil.ExtractorAsserts.AssertionConfig; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link Mp3Extractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class Mp3ExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void mp3SampleWithXingHeader() throws Exception { - ExtractorAsserts.assertBehavior(Mp3Extractor::new, "mp3/bear-vbr-xing-header.mp3"); + ExtractorAsserts.assertBehavior( + 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"); + 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"); + ExtractorAsserts.assertBehavior( + Mp3Extractor::new, "media/mp3/play-trimmed.mp3", simulationConfig); } @Test public void mp3SampleWithId3Enabled() throws Exception { ExtractorAsserts.assertBehavior( Mp3Extractor::new, - /* file= */ "mp3/bear-id3.mp3", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "mp3/bear-id3-enabled"); + "media/mp3/bear-id3.mp3", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/mp3/bear-id3-enabled") + .build(), + simulationConfig); } @Test public void mp3SampleWithId3Disabled() throws Exception { ExtractorAsserts.assertBehavior( () -> new Mp3Extractor(Mp3Extractor.FLAG_DISABLE_ID3_METADATA), - /* file= */ "mp3/bear-id3.mp3", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "mp3/bear-id3-disabled"); + "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 86f8e846f8d..e8ab027e9b1 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 @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.extractor.mp4; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.testutil.ExtractorAsserts; import com.google.android.exoplayer2.testutil.ExtractorAsserts.ExtractorFactory; @@ -25,21 +24,35 @@ import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link FragmentedMp4Extractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class FragmentedMp4ExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void sample() throws Exception { ExtractorAsserts.assertBehavior( - getExtractorFactory(ImmutableList.of()), "mp4/sample_fragmented.mp4"); + 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"); + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_fragmented_seekable.mp4", + simulationConfig); } @Test @@ -49,37 +62,64 @@ public void sampleWithSeiPayloadParsing() throws Exception { getExtractorFactory( Collections.singletonList( new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build())); - ExtractorAsserts.assertBehavior(extractorFactory, "mp4/sample_fragmented_sei.mp4"); + ExtractorAsserts.assertBehavior( + extractorFactory, "media/mp4/sample_fragmented_sei.mp4", simulationConfig); } @Test public void sampleWithAc3Track() throws Exception { ExtractorAsserts.assertBehavior( - getExtractorFactory(ImmutableList.of()), "mp4/sample_ac3_fragmented.mp4"); + 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"); + 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"); + 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"); + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_eac3_fragmented.mp4", + simulationConfig); } @Test public void sampleWithEac3jocTrack() throws Exception { ExtractorAsserts.assertBehavior( - getExtractorFactory(ImmutableList.of()), "mp4/sample_eac3joc_fragmented.mp4"); + getExtractorFactory(ImmutableList.of()), + "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); } private static ExtractorFactory getExtractorFactory(final List closedCaptionFormats) { 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 2466c46d8a4..70e483457a5 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 5c1e8e11df1..c2e23673076 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 @@ -15,23 +15,34 @@ */ package com.google.android.exoplayer2.extractor.mp4; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Tests for {@link Mp4Extractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class Mp4ExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void mp4Sample() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample.mp4"); + 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"); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_android_slow_motion.mp4", simulationConfig); } /** @@ -40,26 +51,37 @@ public void mp4SampleWithSlowMotionMetadata() throws Exception { */ @Test public void mp4SampleWithMdatTooLong() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_mdat_too_long.mp4"); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_mdat_too_long.mp4", simulationConfig); } @Test public void mp4SampleWithAc3Track() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac3.mp4"); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_ac3.mp4", simulationConfig); } @Test public void mp4SampleWithAc4Track() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_ac4.mp4"); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_ac4.mp4", simulationConfig); } @Test public void mp4SampleWithEac3Track() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3.mp4"); + ExtractorAsserts.assertBehavior( + Mp4Extractor::new, "media/mp4/sample_eac3.mp4", simulationConfig); } @Test public void mp4SampleWithEac3jocTrack() throws Exception { - ExtractorAsserts.assertBehavior(Mp4Extractor::new, "mp4/sample_eac3joc.mp4"); + 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 83aa8c6d9b0..e30f27713eb 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 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 void setupWithUnsetEndPositionFails() { @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 void seeking() throws Exception { } } - @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 void readGranuleOfLastPage_withUnboundedLength_throwsException() throws E } } - 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/OggExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java similarity index 62% rename from library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorTest.java rename to library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java index bffaa582dc2..f25f97eaa2b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorNonParameterizedTest.java @@ -20,72 +20,58 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; -import com.google.android.exoplayer2.testutil.ExtractorAsserts.ExtractorFactory; import com.google.android.exoplayer2.testutil.FakeExtractorInput; import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link OggExtractor}. */ +/** + * Tests for {@link OggExtractor} that test specific behaviours and don't need to be parameterized. + * + *

        For parameterized tests using {@link ExtractorAsserts} see {@link + * OggExtractorParameterizedTest}. + */ @RunWith(AndroidJUnit4.class) -public final class OggExtractorTest { - - private static final ExtractorFactory OGG_EXTRACTOR_FACTORY = OggExtractor::new; - - @Test - public void opus() throws Exception { - ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear.opus"); - } - - @Test - public void flac() throws Exception { - ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear_flac.ogg"); - } - - @Test - public void flacNoSeektable() throws Exception { - ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear_flac_noseektable.ogg"); - } - - @Test - public void vorbis() throws Exception { - ExtractorAsserts.assertBehavior(OGG_EXTRACTOR_FACTORY, "ogg/bear_vorbis.ogg"); - } +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); } @@ -97,6 +83,6 @@ private void assertSniff(byte[] data, boolean expectedResult) throws IOException .setSimulateUnknownLength(true) .setSimulatePartialReads(true) .build(); - ExtractorAsserts.assertSniff(OGG_EXTRACTOR_FACTORY.create(), input, expectedResult); + ExtractorAsserts.assertSniff(new OggExtractor(), input, expectedResult); } } 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 new file mode 100644 index 00000000000..cc78d59bf48 --- /dev/null +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ogg/OggExtractorParameterizedTest.java @@ -0,0 +1,70 @@ +/* + * 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.extractor.ogg; + +import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; + +/** + * Unit tests for {@link OggExtractor} that use parameterization to test a range of behaviours. + * + *

        For non-parameterized tests see {@link OggExtractorNonParameterizedTest}. + */ +@RunWith(ParameterizedRobolectricTestRunner.class) +public final class OggExtractorParameterizedTest { + + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + + @Test + public void opus() throws Exception { + ExtractorAsserts.assertBehavior(OggExtractor::new, "media/ogg/bear.opus", simulationConfig); + } + + @Test + public void flac() throws Exception { + ExtractorAsserts.assertBehavior(OggExtractor::new, "media/ogg/bear_flac.ogg", simulationConfig); + } + + @Test + public void flacNoSeektable() throws Exception { + ExtractorAsserts.assertBehavior( + OggExtractor::new, "media/ogg/bear_flac_noseektable.ogg", simulationConfig); + } + + @Test + public void vorbis() throws Exception { + 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 492b542e95e..e74ecf7be05 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 @@ @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 void readPacketsWithEmptyPage() throws Exception { 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 void readPacketWithZeroSizeTerminator() throws Exception { 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 void readContinuedPacketOverTwoPages() throws Exception { 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 void readContinuedPacketOverFourPages() throws Exception { 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 void readDiscardContinuedPacketAtStart() throws Exception { 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 void readZeroSizedPacketsAtEndOfStream() throws Exception { 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 @@ private void assertReadPacket(FakeExtractorInput extractorInput, byte[] expected 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 6b5ffe8f915..c952c0b220f 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 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 @@ @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 void populatePageHeader_withLessThan27Bytes_returnFalseWithoutException() 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 @@ private static FakeExtractorInput createInput(byte[] data, boolean simulateUnkno .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 c7edff700a7..7db02d47890 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 void appendNumberOfSamples() { 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 void readSetupHeaders_withIOExceptions_readSuccess() throws IOException { // 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 ca7e30a5b01..173a4049619 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 @@ -15,17 +15,26 @@ */ package com.google.android.exoplayer2.extractor.rawcc; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.testutil.ExtractorAsserts; import com.google.android.exoplayer2.util.MimeTypes; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; /** Tests for {@link RawCcExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class RawCcExtractorTest { + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @ParameterizedRobolectricTestRunner.Parameter(0) + public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void rawCcSample() throws Exception { Format format = @@ -34,6 +43,7 @@ public void rawCcSample() throws Exception { .setCodecs("cea608") .setAccessibilityChannel(1) .build(); - ExtractorAsserts.assertBehavior(() -> new RawCcExtractor(format), "rawcc/sample.rawcc"); + ExtractorAsserts.assertBehavior( + () -> 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 df216c7d58c..4c8ddfb1535 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 @@ -15,27 +15,38 @@ */ package com.google.android.exoplayer2.extractor.ts; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link Ac3Extractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class Ac3ExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void ac3Sample() throws Exception { - ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.ac3"); + ExtractorAsserts.assertBehavior(Ac3Extractor::new, "media/ts/sample.ac3", simulationConfig); } @Test public void eAc3Sample() throws Exception { - ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample.eac3"); + ExtractorAsserts.assertBehavior(Ac3Extractor::new, "media/ts/sample.eac3", simulationConfig); } @Test public void eAc3jocSample() throws Exception { - ExtractorAsserts.assertBehavior(Ac3Extractor::new, "ts/sample_eac3joc.ec3"); + 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 8ddd6f545b2..23b066088aa 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 @@ -15,17 +15,27 @@ */ package com.google.android.exoplayer2.extractor.ts; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link Ac4Extractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class Ac4ExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void ac4Sample() throws Exception { - ExtractorAsserts.assertBehavior(Ac4Extractor::new, "ts/sample.ac4"); + 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 5226aa71e94..2770d4ef66b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java @@ -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 a06d228553e..dca8ba99383 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorTest.java @@ -15,30 +15,46 @@ */ package com.google.android.exoplayer2.extractor.ts; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import com.google.android.exoplayer2.testutil.ExtractorAsserts.AssertionConfig; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link AdtsExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class AdtsExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void sample() throws Exception { - ExtractorAsserts.assertBehavior(AdtsExtractor::new, "ts/sample.adts"); + 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"); + ExtractorAsserts.assertBehavior( + 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.adts", + new AssertionConfig.Builder() + .setDumpFilesPrefix("extractordumps/ts/sample_cbs.adts") + .build(), + simulationConfig); } // https://github.com/google/ExoPlayer/issues/6700 @@ -46,6 +62,7 @@ public void sample_withSeeking() throws Exception { 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 c04c7224f99..6869c0314c2 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.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 void skipToNextSample() throws Exception { 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 void skipToNextSample() throws Exception { 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 728a164b112..8c1805c5689 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 void readDuration_returnsCorrectDuration() throws IOException { 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 void readDuration_midStream_returnsCorrectDuration() throws IOException { 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 b5eb3a5e883..d2d76d6695b 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java @@ -46,7 +46,7 @@ @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 void setUp() throws IOException { expectedTrackOutput = expectedOutput.trackOutputs.get(VIDEO_TRACK_ID); dataSource = - new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent") + new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext()) .createDataSource(); totalInputLength = readInputLength(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorTest.java index c9e9dce4718..a7bd75a56c8 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 @@ -15,22 +15,33 @@ */ package com.google.android.exoplayer2.extractor.ts; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link PsExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class PsExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void sampleWithH262AndMpegAudio() throws Exception { - ExtractorAsserts.assertBehavior(PsExtractor::new, "ts/sample_h262_mpeg_audio.ps"); + ExtractorAsserts.assertBehavior( + PsExtractor::new, "media/ts/sample_h262_mpeg_audio.ps", simulationConfig); } @Test public void sampleWithAc3() throws Exception { - ExtractorAsserts.assertBehavior(PsExtractor::new, "ts/sample_ac3.ps"); + 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 7a1a49d7129..8f744e855d7 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 void readDuration_returnsCorrectDuration() throws IOException, Interrupte 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 void readDuration_midStream_returnsCorrectDuration() throws IOException { 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 42e0acecd4d..a796f3c9940 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java @@ -41,7 +41,7 @@ @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 void setUp() throws IOException { .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 d040c222864..c2fe39285fa 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 @@ -15,12 +15,12 @@ */ package com.google.android.exoplayer2.extractor.ts; +import static com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory.FLAG_DETECT_ACCESS_UNITS; import static com.google.common.truth.Truth.assertThat; import android.util.SparseArray; 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.extractor.Extractor; @@ -36,74 +36,114 @@ import com.google.android.exoplayer2.testutil.TestUtil; import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.TimestampAdjuster; -import org.junit.Ignore; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; /** Unit test for {@link TsExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class TsExtractorTest { + @Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @Parameter public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void sampleWithH262AndMpegAudio() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_h262_mpeg_audio.ts"); + ExtractorAsserts.assertBehavior( + 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"); + ExtractorAsserts.assertBehavior( + TsExtractor::new, "media/ts/sample_h264_mpeg_audio.ts", simulationConfig); + } + + @Test + public void sampleWithH264NoAccessUnitDelimiters() throws Exception { + ExtractorAsserts.assertBehavior( + () -> new TsExtractor(FLAG_DETECT_ACCESS_UNITS), + "media/ts/sample_h264_no_access_unit_delimiters.ts", + simulationConfig); + } + + @Test + public void sampleWithH264AndDtsAudio() throws Exception { + ExtractorAsserts.assertBehavior( + () -> new TsExtractor(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS), + "media/ts/sample_h264_dts_audio.ts", + simulationConfig); } @Test public void sampleWithH265() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_h265.ts"); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_h265.ts", simulationConfig); } @Test - @Ignore - // TODO(internal: b/153539929) Re-enable when ExtractorAsserts is less strict around repeated - // formats and seeking. public void sampleWithScte35() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_scte35.ts"); + ExtractorAsserts.assertBehavior( + TsExtractor::new, + "media/ts/sample_scte35.ts", + new ExtractorAsserts.AssertionConfig.Builder() + .setDeduplicateConsecutiveFormats(true) + .build(), + simulationConfig); } @Test - @Ignore - // TODO(internal: b/153539929) Re-enable when ExtractorAsserts is less strict around repeated - // formats and seeking. public void sampleWithAit() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ait.ts"); + ExtractorAsserts.assertBehavior( + TsExtractor::new, + "media/ts/sample_ait.ts", + new ExtractorAsserts.AssertionConfig.Builder() + .setDeduplicateConsecutiveFormats(true) + .build(), + simulationConfig); } @Test public void sampleWithAc3() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac3.ts"); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_ac3.ts", simulationConfig); } @Test public void sampleWithAc4() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_ac4.ts"); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_ac4.ts", simulationConfig); } @Test public void sampleWithEac3() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3.ts"); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_eac3.ts", simulationConfig); } @Test public void sampleWithEac3joc() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_eac3joc.ts"); + ExtractorAsserts.assertBehavior( + TsExtractor::new, "media/ts/sample_eac3joc.ts", simulationConfig); } @Test public void sampleWithLatm() throws Exception { - ExtractorAsserts.assertBehavior(TsExtractor::new, "ts/sample_latm.ts"); + ExtractorAsserts.assertBehavior(TsExtractor::new, "media/ts/sample_latm.ts", simulationConfig); } @Test public void streamWithJunkData() throws Exception { ExtractorAsserts.assertBehavior( - TsExtractor::new, "ts/sample_with_junk", ApplicationProvider.getApplicationContext()); + TsExtractor::new, "media/ts/sample_with_junk", simulationConfig); } @Test @@ -115,7 +155,8 @@ public void customPesReader() throws Exception { 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) @@ -152,7 +193,7 @@ public void customInitialSectionReader() throws Exception { 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 9287c684233..b411e7517af 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 @@ -15,32 +15,42 @@ */ package com.google.android.exoplayer2.extractor.wav; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.ExtractorAsserts; +import com.google.android.exoplayer2.testutil.ExtractorAsserts.AssertionConfig; +import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; /** Unit test for {@link WavExtractor}. */ -@RunWith(AndroidJUnit4.class) +@RunWith(ParameterizedRobolectricTestRunner.class) public final class WavExtractorTest { + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + public static List params() { + return ExtractorAsserts.configs(); + } + + @ParameterizedRobolectricTestRunner.Parameter(0) + public ExtractorAsserts.SimulationConfig simulationConfig; + @Test public void sample() throws Exception { - ExtractorAsserts.assertBehavior(WavExtractor::new, "wav/sample.wav"); + 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", - ApplicationProvider.getApplicationContext(), - /* dumpFilesPrefix= */ "wav/sample.wav"); + "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"); + ExtractorAsserts.assertBehavior( + WavExtractor::new, "media/wav/sample_ima_adpcm.wav", simulationConfig); } } diff --git a/library/hls/README.md b/library/hls/README.md index 3470c29e3cd..b7eecc1ff87 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 4764cf9882b..df3b6d35866 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 fe70298dc80..11d68b1c089 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 @@ public Aes128DataSource(DataSource upstream, byte[] encryptionKey, byte[] encryp @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 00000000000..78fc9ae732f --- /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 2ba2cd83af2..0a9ead7c480 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.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 @@ */ 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 DefaultHlsExtractorFactory( } @Override - public Result createExtractor( - @Nullable Extractor previousExtractor, + public BundledHlsMediaChunkExtractor createExtractor( Uri uri, Format format, @Nullable List muxedCaptionFormats, @@ -95,139 +96,79 @@ public Result createExtractor( 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()); - } - } - } - - // 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); + // 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); } - // 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); - } - } - - if (!(extractorByFileExtension instanceof WebvttExtractor)) { - WebvttExtractor webvttExtractor = new WebvttExtractor(format.language, timestampAdjuster); - if (sniffQuietly(webvttExtractor, extractorInput)) { - return buildResult(webvttExtractor); - } - } - - if (!(extractorByFileExtension instanceof TsExtractor)) { - TsExtractor tsExtractor = - createTsExtractor( - payloadReaderFactoryFlags, - exposeCea608WhenMissingDeclarations, - format, - muxedCaptionFormats, - timestampAdjuster); - if (sniffQuietly(tsExtractor, extractorInput)) { - return buildResult(tsExtractor); + @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 (fallBackExtractor == null) { - fallBackExtractor = tsExtractor; + if (fileType == FileTypes.TS) { + fallBackExtractor = extractor; } } - 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); - } - } + return new BundledHlsMediaChunkExtractor( + checkNotNull(fallBackExtractor), format, timestampAdjuster); + } - if (!(extractorByFileExtension instanceof Mp3Extractor)) { - Mp3Extractor mp3Extractor = - new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); - if (sniffQuietly(mp3Extractor, extractorInput)) { - return buildResult(mp3Extractor); - } + private static void addFileTypeIfNotPresent( + @FileTypes.Type int fileType, List fileTypes) { + if (fileType == FileTypes.UNKNOWN || fileTypes.contains(fileType)) { + return; } - - 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 @@ private static boolean isFmp4Variant(Format format) { 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 @@ private static boolean sniffQuietly(Extractor extractor, ExtractorInput input) } 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 c269691d772..530d56fa9c8 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.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 @@ public HlsChunkSource( } 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 @@ public void getNextChunk( // 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 @@ public void onChunkLoadCompleted(Chunk chunk) { } /** - * 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 @@ public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) { 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 @@ public MediaChunkIterator[] createMediaChunkIterators( 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 @@ private long getChunkMediaSequence( /* 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 eb3cf8bfcf2..4fe78514cff 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 @@ */ 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 Result(Extractor extractor, boolean isPackedAudioExtractor, boolean isReu * @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 3a2285a444f..9994ede1cf4 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.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.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 @@ /** * 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 @@ public static HlsMediaChunk createInstance( 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 @@ public static HlsMediaChunk createInstance( 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 @@ public static HlsMediaChunk createInstance( /** 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 @@ public static HlsMediaChunk createInstance( 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 @@ public static HlsMediaChunk createInstance( private boolean initDataLoadRequired; private volatile boolean loadCanceled; private boolean loadCompleted; + private ImmutableList sampleQueueFirstSampleIndices; + private boolean extractorInvalidated; private HlsMediaChunk( HlsExtractorFactory extractorFactory, @@ -243,7 +248,7 @@ private HlsMediaChunk( boolean isMasterTimestampSource, TimestampAdjuster timestampAdjuster, @Nullable DrmInitData drmInitData, - @Nullable Extractor previousExtractor, + @Nullable HlsMediaChunkExtractor previousExtractor, Id3Decoder id3Decoder, ParsableByteArray scratchId3Data, boolean shouldSpliceIn) { @@ -273,17 +278,42 @@ private HlsMediaChunk( 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 @@ public void cancelLoad() { 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 @@ public void load() throws IOException { if (!hasGapTag) { loadMedia(); } - loadCompleted = true; + loadCompleted = !loadCanceled; } } @@ -373,10 +402,7 @@ private void feedDataToExtractor( 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 @@ private DefaultExtractorInput prepareExtraction(DataSource dataSource, DataSpec 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 @@ private DefaultExtractorInput prepareExtraction(DataSource dataSource, DataSpec 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 @@ private long peekId3PrivTimestamp(ExtractorInput input) throws IOException { 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 @@ private long peekId3PrivTimestamp(ExtractorInput input) throws IOException { 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 00000000000..0ca5c5d0ad6 --- /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 b6985a836c3..5e0709228db 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.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.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 HlsMediaPeriod( HlsDataSourceFactory dataSourceFactory, @Nullable TransferListener mediaTransferListener, DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, EventDispatcher eventDispatcher, Allocator allocator, @@ -125,6 +128,7 @@ public HlsMediaPeriod( 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 HlsMediaPeriod( sampleStreamWrappers = new HlsSampleStreamWrapper[0]; enabledSampleStreamWrappers = new HlsSampleStreamWrapper[0]; manifestUrlIndicesPerWrapper = new int[0][]; - eventDispatcher.mediaPeriodCreated(); } public void release() { @@ -148,7 +151,6 @@ public void release() { sampleStreamWrapper.release(); } callback = null; - eventDispatcher.mediaPeriodReleased(); } @Override @@ -376,10 +378,6 @@ public long getNextLoadPositionUs() { @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } @@ -451,13 +449,13 @@ public void onPlaylistChanged() { } @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 @@ private void buildAndPrepareAudioSampleStreamWrappers( /* 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 @@ private HlsSampleStreamWrapper buildSampleStreamWrapper( 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 b2ce33a1cb4..b58f3da928f 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 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.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.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 Factory(DataSource.Factory dataSourceFactory) { * 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 Factory setUseSessionKeys(boolean useSessionKeys) { 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 Factory setStreamKeys(@Nullable List streamKeys) { } /** - * @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 HlsMediaSource createMediaSource( @Deprecated @Override public HlsMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setSourceUri(uri).build()); + return createMediaSource( + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_M3U8).build()); } /** @@ -344,29 +353,39 @@ public HlsMediaSource createMediaSource(Uri uri) { */ @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.sourceUri, + 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 int[] getSupportedTypes() { } 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 int[] getSupportedTypes() { 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 @@ private HlsMediaSource( 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 @@ private HlsMediaSource( 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 void maybeThrowSourceInfoRefreshError() throws IOException { @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 void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { 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 void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { 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 void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { /* isDynamic= */ !playlist.hasEndTag, /* isLive= */ true, manifest, - tag); + mediaItem); } else /* not live */ { if (windowDefaultStartPositionUs == C.TIME_UNSET) { windowDefaultStartPositionUs = 0; @@ -529,7 +560,7 @@ public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { /* 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 41dc652e518..89e7687a21a 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,8 +15,11 @@ */ 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; import android.util.SparseIntArray; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -26,6 +29,7 @@ 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; @@ -36,7 +40,9 @@ import com.google.android.exoplayer2.metadata.emsg.EventMessage; import com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder; import com.google.android.exoplayer2.metadata.id3.PrivFrame; -import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; +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; @@ -49,14 +55,16 @@ import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataReader; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; 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; @@ -115,9 +123,10 @@ public interface Callback extends SequenceableLoader.Callback mediaChunks; @@ -129,6 +138,7 @@ public interface Callback extends SequenceableLoader.Callback hlsSampleStreams; private final Map overridingDrmInitData; + @Nullable private Chunk loadingChunk; private HlsSampleQueue[] sampleQueues; private int[] sampleQueueTrackIds; private Set sampleQueueMappingDoneByType; @@ -179,8 +189,10 @@ public interface Callback extends SequenceableLoader.Callback sampleQueue.getLargestQueuedTimestampUs()) { - return sampleQueue.advanceToEnd(); - } else { - return sampleQueue.advanceTo(positionUs); - } + int skipCount = sampleQueue.getSkipCount(positionUs, loadingFinished); + sampleQueue.skip(skipCount); + return skipCount; } // SequenceableLoader implementation @@ -603,12 +617,11 @@ public long getBufferedPositionUs() { 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; @@ -635,13 +648,16 @@ public boolean continueLoading(long positionUs) { 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, @@ -670,19 +686,19 @@ public boolean continueLoading(long positionUs) { if (isMediaChunk(loadable)) { initMediaChunkLoad((HlsMediaChunk) loadable); } + loadingChunk = loadable; long elapsedRealtimeMs = loader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); - eventDispatcher.loadStarted( - loadable.dataSpec, + mediaSourceEventDispatcher.loadStarted( + new LoadEventInfo(loadable.loadTaskId, loadable.dataSpec, elapsedRealtimeMs), loadable.type, trackType, loadable.trackFormat, loadable.trackSelectionReason, loadable.trackSelectionData, loadable.startTimeUs, - loadable.endTimeUs, - elapsedRealtimeMs); + loadable.endTimeUs); return true; } @@ -693,28 +709,49 @@ public boolean isLoading() { @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); - eventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + mediaSourceEventDispatcher.loadCompleted( + loadEventInfo, loadable.type, trackType, loadable.trackFormat, loadable.trackSelectionReason, loadable.trackSelectionData, loadable.startTimeUs, - loadable.endTimeUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + loadable.endTimeUs); if (!prepared) { continueLoading(lastSeekPositionUs); } else { @@ -725,22 +762,30 @@ public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDur @Override public void onLoadCanceled( Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) { - eventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), + loadingChunk = null; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + mediaSourceEventDispatcher.loadCanceled( + loadEventInfo, loadable.type, trackType, loadable.trackFormat, loadable.trackSelectionReason, loadable.trackSelectionData, loadable.startTimeUs, - loadable.endTimeUs, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + loadable.endTimeUs); if (!released) { - resetSampleQueues(); + if (isPendingReset() || enabledTrackGroupCount == 0) { + resetSampleQueues(); + } if (enabledTrackGroupCount > 0) { callback.onContinueLoadingRequested(this); } @@ -756,39 +801,55 @@ public LoadErrorAction onLoadError( int errorCount) { long bytesLoaded = loadable.bytesLoaded(); boolean isMediaChunk = isMediaChunk(loadable); - boolean blacklistSucceeded = false; + boolean exclusionSucceeded = false; + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded); + MediaLoadData mediaLoadData = + new MediaLoadData( + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + C.usToMs(loadable.startTimeUs), + C.usToMs(loadable.endTimeUs)); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); LoadErrorAction loadErrorAction; - - long blacklistDurationMs = - loadErrorHandlingPolicy.getBlacklistDurationMsFor( - loadable.type, loadDurationMs, error, errorCount); - 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 */ { - long retryDelayMs = - loadErrorHandlingPolicy.getRetryDelayMsFor( - loadable.type, loadDurationMs, error, errorCount); + } else /* did not exclude */ { + long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo); loadErrorAction = retryDelayMs != C.TIME_UNSET ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs) : Loader.DONT_RETRY_FATAL; } - eventDispatcher.loadError( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), + boolean wasCanceled = !loadErrorAction.isRetry(); + mediaSourceEventDispatcher.loadError( + loadEventInfo, loadable.type, trackType, loadable.trackFormat, @@ -796,13 +857,14 @@ public LoadErrorAction onLoadError( loadable.trackSelectionData, loadable.startTimeUs, loadable.endTimeUs, - elapsedRealtimeMs, - loadDurationMs, - bytesLoaded, error, - /* wasCanceled= */ !loadErrorAction.isRetry()); + wasCanceled); + if (wasCanceled) { + loadingChunk = null; + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } - if (blacklistSucceeded) { + if (exclusionSucceeded) { if (!prepared) { continueLoading(lastSeekPositionUs); } else { @@ -824,18 +886,46 @@ private void initMediaChunkLoad(HlsMediaChunk chunk) { 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 @@ -855,7 +945,7 @@ public TrackOutput track(int id, int type) { 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); @@ -899,7 +989,7 @@ private TrackOutput getMappedTrackOutput(int id, int type) { } return sampleQueueTrackIds[sampleQueueIndex] == id ? sampleQueues[sampleQueueIndex] - : createDummyTrackOutput(id, type); + : createFakeTrackOutput(id, type); } private SampleQueue createSampleQueue(int id, int type) { @@ -907,7 +997,12 @@ private SampleQueue createSampleQueue(int id, int type) { boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO; HlsSampleQueue sampleQueue = - new HlsSampleQueue(allocator, drmSessionManager, eventDispatcher, overridingDrmInitData); + new HlsSampleQueue( + allocator, + /* playbackLooper= */ handler.getLooper(), + drmSessionManager, + drmEventDispatcher, + overridingDrmInitData); if (isAudioVideo) { sampleQueue.setDrmInitData(drmInitData); } @@ -1029,6 +1124,38 @@ private boolean finishedReadingChunk(HlsMediaChunk chunk) { 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); @@ -1191,12 +1318,8 @@ private TrackGroupArray createTrackGroupArrayWithDrmInfo(TrackGroup[] trackGroup 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); } @@ -1292,6 +1415,7 @@ private static Format deriveFormat( .setLabel(playlistFormat.label) .setLanguage(playlistFormat.language) .setSelectionFlags(playlistFormat.selectionFlags) + .setRoleFlags(playlistFormat.roleFlags) .setAverageBitrate(propagateBitrates ? playlistFormat.averageBitrate : Format.NO_VALUE) .setPeakBitrate(propagateBitrates ? playlistFormat.peakBitrate : Format.NO_VALUE) .setCodecs(codecs) @@ -1337,7 +1461,7 @@ private static boolean formatsMatch(Format manifestFormat, Format sampleFormat) 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(); } @@ -1355,46 +1479,52 @@ private static DummyTrackOutput createDummyTrackOutput(int id, int type) { */ 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; private HlsSampleQueue( Allocator allocator, + Looper playbackLooper, DrmSessionManager drmSessionManager, - MediaSourceEventDispatcher eventDispatcher, + DrmSessionEventListener.EventDispatcher eventDispatcher, Map overridingDrmInitData) { - super(allocator, drmSessionManager, eventDispatcher); + 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) { @@ -1472,7 +1602,7 @@ public void sampleMetadata( // new UnexpectedSampleTimestampException( // sourceChunk, sourceChunkLastSampleTimeUs, timeUs)); // } - sourceChunkLastSampleTimeUs = timeUs; + // sourceChunkLastSampleTimeUs = timeUs; super.sampleMetadata(timeUs, flags, size, offset, cryptoData); } } @@ -1521,7 +1651,8 @@ public void format(Format format) { } @Override - public int sampleData(DataReader input, int length, boolean allowEndOfInput) + public int sampleData( + DataReader input, int length, boolean allowEndOfInput, @SampleDataPart int sampleDataPart) throws IOException { ensureBufferCapacity(bufferPosition + length); int numBytesRead = input.read(buffer, bufferPosition, length); @@ -1537,7 +1668,8 @@ public int sampleData(DataReader input, int length, boolean allowEndOfInput) } @Override - public void sampleData(ParsableByteArray buffer, int length) { + public void sampleData( + ParsableByteArray buffer, int length, @SampleDataPart int sampleDataPart) { ensureBufferCapacity(bufferPosition + length); buffer.readBytes(this.buffer, bufferPosition, length); bufferPosition += length; 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 6a390001d23..832de00cf93 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 @@ private void processSample() throws ParserException { 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 d172aa299c1..39462f3d061 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 @@ -16,8 +16,8 @@ package com.google.android.exoplayer2.source.hls.offline; import android.net.Uri; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; +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; @@ -26,12 +26,14 @@ 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; +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; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.concurrent.Executor; /** * A downloader for HLS streams. @@ -40,43 +42,100 @@ * *

        {@code
          * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
        - * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
        - * DownloaderConstructorHelper constructorHelper =
        - *     new DownloaderConstructorHelper(cache, factory);
        + * CacheDataSource.Factory cacheDataSourceFactory =
        + *     new CacheDataSource.Factory()
        + *         .setCache(cache)
        + *         .setUpstreamDataSourceFactory(new DefaultHttpDataSourceFactory(userAgent));
          * // 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)),
        - *         constructorHelper);
        + *         new MediaItem.Builder()
        + *             .setUri(playlistUri)
        + *             .setStreamKeys(
        + *                 Collections.singletonList(
        + *                     new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0)))
        + *             .build(),
        + *         Collections.singletonList();
          * // Perform the download.
          * hlsDownloader.download(progressListener);
        - * // Access downloaded data using CacheDataSource
        - * CacheDataSource cacheDataSource =
        - *     new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE);
        + * // Use the downloaded data for playback.
        + * HlsMediaSource mediaSource =
        + *     new HlsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem);
          * }
        */ public final class HlsDownloader extends SegmentDownloader { + /** @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. - * @param constructorHelper A {@link DownloaderConstructorHelper} instance. + * 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, DownloaderConstructorHelper constructorHelper) { - super(playlistUri, streamKeys, constructorHelper); + Uri playlistUri, + List streamKeys, + CacheDataSource.Factory cacheDataSourceFactory, + Executor executor) { + this( + new MediaItem.Builder().setUri(playlistUri).setStreamKeys(streamKeys).build(), + cacheDataSourceFactory, + executor); } - @Override - protected HlsPlaylist getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException { - return loadManifest(dataSource, dataSpec); + /** + * 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. + * 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, 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(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; @@ -92,15 +151,15 @@ protected List getSegments( segments.add(new Segment(/* startTimeUs= */ 0, mediaPlaylistDataSpec)); HlsMediaPlaylist mediaPlaylist; try { - mediaPlaylist = (HlsMediaPlaylist) loadManifest(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. continue; } - HlsMediaPlaylist.Segment lastInitSegment = null; + @Nullable HlsMediaPlaylist.Segment lastInitSegment = null; List hlsSegments = mediaPlaylist.segments; for (int i = 0; i < hlsSegments.size(); i++) { HlsMediaPlaylist.Segment segment = hlsSegments.get(i); @@ -121,12 +180,6 @@ private void addMediaPlaylistDataSpecs(List mediaPlaylistUrls, List masterPlaylistLoadable = @@ -135,9 +140,9 @@ public void start( this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(masterPlaylistLoadable.type)); eventDispatcher.loadStarted( - masterPlaylistLoadable.dataSpec, - masterPlaylistLoadable.type, - elapsedRealtime); + new LoadEventInfo( + masterPlaylistLoadable.loadTaskId, masterPlaylistLoadable.dataSpec, elapsedRealtime), + masterPlaylistLoadable.type); } @Override @@ -158,6 +163,7 @@ public void stop() { @Override public void addListener(PlaylistEventListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } @@ -235,20 +241,23 @@ public void onLoadCompleted( primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url; createBundles(masterPlaylist.mediaPlaylistUrls); MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + 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, loadDurationMs); + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo); } else { primaryBundle.loadPlaylist(); } - eventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - C.DATA_TYPE_MANIFEST, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); } @Override @@ -257,14 +266,17 @@ public void onLoadCanceled( long elapsedRealtimeMs, long loadDurationMs, boolean released) { - eventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - C.DATA_TYPE_MANIFEST, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST); } @Override @@ -274,20 +286,24 @@ public LoadErrorAction onLoadError( long loadDurationMs, IOException error, int errorCount) { + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + MediaLoadData mediaLoadData = new MediaLoadData(loadable.type); long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor( - loadable.type, loadDurationMs, error, errorCount); + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount)); boolean isFatal = retryDelayMs == C.TIME_UNSET; - eventDispatcher.loadError( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - C.DATA_TYPE_MANIFEST, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded(), - error, - isFatal); + eventDispatcher.loadError(loadEventInfo, loadable.type, error, isFatal); + if (isFatal) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } return isFatal ? Loader.DONT_RETRY_FATAL : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs); @@ -301,7 +317,7 @@ private boolean maybeSelectNewPrimaryUrl() { 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; @@ -364,13 +380,13 @@ private void onPlaylistUpdated(Uri url, HlsMediaPlaylist newSnapshot) { } } - 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( @@ -454,7 +470,7 @@ private final class MediaPlaylistBundle private long lastSnapshotLoadMs; private long lastSnapshotChangeMs; private long earliestNextLoadTimeMs; - private long blacklistUntilMs; + private long excludeUntilMs; private boolean loadPending; private IOException playlistError; @@ -479,7 +495,7 @@ public boolean isSnapshotValid() { 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 @@ -491,7 +507,7 @@ public void release() { } 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; @@ -518,19 +534,24 @@ public void maybeThrowPlaylistRefreshError() throws IOException { public void onLoadCompleted( ParsingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { HlsPlaylist result = loadable.getResult(); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); if (result instanceof HlsMediaPlaylist) { - processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); - eventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - C.DATA_TYPE_MANIFEST, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + 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 @@ -539,14 +560,17 @@ public void onLoadCanceled( long elapsedRealtimeMs, long loadDurationMs, boolean released) { - eventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - C.DATA_TYPE_MANIFEST, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST); } @Override @@ -556,23 +580,30 @@ public LoadErrorAction onLoadError( long loadDurationMs, IOException error, int errorCount) { + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + MediaLoadData mediaLoadData = new MediaLoadData(loadable.type); + LoadErrorInfo loadErrorInfo = + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount); LoadErrorAction loadErrorAction; + long exclusionDurationMs = loadErrorHandlingPolicy.getBlacklistDurationMsFor(loadErrorInfo); + boolean shouldExclude = exclusionDurationMs != C.TIME_UNSET; - long blacklistDurationMs = - loadErrorHandlingPolicy.getBlacklistDurationMsFor( - loadable.type, loadDurationMs, error, errorCount); - boolean shouldBlacklist = blacklistDurationMs != 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) { - long retryDelay = - loadErrorHandlingPolicy.getRetryDelayMsFor( - loadable.type, loadDurationMs, error, errorCount); + if (exclusionFailed) { + long retryDelay = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo); loadErrorAction = retryDelay != C.TIME_UNSET ? Loader.createRetryAction(false, retryDelay) @@ -581,17 +612,11 @@ public LoadErrorAction onLoadError( loadErrorAction = Loader.DONT_RETRY; } - eventDispatcher.loadError( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - C.DATA_TYPE_MANIFEST, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded(), - error, - /* wasCanceled= */ !loadErrorAction.isRetry()); - + boolean wasCanceled = !loadErrorAction.isRetry(); + eventDispatcher.loadError(loadEventInfo, loadable.type, error, wasCanceled); + if (wasCanceled) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } return loadErrorAction; } @@ -612,12 +637,13 @@ private void loadPlaylistImmediately() { this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type)); eventDispatcher.loadStarted( - mediaPlaylistLoadable.dataSpec, - mediaPlaylistLoadable.type, - elapsedRealtime); + new LoadEventInfo( + mediaPlaylistLoadable.loadTaskId, mediaPlaylistLoadable.dataSpec, elapsedRealtime), + 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; @@ -631,7 +657,7 @@ private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDur < 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 @@ -639,12 +665,17 @@ private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDur * 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); } } } @@ -665,14 +696,14 @@ private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDur } /** - * 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 77e541fb57e..fd6efbf4455 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 @@ private static HlsMasterPlaylist parseMasterPlaylist(LineIterator iterator, Stri } } formatBuilder.setSampleMimeType(sampleMimeType); - if (uri == null) { - // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios. - muxedAudioFormat = formatBuilder.build(); - } else { + if (uri != null) { formatBuilder.setMetadata(metadata); audios.add(new Rendition(uri, formatBuilder.build(), groupId, name)); + } else if (variant != null) { + // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios. + muxedAudioFormat = formatBuilder.build(); } break; case TYPE_SUBTITLES: diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java index 96c9660db0e..0590361cc0e 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java @@ -32,7 +32,7 @@ * media playlists while the master playlist is an optional kind of playlist defined by the HLS * specification (RFC 8216). * - *

        Playlist 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 @@ interface PlaylistEventListener { * 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 @@ void start( 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 00000000000..d51a800b888 --- /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 06f4dfab3c3..54383ffe335 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/DefaultMediaSourceFactoryTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -36,12 +37,9 @@ 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() - .setSourceUri(URI_MEDIA) - .setMimeType(MimeTypes.APPLICATION_M3U8) - .build(); + new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_M3U8).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -52,24 +50,24 @@ public void createMediaSource_withMimeType_hlsSource() { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_MEDIA) + .setUri(URI_MEDIA) .setMimeType(MimeTypes.APPLICATION_M3U8) .setTag(tag) .build(); 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()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + "/file.m3u8").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.m3u8").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -79,8 +77,8 @@ public void createMediaSource_withPath_hlsSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + "/file.m3u8").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.m3u8").build(); MediaSource mediaSource = defaultMediaSourceFactory @@ -95,7 +93,7 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void getSupportedTypes_hlsModule_containsTypeHls() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_HLS); diff --git a/library/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 fe42ebb07e7..a6c42f97549 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 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.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 void getSteamKeys_isCompatibleWithHlsMasterPlaylistFilter() { 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 00000000000..7001417186d --- /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 2f7f8e3fc04..b9c34774561 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 @@ 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 void sniff_failsForIncorrectHeader() throws IOException { 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 f1d0b8ab8aa..9d1127a3d76 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 f38a4577be8..9215dd31f09 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 @@ + "\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 @@ + "#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 @@ + "#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 a8473b8c9ca..986ee4dedae 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,20 +37,23 @@ 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; -import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.DownloaderFactory; import com.google.android.exoplayer2.offline.StreamKey; 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.Factory; +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() @@ -97,19 +101,21 @@ public void tearDown() { @Test public void createWithDefaultDownloaderFactory() { - DownloaderConstructorHelper constructorHelper = - new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper); + CacheDataSource.Factory cacheDataSourceFactory = + new CacheDataSource.Factory() + .setCache(Mockito.mock(Cache.class)) + .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); + 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); } @@ -213,9 +219,13 @@ public void downloadEncMediaPlaylist() throws Exception { } private HlsDownloader getHlsDownloader(String mediaPlaylistUri, List keys) { - Factory factory = new Factory().setFakeDataSet(fakeDataSet); + CacheDataSource.Factory cacheDataSourceFactory = + new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(new FakeDataSource.Factory().setFakeDataSet(fakeDataSet)); return new HlsDownloader( - Uri.parse(mediaPlaylistUri), keys, new DownloaderConstructorHelper(cache, factory)); + 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 05bc3ba9857..145d01bbb5a 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.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 @@ private static HlsMasterPlaylist parseMasterPlaylist(String uri, String playlist 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 dd8a32b7f0b..42b51056cf7 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 void gapTag() throws IOException { + "#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 d53471d17ca..2fab69c7568 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 404f1d6541a..34fa62e0962 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 5ce2e6a1c5d..868cea7fd0c 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.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 SsChunkSource createChunkSource( 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 DefaultSsChunkSource( 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 DefaultSsChunkSource( | 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 int getPreferredQueueSize(long playbackPositionUs, 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 final void getNextChunk( 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 final void getNextChunk( chunkSeekTimeUs, trackSelection.getSelectionReason(), trackSelection.getSelectionData(), - extractorWrapper); + chunkExtractor); } @Override @@ -264,10 +274,17 @@ public void onChunkLoadCompleted(Chunk chunk) { @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 @@ private static MediaChunk newMediaChunk( 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 @@ private static MediaChunk newMediaChunk( 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 8efff23f435..b6e21cd870b 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 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 @@ @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 @@ private SsManifest manifest; private ChunkSampleStream[] sampleStreams; private SequenceableLoader compositeSequenceableLoader; - private boolean notifiedReadingStarted; public SsMediaPeriod( SsManifest manifest, @@ -66,8 +67,9 @@ public SsMediaPeriod( @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 @@ public SsMediaPeriod( 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 @@ public void release() { sampleStream.release(); } callback = null; - eventDispatcher.mediaPeriodReleased(); } // MediaPeriod implementation. @@ -196,10 +197,6 @@ public long getNextLoadPositionUs() { @Override public long readDiscontinuity() { - if (!notifiedReadingStarted) { - eventDispatcher.readingStarted(); - notifiedReadingStarted = true; - } return C.TIME_UNSET; } @@ -254,8 +251,9 @@ private ChunkSampleStream buildSampleStream(TrackSelection select allocator, positionUs, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, - eventDispatcher); + mediaSourceEventDispatcher); } private static TrackGroupArray buildTrackGroups( @@ -267,10 +265,8 @@ private static TrackGroupArray buildTrackGroups( 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 ac3085cee2e..a2ebb06936c 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,15 +27,18 @@ 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; import com.google.android.exoplayer2.source.BaseMediaSource; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory; +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; @@ -40,17 +47,19 @@ 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; import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; 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; @@ -69,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; @@ -101,9 +111,9 @@ public Factory(DataSource.Factory dataSourceFactory) { 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(); @@ -189,19 +199,22 @@ public Factory setCompositeSequenceableLoaderFactory( 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; } @@ -222,7 +235,7 @@ public Factory setStreamKeys(@Nullable List streamKeys) { @Deprecated @Override public SsMediaSource createMediaSource(Uri uri) { - return createMediaSource(new MediaItem.Builder().setSourceUri(uri).build()); + return createMediaSource(new MediaItem.Builder().setUri(uri).build()); } /** @@ -234,21 +247,47 @@ public SsMediaSource createMediaSource(Uri uri) { * @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); } /** @@ -268,9 +307,10 @@ public SsMediaSource createMediaSource( } /** - * @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, @@ -292,7 +332,7 @@ public SsMediaSource createMediaSource( */ @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(); @@ -304,17 +344,27 @@ public SsMediaSource createMediaSource(MediaItem mediaItem) { 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.sourceUri, manifestDataSourceFactory, manifestParser, chunkSourceFactory, compositeSequenceableLoaderFactory, - drmSessionManager, + drmSessionManager != null ? drmSessionManager : mediaSourceDrmHelper.create(mediaItem), loadErrorHandlingPolicy, - livePresentationDelayMs, - mediaItem.playbackProperties.tag != null ? mediaItem.playbackProperties.tag : tag); + livePresentationDelayMs); } @Override @@ -327,7 +377,7 @@ public int[] getSupportedTypes() { * 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. @@ -336,10 +386,12 @@ public int[] getSupportedTypes() { /** * 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; @@ -349,7 +401,6 @@ public int[] getSupportedTypes() { private final EventDispatcher manifestEventDispatcher; private final ParsingLoadable.Parser manifestParser; private final ArrayList mediaPeriods; - @Nullable private final Object tag; private DataSource manifestDataSource; private Loader manifestLoader; @@ -403,16 +454,15 @@ public SsMediaSource( @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); } @@ -504,35 +554,38 @@ public SsMediaSource( @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; @@ -541,17 +594,26 @@ private SsMediaSource( 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 @@ -565,7 +627,7 @@ protected void prepareSourceInternal(@Nullable TransferListener mediaTransferLis manifestDataSource = manifestDataSourceFactory.createDataSource(); manifestLoader = new Loader("Loader:Manifest"); manifestLoaderErrorThrower = manifestLoader; - manifestRefreshHandler = Util.createHandler(); + manifestRefreshHandler = Util.createHandlerForCurrentLooper(); startLoadingManifest(); } } @@ -577,7 +639,8 @@ public void maybeThrowSourceInfoRefreshError() throws IOException { @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, @@ -585,8 +648,9 @@ public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long star mediaTransferListener, compositeSequenceableLoaderFactory, drmSessionManager, + drmEventDispatcher, loadErrorHandlingPolicy, - eventDispatcher, + mediaSourceEventDispatcher, manifestLoaderErrorThrower, allocator); mediaPeriods.add(period); @@ -620,14 +684,17 @@ protected void releaseSourceInternal() { @Override public void onLoadCompleted( ParsingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { - manifestEventDispatcher.loadCompleted( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + manifestEventDispatcher.loadCompleted(loadEventInfo, loadable.type); manifest = loadable.getResult(); manifestLoadStartTimestamp = elapsedRealtimeMs - loadDurationMs; processManifest(); @@ -640,14 +707,17 @@ public void onLoadCanceled( long elapsedRealtimeMs, long loadDurationMs, boolean released) { - manifestEventDispatcher.loadCanceled( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded()); + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + manifestEventDispatcher.loadCanceled(loadEventInfo, loadable.type); } @Override @@ -657,23 +727,28 @@ public LoadErrorAction onLoadError( long loadDurationMs, IOException error, int errorCount) { + LoadEventInfo loadEventInfo = + new LoadEventInfo( + loadable.loadTaskId, + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + MediaLoadData mediaLoadData = new MediaLoadData(loadable.type); long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor( - C.DATA_TYPE_MANIFEST, loadDurationMs, error, errorCount); + new LoadErrorInfo(loadEventInfo, mediaLoadData, error, errorCount)); LoadErrorAction loadErrorAction = retryDelayMs == C.TIME_UNSET ? Loader.DONT_RETRY_FATAL : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs); - manifestEventDispatcher.loadError( - loadable.dataSpec, - loadable.getUri(), - loadable.getResponseHeaders(), - loadable.type, - elapsedRealtimeMs, - loadDurationMs, - loadable.bytesLoaded(), - error, - !loadErrorAction.isRetry()); + boolean wasCanceled = !loadErrorAction.isRetry(); + manifestEventDispatcher.loadError(loadEventInfo, loadable.type, error, wasCanceled); + if (wasCanceled) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } return loadErrorAction; } @@ -688,9 +763,12 @@ private void processManifest() { 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)); } } @@ -707,10 +785,10 @@ private void processManifest() { /* 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); @@ -718,7 +796,7 @@ private void processManifest() { // 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( @@ -730,7 +808,7 @@ private void processManifest() { /* isDynamic= */ true, /* isLive= */ true, manifest, - tag); + mediaItem); } else { long durationUs = manifest.durationUs != C.TIME_UNSET ? manifest.durationUs : endTimeUs - startTimeUs; @@ -744,7 +822,7 @@ private void processManifest() { /* isDynamic= */ false, /* isLive= */ false, manifest, - tag); + mediaItem); } refreshSourceInfo(timeline); } @@ -754,7 +832,7 @@ private void scheduleManifestRefresh() { 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); } @@ -767,7 +845,9 @@ private void startLoadingManifest() { long elapsedRealtimeMs = manifestLoader.startLoading( loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); - manifestEventDispatcher.loadStarted(loadable.dataSpec, loadable.type, elapsedRealtimeMs); + manifestEventDispatcher.loadStarted( + new LoadEventInfo(loadable.loadTaskId, loadable.dataSpec, elapsedRealtimeMs), + loadable.type); } } 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 b54b2abc74e..00000000000 --- 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 1331fe46178..998820de4ba 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,21 +15,23 @@ */ 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.C; -import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; +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; -import java.io.IOException; +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; /** * A downloader for SmoothStreaming streams. @@ -38,38 +40,104 @@ * *

        {@code
          * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider);
        - * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null);
        - * DownloaderConstructorHelper constructorHelper =
        - *     new DownloaderConstructorHelper(cache, factory);
        + * CacheDataSource.Factory cacheDataSourceFactory =
        + *     new CacheDataSource.Factory()
        + *         .setCache(cache)
        + *         .setUpstreamDataSourceFactory(new DefaultHttpDataSourceFactory(userAgent));
          * // Create a downloader for the first track of the first stream element.
          * SsDownloader ssDownloader =
          *     new SsDownloader(
        - *         manifestUrl,
        - *         Collections.singletonList(new StreamKey(0, 0)),
        - *         constructorHelper);
        + *         new MediaItem.Builder()
        + *             .setUri(manifestUri)
        + *             .setStreamKeys(Collections.singletonList(new StreamKey(0, 0)))
        + *             .build(),
        + *         cacheDataSourceFactory);
          * // Perform the download.
          * ssDownloader.download(progressListener);
        - * // Access downloaded data using CacheDataSource
        - * CacheDataSource cacheDataSource =
        - *     new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE);
        + * // Use the downloaded data for playback.
        + * SsMediaSource mediaSource =
        + *     new SsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem);
          * }
        */ 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 constructorHelper A {@link DownloaderConstructorHelper} instance. + * @deprecated Use {@link #SsDownloader(MediaItem, CacheDataSource.Factory, Executor)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public SsDownloader( - Uri manifestUri, List streamKeys, DownloaderConstructorHelper constructorHelper) { - super(SsUtil.fixManifestUri(manifestUri), streamKeys, constructorHelper); + Uri manifestUri, List streamKeys, CacheDataSource.Factory cacheDataSourceFactory) { + this(manifestUri, streamKeys, cacheDataSourceFactory, Runnable::run); } - @Override - protected SsManifest getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException { - return ParsingLoadable.load(dataSource, new SsManifestParser(), dataSpec, C.DATA_TYPE_MANIFEST); + /** + * 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. + * 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, CacheDataSource.Factory cacheDataSourceFactory, Executor executor) { + this( + mediaItem + .buildUpon() + .setUri( + Util.fixSmoothStreamingIsmManifestUri( + checkNotNull(mediaItem.playbackProperties).uri)) + .build(), + new SsManifestParser(), + 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 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 54c2923bcd4..43c62071d39 100644 --- a/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java +++ b/library/smoothstreaming/src/test/java/com/google/android/exoplayer2/source/smoothstreaming/DefaultMediaSourceFactoryTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; +import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; @@ -38,12 +39,9 @@ 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() - .setSourceUri(URI_MEDIA) - .setMimeType(MimeTypes.APPLICATION_SS) - .build(); + new MediaItem.Builder().setUri(URI_MEDIA).setMimeType(MimeTypes.APPLICATION_SS).build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); assertThat(mediaSource).isInstanceOf(SsMediaSource.class); } @@ -52,24 +50,24 @@ public void createMediaSource_withMimeType_smoothstreamingSource() { public void createMediaSource_withTag_tagInSource() { Object tag = new Object(); DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); MediaItem mediaItem = new MediaItem.Builder() - .setSourceUri(URI_MEDIA) + .setUri(URI_MEDIA) .setMimeType(MimeTypes.APPLICATION_SS) .setTag(tag) .build(); 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()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + "/file.ism").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.ism").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -79,8 +77,8 @@ public void createMediaSource_withIsmPath_smoothstreamingSource() { @Test public void createMediaSource_withManifestPath_smoothstreamingSource() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + ".ism/Manifest").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + ".ism/Manifest").build(); MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); @@ -90,8 +88,8 @@ public void createMediaSource_withManifestPath_smoothstreamingSource() { @Test public void createMediaSource_withNull_usesNonNullDefaults() { DefaultMediaSourceFactory defaultMediaSourceFactory = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); - MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_MEDIA + "/file.ism").build(); + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = new MediaItem.Builder().setUri(URI_MEDIA + "/file.ism").build(); MediaSource mediaSource = defaultMediaSourceFactory @@ -106,7 +104,7 @@ public void createMediaSource_withNull_usesNonNullDefaults() { @Test public void getSupportedTypes_smoothstreamingModule_containsTypeSS() { int[] supportedTypes = - DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()) + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) .getSupportedTypes(); assertThat(supportedTypes).asList().containsExactly(C.TYPE_OTHER, C.TYPE_SS); 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 a20e5790a79..81648706c41 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 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.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 void getSteamKeys_isCompatibleWithSsManifestFilter() { 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 00000000000..1f28d2263bf --- /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 60d9c40dc31..e5a7ee5addd 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 @@ @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 b6d29d8b724..df1a0bd6dae 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 5560a724c8e..38132b55ede 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 @@ -22,11 +22,12 @@ import com.google.android.exoplayer2.offline.DefaultDownloaderFactory; import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.offline.Downloader; -import com.google.android.exoplayer2.offline.DownloaderConstructorHelper; import com.google.android.exoplayer2.offline.DownloaderFactory; 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; @@ -38,19 +39,21 @@ public final class SsDownloaderTest { @Test public void createWithDefaultDownloaderFactory() throws Exception { - DownloaderConstructorHelper constructorHelper = - new DownloaderConstructorHelper(Mockito.mock(Cache.class), DummyDataSource.FACTORY); - DownloaderFactory factory = new DefaultDownloaderFactory(constructorHelper); + CacheDataSource.Factory cacheDataSourceFactory = + new CacheDataSource.Factory() + .setCache(Mockito.mock(Cache.class)) + .setUpstreamDataSourceFactory(DummyDataSource.FACTORY); + 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 f404ee38a55..f63e55b3b3d 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 00000000000..ad7c139ea8e --- /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 58% 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 39aaebc755f..19ad36c29df 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.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 @@ public void onCues(List cues) { 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; @@ -154,13 +108,15 @@ public void dispatchDraw(Canvas canvas) { int cueCount = cues.size(); for (int i = 0; i < cueCount; i++) { Cue cue = cues.get(i); + if (cue.verticalType != Cue.TYPE_UNSET) { + cue = repositionVerticalCue(cue); + } float cueTextSizePx = - SubtitleViewUtils.resolveCueTextSize(cue, rawViewHeight, viewHeightMinusPadding); + SubtitleViewUtils.resolveTextSize( + cue.textSizeType, cue.textSize, rawViewHeight, viewHeightMinusPadding); SubtitlePainter painter = painters.get(i); painter.draw( cue, - applyEmbeddedStyles, - applyEmbeddedFontSizes, style, defaultViewTextSizePx, cueTextSizePx, @@ -172,4 +128,46 @@ public void dispatchDraw(Canvas canvas) { bottom); } } + + /** + * Reposition a vertical cue for horizontal display. + * + *

        This class doesn't support rendering vertical text, but if we naively interpret vertical + * {@link Cue#position} and{@link Cue#line} values for horizontal display then the cues will often + * be displayed in unexpected positions. For example, the 'default' position for vertical-rl + * subtitles is the right-hand edge of the viewport, so cues that would appear vertically in this + * position should appear horizontally at the bottom of the viewport (generally the default + * position). Similarly left-edge vertical-rl cues should be shown at the top of a horizontal + * viewport. + * + *

        There isn't a meaningful way to transform {@link Cue#position} and related values (e.g. + * alignment), so we clear these and allow {@link SubtitlePainter} to do the default behaviour of + * centering the cue. + */ + private static Cue repositionVerticalCue(Cue cue) { + Cue.Builder cueBuilder = + cue.buildUpon() + .setPosition(Cue.DIMEN_UNSET) + .setPositionAnchor(Cue.TYPE_UNSET) + .setTextAlignment(null); + + if (cue.lineType == Cue.LINE_TYPE_FRACTION) { + cueBuilder.setLine(1.0f - cue.line, Cue.LINE_TYPE_FRACTION); + } else { + cueBuilder.setLine(-cue.line - 1f, Cue.LINE_TYPE_NUMBER); + } + switch (cue.lineAnchor) { + case Cue.ANCHOR_TYPE_END: + cueBuilder.setLineAnchor(Cue.ANCHOR_TYPE_START); + break; + case Cue.ANCHOR_TYPE_START: + cueBuilder.setLineAnchor(Cue.ANCHOR_TYPE_END); + break; + case Cue.ANCHOR_TYPE_MIDDLE: + case Cue.TYPE_UNSET: + default: + // Fall through + } + return cueBuilder.build(); + } } 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 b7e96d2484a..24d890134a9 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 @@ -196,7 +196,6 @@ public class DefaultTimeBar extends View implements TimeBar { private final Formatter formatter; private final Runnable stopScrubbingRunnable; private final CopyOnWriteArraySet listeners; - private final int[] locationOnScreen; private final Point touchPosition; private final float density; @@ -248,7 +247,6 @@ public DefaultTimeBar( scrubberPaint = new Paint(); scrubberPaint.setAntiAlias(true); listeners = new CopyOnWriteArraySet<>(); - locationOnScreen = new int[2]; touchPosition = new Point(); // Calculate the dimensions and paints for drawn elements. @@ -452,6 +450,7 @@ public void setPlayedAdMarkerColor(@ColorInt int playedAdMarkerColor) { @Override public void addListener(OnScrubListener listener) { + Assertions.checkNotNull(listener); listeners.add(listener); } @@ -798,10 +797,7 @@ private void positionScrubber(float xPosition) { } private Point resolveRelativeTouchPosition(MotionEvent motionEvent) { - getLocationOnScreen(locationOnScreen); - touchPosition.set( - ((int) motionEvent.getRawX()) - locationOnScreen[0], - ((int) motionEvent.getRawY()) - locationOnScreen[1]); + touchPosition.set((int) motionEvent.getX(), (int) motionEvent.getY()); return touchPosition; } 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 178cd44dd30..83da4d54a83 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 DownloadNotificationHelper(Context context, String channelId) { * @return The notification. */ public Notification buildProgressNotification( + Context context, @DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message, @@ -95,6 +95,7 @@ public Notification buildProgressNotification( indeterminate = allDownloadPercentagesUnknown && haveDownloadedBytes; } return buildNotification( + context, smallIcon, contentIntent, message, @@ -109,37 +110,47 @@ public Notification buildProgressNotification( /** * 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 @@ private Notification buildEndStateNotification( } 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 223a97f69c2..8c03dbea42d 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 static Notification buildProgressNotification( @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 static Notification buildDownloadCompletedNotification( @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 static Notification buildDownloadFailedNotification( @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 new file mode 100644 index 00000000000..13a14d00332 --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/HtmlUtils.java @@ -0,0 +1,43 @@ +/* + * 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.ui; + +import android.graphics.Color; +import androidx.annotation.ColorInt; +import com.google.android.exoplayer2.util.Util; + +/** + * Utility methods for generating HTML and CSS for use with {@link WebViewSubtitleOutput} and {@link + * SpannedToHtmlConverter}. + */ +/* package */ final class HtmlUtils { + + private HtmlUtils() {} + + public static String toCssRgba(@ColorInt int color) { + return Util.formatInvariant( + "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 47d60e02330..00000000000 --- 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 778f033f0c7..65a9a5ed8f7 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.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 @@ *

      • Corresponding method: {@link #setShowTimeoutMs(int)} *
      • Default: {@link #DEFAULT_SHOW_TIMEOUT_MS} *
      + *
    11. {@code show_rewind_button} - Whether the rewind button is shown. + *
        + *
      • Corresponding method: {@link #setShowRewindButton(boolean)} + *
      • Default: true + *
      + *
    12. {@code show_fastforward_button} - Whether the fast forward button is shown. + *
        + *
      • Corresponding method: {@link #setShowFastForwardButton(boolean)} + *
      • Default: true + *
      + *
    13. {@code show_previous_button} - Whether the previous button is shown. + *
        + *
      • Corresponding method: {@link #setShowPreviousButton(boolean)} + *
      • Default: true + *
      + *
    14. {@code show_next_button} - Whether the next button is shown. + *
        + *
      • Corresponding method: {@link #setShowNextButton(boolean)} + *
      • Default: true + *
      *
    15. {@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 interface ProgressUpdateListener { 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 PlayerControlView( 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 PlayerControlView( 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 PlayerControlView( } vrButton = findViewById(R.id.exo_vr); setShowVrButton(false); + updateButton(false, false, vrButton); Resources resources = context.getResources(); @@ -549,6 +588,7 @@ public void setExtraAdGroupMarkers( * @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 void setControlDispatcher(ControlDispatcher controlDispatcher) { } } + /** + * 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 void setShowVrButton(boolean showVrButton) { public void setVrButtonListener(@Nullable OnClickListener onClickListener) { if (vrButton != null) { vrButton.setOnClickListener(onClickListener); + updateButton(getShowVrButton(), onClickListener != null, vrButton); } } @@ -832,10 +913,10 @@ private void updateNavigation() { } } - 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 @@ private void updateRepeatModeButton() { } 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 @@ private void updateShuffleButton() { @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 @@ private void updateProgress() { 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 @@ private void requestPlayPauseFocus() { } } - 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 boolean dispatchMediaKeyEvent(KeyEvent event) { } 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 @@ private boolean shouldShowPauseButton() { && 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 void onClick(View view) { } 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 b3d646c99da..b52a3e6f82b 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java @@ -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 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 final void setColor(int color) { * *

        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 void invalidate() { } } + // 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); @@ -981,7 +989,6 @@ private void startOrUpdateNotification(Player player, @Nullable Bitmap bitmap) { Notification notification = builder.build(); notificationManager.notify(notificationId, notification); if (!isNotificationStarted) { - isNotificationStarted = true; context.registerReceiver(notificationBroadcastReceiver, intentFilter); if (notificationListener != null) { notificationListener.onNotificationStarted(notificationId, notification); @@ -989,10 +996,16 @@ private void startOrUpdateNotification(Player player, @Nullable Bitmap bitmap) { } @Nullable NotificationListener listener = notificationListener; if (listener != null) { - listener.onNotificationPosted(notificationId, notification, ongoing); + // Always pass true for ongoing with the first notification to tell a service to go into + // foreground even when paused. + listener.onNotificationPosted( + notificationId, notification, ongoing || !isNotificationStarted); } + isNotificationStarted = true; } + // We're calling a deprecated listener method that we still want to notify. + @SuppressWarnings("deprecation") private void stopNotification(boolean dismissedByUser) { if (isNotificationStarted) { isNotificationStarted = false; @@ -1332,7 +1345,7 @@ public void onTimelineChanged(Timeline timeline, int reason) { } @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 60b72783e83..049af9b64a1 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.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.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; @@ -580,13 +583,13 @@ public void setPlayer(@Nullable Player player) { oldTextComponent.removeTextOutput(componentListener); } } + if (subtitleView != null) { + subtitleView.setCues(null); + } this.player = player; if (useController()) { controller.setPlayer(player); } - if (subtitleView != null) { - subtitleView.setCues(null); - } updateBuffering(); updateErrorMessage(); updateForCurrentTrackSelections(/* isNewPlayer= */ true); @@ -608,6 +611,9 @@ public void setPlayer(@Nullable Player player) { @Nullable Player.TextComponent newTextComponent = player.getTextComponent(); if (newTextComponent != null) { newTextComponent.addTextOutput(componentListener); + if (subtitleView != null) { + subtitleView.setCues(newTextComponent.getCurrentCues()); + } } player.addListener(componentListener); maybeShowController(false); @@ -997,6 +1003,46 @@ public void setControlDispatcher(ControlDispatcher controlDispatcher) { 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)}. @@ -1213,15 +1259,20 @@ public ViewGroup getAdViewGroup() { } @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. @@ -1511,6 +1562,13 @@ private final class ComponentListener SingleTapListener, PlayerControlView.VisibilityListener { + private final Period period; + private @Nullable Object lastPeriodUidWithTracks; + + public ComponentListener() { + period = new Period(); + } + // TextOutput implementation @Override @@ -1559,6 +1617,29 @@ public void onRenderedFirstFrame() { @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 fae3382a328..00000000000 --- 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 8d0760e35cf..7ea2b55cf4a 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 @@ -16,24 +16,32 @@ */ package com.google.android.exoplayer2.ui; -import android.graphics.Color; import android.graphics.Typeface; import android.text.Html; import android.text.Spanned; +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; import android.util.SparseArray; -import androidx.annotation.ColorInt; import androidx.annotation.Nullable; import com.google.android.exoplayer2.text.span.HorizontalTextInVerticalContextSpan; 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; /** @@ -43,7 +51,6 @@ *

        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). @@ -66,17 +73,34 @@ private SpannedToHtmlConverter() {} *

      • WebView/Chromium (the intended destination of this HTML) gracefully handles overlapping * tags and usually renders the same result as spanned text in a TextView. *
      + * + * @param text The (possibly span-styled) text to convert to HTML. + * @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) { + 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); + // 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++) { @@ -97,14 +121,15 @@ public static String convert(@Nullable CharSequence text) { html.append(escapeHtml(spanned.subSequence(previousTransition, spanned.length()))); - return html.toString(); + return new HtmlAndCss(html.toString(), cssRuleSets); } - private static SparseArray findSpanTransitions(Spanned spanned) { + private static SparseArray findSpanTransitions( + Spanned spanned, float displayDensity) { SparseArray spanTransitions = new SparseArray<>(); for (Object span : spanned.getSpans(0, spanned.length(), Object.class)) { - @Nullable String openingTag = getOpeningTag(span); + @Nullable String openingTag = getOpeningTag(span, displayDensity); @Nullable String closingTag = getClosingTag(span); int spanStart = spanned.getSpanStart(span); int spanEnd = spanned.getSpanEnd(span); @@ -120,13 +145,33 @@ private static SparseArray findSpanTransitions(Spanned spanned) { } @Nullable - private static String getOpeningTag(Object span) { - if (span instanceof ForegroundColorSpan) { + private static String getOpeningTag(Object span, float displayDensity) { + if (span instanceof StrikethroughSpan) { + return ""; + } else if (span instanceof ForegroundColorSpan) { ForegroundColorSpan colorSpan = (ForegroundColorSpan) span; return Util.formatInvariant( - "", toCssColor(colorSpan.getForegroundColor())); + "", HtmlUtils.toCssRgba(colorSpan.getForegroundColor())); + } else if (span instanceof BackgroundColorSpan) { + BackgroundColorSpan colorSpan = (BackgroundColorSpan) span; + return Util.formatInvariant("", colorSpan.getBackgroundColor()); } else if (span instanceof HorizontalTextInVerticalContextSpan) { return ""; + } else if (span instanceof AbsoluteSizeSpan) { + AbsoluteSizeSpan absoluteSizeSpan = (AbsoluteSizeSpan) span; + float sizeCssPx = + absoluteSizeSpan.getDip() + ? absoluteSizeSpan.getSize() + : absoluteSizeSpan.getSize() / displayDensity; + return Util.formatInvariant("", sizeCssPx); + } else if (span instanceof RelativeSizeSpan) { + return Util.formatInvariant( + "", ((RelativeSizeSpan) span).getSizeChange() * 100); + } else if (span instanceof TypefaceSpan) { + @Nullable String fontFamily = ((TypefaceSpan) span).getFamily(); + return fontFamily != null + ? Util.formatInvariant("", fontFamily) + : null; } else if (span instanceof StyleSpan) { switch (((StyleSpan) span).getStyle()) { case Typeface.BOLD: @@ -159,10 +204,16 @@ private static String getOpeningTag(Object span) { @Nullable private static String getClosingTag(Object span) { - if (span instanceof ForegroundColorSpan) { - return ""; - } else if (span instanceof HorizontalTextInVerticalContextSpan) { + if (span instanceof StrikethroughSpan + || span instanceof ForegroundColorSpan + || span instanceof BackgroundColorSpan + || span instanceof HorizontalTextInVerticalContextSpan + || span instanceof AbsoluteSizeSpan + || span instanceof RelativeSizeSpan) { return ""; + } else if (span instanceof TypefaceSpan) { + @Nullable String fontFamily = ((TypefaceSpan) span).getFamily(); + return fontFamily != null ? "" : null; } else if (span instanceof StyleSpan) { switch (((StyleSpan) span).getStyle()) { case Typeface.BOLD: @@ -181,12 +232,6 @@ private static String getClosingTag(Object span) { return null; } - private static String toCssColor(@ColorInt int color) { - return Util.formatInvariant( - "rgba(%d,%d,%d,%.3f)", - Color.red(color), Color.green(color), Color.blue(color), Color.alpha(color) / 255.0); - } - private static Transition getOrCreate(SparseArray transitions, int key) { @Nullable Transition transition = transitions.get(key); if (transition == null) { @@ -201,6 +246,26 @@ private static String escapeHtml(CharSequence text) { 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 00000000000..8bb9babeb0b --- /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 00000000000..9435d2b5ba2 --- /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 00000000000..8b6c5983c6d --- /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 841d1b6c4b0..fd7c3bffee1 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.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; @@ -65,7 +64,8 @@ private final float spacingAdd; private final TextPaint textPaint; - private final Paint paint; + private final Paint windowPaint; + private final Paint bitmapPaint; // Previous input variables. @Nullable private CharSequence cueText; @@ -81,8 +81,6 @@ private int cuePositionAnchor; private float cueSize; private float cueBitmapHeight; - private boolean applyEmbeddedStyles; - private boolean applyEmbeddedFontSizes; private int foregroundColor; private int backgroundColor; private int windowColor; @@ -124,9 +122,13 @@ public SubtitlePainter(Context context) { textPaint.setAntiAlias(true); textPaint.setSubpixelText(true); - paint = new Paint(); - paint.setAntiAlias(true); - paint.setStyle(Style.FILL); + windowPaint = new Paint(); + windowPaint.setAntiAlias(true); + windowPaint.setStyle(Style.FILL); + + bitmapPaint = new Paint(); + bitmapPaint.setAntiAlias(true); + bitmapPaint.setFilterBitmap(true); } /** @@ -137,8 +139,6 @@ public SubtitlePainter(Context context) { * 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. @@ -153,8 +153,6 @@ public SubtitlePainter(Context context) { */ public void draw( Cue cue, - boolean applyEmbeddedStyles, - boolean applyEmbeddedFontSizes, CaptionStyleCompat style, float defaultTextSizePx, float cueTextSizePx, @@ -171,8 +169,7 @@ public void draw( // 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) @@ -184,8 +181,6 @@ public void draw( && 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 @@ -214,8 +209,6 @@ public void draw( 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; @@ -261,31 +254,13 @@ private void setupTextLayout() { 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. @@ -362,21 +337,24 @@ private void setupTextLayout() { 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) { @@ -442,9 +420,13 @@ private void drawTextLayout(Canvas canvas) { canvas.translate(textLeft, textTop); if (Color.alpha(windowColor) > 0) { - paint.setColor(windowColor); + windowPaint.setColor(windowColor); canvas.drawRect( - -textPaddingX, 0, textLayout.getWidth() + textPaddingX, textLayout.getHeight(), paint); + -textPaddingX, + 0, + textLayout.getWidth() + textPaddingX, + textLayout.getHeight(), + windowPaint); } if (edgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE) { @@ -478,7 +460,7 @@ private void drawTextLayout(Canvas canvas) { @RequiresNonNull({"cueBitmap", "bitmapRect"}) private void drawBitmapLayout(Canvas canvas) { - canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, /* paint= */ null); + canvas.drawBitmap(cueBitmap, /* src= */ null, bitmapRect, bitmapPaint); } /** diff --git a/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 ee9f3f9e1f5..452be5a3b77 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 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 SubtitleView(Context context) { 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 void onCues(List cues) { * @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 void setViewType(@ViewType int viewType) { 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,6 +194,9 @@ public void setViewType(@ViewType int viewType) { private void setView(T view) { removeView(innerSubtitleView); + if (innerSubtitleView instanceof WebViewSubtitleOutput) { + ((WebViewSubtitleOutput) innerSubtitleView).destroy(); + } innerSubtitleView = view; output = view; addView(view); @@ -170,12 +224,13 @@ public void setFixedTextSize(@Dimension int unit, float size) { } /** - * 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()); } /** @@ -208,7 +263,9 @@ public void setFractionalTextSize(float fractionOfHeight, boolean ignorePadding) } private void setTextSize(@Cue.TextSizeType int textSizeType, float textSize) { - output.setTextSize(textSizeType, textSize); + this.defaultTextSizeType = textSizeType; + this.defaultTextSize = textSize; + updateOutput(); } /** @@ -218,7 +275,8 @@ private void setTextSize(@Cue.TextSizeType int textSizeType, float textSize) { * @param applyEmbeddedStyles Whether styling embedded within the cues should be applied. */ public void setApplyEmbeddedStyles(boolean applyEmbeddedStyles) { - output.setApplyEmbeddedStyles(applyEmbeddedStyles); + this.applyEmbeddedStyles = applyEmbeddedStyles; + updateOutput(); } /** @@ -228,18 +286,18 @@ public void setApplyEmbeddedStyles(boolean applyEmbeddedStyles) { * @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()); } /** @@ -248,7 +306,8 @@ public void setUserDefaultStyle() { * @param style A style for the view. */ public void setStyle(CaptionStyleCompat style) { - output.setStyle(style); + this.style = style; + updateOutput(); } /** @@ -261,36 +320,99 @@ public void setStyle(CaptionStyleCompat style) { * @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/SubtitleViewUtils.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleViewUtils.java index 4c1eb3298e2..24b5e30b2e4 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleViewUtils.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleViewUtils.java @@ -21,20 +21,20 @@ /** Utility class for subtitle layout logic. */ /* package */ final class SubtitleViewUtils { - public static float resolveCueTextSize(Cue cue, int rawViewHeight, int viewHeightMinusPadding) { - if (cue.textSizeType == Cue.TYPE_UNSET || cue.textSize == Cue.DIMEN_UNSET) { - return 0; - } - float defaultCueTextSizePx = - resolveTextSize(cue.textSizeType, cue.textSize, rawViewHeight, viewHeightMinusPadding); - return Math.max(defaultCueTextSizePx, 0); - } - + /** + * Returns the text size in px, derived from {@code textSize} and {@code textSizeType}. + * + *

      Returns {@link Cue#DIMEN_UNSET} if {@code textSize == Cue.DIMEN_UNSET} or {@code + * textSizeType == Cue.TYPE_UNSET}. + */ public static float resolveTextSize( @Cue.TextSizeType int textSizeType, float textSize, int rawViewHeight, int viewHeightMinusPadding) { + if (textSize == Cue.DIMEN_UNSET) { + return Cue.DIMEN_UNSET; + } switch (textSizeType) { case Cue.TEXT_SIZE_TYPE_ABSOLUTE: return textSize; 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/SubtitleWebView.java deleted file mode 100644 index 3a5560bbb33..00000000000 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/SubtitleWebView.java +++ /dev/null @@ -1,316 +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.ui; - -import static com.google.android.exoplayer2.ui.SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION; -import static com.google.android.exoplayer2.ui.SubtitleView.DEFAULT_TEXT_SIZE_FRACTION; - -import android.content.Context; -import android.graphics.Color; -import android.text.Layout; -import android.util.AttributeSet; -import android.util.Base64; -import android.view.MotionEvent; -import android.webkit.WebView; -import android.widget.FrameLayout; -import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.text.CaptionStyleCompat; -import com.google.android.exoplayer2.text.Cue; -import com.google.android.exoplayer2.util.Util; -import java.nio.charset.Charset; -import java.util.Collections; -import java.util.List; - -/** - * 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 { - - private final WebView webView; - - private List cues; - @Cue.TextSizeType private int textSizeType; - private float textSize; - private boolean applyEmbeddedStyles; - private boolean applyEmbeddedFontSizes; - private CaptionStyleCompat style; - private float bottomPaddingFraction; - - public SubtitleWebView(Context context) { - this(context, null); - } - - public SubtitleWebView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - 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; - - webView = - new WebView(context, attrs) { - @Override - public boolean onTouchEvent(MotionEvent event) { - super.onTouchEvent(event); - // Return false so that touch events are allowed down into @id/exo_content_frame below. - return false; - } - - @Override - public boolean performClick() { - super.performClick(); - // Return false so that clicks are allowed down into @id/exo_content_frame below. - return false; - } - }; - webView.setBackgroundColor(Color.TRANSPARENT); - addView(webView); - } - - @Override - public void onCues(List cues) { - this.cues = cues; - updateWebView(); - } - - @Override - public void setTextSize(@Cue.TextSizeType int textSizeType, float textSize) { - if (this.textSizeType == textSizeType && this.textSize == textSize) { - return; - } - this.textSizeType = textSizeType; - this.textSize = textSize; - updateWebView(); - } - - @Override - public void setApplyEmbeddedStyles(boolean applyEmbeddedStyles) { - if (this.applyEmbeddedStyles == applyEmbeddedStyles - && this.applyEmbeddedFontSizes == applyEmbeddedStyles) { - return; - } - this.applyEmbeddedStyles = applyEmbeddedStyles; - this.applyEmbeddedFontSizes = applyEmbeddedStyles; - updateWebView(); - } - - @Override - public void setApplyEmbeddedFontSizes(boolean applyEmbeddedFontSizes) { - if (this.applyEmbeddedFontSizes == applyEmbeddedFontSizes) { - return; - } - this.applyEmbeddedFontSizes = applyEmbeddedFontSizes; - updateWebView(); - } - - @Override - public void setStyle(CaptionStyleCompat style) { - if (this.style == style) { - return; - } - this.style = style; - updateWebView(); - } - - @Override - public void setBottomPaddingFraction(float bottomPaddingFraction) { - if (this.bottomPaddingFraction == bottomPaddingFraction) { - return; - } - this.bottomPaddingFraction = bottomPaddingFraction; - updateWebView(); - } - - private void updateWebView() { - StringBuilder html = new StringBuilder(); - html.append("") - .append("

      "); - - for (int i = 0; i < cues.size(); i++) { - Cue cue = cues.get(i); - float positionPercent = (cue.position != Cue.DIMEN_UNSET) ? (cue.position * 100) : 50; - int positionAnchorTranslatePercent = anchorTypeToTranslatePercent(cue.positionAnchor); - - float linePercent; - int lineTranslatePercent; - 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; - } else { - linePercent = 100; - lineTranslatePercent = Math.round(cue.line + 1) * 100; - } - break; - case Cue.LINE_TYPE_FRACTION: - case Cue.TYPE_UNSET: - default: - linePercent = cue.line * 100; - lineTranslatePercent = 0; - } - } else { - linePercent = 100; - lineTranslatePercent = 0; - } - int lineAnchorTranslatePercent = - cue.verticalType == Cue.VERTICAL_TYPE_RL - ? -anchorTypeToTranslatePercent(cue.lineAnchor) - : anchorTypeToTranslatePercent(cue.lineAnchor); - - String size = - cue.size != Cue.DIMEN_UNSET - ? Util.formatInvariant("%.2f%%", cue.size * 100) - : "fit-content"; - - String textAlign = convertAlignmentToCss(cue.textAlignment); - - String writingMode = convertVerticalTypeToCss(cue.verticalType); - - String positionProperty; - String lineProperty; - switch (cue.verticalType) { - case Cue.VERTICAL_TYPE_LR: - lineProperty = "left"; - positionProperty = "top"; - break; - case Cue.VERTICAL_TYPE_RL: - lineProperty = "right"; - positionProperty = "top"; - break; - case Cue.TYPE_UNSET: - default: - lineProperty = "top"; - positionProperty = "left"; - } - - String sizeProperty; - int horizontalTranslatePercent; - int verticalTranslatePercent; - if (cue.verticalType == Cue.VERTICAL_TYPE_LR || cue.verticalType == Cue.VERTICAL_TYPE_RL) { - sizeProperty = "height"; - horizontalTranslatePercent = lineTranslatePercent + lineAnchorTranslatePercent; - verticalTranslatePercent = positionAnchorTranslatePercent; - } else { - sizeProperty = "width"; - horizontalTranslatePercent = positionAnchorTranslatePercent; - verticalTranslatePercent = lineTranslatePercent + lineAnchorTranslatePercent; - } - - html.append( - Util.formatInvariant( - "
      ", - positionProperty, - positionPercent, - lineProperty, - linePercent, - sizeProperty, - size, - textAlign, - writingMode, - horizontalTranslatePercent, - verticalTranslatePercent)) - .append(SpannedToHtmlConverter.convert(cue.text)) - .append("
      "); - } - - html.append("
      "); - - webView.loadData( - Base64.encodeToString( - html.toString().getBytes(Charset.forName(C.UTF8_NAME)), Base64.NO_PADDING), - "text/html", - "base64"); - } - - private String convertVerticalTypeToCss(@Cue.VerticalType int verticalType) { - switch (verticalType) { - case Cue.VERTICAL_TYPE_LR: - return "vertical-lr"; - case Cue.VERTICAL_TYPE_RL: - return "vertical-rl"; - case Cue.TYPE_UNSET: - default: - return "horizontal-tb"; - } - } - - private String convertAlignmentToCss(@Nullable Layout.Alignment alignment) { - if (alignment == null) { - return "unset"; - } - switch (alignment) { - case ALIGN_NORMAL: - return "start"; - case ALIGN_CENTER: - return "center"; - case ALIGN_OPPOSITE: - return "end"; - default: - return "unset"; - } - } - - /** - * Converts a {@link Cue.AnchorType} to a percentage for use in a CSS {@code transform: - * translate(x,y)} function. - * - *

      We use {@code position: absolute} and always use the same CSS positioning property (top, - * bottom, left, right) regardless of the anchor type. The anchor is effectively 'moved' by using - * a CSS {@code translate(x,y)} operation on the value returned from this function. - */ - private static int anchorTypeToTranslatePercent(@Cue.AnchorType int anchorType) { - switch (anchorType) { - case Cue.ANCHOR_TYPE_END: - return -100; - case Cue.ANCHOR_TYPE_MIDDLE: - return -50; - case Cue.ANCHOR_TYPE_START: - case Cue.TYPE_UNSET: - default: - return 0; - } - } -} 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 f8a016bc8b2..be3fb9bc90f 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,19 +15,25 @@ */ 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.Format; 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.Comparator; import java.util.List; /** Builder for a dialog with a {@link TrackSelectionView}. */ @@ -46,6 +52,7 @@ public interface DialogCallback { } private final Context context; + @StyleRes private int themeResId; private final CharSequence title; private final MappedTrackInfo mappedTrackInfo; private final int rendererIndex; @@ -57,6 +64,7 @@ public interface DialogCallback { @Nullable private TrackNameProvider trackNameProvider; private boolean isDisabled; private List overrides; + @Nullable private Comparator trackFormatComparator; /** * Creates a builder for a track selection dialog. @@ -97,7 +105,7 @@ public 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 +126,17 @@ public 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. * @@ -192,6 +211,16 @@ public TrackSelectionDialogBuilder setShowDisableOption(boolean showDisableOptio return this; } + /** + * Sets a {@link Comparator} used to determine the display order of the tracks within each track + * group. + * + * @param trackFormatComparator The comparator, or {@code null} to use the original order. + */ + public void setTrackFormatComparator(@Nullable Comparator trackFormatComparator) { + this.trackFormatComparator = trackFormatComparator; + } + /** * Sets the {@link TrackNameProvider} used to generate the user visible name of each track and * updates the view with track names queried from the specified provider. @@ -205,13 +234,65 @@ public TrackSelectionDialogBuilder setTrackNameProvider( } /** 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); + Dialog.OnClickListener okClickListener = setUpDialogView(dialogView); + return builder + .setTitle(title) + .setView(dialogView) + .setPositiveButton(android.R.string.ok, okClickListener) + .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); @@ -219,16 +300,14 @@ public AlertDialog build() { 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()); - - return builder - .setTitle(title) - .setView(dialogView) - .setPositiveButton(android.R.string.ok, okClickListener) - .setNegativeButton(android.R.string.cancel, null) - .create(); + selectionView.init( + mappedTrackInfo, + rendererIndex, + isDisabled, + overrides, + trackFormatComparator, + /* listener= */ null); + return (dialog, which) -> + callback.onTracksSelected(selectionView.getIsDisabled(), selectionView.getOverrides()); } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java index 1e2d226fd6c..8a8f3d3c76f 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/TrackSelectionView.java @@ -18,7 +18,6 @@ import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; -import android.util.Pair; import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; @@ -26,6 +25,7 @@ import android.widget.LinearLayout; import androidx.annotation.AttrRes; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -35,6 +35,7 @@ import com.google.android.exoplayer2.util.Assertions; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -71,6 +72,7 @@ public interface TrackSelectionListener { private int rendererIndex; private TrackGroupArray trackGroups; private boolean isDisabled; + @Nullable private Comparator trackInfoComparator; @Nullable private TrackSelectionListener listener; /** Creates a track selection view. */ @@ -196,6 +198,8 @@ public void setTrackNameProvider(TrackNameProvider trackNameProvider) { * @param overrides List of initial overrides to be shown for this renderer. There must be at most * one override for each track group. If {@link #setAllowMultipleOverrides(boolean)} hasn't * been set to {@code true}, only the first override is used. + * @param trackFormatComparator An optional comparator used to determine the display order of the + * tracks within each track group. * @param listener An optional listener for track selection updates. */ public void init( @@ -203,10 +207,15 @@ public void init( int rendererIndex, boolean isDisabled, List overrides, + @Nullable Comparator trackFormatComparator, @Nullable TrackSelectionListener listener) { this.mappedTrackInfo = mappedTrackInfo; this.rendererIndex = rendererIndex; this.isDisabled = isDisabled; + this.trackInfoComparator = + trackFormatComparator == null + ? null + : (o1, o2) -> trackFormatComparator.compare(o1.format, o2.format); this.listener = listener; int maxOverrides = allowMultipleOverrides ? overrides.size() : Math.min(overrides.size(), 1); for (int i = 0; i < maxOverrides; i++) { @@ -259,7 +268,16 @@ private void updateViews() { TrackGroup group = trackGroups.get(groupIndex); boolean enableMultipleChoiceForAdaptiveSelections = shouldEnableAdaptiveSelection(groupIndex); trackViews[groupIndex] = new CheckedTextView[group.length]; + + TrackInfo[] trackInfos = new TrackInfo[group.length]; for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + trackInfos[trackIndex] = new TrackInfo(groupIndex, trackIndex, group.getFormat(trackIndex)); + } + if (trackInfoComparator != null) { + Arrays.sort(trackInfos, trackInfoComparator); + } + + for (int trackIndex = 0; trackIndex < trackInfos.length; trackIndex++) { if (trackIndex == 0) { addView(inflater.inflate(R.layout.exo_list_divider, this, false)); } @@ -270,11 +288,11 @@ private void updateViews() { CheckedTextView trackView = (CheckedTextView) inflater.inflate(trackViewLayoutId, this, false); trackView.setBackgroundResource(selectableItemBackgroundResourceId); - trackView.setText(trackNameProvider.getTrackName(group.getFormat(trackIndex))); + trackView.setText(trackNameProvider.getTrackName(trackInfos[trackIndex].format)); if (mappedTrackInfo.getTrackSupport(rendererIndex, groupIndex, trackIndex) == RendererCapabilities.FORMAT_HANDLED) { trackView.setFocusable(true); - trackView.setTag(Pair.create(groupIndex, trackIndex)); + trackView.setTag(trackInfos[trackIndex]); trackView.setOnClickListener(componentListener); } else { trackView.setFocusable(false); @@ -294,7 +312,12 @@ private void updateViewStates() { for (int i = 0; i < trackViews.length; i++) { SelectionOverride override = overrides.get(i); for (int j = 0; j < trackViews[i].length; j++) { - trackViews[i][j].setChecked(override != null && override.containsTrack(j)); + if (override != null) { + TrackInfo trackInfo = (TrackInfo) Assertions.checkNotNull(trackViews[i][j].getTag()); + trackViews[i][j].setChecked(override.containsTrack(trackInfo.trackIndex)); + } else { + trackViews[i][j].setChecked(false); + } } } } @@ -325,10 +348,9 @@ private void onDefaultViewClicked() { private void onTrackViewClicked(View view) { isDisabled = false; - @SuppressWarnings("unchecked") - Pair tag = (Pair) view.getTag(); - int groupIndex = tag.first; - int trackIndex = tag.second; + TrackInfo trackInfo = (TrackInfo) Assertions.checkNotNull(view.getTag()); + int groupIndex = trackInfo.groupIndex; + int trackIndex = trackInfo.trackIndex; SelectionOverride override = overrides.get(groupIndex); Assertions.checkNotNull(mappedTrackInfo); if (override == null) { @@ -406,4 +428,16 @@ public void onClick(View view) { TrackSelectionView.this.onClick(view); } } + + private static final class TrackInfo { + public final int groupIndex; + public final int trackIndex; + public final Format format; + + public TrackInfo(int groupIndex, int trackIndex, Format format) { + this.groupIndex = groupIndex; + this.trackIndex = trackIndex; + this.format = format; + } + } } diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java new file mode 100644 index 00000000000..f3de4298a5a --- /dev/null +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/WebViewSubtitleOutput.java @@ -0,0 +1,412 @@ +/* + * 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.ui; + +import static com.google.android.exoplayer2.ui.SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION; +import static com.google.android.exoplayer2.ui.SubtitleView.DEFAULT_TEXT_SIZE_FRACTION; + +import android.content.Context; +import android.graphics.Color; +import android.text.Layout; +import android.util.AttributeSet; +import android.util.Base64; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.webkit.WebView; +import android.widget.FrameLayout; +import androidx.annotation.Nullable; +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 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. + */ +/* package */ final class WebViewSubtitleOutput extends FrameLayout implements SubtitleView.Output { + + /** + * A hard-coded value for the line-height attribute, so we can use it to move text up and down by + * one line-height. Most browsers default 'normal' (CSS default) to 1.2 for most font families. + */ + private static final float CSS_LINE_HEIGHT = 1.2f; + + 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 CanvasSubtitleOutput canvasSubtitleOutput; + + private final WebView webView; + + private List textCues; + private CaptionStyleCompat style; + private float defaultTextSize; + @Cue.TextSizeType private int defaultTextSizeType; + private float bottomPaddingFraction; + + public WebViewSubtitleOutput(Context context) { + this(context, null); + } + + public WebViewSubtitleOutput(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + + textCues = Collections.emptyList(); + style = CaptionStyleCompat.DEFAULT; + defaultTextSize = DEFAULT_TEXT_SIZE_FRACTION; + defaultTextSizeType = Cue.TEXT_SIZE_TYPE_FRACTIONAL; + bottomPaddingFraction = DEFAULT_BOTTOM_PADDING_FRACTION; + + canvasSubtitleOutput = new CanvasSubtitleOutput(context, attrs); + webView = + new WebView(context, attrs) { + @Override + public boolean onTouchEvent(MotionEvent event) { + super.onTouchEvent(event); + // Return false so that touch events are allowed down into @id/exo_content_frame below. + return false; + } + + @Override + public boolean performClick() { + super.performClick(); + // Return false so that clicks are allowed down into @id/exo_content_frame below. + return false; + } + }; + webView.setBackgroundColor(Color.TRANSPARENT); + + addView(canvasSubtitleOutput); + addView(webView); + } + + @Override + 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<>(); + 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 { + textCues.add(cue); + } + } + + if (!this.textCues.isEmpty() || !textCues.isEmpty()) { + this.textCues = textCues; + // Skip updating if this is a transition from empty-cues to empty-cues (i.e. only positioning + // info has changed) since a positional-only change with no cues is a visual no-op. The new + // position info will be used when we get non-empty cue data in a future update() call. + updateWebView(); + } + canvasSubtitleOutput.update(bitmapCues, style, textSize, textSizeType, bottomPaddingFraction); + // Invalidate to trigger canvasSubtitleOutput to draw. + invalidate(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed && !textCues.isEmpty()) { + // A positional change with no cues is a visual no-op. The new layout info will be used + // automatically next time update() is called. + updateWebView(); + } + } + + /** + * Cleans up internal state, including calling {@link WebView#destroy()} on the delegate view. + * + *

      This method may only be called after this view has been removed from the view system. No + * other methods may be called on this view after destroy. + */ + public void destroy() { + webView.destroy(); + } + + private void updateWebView() { + StringBuilder html = new StringBuilder(); + html.append( + Util.formatInvariant( + "

      ", + HtmlUtils.toCssRgba(style.foregroundColor), + convertTextSizeToCss(defaultTextSizeType, defaultTextSize), + CSS_LINE_HEIGHT, + convertCaptionStyleToCssTextShadow(style))); + + 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); + + 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) { + lineValue = Util.formatInvariant("%.2fem", cue.line * CSS_LINE_HEIGHT); + } else { + lineValue = Util.formatInvariant("%.2fem", (-cue.line - 1) * CSS_LINE_HEIGHT); + lineMeasuredFromEnd = true; + } + break; + case Cue.LINE_TYPE_FRACTION: + case Cue.TYPE_UNSET: + default: + lineValue = Util.formatInvariant("%.2f%%", cue.line * 100); + + lineAnchorTranslatePercent = + cue.verticalType == Cue.VERTICAL_TYPE_RL + ? -anchorTypeToTranslatePercent(cue.lineAnchor) + : anchorTypeToTranslatePercent(cue.lineAnchor); + } + } else { + lineValue = Util.formatInvariant("%.2f%%", (1.0f - bottomPaddingFraction) * 100); + lineAnchorTranslatePercent = -100; + } + + String size = + cue.size != Cue.DIMEN_UNSET + ? Util.formatInvariant("%.2f%%", cue.size * 100) + : "fit-content"; + + String textAlign = convertAlignmentToCss(cue.textAlignment); + String writingMode = convertVerticalTypeToCss(cue.verticalType); + String cueTextSizeCssPx = convertTextSizeToCss(cue.textSizeType, cue.textSize); + String windowCssColor = + HtmlUtils.toCssRgba(cue.windowColorSet ? cue.windowColor : style.windowColor); + + String positionProperty; + String lineProperty; + switch (cue.verticalType) { + case Cue.VERTICAL_TYPE_LR: + lineProperty = lineMeasuredFromEnd ? "right" : "left"; + positionProperty = "top"; + break; + case Cue.VERTICAL_TYPE_RL: + lineProperty = lineMeasuredFromEnd ? "left" : "right"; + positionProperty = "top"; + break; + case Cue.TYPE_UNSET: + default: + lineProperty = lineMeasuredFromEnd ? "bottom" : "top"; + positionProperty = "left"; + } + + String sizeProperty; + int horizontalTranslatePercent; + int verticalTranslatePercent; + if (cue.verticalType == Cue.VERTICAL_TYPE_LR || cue.verticalType == Cue.VERTICAL_TYPE_RL) { + sizeProperty = "height"; + horizontalTranslatePercent = lineAnchorTranslatePercent; + verticalTranslatePercent = positionAnchorTranslatePercent; + } else { + sizeProperty = "width"; + horizontalTranslatePercent = positionAnchorTranslatePercent; + 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, + lineValue, + sizeProperty, + size, + textAlign, + writingMode, + cueTextSizeCssPx, + windowCssColor, + horizontalTranslatePercent, + verticalTranslatePercent)) + .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(Charsets.UTF_8), Base64.NO_PADDING), + "text/html", + "base64"); + } + + /** + * Converts a text size to a CSS px value. + * + *

      First converts to Android px using {@link SubtitleViewUtils#resolveTextSize(int, float, int, + * int)}. + * + *

      Then divides by {@link DisplayMetrics#density} to convert from Android px to dp because + * WebView treats one CSS px as one Android dp. + */ + private String convertTextSizeToCss(@Cue.TextSizeType int type, float size) { + float sizePx = + SubtitleViewUtils.resolveTextSize( + type, size, getHeight(), getHeight() - getPaddingTop() - getPaddingBottom()); + if (sizePx == Cue.DIMEN_UNSET) { + return "unset"; + } + float sizeDp = sizePx / getContext().getResources().getDisplayMetrics().density; + 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: + return "vertical-lr"; + case Cue.VERTICAL_TYPE_RL: + return "vertical-rl"; + case Cue.TYPE_UNSET: + default: + return "horizontal-tb"; + } + } + + private static String convertAlignmentToCss(@Nullable Layout.Alignment alignment) { + if (alignment == null) { + return "center"; + } + switch (alignment) { + case ALIGN_NORMAL: + return "start"; + case ALIGN_OPPOSITE: + return "end"; + case ALIGN_CENTER: + default: + return "center"; + } + } + + /** + * Converts a {@link Cue.AnchorType} to a percentage for use in a CSS {@code transform: + * translate(x,y)} function. + * + *

      We use {@code position: absolute} and always use the same CSS positioning property (top, + * bottom, left, right) regardless of the anchor type. The anchor is effectively 'moved' by using + * a CSS {@code translate(x,y)} operation on the value returned from this function. + */ + private static int anchorTypeToTranslatePercent(@Cue.AnchorType int anchorType) { + switch (anchorType) { + case Cue.ANCHOR_TYPE_END: + return -100; + case Cue.ANCHOR_TYPE_MIDDLE: + return -50; + case Cue.ANCHOR_TYPE_START: + case Cue.TYPE_UNSET: + default: + return 0; + } + } +} 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 00000000000..7ee298e3577 --- /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 00000000000..ad5d63ac5c5 --- /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 00000000000..d614a9e2f20 --- /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 00000000000..9b25426dd19 --- /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 00000000000..d95f42ab3d7 --- /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 00000000000..dd023b2fd84 --- /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 00000000000..f0faf4d0254 --- /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 00000000000..73d35277a34 --- /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 00000000000..6789374094a --- /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 00000000000..f00f85f543b --- /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 00000000000..487a1783697 --- /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 00000000000..2dab2c0f173 --- /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 00000000000..183434e8641 --- /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 00000000000..363b94f3dcd --- /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 00000000000..fd1fd8e1d5e --- /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 00000000000..ea6819eb3af --- /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 00000000000..b1d36cde793 --- /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 00000000000..49119345ebc 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 00000000000..f034030e946 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 00000000000..8b0090016b4 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 00000000000..136a2f11f6c 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 00000000000..5524979c80c 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 00000000000..a6fe9576663 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 00000000000..9a060ba139c 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 00000000000..0916a679c00 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 00000000000..175037d7dbd 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 00000000000..9c78d75da36 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 00000000000..4233b9de2f7 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 00000000000..46d48272485 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 00000000000..1ff783555a9 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 00000000000..0351aa8d861 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 00000000000..a730d2f0584 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 00000000000..eb3aa47167d 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 00000000000..ef03dfb0911 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 00000000000..deda835983a 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 00000000000..594518467cb 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 00000000000..d4cbf83246f 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 00000000000..cef4663c17f 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 00000000000..c471afcd4ee 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 00000000000..b6a718ff00f 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 00000000000..c8e5a072c6a 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 00000000000..74d8a009be4 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 00000000000..e5cca64c17c 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 00000000000..d1b24e8d1c6 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 00000000000..cc9962ade73 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 00000000000..40fb18a4e9a 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 00000000000..d93dddca415 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 00000000000..1d45348eec7 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 00000000000..5847a7e79a3 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 00000000000..b89a9411fac 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 00000000000..22e85352bc7 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 00000000000..f4ced436648 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 00000000000..10bcaa32677 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 00000000000..fc0243bf4c7 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 00000000000..5bd2902aeda 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 00000000000..9eacd7f57ea 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 00000000000..36da4e63485 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 00000000000..fc4f4efb953 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 00000000000..8d4b1337d98 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 00000000000..9afa617786a 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 00000000000..6039e3cfd87 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 00000000000..23c3eb55d82 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 00000000000..cafa79d9217 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 00000000000..027bc1157b6 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 00000000000..02980c5b464 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 00000000000..10448d399d0 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 00000000000..f6be472ad2e 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 00000000000..7dc4a41a3e0 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 00000000000..040ba0ab690 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 00000000000..eea21c2ebb7 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 00000000000..51df3049dc2 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 00000000000..5e4dd5550f9 --- /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 00000000000..ee43206b4ad --- /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 00000000000..ee42e2374ab 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 00000000000..ae4cc4689b1 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 00000000000..1f58c697e7f 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 00000000000..32ce426b80a 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 00000000000..2f47653961c 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 00000000000..201f6ff580f 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 00000000000..fdacfa9e71d 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 00000000000..4423c7ce990 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 00000000000..364bad0b843 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 00000000000..06f936803f9 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 00000000000..c978556298f 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 00000000000..d9b2a4e9db7 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 00000000000..23358ae9cfe 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 00000000000..974ee29acf7 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 00000000000..eb08953752a 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 00000000000..ace3e4378bf 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 00000000000..820c7983fe3 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 00000000000..2b5bf9fe77f 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 00000000000..bd2e9e6331e 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 00000000000..da5609f865f 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 00000000000..338d25f8b4f 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 00000000000..955e07861fe 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 00000000000..32ec519cd13 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 00000000000..d3901f6622f 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 00000000000..96eebf87475 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 00000000000..9652e513db0 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 00000000000..5fb4d7bef42 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 00000000000..92af7254636 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 00000000000..352b28a5d59 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 00000000000..46bf418a7cc 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 00000000000..01cbd166784 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 00000000000..59f5bc33fd0 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 00000000000..381625dd7cf 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 00000000000..628c75380d1 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 00000000000..35968f85e24 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 00000000000..1770a0d4387 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 00000000000..0e9df1baa03 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 00000000000..b34687258a3 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 00000000000..bce32333d28 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 00000000000..92e6ea58448 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 00000000000..a7aa4c71b4f 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 00000000000..25a59d6f927 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 00000000000..669a9d3fcc4 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 00000000000..c1dcfb29024 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 00000000000..ef360fe40c7 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 00000000000..0143694140b 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 00000000000..f4ab8b20f2a 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 00000000000..a85aa70d5ed 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 00000000000..b87c23ee339 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 00000000000..2368f14d552 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 00000000000..412ae6a0b7a 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 00000000000..a9b45609c62 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 00000000000..3a2398f9cb0 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 00000000000..5178e3a619c 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 00000000000..2ba05326f01 --- /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 00000000000..e61a015f7d1 --- /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 00000000000..9f7e1fd0279 --- /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 00000000000..5562b1352cb --- /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 00000000000..c5bbb6ecc31 --- /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 00000000000..b61ac79ddf1 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_playback_control_view.xml b/library/ui/src/main/res/layout/exo_playback_control_view.xml deleted file mode 100644 index acfddf11462..00000000000 --- a/library/ui/src/main/res/layout/exo_playback_control_view.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/library/ui/src/main/res/layout/exo_player_control_view.xml b/library/ui/src/main/res/layout/exo_player_control_view.xml index fd221e5d846..acfddf11462 100644 --- a/library/ui/src/main/res/layout/exo_player_control_view.xml +++ b/library/ui/src/main/res/layout/exo_player_control_view.xml @@ -1,5 +1,5 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/library/ui/src/main/res/layout/exo_player_view.xml b/library/ui/src/main/res/layout/exo_player_view.xml index dc6dda1667d..65dea9271eb 100644 --- a/library/ui/src/main/res/layout/exo_player_view.xml +++ b/library/ui/src/main/res/layout/exo_player_view.xml @@ -1,5 +1,5 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + 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 00000000000..75db3e4527b --- /dev/null +++ b/library/ui/src/main/res/layout/exo_styled_embedded_transport_controls.xml @@ -0,0 +1,38 @@ + + + + + + +