Merge branch 'dev-v2' of https://github.com/google/ExoPlayer into dev-v2

This commit is contained in:
Sebastian Roth 2019-08-14 09:24:28 +01:00
commit 6f9102cf54
1100 changed files with 61429 additions and 24630 deletions

62
.github/ISSUE_TEMPLATE/bug.md vendored Normal file
View file

@ -0,0 +1,62 @@
---
name: Bug report
about: Issue template for a bug report.
title: ''
labels: bug, needs triage
assignees: ''
---
Before filing a bug:
-----------------------
- Search existing issues, including issues that are closed:
https://github.com/google/ExoPlayer/issues?q=is%3Aissue
- Consult our developer website, which can be found at https://exoplayer.dev/.
It provides detailed information about supported formats and devices.
- Learn how to create useful log output by using the EventLogger:
https://exoplayer.dev/listening-to-player-events.html#using-eventlogger
- Rule out issues in your own code. A good way to do this is to try and
reproduce the issue in the ExoPlayer demo app. Information about the ExoPlayer
demo app can be found here:
http://exoplayer.dev/demo-application.html.
When reporting a bug:
-----------------------
Fill out the sections below, leaving the headers but replacing the content. If
you're unable to provide certain information, please explain why in the relevant
section. We may close issues if they do not include sufficient information.
### [REQUIRED] Issue description
Describe the issue in detail, including observed and expected behavior.
### [REQUIRED] Reproduction steps
Describe how the issue can be reproduced, ideally using the ExoPlayer demo app
or a small sample app that youre able to share as source code on GitHub.
### [REQUIRED] Link to test content
Provide a JSON snippet for the demo apps media.exolist.json file, or a link to
media that reproduces the issue. If you don't wish to post it publicly, please
submit the issue, then email the link to dev.exoplayer@gmail.com using a subject
in the format "Issue #1234", where "#1234" should be replaced with your issue
number. Provide all the metadata we'd need to play the content like drm license
urls or similar. If the content is accessible only in certain countries or
regions, please say so.
### [REQUIRED] A full bug report captured from the device
Capture a full bug report using "adb bugreport". Output from "adb logcat" or a
log snippet is NOT sufficient. Please attach the captured bug report as a file.
If you don't wish to post it publicly, please submit the issue, then email the
bug report to dev.exoplayer@gmail.com using a subject in the format
"Issue #1234", where "#1234" should be replaced with your issue number.
### [REQUIRED] Version of ExoPlayer being used
Specify the absolute version number. Avoid using terms such as "latest".
### [REQUIRED] Device(s) and version(s) of Android being used
Specify the devices and versions of Android on which the issue can be
reproduced, and how easily it reproduces. If possible, please test on multiple
devices and Android versions.
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/bug.md
-->

View file

@ -0,0 +1,58 @@
---
name: Content not playing correctly
about: Issue template for a content not playing issue.
title: ''
labels: content not playing, needs triage
assignees: ''
---
Before filing a content issue:
------------------------------
- Search existing issues, including issues that are closed:
https://github.com/google/ExoPlayer/issues?q=is%3Aissue
- Consult our supported formats page, which can be found at
https://exoplayer.dev/supported-formats.html.
- Learn how to create useful log output by using the EventLogger:
https://exoplayer.dev/listening-to-player-events.html#using-eventlogger
- Try playing your content in the ExoPlayer demo app. Information about the
ExoPlayer demo app can be found here:
http://exoplayer.dev/demo-application.html.
When reporting a content issue:
-----------------------------
Fill out the sections below, leaving the headers but replacing the content. If
you're unable to provide certain information, please explain why in the relevant
section. We may close issues if they do not include sufficient information.
### [REQUIRED] Content description
Describe the content and any specifics you expected to play but did not. This
could be the container or sample format itself or any features the stream has
and you expect to play, like 5.1 audio track, text tracks or drm systems.
### [REQUIRED] Link to test content
Provide a JSON snippet for the demo apps media.exolist.json file, or a link to
media that reproduces the issue. If you don't wish to post it publicly, please
submit the issue, then email the link to dev.exoplayer@gmail.com using a subject
in the format "Issue #1234", where "#1234" should be replaced with your issue
number. Provide all the metadata we'd need to play the content like drm license
urls or similar. If the content is accessible only in certain countries or
regions, please say so.
### [REQUIRED] Version of ExoPlayer being used
Specify the absolute version number. Avoid using terms such as "latest".
### [REQUIRED] Device(s) and version(s) of Android being used
Specify the devices and versions of Android on which you expect the content to
play. If possible, please test on multiple devices and Android versions.
### [REQUIRED] A full bug report captured from the device
Capture a full bug report using "adb bugreport". Output from "adb logcat" or a
log snippet is NOT sufficient. Please attach the captured bug report as a file.
If you don't wish to post it publicly, please submit the issue, then email the
bug report to dev.exoplayer@gmail.com using a subject in the format
"Issue #1234", where "#1234" should be replaced with your issue number.
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/content_not_playing.md
-->

View file

@ -0,0 +1,35 @@
---
name: Feature request
about: Issue template for a feature request.
title: ''
labels: enhancement, needs triage
assignees: ''
---
Before filing a feature request:
-----------------------
- Search existing open issues, specifically with the label enhancement:
https://github.com/google/ExoPlayer/labels/enhancement
- Search existing pull requests: https://github.com/google/ExoPlayer/pulls
When filing a feature request:
-----------------------
Fill out the sections below, leaving the headers but replacing the content. If
you're unable to provide certain information, please explain why in the relevant
section. We may close issues if they do not include sufficient information.
### [REQUIRED] Use case description
Describe the use case or problem you are trying to solve in detail. If there are
any standards or specifications involved, please provide the relevant details.
### Proposed solution
A clear and concise description of your proposed solution, if you have one.
### Alternatives considered
A clear and concise description of any alternative solutions you considered,
if applicable.
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/feature_request.md
-->

55
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View file

@ -0,0 +1,55 @@
---
name: Question
about: Issue template for a question.
title: ''
labels: question, needs triage
assignees: ''
---
Before filing a question:
-----------------------
- This issue tracker is intended ExoPlayer specific questions. If you're asking
a general Android development question, please do so on Stack Overflow.
- Search existing issues, including issues that are closed. Its often the
quickest way to get an answer!
https://github.com/google/ExoPlayer/issues?q=is%3Aissue
- Consult our developer website, which can be found at https://exoplayer.dev/.
It provides detailed information about supported formats, devices as well as
information about how to use the ExoPlayer library.
- The ExoPlayer library Javadoc can be found at
https://exoplayer.dev/doc/reference/
When filing a question:
-----------------------
Fill out the sections below, leaving the headers but replacing the content. If
you're unable to provide certain information, please explain why in the relevant
section. We may close issues if they do not include sufficient information.
### [REQUIRED] Searched documentation and issues
Tell us where youve already looked for an answer to your question. Its
important for us to know this so that we can improve our documentation.
### [REQUIRED] Question
Describe your question in detail.
### A full bug report captured from the device
In case your question refers to a problem you are seeing in your app, capture a
full bug report using "adb bugreport". Please attach the captured bug report as
a file. If you don't wish to post it publicly, please submit the issue, then
email the bug report to dev.exoplayer@gmail.com using a subject in the format
"Issue #1234", where "#1234" should be replaced with your issue number.
### Link to test content
In case your question is related to a piece of media, which you are trying to
play, please provide a JSON snippet for the demo apps media.exolist.json file,
or a link to media that reproduces the issue. If you don't wish to post it
publicly, please submit the issue, then email the link to
dev.exoplayer@gmail.com using a subject in the format "Issue #1234", where
"#1234" should be replaced with your issue number. Provide all the metadata we'd
need to play the content like drm license urls or similar. If the content is
accessible only in certain countries or regions, please say so.
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/question.md
-->

9
.gitignore vendored
View file

@ -37,6 +37,12 @@ local.properties
proguard.cfg
proguard-project.txt
# Bazel
bazel-bin
bazel-genfiles
bazel-out
bazel-testlogs
# Other
.DS_Store
cmake-build-debug
@ -66,3 +72,6 @@ extensions/cronet/jniLibs/*
extensions/cronet/libs/*
!extensions/cronet/libs/README.md
# Cast receiver
cast_receiver_app/external-js
cast_receiver_app/bazel-cast_receiver_app

View file

@ -44,6 +44,12 @@ local.properties
proguard.cfg
proguard-project.txt
# Bazel
bazel-bin
bazel-genfiles
bazel-out
bazel-testlogs
# Other
.DS_Store
cmake-build-debug
@ -69,3 +75,7 @@ extensions/cronet/jniLibs/*
!extensions/cronet/jniLibs/README.md
extensions/cronet/libs/*
!extensions/cronet/libs/README.md
# Cast receiver
cast_receiver_app/external-js
cast_receiver_app/bazel-cast_receiver_app

View file

@ -16,9 +16,8 @@ all of the information requested in the issue template.
## Pull requests ##
We will also consider high quality pull requests. These should normally merge
into the `dev-vX` branch with the highest major version number. Bug fixes may
be suitable for merging into older `dev-vX` branches. Before a pull request can
be accepted you must submit a Contributor License Agreement, as described below.
into the `dev-v2` branch. Before a pull request can be accepted you must submit
a Contributor License Agreement, as described below.
[dev]: https://github.com/google/ExoPlayer/tree/dev

View file

@ -1,43 +0,0 @@
Before filing an issue:
-----------------------
- Search existing issues, including issues that are closed.
- Consult our FAQs, supported devices and supported formats pages. These can be
found at https://google.github.io/ExoPlayer/.
- Rule out issues in your own code. A good way to do this is to try and
reproduce the issue in the ExoPlayer demo app.
- This issue tracker is intended for bugs, feature requests and ExoPlayer
specific questions. If you're asking a general Android development question,
please do so on Stack Overflow.
When reporting a bug:
-----------------------
Fill out the sections below, leaving the headers but replacing the content. If
you're unable to provide certain information, please explain why in the relevant
section. We may close issues if they do not include sufficient information.
### Issue description
Describe the issue in detail, including observed and expected behavior.
### Reproduction steps
Describe how the issue can be reproduced, ideally using the ExoPlayer demo app.
### Link to test content
Provide a link to media that reproduces the issue. If you don't wish to post it
publicly, please submit the issue, then email the link to
dev.exoplayer@gmail.com using a subject in the format "Issue #1234".
### Version of ExoPlayer being used
Specify the absolute version number. Avoid using terms such as "latest".
### Device(s) and version(s) of Android being used
Specify the devices and versions of Android on which the issue can be
reproduced, and how easily it reproduces. If possible, please test on multiple
devices and Android versions.
### A full bug report captured from the device
Capture a full bug report using "adb bugreport". Output from "adb logcat" or a
log snippet is NOT sufficient. Please attach the captured bug report as a file.
If you don't wish to post it publicly, please submit the issue, then email the
bug report to dev.exoplayer@gmail.com using a subject in the format
"Issue #1234".

View file

@ -15,8 +15,8 @@ and extend, and can be updated through Play Store application updates.
* Follow our [developer blog][] to keep up to date with the latest ExoPlayer
developments!
[developer guide]: https://google.github.io/ExoPlayer/guide.html
[class reference]: https://google.github.io/ExoPlayer/doc/reference
[developer guide]: https://exoplayer.dev/guide.html
[class reference]: https://exoplayer.dev/doc/reference
[release notes]: https://github.com/google/ExoPlayer/blob/release-v2/RELEASENOTES.md
[developer blog]: https://medium.com/google-exoplayer
@ -27,17 +27,21 @@ repository and depend on the modules locally.
### From JCenter ###
#### 1. Add repositories ####
The easiest way to get started using ExoPlayer is to add it as a gradle
dependency. You need to make sure you have the JCenter and Google repositories
dependency. You need to make sure you have the Google and JCenter repositories
included in the `build.gradle` file in the root of your project:
```gradle
repositories {
jcenter()
google()
jcenter()
}
```
#### 2. Add ExoPlayer module dependencies ####
Next add a dependency in the `build.gradle` file of your app module. The
following will add a dependency to the full library:
@ -45,10 +49,12 @@ following will add a dependency to the full library:
implementation 'com.google.android.exoplayer:exoplayer:2.X.X'
```
where `2.X.X` is your preferred version. Alternatively, you can depend on only
the library modules that you actually need. For example the following will add
dependencies on the Core, DASH and UI library modules, as might be required for
an app that plays DASH content:
where `2.X.X` is your preferred version.
As an alternative to the full library, you can depend on only the library
modules that you actually need. For example the following will add dependencies
on the Core, DASH and UI library modules, as might be required for an app that
plays DASH content:
```gradle
implementation 'com.google.android.exoplayer:exoplayer-core:2.X.X'
@ -77,6 +83,18 @@ JCenter can be found on [Bintray][].
[extensions directory]: https://github.com/google/ExoPlayer/tree/release-v2/extensions/
[Bintray]: https://bintray.com/google/exoplayer
#### 3. Turn on Java 8 support ####
If not enabled already, you also need to turn on Java 8 support in all
`build.gradle` files depending on ExoPlayer, by adding the following to the
`android` section:
```gradle
compileOptions {
targetCompatibility JavaVersion.VERSION_1_8
}
```
### Locally ###
Cloning the repository and depending on the modules locally is required when

View file

@ -2,144 +2,610 @@
### dev-v2 (not yet released) ###
* Add a flag to opt-in to automatic audio focus handling via
* Update `DefaultTrackSelector` to apply a viewport constraint for the default
display by default.
* Add `PlaybackStatsListener` to collect `PlaybackStats` for playbacks analysis
and analytics reporting (TODO: link to developer guide page/blog post).
* Add basic DRM support to the Cast demo app.
* Assume that encrypted content requires secure decoders in renderer support
checks ([#5568](https://github.com/google/ExoPlayer/issues/5568)).
* Decoders: Prefer decoders that advertise format support over ones that do not,
even if they are listed lower in the `MediaCodecList`.
* Add a workaround for broken raw audio decoding on Oppo R9
([#5782](https://github.com/google/ExoPlayer/issues/5782)).
* Add VR player demo.
* Wrap decoder exceptions in a new `DecoderException` class and report as
renderer error.
* Do not pass the manifest to callbacks of `Player.EventListener` and
`SourceInfoRefreshListener` anymore. Instead make it accessible through
`Player.getCurrentManifest()` and `Timeline.Window.manifest`. Also rename
`SourceInfoRefreshListener` to `MediaSourceCaller`.
* Set `compileSdkVersion` to 29 to use Android Q APIs.
* Add `enable` and `disable` methods to `MediaSource` to improve resource
management in playlists.
* Improve text selection logic to always prefer the better language matches
over other selection parameters.
* Remove `AnalyticsCollector.Factory`. Instances can be created directly and
the `Player` set later using `AnalyticsCollector.setPlayer`.
* Add `allowAudioMixedChannelCountAdaptiveness` parameter to
`DefaultTrackSelector` to allow adaptive selections of audio tracks with
different channel counts
([#6257](https://github.com/google/ExoPlayer/issues/6257)).
### 2.10.4 ###
* Offline: Add `Scheduler` implementation that uses `WorkManager`.
* Add ability to specify a description when creating notification channels via
ExoPlayer library classes.
* Switch normalized BCP-47 language codes to use 2-letter ISO 639-1 language
tags instead of 3-letter ISO 639-2 language tags.
* Ensure the `SilenceMediaSource` position is in range
([#6229](https://github.com/google/ExoPlayer/issues/6229)).
* WAV: Calculate correct duration for clipped streams
([#6241](https://github.com/google/ExoPlayer/issues/6241)).
* MP3: Use CBR header bitrate, not calculated bitrate. This reverts a change
from 2.9.3 ([#6238](https://github.com/google/ExoPlayer/issues/6238)).
* Flac extension: Parse `VORBIS_COMMENT` and `PICTURE` metadata
([#5527](https://github.com/google/ExoPlayer/issues/5527)).
* Fix issue where initial seek positions get ignored when playing a preroll ad
([#6201](https://github.com/google/ExoPlayer/issues/6201)).
* Fix issue where invalid language tags were normalized to "und" instead of
keeping the original
([#6153](https://github.com/google/ExoPlayer/issues/6153)).
* Fix `DataSchemeDataSource` re-opening and range requests
([#6192](https://github.com/google/ExoPlayer/issues/6192)).
* Fix Flac and ALAC playback on some LG devices
([#5938](https://github.com/google/ExoPlayer/issues/5938)).
* Fix issue when calling `performClick` on `PlayerView` without
`PlayerControlView`
([#6260](https://github.com/google/ExoPlayer/issues/6260)).
* Fix issue where playback speeds are not used in adaptive track selections
after manual selection changes for other renderers
([#6256](https://github.com/google/ExoPlayer/issues/6256)).
### 2.10.3 ###
* Display last frame when seeking to end of stream
([#2568](https://github.com/google/ExoPlayer/issues/2568)).
* Audio:
* Fix an issue where not all audio was played out when the configuration
for the underlying track was changing (e.g., at some period transitions).
* Fix an issue where playback speed was applied inaccurately in playlists
([#6117](https://github.com/google/ExoPlayer/issues/6117)).
* UI: Fix `PlayerView` incorrectly consuming touch events if no controller is
attached ([#6109](https://github.com/google/ExoPlayer/issues/6109)).
* CEA608: Fix repetition of special North American characters
([#6133](https://github.com/google/ExoPlayer/issues/6133)).
* FLV: Fix bug that caused playback of some live streams to not start
([#6111](https://github.com/google/ExoPlayer/issues/6111)).
* SmoothStreaming: Parse text stream `Subtype` into `Format.roleFlags`.
* MediaSession extension: Fix `MediaSessionConnector.play()` not resuming
playback ([#6093](https://github.com/google/ExoPlayer/issues/6093)).
### 2.10.2 ###
* Add `ResolvingDataSource` for just-in-time resolution of `DataSpec`s
([#5779](https://github.com/google/ExoPlayer/issues/5779)).
* Add `SilenceMediaSource` that can be used to play silence of a given
duration ([#5735](https://github.com/google/ExoPlayer/issues/5735)).
* Offline:
* Prevent unexpected `DownloadHelper.Callback.onPrepared` callbacks after
preparation of a `DownloadHelper` fails
([#5915](https://github.com/google/ExoPlayer/issues/5915)).
* Fix `CacheUtil.cache()` downloading too much data
([#5927](https://github.com/google/ExoPlayer/issues/5927)).
* Fix misreporting cached bytes when caching is paused
([#5573](https://github.com/google/ExoPlayer/issues/5573)).
* UI:
* Allow setting `DefaultTimeBar` attributes on `PlayerView` and
`PlayerControlView`.
* Change playback controls toggle from touch down to touch up events
([#5784](https://github.com/google/ExoPlayer/issues/5784)).
* Fix issue where playback controls were not kept visible on key presses
([#5963](https://github.com/google/ExoPlayer/issues/5963)).
* Subtitles:
* CEA-608: Handle XDS and TEXT modes
([#5807](https://github.com/google/ExoPlayer/pull/5807)).
* TTML: Fix bitmap rendering
([#5633](https://github.com/google/ExoPlayer/pull/5633)).
* IMA: Fix ad pod index offset calculation without preroll
([#5928](https://github.com/google/ExoPlayer/issues/5928)).
* Add a `playWhenReady` flag to MediaSessionConnector.PlaybackPreparer methods
to indicate whether a controller sent a play or only a prepare command. This
allows to take advantage of decoder reuse with the MediaSessionConnector
([#5891](https://github.com/google/ExoPlayer/issues/5891)).
* Add `ProgressUpdateListener` to `PlayerControlView`
([#5834](https://github.com/google/ExoPlayer/issues/5834)).
* Add support for auto-detecting UDP streams in `DefaultDataSource`
([#6036](https://github.com/google/ExoPlayer/pull/6036)).
* Allow enabling decoder fallback with `DefaultRenderersFactory`
([#5942](https://github.com/google/ExoPlayer/issues/5942)).
* Gracefully handle revoked `ACCESS_NETWORK_STATE` permission
([#6019](https://github.com/google/ExoPlayer/issues/6019)).
* Fix decoding problems when seeking back after seeking beyond a mid-roll ad
([#6009](https://github.com/google/ExoPlayer/issues/6009)).
* Fix application of `maxAudioBitrate` for adaptive audio track groups
([#6006](https://github.com/google/ExoPlayer/issues/6006)).
* Fix bug caused by parallel adaptive track selection using `Format`s without
bitrate information
([#5971](https://github.com/google/ExoPlayer/issues/5971)).
* Fix bug in `CastPlayer.getCurrentWindowIndex()`
([#5955](https://github.com/google/ExoPlayer/issues/5955)).
### 2.10.1 ###
* Offline: Add option to remove all downloads.
* HLS: Fix `NullPointerException` when using HLS chunkless preparation
([#5868](https://github.com/google/ExoPlayer/issues/5868)).
* Fix handling of empty values and line terminators in SHOUTcast ICY metadata
([#5876](https://github.com/google/ExoPlayer/issues/5876)).
* Fix DVB subtitles for SDK 28
([#5862](https://github.com/google/ExoPlayer/issues/5862)).
* Add a workaround for a decoder failure on ZTE Axon7 mini devices when playing
48kHz audio ([#5821](https://github.com/google/ExoPlayer/issues/5821)).
### 2.10.0 ###
* Core library:
* Improve decoder re-use between playbacks
([#2826](https://github.com/google/ExoPlayer/issues/2826)). Read
[this blog post](https://medium.com/google-exoplayer/improved-decoder-reuse-in-exoplayer-ef4c6d99591d)
for more details.
* Rename `ExtractorMediaSource` to `ProgressiveMediaSource`.
* Fix issue where using `ProgressiveMediaSource.Factory` would mean that
`DefaultExtractorsFactory` would be kept by proguard. Custom
`ExtractorsFactory` instances must now be passed via the
`ProgressiveMediaSource.Factory` constructor, and `setExtractorsFactory` is
deprecated.
* Move `PriorityTaskManager` from `DefaultLoadControl` to `SimpleExoPlayer`.
* Add new `ExoPlaybackException` types for remote exceptions and out-of-memory
errors.
* Use full BCP 47 language tags in `Format`.
* Do not retry failed loads whose error is `FileNotFoundException`.
* Fix issue where not resetting the position for a new `MediaSource` in calls
to `ExoPlayer.prepare` causes an `IndexOutOfBoundsException`
([#5520](https://github.com/google/ExoPlayer/issues/5520)).
* Offline:
* Improve offline support. `DownloadManager` now tracks all offline content,
not just tasks in progress. Read
[this page](https://exoplayer.dev/downloading-media.html) for more details.
* Caching:
* Improve performance of `SimpleCache`
([#4253](https://github.com/google/ExoPlayer/issues/4253)).
* Cache data with unknown length by default. The previous flag to opt in to
this behavior (`DataSpec.FLAG_ALLOW_CACHING_UNKNOWN_LENGTH`) has been
replaced with an opt out flag
(`DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN`).
* Extractors:
* MP4/FMP4: Add support for Dolby Vision.
* MP4: Fix issue handling meta atoms in some streams
([#5698](https://github.com/google/ExoPlayer/issues/5698),
[#5694](https://github.com/google/ExoPlayer/issues/5694)).
* MP3: Add support for SHOUTcast ICY metadata
([#3735](https://github.com/google/ExoPlayer/issues/3735)).
* MP3: Fix ID3 frame unsychronization
([#5673](https://github.com/google/ExoPlayer/issues/5673)).
* MP3: Fix playback of badly clipped files
([#5772](https://github.com/google/ExoPlayer/issues/5772)).
* MPEG-TS: Enable HDMV DTS stream detection only if a flag is set. By default
(i.e. if the flag is not set), the 0x82 elementary stream type is now
treated as an SCTE subtitle track
([#5330](https://github.com/google/ExoPlayer/issues/5330)).
* Track selection:
* Add options for controlling audio track selections to `DefaultTrackSelector`
([#3314](https://github.com/google/ExoPlayer/issues/3314)).
* Update `TrackSelection.Factory` interface to support creating all track
selections together.
* Allow to specify a selection reason for a `SelectionOverride`.
* When no text language preference matches, only select forced text tracks
whose language matches the selected audio language.
* UI:
* Update `DefaultTimeBar` based on duration of media and add parameter to set
the minimum update interval to control the smoothness of the updates
([#5040](https://github.com/google/ExoPlayer/issues/5040)).
* Move creation of dialogs for `TrackSelectionView`s to
`TrackSelectionDialogBuilder` and add option to select multiple overrides.
* Change signature of `PlayerNotificationManager.NotificationListener` to
better fit service requirements.
* Add option to include navigation actions in the compact mode of
notifications created using `PlayerNotificationManager`.
* Fix issues with flickering notifications on KitKat when using
`PlayerNotificationManager` and `DownloadNotificationUtil`. For the latter,
applications should switch to using `DownloadNotificationHelper`.
* Fix accuracy of D-pad seeking in `DefaultTimeBar`
([#5767](https://github.com/google/ExoPlayer/issues/5767)).
* Audio:
* Allow `AudioProcessor`s to be drained of pending output after they are
reconfigured.
* Fix an issue that caused audio to be truncated at the end of a period
when switching to a new period where gapless playback information was newly
present or newly absent.
* Add support for reading AC-4 streams
([#5303](https://github.com/google/ExoPlayer/pull/5303)).
* Video:
* Remove `MediaCodecSelector.DEFAULT_WITH_FALLBACK`. Apps should instead
signal that fallback should be used by passing `true` as the
`enableDecoderFallback` parameter when instantiating the video renderer.
* Support video tunneling when the decoder is not listed first for the MIME
type ([#3100](https://github.com/google/ExoPlayer/issues/3100)).
* Query `MediaCodecList.ALL_CODECS` when selecting a tunneling decoder
([#5547](https://github.com/google/ExoPlayer/issues/5547)).
* DRM:
* Fix black flicker when keys rotate in DRM protected content
([#3561](https://github.com/google/ExoPlayer/issues/3561)).
* Work around lack of LA_URL attribute in PlayReady key request init data.
* CEA-608: Improved conformance to the specification
([#3860](https://github.com/google/ExoPlayer/issues/3860)).
* DASH:
* Parse role and accessibility descriptors into `Format.roleFlags`.
* Support multiple CEA-608 channels muxed into FMP4 representations
([#5656](https://github.com/google/ExoPlayer/issues/5656)).
* HLS:
* Prevent unnecessary reloads of initialization segments.
* Form an adaptive track group out of audio renditions with matching name.
* Support encrypted initialization segments
([#5441](https://github.com/google/ExoPlayer/issues/5441)).
* Parse `EXT-X-MEDIA` `CHARACTERISTICS` attribute into `Format.roleFlags`.
* Add metadata entry for HLS tracks to expose master playlist information.
* Prevent `IndexOutOfBoundsException` in some live HLS scenarios
([#5816](https://github.com/google/ExoPlayer/issues/5816)).
* Support for playing spherical videos on Daydream.
* Cast extension: Work around Cast framework returning a limited-size queue
items list ([#4964](https://github.com/google/ExoPlayer/issues/4964)).
* VP9 extension: Remove RGB output mode and libyuv dependency, and switch to
surface YUV output as the default. Remove constructor parameters `scaleToFit`
and `useSurfaceYuvOutput`.
* MediaSession extension:
* Let apps intercept media button events
([#5179](https://github.com/google/ExoPlayer/issues/5179)).
* Fix issue with `TimelineQueueNavigator` not publishing the queue in shuffled
order when in shuffle mode.
* Allow handling of custom commands via `registerCustomCommandReceiver`.
* Add ability to include an extras `Bundle` when reporting a custom error.
* LoadControl: Set minimum buffer for playbacks with video equal to maximum
buffer ([#2083](https://github.com/google/ExoPlayer/issues/2083)).
* Log warnings when extension native libraries can't be used, to help with
diagnosing playback failures
([#5788](https://github.com/google/ExoPlayer/issues/5788)).
### 2.9.6 ###
* Remove `player` and `isTopLevelSource` parameters from `MediaSource.prepare`.
* IMA extension:
* Require setting the `Player` on `AdsLoader` instances before
playback.
* Remove deprecated `ImaAdsMediaSource`. Create `AdsMediaSource` with an
`ImaAdsLoader` instead.
* Remove deprecated `AdsMediaSource` constructors. Listen for media source
events using `AdsMediaSource.addEventListener`, and ad interaction events by
adding a listener when building `ImaAdsLoader`.
* Allow apps to register playback-related obstructing views that are on top of
their ad display containers via `AdsLoader.AdViewProvider`. `PlayerView`
implements this interface and will register its control view. This makes it
possible for ad loading SDKs to calculate ad viewability accurately.
* DASH: Fix issue handling large `EventStream` presentation timestamps
([#5490](https://github.com/google/ExoPlayer/issues/5490)).
* HLS: Fix transition to STATE_ENDED when playing fragmented mp4 in chunkless
preparation ([#5524](https://github.com/google/ExoPlayer/issues/5524)).
* Revert workaround for video quality problems with Amlogic decoders, as this
may cause problems for some devices and/or non-interlaced content
([#5003](https://github.com/google/ExoPlayer/issues/5003)).
### 2.9.5 ###
* HLS: Parse `CHANNELS` attribute from `EXT-X-MEDIA` tag.
* ConcatenatingMediaSource:
* Add `Handler` parameter to methods that take a callback `Runnable`.
* Fix issue with dropped messages when releasing the source
([#5464](https://github.com/google/ExoPlayer/issues/5464)).
* ExtractorMediaSource: Fix issue that could cause the player to get stuck
buffering at the end of the media.
* PlayerView: Fix issue preventing `OnClickListener` from receiving events
([#5433](https://github.com/google/ExoPlayer/issues/5433)).
* IMA extension: Upgrade IMA dependency to 3.10.6.
* Cronet extension: Upgrade Cronet dependency to 71.3578.98.
* OkHttp extension: Upgrade OkHttp dependency to 3.12.1.
* MP3: Wider fix for issue where streams would play twice on some Samsung
devices ([#4519](https://github.com/google/ExoPlayer/issues/4519)).
### 2.9.4 ###
* IMA extension: Clear ads loader listeners on release
([#4114](https://github.com/google/ExoPlayer/issues/4114)).
* SmoothStreaming: Fix support for subtitles in DRM protected streams
([#5378](https://github.com/google/ExoPlayer/issues/5378)).
* FFmpeg extension: Treat invalid data errors as non-fatal to match the behavior
of MediaCodec ([#5293](https://github.com/google/ExoPlayer/issues/5293)).
* GVR extension: upgrade GVR SDK dependency to 1.190.0.
* Associate fatal player errors of type SOURCE with the loading source in
`AnalyticsListener.EventTime`
([#5407](https://github.com/google/ExoPlayer/issues/5407)).
* Add `startPositionUs` to `MediaSource.createPeriod`. This fixes an issue where
using lazy preparation in `ConcatenatingMediaSource` with an
`ExtractorMediaSource` overrides initial seek positions
([#5350](https://github.com/google/ExoPlayer/issues/5350)).
* Add subtext to the `MediaDescriptionAdapter` of the
`PlayerNotificationManager`.
* Add workaround for video quality problems with Amlogic decoders
([#5003](https://github.com/google/ExoPlayer/issues/5003)).
* Fix issue where sending callbacks for playlist changes may cause problems
because of parallel player access
([#5240](https://github.com/google/ExoPlayer/issues/5240)).
* Fix issue with reusing a `ClippingMediaSource` with an inner
`ExtractorMediaSource` and a non-zero start position
([#5351](https://github.com/google/ExoPlayer/issues/5351)).
* Fix issue where uneven track durations in MP4 streams can cause OOM problems
([#3670](https://github.com/google/ExoPlayer/issues/3670)).
### 2.9.3 ###
* Captions: Support PNG subtitles in SMPTE-TT
([#1583](https://github.com/google/ExoPlayer/issues/1583)).
* MPEG-TS: Use random access indicators to minimize the need for
`FLAG_ALLOW_NON_IDR_KEYFRAMES`.
* Downloading: Reduce time taken to remove downloads
([#5136](https://github.com/google/ExoPlayer/issues/5136)).
* MP3:
* Use the true bitrate for constant-bitrate MP3 seeking.
* Fix issue where streams would play twice on some Samsung devices
([#4519](https://github.com/google/ExoPlayer/issues/4519)).
* Fix regression where some audio formats were incorrectly marked as being
unplayable due to under-reporting of platform decoder capabilities
([#5145](https://github.com/google/ExoPlayer/issues/5145)).
* Fix decode-only frame skipping on Nvidia Shield TV devices.
* Workaround for MiTV (dangal) issue when swapping output surface
([#5169](https://github.com/google/ExoPlayer/issues/5169)).
### 2.9.2 ###
* HLS:
* Fix issue causing unnecessary media playlist requests when playing live
streams ([#5059](https://github.com/google/ExoPlayer/issues/5059)).
* Fix decoder re-instantiation issue for packed audio streams
([#5063](https://github.com/google/ExoPlayer/issues/5063)).
* MP4: Support Opus and FLAC in the MP4 container, and in DASH
([#4883](https://github.com/google/ExoPlayer/issues/4883)).
* DASH: Fix detecting the end of live events
([#4780](https://github.com/google/ExoPlayer/issues/4780)).
* Spherical video: Fall back to `TYPE_ROTATION_VECTOR` if
`TYPE_GAME_ROTATION_VECTOR` is unavailable
([#5119](https://github.com/google/ExoPlayer/issues/5119)).
* Support seeking for a wider range of MPEG-TS streams
([#5097](https://github.com/google/ExoPlayer/issues/5097)).
* Include channel count in audio capabilities check
([#4690](https://github.com/google/ExoPlayer/issues/4690)).
* Fix issue with applying the `show_buffering` attribute in `PlayerView`
([#5139](https://github.com/google/ExoPlayer/issues/5139)).
* Fix issue where null `Metadata` was output when it failed to decode
([#5149](https://github.com/google/ExoPlayer/issues/5149)).
* Fix playback of some invalid but playable MP4 streams by replacing assertions
with logged warnings in sample table parsing code
([#5162](https://github.com/google/ExoPlayer/issues/5162)).
* Fix UUID passed to `MediaCrypto` when using `C.CLEARKEY_UUID` before API 27.
### 2.9.1 ###
* Add convenience methods `Player.next`, `Player.previous`, `Player.hasNext`
and `Player.hasPrevious`
([#4863](https://github.com/google/ExoPlayer/issues/4863)).
* Improve initial bandwidth meter estimates using the current country and
network type.
* IMA extension:
* For preroll to live stream transitions, project forward the loading position
to avoid being behind the live window.
* Let apps specify whether to focus the skip button on ATV
([#5019](https://github.com/google/ExoPlayer/issues/5019)).
* MP3:
* Support seeking based on MLLT metadata
([#3241](https://github.com/google/ExoPlayer/issues/3241)).
* Fix handling of streams with appended data
([#4954](https://github.com/google/ExoPlayer/issues/4954)).
* DASH: Parse ProgramInformation element if present in the manifest.
* HLS:
* Add constructor to `DefaultHlsExtractorFactory` for adding TS payload
reader factory flags
([#4861](https://github.com/google/ExoPlayer/issues/4861)).
* Fix bug in segment sniffing
([#5039](https://github.com/google/ExoPlayer/issues/5039)).
* SubRip: Add support for alignment tags, and remove tags from the displayed
captions ([#4306](https://github.com/google/ExoPlayer/issues/4306)).
* Fix issue with blind seeking to windows with non-zero offset in a
`ConcatenatingMediaSource`
([#4873](https://github.com/google/ExoPlayer/issues/4873)).
* Fix logic for enabling next and previous actions in `TimelineQueueNavigator`
([#5065](https://github.com/google/ExoPlayer/issues/5065)).
* Fix issue where audio focus handling could not be disabled after enabling it
([#5055](https://github.com/google/ExoPlayer/issues/5055)).
* Fix issue where subtitles were positioned incorrectly if `SubtitleView` had a
non-zero position offset to its parent
([#4788](https://github.com/google/ExoPlayer/issues/4788)).
* Fix issue where the buffered position was not updated correctly when
transitioning between periods
([#4899](https://github.com/google/ExoPlayer/issues/4899)).
* Fix issue where a `NullPointerException` is thrown when removing an unprepared
media source from a `ConcatenatingMediaSource` with the `useLazyPreparation`
option enabled ([#4986](https://github.com/google/ExoPlayer/issues/4986)).
* Work around an issue where a non-empty end-of-stream audio buffer would be
output with timestamp zero, causing the player position to jump backwards
([#5045](https://github.com/google/ExoPlayer/issues/5045)).
* Suppress a spurious assertion failure on some Samsung devices
([#4532](https://github.com/google/ExoPlayer/issues/4532)).
* Suppress spurious "references unknown class member" shrinking warning
([#4890](https://github.com/google/ExoPlayer/issues/4890)).
* Swap recommended order for google() and jcenter() in gradle config
([#4997](https://github.com/google/ExoPlayer/issues/4997)).
### 2.9.0 ###
* Turn on Java 8 compiler support for the ExoPlayer library. Apps may need to
add `compileOptions { targetCompatibility JavaVersion.VERSION_1_8 }` to their
gradle settings to ensure bytecode compatibility.
* Set `compileSdkVersion` and `targetSdkVersion` to 28.
* Support for automatic audio focus handling via
`SimpleExoPlayer.setAudioAttributes`.
* Distribute Cronet extension via jCenter.
* Set compileSdkVersion and targetSdkVersion to 28.
* Add `ExoPlayer.retry` convenience method.
* Add `AudioListener` for listening to changes in audio configuration during
playback ([#3994](https://github.com/google/ExoPlayer/issues/3994)).
* Improved seeking support:
* Add `LoadErrorHandlingPolicy` to allow configuration of load error handling
across `MediaSource` implementations
([#3370](https://github.com/google/ExoPlayer/issues/3370)).
* Allow passing a `Looper`, which specifies the thread that must be used to
access the player, when instantiating player instances using
`ExoPlayerFactory` ([#4278](https://github.com/google/ExoPlayer/issues/4278)).
* Allow setting log level for ExoPlayer logcat output
([#4665](https://github.com/google/ExoPlayer/issues/4665)).
* Simplify `BandwidthMeter` injection: The `BandwidthMeter` should now be
passed directly to `ExoPlayerFactory`, instead of to `TrackSelection.Factory`
and `DataSource.Factory`. The `BandwidthMeter` is passed to the components
that need it internally. The `BandwidthMeter` may also be omitted, in which
case a default instance will be used.
* Spherical video:
* Support for spherical video by setting `surface_type="spherical_view"` on
`PlayerView`.
* Support for
[VR180](https://github.com/google/spatial-media/blob/master/docs/vr180.md).
* HLS:
* Support PlayReady.
* Add container format sniffing
([#2025](https://github.com/google/ExoPlayer/issues/2025)).
* Support alternative `EXT-X-KEY` tags.
* Support `EXT-X-INDEPENDENT-SEGMENTS` in the master playlist.
* Support variable substitution
([#4422](https://github.com/google/ExoPlayer/issues/4422)).
* Fix the bitrate being unset on primary track sample formats
([#3297](https://github.com/google/ExoPlayer/issues/3297)).
* Make `HlsMediaSource.Factory` take a factory of trackers instead of a
tracker instance ([#4814](https://github.com/google/ExoPlayer/issues/4814)).
* DASH:
* Support `messageData` attribute for in-manifest event streams.
* Clip periods to their specified durations
([#4185](https://github.com/google/ExoPlayer/issues/4185)).
* Improve seeking support for progressive streams:
* Support seeking in MPEG-TS
([#966](https://github.com/google/ExoPlayer/issues/966)).
* Support seeking in MPEG-PS
([#4476](https://github.com/google/ExoPlayer/issues/4476)).
* Support approximate seeking in ADTS using a constant bitrate assumption
([#4548](https://github.com/google/ExoPlayer/issues/4548)). Note that the
([#4548](https://github.com/google/ExoPlayer/issues/4548)). The
`FLAG_ENABLE_CONSTANT_BITRATE_SEEKING` flag must be set on the extractor to
enable this functionality.
* Support approximate seeking in AMR using a constant bitrate assumption.
Note that the `FLAG_ENABLE_CONSTANT_BITRATE_SEEKING` flag must be set on the
extractor to enable this functionality.
The `FLAG_ENABLE_CONSTANT_BITRATE_SEEKING` flag must be set on the extractor
to enable this functionality.
* Add `DefaultExtractorsFactory.setConstantBitrateSeekingEnabled` to enable
approximate seeking using a constant bitrate assumption for all extractors
approximate seeking using a constant bitrate assumption on all extractors
that support it.
* MPEG-TS: Support CEA-608/708 in H262
([#2565](https://github.com/google/ExoPlayer/issues/2565)).
* MediaSession extension: Allow apps to set custom errors.
* Video:
* Add callback to `VideoListener` to notify of surface size changes.
* Improve performance when playing high frame-rate content, and when playing
at greater than 1x speed
([#2777](https://github.com/google/ExoPlayer/issues/2777)).
* Scale up the initial video decoder maximum input size so playlist
transitions with small increases in maximum sample size do not require
reinitialization ([#4510](https://github.com/google/ExoPlayer/issues/4510)).
* Fix a bug where the player would not transition to the ended state when
playing video in tunneled mode.
* Audio:
* Add support for mu-law and A-law PCM with the ffmpeg extension
* Support attaching auxiliary audio effects to the `AudioTrack` via
`Player.setAuxEffectInfo` and `Player.clearAuxEffectInfo`.
* Support seamless adaptation while playing xHE-AAC streams.
([#4360](https://github.com/google/ExoPlayer/issues/4360)).
* Increase `AudioTrack` buffer sizes to the theoretical maximum required for
each encoding for passthrough playbacks
([#3803](https://github.com/google/ExoPlayer/issues/3803)).
* Add support for attaching auxiliary audio effects to the `AudioTrack`.
* Add support for seamless adaptation while playing xHE-AAC streams.
* Video:
* Add callback to `VideoListener` to notify of surface size changes.
* Scale up the initial video decoder maximum input size so playlist item
transitions with small increases in maximum sample size don't require
reinitialization ([#4510](https://github.com/google/ExoPlayer/issues/4510)).
* Propagate the end-of-stream signal directly in the renderer when using
tunneling, to fix an issue where the player would remain ready after the
stream ended.
* WAV: Fix issue where white noise would be output at the end of playback
([#4724](https://github.com/google/ExoPlayer/issues/4724)).
* MP3: Fix issue where streams would play twice on the SM-T530
([#4519](https://github.com/google/ExoPlayer/issues/4519)).
* Analytics:
* Add callbacks to `DefaultDrmSessionEventListener` and `AnalyticsListener` to
be notified of acquired and released DRM sessions.
* Add uri field to `LoadEventInfo` in `MediaSourceEventListener` and
`AnalyticsListener` callbacks. This uri is the redirected uri if redirection
occurred ([#2054](https://github.com/google/ExoPlayer/issues/2054)).
* Add response headers field to `LoadEventInfo` in `MediaSourceEventListener`
and `AnalyticsListener` callbacks
([#4361](https://github.com/google/ExoPlayer/issues/4361) and
[#4615](https://github.com/google/ExoPlayer/issues/4615)).
* UI components:
* Add option to `PlayerView` to show buffering view when playWhenReady is
false ([#4304](https://github.com/google/ExoPlayer/issues/4304)).
* Allow any `Drawable` to be used as `PlayerView` default artwork.
* ConcatenatingMediaSource:
* Support lazy preparation of playlist media sources
([#3972](https://github.com/google/ExoPlayer/issues/3972)).
* Support range removal with `removeMediaSourceRange` methods
([#4542](https://github.com/google/ExoPlayer/issues/4542)).
* Support setting a new shuffle order with `setShuffleOrder`
([#4791](https://github.com/google/ExoPlayer/issues/4791)).
* MPEG-TS: Support CEA-608/708 in H262
([#2565](https://github.com/google/ExoPlayer/issues/2565)).
* Allow configuration of the back buffer in `DefaultLoadControl.Builder`
([#4857](https://github.com/google/ExoPlayer/issues/4857)).
* Allow apps to pass a `CacheKeyFactory` for setting custom cache keys when
creating a `CacheDataSource`.
* Turned on Java 8 compiler support for the ExoPlayer library. Apps that depend
on ExoPlayer via its source code rather than an AAR may need to add
`compileOptions { targetCompatibility JavaVersion.VERSION_1_8 }` to their
gradle settings to ensure bytecode compatibility.
* ConcatenatingMediaSource:
* Add support for lazy preparation of playlist media sources
([#3972](https://github.com/google/ExoPlayer/issues/3972)).
* Add support for range removal with `removeMediaSourceRange` methods.
* `BandwidthMeter` management:
* Pass `BandwidthMeter` directly to `ExoPlayerFactory` instead of
`TrackSelection.Factory` and `DataSource.Factory`. May also be omitted to
use the default bandwidth meter automatically. This change only works
correctly if the following changes are adopted for custom `BandwidthMeter`s,
`TrackSelection`s, `MediaSource`s and `DataSource`s.
* Pass `BandwidthMeter` to `TrackSelection.Factory` which should be used to
obtain bandwidth estimates.
* Add method to `BandwidthMeter` to return the `TransferListener` used to
gather bandwidth information. Also add methods to add and remove event
listeners.
* Pass `TransferListener` to `MediaSource`s to listen to media data transfers.
* Add method to `DataSource` to add `TransferListener`s. Custom `DataSource`s
directly reading data should implement `BaseDataSource` to handle the
registration correctly. Custom `DataSource`'s forwarding to other sources
should forward all calls to `addTransferListener`.
* Extend `TransferListener` with additional callback parameters.
* Error handling:
* Allow configuration of the Loader retry delay
([#3370](https://github.com/google/ExoPlayer/issues/3370)).
* HLS:
* Add support for variable substitution
([#4422](https://github.com/google/ExoPlayer/issues/4422)).
* Add support for PlayReady.
* Add support for alternative EXT-X-KEY tags.
* Set the bitrate on primary track sample formats
([#3297](https://github.com/google/ExoPlayer/issues/3297)).
* Pass HTTP response headers to `HlsExtractorFactory.createExtractor`.
* Add support for EXT-X-INDEPENDENT-SEGMENTS in the master playlist.
* Support load error handling customization
([#2981](https://github.com/google/ExoPlayer/issues/2981)).
* Fix bug when reporting buffered position for multi-period windows and add
two additional convenience methods `Player.getTotalBufferedDuration` and
`Player.getContentBufferedDuration`
([#4023](https://github.com/google/ExoPlayer/issues/4023)).
* MediaSession extension:
* Allow apps to set custom metadata with a MediaMetadataProvider
([#3497](https://github.com/google/ExoPlayer/issues/3497)).
* Improved performance when playing high frame-rate content, and when playing
at greater than 1x speed
([#2777](https://github.com/google/ExoPlayer/issues/2777)).
* Allow setting the `Looper`, which is used to access the player, in
`ExoPlayerFactory` ([#4278](https://github.com/google/ExoPlayer/issues/4278)).
* Use default Deserializers if non given to DownloadManager.
* 360:
* Add monoscopic 360 surface type to PlayerView.
* Support
[VR180 video format](https://github.com/google/spatial-media/blob/master/docs/vr180.md).
* Deprecate `Player.DefaultEventListener` as selective listener overrides can
be directly made with the `Player.EventListener` interface.
* Deprecate `DefaultAnalyticsListener` as selective listener overrides can be
directly made with the `AnalyticsListener` interface.
* Add uri field to `LoadEventInfo` in `MediaSourceEventListener` or
`AnalyticsListener` callbacks. This uri is the redirected uri if redirection
occurred ([#2054](https://github.com/google/ExoPlayer/issues/2054)).
* Add response headers field to `LoadEventInfo` in `MediaSourceEventListener` or
`AnalyticsListener` callbacks
([#4361](https://github.com/google/ExoPlayer/issues/4361) and
[#4615](https://github.com/google/ExoPlayer/issues/4615)).
* Provide additional information for adaptive track selection.
`TrackSelection.updateSelectedTrack` has two new parameters for the current
queue of media chunks and iterators for information about upcoming chunks.
* Allow `MediaCodecSelector`s to return multiple compatible decoders for
`MediaCodecRenderer`, and provide an (optional) `MediaCodecSelector` that
falls back to less preferred decoders like `MediaCodec.createDecoderByType`
([#273](https://github.com/google/ExoPlayer/issues/273)).
* Fix where transitions to clipped media sources happened too early
* Enable gzip for requests made by `SingleSampleMediaSource`
([#4771](https://github.com/google/ExoPlayer/issues/4771)).
* Fix bug reporting buffered position for multi-period windows, and add
convenience methods `Player.getTotalBufferedDuration` and
`Player.getContentBufferedDuration`
([#4023](https://github.com/google/ExoPlayer/issues/4023)).
* Fix bug where transitions to clipped media sources would happen too early
([#4583](https://github.com/google/ExoPlayer/issues/4583)).
* Add `DataSpec.httpMethod` and update `HttpDataSource` implementations to
support HTTP HEAD method. Previously, only GET and POST were supported.
* Add option to show buffering view when playWhenReady is false
([#4304](https://github.com/google/ExoPlayer/issues/4304)).
* Allow any `Drawable` to be used as `PlayerView` default artwork.
* IMA:
* Fix bugs reporting events for multi-period media sources
([#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).
* 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)).
* Fix issue where errors of upcoming playlist items are thrown too early
([#4661](https://github.com/google/ExoPlayer/issues/4661)).
* Allow edit lists which do not start with a sync sample.
([#4774](https://github.com/google/ExoPlayer/issues/4774)).
* Fix issue with audio discontinuities at period transitions, e.g. when
looping ([#3829](https://github.com/google/ExoPlayer/issues/3829)).
* Fix issue where `player.getCurrentTag()` throws an `IndexOutOfBoundsException`
([#4822](https://github.com/google/ExoPlayer/issues/4822)).
* Fix bug preventing use of multiple key session support (`multiSession=true`)
for non-Widevine `DefaultDrmSessionManager` instances
([#4834](https://github.com/google/ExoPlayer/issues/4834)).
* Fix issue where audio and video would desynchronize when playing
concatenations of gapless content
([#4559](https://github.com/google/ExoPlayer/issues/4559)).
* IMA extension:
* Refine the previous fix for empty ad groups to avoid discarding ad breaks
unnecessarily ([#4030](https://github.com/google/ExoPlayer/issues/4030)),
([#4280](https://github.com/google/ExoPlayer/issues/4280)).
unnecessarily ([#4030](https://github.com/google/ExoPlayer/issues/4030) and
[#4280](https://github.com/google/ExoPlayer/issues/4280)).
* Fix handling of empty postrolls
([#4681](https://github.com/google/ExoPlayer/issues/4681).
([#4681](https://github.com/google/ExoPlayer/issues/4681)).
* Fix handling of postrolls with multiple ads
([#4710](https://github.com/google/ExoPlayer/issues/4710).
([#4710](https://github.com/google/ExoPlayer/issues/4710)).
* MediaSession extension:
* Add `MediaSessionConnector.setCustomErrorMessage` to support setting custom
error messages.
* Add `MediaMetadataProvider` to support setting custom metadata
([#3497](https://github.com/google/ExoPlayer/issues/3497)).
* Cronet extension: Now distributed via jCenter.
* FFmpeg extension: Support mu-law and A-law PCM.
### 2.8.4 ###
* IMA: Improve handling of consecutive empty ad groups
* IMA extension: Improve handling of consecutive empty ad groups
([#4030](https://github.com/google/ExoPlayer/issues/4030)),
([#4280](https://github.com/google/ExoPlayer/issues/4280)).
### 2.8.3 ###
* IMA:
* IMA extension:
* Fix behavior when creating/releasing the player then releasing
`ImaAdsLoader` ([#3879](https://github.com/google/ExoPlayer/issues/3879)).
* Add support for setting slots for companion ads.
@ -182,8 +648,9 @@
### 2.8.2 ###
* IMA: Don't advertise support for video/mpeg ad media, as we don't have an
extractor for this ([#4297](https://github.com/google/ExoPlayer/issues/4297)).
* IMA extension: Don't advertise support for video/mpeg ad media, as we don't
have an extractor for this
([#4297](https://github.com/google/ExoPlayer/issues/4297)).
* DASH: Fix playback getting stuck when playing representations that have both
sidx atoms and non-zero presentationTimeOffset values.
* HLS:
@ -293,18 +760,18 @@
begins, and poll the audio timestamp less frequently once it starts
advancing ([#3841](https://github.com/google/ExoPlayer/issues/3841)).
* Add an option to skip silent audio in `PlaybackParameters`
((#2635)[https://github.com/google/ExoPlayer/issues/2635]).
([#2635](https://github.com/google/ExoPlayer/issues/2635)).
* Fix an issue where playback of TrueHD streams would get stuck after seeking
due to not finding a syncframe
((#3845)[https://github.com/google/ExoPlayer/issues/3845]).
([#3845](https://github.com/google/ExoPlayer/issues/3845)).
* Fix an issue with eac3-joc playback where a codec would fail to configure
((#4165)[https://github.com/google/ExoPlayer/issues/4165]).
([#4165](https://github.com/google/ExoPlayer/issues/4165)).
* Handle non-empty end-of-stream buffers, to fix gapless playback of streams
with encoder padding when the decoder returns a non-empty final buffer.
* Allow trimming more than one sample when applying an elst audio edit via
gapless playback info.
* Allow overriding skipping/scaling with custom `AudioProcessor`s
((#3142)[https://github.com/google/ExoPlayer/issues/3142]).
([#3142](https://github.com/google/ExoPlayer/issues/3142)).
* Caching:
* Add release method to the `Cache` interface, and prevent multiple instances
of `SimpleCache` using the same folder at the same time.
@ -328,7 +795,7 @@
([#4164](https://github.com/google/ExoPlayer/issues/4182)).
* Fix seeking in live streams
([#4187](https://github.com/google/ExoPlayer/issues/4187)).
* IMA:
* IMA extension:
* Allow setting the ad media load timeout
([#3691](https://github.com/google/ExoPlayer/issues/3691)).
* Expose ad load errors via `MediaSourceEventListener` on `AdsMediaSource`,
@ -1001,7 +1468,7 @@
[here](https://medium.com/google-exoplayer/customizing-exoplayers-ui-components-728cf55ee07a#.9ewjg7avi).
* Robustness improvements when handling MediaSource timeline changes and
MediaPeriod transitions.
* EIA608: Support for caption styling and positioning.
* CEA-608: Support for caption styling and positioning.
* MPEG-TS: Improved support:
* Support injection of custom TS payload readers.
* Support injection of custom section payload readers.
@ -1245,8 +1712,8 @@ V2 release.
(#801).
* MP3: Fix playback of some streams when stream length is unknown.
* ID3: Support multiple frames of the same type in a single tag.
* EIA608: Correctly handle repeated control characters, fixing an issue in which
captions would immediately disappear.
* CEA-608: Correctly handle repeated control characters, fixing an issue in
which captions would immediately disappear.
* AVC3: Fix decoder failures on some MediaTek devices in the case where the
first buffer fed to the decoder does not start with SPS/PPS NAL units.
* Misc bug fixes.

View file

@ -13,30 +13,22 @@
// limitations under the License.
buildscript {
repositories {
jcenter()
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.1.4'
classpath 'com.novoda:bintray-release:0.8.1'
classpath 'com.google.android.gms:strict-version-matcher-plugin:1.0.3'
}
// Workaround for the following test coverage issue. Remove when fixed:
// https://code.google.com/p/android/issues/detail?id=226070
configurations.all {
resolutionStrategy {
force 'org.jacoco:org.jacoco.report:0.7.4.201502262128'
force 'org.jacoco:org.jacoco.core:0.7.4.201502262128'
}
classpath 'com.android.tools.build:gradle:3.4.0'
classpath 'com.novoda:bintray-release:0.9'
classpath 'com.google.android.gms:strict-version-matcher-plugin:1.1.0'
}
}
allprojects {
repositories {
jcenter()
google()
jcenter()
}
project.ext {
exoplayerPublishEnabled = true
exoplayerPublishEnabled = false
}
if (it.hasProperty('externalBuildDir')) {
if (!new File(externalBuildDir).isAbsolute()) {
@ -44,6 +36,7 @@ allprojects {
}
buildDir = "${externalBuildDir}/${project.name}"
}
group = 'com.google.android.exoplayer'
}
apply from: 'javadoc_combined.gradle'

View file

@ -13,26 +13,20 @@
// limitations under the License.
project.ext {
// ExoPlayer version and version code.
releaseVersion = '2.8.4'
releaseVersionCode = 2804
// Important: ExoPlayer specifies a minSdkVersion of 14 because various
// components provided by the library may be of use on older devices.
// However, please note that the core media playback functionality provided
// by the library requires API level 16 or greater.
minSdkVersion = 14
releaseVersion = '2.10.4'
releaseVersionCode = 2010004
minSdkVersion = 16
targetSdkVersion = 28
compileSdkVersion = 28
buildToolsVersion = '28.0.2'
testSupportLibraryVersion = '0.5'
supportLibraryVersion = '27.1.1'
dexmakerVersion = '1.2'
mockitoVersion = '1.9.5'
junitVersion = '4.12'
truthVersion = '0.39'
robolectricVersion = '3.7.1'
compileSdkVersion = 29
dexmakerVersion = '2.21.0'
mockitoVersion = '2.25.0'
robolectricVersion = '4.3'
autoValueVersion = '1.6'
autoServiceVersion = '1.0-rc4'
checkerframeworkVersion = '2.5.0'
testRunnerVersion = '1.1.0-alpha3'
jsr305Version = '3.0.2'
androidXTestVersion = '1.1.0'
truthVersion = '0.44'
modulePrefix = ':'
if (gradle.ext.has('exoplayerModulePrefix')) {
modulePrefix += gradle.ext.exoplayerModulePrefix

View file

@ -24,7 +24,6 @@ include modulePrefix + 'library-hls'
include modulePrefix + 'library-smoothstreaming'
include modulePrefix + 'library-ui'
include modulePrefix + 'testutils'
include modulePrefix + 'testutils-robolectric'
include modulePrefix + 'extension-ffmpeg'
include modulePrefix + 'extension-flac'
include modulePrefix + 'extension-gvr'
@ -38,6 +37,7 @@ include modulePrefix + 'extension-vp9'
include modulePrefix + 'extension-rtmp'
include modulePrefix + 'extension-leanback'
include modulePrefix + 'extension-jobdispatcher'
include modulePrefix + 'extension-workmanager'
project(modulePrefix + 'library').projectDir = new File(rootDir, 'library/all')
project(modulePrefix + 'library-core').projectDir = new File(rootDir, 'library/core')
@ -46,7 +46,6 @@ project(modulePrefix + 'library-hls').projectDir = new File(rootDir, 'library/hl
project(modulePrefix + 'library-smoothstreaming').projectDir = new File(rootDir, 'library/smoothstreaming')
project(modulePrefix + 'library-ui').projectDir = new File(rootDir, 'library/ui')
project(modulePrefix + 'testutils').projectDir = new File(rootDir, 'testutils')
project(modulePrefix + 'testutils-robolectric').projectDir = new File(rootDir, 'testutils_robolectric')
project(modulePrefix + 'extension-ffmpeg').projectDir = new File(rootDir, 'extensions/ffmpeg')
project(modulePrefix + 'extension-flac').projectDir = new File(rootDir, 'extensions/flac')
project(modulePrefix + 'extension-gvr').projectDir = new File(rootDir, 'extensions/gvr')
@ -60,3 +59,4 @@ project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensio
project(modulePrefix + 'extension-rtmp').projectDir = new File(rootDir, 'extensions/rtmp')
project(modulePrefix + 'extension-leanback').projectDir = new File(rootDir, 'extensions/leanback')
project(modulePrefix + 'extension-jobdispatcher').projectDir = new File(rootDir, 'extensions/jobdispatcher')
project(modulePrefix + 'extension-workmanager').projectDir = new File(rootDir, 'extensions/workmanager')

View file

@ -16,7 +16,6 @@ apply plugin: 'com.android.application'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -26,7 +25,7 @@ android {
defaultConfig {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion 16
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
}
@ -45,10 +44,9 @@ android {
}
lintOptions {
// The demo app does not have translations.
disable 'MissingTranslation'
// The demo app isn't indexed and doesn't have translations.
disable 'GoogleAppIndexingWarning','MissingTranslation'
}
}
dependencies {
@ -58,9 +56,10 @@ dependencies {
implementation project(modulePrefix + 'library-smoothstreaming')
implementation project(modulePrefix + 'library-ui')
implementation project(modulePrefix + 'extension-cast')
implementation 'com.android.support:support-v4:' + supportLibraryVersion
implementation 'com.android.support:appcompat-v7:' + supportLibraryVersion
implementation 'com.android.support:recyclerview-v7:' + supportLibraryVersion
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
}
apply plugin: 'com.google.android.gms.strict-version-matcher-plugin'

View file

@ -1,6 +1,6 @@
# Proguard rules specific to the Cast demo app.
# Accessed via menu.xml
-keep class android.support.v7.app.MediaRouteActionProvider {
-keep class androidx.mediarouter.app.MediaRouteActionProvider {
*;
}

View file

@ -17,13 +17,15 @@
package="com.google.android.exoplayer2.castdemo">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-sdk/>
<application android:label="@string/application_name" android:icon="@mipmap/ic_launcher"
android:largeHeap="true" android:allowBackup="false">
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider" />
android:value="com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider"/>
<activity android:name="com.google.android.exoplayer2.castdemo.MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"

View file

@ -15,15 +15,16 @@
*/
package com.google.android.exoplayer2.castdemo;
import android.net.Uri;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ext.cast.MediaItem;
import com.google.android.exoplayer2.ext.cast.MediaItem.DrmConfiguration;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.gms.cast.MediaInfo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Utility methods and constants for the Cast demo application.
*/
/** Utility methods and constants for the Cast demo application. */
/* package */ final class DemoUtil {
public static final String MIME_TYPE_DASH = MimeTypes.APPLICATION_MPD;
@ -31,62 +32,73 @@ import java.util.List;
public static final String MIME_TYPE_SS = MimeTypes.APPLICATION_SS;
public static final String MIME_TYPE_VIDEO_MP4 = MimeTypes.VIDEO_MP4;
/**
* The list of samples available in the cast demo app.
*/
public static final List<Sample> SAMPLES;
/**
* Represents a media sample.
*/
public static final class Sample {
/**
* The uri from which the media sample is obtained.
*/
public final String uri;
/**
* A descriptive name for the sample.
*/
public final String name;
/**
* The mime type of the media sample, as required by {@link MediaInfo#setContentType}.
*/
public final String mimeType;
/**
* @param uri See {@link #uri}.
* @param name See {@link #name}.
* @param mimeType See {@link #mimeType}.
*/
public Sample(String uri, String name, String mimeType) {
this.uri = uri;
this.name = name;
this.mimeType = mimeType;
}
@Override
public String toString() {
return name;
}
}
/** The list of samples available in the cast demo app. */
public static final List<MediaItem> SAMPLES;
static {
// App samples.
ArrayList<Sample> samples = new ArrayList<>();
samples.add(new Sample("https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd",
"DASH (clear,MP4,H264)", MIME_TYPE_DASH));
samples.add(new Sample("https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/"
+ "hls/TearsOfSteel.m3u8", "Tears of Steel (HLS)", MIME_TYPE_HLS));
samples.add(new Sample("https://html5demos.com/assets/dizzy.mp4", "Dizzy (MP4)",
MIME_TYPE_VIDEO_MP4));
ArrayList<MediaItem> samples = new ArrayList<>();
// Clear content.
samples.add(
new MediaItem.Builder()
.setUri("https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd")
.setTitle("Clear DASH: Tears")
.setMimeType(MIME_TYPE_DASH)
.build());
samples.add(
new MediaItem.Builder()
.setUri("https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hls.m3u8")
.setTitle("Clear HLS: Angel one")
.setMimeType(MIME_TYPE_HLS)
.build());
samples.add(
new MediaItem.Builder()
.setUri("https://html5demos.com/assets/dizzy.mp4")
.setTitle("Clear MP4: Dizzy")
.setMimeType(MIME_TYPE_VIDEO_MP4)
.build());
// DRM content.
samples.add(
new MediaItem.Builder()
.setUri(Uri.parse("https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd"))
.setTitle("Widevine DASH cenc: Tears")
.setMimeType(MIME_TYPE_DASH)
.setDrmConfiguration(
new DrmConfiguration(
C.WIDEVINE_UUID,
Uri.parse("https://proxy.uat.widevine.com/proxy?provider=widevine_test"),
Collections.emptyMap()))
.build());
samples.add(
new MediaItem.Builder()
.setUri(
Uri.parse(
"https://storage.googleapis.com/wvmedia/cbc1/h264/tears/tears_aes_cbc1.mpd"))
.setTitle("Widevine DASH cbc1: Tears")
.setMimeType(MIME_TYPE_DASH)
.setDrmConfiguration(
new DrmConfiguration(
C.WIDEVINE_UUID,
Uri.parse("https://proxy.uat.widevine.com/proxy?provider=widevine_test"),
Collections.emptyMap()))
.build());
samples.add(
new MediaItem.Builder()
.setUri(
Uri.parse(
"https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd"))
.setTitle("Widevine DASH cbcs: Tears")
.setMimeType(MIME_TYPE_DASH)
.setDrmConfiguration(
new DrmConfiguration(
C.WIDEVINE_UUID,
Uri.parse("https://proxy.uat.widevine.com/proxy?provider=widevine_test"),
Collections.emptyMap()))
.build());
SAMPLES = Collections.unmodifiableList(samples);
}
private DemoUtil() {}
}

View file

@ -17,13 +17,15 @@ package com.google.android.exoplayer2.castdemo;
import android.content.Context;
import android.os.Bundle;
import android.support.v4.graphics.ColorUtils;
import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.support.v7.widget.helper.ItemTouchHelper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.ColorUtils;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import androidx.recyclerview.widget.ItemTouchHelper;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
@ -33,20 +35,22 @@ import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.castdemo.DemoUtil.Sample;
import com.google.android.exoplayer2.ext.cast.CastPlayer;
import com.google.android.exoplayer2.ext.cast.MediaItem;
import com.google.android.exoplayer2.ui.PlayerControlView;
import com.google.android.exoplayer2.ui.PlayerView;
import com.google.android.gms.cast.framework.CastButtonFactory;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.dynamite.DynamiteModule;
/**
* An activity that plays video using {@link SimpleExoPlayer} and {@link CastPlayer}.
* An activity that plays video using {@link SimpleExoPlayer} and supports casting using ExoPlayer's
* Cast extension.
*/
public class MainActivity extends AppCompatActivity implements OnClickListener,
PlayerManager.QueuePositionListener {
public class MainActivity extends AppCompatActivity
implements OnClickListener, PlayerManager.Listener {
private PlayerView localPlayerView;
private PlayerControlView castControlView;
@ -61,7 +65,20 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Getting the cast context later than onStart can cause device discovery not to take place.
castContext = CastContext.getSharedInstance(this);
try {
castContext = CastContext.getSharedInstance(this);
} catch (RuntimeException e) {
Throwable cause = e.getCause();
while (cause != null) {
if (cause instanceof DynamiteModule.LoadingException) {
setContentView(R.layout.cast_context_error);
return;
}
cause = cause.getCause();
}
// Unknown error. We propagate it.
throw e;
}
setContentView(R.layout.main_activity);
@ -91,9 +108,13 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
@Override
public void onResume() {
super.onResume();
if (castContext == null) {
// There is no Cast context to work with. Do nothing.
return;
}
playerManager =
PlayerManager.createPlayerManager(
/* queuePositionListener= */ this,
new PlayerManager(
/* listener= */ this,
localPlayerView,
castControlView,
/* context= */ this,
@ -104,9 +125,14 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
@Override
public void onPause() {
super.onPause();
if (castContext == null) {
// Nothing to release.
return;
}
mediaQueueListAdapter.notifyItemRangeRemoved(0, mediaQueueListAdapter.getItemCount());
mediaQueueList.setAdapter(null);
playerManager.release();
playerManager = null;
}
// Activity input.
@ -119,12 +145,15 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
@Override
public void onClick(View view) {
new AlertDialog.Builder(this).setTitle(R.string.sample_list_dialog_title)
.setView(buildSampleListView()).setPositiveButton(android.R.string.ok, null).create()
new AlertDialog.Builder(this)
.setTitle(R.string.add_samples)
.setView(buildSampleListView())
.setPositiveButton(android.R.string.ok, null)
.create()
.show();
}
// PlayerManager.QueuePositionListener implementation.
// PlayerManager.Listener implementation.
@Override
public void onQueuePositionChanged(int previousIndex, int newIndex) {
@ -136,8 +165,23 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
}
}
@Override
public void onUnsupportedTrack(int trackType) {
if (trackType == C.TRACK_TYPE_AUDIO) {
showToast(R.string.error_unsupported_audio);
} else if (trackType == C.TRACK_TYPE_VIDEO) {
showToast(R.string.error_unsupported_video);
} else {
// Do nothing.
}
}
// Internal methods.
private void showToast(int messageId) {
Toast.makeText(getApplicationContext(), messageId, Toast.LENGTH_LONG).show();
}
private View buildSampleListView() {
View dialogList = getLayoutInflater().inflate(R.layout.sample_list, null);
ListView sampleList = dialogList.findViewById(R.id.sample_list);
@ -152,23 +196,6 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
// Internal classes.
private class QueueItemViewHolder extends RecyclerView.ViewHolder implements OnClickListener {
public final TextView textView;
public QueueItemViewHolder(TextView textView) {
super(textView);
this.textView = textView;
textView.setOnClickListener(this);
}
@Override
public void onClick(View v) {
playerManager.selectQueueItem(getAdapterPosition());
}
}
private class MediaQueueListAdapter extends RecyclerView.Adapter<QueueItemViewHolder> {
@Override
@ -180,11 +207,14 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
@Override
public void onBindViewHolder(QueueItemViewHolder holder, int position) {
holder.item = playerManager.getItem(position);
TextView view = holder.textView;
view.setText(playerManager.getItem(position).name);
view.setText(holder.item.title);
// TODO: Solve coloring using the theme's ColorStateList.
view.setTextColor(ColorUtils.setAlphaComponent(view.getCurrentTextColor(),
position == playerManager.getCurrentItemIndex() ? 255 : 100));
view.setTextColor(
ColorUtils.setAlphaComponent(
view.getCurrentTextColor(),
position == playerManager.getCurrentItemIndex() ? 255 : 100));
}
@Override
@ -222,8 +252,11 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
int position = viewHolder.getAdapterPosition();
if (playerManager.removeItem(position)) {
QueueItemViewHolder queueItemHolder = (QueueItemViewHolder) viewHolder;
if (playerManager.removeItem(queueItemHolder.item)) {
mediaQueueListAdapter.notifyItemRemoved(position);
// Update whichever item took its place, in case it became the new selected item.
mediaQueueListAdapter.notifyItemChanged(position);
}
}
@ -231,8 +264,9 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
public void clearView(RecyclerView recyclerView, ViewHolder viewHolder) {
super.clearView(recyclerView, viewHolder);
if (draggingFromPosition != C.INDEX_UNSET) {
QueueItemViewHolder queueItemHolder = (QueueItemViewHolder) viewHolder;
// A drag has ended. We reflect the media queue change in the player.
if (!playerManager.moveItem(draggingFromPosition, draggingToPosition)) {
if (!playerManager.moveItem(queueItemHolder.item, draggingToPosition)) {
// The move failed. The entire sequence of onMove calls since the drag started needs to be
// invalidated.
mediaQueueListAdapter.notifyDataSetChanged();
@ -241,15 +275,37 @@ public class MainActivity extends AppCompatActivity implements OnClickListener,
draggingFromPosition = C.INDEX_UNSET;
draggingToPosition = C.INDEX_UNSET;
}
}
private static final class SampleListAdapter extends ArrayAdapter<Sample> {
private class QueueItemViewHolder extends RecyclerView.ViewHolder implements OnClickListener {
public final TextView textView;
public MediaItem item;
public QueueItemViewHolder(TextView textView) {
super(textView);
this.textView = textView;
textView.setOnClickListener(this);
}
@Override
public void onClick(View v) {
playerManager.selectQueueItem(getAdapterPosition());
}
}
private static final class SampleListAdapter extends ArrayAdapter<MediaItem> {
public SampleListAdapter(Context context) {
super(context, android.R.layout.simple_list_item_1, DemoUtil.SAMPLES);
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = super.getView(position, convertView, parent);
((TextView) view).setText(getItem(position).title);
return view;
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (C) 2017 The Android Open Source Project
* 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.
@ -17,52 +17,62 @@ package com.google.android.exoplayer2.castdemo;
import android.content.Context;
import android.net.Uri;
import android.support.annotation.Nullable;
import android.view.KeyEvent;
import android.view.View;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlayerFactory;
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;
import com.google.android.exoplayer2.RenderersFactory;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Timeline.Period;
import com.google.android.exoplayer2.castdemo.DemoUtil.Sample;
import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.FrameworkMediaCrypto;
import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
import com.google.android.exoplayer2.drm.UnsupportedDrmException;
import com.google.android.exoplayer2.ext.cast.CastPlayer;
import com.google.android.exoplayer2.ext.cast.DefaultMediaItemConverter;
import com.google.android.exoplayer2.ext.cast.MediaItem;
import com.google.android.exoplayer2.ext.cast.MediaItemConverter;
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import com.google.android.exoplayer2.source.ExtractorMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.source.dash.DashMediaSource;
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.MappingTrackSelector;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.ui.PlayerControlView;
import com.google.android.exoplayer2.ui.PlayerView;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.framework.CastContext;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
/** Manages players and an internal media queue for the ExoPlayer/Cast demo app. */
/* package */ final class PlayerManager
implements EventListener, CastPlayer.SessionAvailabilityListener {
/** Manages players and an internal media queue for the demo app. */
/* package */ class PlayerManager implements EventListener, SessionAvailabilityListener {
/**
* Listener for changes in the media queue playback position.
*/
public interface QueuePositionListener {
/** Listener for events. */
interface Listener {
/**
* Called when the currently played item of the media queue changes.
*/
/** Called when the currently played item of the media queue changes. */
void onQueuePositionChanged(int previousIndex, int newIndex);
/**
* Called when a track of type {@code trackType} is not supported by the player.
*
* @param trackType One of the {@link C}{@code .TRACK_TYPE_*} constants.
*/
void onUnsupportedTrack(int trackType);
}
private static final String USER_AGENT = "ExoCastDemoPlayer";
@ -71,52 +81,45 @@ import java.util.ArrayList;
private final PlayerView localPlayerView;
private final PlayerControlView castControlView;
private final DefaultTrackSelector trackSelector;
private final SimpleExoPlayer exoPlayer;
private final CastPlayer castPlayer;
private final ArrayList<DemoUtil.Sample> mediaQueue;
private final QueuePositionListener queuePositionListener;
private final ArrayList<MediaItem> mediaQueue;
private final Listener listener;
private final ConcatenatingMediaSource concatenatingMediaSource;
private final MediaItemConverter mediaItemConverter;
private final IdentityHashMap<MediaSource, FrameworkMediaDrm> mediaDrms;
private boolean castMediaQueueCreationPending;
private TrackGroupArray lastSeenTrackGroupArray;
private int currentItemIndex;
private Player currentPlayer;
/**
* @param queuePositionListener A {@link QueuePositionListener} for queue position changes.
* Creates a new manager for {@link SimpleExoPlayer} and {@link CastPlayer}.
*
* @param listener A {@link Listener} for queue position changes.
* @param localPlayerView The {@link PlayerView} for local playback.
* @param castControlView The {@link PlayerControlView} to control remote playback.
* @param context A {@link Context}.
* @param castContext The {@link CastContext}.
*/
public static PlayerManager createPlayerManager(
QueuePositionListener queuePositionListener,
public PlayerManager(
Listener listener,
PlayerView localPlayerView,
PlayerControlView castControlView,
Context context,
CastContext castContext) {
PlayerManager playerManager =
new PlayerManager(
queuePositionListener, localPlayerView, castControlView, context, castContext);
playerManager.init();
return playerManager;
}
private PlayerManager(
QueuePositionListener queuePositionListener,
PlayerView localPlayerView,
PlayerControlView castControlView,
Context context,
CastContext castContext) {
this.queuePositionListener = queuePositionListener;
this.listener = listener;
this.localPlayerView = localPlayerView;
this.castControlView = castControlView;
mediaQueue = new ArrayList<>();
currentItemIndex = C.INDEX_UNSET;
concatenatingMediaSource = new ConcatenatingMediaSource();
mediaItemConverter = new DefaultMediaItemConverter();
mediaDrms = new IdentityHashMap<>();
DefaultTrackSelector trackSelector = new DefaultTrackSelector();
RenderersFactory renderersFactory = new DefaultRenderersFactory(context);
exoPlayer = ExoPlayerFactory.newSimpleInstance(context, renderersFactory, trackSelector);
trackSelector = new DefaultTrackSelector(context);
exoPlayer = ExoPlayerFactory.newSimpleInstance(context, trackSelector);
exoPlayer.addListener(this);
localPlayerView.setPlayer(exoPlayer);
@ -124,6 +127,8 @@ import java.util.ArrayList;
castPlayer.addListener(this);
castPlayer.setSessionAvailabilityListener(this);
castControlView.setPlayer(castPlayer);
setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer);
}
// Queue manipulation methods.
@ -137,29 +142,25 @@ import java.util.ArrayList;
setCurrentItem(itemIndex, C.TIME_UNSET, true);
}
/**
* Returns the index of the currently played item.
*/
/** Returns the index of the currently played item. */
public int getCurrentItemIndex() {
return currentItemIndex;
}
/**
* Appends {@code sample} to the media queue.
* Appends {@code item} to the media queue.
*
* @param sample The {@link Sample} to append.
* @param item The {@link MediaItem} to append.
*/
public void addItem(Sample sample) {
mediaQueue.add(sample);
concatenatingMediaSource.addMediaSource(buildMediaSource(sample));
public void addItem(MediaItem item) {
mediaQueue.add(item);
concatenatingMediaSource.addMediaSource(buildMediaSource(item));
if (currentPlayer == castPlayer) {
castPlayer.addItems(buildMediaQueueItem(sample));
castPlayer.addItems(mediaItemConverter.toMediaQueueItem(item));
}
}
/**
* Returns the size of the media queue.
*/
/** Returns the size of the media queue. */
public int getMediaQueueSize() {
return mediaQueue.size();
}
@ -170,18 +171,23 @@ import java.util.ArrayList;
* @param position The index of the item.
* @return The item at the given index in the media queue.
*/
public Sample getItem(int position) {
public MediaItem getItem(int position) {
return mediaQueue.get(position);
}
/**
* Removes the item at the given index from the media queue.
*
* @param itemIndex The index of the item to remove.
* @param item The item to remove.
* @return Whether the removal was successful.
*/
public boolean removeItem(int itemIndex) {
concatenatingMediaSource.removeMediaSource(itemIndex);
public boolean removeItem(MediaItem item) {
int itemIndex = mediaQueue.indexOf(item);
if (itemIndex == -1) {
return false;
}
MediaSource removedMediaSource = concatenatingMediaSource.removeMediaSource(itemIndex);
releaseMediaDrmOfMediaSource(removedMediaSource);
if (currentPlayer == castPlayer) {
if (castPlayer.getPlaybackState() != Player.STATE_IDLE) {
Timeline castTimeline = castPlayer.getCurrentTimeline();
@ -203,11 +209,15 @@ import java.util.ArrayList;
/**
* Moves an item within the queue.
*
* @param fromIndex The index of the item to move.
* @param item The item to move.
* @param toIndex The target index of the item in the queue.
* @return Whether the item move was successful.
*/
public boolean moveItem(int fromIndex, int toIndex) {
public boolean moveItem(MediaItem item, int toIndex) {
int fromIndex = mediaQueue.indexOf(item);
if (fromIndex == -1) {
return false;
}
// Player update.
concatenatingMediaSource.moveMediaSource(fromIndex, toIndex);
if (currentPlayer == castPlayer && castPlayer.getPlaybackState() != Player.STATE_IDLE) {
@ -234,8 +244,6 @@ import java.util.ArrayList;
return true;
}
// Miscellaneous methods.
/**
* Dispatches a given {@link KeyEvent} to the corresponding view of the current player.
*
@ -250,13 +258,14 @@ import java.util.ArrayList;
}
}
/**
* Releases the manager and the players that it holds.
*/
/** Releases the manager and the players that it holds. */
public void release() {
currentItemIndex = C.INDEX_UNSET;
mediaQueue.clear();
concatenatingMediaSource.clear();
for (FrameworkMediaDrm mediaDrm : mediaDrms.values()) {
mediaDrm.release();
}
castPlayer.setSessionAvailabilityListener(null);
castPlayer.release();
localPlayerView.setPlayer(null);
@ -266,7 +275,7 @@ import java.util.ArrayList;
// Player.EventListener implementation.
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
updateCurrentItemIndex();
}
@ -276,11 +285,26 @@ import java.util.ArrayList;
}
@Override
public void onTimelineChanged(
Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {
public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) {
updateCurrentItemIndex();
if (timeline.isEmpty()) {
castMediaQueueCreationPending = true;
}
@Override
public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
if (currentPlayer == exoPlayer && trackGroups != lastSeenTrackGroupArray) {
MappingTrackSelector.MappedTrackInfo mappedTrackInfo =
trackSelector.getCurrentMappedTrackInfo();
if (mappedTrackInfo != null) {
if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO)
== MappingTrackSelector.MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
listener.onUnsupportedTrack(C.TRACK_TYPE_VIDEO);
}
if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_AUDIO)
== MappingTrackSelector.MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
listener.onUnsupportedTrack(C.TRACK_TYPE_AUDIO);
}
}
lastSeenTrackGroupArray = trackGroups;
}
}
@ -298,15 +322,12 @@ import java.util.ArrayList;
// Internal methods.
private void init() {
setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer);
}
private void updateCurrentItemIndex() {
int playbackState = currentPlayer.getPlaybackState();
maybeSetCurrentItemAndNotify(
playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED
? currentPlayer.getCurrentWindowIndex() : C.INDEX_UNSET);
? currentPlayer.getCurrentWindowIndex()
: C.INDEX_UNSET);
}
private void setCurrentPlayer(Player currentPlayer) {
@ -327,26 +348,26 @@ import java.util.ArrayList;
long playbackPositionMs = C.TIME_UNSET;
int windowIndex = C.INDEX_UNSET;
boolean playWhenReady = false;
if (this.currentPlayer != null) {
int playbackState = this.currentPlayer.getPlaybackState();
Player previousPlayer = this.currentPlayer;
if (previousPlayer != null) {
// Save state from the previous player.
int playbackState = previousPlayer.getPlaybackState();
if (playbackState != Player.STATE_ENDED) {
playbackPositionMs = this.currentPlayer.getCurrentPosition();
playWhenReady = this.currentPlayer.getPlayWhenReady();
windowIndex = this.currentPlayer.getCurrentWindowIndex();
playbackPositionMs = previousPlayer.getCurrentPosition();
playWhenReady = previousPlayer.getPlayWhenReady();
windowIndex = previousPlayer.getCurrentWindowIndex();
if (windowIndex != currentItemIndex) {
playbackPositionMs = C.TIME_UNSET;
windowIndex = currentItemIndex;
}
}
this.currentPlayer.stop(true);
} else {
// This is the initial setup. No need to save any state.
previousPlayer.stop(true);
}
this.currentPlayer = currentPlayer;
// Media queue management.
castMediaQueueCreationPending = currentPlayer == castPlayer;
if (currentPlayer == exoPlayer) {
exoPlayer.prepare(concatenatingMediaSource);
}
@ -366,12 +387,11 @@ import java.util.ArrayList;
*/
private void setCurrentItem(int itemIndex, long positionMs, boolean playWhenReady) {
maybeSetCurrentItemAndNotify(itemIndex);
if (castMediaQueueCreationPending) {
if (currentPlayer == castPlayer && castPlayer.getCurrentTimeline().isEmpty()) {
MediaQueueItem[] items = new MediaQueueItem[mediaQueue.size()];
for (int i = 0; i < items.length; i++) {
items[i] = buildMediaQueueItem(mediaQueue.get(i));
items[i] = mediaItemConverter.toMediaQueueItem(mediaQueue.get(i));
}
castMediaQueueCreationPending = false;
castPlayer.loadItems(items, itemIndex, positionMs, Player.REPEAT_MODE_OFF);
} else {
currentPlayer.seekTo(itemIndex, positionMs);
@ -383,34 +403,82 @@ import java.util.ArrayList;
if (this.currentItemIndex != currentItemIndex) {
int oldIndex = this.currentItemIndex;
this.currentItemIndex = currentItemIndex;
queuePositionListener.onQueuePositionChanged(oldIndex, currentItemIndex);
listener.onQueuePositionChanged(oldIndex, currentItemIndex);
}
}
private static MediaSource buildMediaSource(DemoUtil.Sample sample) {
Uri uri = Uri.parse(sample.uri);
switch (sample.mimeType) {
case DemoUtil.MIME_TYPE_SS:
return new SsMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri);
case DemoUtil.MIME_TYPE_DASH:
return new DashMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri);
case DemoUtil.MIME_TYPE_HLS:
return new HlsMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri);
case DemoUtil.MIME_TYPE_VIDEO_MP4:
return new ExtractorMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri);
default: {
throw new IllegalStateException("Unsupported type: " + sample.mimeType);
private MediaSource buildMediaSource(MediaItem item) {
Uri uri = item.uri;
String mimeType = item.mimeType;
if (mimeType == null) {
throw new IllegalArgumentException("mimeType is required");
}
FrameworkMediaDrm mediaDrm = null;
DrmSessionManager<FrameworkMediaCrypto> drmSessionManager =
DrmSessionManager.getDummyDrmSessionManager();
MediaItem.DrmConfiguration drmConfiguration = item.drmConfiguration;
if (drmConfiguration != null) {
String licenseServerUrl =
drmConfiguration.licenseUri != null ? drmConfiguration.licenseUri.toString() : "";
HttpMediaDrmCallback drmCallback =
new HttpMediaDrmCallback(licenseServerUrl, DATA_SOURCE_FACTORY);
for (Map.Entry<String, String> requestHeader : drmConfiguration.requestHeaders.entrySet()) {
drmCallback.setKeyRequestProperty(requestHeader.getKey(), requestHeader.getValue());
}
try {
mediaDrm = FrameworkMediaDrm.newInstance(drmConfiguration.uuid);
drmSessionManager =
new DefaultDrmSessionManager<>(
drmConfiguration.uuid,
mediaDrm,
drmCallback,
/* optionalKeyRequestParameters= */ null,
/* multiSession= */ true);
} catch (UnsupportedDrmException e) {
// Do nothing. The track selector will avoid selecting the DRM protected tracks.
}
}
MediaSource createdMediaSource;
switch (mimeType) {
case DemoUtil.MIME_TYPE_SS:
createdMediaSource =
new SsMediaSource.Factory(DATA_SOURCE_FACTORY)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
break;
case DemoUtil.MIME_TYPE_DASH:
createdMediaSource =
new DashMediaSource.Factory(DATA_SOURCE_FACTORY)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
break;
case DemoUtil.MIME_TYPE_HLS:
createdMediaSource =
new HlsMediaSource.Factory(DATA_SOURCE_FACTORY)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
break;
case DemoUtil.MIME_TYPE_VIDEO_MP4:
createdMediaSource =
new ProgressiveMediaSource.Factory(DATA_SOURCE_FACTORY)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
break;
default:
throw new IllegalArgumentException("mimeType is unsupported: " + mimeType);
}
if (mediaDrm != null) {
mediaDrms.put(createdMediaSource, mediaDrm);
}
return createdMediaSource;
}
private static MediaQueueItem buildMediaQueueItem(DemoUtil.Sample sample) {
MediaMetadata movieMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);
movieMetadata.putString(MediaMetadata.KEY_TITLE, sample.name);
MediaInfo mediaInfo = new MediaInfo.Builder(sample.uri)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED).setContentType(sample.mimeType)
.setMetadata(movieMetadata).build();
return new MediaQueueItem.Builder(mediaInfo).build();
private void releaseMediaDrmOfMediaSource(MediaSource mediaSource) {
FrameworkMediaDrm mediaDrmToRelease = mediaDrms.remove(mediaSource);
if (mediaDrmToRelease != null) {
mediaDrmToRelease.release();
}
}
}

View file

@ -13,8 +13,12 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector android:alpha="0.8" android:height="24dp" android:viewportHeight="24.0"
android:viewportWidth="24.0" android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM17,13h-4v4h-2v-4L7,13v-2h4L11,7h2v4h4v2z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24.0dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24.0dp" >
<path
android:fillColor="#FFFFFFFF"
android:pathData="M18,13h-5v5c0,0.55 -0.45,1 -1,1h0c-0.55,0 -1,-0.45 -1,-1v-5H6c-0.55,0 -1,-0.45 -1,-1v0c0,-0.55 0.45,-1 1,-1h5V6c0,-0.55 0.45,-1 1,-1h0c0.55,0 1,0.45 1,1v5h5c0.55,0 1,0.45 1,1v0C19,12.55 18.55,13 18,13z"/>
</vector>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textSize="20sp"
android:text="@string/cast_context_error"/>

View file

@ -19,34 +19,42 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true">
<com.google.android.exoplayer2.ui.PlayerView android:id="@+id/local_player_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="12"
android:layout_weight="1"
android:background="@android:color/black"
app:repeat_toggle_modes="all|one"/>
<RelativeLayout android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="12">
<android.support.v7.widget.RecyclerView android:id="@+id/sample_list"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView android:id="@+id/sample_list"
android:choiceMode="singleChoice"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
android:fadeScrollbars="false"/>
<ImageButton android:id="@+id/add_sample_button"
android:background="@drawable/ic_add_circle_white_24dp"
<com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/add_sample_button"
android:src="@drawable/ic_plus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_alignParentBottom="true"
android:padding="30dp"/>
android:layout_margin="16dp"
android:contentDescription="@string/add_samples"/>
</RelativeLayout>
<com.google.android.exoplayer2.ui.PlayerControlView android:id="@+id/cast_control_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:layout_height="wrap_content"
android:visibility="gone"
app:repeat_toggle_modes="all|one"
app:show_timeout="-1"/>
</LinearLayout>

View file

@ -14,7 +14,7 @@
limitations under the License.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView android:id="@+id/sample_list"

View file

@ -19,7 +19,7 @@
<item
android:id="@+id/media_route_menu_item"
android:title="@string/media_route_menu_title"
app:actionProviderClass="android.support.v7.app.MediaRouteActionProvider"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always" />
</menu>

View file

@ -20,6 +20,12 @@
<string name="media_route_menu_title">Cast</string>
<string name="sample_list_dialog_title">Add samples</string>
<string name="add_samples">Add samples</string>
<string name="cast_context_error">Failed to get Cast context. Try updating Google Play Services and restart the app.</string>
<string name="error_unsupported_video">Media includes video tracks, but none are playable by this device</string>
<string name="error_unsupported_audio">Media includes audio tracks, but none are playable by this device</string>
</resources>

4
demos/gvr/README.md Normal file
View file

@ -0,0 +1,4 @@
# ExoPlayer VR player demo #
This folder contains a demo application that showcases 360 video playback using
ExoPlayer GVR extension.

59
demos/gvr/build.gradle Normal file
View file

@ -0,0 +1,59 @@
// 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.
apply from: '../../constants.gradle'
apply plugin: 'com.android.application'
android {
compileSdkVersion project.ext.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion 19
targetSdkVersion project.ext.targetSdkVersion
}
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt')
}
debug {
jniDebuggable = true
}
}
lintOptions {
// The demo app isn't indexed and doesn't have translations.
disable 'GoogleAppIndexingWarning','MissingTranslation'
}
}
dependencies {
implementation project(modulePrefix + 'library-core')
implementation project(modulePrefix + 'library-ui')
implementation project(modulePrefix + 'library-dash')
implementation project(modulePrefix + 'library-hls')
implementation project(modulePrefix + 'library-smoothstreaming')
implementation project(modulePrefix + 'extension-gvr')
implementation 'androidx.annotation:annotation:1.1.0'
}
apply plugin: 'com.google.android.gms.strict-version-matcher-plugin'

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.android.exoplayer2.gvrdemo">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-sdk/>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/application_name"
android:largeHeap="true">
<activity
android:name="com.google.android.exoplayer2.gvrdemo.SampleChooserActivity"
android:configChanges="keyboardHidden"
android:exported="true"
android:label="@string/application_name">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="http"/>
<data android:scheme="https"/>
<data android:scheme="content"/>
<data android:scheme="asset"/>
<data android:scheme="file"/>
<data android:host="*"/>
<data android:pathPattern=".*\\.exolist\\.json"/>
</intent-filter>
</activity>
<activity
android:name="com.google.android.exoplayer2.gvrdemo.PlayerActivity"
android:configChanges="density|keyboardHidden|navigation|orientation|screenSize|uiMode"
android:enableVrMode="@string/gvr_vr_mode_component"
android:exported="false"
android:label="@string/application_name"
android:launchMode="singleTask"
android:resizeableActivity="false"
android:screenOrientation="landscape"
android:theme="@style/VrActivityTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="com.google.intent.category.CARDBOARD"/> <!-- copybara:strip(development-only) -->
<category android:name="com.google.intent.category.DAYDREAM"/>
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,242 @@
/*
* 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.gvrdemo;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.Nullable;
import android.widget.Toast;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.C.ContentType;
import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.PlaybackPreparer;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.ext.gvr.GvrPlayerActivity;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.source.dash.DashMediaSource;
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
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.util.EventLogger;
import com.google.android.exoplayer2.util.Util;
/** An activity that plays media using {@link SimpleExoPlayer}. */
public class PlayerActivity extends GvrPlayerActivity implements PlaybackPreparer {
public static final String EXTENSION_EXTRA = "extension";
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";
private DataSource.Factory dataSourceFactory;
private SimpleExoPlayer player;
private MediaSource mediaSource;
private DefaultTrackSelector trackSelector;
private TrackGroupArray lastSeenTrackGroupArray;
private boolean startAutoPlay;
private int startWindow;
private long startPosition;
// Activity lifecycle
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String userAgent = Util.getUserAgent(this, "ExoPlayerDemo");
dataSourceFactory =
new DefaultDataSourceFactory(this, new DefaultHttpDataSourceFactory(userAgent));
String sphericalStereoMode = getIntent().getStringExtra(SPHERICAL_STEREO_MODE_EXTRA);
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;
}
setDefaultStereoMode(stereoMode);
}
clearStartPosition();
}
@Override
public void onResume() {
super.onResume();
if (Util.SDK_INT <= 23 || player == null) {
initializePlayer();
}
}
@Override
public void onPause() {
super.onPause();
if (Util.SDK_INT <= 23) {
releasePlayer();
}
}
@Override
public void onDestroy() {
super.onDestroy();
}
// PlaybackControlView.PlaybackPreparer implementation
@Override
public void preparePlayback() {
initializePlayer();
}
// Internal methods
private void initializePlayer() {
if (player == null) {
Intent intent = getIntent();
Uri uri = intent.getData();
if (!Util.checkCleartextTrafficPermitted(uri)) {
showToast(R.string.error_cleartext_not_permitted);
return;
}
DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(this);
trackSelector = new DefaultTrackSelector(/* context= */ this);
lastSeenTrackGroupArray = null;
player =
ExoPlayerFactory.newSimpleInstance(/* context= */ this, renderersFactory, trackSelector);
player.addListener(new PlayerEventListener());
player.setPlayWhenReady(startAutoPlay);
player.addAnalyticsListener(new EventLogger(trackSelector));
setPlayer(player);
mediaSource = buildMediaSource(uri, intent.getStringExtra(EXTENSION_EXTRA));
}
boolean haveStartPosition = startWindow != C.INDEX_UNSET;
if (haveStartPosition) {
player.seekTo(startWindow, startPosition);
}
player.prepare(mediaSource, !haveStartPosition, false);
}
private MediaSource buildMediaSource(Uri uri, @Nullable String overrideExtension) {
@ContentType int type = Util.inferContentType(uri, overrideExtension);
switch (type) {
case C.TYPE_DASH:
return new DashMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
case C.TYPE_SS:
return new SsMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
case C.TYPE_HLS:
return new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
case C.TYPE_OTHER:
return new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
default:
throw new IllegalStateException("Unsupported type: " + type);
}
}
private void releasePlayer() {
if (player != null) {
updateStartPosition();
player.release();
player = null;
mediaSource = null;
trackSelector = null;
}
}
private void updateStartPosition() {
if (player != null) {
startAutoPlay = player.getPlayWhenReady();
startWindow = player.getCurrentWindowIndex();
startPosition = Math.max(0, player.getContentPosition());
}
}
private void clearStartPosition() {
startAutoPlay = true;
startWindow = C.INDEX_UNSET;
startPosition = C.TIME_UNSET;
}
private void showToast(int messageId) {
showToast(getString(messageId));
}
private void showToast(String message) {
Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show();
}
private class PlayerEventListener implements Player.EventListener {
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {}
@Override
public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
if (player.getPlaybackError() != null) {
// The user has performed a seek whilst in the error state. Update the resume position so
// that if the user then retries, playback resumes from the position to which they seeked.
updateStartPosition();
}
}
@Override
public void onPlayerError(ExoPlaybackException e) {
updateStartPosition();
}
@Override
@SuppressWarnings("ReferenceEquality")
public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
if (trackGroups != lastSeenTrackGroupArray) {
MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
if (mappedTrackInfo != null) {
if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO)
== MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
showToast(R.string.error_unsupported_video);
}
if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_AUDIO)
== MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
showToast(R.string.error_unsupported_audio);
}
}
lastSeenTrackGroupArray = trackGroups;
}
}
}
}

View file

@ -0,0 +1,133 @@
/*
* 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.gvrdemo;
import static com.google.android.exoplayer2.gvrdemo.PlayerActivity.SPHERICAL_STEREO_MODE_LEFT_RIGHT;
import static com.google.android.exoplayer2.gvrdemo.PlayerActivity.SPHERICAL_STEREO_MODE_MONO;
import static com.google.android.exoplayer2.gvrdemo.PlayerActivity.SPHERICAL_STEREO_MODE_TOP_BOTTOM;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.ListView;
/** An activity for selecting from a list of media samples. */
public class SampleChooserActivity extends Activity {
private final Sample[] samples =
new Sample[] {
new Sample(
"Congo (360 top-bottom stereo)",
"https://storage.googleapis.com/exoplayer-test-media-1/360/congo.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"Sphericalv2 (180 top-bottom stereo)",
"https://storage.googleapis.com/exoplayer-test-media-1/360/sphericalv2.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"Iceland (360 top-bottom stereo ts)",
"https://storage.googleapis.com/exoplayer-test-media-1/360/iceland0.ts",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"Camera motion metadata test",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/synthetic_with_camm.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"actual_camera_cat",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/actual_camera_cat.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"johnny_stitched",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/johnny_stitched.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"lenovo_birds.vr",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/lenovo_birds.vr.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"mono_v1_sample",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/mono_v1_sample.mp4",
SPHERICAL_STEREO_MODE_MONO),
new Sample(
"not_vr180_actually_shot_with_moto_mod",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/"
+ "not_vr180_actually_shot_with_moto_mod.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"stereo_v1_sample",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/stereo_v1_sample.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
new Sample(
"yi_giraffes.vr",
"https://storage.googleapis.com/exoplayer-test-media-internal-"
+ "63834241aced7884c2544af1a3452e01/vr180/yi_giraffes.vr.mp4",
SPHERICAL_STEREO_MODE_TOP_BOTTOM),
};
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.sample_chooser_activity);
ListView sampleListView = findViewById(R.id.sample_list);
sampleListView.setAdapter(
new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, samples));
sampleListView.setOnItemClickListener(
(parent, view, position, id) ->
startActivity(
samples[position].buildIntent(/* context= */ SampleChooserActivity.this)));
}
private static final class Sample {
public final String name;
public final String uri;
public final String extension;
public final String sphericalStereoMode;
public Sample(String name, String uri, String sphericalStereoMode) {
this(name, uri, sphericalStereoMode, null);
}
public Sample(String name, String uri, String sphericalStereoMode, String extension) {
this.name = name;
this.uri = uri;
this.extension = extension;
this.sphericalStereoMode = sphericalStereoMode;
}
public Intent buildIntent(Context context) {
Intent intent = new Intent(context, PlayerActivity.class);
return intent
.setData(Uri.parse(uri))
.putExtra(PlayerActivity.EXTENSION_EXTRA, extension)
.putExtra(PlayerActivity.SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode);
}
@Override
public String toString() {
return name;
}
}
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- 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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ListView android:id="@+id/sample_list"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="application_name">ExoPlayer VR Demo</string>
<string name="error_cleartext_not_permitted">Cleartext traffic not permitted</string>
<string name="error_unrecognized_stereo_mode">Unrecognized stereo mode</string>
<string name="error_unsupported_video">Media includes video tracks, but none are playable by this device</string>
<string name="error_unsupported_audio">Media includes audio tracks, but none are playable by this device</string>
</resources>

View file

@ -16,7 +16,6 @@ apply plugin: 'com.android.application'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -26,7 +25,7 @@ android {
defaultConfig {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion 16
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
}
@ -42,8 +41,8 @@ android {
}
lintOptions {
// The demo app does not have translations.
disable 'MissingTranslation'
// The demo app isn't indexed and doesn't have translations.
disable 'GoogleAppIndexingWarning','MissingTranslation'
}
}
@ -54,7 +53,7 @@ dependencies {
implementation project(modulePrefix + 'library-hls')
implementation project(modulePrefix + 'library-smoothstreaming')
implementation project(modulePrefix + 'extension-ima')
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
implementation 'androidx.annotation:annotation:1.1.0'
}
apply plugin: 'com.google.android.gms.strict-version-matcher-plugin'

View file

@ -17,6 +17,7 @@
package="com.google.android.exoplayer2.imademo">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-sdk/>
<application android:label="@string/application_name" android:icon="@mipmap/ic_launcher"

View file

@ -23,23 +23,20 @@ import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.ext.ima.ImaAdsLoader;
import com.google.android.exoplayer2.source.ExtractorMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.MediaSourceFactory;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.ads.AdsMediaSource;
import com.google.android.exoplayer2.source.dash.DashMediaSource;
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.TrackSelection;
import com.google.android.exoplayer2.trackselection.TrackSelector;
import com.google.android.exoplayer2.ui.PlayerView;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.util.Util;
/** Manages the {@link ExoPlayer}, the IMA plugin and all video playback. */
/* package */ final class PlayerManager implements AdsMediaSource.MediaSourceFactory {
/* package */ final class PlayerManager implements MediaSourceFactory {
private final ImaAdsLoader adsLoader;
private final DataSource.Factory dataSourceFactory;
@ -56,14 +53,9 @@ import com.google.android.exoplayer2.util.Util;
}
public void init(Context context, PlayerView playerView) {
// Create a default track selector.
TrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory();
TrackSelector trackSelector = new DefaultTrackSelector(videoTrackSelectionFactory);
// Create a player instance.
player = ExoPlayerFactory.newSimpleInstance(context, trackSelector);
// Bind the player to the view.
player = ExoPlayerFactory.newSimpleInstance(context);
adsLoader.setPlayer(player);
playerView.setPlayer(player);
// This is the MediaSource representing the content media (i.e. not the ad).
@ -73,10 +65,7 @@ import com.google.android.exoplayer2.util.Util;
// Compose the content media source into a new AdsMediaSource with both ads and content.
MediaSource mediaSourceWithAds =
new AdsMediaSource(
contentMediaSource,
/* adMediaSourceFactory= */ this,
adsLoader,
playerView.getOverlayFrameLayout());
contentMediaSource, /* adMediaSourceFactory= */ this, adsLoader, playerView);
// Prepare the player with the source.
player.seekTo(contentPosition);
@ -89,6 +78,7 @@ import com.google.android.exoplayer2.util.Util;
contentPosition = player.getContentPosition();
player.release();
player = null;
adsLoader.setPlayer(null);
}
}
@ -100,7 +90,7 @@ import com.google.android.exoplayer2.util.Util;
adsLoader.release();
}
// AdsMediaSource.MediaSourceFactory implementation.
// MediaSourceFactory implementation.
@Override
public MediaSource createMediaSource(Uri uri) {
@ -125,7 +115,7 @@ import com.google.android.exoplayer2.util.Util;
case C.TYPE_HLS:
return new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
case C.TYPE_OTHER:
return new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
return new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
default:
throw new IllegalStateException("Unsupported type: " + type);
}

View file

@ -17,7 +17,7 @@
<string name="application_name">Exo IMA Demo</string>
<string name="content_url"><![CDATA[http://rmcdn.2mdn.net/MotifFiles/html/1248596/android_1330378998288.mp4]]></string>
<string name="content_url"><![CDATA[https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv]]></string>
<string name="ad_tag_url"><![CDATA[https://pubads.g.doubleclick.net/gampad/ads?sz=640x480&iu=/124319096/external/single_ad_samples&ciu_szs=300x250&impl=s&gdfp_req=1&env=vp&output=vast&unviewed_position_start=1&cust_params=deployment%3Ddevsite%26sample_ct%3Dlinear&correlator=]]></string>

View file

@ -16,7 +16,6 @@ apply plugin: 'com.android.application'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -26,7 +25,7 @@ android {
defaultConfig {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion 16
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
}
@ -45,8 +44,9 @@ android {
}
lintOptions {
// The demo app does not have translations.
disable 'MissingTranslation'
// The demo app isn't indexed, doesn't have translations, and has a
// banner for AndroidTV that's only in xhdpi density.
disable 'GoogleAppIndexingWarning','MissingTranslation','IconDensities'
}
flavorDimensions "extensions"
@ -62,7 +62,10 @@ android {
}
dependencies {
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.viewpager:viewpager:1.0.0'
implementation 'androidx.fragment:fragment:1.0.0'
implementation 'com.google.android.material:material:1.0.0'
implementation project(modulePrefix + 'library-core')
implementation project(modulePrefix + 'library-dash')
implementation project(modulePrefix + 'library-hls')

View file

@ -15,6 +15,7 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.google.android.exoplayer2.demo">
<uses-permission android:name="android.permission.INTERNET"/>
@ -33,11 +34,13 @@
android:banner="@drawable/ic_banner"
android:largeHeap="true"
android:allowBackup="false"
android:name="com.google.android.exoplayer2.demo.DemoApplication">
android:name="com.google.android.exoplayer2.demo.DemoApplication"
tools:ignore="UnusedAttribute">
<activity android:name="com.google.android.exoplayer2.demo.SampleChooserActivity"
android:configChanges="keyboardHidden"
android:label="@string/application_name">
android:label="@string/application_name"
android:theme="@style/Theme.AppCompat">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

View file

@ -330,11 +330,11 @@
"samples": [
{
"name": "Super speed",
"uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism"
"uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism/Manifest"
},
{
"name": "Super speed (PlayReady)",
"uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264PR/SuperSpeedway_720.ism",
"uri": "https://playready.directtaps.net/smoothstreaming/SSWSS720H264PR/SuperSpeedway_720.ism/Manifest",
"drm_scheme": "playready"
}
]
@ -352,11 +352,11 @@
},
{
"name": "Apple master playlist advanced (TS)",
"uri": "https://tungsten.aaplimg.com/VOD/bipbop_adv_example_v2/master.m3u8"
"uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8"
},
{
"name": "Apple master playlist advanced (fMP4)",
"uri": "https://tungsten.aaplimg.com/VOD/bipbop_adv_fmp4_example/master.m3u8"
"uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
},
{
"name": "Apple TS media playlist",

View file

@ -16,6 +16,13 @@
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.upstream.DataSource;
@ -28,21 +35,24 @@ 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 {
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";
private static final int MAX_SIMULTANEOUS_DOWNLOADS = 2;
protected String userAgent;
private DatabaseProvider databaseProvider;
private File downloadDirectory;
private Cache downloadCache;
private DownloadManager downloadManager;
@ -71,6 +81,18 @@ public class DemoApplication extends Application {
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 DownloadManager getDownloadManager() {
initDownloadManager();
return downloadManager;
@ -81,31 +103,51 @@ public class DemoApplication extends Application {
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(
downloaderConstructorHelper,
MAX_SIMULTANEOUS_DOWNLOADS,
DownloadManager.DEFAULT_MIN_RETRY_COUNT,
new File(getDownloadDirectory(), DOWNLOAD_ACTION_FILE));
this, downloadIndex, new DefaultDownloaderFactory(downloaderConstructorHelper));
downloadTracker =
new DownloadTracker(
/* context= */ this,
buildDataSourceFactory(),
new File(getDownloadDirectory(), DOWNLOAD_TRACKER_ACTION_FILE));
downloadManager.addListener(downloadTracker);
new DownloadTracker(/* context= */ this, buildDataSourceFactory(), downloadManager);
}
}
private synchronized Cache getDownloadCache() {
if (downloadCache == null) {
File downloadContentDirectory = new File(getDownloadDirectory(), DOWNLOAD_CONTENT_DIRECTORY);
downloadCache = new SimpleCache(downloadContentDirectory, new NoOpCacheEvictor());
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);
}
return downloadCache;
}
private DatabaseProvider getDatabaseProvider() {
if (databaseProvider == null) {
databaseProvider = new ExoDatabaseProvider(this);
}
return databaseProvider;
}
private File getDownloadDirectory() {
@ -118,8 +160,8 @@ public class DemoApplication extends Application {
return downloadDirectory;
}
private static CacheDataSourceFactory buildReadOnlyCacheDataSource(
DefaultDataSourceFactory upstreamFactory, Cache cache) {
protected static CacheDataSourceFactory buildReadOnlyCacheDataSource(
DataSource.Factory upstreamFactory, Cache cache) {
return new CacheDataSourceFactory(
cache,
upstreamFactory,

View file

@ -16,13 +16,14 @@
package com.google.android.exoplayer2.demo;
import android.app.Notification;
import com.google.android.exoplayer2.offline.Download;
import com.google.android.exoplayer2.offline.DownloadManager;
import com.google.android.exoplayer2.offline.DownloadManager.TaskState;
import com.google.android.exoplayer2.offline.DownloadService;
import com.google.android.exoplayer2.scheduler.PlatformScheduler;
import com.google.android.exoplayer2.ui.DownloadNotificationUtil;
import com.google.android.exoplayer2.ui.DownloadNotificationHelper;
import com.google.android.exoplayer2.util.NotificationUtil;
import com.google.android.exoplayer2.util.Util;
import java.util.List;
/** A service for downloading media. */
public class DemoDownloadService extends DownloadService {
@ -31,12 +32,24 @@ public class DemoDownloadService extends DownloadService {
private static final int JOB_ID = 1;
private static final int FOREGROUND_NOTIFICATION_ID = 1;
private static int nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1;
private DownloadNotificationHelper notificationHelper;
public DemoDownloadService() {
super(
FOREGROUND_NOTIFICATION_ID,
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
CHANNEL_ID,
R.string.exo_download_notification_channel_name);
R.string.exo_download_notification_channel_name,
/* channelDescriptionResourceId= */ 0);
nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1;
}
@Override
public void onCreate() {
super.onCreate();
notificationHelper = new DownloadNotificationHelper(this, CHANNEL_ID);
}
@Override
@ -50,40 +63,29 @@ public class DemoDownloadService extends DownloadService {
}
@Override
protected Notification getForegroundNotification(TaskState[] taskStates) {
return DownloadNotificationUtil.buildProgressNotification(
/* context= */ this,
R.drawable.exo_controls_play,
CHANNEL_ID,
/* contentIntent= */ null,
/* message= */ null,
taskStates);
protected Notification getForegroundNotification(List<Download> downloads) {
return notificationHelper.buildProgressNotification(
R.drawable.ic_download, /* contentIntent= */ null, /* message= */ null, downloads);
}
@Override
protected void onTaskStateChanged(TaskState taskState) {
if (taskState.action.isRemoveAction) {
protected void onDownloadChanged(Download download) {
Notification notification;
if (download.state == Download.STATE_COMPLETED) {
notification =
notificationHelper.buildDownloadCompletedNotification(
R.drawable.ic_download_done,
/* contentIntent= */ null,
Util.fromUtf8Bytes(download.request.data));
} else if (download.state == Download.STATE_FAILED) {
notification =
notificationHelper.buildDownloadFailedNotification(
R.drawable.ic_download_done,
/* contentIntent= */ null,
Util.fromUtf8Bytes(download.request.data));
} else {
return;
}
Notification notification = null;
if (taskState.state == TaskState.STATE_COMPLETED) {
notification =
DownloadNotificationUtil.buildDownloadCompletedNotification(
/* context= */ this,
R.drawable.exo_controls_play,
CHANNEL_ID,
/* contentIntent= */ null,
Util.fromUtf8Bytes(taskState.action.data));
} else if (taskState.state == TaskState.STATE_FAILED) {
notification =
DownloadNotificationUtil.buildDownloadFailedNotification(
/* context= */ this,
R.drawable.exo_controls_play,
CHANNEL_ID,
/* contentIntent= */ null,
Util.fromUtf8Bytes(taskState.action.data));
}
int notificationId = FOREGROUND_NOTIFICATION_ID + 1 + taskState.taskId;
NotificationUtil.setNotification(this, notificationId, notification);
NotificationUtil.setNotification(this, nextNotificationId++, notification);
}
}

View file

@ -15,54 +15,32 @@
*/
package com.google.android.exoplayer2.demo;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentManager;
import android.widget.Toast;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.offline.ActionFile;
import com.google.android.exoplayer2.offline.DownloadAction;
import com.google.android.exoplayer2.RenderersFactory;
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.DownloadIndex;
import com.google.android.exoplayer2.offline.DownloadManager;
import com.google.android.exoplayer2.offline.DownloadManager.TaskState;
import com.google.android.exoplayer2.offline.DownloadRequest;
import com.google.android.exoplayer2.offline.DownloadService;
import com.google.android.exoplayer2.offline.ProgressiveDownloadHelper;
import com.google.android.exoplayer2.offline.StreamKey;
import com.google.android.exoplayer2.offline.TrackKey;
import com.google.android.exoplayer2.source.TrackGroup;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.source.dash.offline.DashDownloadHelper;
import com.google.android.exoplayer2.source.hls.offline.HlsDownloadHelper;
import com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloadHelper;
import com.google.android.exoplayer2.ui.DefaultTrackNameProvider;
import com.google.android.exoplayer2.ui.TrackNameProvider;
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.util.Log;
import com.google.android.exoplayer2.util.Util;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* Tracks media that has been downloaded.
*
* <p>Tracked downloads are persisted using an {@link ActionFile}, however in a real application
* it's expected that state will be stored directly in the application's media database, so that it
* can be queried efficiently together with other information about the media.
*/
public class DownloadTracker implements DownloadManager.Listener {
/** Tracks media that has been downloaded. */
public class DownloadTracker {
/** Listens for changes in the tracked downloads. */
public interface Listener {
@ -75,28 +53,23 @@ public class DownloadTracker implements DownloadManager.Listener {
private final Context context;
private final DataSource.Factory dataSourceFactory;
private final TrackNameProvider trackNameProvider;
private final CopyOnWriteArraySet<Listener> listeners;
private final HashMap<Uri, DownloadAction> trackedDownloadStates;
private final ActionFile actionFile;
private final Handler actionFileWriteHandler;
private final HashMap<Uri, Download> downloads;
private final DownloadIndex downloadIndex;
private final DefaultTrackSelector.Parameters trackSelectorParameters;
@Nullable private StartDownloadDialogHelper startDownloadDialogHelper;
public DownloadTracker(
Context context,
DataSource.Factory dataSourceFactory,
File actionFile,
DownloadAction.Deserializer... deserializers) {
Context context, DataSource.Factory dataSourceFactory, DownloadManager downloadManager) {
this.context = context.getApplicationContext();
this.dataSourceFactory = dataSourceFactory;
this.actionFile = new ActionFile(actionFile);
trackNameProvider = new DefaultTrackNameProvider(context.getResources());
listeners = new CopyOnWriteArraySet<>();
trackedDownloadStates = new HashMap<>();
HandlerThread actionFileWriteThread = new HandlerThread("DownloadTracker");
actionFileWriteThread.start();
actionFileWriteHandler = new Handler(actionFileWriteThread.getLooper());
loadTrackedActions(
deserializers.length > 0 ? deserializers : DownloadAction.getDefaultDeserializers());
downloads = new HashMap<>();
downloadIndex = downloadManager.getDownloadIndex();
trackSelectorParameters = DownloadHelper.getDefaultTrackSelectorParameters(context);
downloadManager.addListener(new DownloadManagerListener());
loadDownloads();
}
public void addListener(Listener listener) {
@ -108,191 +81,189 @@ public class DownloadTracker implements DownloadManager.Listener {
}
public boolean isDownloaded(Uri uri) {
return trackedDownloadStates.containsKey(uri);
Download download = downloads.get(uri);
return download != null && download.state != Download.STATE_FAILED;
}
@SuppressWarnings("unchecked")
public List<StreamKey> getOfflineStreamKeys(Uri uri) {
if (!trackedDownloadStates.containsKey(uri)) {
return Collections.emptyList();
}
return trackedDownloadStates.get(uri).getKeys();
public DownloadRequest getDownloadRequest(Uri uri) {
Download download = downloads.get(uri);
return download != null && download.state != Download.STATE_FAILED ? download.request : null;
}
public void toggleDownload(Activity activity, String name, Uri uri, String extension) {
if (isDownloaded(uri)) {
DownloadAction removeAction =
getDownloadHelper(uri, extension).getRemoveAction(Util.getUtf8Bytes(name));
startServiceWithAction(removeAction);
public void toggleDownload(
FragmentManager fragmentManager,
String name,
Uri uri,
String extension,
RenderersFactory renderersFactory) {
Download download = downloads.get(uri);
if (download != null) {
DownloadService.sendRemoveDownload(
context, DemoDownloadService.class, download.request.id, /* foreground= */ false);
} else {
StartDownloadDialogHelper helper =
new StartDownloadDialogHelper(activity, getDownloadHelper(uri, extension), name);
helper.prepare();
}
}
// DownloadManager.Listener
@Override
public void onInitialized(DownloadManager downloadManager) {
// Do nothing.
}
@Override
public void onTaskStateChanged(DownloadManager downloadManager, TaskState taskState) {
DownloadAction action = taskState.action;
Uri uri = action.uri;
if ((action.isRemoveAction && taskState.state == TaskState.STATE_COMPLETED)
|| (!action.isRemoveAction && taskState.state == TaskState.STATE_FAILED)) {
// A download has been removed, or has failed. Stop tracking it.
if (trackedDownloadStates.remove(uri) != null) {
handleTrackedDownloadStatesChanged();
if (startDownloadDialogHelper != null) {
startDownloadDialogHelper.release();
}
startDownloadDialogHelper =
new StartDownloadDialogHelper(
fragmentManager, getDownloadHelper(uri, extension, renderersFactory), name);
}
}
@Override
public void onIdle(DownloadManager downloadManager) {
// Do nothing.
}
// Internal methods
private void loadTrackedActions(DownloadAction.Deserializer[] deserializers) {
try {
DownloadAction[] allActions = actionFile.load(deserializers);
for (DownloadAction action : allActions) {
trackedDownloadStates.put(action.uri, action);
private void loadDownloads() {
try (DownloadCursor loadedDownloads = downloadIndex.getDownloads()) {
while (loadedDownloads.moveToNext()) {
Download download = loadedDownloads.getDownload();
downloads.put(download.request.uri, download);
}
} catch (IOException e) {
Log.e(TAG, "Failed to load tracked actions", e);
Log.w(TAG, "Failed to query downloads", e);
}
}
private void handleTrackedDownloadStatesChanged() {
for (Listener listener : listeners) {
listener.onDownloadsChanged();
}
final DownloadAction[] actions = trackedDownloadStates.values().toArray(new DownloadAction[0]);
actionFileWriteHandler.post(
() -> {
try {
actionFile.store(actions);
} catch (IOException e) {
Log.e(TAG, "Failed to store tracked actions", e);
}
});
}
private void startDownload(DownloadAction action) {
if (trackedDownloadStates.containsKey(action.uri)) {
// This content is already being downloaded. Do nothing.
return;
}
trackedDownloadStates.put(action.uri, action);
handleTrackedDownloadStatesChanged();
startServiceWithAction(action);
}
private void startServiceWithAction(DownloadAction action) {
DownloadService.startWithAction(context, DemoDownloadService.class, action, false);
}
private DownloadHelper getDownloadHelper(Uri uri, String extension) {
private DownloadHelper getDownloadHelper(
Uri uri, String extension, RenderersFactory renderersFactory) {
int type = Util.inferContentType(uri, extension);
switch (type) {
case C.TYPE_DASH:
return new DashDownloadHelper(uri, dataSourceFactory);
return DownloadHelper.forDash(context, uri, dataSourceFactory, renderersFactory);
case C.TYPE_SS:
return new SsDownloadHelper(uri, dataSourceFactory);
return DownloadHelper.forSmoothStreaming(context, uri, dataSourceFactory, renderersFactory);
case C.TYPE_HLS:
return new HlsDownloadHelper(uri, dataSourceFactory);
return DownloadHelper.forHls(context, uri, dataSourceFactory, renderersFactory);
case C.TYPE_OTHER:
return new ProgressiveDownloadHelper(uri);
return DownloadHelper.forProgressive(context, uri);
default:
throw new IllegalStateException("Unsupported type: " + type);
}
}
private final class StartDownloadDialogHelper
implements DownloadHelper.Callback, DialogInterface.OnClickListener {
private class DownloadManagerListener implements DownloadManager.Listener {
private final DownloadHelper downloadHelper;
private final String name;
private final AlertDialog.Builder builder;
private final View dialogView;
private final List<TrackKey> trackKeys;
private final ArrayAdapter<String> trackTitles;
private final ListView representationList;
public StartDownloadDialogHelper(
Activity activity, DownloadHelper downloadHelper, String name) {
this.downloadHelper = downloadHelper;
this.name = name;
builder =
new AlertDialog.Builder(activity)
.setTitle(R.string.exo_download_description)
.setPositiveButton(android.R.string.ok, this)
.setNegativeButton(android.R.string.cancel, null);
// Inflate with the builder's context to ensure the correct style is used.
LayoutInflater dialogInflater = LayoutInflater.from(builder.getContext());
dialogView = dialogInflater.inflate(R.layout.start_download_dialog, null);
trackKeys = new ArrayList<>();
trackTitles =
new ArrayAdapter<>(
builder.getContext(), android.R.layout.simple_list_item_multiple_choice);
representationList = dialogView.findViewById(R.id.representation_list);
representationList.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
representationList.setAdapter(trackTitles);
}
public void prepare() {
downloadHelper.prepare(this);
@Override
public void onDownloadChanged(DownloadManager downloadManager, Download download) {
downloads.put(download.request.uri, download);
for (Listener listener : listeners) {
listener.onDownloadsChanged();
}
}
@Override
public void onDownloadRemoved(DownloadManager downloadManager, Download download) {
downloads.remove(download.request.uri);
for (Listener listener : listeners) {
listener.onDownloadsChanged();
}
}
}
private final class StartDownloadDialogHelper
implements DownloadHelper.Callback,
DialogInterface.OnClickListener,
DialogInterface.OnDismissListener {
private final FragmentManager fragmentManager;
private final DownloadHelper downloadHelper;
private final String name;
private TrackSelectionDialog trackSelectionDialog;
private MappedTrackInfo mappedTrackInfo;
public StartDownloadDialogHelper(
FragmentManager fragmentManager, DownloadHelper downloadHelper, String name) {
this.fragmentManager = fragmentManager;
this.downloadHelper = downloadHelper;
this.name = name;
downloadHelper.prepare(this);
}
public void release() {
downloadHelper.release();
if (trackSelectionDialog != null) {
trackSelectionDialog.dismiss();
}
}
// DownloadHelper.Callback implementation.
@Override
public void onPrepared(DownloadHelper helper) {
for (int i = 0; i < downloadHelper.getPeriodCount(); i++) {
TrackGroupArray trackGroups = downloadHelper.getTrackGroups(i);
for (int j = 0; j < trackGroups.length; j++) {
TrackGroup trackGroup = trackGroups.get(j);
for (int k = 0; k < trackGroup.length; k++) {
trackKeys.add(new TrackKey(i, j, k));
trackTitles.add(trackNameProvider.getTrackName(trackGroup.getFormat(k)));
}
}
if (helper.getPeriodCount() == 0) {
Log.d(TAG, "No periods found. Downloading entire stream.");
startDownload();
downloadHelper.release();
return;
}
if (!trackKeys.isEmpty()) {
builder.setView(dialogView);
mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0);
if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) {
Log.d(TAG, "No dialog content. Downloading entire stream.");
startDownload();
downloadHelper.release();
return;
}
builder.create().show();
trackSelectionDialog =
TrackSelectionDialog.createForMappedTrackInfoAndParameters(
/* titleId= */ R.string.exo_download_description,
mappedTrackInfo,
trackSelectorParameters,
/* allowAdaptiveSelections =*/ false,
/* allowMultipleOverrides= */ true,
/* onClickListener= */ this,
/* onDismissListener= */ this);
trackSelectionDialog.show(fragmentManager, /* tag= */ null);
}
@Override
public void onPrepareError(DownloadHelper helper, IOException e) {
Toast.makeText(
context.getApplicationContext(), R.string.download_start_error, Toast.LENGTH_LONG)
.show();
Toast.makeText(context, R.string.download_start_error, Toast.LENGTH_LONG).show();
Log.e(TAG, "Failed to start download", e);
}
// DialogInterface.OnClickListener implementation.
@Override
public void onClick(DialogInterface dialog, int which) {
ArrayList<TrackKey> selectedTrackKeys = new ArrayList<>();
for (int i = 0; i < representationList.getChildCount(); i++) {
if (representationList.isItemChecked(i)) {
selectedTrackKeys.add(trackKeys.get(i));
for (int periodIndex = 0; periodIndex < downloadHelper.getPeriodCount(); periodIndex++) {
downloadHelper.clearTrackSelections(periodIndex);
for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
if (!trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i)) {
downloadHelper.addTrackSelectionForSingleRenderer(
periodIndex,
/* rendererIndex= */ i,
trackSelectorParameters,
trackSelectionDialog.getOverrides(/* rendererIndex= */ i));
}
}
}
if (!selectedTrackKeys.isEmpty() || trackKeys.isEmpty()) {
// We have selected keys, or we're dealing with single stream content.
DownloadAction downloadAction =
downloadHelper.getDownloadAction(Util.getUtf8Bytes(name), selectedTrackKeys);
startDownload(downloadAction);
DownloadRequest downloadRequest = buildDownloadRequest();
if (downloadRequest.streamKeys.isEmpty()) {
// All tracks were deselected in the dialog. Don't start the download.
return;
}
startDownload(downloadRequest);
}
// DialogInterface.OnDismissListener implementation.
@Override
public void onDismiss(DialogInterface dialogInterface) {
trackSelectionDialog = null;
downloadHelper.release();
}
// Internal methods.
private void startDownload() {
startDownload(buildDownloadRequest());
}
private void startDownload(DownloadRequest downloadRequest) {
DownloadService.sendAddDownload(
context, DemoDownloadService.class, downloadRequest, /* foreground= */ false);
}
private DownloadRequest buildDownloadRequest() {
return downloadHelper.getDownloadRequest(Util.getUtf8Bytes(name));
}
}
}

View file

@ -15,54 +15,51 @@
*/
package com.google.android.exoplayer2.demo;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import android.util.Pair;
import android.view.KeyEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.C.ContentType;
import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.PlaybackPreparer;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.RenderersFactory;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.demo.Sample.UriSample;
import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.FrameworkMediaCrypto;
import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
import com.google.android.exoplayer2.drm.UnsupportedDrmException;
import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException;
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException;
import com.google.android.exoplayer2.offline.FilteringManifestParser;
import com.google.android.exoplayer2.offline.StreamKey;
import com.google.android.exoplayer2.offline.DownloadHelper;
import com.google.android.exoplayer2.offline.DownloadRequest;
import com.google.android.exoplayer2.source.BehindLiveWindowException;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import com.google.android.exoplayer2.source.ExtractorMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.MediaSourceFactory;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
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.source.dash.DashMediaSource;
import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser;
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistParserFactory;
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
@ -72,7 +69,6 @@ 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.TrackSelectionView;
import com.google.android.exoplayer2.ui.spherical.SphericalSurfaceView;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource;
@ -83,42 +79,48 @@ import java.lang.reflect.Constructor;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.util.List;
import java.util.ArrayList;
import java.util.UUID;
/** An activity that plays media using {@link SimpleExoPlayer}. */
public class PlayerActivity extends Activity
public class PlayerActivity extends AppCompatActivity
implements OnClickListener, PlaybackPreparer, PlayerControlView.VisibilityListener {
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_MULTI_SESSION_EXTRA = "drm_multi_session";
public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders";
public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW";
public static final String EXTENSION_EXTRA = "extension";
public static final String ACTION_VIEW_LIST =
"com.google.android.exoplayer.demo.action.VIEW_LIST";
public static final String URI_LIST_EXTRA = "uri_list";
public static final String EXTENSION_LIST_EXTRA = "extension_list";
public static final String AD_TAG_URI_EXTRA = "ad_tag_uri";
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";
// 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 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_MULTI_SESSION_EXTRA = "drm_multi_session";
public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders";
public static final String AD_TAG_URI_EXTRA = "ad_tag_uri";
// For backwards compatibility only.
private static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid";
public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid";
// Saved instance state keys.
private static final String KEY_TRACK_SELECTOR_PARAMETERS = "track_selector_parameters";
private static final String KEY_WINDOW = "window";
private static final String KEY_POSITION = "position";
@ -130,13 +132,16 @@ public class PlayerActivity extends Activity
DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
}
private final ArrayList<FrameworkMediaDrm> mediaDrms;
private PlayerView playerView;
private LinearLayout debugRootView;
private Button selectTracksButton;
private TextView debugTextView;
private boolean isShowingTrackSelectionDialog;
private DataSource.Factory dataSourceFactory;
private SimpleExoPlayer player;
private FrameworkMediaDrm mediaDrm;
private MediaSource mediaSource;
private DefaultTrackSelector trackSelector;
private DefaultTrackSelector.Parameters trackSelectorParameters;
@ -151,13 +156,17 @@ public class PlayerActivity extends Activity
private AdsLoader adsLoader;
private Uri loadedAdTagUri;
private ViewGroup adUiViewGroup;
public PlayerActivity() {
mediaDrms = new ArrayList<>();
}
// Activity lifecycle
@Override
public void onCreate(Bundle savedInstanceState) {
String sphericalStereoMode = getIntent().getStringExtra(SPHERICAL_STEREO_MODE_EXTRA);
Intent intent = getIntent();
String sphericalStereoMode = intent.getStringExtra(SPHERICAL_STEREO_MODE_EXTRA);
if (sphericalStereoMode != null) {
setTheme(R.style.PlayerTheme_Spherical);
}
@ -168,10 +177,10 @@ public class PlayerActivity extends Activity
}
setContentView(R.layout.player_activity);
View rootView = findViewById(R.id.root);
rootView.setOnClickListener(this);
debugRootView = findViewById(R.id.controls_root);
debugTextView = findViewById(R.id.debug_text_view);
selectTracksButton = findViewById(R.id.select_tracks_button);
selectTracksButton.setOnClickListener(this);
playerView = findViewById(R.id.player_view);
playerView.setControllerVisibilityListener(this);
@ -199,13 +208,14 @@ public class PlayerActivity extends Activity
startWindow = savedInstanceState.getInt(KEY_WINDOW);
startPosition = savedInstanceState.getLong(KEY_POSITION);
} else {
trackSelectorParameters = new DefaultTrackSelector.ParametersBuilder().build();
trackSelectorParameters = DefaultTrackSelector.Parameters.getDefaults(/* context= */ this);
clearStartPosition();
}
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
releasePlayer();
releaseAdsLoader();
clearStartPosition();
@ -280,6 +290,7 @@ public class PlayerActivity extends Activity
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
updateTrackSelectorParameters();
updateStartPosition();
outState.putParcelable(KEY_TRACK_SELECTOR_PARAMETERS, trackSelectorParameters);
@ -300,23 +311,15 @@ public class PlayerActivity extends Activity
@Override
public void onClick(View view) {
if (view.getParent() == debugRootView) {
MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
if (mappedTrackInfo != null) {
CharSequence title = ((Button) view).getText();
int rendererIndex = (int) view.getTag();
int rendererType = mappedTrackInfo.getRendererType(rendererIndex);
boolean allowAdaptiveSelections =
rendererType == C.TRACK_TYPE_VIDEO
|| (rendererType == C.TRACK_TYPE_AUDIO
&& mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO)
== MappedTrackInfo.RENDERER_SUPPORT_NO_TRACKS);
Pair<AlertDialog, TrackSelectionView> dialogPair =
TrackSelectionView.getDialog(this, title, trackSelector, rendererIndex);
dialogPair.second.setShowDisableOption(true);
dialogPair.second.setAllowAdaptiveSelections(allowAdaptiveSelections);
dialogPair.first.show();
}
if (view == selectTracksButton
&& !isShowingTrackSelectionDialog
&& TrackSelectionDialog.willHaveContent(trackSelector)) {
isShowingTrackSelectionDialog = true;
TrackSelectionDialog trackSelectionDialog =
TrackSelectionDialog.createForTrackSelector(
trackSelector,
/* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false);
trackSelectionDialog.show(getSupportFragmentManager(), /* tag= */ null);
}
}
@ -324,7 +327,7 @@ public class PlayerActivity extends Activity
@Override
public void preparePlayback() {
initializePlayer();
player.retry();
}
// PlaybackControlView.VisibilityListener implementation
@ -339,67 +342,11 @@ public class PlayerActivity extends Activity
private void initializePlayer() {
if (player == null) {
Intent intent = getIntent();
String action = intent.getAction();
Uri[] uris;
String[] extensions;
if (ACTION_VIEW.equals(action)) {
uris = new Uri[] {intent.getData()};
extensions = new String[] {intent.getStringExtra(EXTENSION_EXTRA)};
} else if (ACTION_VIEW_LIST.equals(action)) {
String[] uriStrings = intent.getStringArrayExtra(URI_LIST_EXTRA);
uris = new Uri[uriStrings.length];
for (int i = 0; i < uriStrings.length; i++) {
uris[i] = Uri.parse(uriStrings[i]);
}
extensions = intent.getStringArrayExtra(EXTENSION_LIST_EXTRA);
if (extensions == null) {
extensions = new String[uriStrings.length];
}
} else {
showToast(getString(R.string.unexpected_intent_action, action));
finish();
return;
}
if (!Util.checkCleartextTrafficPermitted(uris)) {
showToast(R.string.error_cleartext_not_permitted);
return;
}
if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, uris)) {
// The player will be reinitialized if the permission is granted.
return;
}
DefaultDrmSessionManager<FrameworkMediaCrypto> drmSessionManager = null;
if (intent.hasExtra(DRM_SCHEME_EXTRA) || intent.hasExtra(DRM_SCHEME_UUID_EXTRA)) {
String drmLicenseUrl = intent.getStringExtra(DRM_LICENSE_URL_EXTRA);
String[] keyRequestPropertiesArray =
intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA);
boolean multiSession = intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA, false);
int errorStringId = R.string.error_drm_unknown;
if (Util.SDK_INT < 18) {
errorStringId = R.string.error_drm_not_supported;
} else {
try {
String drmSchemeExtra = intent.hasExtra(DRM_SCHEME_EXTRA) ? DRM_SCHEME_EXTRA
: DRM_SCHEME_UUID_EXTRA;
UUID drmSchemeUuid = Util.getDrmUuid(intent.getStringExtra(drmSchemeExtra));
if (drmSchemeUuid == null) {
errorStringId = R.string.error_drm_unsupported_scheme;
} else {
drmSessionManager =
buildDrmSessionManagerV18(
drmSchemeUuid, drmLicenseUrl, keyRequestPropertiesArray, multiSession);
}
} catch (UnsupportedDrmException e) {
errorStringId = e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME
? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown;
}
}
if (drmSessionManager == null) {
showToast(errorStringId);
finish();
return;
}
releaseMediaDrms();
mediaSource = createTopLevelMediaSource(intent);
if (mediaSource == null) {
return;
}
TrackSelection.Factory trackSelectionFactory;
@ -416,21 +363,15 @@ public class PlayerActivity extends Activity
boolean preferExtensionDecoders =
intent.getBooleanExtra(PREFER_EXTENSION_DECODERS_EXTRA, false);
@DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode =
((DemoApplication) getApplication()).useExtensionRenderers()
? (preferExtensionDecoders ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
: DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
: DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF;
DefaultRenderersFactory renderersFactory =
new DefaultRenderersFactory(this, extensionRendererMode);
RenderersFactory renderersFactory =
((DemoApplication) getApplication()).buildRenderersFactory(preferExtensionDecoders);
trackSelector = new DefaultTrackSelector(trackSelectionFactory);
trackSelector = new DefaultTrackSelector(/* context= */ this, trackSelectionFactory);
trackSelector.setParameters(trackSelectorParameters);
lastSeenTrackGroupArray = null;
player =
ExoPlayerFactory.newSimpleInstance(
/* context= */ this, renderersFactory, trackSelector, drmSessionManager);
ExoPlayerFactory.newSimpleInstance(/* context= */ this, renderersFactory, trackSelector);
player.addListener(new PlayerEventListener());
player.setPlayWhenReady(startAutoPlay);
player.addAnalyticsListener(new EventLogger(trackSelector));
@ -438,28 +379,8 @@ public class PlayerActivity extends Activity
playerView.setPlaybackPreparer(this);
debugViewHelper = new DebugTextViewHelper(player, debugTextView);
debugViewHelper.start();
MediaSource[] mediaSources = new MediaSource[uris.length];
for (int i = 0; i < uris.length; i++) {
mediaSources[i] = buildMediaSource(uris[i], extensions[i]);
}
mediaSource =
mediaSources.length == 1 ? mediaSources[0] : new ConcatenatingMediaSource(mediaSources);
String adTagUriString = intent.getStringExtra(AD_TAG_URI_EXTRA);
if (adTagUriString != null) {
Uri adTagUri = Uri.parse(adTagUriString);
if (!adTagUri.equals(loadedAdTagUri)) {
releaseAdsLoader();
loadedAdTagUri = adTagUri;
}
MediaSource adsMediaSource = createAdsMediaSource(mediaSource, Uri.parse(adTagUriString));
if (adsMediaSource != null) {
mediaSource = adsMediaSource;
} else {
showToast(R.string.ima_not_loaded);
}
} else {
releaseAdsLoader();
if (adsLoader != null) {
adsLoader.setPlayer(player);
}
}
boolean haveStartPosition = startWindow != C.INDEX_UNSET;
@ -467,44 +388,138 @@ public class PlayerActivity extends Activity
player.seekTo(startWindow, startPosition);
}
player.prepare(mediaSource, !haveStartPosition, false);
updateButtonVisibilities();
updateButtonVisibility();
}
private MediaSource buildMediaSource(Uri uri) {
return buildMediaSource(uri, null);
@Nullable
private MediaSource createTopLevelMediaSource(Intent intent) {
String action = intent.getAction();
boolean actionIsListView = ACTION_VIEW_LIST.equals(action);
if (!actionIsListView && !ACTION_VIEW.equals(action)) {
showToast(getString(R.string.unexpected_intent_action, action));
finish();
return null;
}
Sample intentAsSample = Sample.createFromIntent(intent);
UriSample[] samples =
intentAsSample instanceof Sample.PlaylistSample
? ((Sample.PlaylistSample) intentAsSample).children
: new UriSample[] {(UriSample) intentAsSample};
boolean seenAdsTagUri = false;
for (UriSample sample : samples) {
seenAdsTagUri |= sample.adTagUri != null;
if (!Util.checkCleartextTrafficPermitted(sample.uri)) {
showToast(R.string.error_cleartext_not_permitted);
return null;
}
if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, sample.uri)) {
// The player will be reinitialized if the permission is granted.
return null;
}
}
MediaSource[] mediaSources = new MediaSource[samples.length];
for (int i = 0; i < samples.length; i++) {
mediaSources[i] = createLeafMediaSource(samples[i]);
}
MediaSource mediaSource =
mediaSources.length == 1 ? mediaSources[0] : new ConcatenatingMediaSource(mediaSources);
if (seenAdsTagUri) {
Uri adTagUri = samples[0].adTagUri;
if (actionIsListView) {
showToast(R.string.unsupported_ads_in_concatenation);
} else {
if (!adTagUri.equals(loadedAdTagUri)) {
releaseAdsLoader();
loadedAdTagUri = adTagUri;
}
MediaSource adsMediaSource = createAdsMediaSource(mediaSource, adTagUri);
if (adsMediaSource != null) {
mediaSource = adsMediaSource;
} else {
showToast(R.string.ima_not_loaded);
}
}
} else {
releaseAdsLoader();
}
return mediaSource;
}
@SuppressWarnings("unchecked")
private MediaSource buildMediaSource(Uri uri, @Nullable String overrideExtension) {
@ContentType int type = Util.inferContentType(uri, overrideExtension);
private MediaSource createLeafMediaSource(UriSample parameters) {
DrmSessionManager<FrameworkMediaCrypto> drmSessionManager = null;
Sample.DrmInfo drmInfo = parameters.drmInfo;
if (drmInfo != null) {
int errorStringId = R.string.error_drm_unknown;
if (Util.SDK_INT < 18) {
errorStringId = R.string.error_drm_not_supported;
} else {
try {
if (drmInfo.drmScheme == null) {
errorStringId = R.string.error_drm_unsupported_scheme;
} else {
drmSessionManager =
buildDrmSessionManagerV18(
drmInfo.drmScheme,
drmInfo.drmLicenseUrl,
drmInfo.drmKeyRequestProperties,
drmInfo.drmMultiSession);
}
} catch (UnsupportedDrmException e) {
errorStringId =
e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME
? R.string.error_drm_unsupported_scheme
: R.string.error_drm_unknown;
}
}
if (drmSessionManager == null) {
showToast(errorStringId);
finish();
return null;
}
} else {
drmSessionManager = DrmSessionManager.getDummyDrmSessionManager();
}
DownloadRequest downloadRequest =
((DemoApplication) getApplication())
.getDownloadTracker()
.getDownloadRequest(parameters.uri);
if (downloadRequest != null) {
return DownloadHelper.createMediaSource(downloadRequest, dataSourceFactory);
}
return createLeafMediaSource(parameters.uri, parameters.extension, drmSessionManager);
}
private MediaSource createLeafMediaSource(
Uri uri, String extension, DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) {
@ContentType int type = Util.inferContentType(uri, extension);
switch (type) {
case C.TYPE_DASH:
return new DashMediaSource.Factory(dataSourceFactory)
.setManifestParser(
new FilteringManifestParser<>(new DashManifestParser(), getOfflineStreamKeys(uri)))
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
case C.TYPE_SS:
return new SsMediaSource.Factory(dataSourceFactory)
.setManifestParser(
new FilteringManifestParser<>(new SsManifestParser(), getOfflineStreamKeys(uri)))
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
case C.TYPE_HLS:
return new HlsMediaSource.Factory(dataSourceFactory)
.setPlaylistParserFactory(
new DefaultHlsPlaylistParserFactory(getOfflineStreamKeys(uri)))
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
case C.TYPE_OTHER:
return new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
default: {
return new ProgressiveMediaSource.Factory(dataSourceFactory)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
default:
throw new IllegalStateException("Unsupported type: " + type);
}
}
}
private List<StreamKey> getOfflineStreamKeys(Uri uri) {
return ((DemoApplication) getApplication()).getDownloadTracker().getOfflineStreamKeys(uri);
}
private DefaultDrmSessionManager<FrameworkMediaCrypto> buildDrmSessionManagerV18(
UUID uuid, String licenseUrl, String[] keyRequestPropertiesArray, boolean multiSession)
throws UnsupportedDrmException {
@ -518,8 +533,9 @@ public class PlayerActivity extends Activity
keyRequestPropertiesArray[i + 1]);
}
}
releaseMediaDrm();
mediaDrm = FrameworkMediaDrm.newInstance(uuid);
FrameworkMediaDrm mediaDrm = FrameworkMediaDrm.newInstance(uuid);
mediaDrms.add(mediaDrm);
return new DefaultDrmSessionManager<>(uuid, mediaDrm, drmCallback, null, multiSession);
}
@ -534,14 +550,17 @@ public class PlayerActivity extends Activity
mediaSource = null;
trackSelector = null;
}
releaseMediaDrm();
if (adsLoader != null) {
adsLoader.setPlayer(null);
}
releaseMediaDrms();
}
private void releaseMediaDrm() {
if (mediaDrm != null) {
private void releaseMediaDrms() {
for (FrameworkMediaDrm mediaDrm : mediaDrms) {
mediaDrm.release();
mediaDrm = null;
}
mediaDrms.clear();
}
private void releaseAdsLoader() {
@ -579,7 +598,8 @@ public class PlayerActivity extends Activity
}
/** Returns an ads media source, reusing the ads loader if one exists. */
private @Nullable MediaSource createAdsMediaSource(MediaSource mediaSource, Uri adTagUri) {
@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 {
@ -593,15 +613,13 @@ public class PlayerActivity extends Activity
.getConstructor(android.content.Context.class, android.net.Uri.class);
// LINT.ThenChange(../../../../../../../../proguard-rules.txt)
adsLoader = loaderConstructor.newInstance(this, adTagUri);
adUiViewGroup = new FrameLayout(this);
// The demo app has a non-null overlay frame layout.
playerView.getOverlayFrameLayout().addView(adUiViewGroup);
}
AdsMediaSource.MediaSourceFactory adMediaSourceFactory =
new AdsMediaSource.MediaSourceFactory() {
MediaSourceFactory adMediaSourceFactory =
new MediaSourceFactory() {
@Override
public MediaSource createMediaSource(Uri uri) {
return PlayerActivity.this.buildMediaSource(uri);
return PlayerActivity.this.createLeafMediaSource(
uri, /* extension=*/ null, DrmSessionManager.getDummyDrmSessionManager());
}
@Override
@ -609,7 +627,7 @@ public class PlayerActivity extends Activity
return new int[] {C.TYPE_DASH, C.TYPE_SS, C.TYPE_HLS, C.TYPE_OTHER};
}
};
return new AdsMediaSource(mediaSource, adMediaSourceFactory, adsLoader, adUiViewGroup);
return new AdsMediaSource(mediaSource, adMediaSourceFactory, adsLoader, playerView);
} catch (ClassNotFoundException e) {
// IMA extension not loaded.
return null;
@ -620,41 +638,9 @@ public class PlayerActivity extends Activity
// User controls
private void updateButtonVisibilities() {
debugRootView.removeAllViews();
if (player == null) {
return;
}
MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
if (mappedTrackInfo == null) {
return;
}
for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(i);
if (trackGroups.length != 0) {
Button button = new Button(this);
int label;
switch (player.getRendererType(i)) {
case C.TRACK_TYPE_AUDIO:
label = R.string.exo_track_selection_title_audio;
break;
case C.TRACK_TYPE_VIDEO:
label = R.string.exo_track_selection_title_video;
break;
case C.TRACK_TYPE_TEXT:
label = R.string.exo_track_selection_title_text;
break;
default:
continue;
}
button.setText(label);
button.setTag(i);
button.setOnClickListener(this);
debugRootView.addView(button);
}
}
private void updateButtonVisibility() {
selectTracksButton.setEnabled(
player != null && TrackSelectionDialog.willHaveContent(trackSelector));
}
private void showControls() {
@ -686,20 +672,11 @@ public class PlayerActivity extends Activity
private class PlayerEventListener implements Player.EventListener {
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
if (playbackState == Player.STATE_ENDED) {
showControls();
}
updateButtonVisibilities();
}
@Override
public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
if (player.getPlaybackError() != null) {
// The user has performed a seek whilst in the error state. Update the resume position so
// that if the user then retries, playback resumes from the position to which they seeked.
updateStartPosition();
}
updateButtonVisibility();
}
@Override
@ -708,8 +685,7 @@ public class PlayerActivity extends Activity
clearStartPosition();
initializePlayer();
} else {
updateStartPosition();
updateButtonVisibilities();
updateButtonVisibility();
showControls();
}
}
@ -717,7 +693,7 @@ public class PlayerActivity extends Activity
@Override
@SuppressWarnings("ReferenceEquality")
public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
updateButtonVisibilities();
updateButtonVisibility();
if (trackGroups != lastSeenTrackGroupArray) {
MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
if (mappedTrackInfo != null) {
@ -746,7 +722,7 @@ public class PlayerActivity extends Activity
// Special case for decoder initialization failures.
DecoderInitializationException decoderInitializationException =
(DecoderInitializationException) cause;
if (decoderInitializationException.decoderName == null) {
if (decoderInitializationException.codecInfo == null) {
if (decoderInitializationException.getCause() instanceof DecoderQueryException) {
errorString = getString(R.string.error_querying_decoders);
} else if (decoderInitializationException.secureDecoderRequired) {
@ -761,12 +737,11 @@ public class PlayerActivity extends Activity
errorString =
getString(
R.string.error_instantiating_decoder,
decoderInitializationException.decoderName);
decoderInitializationException.codecInfo.name);
}
}
}
return Pair.create(0, errorString);
}
}
}

View file

@ -0,0 +1,187 @@
/*
* 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.EXTENSION_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.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import java.util.ArrayList;
import java.util.UUID;
/* package */ abstract class Sample {
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);
Uri adTagUri = adsTagUriString != null ? Uri.parse(adsTagUriString) : null;
return new UriSample(
/* name= */ null,
DrmInfo.createFromIntent(intent, extrasKeySuffix),
uri,
extension,
adTagUri,
/* sphericalStereoMode= */ null);
}
public final Uri uri;
public final String extension;
public final DrmInfo drmInfo;
public final Uri adTagUri;
public final String sphericalStereoMode;
public UriSample(
String name,
DrmInfo drmInfo,
Uri uri,
String extension,
Uri adTagUri,
String sphericalStereoMode) {
super(name);
this.uri = uri;
this.extension = extension;
this.drmInfo = drmInfo;
this.adTagUri = adTagUri;
this.sphericalStereoMode = sphericalStereoMode;
}
@Override
public void addToIntent(Intent intent) {
intent.setAction(PlayerActivity.ACTION_VIEW).setData(uri);
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());
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);
}
}
}
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);
boolean drmMultiSession =
intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false);
return new DrmInfo(drmScheme, drmLicenseUrl, keyRequestPropertiesArray, drmMultiSession);
}
public final UUID drmScheme;
public final String drmLicenseUrl;
public final String[] drmKeyRequestProperties;
public final boolean drmMultiSession;
public DrmInfo(
UUID drmScheme,
String drmLicenseUrl,
String[] drmKeyRequestProperties,
boolean drmMultiSession) {
this.drmScheme = drmScheme;
this.drmLicenseUrl = drmLicenseUrl;
this.drmKeyRequestProperties = drmKeyRequestProperties;
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);
intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmMultiSession);
}
}
public static Sample createFromIntent(Intent intent) {
if (ACTION_VIEW_LIST.equals(intent.getAction())) {
ArrayList<String> 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= */ "");
}
}
@Nullable public final String name;
public Sample(String name) {
this.name = name;
}
public abstract void addToIntent(Intent intent);
}

View file

@ -15,16 +15,15 @@
*/
package com.google.android.exoplayer2.demo;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.AssetManager;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.support.annotation.Nullable;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import android.util.JsonReader;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
@ -38,12 +37,17 @@ import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;
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 java.io.IOException;
import java.io.InputStream;
@ -54,7 +58,7 @@ import java.util.Collections;
import java.util.List;
/** An activity for selecting from a list of media samples. */
public class SampleChooserActivity extends Activity
public class SampleChooserActivity extends AppCompatActivity
implements DownloadTracker.Listener, OnChildClickListener {
private static final String TAG = "SampleChooserActivity";
@ -160,13 +164,17 @@ public class SampleChooserActivity extends Activity
public boolean onChildClick(
ExpandableListView parent, View view, int groupPosition, int childPosition, long id) {
Sample sample = (Sample) view.getTag();
startActivity(
sample.buildIntent(
/* context= */ this,
isNonNullAndChecked(preferExtensionDecodersMenuItem),
isNonNullAndChecked(randomAbrMenuItem)
? PlayerActivity.ABR_ALGORITHM_RANDOM
: PlayerActivity.ABR_ALGORITHM_DEFAULT));
Intent intent = new Intent(this, PlayerActivity.class);
intent.putExtra(
PlayerActivity.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);
sample.addToIntent(intent);
startActivity(intent);
return true;
}
@ -177,7 +185,15 @@ public class SampleChooserActivity extends Activity
.show();
} else {
UriSample uriSample = (UriSample) sample;
downloadTracker.toggleDownload(this, sample.name, uriSample.uri, uriSample.extension);
RenderersFactory renderersFactory =
((DemoApplication) getApplication())
.buildRenderersFactory(isNonNullAndChecked(preferExtensionDecodersMenuItem));
downloadTracker.toggleDownload(
getSupportFragmentManager(),
sample.name,
uriSample.uri,
uriSample.extension,
renderersFactory);
}
}
@ -300,17 +316,12 @@ public class SampleChooserActivity extends Activity
extension = reader.nextString();
break;
case "drm_scheme":
Assertions.checkState(!insidePlaylist, "Invalid attribute on nested item: drm_scheme");
drmScheme = reader.nextString();
break;
case "drm_license_url":
Assertions.checkState(!insidePlaylist,
"Invalid attribute on nested item: drm_license_url");
drmLicenseUrl = reader.nextString();
break;
case "drm_key_request_properties":
Assertions.checkState(!insidePlaylist,
"Invalid attribute on nested item: drm_key_request_properties");
ArrayList<String> drmKeyRequestPropertiesList = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
@ -348,18 +359,21 @@ public class SampleChooserActivity extends Activity
DrmInfo drmInfo =
drmScheme == null
? null
: new DrmInfo(drmScheme, drmLicenseUrl, drmKeyRequestProperties, drmMultiSession);
: new DrmInfo(
Util.getDrmUuid(drmScheme),
drmLicenseUrl,
drmKeyRequestProperties,
drmMultiSession);
if (playlistSamples != null) {
UriSample[] playlistSamplesArray = playlistSamples.toArray(
new UriSample[playlistSamples.size()]);
return new PlaylistSample(sampleName, drmInfo, playlistSamplesArray);
UriSample[] playlistSamplesArray = playlistSamples.toArray(new UriSample[0]);
return new PlaylistSample(sampleName, playlistSamplesArray);
} else {
return new UriSample(
sampleName,
drmInfo,
uri,
extension,
adTagUri,
adTagUri != null ? Uri.parse(adTagUri) : null,
sphericalStereoMode);
}
}
@ -489,116 +503,4 @@ public class SampleChooserActivity extends Activity
}
}
private static final class DrmInfo {
public final String drmScheme;
public final String drmLicenseUrl;
public final String[] drmKeyRequestProperties;
public final boolean drmMultiSession;
public DrmInfo(
String drmScheme,
String drmLicenseUrl,
String[] drmKeyRequestProperties,
boolean drmMultiSession) {
this.drmScheme = drmScheme;
this.drmLicenseUrl = drmLicenseUrl;
this.drmKeyRequestProperties = drmKeyRequestProperties;
this.drmMultiSession = drmMultiSession;
}
public void updateIntent(Intent intent) {
Assertions.checkNotNull(intent);
intent.putExtra(PlayerActivity.DRM_SCHEME_EXTRA, drmScheme);
intent.putExtra(PlayerActivity.DRM_LICENSE_URL_EXTRA, drmLicenseUrl);
intent.putExtra(PlayerActivity.DRM_KEY_REQUEST_PROPERTIES_EXTRA, drmKeyRequestProperties);
intent.putExtra(PlayerActivity.DRM_MULTI_SESSION_EXTRA, drmMultiSession);
}
}
private abstract static class Sample {
public final String name;
public final DrmInfo drmInfo;
public Sample(String name, DrmInfo drmInfo) {
this.name = name;
this.drmInfo = drmInfo;
}
public Intent buildIntent(
Context context, boolean preferExtensionDecoders, String abrAlgorithm) {
Intent intent = new Intent(context, PlayerActivity.class);
intent.putExtra(PlayerActivity.PREFER_EXTENSION_DECODERS_EXTRA, preferExtensionDecoders);
intent.putExtra(PlayerActivity.ABR_ALGORITHM_EXTRA, abrAlgorithm);
if (drmInfo != null) {
drmInfo.updateIntent(intent);
}
return intent;
}
}
private static final class UriSample extends Sample {
public final Uri uri;
public final String extension;
public final String adTagUri;
public final String sphericalStereoMode;
public UriSample(
String name,
DrmInfo drmInfo,
Uri uri,
String extension,
String adTagUri,
String sphericalStereoMode) {
super(name, drmInfo);
this.uri = uri;
this.extension = extension;
this.adTagUri = adTagUri;
this.sphericalStereoMode = sphericalStereoMode;
}
@Override
public Intent buildIntent(
Context context, boolean preferExtensionDecoders, String abrAlgorithm) {
return super.buildIntent(context, preferExtensionDecoders, abrAlgorithm)
.setData(uri)
.putExtra(PlayerActivity.EXTENSION_EXTRA, extension)
.putExtra(PlayerActivity.AD_TAG_URI_EXTRA, adTagUri)
.putExtra(PlayerActivity.SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode)
.setAction(PlayerActivity.ACTION_VIEW);
}
}
private static final class PlaylistSample extends Sample {
public final UriSample[] children;
public PlaylistSample(
String name,
DrmInfo drmInfo,
UriSample... children) {
super(name, drmInfo);
this.children = children;
}
@Override
public Intent buildIntent(
Context context, boolean preferExtensionDecoders, String abrAlgorithm) {
String[] uris = new String[children.length];
String[] extensions = new String[children.length];
for (int i = 0; i < children.length; i++) {
uris[i] = children[i].uri.toString();
extensions[i] = children[i].extension;
}
return super.buildIntent(context, preferExtensionDecoders, abrAlgorithm)
.putExtra(PlayerActivity.URI_LIST_EXTRA, uris)
.putExtra(PlayerActivity.EXTENSION_LIST_EXTRA, extensions)
.setAction(PlayerActivity.ACTION_VIEW_LIST);
}
}
}

View file

@ -0,0 +1,368 @@
/*
* 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 android.app.Dialog;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.os.Bundle;
import androidx.annotation.Nullable;
import com.google.android.material.tabs.TabLayout;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentPagerAdapter;
import androidx.appcompat.app.AppCompatDialog;
import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import androidx.viewpager.widget.ViewPager;
import com.google.android.exoplayer2.C;
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.ui.TrackSelectionView;
import com.google.android.exoplayer2.util.Assertions;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Dialog to select tracks. */
public final class TrackSelectionDialog extends DialogFragment {
private final SparseArray<TrackSelectionViewFragment> tabFragments;
private final ArrayList<Integer> tabTrackTypes;
private int titleId;
private DialogInterface.OnClickListener onClickListener;
private DialogInterface.OnDismissListener onDismissListener;
/**
* Returns whether a track selection dialog will have content to display if initialized with the
* specified {@link DefaultTrackSelector} in its current state.
*/
public static boolean willHaveContent(DefaultTrackSelector trackSelector) {
MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
return mappedTrackInfo != null && willHaveContent(mappedTrackInfo);
}
/**
* Returns whether a track selection dialog will have content to display if initialized with the
* specified {@link MappedTrackInfo}.
*/
public static boolean willHaveContent(MappedTrackInfo mappedTrackInfo) {
for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
if (showTabForRenderer(mappedTrackInfo, i)) {
return true;
}
}
return false;
}
/**
* Creates a dialog for a given {@link DefaultTrackSelector}, whose parameters will be
* automatically updated when tracks are selected.
*
* @param trackSelector The {@link DefaultTrackSelector}.
* @param onDismissListener A {@link DialogInterface.OnDismissListener} to call when the dialog is
* dismissed.
*/
public static TrackSelectionDialog createForTrackSelector(
DefaultTrackSelector trackSelector, DialogInterface.OnDismissListener onDismissListener) {
MappedTrackInfo mappedTrackInfo =
Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo());
TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog();
DefaultTrackSelector.Parameters parameters = trackSelector.getParameters();
trackSelectionDialog.init(
/* titleId= */ R.string.track_selection_title,
mappedTrackInfo,
/* initialParameters = */ parameters,
/* allowAdaptiveSelections =*/ true,
/* allowMultipleOverrides= */ false,
/* onClickListener= */ (dialog, which) -> {
DefaultTrackSelector.ParametersBuilder builder = parameters.buildUpon();
for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
builder
.clearSelectionOverrides(/* rendererIndex= */ i)
.setRendererDisabled(
/* rendererIndex= */ i,
trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i));
List<SelectionOverride> overrides =
trackSelectionDialog.getOverrides(/* rendererIndex= */ i);
if (!overrides.isEmpty()) {
builder.setSelectionOverride(
/* rendererIndex= */ i,
mappedTrackInfo.getTrackGroups(/* rendererIndex= */ i),
overrides.get(0));
}
}
trackSelector.setParameters(builder);
},
onDismissListener);
return trackSelectionDialog;
}
/**
* Creates a dialog for given {@link MappedTrackInfo} and {@link DefaultTrackSelector.Parameters}.
*
* @param titleId The resource id of the dialog title.
* @param mappedTrackInfo The {@link MappedTrackInfo} to display.
* @param initialParameters The {@link DefaultTrackSelector.Parameters} describing the initial
* track selection.
* @param allowAdaptiveSelections Whether adaptive selections (consisting of more than one track)
* can be made.
* @param allowMultipleOverrides Whether tracks from multiple track groups can be selected.
* @param onClickListener {@link DialogInterface.OnClickListener} called when tracks are selected.
* @param onDismissListener {@link DialogInterface.OnDismissListener} called when the dialog is
* dismissed.
*/
public static TrackSelectionDialog createForMappedTrackInfoAndParameters(
int titleId,
MappedTrackInfo mappedTrackInfo,
DefaultTrackSelector.Parameters initialParameters,
boolean allowAdaptiveSelections,
boolean allowMultipleOverrides,
DialogInterface.OnClickListener onClickListener,
DialogInterface.OnDismissListener onDismissListener) {
TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog();
trackSelectionDialog.init(
titleId,
mappedTrackInfo,
initialParameters,
allowAdaptiveSelections,
allowMultipleOverrides,
onClickListener,
onDismissListener);
return trackSelectionDialog;
}
public TrackSelectionDialog() {
tabFragments = new SparseArray<>();
tabTrackTypes = new ArrayList<>();
// Retain instance across activity re-creation to prevent losing access to init data.
setRetainInstance(true);
}
private void init(
int titleId,
MappedTrackInfo mappedTrackInfo,
DefaultTrackSelector.Parameters initialParameters,
boolean allowAdaptiveSelections,
boolean allowMultipleOverrides,
DialogInterface.OnClickListener onClickListener,
DialogInterface.OnDismissListener onDismissListener) {
this.titleId = titleId;
this.onClickListener = onClickListener;
this.onDismissListener = onDismissListener;
for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
if (showTabForRenderer(mappedTrackInfo, i)) {
int trackType = mappedTrackInfo.getRendererType(/* rendererIndex= */ i);
TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i);
TrackSelectionViewFragment tabFragment = new TrackSelectionViewFragment();
tabFragment.init(
mappedTrackInfo,
/* rendererIndex= */ i,
initialParameters.getRendererDisabled(/* rendererIndex= */ i),
initialParameters.getSelectionOverride(/* rendererIndex= */ i, trackGroupArray),
allowAdaptiveSelections,
allowMultipleOverrides);
tabFragments.put(i, tabFragment);
tabTrackTypes.add(trackType);
}
}
}
/**
* Returns whether a renderer is disabled.
*
* @param rendererIndex Renderer index.
* @return Whether the renderer is disabled.
*/
public boolean getIsDisabled(int rendererIndex) {
TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex);
return rendererView != null && rendererView.isDisabled;
}
/**
* Returns the list of selected track selection overrides for the specified renderer. There will
* be at most one override for each track group.
*
* @param rendererIndex Renderer index.
* @return The list of track selection overrides for this renderer.
*/
public List<SelectionOverride> getOverrides(int rendererIndex) {
TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex);
return rendererView == null ? Collections.emptyList() : rendererView.overrides;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
// We need to own the view to let tab layout work correctly on all API levels. We can't use
// AlertDialog because it owns the view itself, so we use AppCompatDialog instead, themed using
// the AlertDialog theme overlay with force-enabled title.
AppCompatDialog dialog =
new AppCompatDialog(getActivity(), R.style.TrackSelectionDialogThemeOverlay);
dialog.setTitle(titleId);
return dialog;
}
@Override
public void onDismiss(DialogInterface dialog) {
super.onDismiss(dialog);
onDismissListener.onDismiss(dialog);
}
@Nullable
@Override
public View onCreateView(
LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View dialogView = inflater.inflate(R.layout.track_selection_dialog, container, false);
TabLayout tabLayout = dialogView.findViewById(R.id.track_selection_dialog_tab_layout);
ViewPager viewPager = dialogView.findViewById(R.id.track_selection_dialog_view_pager);
Button cancelButton = dialogView.findViewById(R.id.track_selection_dialog_cancel_button);
Button okButton = dialogView.findViewById(R.id.track_selection_dialog_ok_button);
viewPager.setAdapter(new FragmentAdapter(getChildFragmentManager()));
tabLayout.setupWithViewPager(viewPager);
tabLayout.setVisibility(tabFragments.size() > 1 ? View.VISIBLE : View.GONE);
cancelButton.setOnClickListener(view -> dismiss());
okButton.setOnClickListener(
view -> {
onClickListener.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE);
dismiss();
});
return dialogView;
}
private static boolean showTabForRenderer(MappedTrackInfo mappedTrackInfo, int rendererIndex) {
TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex);
if (trackGroupArray.length == 0) {
return false;
}
int trackType = mappedTrackInfo.getRendererType(rendererIndex);
return isSupportedTrackType(trackType);
}
private static boolean isSupportedTrackType(int trackType) {
switch (trackType) {
case C.TRACK_TYPE_VIDEO:
case C.TRACK_TYPE_AUDIO:
case C.TRACK_TYPE_TEXT:
return true;
default:
return false;
}
}
private static String getTrackTypeString(Resources resources, int trackType) {
switch (trackType) {
case C.TRACK_TYPE_VIDEO:
return resources.getString(R.string.exo_track_selection_title_video);
case C.TRACK_TYPE_AUDIO:
return resources.getString(R.string.exo_track_selection_title_audio);
case C.TRACK_TYPE_TEXT:
return resources.getString(R.string.exo_track_selection_title_text);
default:
throw new IllegalArgumentException();
}
}
private final class FragmentAdapter extends FragmentPagerAdapter {
public FragmentAdapter(FragmentManager fragmentManager) {
super(fragmentManager);
}
@Override
public Fragment getItem(int position) {
return tabFragments.valueAt(position);
}
@Override
public int getCount() {
return tabFragments.size();
}
@Nullable
@Override
public CharSequence getPageTitle(int position) {
return getTrackTypeString(getResources(), tabTrackTypes.get(position));
}
}
/** Fragment to show a track selection in tab of the track selection dialog. */
public static final class TrackSelectionViewFragment extends Fragment
implements TrackSelectionView.TrackSelectionListener {
private MappedTrackInfo mappedTrackInfo;
private int rendererIndex;
private boolean allowAdaptiveSelections;
private boolean allowMultipleOverrides;
/* package */ boolean isDisabled;
/* package */ List<SelectionOverride> overrides;
public TrackSelectionViewFragment() {
// Retain instance across activity re-creation to prevent losing access to init data.
setRetainInstance(true);
}
public void init(
MappedTrackInfo mappedTrackInfo,
int rendererIndex,
boolean initialIsDisabled,
@Nullable SelectionOverride initialOverride,
boolean allowAdaptiveSelections,
boolean allowMultipleOverrides) {
this.mappedTrackInfo = mappedTrackInfo;
this.rendererIndex = rendererIndex;
this.isDisabled = initialIsDisabled;
this.overrides =
initialOverride == null
? Collections.emptyList()
: Collections.singletonList(initialOverride);
this.allowAdaptiveSelections = allowAdaptiveSelections;
this.allowMultipleOverrides = allowMultipleOverrides;
}
@Nullable
@Override
public View onCreateView(
LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View rootView =
inflater.inflate(
R.layout.exo_track_selection_dialog, container, /* attachToRoot= */ false);
TrackSelectionView trackSelectionView = rootView.findViewById(R.id.exo_track_selection_view);
trackSelectionView.setShowDisableOption(true);
trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides);
trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections);
trackSelectionView.init(
mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this);
return rootView;
}
@Override
public void onTrackSelectionChanged(boolean isDisabled, List<SelectionOverride> overrides) {
this.isDisabled = isDisabled;
this.overrides = overrides;
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 263 B

View file

@ -42,7 +42,15 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone"/>
android:visibility="gone">
<Button android:id="@+id/select_tracks_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/track_selection_title"
android:enabled="false"/>
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager.widget.ViewPager
android:id="@+id/track_selection_dialog_view_pager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<com.google.android.material.tabs.TabLayout
android:id="@+id/track_selection_dialog_tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="fill"
app:tabMode="fixed"/>
</androidx.viewpager.widget.ViewPager>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end">
<Button
android:id="@+id/track_selection_dialog_cancel_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/cancel"
style="?android:attr/borderlessButtonStyle"/>
<Button
android:id="@+id/track_selection_dialog_ok_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/ok"
style="?android:attr/borderlessButtonStyle"/>
</LinearLayout>
</LinearLayout>

View file

@ -13,13 +13,14 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/prefer_extension_decoders"
android:title="@string/prefer_extension_decoders"
android:showAsAction="never"
android:checkable="true"/>
android:title="@string/prefer_extension_decoders"
android:checkable="true"
app:showAsAction="never"/>
<item android:id="@+id/random_abr"
android:title="@string/random_abr"
android:showAsAction="never"
android:checkable="true"/>
android:title="@string/random_abr"
android:checkable="true"
app:showAsAction="never"/>
</menu>

View file

@ -17,6 +17,8 @@
<string name="application_name">ExoPlayer</string>
<string name="track_selection_title">Select tracks</string>
<string name="unexpected_intent_action">Unexpected intent action: <xliff:g id="action">%1$s</xliff:g></string>
<string name="error_cleartext_not_permitted">Cleartext traffic not permitted</string>
@ -51,6 +53,8 @@
<string name="ima_not_loaded">Playing sample without ads, as the IMA extension was not loaded</string>
<string name="unsupported_ads_in_concatenation">Playing sample without ads, as ads are not supported in concatenations</string>
<string name="download_start_error">Failed to start download</string>
<string name="download_playlist_unsupported">This demo app does not support downloading playlists</string>

View file

@ -15,8 +15,11 @@
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<style name="PlayerTheme" parent="android:Theme.Holo">
<item name="android:windowNoTitle">true</item>
<style name="TrackSelectionDialogThemeOverlay" parent="ThemeOverlay.AppCompat.Dialog.Alert">
<item name="windowNoTitle">false</item>
</style>
<style name="PlayerTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
</style>

View file

@ -5,7 +5,7 @@
The cast extension is a [Player][] implementation that controls playback on a
Cast receiver app.
[Player]: https://google.github.io/ExoPlayer/doc/reference/index.html?com/google/android/exoplayer2/Player.html
[Player]: https://exoplayer.dev/doc/reference/index.html?com/google/android/exoplayer2/Player.html
## Getting the extension ##

View file

@ -16,7 +16,6 @@ apply plugin: 'com.android.library'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -24,30 +23,22 @@ android {
}
defaultConfig {
minSdkVersion 14
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
consumerProguardFiles 'proguard-rules.txt'
}
testOptions.unitTests.includeAndroidResources = true
}
dependencies {
api 'com.google.android.gms:play-services-cast-framework:16.0.1'
api 'com.google.android.gms:play-services-cast-framework:17.0.0'
implementation 'androidx.annotation:annotation:1.1.0'
implementation project(modulePrefix + 'library-core')
implementation project(modulePrefix + 'library-ui')
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkVersion
testImplementation project(modulePrefix + 'testutils')
testImplementation 'junit:junit:' + junitVersion
testImplementation 'org.mockito:mockito-core:' + mockitoVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
testImplementation project(modulePrefix + 'testutils-robolectric')
// These dependencies are necessary to force the supportLibraryVersion of
// com.android.support:support-v4, com.android.support:appcompat-v7 and
// com.android.support:mediarouter-v7 to be used. Else older versions are
// used, for example via:
// com.google.android.gms:play-services-cast-framework:15.0.1
// |-- com.android.support:mediarouter-v7:26.1.0
api 'com.android.support:support-v4:' + supportLibraryVersion
api 'com.android.support:mediarouter-v7:' + supportLibraryVersion
api 'com.android.support:recyclerview-v7:' + supportLibraryVersion
}
ext {

View file

@ -1,4 +0,0 @@
# Proguard rules specific to the Cast extension.
# DefaultCastOptionsProvider is commonly referred to only by the app's manifest.
-keep class com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider

View file

@ -15,9 +15,10 @@
*/
package com.google.android.exoplayer2.ext.cast;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.BasePlayer;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
@ -29,8 +30,8 @@ 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.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.android.gms.cast.CastStatusCodes;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaQueueItem;
@ -44,41 +45,27 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult;
import com.google.android.gms.common.api.PendingResult;
import com.google.android.gms.common.api.ResultCallback;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* {@link Player} implementation that communicates with a Cast receiver app.
*
* <p>The behavior of this class depends on the underlying Cast session, which is obtained from the
* Cast context passed to {@link #CastPlayer}. To keep track of the session,
* {@link #isCastSessionAvailable()} can be queried and {@link SessionAvailabilityListener} can be
* implemented and attached to the player.</p>
* Cast context passed to {@link #CastPlayer}. To keep track of the session, {@link
* #isCastSessionAvailable()} can be queried and {@link SessionAvailabilityListener} can be
* implemented and attached to the player.
*
* <p> If no session is available, the player state will remain unchanged and calls to methods that
* <p>If no session is available, the player state will remain unchanged and calls to methods that
* alter it will be ignored. Querying the player state is possible even when no session is
* available, in which case, the last observed receiver app state is reported.</p>
* available, in which case, the last observed receiver app state is reported.
*
* <p>Methods should be called on the application's main thread.</p>
* <p>Methods should be called on the application's main thread.
*/
public final class CastPlayer implements Player {
/**
* Listener of changes in the cast session availability.
*/
public interface SessionAvailabilityListener {
/**
* Called when a cast session becomes available to the player.
*/
void onCastSessionAvailable();
/**
* Called when the cast session becomes unavailable.
*/
void onCastSessionUnavailable();
}
public final class CastPlayer extends BasePlayer {
private static final String TAG = "CastPlayer";
@ -94,24 +81,24 @@ public final class CastPlayer implements Player {
private final CastContext castContext;
// TODO: Allow custom implementations of CastTimelineTracker.
private final CastTimelineTracker timelineTracker;
private final Timeline.Window window;
private final Timeline.Period period;
private RemoteMediaClient remoteMediaClient;
// Result callbacks.
private final StatusListener statusListener;
private final SeekResultCallback seekResultCallback;
// Listeners.
private final CopyOnWriteArraySet<EventListener> listeners;
private SessionAvailabilityListener sessionAvailabilityListener;
// Listeners and notification.
private final CopyOnWriteArrayList<ListenerHolder> listeners;
private final ArrayList<ListenerNotificationTask> notificationsBatch;
private final ArrayDeque<ListenerNotificationTask> ongoingNotificationsTasks;
@Nullable private SessionAvailabilityListener sessionAvailabilityListener;
// Internal state.
@Nullable private RemoteMediaClient remoteMediaClient;
private CastTimeline currentTimeline;
private TrackGroupArray currentTrackGroups;
private TrackSelectionArray currentTrackSelection;
private int playbackState;
@Player.State private int playbackState;
private int repeatMode;
private int currentWindowIndex;
private boolean playWhenReady;
@ -127,11 +114,12 @@ public final class CastPlayer implements Player {
public CastPlayer(CastContext castContext) {
this.castContext = castContext;
timelineTracker = new CastTimelineTracker();
window = new Timeline.Window();
period = new Timeline.Period();
statusListener = new StatusListener();
seekResultCallback = new SeekResultCallback();
listeners = new CopyOnWriteArraySet<>();
listeners = new CopyOnWriteArrayList<>();
notificationsBatch = new ArrayList<>();
ongoingNotificationsTasks = new ArrayDeque<>();
SessionManager sessionManager = castContext.getSessionManager();
sessionManager.addSessionManagerListener(statusListener, CastSession.class);
@ -159,6 +147,7 @@ public final class CastPlayer implements Player {
* starts at position 0.
* @return The Cast {@code PendingResult}, or null if no session is available.
*/
@Nullable
public PendingResult<MediaChannelResult> loadItem(MediaQueueItem item, long positionMs) {
return loadItems(new MediaQueueItem[] {item}, 0, positionMs, REPEAT_MODE_OFF);
}
@ -174,8 +163,9 @@ public final class CastPlayer implements Player {
* @param repeatMode The repeat mode for the created media queue.
* @return The Cast {@code PendingResult}, or null if no session is available.
*/
public PendingResult<MediaChannelResult> loadItems(MediaQueueItem[] items, int startIndex,
long positionMs, @RepeatMode int repeatMode) {
@Nullable
public PendingResult<MediaChannelResult> loadItems(
MediaQueueItem[] items, int startIndex, long positionMs, @RepeatMode int repeatMode) {
if (remoteMediaClient != null) {
positionMs = positionMs != C.TIME_UNSET ? positionMs : 0;
waitingForInitialTimeline = true;
@ -191,6 +181,7 @@ public final class CastPlayer implements Player {
* @param items The items to append.
* @return The Cast {@code PendingResult}, or null if no media queue exists.
*/
@Nullable
public PendingResult<MediaChannelResult> addItems(MediaQueueItem... items) {
return addItems(MediaQueueItem.INVALID_ITEM_ID, items);
}
@ -205,6 +196,7 @@ public final class CastPlayer implements Player {
* @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code
* periodId} exist.
*/
@Nullable
public PendingResult<MediaChannelResult> addItems(int periodId, MediaQueueItem... items) {
if (getMediaStatus() != null && (periodId == MediaQueueItem.INVALID_ITEM_ID
|| currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET)) {
@ -222,6 +214,7 @@ public final class CastPlayer implements Player {
* @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code
* periodId} exist.
*/
@Nullable
public PendingResult<MediaChannelResult> removeItem(int periodId) {
if (getMediaStatus() != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) {
return remoteMediaClient.queueRemoveItem(periodId, null);
@ -240,6 +233,7 @@ public final class CastPlayer implements Player {
* @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code
* periodId} exist.
*/
@Nullable
public PendingResult<MediaChannelResult> moveItem(int periodId, int newIndex) {
Assertions.checkArgument(newIndex >= 0 && newIndex < currentTimeline.getPeriodCount());
if (getMediaStatus() != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) {
@ -257,6 +251,7 @@ public final class CastPlayer implements Player {
* @return The item that corresponds to the period with the given id, or null if no media queue or
* period with id {@code periodId} exist.
*/
@Nullable
public MediaQueueItem getItem(int periodId) {
MediaStatus mediaStatus = getMediaStatus();
return mediaStatus != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET
@ -275,45 +270,66 @@ public final class CastPlayer implements Player {
/**
* Sets a listener for updates on the cast session availability.
*
* @param listener The {@link SessionAvailabilityListener}.
* @param listener The {@link SessionAvailabilityListener}, or null to clear the listener.
*/
public void setSessionAvailabilityListener(SessionAvailabilityListener listener) {
public void setSessionAvailabilityListener(@Nullable SessionAvailabilityListener listener) {
sessionAvailabilityListener = listener;
}
// Player implementation.
@Override
@Nullable
public AudioComponent getAudioComponent() {
return null;
}
@Override
@Nullable
public VideoComponent getVideoComponent() {
return null;
}
@Override
@Nullable
public TextComponent getTextComponent() {
return null;
}
@Override
@Nullable
public MetadataComponent getMetadataComponent() {
return null;
}
@Override
public Looper getApplicationLooper() {
return Looper.getMainLooper();
}
@Override
public void addListener(EventListener listener) {
listeners.add(listener);
listeners.addIfAbsent(new ListenerHolder(listener));
}
@Override
public void removeListener(EventListener listener) {
listeners.remove(listener);
for (ListenerHolder listenerHolder : listeners) {
if (listenerHolder.listener.equals(listener)) {
listenerHolder.release();
listeners.remove(listenerHolder);
}
}
}
@Override
@Player.State
public int getPlaybackState() {
return playbackState;
}
@Override
@Nullable
public ExoPlaybackException getPlaybackError() {
return null;
}
@ -335,21 +351,6 @@ public final class CastPlayer implements Player {
return playWhenReady;
}
@Override
public void seekToDefaultPosition() {
seekTo(0);
}
@Override
public void seekToDefaultPosition(int windowIndex) {
seekTo(windowIndex, 0);
}
@Override
public void seekTo(long positionMs) {
seekTo(getCurrentWindowIndex(), positionMs);
}
@Override
public void seekTo(int windowIndex, long positionMs) {
MediaStatus mediaStatus = getMediaStatus();
@ -366,14 +367,13 @@ public final class CastPlayer implements Player {
pendingSeekCount++;
pendingSeekWindowIndex = windowIndex;
pendingSeekPositionMs = positionMs;
for (EventListener listener : listeners) {
listener.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK);
}
notificationsBatch.add(
new ListenerNotificationTask(
listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)));
} else if (pendingSeekCount == 0) {
for (EventListener listener : listeners) {
listener.onSeekProcessed();
}
notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed));
}
flushNotifications();
}
@Override
@ -386,11 +386,6 @@ public final class CastPlayer implements Player {
return PlaybackParameters.DEFAULT;
}
@Override
public void stop() {
stop(/* reset= */ false);
}
@Override
public void stop(boolean reset) {
playbackState = STATE_IDLE;
@ -465,11 +460,6 @@ public final class CastPlayer implements Player {
return currentTimeline;
}
@Override
@Nullable public Object getCurrentManifest() {
return null;
}
@Override
public int getCurrentPeriodIndex() {
return getCurrentWindowIndex();
@ -480,32 +470,11 @@ public final class CastPlayer implements Player {
return pendingSeekWindowIndex != C.INDEX_UNSET ? pendingSeekWindowIndex : currentWindowIndex;
}
@Override
public int getNextWindowIndex() {
return currentTimeline.isEmpty() ? C.INDEX_UNSET
: currentTimeline.getNextWindowIndex(getCurrentWindowIndex(), repeatMode, false);
}
@Override
public int getPreviousWindowIndex() {
return currentTimeline.isEmpty() ? C.INDEX_UNSET
: currentTimeline.getPreviousWindowIndex(getCurrentWindowIndex(), repeatMode, false);
}
@Override
public @Nullable Object getCurrentTag() {
int windowIndex = getCurrentWindowIndex();
return windowIndex > currentTimeline.getWindowCount()
? null
: currentTimeline.getWindow(windowIndex, window, /* setTag= */ true).tag;
}
// TODO: Fill the cast timeline information with ProgressListener's duration updates.
// See [Internal: b/65152553].
@Override
public long getDuration() {
return currentTimeline.isEmpty() ? C.TIME_UNSET
: currentTimeline.getWindow(getCurrentWindowIndex(), window).getDurationMs();
return getContentDuration();
}
@Override
@ -522,15 +491,6 @@ public final class CastPlayer implements Player {
return getCurrentPosition();
}
@Override
public int getBufferedPercentage() {
long position = getBufferedPosition();
long duration = getDuration();
return position == C.TIME_UNSET || duration == C.TIME_UNSET
? 0
: duration == 0 ? 100 : Util.constrainValue((int) ((position * 100) / duration), 0, 100);
}
@Override
public long getTotalBufferedDuration() {
long bufferedPosition = getBufferedPosition();
@ -540,18 +500,6 @@ public final class CastPlayer implements Player {
: bufferedPosition - currentPosition;
}
@Override
public boolean isCurrentWindowDynamic() {
return !currentTimeline.isEmpty()
&& currentTimeline.getWindow(getCurrentWindowIndex(), window).isDynamic;
}
@Override
public boolean isCurrentWindowSeekable() {
return !currentTimeline.isEmpty()
&& currentTimeline.getWindow(getCurrentWindowIndex(), window).isSeekable;
}
@Override
public boolean isPlayingAd() {
return false;
@ -567,11 +515,6 @@ public final class CastPlayer implements Player {
return C.INDEX_UNSET;
}
@Override
public long getContentDuration() {
return getDuration();
}
@Override
public boolean isLoading() {
return false;
@ -589,7 +532,7 @@ public final class CastPlayer implements Player {
// Internal methods.
public void updateInternalState() {
private void updateInternalState() {
if (remoteMediaClient == null) {
// There is no session. We leave the state of the player as it is now.
return;
@ -601,30 +544,40 @@ public final class CastPlayer implements Player {
|| this.playWhenReady != playWhenReady) {
this.playbackState = playbackState;
this.playWhenReady = playWhenReady;
for (EventListener listener : listeners) {
listener.onPlayerStateChanged(this.playWhenReady, this.playbackState);
}
notificationsBatch.add(
new ListenerNotificationTask(
listener -> listener.onPlayerStateChanged(this.playWhenReady, this.playbackState)));
}
@RepeatMode int repeatMode = fetchRepeatMode(remoteMediaClient);
if (this.repeatMode != repeatMode) {
this.repeatMode = repeatMode;
for (EventListener listener : listeners) {
listener.onRepeatModeChanged(repeatMode);
}
}
int currentWindowIndex = fetchCurrentWindowIndex(getMediaStatus());
if (this.currentWindowIndex != currentWindowIndex && pendingSeekCount == 0) {
this.currentWindowIndex = currentWindowIndex;
for (EventListener listener : listeners) {
listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION);
}
}
if (updateTracksAndSelections()) {
for (EventListener listener : listeners) {
listener.onTracksChanged(currentTrackGroups, currentTrackSelection);
}
notificationsBatch.add(
new ListenerNotificationTask(listener -> listener.onRepeatModeChanged(this.repeatMode)));
}
maybeUpdateTimelineAndNotify();
int currentWindowIndex = C.INDEX_UNSET;
MediaQueueItem currentItem = remoteMediaClient.getCurrentItem();
if (currentItem != null) {
currentWindowIndex = currentTimeline.getIndexOfPeriod(currentItem.getItemId());
}
if (currentWindowIndex == C.INDEX_UNSET) {
// The timeline is empty. Fall back to index 0, which is what ExoPlayer would do.
currentWindowIndex = 0;
}
if (this.currentWindowIndex != currentWindowIndex && pendingSeekCount == 0) {
this.currentWindowIndex = currentWindowIndex;
notificationsBatch.add(
new ListenerNotificationTask(
listener ->
listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION)));
}
if (updateTracksAndSelections()) {
notificationsBatch.add(
new ListenerNotificationTask(
listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelection)));
}
flushNotifications();
}
private void maybeUpdateTimelineAndNotify() {
@ -632,9 +585,9 @@ public final class CastPlayer implements Player {
@Player.TimelineChangeReason int reason = waitingForInitialTimeline
? Player.TIMELINE_CHANGE_REASON_PREPARED : Player.TIMELINE_CHANGE_REASON_DYNAMIC;
waitingForInitialTimeline = false;
for (EventListener listener : listeners) {
listener.onTimelineChanged(currentTimeline, null, reason);
}
notificationsBatch.add(
new ListenerNotificationTask(
listener -> listener.onTimelineChanged(currentTimeline, reason)));
}
}
@ -645,7 +598,9 @@ public final class CastPlayer implements Player {
CastTimeline oldTimeline = currentTimeline;
MediaStatus status = getMediaStatus();
currentTimeline =
status != null ? timelineTracker.getCastTimeline(status) : CastTimeline.EMPTY_CAST_TIMELINE;
status != null
? timelineTracker.getCastTimeline(remoteMediaClient)
: CastTimeline.EMPTY_CAST_TIMELINE;
return !oldTimeline.equals(currentTimeline);
}
@ -722,7 +677,8 @@ public final class CastPlayer implements Player {
}
}
private @Nullable MediaStatus getMediaStatus() {
@Nullable
private MediaStatus getMediaStatus() {
return remoteMediaClient != null ? remoteMediaClient.getMediaStatus() : null;
}
@ -770,16 +726,6 @@ public final class CastPlayer implements Player {
}
}
/**
* Retrieves the current item index from {@code mediaStatus} and maps it into a window index. If
* there is no media session, returns 0.
*/
private static int fetchCurrentWindowIndex(@Nullable MediaStatus mediaStatus) {
Integer currentItemId = mediaStatus != null
? mediaStatus.getIndexById(mediaStatus.getCurrentItemId()) : null;
return currentItemId != null ? currentItemId : 0;
}
private static boolean isTrackActive(long id, long[] activeTrackIds) {
for (long activeTrackId : activeTrackIds) {
if (activeTrackId == id) {
@ -895,7 +841,23 @@ public final class CastPlayer implements Player {
}
// Result callbacks hooks.
// Internal methods.
private void flushNotifications() {
boolean recursiveNotification = !ongoingNotificationsTasks.isEmpty();
ongoingNotificationsTasks.addAll(notificationsBatch);
notificationsBatch.clear();
if (recursiveNotification) {
// This will be handled once the current notification task is finished.
return;
}
while (!ongoingNotificationsTasks.isEmpty()) {
ongoingNotificationsTasks.peekFirst().execute();
ongoingNotificationsTasks.removeFirst();
}
}
// Internal classes.
private final class SeekResultCallback implements ResultCallback<MediaChannelResult> {
@ -909,9 +871,25 @@ public final class CastPlayer implements Player {
if (--pendingSeekCount == 0) {
pendingSeekWindowIndex = C.INDEX_UNSET;
pendingSeekPositionMs = C.TIME_UNSET;
for (EventListener listener : listeners) {
listener.onSeekProcessed();
}
notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed));
flushNotifications();
}
}
}
private final class ListenerNotificationTask {
private final Iterator<ListenerHolder> listenersSnapshot;
private final ListenerInvocation listenerInvocation;
private ListenerNotificationTask(ListenerInvocation listenerInvocation) {
this.listenersSnapshot = listeners.iterator();
this.listenerInvocation = listenerInvocation;
}
public void execute() {
while (listenersSnapshot.hasNext()) {
listenersSnapshot.next().invoke(listenerInvocation);
}
}
}

View file

@ -15,24 +15,66 @@
*/
package com.google.android.exoplayer2.ext.cast;
import android.support.annotation.Nullable;
import androidx.annotation.Nullable;
import android.util.SparseArray;
import android.util.SparseIntArray;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Timeline;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaQueueItem;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* A {@link Timeline} for Cast media queues.
*/
/* package */ final class CastTimeline extends Timeline {
/** Holds {@link Timeline} related data for a Cast media item. */
public static final class ItemData {
/** Holds no media information. */
public static final ItemData EMPTY = new ItemData();
/** The duration of the item in microseconds, or {@link C#TIME_UNSET} if unknown. */
public final long durationUs;
/**
* The default start position of the item in microseconds, or {@link C#TIME_UNSET} if unknown.
*/
public final long defaultPositionUs;
private ItemData() {
this(/* durationUs= */ C.TIME_UNSET, /* defaultPositionUs */ C.TIME_UNSET);
}
/**
* Creates an instance.
*
* @param durationUs See {@link #durationsUs}.
* @param defaultPositionUs See {@link #defaultPositionUs}.
*/
public ItemData(long durationUs, long defaultPositionUs) {
this.durationUs = durationUs;
this.defaultPositionUs = defaultPositionUs;
}
/** Returns an instance with the given {@link #durationsUs}. */
public ItemData copyWithDurationUs(long durationUs) {
if (durationUs == this.durationUs) {
return this;
}
return new ItemData(durationUs, defaultPositionUs);
}
/** Returns an instance with the given {@link #defaultPositionsUs}. */
public ItemData copyWithDefaultPositionUs(long defaultPositionUs) {
if (defaultPositionUs == this.defaultPositionUs) {
return this;
}
return new ItemData(durationUs, defaultPositionUs);
}
}
/** {@link Timeline} for a cast queue that has no items. */
public static final CastTimeline EMPTY_CAST_TIMELINE =
new CastTimeline(Collections.emptyList(), Collections.emptyMap());
new CastTimeline(new int[0], new SparseArray<>());
private final SparseIntArray idsToIndex;
private final int[] ids;
@ -40,28 +82,23 @@ import java.util.Map;
private final long[] defaultPositionsUs;
/**
* @param items A list of cast media queue items to represent.
* @param contentIdToDurationUsMap A map of content id to duration in microseconds.
* Creates a Cast timeline from the given data.
*
* @param itemIds The ids of the items in the timeline.
* @param itemIdToData Maps item ids to {@link ItemData}.
*/
public CastTimeline(List<MediaQueueItem> items, Map<String, Long> contentIdToDurationUsMap) {
int itemCount = items.size();
int index = 0;
public CastTimeline(int[] itemIds, SparseArray<ItemData> itemIdToData) {
int itemCount = itemIds.length;
idsToIndex = new SparseIntArray(itemCount);
ids = new int[itemCount];
ids = Arrays.copyOf(itemIds, itemCount);
durationsUs = new long[itemCount];
defaultPositionsUs = new long[itemCount];
for (MediaQueueItem item : items) {
int itemId = item.getItemId();
ids[index] = itemId;
idsToIndex.put(itemId, index);
MediaInfo mediaInfo = item.getMedia();
String contentId = mediaInfo.getContentId();
durationsUs[index] =
contentIdToDurationUsMap.containsKey(contentId)
? contentIdToDurationUsMap.get(contentId)
: CastUtils.getStreamDurationUs(mediaInfo);
defaultPositionsUs[index] = (long) (item.getStartTime() * C.MICROS_PER_SECOND);
index++;
for (int i = 0; i < ids.length; i++) {
int id = ids[i];
idsToIndex.put(id, i);
ItemData data = itemIdToData.get(id, ItemData.EMPTY);
durationsUs[i] = data.durationUs;
defaultPositionsUs[i] = data.defaultPositionUs;
}
}
@ -80,6 +117,7 @@ import java.util.Map;
Object tag = setTag ? ids[windowIndex] : null;
return window.set(
tag,
/* manifest= */ null,
/* presentationStartTimeMs= */ C.TIME_UNSET,
/* windowStartTimeMs= */ C.TIME_UNSET,
/* isSeekable= */ !isDynamic,
@ -108,7 +146,7 @@ import java.util.Map;
}
@Override
public Object getUidOfPeriod(int periodIndex) {
public Integer getUidOfPeriod(int periodIndex) {
return ids[periodIndex];
}

View file

@ -15,53 +15,84 @@
*/
package com.google.android.exoplayer2.ext.cast;
import com.google.android.gms.cast.MediaInfo;
import android.util.SparseArray;
import com.google.android.exoplayer2.C;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.MediaStatus;
import java.util.HashMap;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import java.util.HashSet;
import java.util.List;
/**
* Creates {@link CastTimeline}s from cast receiver app media status.
* Creates {@link CastTimeline CastTimelines} from cast receiver app status updates.
*
* <p>This class keeps track of the duration reported by the current item to fill any missing
* durations in the media queue items [See internal: b/65152553].
*/
/* package */ final class CastTimelineTracker {
private final HashMap<String, Long> contentIdToDurationUsMap;
private final HashSet<String> scratchContentIdSet;
private final SparseArray<CastTimeline.ItemData> itemIdToData;
public CastTimelineTracker() {
contentIdToDurationUsMap = new HashMap<>();
scratchContentIdSet = new HashSet<>();
itemIdToData = new SparseArray<>();
}
/**
* Returns a {@link CastTimeline} that represent the given {@code status}.
* Returns a {@link CastTimeline} that represents the state of the given {@code
* remoteMediaClient}.
*
* @param status The Cast media status.
* @return A {@link CastTimeline} that represent the given {@code status}.
* <p>Returned timelines may contain values obtained from {@code remoteMediaClient} in previous
* invocations of this method.
*
* @param remoteMediaClient The Cast media client.
* @return A {@link CastTimeline} that represents the given {@code remoteMediaClient} status.
*/
public CastTimeline getCastTimeline(MediaStatus status) {
MediaInfo mediaInfo = status.getMediaInfo();
List<MediaQueueItem> items = status.getQueueItems();
removeUnusedDurationEntries(items);
if (mediaInfo != null) {
String contentId = mediaInfo.getContentId();
long durationUs = CastUtils.getStreamDurationUs(mediaInfo);
contentIdToDurationUsMap.put(contentId, durationUs);
public CastTimeline getCastTimeline(RemoteMediaClient remoteMediaClient) {
int[] itemIds = remoteMediaClient.getMediaQueue().getItemIds();
if (itemIds.length > 0) {
// Only remove unused items when there is something in the queue to avoid removing all entries
// if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
removeUnusedItemDataEntries(itemIds);
}
return new CastTimeline(items, contentIdToDurationUsMap);
// TODO: Reset state when the app instance changes [Internal ref: b/129672468].
MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
if (mediaStatus == null) {
return CastTimeline.EMPTY_CAST_TIMELINE;
}
int currentItemId = mediaStatus.getCurrentItemId();
long durationUs = CastUtils.getStreamDurationUs(mediaStatus.getMediaInfo());
itemIdToData.put(
currentItemId,
itemIdToData
.get(currentItemId, CastTimeline.ItemData.EMPTY)
.copyWithDurationUs(durationUs));
for (MediaQueueItem item : mediaStatus.getQueueItems()) {
int itemId = item.getItemId();
itemIdToData.put(
itemId,
itemIdToData
.get(itemId, CastTimeline.ItemData.EMPTY)
.copyWithDefaultPositionUs((long) (item.getStartTime() * C.MICROS_PER_SECOND)));
}
return new CastTimeline(itemIds, itemIdToData);
}
private void removeUnusedDurationEntries(List<MediaQueueItem> items) {
scratchContentIdSet.clear();
for (MediaQueueItem item : items) {
scratchContentIdSet.add(item.getMedia().getContentId());
private void removeUnusedItemDataEntries(int[] itemIds) {
HashSet<Integer> scratchItemIds = new HashSet<>(/* initialCapacity= */ itemIds.length * 2);
for (int id : itemIds) {
scratchItemIds.add(id);
}
int index = 0;
while (index < itemIdToData.size()) {
if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
itemIdToData.removeAt(index);
} else {
index++;
}
}
contentIdToDurationUsMap.keySet().retainAll(scratchContentIdSet);
}
}

View file

@ -15,6 +15,7 @@
*/
package com.google.android.exoplayer2.ext.cast;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Format;
import com.google.android.gms.cast.CastStatusCodes;
@ -31,11 +32,13 @@ import com.google.android.gms.cast.MediaTrack;
* unknown or not applicable.
*
* @param mediaInfo The media info to get the duration from.
* @return The duration in microseconds.
* @return The duration in microseconds, or {@link C#TIME_UNSET} if unknown or not applicable.
*/
public static long getStreamDurationUs(MediaInfo mediaInfo) {
long durationMs =
mediaInfo != null ? mediaInfo.getStreamDuration() : MediaInfo.UNKNOWN_DURATION;
public static long getStreamDurationUs(@Nullable MediaInfo mediaInfo) {
if (mediaInfo == null) {
return C.TIME_UNSET;
}
long durationMs = mediaInfo.getStreamDuration();
return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET;
}
@ -109,6 +112,7 @@ import com.google.android.gms.cast.MediaTrack;
/* codecs= */ null,
/* bitrate= */ Format.NO_VALUE,
/* selectionFlags= */ 0,
/* roleFlags= */ 0,
mediaTrack.getLanguage());
}

View file

@ -20,6 +20,7 @@ import com.google.android.gms.cast.CastMediaControlIntent;
import com.google.android.gms.cast.framework.CastOptions;
import com.google.android.gms.cast.framework.OptionsProvider;
import com.google.android.gms.cast.framework.SessionProvider;
import java.util.Collections;
import java.util.List;
/**
@ -27,16 +28,38 @@ import java.util.List;
*/
public final class DefaultCastOptionsProvider implements OptionsProvider {
/**
* App id of the Default Media Receiver app. Apps that do not require DRM support may use this
* receiver receiver app ID.
*
* <p>See https://developers.google.com/cast/docs/caf_receiver/#default_media_receiver.
*/
public static final String APP_ID_DEFAULT_RECEIVER =
CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID;
/**
* App id for receiver app with rudimentary support for DRM.
*
* <p>This app id is only suitable for ExoPlayer's Cast Demo app, and it is not intended for
* production use. In order to use DRM, custom receiver apps should be used. For environments that
* do not require DRM, the default receiver app should be used (see {@link
* #APP_ID_DEFAULT_RECEIVER}).
*/
// TODO: Add a documentation resource link for DRM support in the receiver app [Internal ref:
// b/128603245].
public static final String APP_ID_DEFAULT_RECEIVER_WITH_DRM = "A12D4273";
@Override
public CastOptions getCastOptions(Context context) {
return new CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.setStopReceiverApplicationWhenEndingSession(true).build();
.setReceiverApplicationId(APP_ID_DEFAULT_RECEIVER_WITH_DRM)
.setStopReceiverApplicationWhenEndingSession(true)
.build();
}
@Override
public List<SessionProvider> getAdditionalSessionProviders(Context context) {
return null;
return Collections.emptyList();
}
}

View file

@ -0,0 +1,167 @@
/*
* 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.ext.cast;
import android.net.Uri;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ext.cast.MediaItem.DrmConfiguration;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.cast.MediaQueueItem;
import java.util.HashMap;
import java.util.Iterator;
import java.util.UUID;
import org.json.JSONException;
import org.json.JSONObject;
/** Default {@link MediaItemConverter} implementation. */
public final class DefaultMediaItemConverter implements MediaItemConverter {
private static final String KEY_MEDIA_ITEM = "mediaItem";
private static final String KEY_PLAYER_CONFIG = "exoPlayerConfig";
private static final String KEY_URI = "uri";
private static final String KEY_TITLE = "title";
private static final String KEY_MIME_TYPE = "mimeType";
private static final String KEY_DRM_CONFIGURATION = "drmConfiguration";
private static final String KEY_UUID = "uuid";
private static final String KEY_LICENSE_URI = "licenseUri";
private static final String KEY_REQUEST_HEADERS = "requestHeaders";
@Override
public MediaItem toMediaItem(MediaQueueItem item) {
return getMediaItem(item.getMedia().getCustomData());
}
@Override
public MediaQueueItem toMediaQueueItem(MediaItem item) {
if (item.mimeType == null) {
throw new IllegalArgumentException("The item must specify its mimeType");
}
MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);
if (item.title != null) {
metadata.putString(MediaMetadata.KEY_TITLE, item.title);
}
MediaInfo mediaInfo =
new MediaInfo.Builder(item.uri.toString())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setContentType(item.mimeType)
.setMetadata(metadata)
.setCustomData(getCustomData(item))
.build();
return new MediaQueueItem.Builder(mediaInfo).build();
}
// Deserialization.
private static MediaItem getMediaItem(JSONObject customData) {
try {
JSONObject mediaItemJson = customData.getJSONObject(KEY_MEDIA_ITEM);
MediaItem.Builder builder = new MediaItem.Builder();
builder.setUri(Uri.parse(mediaItemJson.getString(KEY_URI)));
if (mediaItemJson.has(KEY_TITLE)) {
builder.setTitle(mediaItemJson.getString(KEY_TITLE));
}
if (mediaItemJson.has(KEY_MIME_TYPE)) {
builder.setMimeType(mediaItemJson.getString(KEY_MIME_TYPE));
}
if (mediaItemJson.has(KEY_DRM_CONFIGURATION)) {
builder.setDrmConfiguration(
getDrmConfiguration(mediaItemJson.getJSONObject(KEY_DRM_CONFIGURATION)));
}
return builder.build();
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
private static DrmConfiguration getDrmConfiguration(JSONObject json) throws JSONException {
UUID uuid = UUID.fromString(json.getString(KEY_UUID));
Uri licenseUri = Uri.parse(json.getString(KEY_LICENSE_URI));
JSONObject requestHeadersJson = json.getJSONObject(KEY_REQUEST_HEADERS);
HashMap<String, String> requestHeaders = new HashMap<>();
for (Iterator<String> iterator = requestHeadersJson.keys(); iterator.hasNext(); ) {
String key = iterator.next();
requestHeaders.put(key, requestHeadersJson.getString(key));
}
return new DrmConfiguration(uuid, licenseUri, requestHeaders);
}
// Serialization.
private static JSONObject getCustomData(MediaItem item) {
JSONObject json = new JSONObject();
try {
json.put(KEY_MEDIA_ITEM, getMediaItemJson(item));
JSONObject playerConfigJson = getPlayerConfigJson(item);
if (playerConfigJson != null) {
json.put(KEY_PLAYER_CONFIG, playerConfigJson);
}
} catch (JSONException e) {
throw new RuntimeException(e);
}
return json;
}
private static JSONObject getMediaItemJson(MediaItem item) throws JSONException {
JSONObject json = new JSONObject();
json.put(KEY_URI, item.uri.toString());
json.put(KEY_TITLE, item.title);
json.put(KEY_MIME_TYPE, item.mimeType);
if (item.drmConfiguration != null) {
json.put(KEY_DRM_CONFIGURATION, getDrmConfigurationJson(item.drmConfiguration));
}
return json;
}
private static JSONObject getDrmConfigurationJson(DrmConfiguration drmConfiguration)
throws JSONException {
JSONObject json = new JSONObject();
json.put(KEY_UUID, drmConfiguration.uuid);
json.put(KEY_LICENSE_URI, drmConfiguration.licenseUri);
json.put(KEY_REQUEST_HEADERS, new JSONObject(drmConfiguration.requestHeaders));
return json;
}
@Nullable
private static JSONObject getPlayerConfigJson(MediaItem item) throws JSONException {
DrmConfiguration drmConfiguration = item.drmConfiguration;
if (drmConfiguration == null) {
return null;
}
String drmScheme;
if (C.WIDEVINE_UUID.equals(drmConfiguration.uuid)) {
drmScheme = "widevine";
} else if (C.PLAYREADY_UUID.equals(drmConfiguration.uuid)) {
drmScheme = "playready";
} else {
return null;
}
JSONObject exoPlayerConfigJson = new JSONObject();
exoPlayerConfigJson.put("withCredentials", false);
exoPlayerConfigJson.put("protectionSystem", drmScheme);
if (drmConfiguration.licenseUri != null) {
exoPlayerConfigJson.put("licenseUrl", drmConfiguration.licenseUri);
}
if (!drmConfiguration.requestHeaders.isEmpty()) {
exoPlayerConfigJson.put("headers", new JSONObject(drmConfiguration.requestHeaders));
}
return exoPlayerConfigJson;
}
}

View file

@ -0,0 +1,175 @@
/*
* 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.cast;
import android.net.Uri;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
/** Representation of a media item. */
public final class MediaItem {
/** A builder for {@link MediaItem} instances. */
public static final class Builder {
@Nullable private Uri uri;
@Nullable private String title;
@Nullable private String mimeType;
@Nullable private DrmConfiguration drmConfiguration;
/** See {@link MediaItem#uri}. */
public Builder setUri(String uri) {
return setUri(Uri.parse(uri));
}
/** See {@link MediaItem#uri}. */
public Builder setUri(Uri uri) {
this.uri = uri;
return this;
}
/** See {@link MediaItem#title}. */
public Builder setTitle(String title) {
this.title = title;
return this;
}
/** See {@link MediaItem#mimeType}. */
public Builder setMimeType(String mimeType) {
this.mimeType = mimeType;
return this;
}
/** See {@link MediaItem#drmConfiguration}. */
public Builder setDrmConfiguration(DrmConfiguration drmConfiguration) {
this.drmConfiguration = drmConfiguration;
return this;
}
/** Returns a new {@link MediaItem} instance with the current builder values. */
public MediaItem build() {
Assertions.checkNotNull(uri);
return new MediaItem(uri, title, mimeType, drmConfiguration);
}
}
/** DRM configuration for a media item. */
public static final class DrmConfiguration {
/** The UUID of the protection scheme. */
public final UUID uuid;
/**
* Optional license server {@link Uri}. If {@code null} then the license server must be
* specified by the media.
*/
@Nullable public final Uri licenseUri;
/** Headers that should be attached to any license requests. */
public final Map<String, String> requestHeaders;
/**
* Creates an instance.
*
* @param uuid See {@link #uuid}.
* @param licenseUri See {@link #licenseUri}.
* @param requestHeaders See {@link #requestHeaders}.
*/
public DrmConfiguration(
UUID uuid, @Nullable Uri licenseUri, @Nullable Map<String, String> requestHeaders) {
this.uuid = uuid;
this.licenseUri = licenseUri;
this.requestHeaders =
requestHeaders == null
? Collections.emptyMap()
: Collections.unmodifiableMap(requestHeaders);
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
DrmConfiguration other = (DrmConfiguration) obj;
return uuid.equals(other.uuid)
&& Util.areEqual(licenseUri, other.licenseUri)
&& requestHeaders.equals(other.requestHeaders);
}
@Override
public int hashCode() {
int result = uuid.hashCode();
result = 31 * result + (licenseUri != null ? licenseUri.hashCode() : 0);
result = 31 * result + requestHeaders.hashCode();
return result;
}
}
/** The media {@link Uri}. */
public final Uri uri;
/** The title of the item, or {@code null} if unspecified. */
@Nullable public final String title;
/** The mime type for the media, or {@code null} if unspecified. */
@Nullable public final String mimeType;
/** Optional {@link DrmConfiguration} for the media. */
@Nullable public final DrmConfiguration drmConfiguration;
private MediaItem(
Uri uri,
@Nullable String title,
@Nullable String mimeType,
@Nullable DrmConfiguration drmConfiguration) {
this.uri = uri;
this.title = title;
this.mimeType = mimeType;
this.drmConfiguration = drmConfiguration;
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
MediaItem other = (MediaItem) obj;
return uri.equals(other.uri)
&& Util.areEqual(title, other.title)
&& Util.areEqual(mimeType, other.mimeType)
&& Util.areEqual(drmConfiguration, other.drmConfiguration);
}
@Override
public int hashCode() {
int result = uri.hashCode();
result = 31 * result + (title == null ? 0 : title.hashCode());
result = 31 * result + (drmConfiguration == null ? 0 : drmConfiguration.hashCode());
result = 31 * result + (mimeType == null ? 0 : mimeType.hashCode());
return result;
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.ext.cast;
import com.google.android.gms.cast.MediaQueueItem;
/** Converts between {@link MediaItem} and the Cast SDK's {@link MediaQueueItem}. */
public interface MediaItemConverter {
/**
* Converts a {@link MediaItem} to a {@link MediaQueueItem}.
*
* @param mediaItem The {@link MediaItem}.
* @return An equivalent {@link MediaQueueItem}.
*/
MediaQueueItem toMediaQueueItem(MediaItem mediaItem);
/**
* Converts a {@link MediaQueueItem} to a {@link MediaItem}.
*
* @param mediaQueueItem The {@link MediaQueueItem}.
* @return The equivalent {@link MediaItem}.
*/
MediaItem toMediaItem(MediaQueueItem mediaQueueItem);
}

View file

@ -0,0 +1,26 @@
/*
* 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.cast;
/** Listener of changes in the cast session availability. */
public interface SessionAvailabilityListener {
/** Called when a cast session becomes available to the player. */
void onCastSessionAvailable();
/** Called when the cast session becomes unavailable. */
void onCastSessionUnavailable();
}

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
@NonNullApi
package com.google.android.exoplayer2.ext.cast;
import com.google.android.exoplayer2.util.NonNullApi;

View file

@ -14,4 +14,6 @@
limitations under the License.
-->
<manifest package="com.google.android.exoplayer2.ext.cast.test"/>
<manifest package="com.google.android.exoplayer2.ext.cast.test">
<uses-sdk/>
</manifest>

View file

@ -15,23 +15,23 @@
*/
package com.google.android.exoplayer2.ext.cast;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.testutil.TimelineAsserts;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.MediaStatus;
import java.util.ArrayList;
import com.google.android.gms.cast.framework.media.MediaQueue;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import java.util.Collections;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.robolectric.RobolectricTestRunner;
/** Tests for {@link CastTimelineTracker}. */
@RunWith(RobolectricTestRunner.class)
@RunWith(AndroidJUnit4.class)
public class CastTimelineTrackerTest {
private static final long DURATION_1_MS = 1000;
private static final long DURATION_2_MS = 2000;
private static final long DURATION_3_MS = 3000;
private static final long DURATION_4_MS = 4000;
@ -39,91 +39,89 @@ public class CastTimelineTrackerTest {
/** Tests that duration of the current media info is correctly propagated to the timeline. */
@Test
public void testGetCastTimeline() {
MediaInfo mediaInfo;
MediaStatus status =
mockMediaStatus(
new int[] {1, 2, 3},
new String[] {"contentId1", "contentId2", "contentId3"},
new long[] {DURATION_1_MS, MediaInfo.UNKNOWN_DURATION, MediaInfo.UNKNOWN_DURATION});
public void testGetCastTimelinePersistsDuration() {
CastTimelineTracker tracker = new CastTimelineTracker();
mediaInfo = getMediaInfo("contentId1", DURATION_1_MS);
Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(status), C.msToUs(DURATION_1_MS), C.TIME_UNSET, C.TIME_UNSET);
mediaInfo = getMediaInfo("contentId3", DURATION_3_MS);
Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo);
RemoteMediaClient remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3, 4, 5},
/* currentItemId= */ 2,
/* currentDurationMs= */ DURATION_2_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(status),
C.msToUs(DURATION_1_MS),
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
C.msToUs(DURATION_3_MS));
C.msToUs(DURATION_2_MS),
C.TIME_UNSET,
C.TIME_UNSET,
C.TIME_UNSET);
mediaInfo = getMediaInfo("contentId2", DURATION_2_MS);
Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3},
/* currentItemId= */ 3,
/* currentDurationMs= */ DURATION_3_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(status),
C.msToUs(DURATION_1_MS),
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
C.msToUs(DURATION_2_MS),
C.msToUs(DURATION_3_MS));
MediaStatus newStatus =
mockMediaStatus(
new int[] {4, 1, 5, 3},
new String[] {"contentId4", "contentId1", "contentId5", "contentId3"},
new long[] {
MediaInfo.UNKNOWN_DURATION,
MediaInfo.UNKNOWN_DURATION,
DURATION_5_MS,
MediaInfo.UNKNOWN_DURATION
});
mediaInfo = getMediaInfo("contentId5", DURATION_5_MS);
Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 3},
/* currentItemId= */ 3,
/* currentDurationMs= */ DURATION_3_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(newStatus),
C.TIME_UNSET,
C.msToUs(DURATION_1_MS),
C.msToUs(DURATION_5_MS),
C.msToUs(DURATION_3_MS));
tracker.getCastTimeline(remoteMediaClient), C.TIME_UNSET, C.msToUs(DURATION_3_MS));
mediaInfo = getMediaInfo("contentId3", DURATION_3_MS);
Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3, 4, 5},
/* currentItemId= */ 4,
/* currentDurationMs= */ DURATION_4_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(newStatus),
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
C.msToUs(DURATION_1_MS),
C.msToUs(DURATION_5_MS),
C.msToUs(DURATION_3_MS));
mediaInfo = getMediaInfo("contentId4", DURATION_4_MS);
Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(newStatus),
C.TIME_UNSET,
C.msToUs(DURATION_3_MS),
C.msToUs(DURATION_4_MS),
C.msToUs(DURATION_1_MS),
C.msToUs(DURATION_5_MS),
C.msToUs(DURATION_3_MS));
C.TIME_UNSET);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3, 4, 5},
/* currentItemId= */ 5,
/* currentDurationMs= */ DURATION_5_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
C.TIME_UNSET,
C.msToUs(DURATION_3_MS),
C.msToUs(DURATION_4_MS),
C.msToUs(DURATION_5_MS));
}
private static MediaStatus mockMediaStatus(
int[] itemIds, String[] contentIds, long[] durationsMs) {
ArrayList<MediaQueueItem> items = new ArrayList<>();
for (int i = 0; i < contentIds.length; i++) {
MediaInfo mediaInfo = getMediaInfo(contentIds[i], durationsMs[i]);
MediaQueueItem item = Mockito.mock(MediaQueueItem.class);
Mockito.when(item.getMedia()).thenReturn(mediaInfo);
Mockito.when(item.getItemId()).thenReturn(itemIds[i]);
items.add(item);
}
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(items);
return status;
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);
MediaQueue mediaQueue = mockMediaQueue(itemIds);
Mockito.when(remoteMediaClient.getMediaQueue()).thenReturn(mediaQueue);
return remoteMediaClient;
}
private static MediaInfo getMediaInfo(String contentId, long durationMs) {
return new MediaInfo.Builder(contentId)
private static MediaQueue mockMediaQueue(int[] itemIds) {
MediaQueue mediaQueue = Mockito.mock(MediaQueue.class);
Mockito.when(mediaQueue.getItemIds()).thenReturn(itemIds);
return mediaQueue;
}
private static MediaInfo getMediaInfo(long durationMs) {
return new MediaInfo.Builder(/*contentId= */ "")
.setStreamDuration(durationMs)
.setContentType(MimeTypes.APPLICATION_MP4)
.setStreamType(MediaInfo.STREAM_TYPE_NONE)

View file

@ -0,0 +1,66 @@
/*
* 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.ext.cast;
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.ext.cast.MediaItem.DrmConfiguration;
import com.google.android.gms.cast.MediaQueueItem;
import java.util.Collections;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Test for {@link DefaultMediaItemConverter}. */
@RunWith(AndroidJUnit4.class)
public class DefaultMediaItemConverterTest {
@Test
public void serialize_deserialize_minimal() {
MediaItem.Builder builder = new MediaItem.Builder();
MediaItem item = builder.setUri(Uri.parse("http://example.com")).setMimeType("mime").build();
DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
MediaQueueItem queueItem = converter.toMediaQueueItem(item);
MediaItem reconstructedItem = converter.toMediaItem(queueItem);
assertThat(reconstructedItem).isEqualTo(item);
}
@Test
public void serialize_deserialize_complete() {
MediaItem.Builder builder = new MediaItem.Builder();
MediaItem item =
builder
.setUri(Uri.parse("http://example.com"))
.setTitle("title")
.setMimeType("mime")
.setDrmConfiguration(
new DrmConfiguration(
C.WIDEVINE_UUID,
Uri.parse("http://license.com"),
Collections.singletonMap("key", "value")))
.build();
DefaultMediaItemConverter converter = new DefaultMediaItemConverter();
MediaQueueItem queueItem = converter.toMediaQueueItem(item);
MediaItem reconstructedItem = converter.toMediaItem(queueItem);
assertThat(reconstructedItem).isEqualTo(item);
}
}

View file

@ -0,0 +1,86 @@
/*
* 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.cast;
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.util.MimeTypes;
import java.util.HashMap;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Test for {@link MediaItem}. */
@RunWith(AndroidJUnit4.class)
public class MediaItemTest {
@Test
public void buildMediaItem_doesNotChangeState() {
MediaItem.Builder builder = new MediaItem.Builder();
MediaItem item1 =
builder
.setUri(Uri.parse("http://example.com"))
.setTitle("title")
.setMimeType(MimeTypes.AUDIO_MP4)
.build();
MediaItem item2 = builder.build();
assertThat(item1).isEqualTo(item2);
}
@Test
public void equals_withEqualDrmSchemes_returnsTrue() {
MediaItem.Builder builder1 = new MediaItem.Builder();
MediaItem mediaItem1 =
builder1
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(1))
.build();
MediaItem.Builder builder2 = new MediaItem.Builder();
MediaItem mediaItem2 =
builder2
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(1))
.build();
assertThat(mediaItem1).isEqualTo(mediaItem2);
}
@Test
public void equals_withDifferentDrmRequestHeaders_returnsFalse() {
MediaItem.Builder builder1 = new MediaItem.Builder();
MediaItem mediaItem1 =
builder1
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(1))
.build();
MediaItem.Builder builder2 = new MediaItem.Builder();
MediaItem mediaItem2 =
builder2
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(2))
.build();
assertThat(mediaItem1).isNotEqualTo(mediaItem2);
}
private static MediaItem.DrmConfiguration buildDrmConfiguration(int seed) {
HashMap<String, String> requestHeaders = new HashMap<>();
requestHeaders.put("key1", "value1");
requestHeaders.put("key2", "value2" + seed);
return new MediaItem.DrmConfiguration(
C.WIDEVINE_UUID, Uri.parse("www.uri1.com"), requestHeaders);
}
}

View file

@ -1 +0,0 @@
manifest=src/test/AndroidManifest.xml

View file

@ -2,7 +2,7 @@
The Cronet extension is an [HttpDataSource][] implementation using [Cronet][].
[HttpDataSource]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/upstream/HttpDataSource.html
[HttpDataSource]: https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/upstream/HttpDataSource.html
[Cronet]: https://chromium.googlesource.com/chromium/src/+/master/components/cronet?autodive=0%2F%2F
## Getting the extension ##
@ -52,4 +52,4 @@ respectively.
* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.cronet.*`
belong to this module.
[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html
[Javadoc]: https://exoplayer.dev/doc/reference/index.html

View file

@ -16,10 +16,9 @@ apply plugin: 'com.android.library'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
defaultConfig {
minSdkVersion 16
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
}
@ -27,14 +26,18 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
testOptions.unitTests.includeAndroidResources = true
}
dependencies {
api 'org.chromium.net:cronet-embedded:66.3359.158'
api 'org.chromium.net:cronet-embedded:75.3770.101'
implementation project(modulePrefix + 'library-core')
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
implementation 'androidx.annotation:annotation:1.1.0'
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
testImplementation project(modulePrefix + 'library')
testImplementation project(modulePrefix + 'testutils-robolectric')
testImplementation project(modulePrefix + 'testutils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {

View file

@ -15,11 +15,14 @@
*/
package com.google.android.exoplayer2.ext.cronet;
import static com.google.android.exoplayer2.util.Util.castNonNull;
import android.net.Uri;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.util.Log;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.metadata.icy.IcyHeaders;
import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
@ -27,6 +30,7 @@ import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.ConditionVariable;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Predicate;
import java.io.IOException;
import java.net.SocketTimeoutException;
@ -39,6 +43,7 @@ import java.util.Map.Entry;
import java.util.concurrent.Executor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
import org.chromium.net.CronetEngine;
import org.chromium.net.CronetException;
import org.chromium.net.NetworkException;
@ -111,16 +116,17 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
private final CronetEngine cronetEngine;
private final Executor executor;
private final Predicate<String> contentTypePredicate;
private final int connectTimeoutMs;
private final int readTimeoutMs;
private final boolean resetTimeoutOnRedirects;
private final boolean handleSetCookieRequests;
private final RequestProperties defaultRequestProperties;
@Nullable private final RequestProperties defaultRequestProperties;
private final RequestProperties requestProperties;
private final ConditionVariable operation;
private final Clock clock;
@Nullable private Predicate<String> contentTypePredicate;
// Accessed by the calling thread only.
private boolean opened;
private long bytesToSkip;
@ -128,18 +134,18 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
// Written from the calling thread only. currentUrlRequest.start() calls ensure writes are visible
// to reads made by the Cronet thread.
private UrlRequest currentUrlRequest;
private DataSpec currentDataSpec;
@Nullable private UrlRequest currentUrlRequest;
@Nullable private DataSpec currentDataSpec;
// Reference written and read by calling thread only. Passed to Cronet thread as a local variable.
// operation.open() calls ensure writes into the buffer are visible to reads made by the calling
// thread.
private ByteBuffer readBuffer;
@Nullable private ByteBuffer readBuffer;
// Written from the Cronet thread only. operation.open() calls ensure writes are visible to reads
// made by the calling thread.
private UrlResponseInfo responseInfo;
private IOException exception;
@Nullable private UrlResponseInfo responseInfo;
@Nullable private IOException exception;
private boolean finished;
private volatile long currentConnectTimeoutMs;
@ -151,21 +157,15 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* hop from Cronet's internal network thread to the response handling thread. However, to
* avoid slowing down overall network performance, care must be taken to make sure response
* handling is a fast operation when using a direct executor.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* #open(DataSpec)}.
*/
public CronetDataSource(
CronetEngine cronetEngine, Executor executor, Predicate<String> contentTypePredicate) {
public CronetDataSource(CronetEngine cronetEngine, Executor executor) {
this(
cronetEngine,
executor,
contentTypePredicate,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
false,
null,
false);
/* resetTimeoutOnRedirects= */ false,
/* defaultRequestProperties= */ null);
}
/**
@ -175,32 +175,28 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* hop from Cronet's internal network thread to the response handling thread. However, to
* avoid slowing down overall network performance, care must be taken to make sure response
* handling is a fast operation when using a direct executor.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* #open(DataSpec)}.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
* @param defaultRequestProperties The default request properties to be used.
* @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
* server as HTTP headers on every request.
*/
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
Predicate<String> contentTypePredicate,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
RequestProperties defaultRequestProperties) {
@Nullable RequestProperties defaultRequestProperties) {
this(
cronetEngine,
executor,
contentTypePredicate,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
Clock.DEFAULT,
defaultRequestProperties,
false);
/* handleSetCookieRequests= */ false);
}
/**
@ -210,29 +206,25 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* hop from Cronet's internal network thread to the response handling thread. However, to
* avoid slowing down overall network performance, care must be taken to make sure response
* handling is a fast operation when using a direct executor.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* #open(DataSpec)}.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
* @param defaultRequestProperties The default request properties to be used.
* @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
* server as HTTP headers on every request.
* @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to
* the redirect url in the "Cookie" header.
*/
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
Predicate<String> contentTypePredicate,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
RequestProperties defaultRequestProperties,
@Nullable RequestProperties defaultRequestProperties,
boolean handleSetCookieRequests) {
this(
cronetEngine,
executor,
contentTypePredicate,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
@ -241,21 +233,127 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
handleSetCookieRequests);
}
/**
* @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
* hop from Cronet's internal network thread to the response handling thread. However, to
* avoid slowing down overall network performance, care must be taken to make sure response
* handling is a fast operation when using a direct executor.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* #open(DataSpec)}.
* @deprecated Use {@link #CronetDataSource(CronetEngine, Executor)} and {@link
* #setContentTypePredicate(Predicate)}.
*/
@Deprecated
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
@Nullable Predicate<String> contentTypePredicate) {
this(
cronetEngine,
executor,
contentTypePredicate,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
/* resetTimeoutOnRedirects= */ false,
/* defaultRequestProperties= */ null);
}
/**
* @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
* hop from Cronet's internal network thread to the response handling thread. However, to
* avoid slowing down overall network performance, care must be taken to make sure response
* handling is a fast operation when using a direct executor.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* #open(DataSpec)}.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
* @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
* server as HTTP headers on every request.
* @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
* RequestProperties)} and {@link #setContentTypePredicate(Predicate)}.
*/
@Deprecated
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
@Nullable Predicate<String> contentTypePredicate,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
@Nullable RequestProperties defaultRequestProperties) {
this(
cronetEngine,
executor,
contentTypePredicate,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
defaultRequestProperties,
/* handleSetCookieRequests= */ false);
}
/**
* @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
* hop from Cronet's internal network thread to the response handling thread. However, to
* avoid slowing down overall network performance, care must be taken to make sure response
* handling is a fast operation when using a direct executor.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* #open(DataSpec)}.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
* @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
* server as HTTP headers on every request.
* @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to
* the redirect url in the "Cookie" header.
* @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
* RequestProperties, boolean)} and {@link #setContentTypePredicate(Predicate)}.
*/
@Deprecated
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
@Nullable Predicate<String> contentTypePredicate,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
@Nullable RequestProperties defaultRequestProperties,
boolean handleSetCookieRequests) {
this(
cronetEngine,
executor,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
Clock.DEFAULT,
defaultRequestProperties,
handleSetCookieRequests);
this.contentTypePredicate = contentTypePredicate;
}
/* package */ CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
Predicate<String> contentTypePredicate,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
Clock clock,
RequestProperties defaultRequestProperties,
@Nullable RequestProperties defaultRequestProperties,
boolean handleSetCookieRequests) {
super(/* isNetwork= */ true);
this.urlRequestCallback = new UrlRequestCallback();
this.cronetEngine = Assertions.checkNotNull(cronetEngine);
this.executor = Assertions.checkNotNull(executor);
this.contentTypePredicate = contentTypePredicate;
this.connectTimeoutMs = connectTimeoutMs;
this.readTimeoutMs = readTimeoutMs;
this.resetTimeoutOnRedirects = resetTimeoutOnRedirects;
@ -266,6 +364,17 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
operation = new ConditionVariable();
}
/**
* Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a
* {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link #open(DataSpec)}.
*
* @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a
* predicate that was previously set.
*/
public void setContentTypePredicate(@Nullable Predicate<String> contentTypePredicate) {
this.contentTypePredicate = contentTypePredicate;
}
// HttpDataSource implementation.
@Override
@ -289,6 +398,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
}
@Override
@Nullable
public Uri getUri() {
return responseInfo == null ? null : Uri.parse(responseInfo.getUrl());
}
@ -301,22 +411,23 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
operation.close();
resetConnectTimeout();
currentDataSpec = dataSpec;
UrlRequest urlRequest;
try {
currentUrlRequest = buildRequestBuilder(dataSpec).build();
urlRequest = buildRequestBuilder(dataSpec).build();
currentUrlRequest = urlRequest;
} catch (IOException e) {
throw new OpenException(e, currentDataSpec, Status.IDLE);
throw new OpenException(e, dataSpec, Status.IDLE);
}
currentUrlRequest.start();
urlRequest.start();
transferInitializing(dataSpec);
try {
boolean connectionOpened = blockUntilConnectTimeout();
if (exception != null) {
throw new OpenException(exception, currentDataSpec, getStatus(currentUrlRequest));
throw new OpenException(exception, dataSpec, getStatus(urlRequest));
} else if (!connectionOpened) {
// The timeout was reached before the connection was opened.
throw new OpenException(
new SocketTimeoutException(), dataSpec, getStatus(currentUrlRequest));
throw new OpenException(new SocketTimeoutException(), dataSpec, getStatus(urlRequest));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
@ -324,10 +435,15 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
}
// Check for a valid response code.
UrlResponseInfo responseInfo = Assertions.checkNotNull(this.responseInfo);
int responseCode = responseInfo.getHttpStatusCode();
if (responseCode < 200 || responseCode > 299) {
InvalidResponseCodeException exception = new InvalidResponseCodeException(responseCode,
responseInfo.getAllHeaders(), currentDataSpec);
InvalidResponseCodeException exception =
new InvalidResponseCodeException(
responseCode,
responseInfo.getHttpStatusText(),
responseInfo.getAllHeaders(),
dataSpec);
if (responseCode == 416) {
exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE));
}
@ -335,11 +451,12 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
}
// Check for a valid content type.
Predicate<String> contentTypePredicate = this.contentTypePredicate;
if (contentTypePredicate != null) {
List<String> contentTypeHeaders = responseInfo.getAllHeaders().get(CONTENT_TYPE);
String contentType = isEmpty(contentTypeHeaders) ? null : contentTypeHeaders.get(0);
if (!contentTypePredicate.evaluate(contentType)) {
throw new InvalidContentTypeException(contentType, currentDataSpec);
if (contentType != null && !contentTypePredicate.evaluate(contentType)) {
throw new InvalidContentTypeException(contentType, dataSpec);
}
}
@ -349,7 +466,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
// Calculate the content length.
if (!getIsCompressed(responseInfo)) {
if (!isCompressed(responseInfo)) {
if (dataSpec.length != C.LENGTH_UNSET) {
bytesRemaining = dataSpec.length;
} else {
@ -358,7 +475,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
} else {
// If the response is compressed then the content length will be that of the compressed data
// which isn't what we want. Always use the dataSpec length in this case.
bytesRemaining = currentDataSpec.length;
bytesRemaining = dataSpec.length;
}
opened = true;
@ -377,37 +494,19 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
return C.RESULT_END_OF_INPUT;
}
ByteBuffer readBuffer = this.readBuffer;
if (readBuffer == null) {
readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES);
readBuffer.limit(0);
this.readBuffer = readBuffer;
}
while (!readBuffer.hasRemaining()) {
// Fill readBuffer with more data from Cronet.
operation.close();
readBuffer.clear();
currentUrlRequest.read(readBuffer);
try {
if (!operation.block(readTimeoutMs)) {
throw new SocketTimeoutException();
}
} catch (InterruptedException e) {
// The operation is ongoing so replace readBuffer to avoid it being written to by this
// operation during a subsequent request.
readBuffer = null;
Thread.currentThread().interrupt();
throw new HttpDataSourceException(
new InterruptedIOException(e), currentDataSpec, HttpDataSourceException.TYPE_READ);
} catch (SocketTimeoutException e) {
// The operation is ongoing so replace readBuffer to avoid it being written to by this
// operation during a subsequent request.
readBuffer = null;
throw new HttpDataSourceException(e, currentDataSpec, HttpDataSourceException.TYPE_READ);
}
readInternal(castNonNull(readBuffer));
if (exception != null) {
throw new HttpDataSourceException(exception, currentDataSpec,
HttpDataSourceException.TYPE_READ);
} else if (finished) {
if (finished) {
bytesRemaining = 0;
return C.RESULT_END_OF_INPUT;
} else {
@ -432,6 +531,115 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
return bytesRead;
}
/**
* Reads up to {@code buffer.remaining()} bytes of data and stores them into {@code buffer},
* starting at {@code buffer.position()}. Advances the position of the buffer by the number of
* bytes read and returns this length.
*
* <p>If there is an error, a {@link HttpDataSourceException} is thrown and the contents of {@code
* buffer} should be ignored. If the exception has error code {@code
* HttpDataSourceException.TYPE_READ}, note that Cronet may continue writing into {@code buffer}
* after the method has returned. Thus the caller should not attempt to reuse the buffer.
*
* <p>If {@code buffer.remaining()} is zero then 0 is returned. Otherwise, if no data is available
* because the end of the opened range has been reached, then {@link C#RESULT_END_OF_INPUT} is
* returned. Otherwise, the call will block until at least one byte of data has been read and the
* number of bytes read is returned.
*
* <p>Passed buffer must be direct ByteBuffer. If you have a non-direct ByteBuffer, consider the
* alternative read method with its backed array.
*
* @param buffer The ByteBuffer into which the read data should be stored. Must be a direct
* ByteBuffer.
* @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if no data is available
* because the end of the opened range has been reached.
* @throws HttpDataSourceException If an error occurs reading from the source.
* @throws IllegalArgumentException If {@code buffer} is not a direct ByteBuffer.
*/
public int read(ByteBuffer buffer) throws HttpDataSourceException {
Assertions.checkState(opened);
if (!buffer.isDirect()) {
throw new IllegalArgumentException("Passed buffer is not a direct ByteBuffer");
}
if (!buffer.hasRemaining()) {
return 0;
} else if (bytesRemaining == 0) {
return C.RESULT_END_OF_INPUT;
}
int readLength = buffer.remaining();
if (readBuffer != null) {
// Skip all the bytes we can from readBuffer if there are still bytes to skip.
if (bytesToSkip != 0) {
if (bytesToSkip >= readBuffer.remaining()) {
bytesToSkip -= readBuffer.remaining();
readBuffer.position(readBuffer.limit());
} else {
readBuffer.position(readBuffer.position() + (int) bytesToSkip);
bytesToSkip = 0;
}
}
// If there is existing data in the readBuffer, read as much as possible. Return if any read.
int copyBytes = copyByteBuffer(/* src= */ readBuffer, /* dst= */ buffer);
if (copyBytes != 0) {
if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= copyBytes;
}
bytesTransferred(copyBytes);
return copyBytes;
}
}
boolean readMore = true;
while (readMore) {
// If bytesToSkip > 0, read into intermediate buffer that we can discard instead of caller's
// buffer. If we do not need to skip bytes, we may write to buffer directly.
final boolean useCallerBuffer = bytesToSkip == 0;
operation.close();
if (!useCallerBuffer) {
if (readBuffer == null) {
readBuffer = ByteBuffer.allocateDirect(READ_BUFFER_SIZE_BYTES);
} else {
readBuffer.clear();
}
if (bytesToSkip < READ_BUFFER_SIZE_BYTES) {
readBuffer.limit((int) bytesToSkip);
}
}
// Fill buffer with more data from Cronet.
readInternal(useCallerBuffer ? buffer : castNonNull(readBuffer));
if (finished) {
bytesRemaining = 0;
return C.RESULT_END_OF_INPUT;
} else {
// The operation didn't time out, fail or finish, and therefore data must have been read.
Assertions.checkState(
useCallerBuffer
? readLength > buffer.remaining()
: castNonNull(readBuffer).position() > 0);
// If we meant to skip bytes, subtract what was left and repeat, otherwise, continue.
if (useCallerBuffer) {
readMore = false;
} else {
bytesToSkip -= castNonNull(readBuffer).position();
}
}
}
final int bytesRead = readLength - buffer.remaining();
if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= bytesRead;
}
bytesTransferred(bytesRead);
return bytesRead;
}
@Override
public synchronized void close() {
if (currentUrlRequest != null) {
@ -451,6 +659,18 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
}
}
/** Returns current {@link UrlRequest}. May be null if the data source is not opened. */
@Nullable
protected UrlRequest getCurrentUrlRequest() {
return currentUrlRequest;
}
/** Returns current {@link UrlResponseInfo}. May be null if the data source is not opened. */
@Nullable
protected UrlResponseInfo getCurrentUrlResponseInfo() {
return responseInfo;
}
// Internal methods.
private UrlRequest.Builder buildRequestBuilder(DataSpec dataSpec) throws IOException {
@ -476,6 +696,11 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
if (dataSpec.httpBody != null && !isContentTypeHeaderSet) {
throw new IOException("HTTP request with non-empty body must set Content-Type");
}
if (dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_ICY_METADATA)) {
requestBuilder.addHeader(
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME,
IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE);
}
// Set the Range header.
if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) {
StringBuilder rangeValue = new StringBuilder();
@ -487,7 +712,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
}
requestBuilder.addHeader("Range", rangeValue.toString());
}
// TODO: Uncomment when https://bugs.chromium.org/p/chromium/issues/detail?id=767025 is fixed
// TODO: Uncomment when https://bugs.chromium.org/p/chromium/issues/detail?id=711810 is fixed
// (adjusting the code as necessary).
// Force identity encoding unless gzip is allowed.
// if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) {
@ -516,7 +741,49 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs;
}
private static boolean getIsCompressed(UrlResponseInfo info) {
/**
* Reads up to {@code buffer.remaining()} bytes of data from {@code currentUrlRequest} and stores
* them into {@code buffer}. If there is an error and {@code buffer == readBuffer}, then it resets
* the current {@code readBuffer} object so that it is not reused in the future.
*
* @param buffer The ByteBuffer into which the read data is stored. Must be a direct ByteBuffer.
* @throws HttpDataSourceException If an error occurs reading from the source.
*/
@SuppressWarnings("ReferenceEquality")
private void readInternal(ByteBuffer buffer) throws HttpDataSourceException {
castNonNull(currentUrlRequest).read(buffer);
try {
if (!operation.block(readTimeoutMs)) {
throw new SocketTimeoutException();
}
} catch (InterruptedException e) {
// The operation is ongoing so replace buffer to avoid it being written to by this
// operation during a subsequent request.
if (buffer == readBuffer) {
readBuffer = null;
}
Thread.currentThread().interrupt();
throw new HttpDataSourceException(
new InterruptedIOException(e),
castNonNull(currentDataSpec),
HttpDataSourceException.TYPE_READ);
} catch (SocketTimeoutException e) {
// The operation is ongoing so replace buffer to avoid it being written to by this
// operation during a subsequent request.
if (buffer == readBuffer) {
readBuffer = null;
}
throw new HttpDataSourceException(
e, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
}
if (exception != null) {
throw new HttpDataSourceException(
exception, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
}
}
private static boolean isCompressed(UrlResponseInfo info) {
for (Map.Entry<String, String> entry : info.getAllHeadersAsList()) {
if (entry.getKey().equalsIgnoreCase("Content-Encoding")) {
return !entry.getValue().equalsIgnoreCase("identity");
@ -594,10 +861,22 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
return statusHolder[0];
}
private static boolean isEmpty(List<?> list) {
@EnsuresNonNullIf(result = false, expression = "#1")
private static boolean isEmpty(@Nullable List<?> list) {
return list == null || list.isEmpty();
}
// 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 limit = src.limit();
src.limit(src.position() + remaining);
dst.put(src);
src.limit(limit);
return remaining;
}
private final class UrlRequestCallback extends UrlRequest.Callback {
@Override
@ -606,12 +885,15 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
if (request != currentUrlRequest) {
return;
}
if (currentDataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) {
UrlRequest urlRequest = Assertions.checkNotNull(currentUrlRequest);
DataSpec dataSpec = Assertions.checkNotNull(currentDataSpec);
if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) {
int responseCode = info.getHttpStatusCode();
// The industry standard is to disregard POST redirects when the status code is 307 or 308.
if (responseCode == 307 || responseCode == 308) {
exception =
new InvalidResponseCodeException(responseCode, info.getAllHeaders(), currentDataSpec);
new InvalidResponseCodeException(
responseCode, info.getHttpStatusText(), info.getAllHeaders(), dataSpec);
operation.open();
return;
}
@ -620,40 +902,46 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
resetConnectTimeout();
}
Map<String, List<String>> headers = info.getAllHeaders();
if (!handleSetCookieRequests || isEmpty(headers.get(SET_COOKIE))) {
if (!handleSetCookieRequests) {
request.followRedirect();
} else {
currentUrlRequest.cancel();
DataSpec redirectUrlDataSpec;
if (currentDataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) {
// For POST redirects that aren't 307 or 308, the redirect is followed but request is
// transformed into a GET.
redirectUrlDataSpec =
new DataSpec(
Uri.parse(newLocationUrl),
DataSpec.HTTP_METHOD_GET,
/* httpBody= */ null,
currentDataSpec.absoluteStreamPosition,
currentDataSpec.position,
currentDataSpec.length,
currentDataSpec.key,
currentDataSpec.flags);
} else {
redirectUrlDataSpec = currentDataSpec.withUri(Uri.parse(newLocationUrl));
}
UrlRequest.Builder requestBuilder;
try {
requestBuilder = buildRequestBuilder(redirectUrlDataSpec);
} catch (IOException e) {
exception = e;
return;
}
String cookieHeadersValue = parseCookies(headers.get(SET_COOKIE));
attachCookies(requestBuilder, cookieHeadersValue);
currentUrlRequest = requestBuilder.build();
currentUrlRequest.start();
return;
}
List<String> setCookieHeaders = info.getAllHeaders().get(SET_COOKIE);
if (isEmpty(setCookieHeaders)) {
request.followRedirect();
return;
}
urlRequest.cancel();
DataSpec redirectUrlDataSpec;
if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) {
// For POST redirects that aren't 307 or 308, the redirect is followed but request is
// transformed into a GET.
redirectUrlDataSpec =
new DataSpec(
Uri.parse(newLocationUrl),
DataSpec.HTTP_METHOD_GET,
/* httpBody= */ null,
dataSpec.absoluteStreamPosition,
dataSpec.position,
dataSpec.length,
dataSpec.key,
dataSpec.flags);
} else {
redirectUrlDataSpec = dataSpec.withUri(Uri.parse(newLocationUrl));
}
UrlRequest.Builder requestBuilder;
try {
requestBuilder = buildRequestBuilder(redirectUrlDataSpec);
} catch (IOException e) {
exception = e;
return;
}
String cookieHeadersValue = parseCookies(setCookieHeaders);
attachCookies(requestBuilder, cookieHeadersValue);
currentUrlRequest = requestBuilder.build();
currentUrlRequest.start();
}
@Override

View file

@ -15,14 +15,12 @@
*/
package com.google.android.exoplayer2.ext.cronet;
import android.support.annotation.Nullable;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource.Factory;
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidContentTypeException;
import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Predicate;
import java.util.concurrent.Executor;
import org.chromium.net.CronetEngine;
@ -45,8 +43,7 @@ public final class CronetDataSourceFactory extends BaseFactory {
private final CronetEngineWrapper cronetEngineWrapper;
private final Executor executor;
private final Predicate<String> contentTypePredicate;
private final @Nullable TransferListener transferListener;
@Nullable private final TransferListener transferListener;
private final int connectTimeoutMs;
private final int readTimeoutMs;
private final boolean resetTimeoutOnRedirects;
@ -64,21 +61,16 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no
* suitable CronetEngine can be build.
*/
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
HttpDataSource.Factory fallbackFactory) {
this(
cronetEngineWrapper,
executor,
contentTypePredicate,
/* transferListener= */ null,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
@ -98,20 +90,15 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param userAgent A user agent used to create a fallback HttpDataSource if needed.
*/
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
String userAgent) {
this(
cronetEngineWrapper,
executor,
contentTypePredicate,
/* transferListener= */ null,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
@ -132,9 +119,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
@ -143,7 +127,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
@ -151,7 +134,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
this(
cronetEngineWrapper,
executor,
contentTypePredicate,
/* transferListener= */ null,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
@ -172,9 +154,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
* @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
@ -184,7 +163,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
@ -192,7 +170,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
this(
cronetEngineWrapper,
executor,
contentTypePredicate,
/* transferListener= */ null,
connectTimeoutMs,
readTimeoutMs,
@ -212,9 +189,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param transferListener An optional listener.
* @param fallbackFactory A {@link HttpDataSource.Factory} which is used as a fallback in case no
* suitable CronetEngine can be build.
@ -222,11 +196,16 @@ public final class CronetDataSourceFactory extends BaseFactory {
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
@Nullable TransferListener transferListener,
HttpDataSource.Factory fallbackFactory) {
this(cronetEngineWrapper, executor, contentTypePredicate, transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false, fallbackFactory);
this(
cronetEngineWrapper,
executor,
transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
false,
fallbackFactory);
}
/**
@ -241,22 +220,27 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param transferListener An optional listener.
* @param userAgent A user agent used to create a fallback HttpDataSource if needed.
*/
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
@Nullable TransferListener transferListener,
String userAgent) {
this(cronetEngineWrapper, executor, contentTypePredicate, transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false,
new DefaultHttpDataSourceFactory(userAgent, transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false));
this(
cronetEngineWrapper,
executor,
transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
false,
new DefaultHttpDataSourceFactory(
userAgent,
transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
false));
}
/**
@ -267,9 +251,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param transferListener An optional listener.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
@ -279,16 +260,20 @@ public final class CronetDataSourceFactory extends BaseFactory {
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
@Nullable TransferListener transferListener,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
String userAgent) {
this(cronetEngineWrapper, executor, contentTypePredicate, transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, resetTimeoutOnRedirects,
new DefaultHttpDataSourceFactory(userAgent, transferListener, connectTimeoutMs,
readTimeoutMs, resetTimeoutOnRedirects));
this(
cronetEngineWrapper,
executor,
transferListener,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
resetTimeoutOnRedirects,
new DefaultHttpDataSourceFactory(
userAgent, transferListener, connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects));
}
/**
@ -299,9 +284,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
*
* @param cronetEngineWrapper A {@link CronetEngineWrapper}.
* @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
* @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
* predicate then an {@link InvalidContentTypeException} is thrown from {@link
* CronetDataSource#open}.
* @param transferListener An optional listener.
* @param connectTimeoutMs The connection timeout, in milliseconds.
* @param readTimeoutMs The read timeout, in milliseconds.
@ -312,7 +294,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
public CronetDataSourceFactory(
CronetEngineWrapper cronetEngineWrapper,
Executor executor,
Predicate<String> contentTypePredicate,
@Nullable TransferListener transferListener,
int connectTimeoutMs,
int readTimeoutMs,
@ -320,7 +301,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
HttpDataSource.Factory fallbackFactory) {
this.cronetEngineWrapper = cronetEngineWrapper;
this.executor = executor;
this.contentTypePredicate = contentTypePredicate;
this.transferListener = transferListener;
this.connectTimeoutMs = connectTimeoutMs;
this.readTimeoutMs = readTimeoutMs;
@ -339,7 +319,6 @@ public final class CronetDataSourceFactory extends BaseFactory {
new CronetDataSource(
cronetEngine,
executor,
contentTypePredicate,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,

View file

@ -16,9 +16,11 @@
package com.google.android.exoplayer2.ext.cronet;
import android.content.Context;
import android.support.annotation.IntDef;
import android.util.Log;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Field;
@ -36,13 +38,14 @@ public final class CronetEngineWrapper {
private static final String TAG = "CronetEngineWrapper";
private final CronetEngine cronetEngine;
private final @CronetEngineSource int cronetEngineSource;
@Nullable private final CronetEngine cronetEngine;
@CronetEngineSource private final int cronetEngineSource;
/**
* Source of {@link CronetEngine}. One of {@link #SOURCE_NATIVE}, {@link #SOURCE_GMS}, {@link
* #SOURCE_UNKNOWN}, {@link #SOURCE_USER_PROVIDED} or {@link #SOURCE_UNAVAILABLE}.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef({SOURCE_NATIVE, SOURCE_GMS, SOURCE_UNKNOWN, SOURCE_USER_PROVIDED, SOURCE_UNAVAILABLE})
public @interface CronetEngineSource {}
@ -142,7 +145,8 @@ public final class CronetEngineWrapper {
*
* @return A {@link CronetEngineSource} value.
*/
public @CronetEngineSource int getCronetEngineSource() {
@CronetEngineSource
public int getCronetEngineSource() {
return cronetEngineSource;
}
@ -151,13 +155,14 @@ public final class CronetEngineWrapper {
*
* @return The CronetEngine, or null if no CronetEngine is available.
*/
@Nullable
/* package */ CronetEngine getCronetEngine() {
return cronetEngine;
}
private static class CronetProviderComparator implements Comparator<CronetProvider> {
private final String gmsCoreCronetName;
@Nullable private final String gmsCoreCronetName;
private final boolean preferGMSCoreCronet;
// Multi-catch can only be used for API 19+ in this case.

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
@NonNullApi
package com.google.android.exoplayer2.ext.cronet;
import com.google.android.exoplayer2.util.NonNullApi;

View file

@ -14,4 +14,6 @@
limitations under the License.
-->
<manifest package="com.google.android.exoplayer2.ext.cronet"/>
<manifest package="com.google.android.exoplayer2.ext.cronet">
<uses-sdk/>
</manifest>

View file

@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
@ -28,10 +29,9 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
/** Tests for {@link ByteArrayUploadDataProvider}. */
@RunWith(RobolectricTestRunner.class)
@RunWith(AndroidJUnit4.class)
public final class ByteArrayUploadDataProviderTest {
private static final byte[] TEST_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

View file

@ -17,8 +17,8 @@ package com.google.android.exoplayer2.ext.cronet;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
@ -31,13 +31,13 @@ import static org.mockito.Mockito.when;
import android.net.Uri;
import android.os.ConditionVariable;
import android.os.SystemClock;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException;
import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.Predicate;
import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.net.SocketTimeoutException;
@ -62,10 +62,9 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
/** Tests for {@link CronetDataSource}. */
@RunWith(RobolectricTestRunner.class)
@RunWith(AndroidJUnit4.class)
public final class CronetDataSourceTest {
private static final int TEST_CONNECT_TIMEOUT_MS = 100;
@ -85,7 +84,6 @@ public final class CronetDataSourceTest {
@Mock private UrlRequest.Builder mockUrlRequestBuilder;
@Mock private UrlRequest mockUrlRequest;
@Mock private Predicate<String> mockContentTypePredicate;
@Mock private TransferListener mockTransferListener;
@Mock private Executor mockExecutor;
@Mock private NetworkException mockNetworkException;
@ -95,21 +93,19 @@ public final class CronetDataSourceTest {
private boolean redirectCalled;
@Before
public void setUp() throws Exception {
public void setUp() {
MockitoAnnotations.initMocks(this);
dataSourceUnderTest =
new CronetDataSource(
mockCronetEngine,
mockExecutor,
mockContentTypePredicate,
TEST_CONNECT_TIMEOUT_MS,
TEST_READ_TIMEOUT_MS,
true, // resetTimeoutOnRedirects
/* resetTimeoutOnRedirects= */ true,
Clock.DEFAULT,
null,
false);
/* defaultRequestProperties= */ null,
/* handleSetCookieRequests= */ false);
dataSourceUnderTest.addTransferListener(mockTransferListener);
when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true);
when(mockCronetEngine.newUrlRequestBuilder(
anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
.thenReturn(mockUrlRequestBuilder);
@ -283,7 +279,13 @@ public final class CronetDataSourceTest {
@Test
public void testRequestOpenValidatesContentTypePredicate() {
mockResponseStartSuccess();
when(mockContentTypePredicate.evaluate(anyString())).thenReturn(false);
ArrayList<String> testedContentTypes = new ArrayList<>();
dataSourceUnderTest.setContentTypePredicate(
(String input) -> {
testedContentTypes.add(input);
return false;
});
try {
dataSourceUnderTest.open(testDataSpec);
@ -292,7 +294,8 @@ public final class CronetDataSourceTest {
assertThat(e instanceof HttpDataSource.InvalidContentTypeException).isTrue();
// Check for connection not automatically closed.
verify(mockUrlRequest, never()).cancel();
verify(mockContentTypePredicate).evaluate(TEST_CONTENT_TYPE);
assertThat(testedContentTypes).hasSize(1);
assertThat(testedContentTypes.get(0)).isEqualTo(TEST_CONTENT_TYPE);
}
}
@ -551,6 +554,260 @@ public final class CronetDataSourceTest {
assertThat(bytesRead).isEqualTo(16);
}
@Test
public void testRequestReadByteBufferTwice() throws HttpDataSourceException {
mockResponseStartSuccess();
mockReadSuccess(0, 16);
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(8);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
// Use a wrapped ByteBuffer instead of direct for coverage.
returnedBuffer.rewind();
bytesRead = dataSourceUnderTest.read(returnedBuffer);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(8, 8));
assertThat(bytesRead).isEqualTo(8);
// Separate cronet calls for each read.
verify(mockUrlRequest, times(2)).read(any(ByteBuffer.class));
verify(mockTransferListener, times(2))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
}
@Test
public void testRequestIntermixRead() throws HttpDataSourceException {
mockResponseStartSuccess();
// Chunking reads into parts 6, 7, 8, 9.
mockReadSuccess(0, 30);
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(6);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 6));
assertThat(bytesRead).isEqualTo(6);
byte[] returnedBytes = new byte[7];
bytesRead += dataSourceUnderTest.read(returnedBytes, 0, 7);
assertThat(returnedBytes).isEqualTo(buildTestDataArray(6, 7));
assertThat(bytesRead).isEqualTo(6 + 7);
returnedBuffer = ByteBuffer.allocateDirect(8);
bytesRead += dataSourceUnderTest.read(returnedBuffer);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(13, 8));
assertThat(bytesRead).isEqualTo(6 + 7 + 8);
returnedBytes = new byte[9];
bytesRead += dataSourceUnderTest.read(returnedBytes, 0, 9);
assertThat(returnedBytes).isEqualTo(buildTestDataArray(21, 9));
assertThat(bytesRead).isEqualTo(6 + 7 + 8 + 9);
// First ByteBuffer call. The first byte[] call populates enough bytes for the rest.
verify(mockUrlRequest, times(2)).read(any(ByteBuffer.class));
verify(mockTransferListener, times(1))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6);
verify(mockTransferListener, times(1))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 7);
verify(mockTransferListener, times(1))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
verify(mockTransferListener, times(1))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 9);
}
@Test
public void testSecondRequestNoContentLengthReadByteBuffer() throws HttpDataSourceException {
mockResponseStartSuccess();
testResponseHeader.put("Content-Length", Long.toString(1L));
mockReadSuccess(0, 16);
// First request.
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
dataSourceUnderTest.read(returnedBuffer);
dataSourceUnderTest.close();
testResponseHeader.remove("Content-Length");
mockReadSuccess(0, 16);
// Second request.
dataSourceUnderTest.open(testDataSpec);
returnedBuffer = ByteBuffer.allocateDirect(16);
returnedBuffer.limit(10);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(10);
returnedBuffer.limit(returnedBuffer.capacity());
bytesRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(6);
returnedBuffer.rewind();
bytesRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(C.RESULT_END_OF_INPUT);
}
@Test
public void testRangeRequestWith206ResponseReadByteBuffer() throws HttpDataSourceException {
mockResponseStartSuccess();
mockReadSuccess(1000, 5000);
testUrlResponseInfo = createUrlResponseInfo(206); // Server supports range requests.
testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(16);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(1000, 16));
verify(mockTransferListener)
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
}
@Test
public void testRangeRequestWith200ResponseReadByteBuffer() throws HttpDataSourceException {
// Tests for skipping bytes.
mockResponseStartSuccess();
mockReadSuccess(0, 7000);
testUrlResponseInfo = createUrlResponseInfo(200); // Server does not support range requests.
testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(16);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(1000, 16));
verify(mockTransferListener)
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
}
@Test
public void testReadByteBufferWithUnsetLength() throws HttpDataSourceException {
testResponseHeader.remove("Content-Length");
mockResponseStartSuccess();
mockReadSuccess(0, 16);
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
returnedBuffer.limit(8);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
assertThat(bytesRead).isEqualTo(8);
verify(mockTransferListener)
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
}
@Test
public void testReadByteBufferReturnsWhatItCan() throws HttpDataSourceException {
mockResponseStartSuccess();
mockReadSuccess(0, 16);
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(24);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 16));
assertThat(bytesRead).isEqualTo(16);
verify(mockTransferListener)
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
}
@Test
public void testOverreadByteBuffer() throws HttpDataSourceException {
testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 16, null);
testResponseHeader.put("Content-Length", Long.toString(16L));
mockResponseStartSuccess();
mockReadSuccess(0, 16);
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
int bytesRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(8);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
// The current buffer is kept if not completely consumed by DataSource reader.
returnedBuffer = ByteBuffer.allocateDirect(6);
bytesRead += dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(14);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(8, 6));
// 2 bytes left at this point.
returnedBuffer = ByteBuffer.allocateDirect(8);
bytesRead += dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesRead).isEqualTo(16);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(14, 2));
// Called on each.
verify(mockUrlRequest, times(3)).read(any(ByteBuffer.class));
verify(mockTransferListener, times(1))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
verify(mockTransferListener, times(1))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 6);
verify(mockTransferListener, times(1))
.onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 2);
// Now we already returned the 16 bytes initially asked.
// Try to read again even though all requested 16 bytes are already returned.
// Return C.RESULT_END_OF_INPUT
returnedBuffer = ByteBuffer.allocateDirect(16);
int bytesOverRead = dataSourceUnderTest.read(returnedBuffer);
assertThat(bytesOverRead).isEqualTo(C.RESULT_END_OF_INPUT);
assertThat(returnedBuffer.position()).isEqualTo(0);
// C.RESULT_END_OF_INPUT should not be reported though the TransferListener.
verify(mockTransferListener, never())
.onBytesTransferred(
dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, C.RESULT_END_OF_INPUT);
// Number of calls to cronet should not have increased.
verify(mockUrlRequest, times(3)).read(any(ByteBuffer.class));
// Check for connection not automatically closed.
verify(mockUrlRequest, never()).cancel();
assertThat(bytesRead).isEqualTo(16);
}
@Test
public void testClosedMeansClosedReadByteBuffer() throws HttpDataSourceException {
mockResponseStartSuccess();
mockReadSuccess(0, 16);
int bytesRead = 0;
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(16);
returnedBuffer.limit(8);
bytesRead += dataSourceUnderTest.read(returnedBuffer);
returnedBuffer.flip();
assertThat(copyByteBufferToArray(returnedBuffer)).isEqualTo(buildTestDataArray(0, 8));
assertThat(bytesRead).isEqualTo(8);
dataSourceUnderTest.close();
verify(mockTransferListener)
.onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
try {
bytesRead += dataSourceUnderTest.read(returnedBuffer);
fail();
} catch (IllegalStateException e) {
// Expected.
}
// 16 bytes were attempted but only 8 should have been successfully read.
assertThat(bytesRead).isEqualTo(8);
}
@Test
public void testConnectTimeout() throws InterruptedException {
long startTimeMs = SystemClock.elapsedRealtime();
@ -734,7 +991,6 @@ public final class CronetDataSourceTest {
new CronetDataSource(
mockCronetEngine,
mockExecutor,
mockContentTypePredicate,
TEST_CONNECT_TIMEOUT_MS,
TEST_READ_TIMEOUT_MS,
true, // resetTimeoutOnRedirects
@ -765,13 +1021,12 @@ public final class CronetDataSourceTest {
new CronetDataSource(
mockCronetEngine,
mockExecutor,
mockContentTypePredicate,
TEST_CONNECT_TIMEOUT_MS,
TEST_READ_TIMEOUT_MS,
true, // resetTimeoutOnRedirects
/* resetTimeoutOnRedirects= */ true,
Clock.DEFAULT,
null,
true);
/* defaultRequestProperties= */ null,
/* handleSetCookieRequests= */ true);
dataSourceUnderTest.addTransferListener(mockTransferListener);
dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
@ -804,13 +1059,12 @@ public final class CronetDataSourceTest {
new CronetDataSource(
mockCronetEngine,
mockExecutor,
mockContentTypePredicate,
TEST_CONNECT_TIMEOUT_MS,
TEST_READ_TIMEOUT_MS,
true, // resetTimeoutOnRedirects
/* resetTimeoutOnRedirects= */ true,
Clock.DEFAULT,
null,
true);
/* defaultRequestProperties= */ null,
/* handleSetCookieRequests= */ true);
dataSourceUnderTest.addTransferListener(mockTransferListener);
mockSingleRedirectSuccess();
mockFollowRedirectSuccess();
@ -855,6 +1109,36 @@ public final class CronetDataSourceTest {
}
}
@Test
public void testReadByteBufferFailure() throws HttpDataSourceException {
mockResponseStartSuccess();
mockReadFailure();
dataSourceUnderTest.open(testDataSpec);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
try {
dataSourceUnderTest.read(returnedBuffer);
fail("dataSourceUnderTest.read() returned, but IOException expected");
} catch (IOException e) {
// Expected.
}
}
@Test
public void testReadNonDirectedByteBufferFailure() throws HttpDataSourceException {
mockResponseStartSuccess();
mockReadFailure();
dataSourceUnderTest.open(testDataSpec);
byte[] returnedBuffer = new byte[8];
try {
dataSourceUnderTest.read(ByteBuffer.wrap(returnedBuffer));
fail("dataSourceUnderTest.read() returned, but IllegalArgumentException expected");
} catch (IllegalArgumentException e) {
// Expected.
}
}
@Test
public void testReadInterrupted() throws HttpDataSourceException, InterruptedException {
mockResponseStartSuccess();
@ -886,6 +1170,37 @@ public final class CronetDataSourceTest {
timedOutLatch.await();
}
@Test
public void testReadByteBufferInterrupted() throws HttpDataSourceException, InterruptedException {
mockResponseStartSuccess();
dataSourceUnderTest.open(testDataSpec);
final ConditionVariable startCondition = buildReadStartedCondition();
final CountDownLatch timedOutLatch = new CountDownLatch(1);
ByteBuffer returnedBuffer = ByteBuffer.allocateDirect(8);
Thread thread =
new Thread() {
@Override
public void run() {
try {
dataSourceUnderTest.read(returnedBuffer);
fail();
} catch (HttpDataSourceException e) {
// Expected.
assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue();
timedOutLatch.countDown();
}
}
};
thread.start();
startCondition.block();
assertNotCountedDown(timedOutLatch);
// Now we interrupt.
thread.interrupt();
timedOutLatch.await();
}
@Test
public void testAllowDirectExecutor() throws HttpDataSourceException {
testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
@ -1064,4 +1379,17 @@ public final class CronetDataSourceTest {
testBuffer.flip();
return testBuffer;
}
// Returns a copy of what is remaining in the src buffer from the current position to capacity.
private static byte[] copyByteBufferToArray(ByteBuffer src) {
if (src == null) {
return null;
}
byte[] copy = new byte[src.remaining()];
int index = 0;
while (src.hasRemaining()) {
copy[index++] = src.get();
}
return copy;
}
}

View file

@ -1 +0,0 @@
manifest=src/test/AndroidManifest.xml

View file

@ -46,7 +46,7 @@ HOST_PLATFORM="linux-x86_64"
be supported. See the [Supported formats][] page for more details of the
available flags.
For example, to fetch and build for armeabi-v7a,
For example, to fetch and build FFmpeg release 4.0 for armeabi-v7a,
arm64-v8a and x86 on Linux x86_64:
```
@ -71,7 +71,7 @@ COMMON_OPTIONS="\
" && \
cd "${FFMPEG_EXT_PATH}/jni" && \
(git -C ffmpeg pull || git clone git://source.ffmpeg.org/ffmpeg ffmpeg) && \
cd ffmpeg && \
cd ffmpeg && git checkout release/4.0 && \
./configure \
--libdir=android-libs/armeabi-v7a \
--arch=arm \
@ -147,11 +147,11 @@ then implement your own logic to use the renderer for a given track.
[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
[#2781]: https://github.com/google/ExoPlayer/issues/2781
[Supported formats]: https://google.github.io/ExoPlayer/supported-formats.html#ffmpeg-extension
[Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension
## Links ##
* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.ffmpeg.*`
belong to this module.
[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html
[Javadoc]: https://exoplayer.dev/doc/reference/index.html

View file

@ -16,7 +16,6 @@ apply plugin: 'com.android.library'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -33,12 +32,16 @@ android {
jniLibs.srcDir 'src/main/libs'
jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio.
}
testOptions.unitTests.includeAndroidResources = true
}
dependencies {
implementation project(modulePrefix + 'library-core')
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
implementation 'androidx.annotation:annotation:1.1.0'
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
testImplementation project(modulePrefix + 'testutils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {

View file

@ -16,7 +16,7 @@
package com.google.android.exoplayer2.ext.ffmpeg;
import android.os.Handler;
import android.support.annotation.Nullable;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.Format;
@ -92,8 +92,8 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
}
@Override
protected int supportsFormatInternal(DrmSessionManager<ExoMediaCrypto> drmSessionManager,
Format format) {
protected int supportsFormatInternal(
@Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, Format format) {
Assertions.checkNotNull(format.sampleMimeType);
if (!FfmpegLibrary.isAvailable()) {
return FORMAT_UNSUPPORTED_TYPE;
@ -113,7 +113,7 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
}
@Override
protected FfmpegDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto)
protected FfmpegDecoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto)
throws FfmpegDecoderException {
int initialInputBufferSize =
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
@ -145,12 +145,13 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
}
private boolean isOutputSupported(Format inputFormat) {
return shouldUseFloatOutput(inputFormat) || supportsOutputEncoding(C.ENCODING_PCM_16BIT);
return shouldUseFloatOutput(inputFormat)
|| supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_16BIT);
}
private boolean shouldUseFloatOutput(Format inputFormat) {
Assertions.checkNotNull(inputFormat.sampleMimeType);
if (!enableFloatOutput || !supportsOutputEncoding(C.ENCODING_PCM_FLOAT)) {
if (!enableFloatOutput || !supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_FLOAT)) {
return false;
}
switch (inputFormat.sampleMimeType) {

View file

@ -15,7 +15,7 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
import android.support.annotation.Nullable;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
@ -37,8 +37,12 @@ import java.util.List;
private static final int OUTPUT_BUFFER_SIZE_16BIT = 65536;
private static final int OUTPUT_BUFFER_SIZE_32BIT = OUTPUT_BUFFER_SIZE_16BIT * 2;
// Error codes matching ffmpeg_jni.cc.
private static final int DECODER_ERROR_INVALID_DATA = -1;
private static final int DECODER_ERROR_OTHER = -2;
private final String codecName;
private final @Nullable byte[] extraData;
@Nullable private final byte[] extraData;
private final @C.Encoding int encoding;
private final int outputBufferSize;
@ -106,8 +110,14 @@ import java.util.List;
int inputSize = inputData.limit();
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, outputBufferSize);
if (result < 0) {
return new FfmpegDecoderException("Error decoding (see logcat). Code: " + result);
if (result == DECODER_ERROR_INVALID_DATA) {
// Treat invalid data errors as non-fatal to match the behavior of MediaCodec. No output will
// be produced for this buffer, so mark it as decode-only to ensure that the audio sink's
// position is reset when more audio is produced.
outputBuffer.setFlags(C.BUFFER_FLAG_DECODE_ONLY);
return null;
} else if (result == DECODER_ERROR_OTHER) {
return new FfmpegDecoderException("Error decoding (see logcat).");
}
if (!hasOutputFormat) {
channelCount = ffmpegGetChannelCount(nativeContext);
@ -162,28 +172,49 @@ import java.util.List;
private static @Nullable byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
case MimeTypes.AUDIO_ALAC:
case MimeTypes.AUDIO_OPUS:
return initializationData.get(0);
case MimeTypes.AUDIO_ALAC:
return getAlacExtraData(initializationData);
case MimeTypes.AUDIO_VORBIS:
byte[] header0 = initializationData.get(0);
byte[] header1 = initializationData.get(1);
byte[] extraData = new byte[header0.length + header1.length + 6];
extraData[0] = (byte) (header0.length >> 8);
extraData[1] = (byte) (header0.length & 0xFF);
System.arraycopy(header0, 0, extraData, 2, header0.length);
extraData[header0.length + 2] = 0;
extraData[header0.length + 3] = 0;
extraData[header0.length + 4] = (byte) (header1.length >> 8);
extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
return extraData;
return getVorbisExtraData(initializationData);
default:
// Other codecs do not require extra data.
return null;
}
}
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
// FFmpeg's ALAC decoder expects an ALAC atom, which contains the ALAC "magic cookie", as extra
// data. initializationData[0] contains only the magic cookie, and so we need to package it into
// an ALAC atom. See:
// https://ffmpeg.org/doxygen/0.6/alac_8c.html
// https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt
byte[] magicCookie = initializationData.get(0);
int alacAtomLength = 12 + magicCookie.length;
ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
alacAtom.putInt(alacAtomLength);
alacAtom.putInt(0x616c6163); // type=alac
alacAtom.putInt(0); // version=0, flags=0
alacAtom.put(magicCookie, /* offset= */ 0, magicCookie.length);
return alacAtom.array();
}
private static byte[] getVorbisExtraData(List<byte[]> initializationData) {
byte[] header0 = initializationData.get(0);
byte[] header1 = initializationData.get(1);
byte[] extraData = new byte[header0.length + header1.length + 6];
extraData[0] = (byte) (header0.length >> 8);
extraData[1] = (byte) (header0.length & 0xFF);
System.arraycopy(header0, 0, extraData, 2, header0.length);
extraData[header0.length + 2] = 0;
extraData[header0.length + 3] = 0;
extraData[header0.length + 4] = (byte) (header1.length >> 8);
extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
return extraData;
}
private native long ffmpegInitialize(
String codecName,
@Nullable byte[] extraData,

View file

@ -15,10 +15,11 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
import android.support.annotation.Nullable;
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;
/**
@ -30,6 +31,8 @@ public final class FfmpegLibrary {
ExoPlayerLibraryInfo.registerModule("goog.exo.ffmpeg");
}
private static final String TAG = "FfmpegLibrary";
private static final LibraryLoader LOADER =
new LibraryLoader("avutil", "avresample", "avcodec", "ffmpeg");
@ -69,7 +72,14 @@ public final class FfmpegLibrary {
return false;
}
String codecName = getCodecName(mimeType, encoding);
return codecName != null && ffmpegHasDecoder(codecName);
if (codecName == null) {
return false;
}
if (!ffmpegHasDecoder(codecName)) {
Log.w(TAG, "No " + codecName + " decoder available. Check the FFmpeg build configuration.");
return false;
}
return true;
}
/**

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
@NonNullApi
package com.google.android.exoplayer2.ext.ffmpeg;
import com.google.android.exoplayer2.util.NonNullApi;

View file

@ -63,6 +63,10 @@ static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16;
// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT.
static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT;
// Error codes matching FfmpegDecoder.java.
static const int DECODER_ERROR_INVALID_DATA = -1;
static const int DECODER_ERROR_OTHER = -2;
/**
* Returns the AVCodec with the specified name, or NULL if it is not available.
*/
@ -79,7 +83,7 @@ AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData,
/**
* Decodes the packet into the output buffer, returning the number of bytes
* written, or a negative value in the case of an error.
* written, or a negative DECODER_ERROR constant value in the case of an error.
*/
int decodePacket(AVCodecContext *context, AVPacket *packet,
uint8_t *outputBuffer, int outputSize);
@ -238,6 +242,7 @@ AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData,
context->channels = rawChannelCount;
context->channel_layout = av_get_default_channel_layout(rawChannelCount);
}
context->err_recognition = AV_EF_IGNORE_ERR;
int result = avcodec_open2(context, codec, NULL);
if (result < 0) {
logError("avcodec_open2", result);
@ -254,7 +259,8 @@ int decodePacket(AVCodecContext *context, AVPacket *packet,
result = avcodec_send_packet(context, packet);
if (result) {
logError("avcodec_send_packet", result);
return result;
return result == AVERROR_INVALIDDATA ? DECODER_ERROR_INVALID_DATA
: DECODER_ERROR_OTHER;
}
// Dequeue output data until it runs out.

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest package="com.google.android.exoplayer2.ext.ffmpeg">
<uses-sdk/>
</manifest>

View file

@ -0,0 +1,33 @@
/*
* 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.ext.ffmpeg;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.testutil.DefaultRenderersFactoryAsserts;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer}. */
@RunWith(AndroidJUnit4.class)
public final class DefaultRenderersFactoryTest {
@Test
public void createRenderers_instantiatesVpxRenderer() {
DefaultRenderersFactoryAsserts.assertExtensionRendererCreated(
FfmpegAudioRenderer.class, C.TRACK_TYPE_AUDIO);
}
}

View file

@ -28,18 +28,19 @@ EXOPLAYER_ROOT="$(pwd)"
FLAC_EXT_PATH="${EXOPLAYER_ROOT}/extensions/flac/src/main"
```
* Download the [Android NDK][] and set its location in an environment variable:
* Download the [Android NDK][] (version <= 17c) and set its location in an
environment variable:
```
NDK_PATH="<path to Android NDK>"
```
* Download and extract flac-1.3.1 as "${FLAC_EXT_PATH}/jni/flac" folder:
* Download and extract flac-1.3.2 as "${FLAC_EXT_PATH}/jni/flac" folder:
```
cd "${FLAC_EXT_PATH}/jni" && \
curl https://ftp.osuosl.org/pub/xiph/releases/flac/flac-1.3.1.tar.xz | tar xJ && \
mv flac-1.3.1 flac
curl https://ftp.osuosl.org/pub/xiph/releases/flac/flac-1.3.2.tar.xz | tar xJ && \
mv flac-1.3.2 flac
```
* Build the JNI native libraries from the command line:
@ -94,4 +95,4 @@ player, then implement your own logic to use the renderer for a given track.
* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.flac.*`
belong to this module.
[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html
[Javadoc]: https://exoplayer.dev/doc/reference/index.html

View file

@ -16,7 +16,6 @@ apply plugin: 'com.android.library'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@ -34,14 +33,18 @@ android {
jniLibs.srcDir 'src/main/libs'
jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio.
}
testOptions.unitTests.includeAndroidResources = true
}
dependencies {
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
implementation project(modulePrefix + 'library-core')
androidTestImplementation 'androidx.test:runner:' + testRunnerVersion
implementation 'androidx.annotation:annotation:1.1.0'
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
androidTestImplementation project(modulePrefix + 'testutils')
testImplementation project(modulePrefix + 'testutils-robolectric')
androidTestImplementation 'androidx.test:runner:' + androidXTestVersion
testImplementation project(modulePrefix + 'testutils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {

View file

@ -9,6 +9,9 @@
-keep class com.google.android.exoplayer2.ext.flac.FlacDecoderJni {
*;
}
-keep class com.google.android.exoplayer2.util.FlacStreamInfo {
-keep class com.google.android.exoplayer2.util.FlacStreamMetadata {
*;
}
-keep class com.google.android.exoplayer2.metadata.flac.PictureFrame {
*;
}

View file

@ -18,6 +18,9 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.google.android.exoplayer2.ext.flac.test">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-sdk/>
<application android:debuggable="true"
android:allowBackup="false"
tools:ignore="MissingApplicationIcon,HardcodedDebugMode">

View file

@ -16,22 +16,26 @@
package com.google.android.exoplayer2.ext.flac;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;
import android.test.InstrumentationTestCase;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.extractor.SeekMap;
import com.google.android.exoplayer2.testutil.FakeExtractorInput;
import com.google.android.exoplayer2.testutil.TestUtil;
import java.io.IOException;
import org.junit.Before;
import org.junit.runner.RunWith;
/** Unit test for {@link FlacBinarySearchSeeker}. */
public final class FlacBinarySearchSeekerTest extends InstrumentationTestCase {
@RunWith(AndroidJUnit4.class)
public final class FlacBinarySearchSeekerTest {
private static final String NOSEEKTABLE_FLAC = "bear_no_seek.flac";
private static final int DURATION_US = 2_741_000;
@Override
protected void setUp() throws Exception {
super.setUp();
@Before
public void setUp() {
if (!FlacLibrary.isAvailable()) {
fail("Flac library not available.");
}
@ -39,7 +43,8 @@ public final class FlacBinarySearchSeekerTest extends InstrumentationTestCase {
public void testGetSeekMap_returnsSeekMapWithCorrectDuration()
throws IOException, FlacDecoderException, InterruptedException {
byte[] data = TestUtil.getByteArray(getInstrumentation().getContext(), NOSEEKTABLE_FLAC);
byte[] data =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), NOSEEKTABLE_FLAC);
FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build();
FlacDecoderJni decoderJni = new FlacDecoderJni();
@ -47,7 +52,10 @@ public final class FlacBinarySearchSeekerTest extends InstrumentationTestCase {
FlacBinarySearchSeeker seeker =
new FlacBinarySearchSeeker(
decoderJni.decodeMetadata(), /* firstFramePosition= */ 0, data.length, decoderJni);
decoderJni.decodeStreamMetadata(),
/* firstFramePosition= */ 0,
data.length,
decoderJni);
SeekMap seekMap = seeker.getSeekMap();
assertThat(seekMap).isNotNull();
@ -57,14 +65,18 @@ public final class FlacBinarySearchSeekerTest extends InstrumentationTestCase {
public void testSetSeekTargetUs_returnsSeekPending()
throws IOException, FlacDecoderException, InterruptedException {
byte[] data = TestUtil.getByteArray(getInstrumentation().getContext(), NOSEEKTABLE_FLAC);
byte[] data =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), NOSEEKTABLE_FLAC);
FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build();
FlacDecoderJni decoderJni = new FlacDecoderJni();
decoderJni.setData(input);
FlacBinarySearchSeeker seeker =
new FlacBinarySearchSeeker(
decoderJni.decodeMetadata(), /* firstFramePosition= */ 0, data.length, decoderJni);
decoderJni.decodeStreamMetadata(),
/* firstFramePosition= */ 0,
data.length,
decoderJni);
seeker.setSeekTargetUs(/* timeUs= */ 1000);
assertThat(seeker.isSeeking()).isTrue();

Some files were not shown because too many files have changed in this diff Show more