Merge pull request #6752 from google/dev-v2-r2.11.0

r2.11.0
This commit is contained in:
Oliver Woodman 2019-12-11 16:11:21 +00:00 committed by GitHub
commit 76962d50f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
821 changed files with 29789 additions and 14700 deletions

3
.gitignore vendored
View File

@ -57,6 +57,9 @@ extensions/vp9/src/main/jni/libvpx
extensions/vp9/src/main/jni/libvpx_android_configs
extensions/vp9/src/main/jni/libyuv
# AV1 extension
extensions/av1/src/main/jni/libgav1
# Opus extension
extensions/opus/src/main/jni/libopus

View File

@ -62,6 +62,9 @@ extensions/vp9/src/main/jni/libvpx
extensions/vp9/src/main/jni/libvpx_android_configs
extensions/vp9/src/main/jni/libyuv
# AV1 extension
extensions/av1/src/main/jni/libgav1
# Opus extension
extensions/opus/src/main/jni/libopus

View File

@ -1,5 +1,167 @@
# Release notes #
### 2.11.0 (2019-12-11) ###
* Core library:
* Replace `ExoPlayerFactory` by `SimpleExoPlayer.Builder` and
`ExoPlayer.Builder`.
* Add automatic `WakeLock` handling to `SimpleExoPlayer`, which can be enabled
by calling `SimpleExoPlayer.setHandleWakeLock`
([#5846](https://github.com/google/ExoPlayer/issues/5846)). To use this
feature, you must add the
[WAKE_LOCK](https://developer.android.com/reference/android/Manifest.permission.html#WAKE_LOCK)
permission to your application's manifest file.
* Add automatic "audio becoming noisy" handling to `SimpleExoPlayer`, which
can be enabled by calling `SimpleExoPlayer.setHandleAudioBecomingNoisy`.
* Wrap decoder exceptions in a new `DecoderException` class and report them as
renderer errors.
* Add `Timeline.Window.isLive` to indicate that a window is a live stream
([#2668](https://github.com/google/ExoPlayer/issues/2668) and
[#5973](https://github.com/google/ExoPlayer/issues/5973)).
* Add `Timeline.Window.uid` to uniquely identify window instances.
* Deprecate `setTag` parameter of `Timeline.getWindow`. Tags will always be
set.
* Deprecate passing the manifest directly to
`Player.EventListener.onTimelineChanged`. It can be accessed through
`Timeline.Window.manifest` or `Player.getCurrentManifest()`
* Add `MediaSource.enable` and `MediaSource.disable` to improve resource
management in playlists.
* Add `MediaPeriod.isLoading` to improve `Player.isLoading` state.
* Fix issue where player errors are thrown too early at playlist transitions
([#5407](https://github.com/google/ExoPlayer/issues/5407)).
* Add `Format` and renderer support flags to renderer `ExoPlaybackException`s.
* DRM:
* Inject `DrmSessionManager` into the `MediaSources` instead of `Renderers`.
This allows each `MediaSource` in a `ConcatenatingMediaSource` to use a
different `DrmSessionManager`
([#5619](https://github.com/google/ExoPlayer/issues/5619)).
* Add `DefaultDrmSessionManager.Builder`, and remove
`DefaultDrmSessionManager` static factory methods that leaked
`ExoMediaDrm` instances
([#4721](https://github.com/google/ExoPlayer/issues/4721)).
* Add support for the use of secure decoders when playing clear content
([#4867](https://github.com/google/ExoPlayer/issues/4867)). This can
be enabled using `DefaultDrmSessionManager.Builder`'s
`setUseDrmSessionsForClearContent` method.
* Add support for custom `LoadErrorHandlingPolicies` in key and provisioning
requests ([#6334](https://github.com/google/ExoPlayer/issues/6334)). Custom
policies can be passed via `DefaultDrmSessionManager.Builder`'s
`setLoadErrorHandlingPolicy` method.
* Use `ExoMediaDrm.Provider` in `OfflineLicenseHelper` to avoid leaking
`ExoMediaDrm` instances
([#4721](https://github.com/google/ExoPlayer/issues/4721)).
* Track selection:
* Update `DefaultTrackSelector` to set a viewport constraint for the default
display by default.
* Update `DefaultTrackSelector` to set text language and role flag
constraints for the device's accessibility settings by default
([#5749](https://github.com/google/ExoPlayer/issues/5749)).
* Add option to set preferred text role flags using
`DefaultTrackSelector.ParametersBuilder.setPreferredTextRoleFlags`.
* Android 10:
* Set `compileSdkVersion` to 29 to enable use of Android 10 APIs.
* Expose new `isHardwareAccelerated`, `isSoftwareOnly` and `isVendor` flags
in `MediaCodecInfo`
([#5839](https://github.com/google/ExoPlayer/issues/5839)).
* Add `allowedCapturePolicy` field to `AudioAttributes` to allow to
configuration of the audio capture policy.
* Video:
* Pass the codec output `MediaFormat` to `VideoFrameMetadataListener`.
* Fix byte order of HDR10+ static metadata to match CTA-861.3.
* Support out-of-band HDR10+ dynamic metadata for VP9 in WebM/Matroska.
* Assume that protected content requires a secure decoder when evaluating
whether `MediaCodecVideoRenderer` supports a given video format
([#5568](https://github.com/google/ExoPlayer/issues/5568)).
* Fix Dolby Vision fallback to AVC and HEVC.
* Fix early end-of-stream detection when using video tunneling, on API level
23 and above.
* Fix an issue where a keyframe was rendered rather than skipped when
performing an exact seek to a non-zero position close to the start of the
stream.
* Audio:
* Fix the start of audio getting truncated when transitioning to a new
item in a playlist of Opus streams.
* Workaround broken raw audio decoding on Oppo R9
([#5782](https://github.com/google/ExoPlayer/issues/5782)).
* Reconfigure audio sink when PCM encoding changes
([#6601](https://github.com/google/ExoPlayer/issues/6601)).
* Allow `AdtsExtractor` to encounter EOF when calculating average frame size
([#6700](https://github.com/google/ExoPlayer/issues/6700)).
* Text:
* Add support for position and overlapping start/end times in SSA/ASS
subtitles ([#6320](https://github.com/google/ExoPlayer/issues/6320)).
* Require an end time or duration for SubRip (SRT) and SubStation Alpha
(SSA/ASS) subtitles. This applies to both sidecar files & subtitles
[embedded in Matroska streams](https://matroska.org/technical/specs/subtitles/index.html).
* UI:
* Make showing and hiding player controls accessible to TalkBack in
`PlayerView`.
* Rename `spherical_view` surface type to `spherical_gl_surface_view`.
* Make it easier to override the shuffle, repeat, fullscreen, VR and small
notification icon assets
([#6709](https://github.com/google/ExoPlayer/issues/6709)).
* Analytics:
* Remove `AnalyticsCollector.Factory`. Instances should be created directly,
and the `Player` should be set by calling `AnalyticsCollector.setPlayer`.
* Add `PlaybackStatsListener` to collect `PlaybackStats` for analysis and
analytics reporting.
* DataSource
* Add `DataSpec.httpRequestHeaders` to support setting per-request headers for
HTTP and HTTPS.
* Remove the `DataSpec.FLAG_ALLOW_ICY_METADATA` flag. Use is replaced by
setting the `IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME` header in
`DataSpec.httpRequestHeaders`.
* Fail more explicitly when local file URIs contain invalid parts (e.g. a
fragment) ([#6470](https://github.com/google/ExoPlayer/issues/6470)).
* DASH: Support negative @r values in segment timelines
([#1787](https://github.com/google/ExoPlayer/issues/1787)).
* HLS:
* Use peak bitrate rather than average bitrate for adaptive track selection.
* Fix issue where streams could get stuck in an infinite buffering state
after a postroll ad
([#6314](https://github.com/google/ExoPlayer/issues/6314)).
* Matroska: Support lacing in Blocks
([#3026](https://github.com/google/ExoPlayer/issues/3026)).
* AV1 extension:
* New in this release. The AV1 extension allows use of the
[libgav1 software decoder](https://chromium.googlesource.com/codecs/libgav1/)
in ExoPlayer. You can read more about playing AV1 videos with ExoPlayer
[here](https://medium.com/google-exoplayer/playing-av1-videos-with-exoplayer-a7cb19bedef9).
* VP9 extension:
* Update to use NDK r20.
* Rename `VpxVideoSurfaceView` to `VideoDecoderSurfaceView` and move it to the
core library.
* Move `LibvpxVideoRenderer.MSG_SET_OUTPUT_BUFFER_RENDERER` to
`C.MSG_SET_OUTPUT_BUFFER_RENDERER`.
* Use `VideoDecoderRenderer` as an implementation of
`VideoDecoderOutputBufferRenderer`, instead of `VideoDecoderSurfaceView`.
* Flac extension: Update to use NDK r20.
* Opus extension: Update to use NDK r20.
* FFmpeg extension:
* Update to use NDK r20.
* Update to use FFmpeg version 4.2. It is necessary to rebuild the native part
of the extension after this change, following the instructions in the
extension's readme.
* MediaSession extension: Add `MediaSessionConnector.setCaptionCallback` to
support `ACTION_SET_CAPTIONING_ENABLED` events.
* GVR extension: This extension is now deprecated.
* Demo apps:
* Add [SurfaceControl demo app](https://github.com/google/ExoPlayer/tree/r2.11.0/demos/surface)
to show how to use the Android 10 `SurfaceControl` API with ExoPlayer
([#677](https://github.com/google/ExoPlayer/issues/677)).
* Add support for subtitle files to the
[Main demo app](https://github.com/google/ExoPlayer/tree/r2.11.0/demos/main)
([#5523](https://github.com/google/ExoPlayer/issues/5523)).
* Remove the IMA demo app. IMA functionality is demonstrated by the
[main demo app](https://github.com/google/ExoPlayer/tree/r2.11.0/demos/main).
* Add basic DRM support to the
[Cast demo app](https://github.com/google/ExoPlayer/tree/r2.11.0/demos/cast).
* TestUtils: Publish the `testutils` module to simplify unit testing with
ExoPlayer ([#6267](https://github.com/google/ExoPlayer/issues/6267)).
* IMA extension: Remove `AdsManager` listeners on release to avoid leaking an
`AdEventListener` provided by the app
([#6687](https://github.com/google/ExoPlayer/issues/6687)).
### 2.10.8 (2019-11-19) ###
* E-AC3 JOC
@ -23,7 +185,7 @@
* MediaSession extension: Update shuffle and repeat modes when playback state
is invalidated ([#6582](https://github.com/google/ExoPlayer/issues/6582)).
* Fix the start of audio getting truncated when transitioning to a new
item in a playlist of opus streams.
item in a playlist of Opus streams.
### 2.10.6 (2019-10-17) ###
@ -264,6 +426,7 @@
* Update `TrackSelection.Factory` interface to support creating all track
selections together.
* Allow to specify a selection reason for a `SelectionOverride`.
* Select audio track based on system language if no preference is provided.
* When no text language preference matches, only select forced text tracks
whose language matches the selected audio language.
* UI:
@ -592,7 +755,7 @@
and `AnalyticsListener` callbacks
([#4361](https://github.com/google/ExoPlayer/issues/4361) and
[#4615](https://github.com/google/ExoPlayer/issues/4615)).
* UI components:
* UI:
* 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.
@ -748,7 +911,7 @@
* OkHttp extension: Fix to correctly include response headers in thrown
`InvalidResponseCodeException`s.
* Add possibility to cancel `PlayerMessage`s.
* UI components:
* UI:
* Add `PlayerView.setKeepContentOnPlayerReset` to keep the currently displayed
video frame or media artwork visible when the player is reset
([#2843](https://github.com/google/ExoPlayer/issues/2843)).
@ -798,7 +961,7 @@
* Support live stream clipping with `ClippingMediaSource`.
* Allow setting tags for all media sources in their factories. The tag of the
current window can be retrieved with `Player.getCurrentTag`.
* UI components:
* UI:
* Add support for displaying error messages and a buffering spinner in
`PlayerView`.
* Add support for listening to `AspectRatioFrameLayout`'s aspect ratio update
@ -962,7 +1125,7 @@
`SsMediaSource.Factory`, and `MergingMediaSource`.
* Play out existing buffer before retrying for progressive live streams
([#1606](https://github.com/google/ExoPlayer/issues/1606)).
* UI components:
* UI:
* Generalized player and control views to allow them to bind with any
`Player`, and renamed them to `PlayerView` and `PlayerControlView`
respectively.

View File

@ -13,17 +13,30 @@
// limitations under the License.
project.ext {
// ExoPlayer version and version code.
releaseVersion = '2.10.8'
releaseVersionCode = 2010008
releaseVersion = '2.11.0'
releaseVersionCode = 2011000
minSdkVersion = 16
targetSdkVersion = 28
compileSdkVersion = 28
appTargetSdkVersion = 29
targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved
compileSdkVersion = 29
dexmakerVersion = '2.21.0'
guavaVersion = '23.5-android'
mockitoVersion = '2.25.0'
robolectricVersion = '4.2'
robolectricVersion = '4.3'
autoValueVersion = '1.6'
autoServiceVersion = '1.0-rc4'
checkerframeworkVersion = '2.5.0'
androidXTestVersion = '1.1.0'
jsr305Version = '3.0.2'
kotlinAnnotationsVersion = '1.3.31'
androidxAnnotationVersion = '1.1.0'
androidxAppCompatVersion = '1.1.0'
androidxCollectionVersion = '1.1.0'
androidxMediaVersion = '1.0.1'
androidxTestCoreVersion = '1.2.0'
androidxTestJUnitVersion = '1.1.1'
androidxTestRunnerVersion = '1.2.0'
androidxTestRulesVersion = '1.2.0'
truthVersion = '0.44'
modulePrefix = ':'
if (gradle.ext.has('exoplayerModulePrefix')) {
modulePrefix += gradle.ext.exoplayerModulePrefix

View File

@ -24,7 +24,7 @@ include modulePrefix + 'library-hls'
include modulePrefix + 'library-smoothstreaming'
include modulePrefix + 'library-ui'
include modulePrefix + 'testutils'
include modulePrefix + 'testutils-robolectric'
include modulePrefix + 'extension-av1'
include modulePrefix + 'extension-ffmpeg'
include modulePrefix + 'extension-flac'
include modulePrefix + 'extension-gvr'
@ -47,7 +47,7 @@ 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-av1').projectDir = new File(rootDir, 'extensions/av1')
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')

View File

@ -26,7 +26,7 @@ android {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
targetSdkVersion project.ext.appTargetSdkVersion
}
buildTypes {
@ -56,10 +56,9 @@ dependencies {
implementation project(modulePrefix + 'library-smoothstreaming')
implementation project(modulePrefix + 'library-ui')
implementation project(modulePrefix + 'extension-cast')
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.appcompat:appcompat:' + androidxAppCompatVersion
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'com.google.android.material:material:1.0.0'
}
apply plugin: 'com.google.android.gms.strict-version-matcher-plugin'

View File

@ -1,399 +0,0 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.castdemo;
import android.content.Context;
import android.net.Uri;
import androidx.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.ext.cast.CastPlayer;
import com.google.android.exoplayer2.ext.cast.MediaItem;
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
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.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;
/** Manages players and an internal media queue for the ExoPlayer/Cast demo app. */
/* package */ class DefaultReceiverPlayerManager
implements PlayerManager, EventListener, SessionAvailabilityListener {
private static final String USER_AGENT = "ExoCastDemoPlayer";
private static final DefaultHttpDataSourceFactory DATA_SOURCE_FACTORY =
new DefaultHttpDataSourceFactory(USER_AGENT);
private final PlayerView localPlayerView;
private final PlayerControlView castControlView;
private final SimpleExoPlayer exoPlayer;
private final CastPlayer castPlayer;
private final ArrayList<MediaItem> mediaQueue;
private final Listener listener;
private final ConcatenatingMediaSource concatenatingMediaSource;
private int currentItemIndex;
private Player currentPlayer;
/**
* 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 DefaultReceiverPlayerManager(
Listener listener,
PlayerView localPlayerView,
PlayerControlView castControlView,
Context context,
CastContext castContext) {
this.listener = listener;
this.localPlayerView = localPlayerView;
this.castControlView = castControlView;
mediaQueue = new ArrayList<>();
currentItemIndex = C.INDEX_UNSET;
concatenatingMediaSource = new ConcatenatingMediaSource();
DefaultTrackSelector trackSelector = new DefaultTrackSelector();
RenderersFactory renderersFactory = new DefaultRenderersFactory(context);
exoPlayer = ExoPlayerFactory.newSimpleInstance(context, renderersFactory, trackSelector);
exoPlayer.addListener(this);
localPlayerView.setPlayer(exoPlayer);
castPlayer = new CastPlayer(castContext);
castPlayer.addListener(this);
castPlayer.setSessionAvailabilityListener(this);
castControlView.setPlayer(castPlayer);
setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer);
}
// Queue manipulation methods.
/**
* Plays a specified queue item in the current player.
*
* @param itemIndex The index of the item to play.
*/
@Override
public void selectQueueItem(int itemIndex) {
setCurrentItem(itemIndex, C.TIME_UNSET, true);
}
/** Returns the index of the currently played item. */
@Override
public int getCurrentItemIndex() {
return currentItemIndex;
}
/**
* Appends {@code item} to the media queue.
*
* @param item The {@link MediaItem} to append.
*/
@Override
public void addItem(MediaItem item) {
mediaQueue.add(item);
concatenatingMediaSource.addMediaSource(buildMediaSource(item));
if (currentPlayer == castPlayer) {
castPlayer.addItems(buildMediaQueueItem(item));
}
}
/** Returns the size of the media queue. */
@Override
public int getMediaQueueSize() {
return mediaQueue.size();
}
/**
* Returns the item at the given index in the media queue.
*
* @param position The index of the item.
* @return The item at the given index in the media queue.
*/
@Override
public MediaItem getItem(int position) {
return mediaQueue.get(position);
}
/**
* Removes the item at the given index from the media queue.
*
* @param item The item to remove.
* @return Whether the removal was successful.
*/
@Override
public boolean removeItem(MediaItem item) {
int itemIndex = mediaQueue.indexOf(item);
if (itemIndex == -1) {
return false;
}
concatenatingMediaSource.removeMediaSource(itemIndex);
if (currentPlayer == castPlayer) {
if (castPlayer.getPlaybackState() != Player.STATE_IDLE) {
Timeline castTimeline = castPlayer.getCurrentTimeline();
if (castTimeline.getPeriodCount() <= itemIndex) {
return false;
}
castPlayer.removeItem((int) castTimeline.getPeriod(itemIndex, new Period()).id);
}
}
mediaQueue.remove(itemIndex);
if (itemIndex == currentItemIndex && itemIndex == mediaQueue.size()) {
maybeSetCurrentItemAndNotify(C.INDEX_UNSET);
} else if (itemIndex < currentItemIndex) {
maybeSetCurrentItemAndNotify(currentItemIndex - 1);
}
return true;
}
/**
* Moves an item within the queue.
*
* @param item The item to move.
* @param toIndex The target index of the item in the queue.
* @return Whether the item move was successful.
*/
@Override
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) {
Timeline castTimeline = castPlayer.getCurrentTimeline();
int periodCount = castTimeline.getPeriodCount();
if (periodCount <= fromIndex || periodCount <= toIndex) {
return false;
}
int elementId = (int) castTimeline.getPeriod(fromIndex, new Period()).id;
castPlayer.moveItem(elementId, toIndex);
}
mediaQueue.add(toIndex, mediaQueue.remove(fromIndex));
// Index update.
if (fromIndex == currentItemIndex) {
maybeSetCurrentItemAndNotify(toIndex);
} else if (fromIndex < currentItemIndex && toIndex >= currentItemIndex) {
maybeSetCurrentItemAndNotify(currentItemIndex - 1);
} else if (fromIndex > currentItemIndex && toIndex <= currentItemIndex) {
maybeSetCurrentItemAndNotify(currentItemIndex + 1);
}
return true;
}
/**
* Dispatches a given {@link KeyEvent} to the corresponding view of the current player.
*
* @param event The {@link KeyEvent}.
* @return Whether the event was handled by the target view.
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (currentPlayer == exoPlayer) {
return localPlayerView.dispatchKeyEvent(event);
} else /* currentPlayer == castPlayer */ {
return castControlView.dispatchKeyEvent(event);
}
}
/** Releases the manager and the players that it holds. */
@Override
public void release() {
currentItemIndex = C.INDEX_UNSET;
mediaQueue.clear();
concatenatingMediaSource.clear();
castPlayer.setSessionAvailabilityListener(null);
castPlayer.release();
localPlayerView.setPlayer(null);
exoPlayer.release();
}
// Player.EventListener implementation.
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
updateCurrentItemIndex();
}
@Override
public void onPositionDiscontinuity(@DiscontinuityReason int reason) {
updateCurrentItemIndex();
}
@Override
public void onTimelineChanged(
Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {
updateCurrentItemIndex();
}
// CastPlayer.SessionAvailabilityListener implementation.
@Override
public void onCastSessionAvailable() {
setCurrentPlayer(castPlayer);
}
@Override
public void onCastSessionUnavailable() {
setCurrentPlayer(exoPlayer);
}
// Internal methods.
private void updateCurrentItemIndex() {
int playbackState = currentPlayer.getPlaybackState();
maybeSetCurrentItemAndNotify(
playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED
? currentPlayer.getCurrentWindowIndex()
: C.INDEX_UNSET);
}
private void setCurrentPlayer(Player currentPlayer) {
if (this.currentPlayer == currentPlayer) {
return;
}
// View management.
if (currentPlayer == exoPlayer) {
localPlayerView.setVisibility(View.VISIBLE);
castControlView.hide();
} else /* currentPlayer == castPlayer */ {
localPlayerView.setVisibility(View.GONE);
castControlView.show();
}
// Player state management.
long playbackPositionMs = C.TIME_UNSET;
int windowIndex = C.INDEX_UNSET;
boolean playWhenReady = false;
if (this.currentPlayer != null) {
int playbackState = this.currentPlayer.getPlaybackState();
if (playbackState != Player.STATE_ENDED) {
playbackPositionMs = this.currentPlayer.getCurrentPosition();
playWhenReady = this.currentPlayer.getPlayWhenReady();
windowIndex = this.currentPlayer.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.
}
this.currentPlayer = currentPlayer;
// Media queue management.
if (currentPlayer == exoPlayer) {
exoPlayer.prepare(concatenatingMediaSource);
}
// Playback transition.
if (windowIndex != C.INDEX_UNSET) {
setCurrentItem(windowIndex, playbackPositionMs, playWhenReady);
}
}
/**
* Starts playback of the item at the given position.
*
* @param itemIndex The index of the item to play.
* @param positionMs The position at which playback should start.
* @param playWhenReady Whether the player should proceed when ready to do so.
*/
private void setCurrentItem(int itemIndex, long positionMs, boolean playWhenReady) {
maybeSetCurrentItemAndNotify(itemIndex);
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));
}
castPlayer.loadItems(items, itemIndex, positionMs, Player.REPEAT_MODE_OFF);
} else {
currentPlayer.seekTo(itemIndex, positionMs);
currentPlayer.setPlayWhenReady(playWhenReady);
}
}
private void maybeSetCurrentItemAndNotify(int currentItemIndex) {
if (this.currentItemIndex != currentItemIndex) {
int oldIndex = this.currentItemIndex;
this.currentItemIndex = currentItemIndex;
listener.onQueuePositionChanged(oldIndex, currentItemIndex);
}
}
private static MediaSource buildMediaSource(MediaItem item) {
Uri uri = item.media.uri;
switch (item.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 ProgressiveMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri);
default:
{
throw new IllegalStateException("Unsupported type: " + item.mimeType);
}
}
}
private static MediaQueueItem buildMediaQueueItem(MediaItem item) {
MediaMetadata movieMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);
movieMetadata.putString(MediaMetadata.KEY_TITLE, item.title);
MediaInfo mediaInfo =
new MediaInfo.Builder(item.media.uri.toString())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setContentType(item.mimeType)
.setMetadata(movieMetadata)
.build();
return new MediaQueueItem.Builder(mediaInfo).build();
}
}

View File

@ -16,87 +16,86 @@
package com.google.android.exoplayer2.castdemo;
import android.net.Uri;
import androidx.annotation.Nullable;
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 java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/** Utility methods and constants for the Cast demo application. */
/* package */ final class DemoUtil {
/** Represents a media sample. */
public static final class Sample {
/** The uri of the media content. */
public final String uri;
/** The name of the sample. */
public final String name;
/** The mime type of the sample media content. */
public final String mimeType;
/**
* The {@link UUID} of the DRM scheme that protects the content, or null if the content is not
* DRM-protected.
*/
@Nullable public final UUID drmSchemeUuid;
/**
* The url from which players should obtain DRM licenses, or null if the content is not
* DRM-protected.
*/
@Nullable public final Uri licenseServerUri;
/**
* @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, name, mimeType, /* drmSchemeUuid= */ null, /* licenseServerUriString= */ null);
}
public Sample(
String uri,
String name,
String mimeType,
@Nullable UUID drmSchemeUuid,
@Nullable String licenseServerUriString) {
this.uri = uri;
this.name = name;
this.mimeType = mimeType;
this.drmSchemeUuid = drmSchemeUuid;
this.licenseServerUri =
licenseServerUriString != null ? Uri.parse(licenseServerUriString) : null;
}
@Override
public String toString() {
return name;
}
}
public static final String MIME_TYPE_DASH = MimeTypes.APPLICATION_MPD;
public static final String MIME_TYPE_HLS = MimeTypes.APPLICATION_M3U8;
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;
public static final List<MediaItem> SAMPLES;
static {
// App samples.
ArrayList<Sample> samples = new ArrayList<>();
ArrayList<MediaItem> samples = new ArrayList<>();
// Clear content.
samples.add(
new Sample(
"https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd",
"Clear DASH: Tears",
MIME_TYPE_DASH));
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 Sample(
"https://html5demos.com/assets/dizzy.mp4", "Clear MP4: Dizzy", MIME_TYPE_VIDEO_MP4));
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);
}

View File

@ -17,13 +17,6 @@ package com.google.android.exoplayer2.castdemo;
import android.content.Context;
import android.os.Bundle;
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;
@ -34,16 +27,23 @@ import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.ColorUtils;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.SimpleExoPlayer;
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.CastMediaControlIntent;
import com.google.android.gms.cast.framework.CastButtonFactory;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.dynamite.DynamiteModule;
import java.util.Collections;
/**
* An activity that plays video using {@link SimpleExoPlayer} and supports casting using ExoPlayer's
@ -52,8 +52,6 @@ import java.util.Collections;
public class MainActivity extends AppCompatActivity
implements OnClickListener, PlayerManager.Listener {
private final MediaItem.Builder mediaItemBuilder;
private PlayerView localPlayerView;
private PlayerControlView castControlView;
private PlayerManager playerManager;
@ -61,10 +59,6 @@ public class MainActivity extends AppCompatActivity
private MediaQueueListAdapter mediaQueueListAdapter;
private CastContext castContext;
public MainActivity() {
mediaItemBuilder = new MediaItem.Builder();
}
// Activity lifecycle methods.
@Override
@ -118,20 +112,13 @@ public class MainActivity extends AppCompatActivity
// There is no Cast context to work with. Do nothing.
return;
}
String applicationId = castContext.getCastOptions().getReceiverApplicationId();
switch (applicationId) {
case CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID:
playerManager =
new DefaultReceiverPlayerManager(
/* listener= */ this,
localPlayerView,
castControlView,
/* context= */ this,
castContext);
break;
default:
throw new IllegalStateException("Illegal receiver app id: " + applicationId);
}
playerManager =
new PlayerManager(
/* listener= */ this,
localPlayerView,
castControlView,
/* context= */ this,
castContext);
mediaQueueList.setAdapter(mediaQueueListAdapter);
}
@ -179,36 +166,29 @@ public class MainActivity extends AppCompatActivity
}
@Override
public void onQueueContentsExternallyChanged() {
mediaQueueListAdapter.notifyDataSetChanged();
}
@Override
public void onPlayerError() {
Toast.makeText(getApplicationContext(), R.string.player_error_msg, Toast.LENGTH_LONG).show();
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);
sampleList.setAdapter(new SampleListAdapter(this));
sampleList.setOnItemClickListener(
(parent, view, position, id) -> {
DemoUtil.Sample sample = DemoUtil.SAMPLES.get(position);
mediaItemBuilder
.clear()
.setMedia(sample.uri)
.setTitle(sample.name)
.setMimeType(sample.mimeType);
if (sample.drmSchemeUuid != null) {
mediaItemBuilder.setDrmSchemes(
Collections.singletonList(
new MediaItem.DrmScheme(
sample.drmSchemeUuid, new MediaItem.UriBundle(sample.licenseServerUri))));
}
playerManager.addItem(mediaItemBuilder.build());
playerManager.addItem(DemoUtil.SAMPLES.get(position));
mediaQueueListAdapter.notifyItemInserted(playerManager.getMediaQueueSize() - 1);
});
return dialogList;
@ -231,8 +211,10 @@ public class MainActivity extends AppCompatActivity
TextView view = holder.textView;
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
@ -312,11 +294,18 @@ public class MainActivity extends AppCompatActivity
}
}
private static final class SampleListAdapter extends ArrayAdapter<DemoUtil.Sample> {
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

@ -15,12 +15,49 @@
*/
package com.google.android.exoplayer2.castdemo;
import android.content.Context;
import android.net.Uri;
import android.view.KeyEvent;
import android.view.View;
import com.google.android.exoplayer2.C;
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.SimpleExoPlayer;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Timeline.Period;
import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
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.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.exoplayer2.util.Util;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.framework.CastContext;
import java.util.ArrayList;
import java.util.Map;
/** Manages the players in the Cast demo app. */
/* package */ interface PlayerManager {
/** Manages players and an internal media queue for the demo app. */
/* package */ class PlayerManager implements EventListener, SessionAvailabilityListener {
/** Listener for events. */
interface Listener {
@ -28,40 +65,395 @@ import com.google.android.exoplayer2.ext.cast.MediaItem;
/** Called when the currently played item of the media queue changes. */
void onQueuePositionChanged(int previousIndex, int newIndex);
/** Called when the media queue changes due to modifications not caused by this manager. */
void onQueueContentsExternallyChanged();
/** Called when an error occurs in the current player. */
void onPlayerError();
/**
* 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);
}
/** Redirects the given {@code keyEvent} to the active player. */
boolean dispatchKeyEvent(KeyEvent keyEvent);
private static final String USER_AGENT = "ExoCastDemoPlayer";
private static final DefaultHttpDataSourceFactory DATA_SOURCE_FACTORY =
new DefaultHttpDataSourceFactory(USER_AGENT);
/** Appends the given {@link MediaItem} to the media queue. */
void addItem(MediaItem mediaItem);
private final PlayerView localPlayerView;
private final PlayerControlView castControlView;
private final DefaultTrackSelector trackSelector;
private final SimpleExoPlayer exoPlayer;
private final CastPlayer castPlayer;
private final ArrayList<MediaItem> mediaQueue;
private final Listener listener;
private final ConcatenatingMediaSource concatenatingMediaSource;
private final MediaItemConverter mediaItemConverter;
/** Returns the number of items in the media queue. */
int getMediaQueueSize();
/** Selects the item at the given position for playback. */
void selectQueueItem(int position);
private TrackGroupArray lastSeenTrackGroupArray;
private int currentItemIndex;
private Player currentPlayer;
/**
* Returns the position of the item currently being played, or {@link C#INDEX_UNSET} if no item is
* being played.
* 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}.
*/
int getCurrentItemIndex();
public PlayerManager(
Listener listener,
PlayerView localPlayerView,
PlayerControlView castControlView,
Context context,
CastContext castContext) {
this.listener = listener;
this.localPlayerView = localPlayerView;
this.castControlView = castControlView;
mediaQueue = new ArrayList<>();
currentItemIndex = C.INDEX_UNSET;
concatenatingMediaSource = new ConcatenatingMediaSource();
mediaItemConverter = new DefaultMediaItemConverter();
/** Returns the {@link MediaItem} at the given {@code position}. */
MediaItem getItem(int position);
trackSelector = new DefaultTrackSelector(context);
exoPlayer = new SimpleExoPlayer.Builder(context).setTrackSelector(trackSelector).build();
exoPlayer.addListener(this);
localPlayerView.setPlayer(exoPlayer);
/** Moves the item at position {@code from} to position {@code to}. */
boolean moveItem(MediaItem item, int to);
castPlayer = new CastPlayer(castContext);
castPlayer.addListener(this);
castPlayer.setSessionAvailabilityListener(this);
castControlView.setPlayer(castPlayer);
/** Removes the item at position {@code index}. */
boolean removeItem(MediaItem item);
setCurrentPlayer(castPlayer.isCastSessionAvailable() ? castPlayer : exoPlayer);
}
/** Releases any acquired resources. */
void release();
// Queue manipulation methods.
/**
* Plays a specified queue item in the current player.
*
* @param itemIndex The index of the item to play.
*/
public void selectQueueItem(int itemIndex) {
setCurrentItem(itemIndex, C.TIME_UNSET, true);
}
/** Returns the index of the currently played item. */
public int getCurrentItemIndex() {
return currentItemIndex;
}
/**
* Appends {@code item} to the media queue.
*
* @param item The {@link MediaItem} to append.
*/
public void addItem(MediaItem item) {
mediaQueue.add(item);
concatenatingMediaSource.addMediaSource(buildMediaSource(item));
if (currentPlayer == castPlayer) {
castPlayer.addItems(mediaItemConverter.toMediaQueueItem(item));
}
}
/** Returns the size of the media queue. */
public int getMediaQueueSize() {
return mediaQueue.size();
}
/**
* Returns the item at the given index in the media queue.
*
* @param position The index of the item.
* @return The item at the given index in the media queue.
*/
public MediaItem getItem(int position) {
return mediaQueue.get(position);
}
/**
* Removes the item at the given index from the media queue.
*
* @param item The item to remove.
* @return Whether the removal was successful.
*/
public boolean removeItem(MediaItem item) {
int itemIndex = mediaQueue.indexOf(item);
if (itemIndex == -1) {
return false;
}
concatenatingMediaSource.removeMediaSource(itemIndex);
if (currentPlayer == castPlayer) {
if (castPlayer.getPlaybackState() != Player.STATE_IDLE) {
Timeline castTimeline = castPlayer.getCurrentTimeline();
if (castTimeline.getPeriodCount() <= itemIndex) {
return false;
}
castPlayer.removeItem((int) castTimeline.getPeriod(itemIndex, new Period()).id);
}
}
mediaQueue.remove(itemIndex);
if (itemIndex == currentItemIndex && itemIndex == mediaQueue.size()) {
maybeSetCurrentItemAndNotify(C.INDEX_UNSET);
} else if (itemIndex < currentItemIndex) {
maybeSetCurrentItemAndNotify(currentItemIndex - 1);
}
return true;
}
/**
* Moves an item within the queue.
*
* @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(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) {
Timeline castTimeline = castPlayer.getCurrentTimeline();
int periodCount = castTimeline.getPeriodCount();
if (periodCount <= fromIndex || periodCount <= toIndex) {
return false;
}
int elementId = (int) castTimeline.getPeriod(fromIndex, new Period()).id;
castPlayer.moveItem(elementId, toIndex);
}
mediaQueue.add(toIndex, mediaQueue.remove(fromIndex));
// Index update.
if (fromIndex == currentItemIndex) {
maybeSetCurrentItemAndNotify(toIndex);
} else if (fromIndex < currentItemIndex && toIndex >= currentItemIndex) {
maybeSetCurrentItemAndNotify(currentItemIndex - 1);
} else if (fromIndex > currentItemIndex && toIndex <= currentItemIndex) {
maybeSetCurrentItemAndNotify(currentItemIndex + 1);
}
return true;
}
/**
* Dispatches a given {@link KeyEvent} to the corresponding view of the current player.
*
* @param event The {@link KeyEvent}.
* @return Whether the event was handled by the target view.
*/
public boolean dispatchKeyEvent(KeyEvent event) {
if (currentPlayer == exoPlayer) {
return localPlayerView.dispatchKeyEvent(event);
} else /* currentPlayer == castPlayer */ {
return castControlView.dispatchKeyEvent(event);
}
}
/** Releases the manager and the players that it holds. */
public void release() {
currentItemIndex = C.INDEX_UNSET;
mediaQueue.clear();
concatenatingMediaSource.clear();
castPlayer.setSessionAvailabilityListener(null);
castPlayer.release();
localPlayerView.setPlayer(null);
exoPlayer.release();
}
// Player.EventListener implementation.
@Override
public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
updateCurrentItemIndex();
}
@Override
public void onPositionDiscontinuity(@DiscontinuityReason int reason) {
updateCurrentItemIndex();
}
@Override
public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) {
updateCurrentItemIndex();
}
@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;
}
}
// CastPlayer.SessionAvailabilityListener implementation.
@Override
public void onCastSessionAvailable() {
setCurrentPlayer(castPlayer);
}
@Override
public void onCastSessionUnavailable() {
setCurrentPlayer(exoPlayer);
}
// Internal methods.
private void updateCurrentItemIndex() {
int playbackState = currentPlayer.getPlaybackState();
maybeSetCurrentItemAndNotify(
playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED
? currentPlayer.getCurrentWindowIndex()
: C.INDEX_UNSET);
}
private void setCurrentPlayer(Player currentPlayer) {
if (this.currentPlayer == currentPlayer) {
return;
}
// View management.
if (currentPlayer == exoPlayer) {
localPlayerView.setVisibility(View.VISIBLE);
castControlView.hide();
} else /* currentPlayer == castPlayer */ {
localPlayerView.setVisibility(View.GONE);
castControlView.show();
}
// Player state management.
long playbackPositionMs = C.TIME_UNSET;
int windowIndex = C.INDEX_UNSET;
boolean playWhenReady = false;
Player previousPlayer = this.currentPlayer;
if (previousPlayer != null) {
// Save state from the previous player.
int playbackState = previousPlayer.getPlaybackState();
if (playbackState != Player.STATE_ENDED) {
playbackPositionMs = previousPlayer.getCurrentPosition();
playWhenReady = previousPlayer.getPlayWhenReady();
windowIndex = previousPlayer.getCurrentWindowIndex();
if (windowIndex != currentItemIndex) {
playbackPositionMs = C.TIME_UNSET;
windowIndex = currentItemIndex;
}
}
previousPlayer.stop(true);
}
this.currentPlayer = currentPlayer;
// Media queue management.
if (currentPlayer == exoPlayer) {
exoPlayer.prepare(concatenatingMediaSource);
}
// Playback transition.
if (windowIndex != C.INDEX_UNSET) {
setCurrentItem(windowIndex, playbackPositionMs, playWhenReady);
}
}
/**
* Starts playback of the item at the given position.
*
* @param itemIndex The index of the item to play.
* @param positionMs The position at which playback should start.
* @param playWhenReady Whether the player should proceed when ready to do so.
*/
private void setCurrentItem(int itemIndex, long positionMs, boolean playWhenReady) {
maybeSetCurrentItemAndNotify(itemIndex);
if (currentPlayer == castPlayer && castPlayer.getCurrentTimeline().isEmpty()) {
MediaQueueItem[] items = new MediaQueueItem[mediaQueue.size()];
for (int i = 0; i < items.length; i++) {
items[i] = mediaItemConverter.toMediaQueueItem(mediaQueue.get(i));
}
castPlayer.loadItems(items, itemIndex, positionMs, Player.REPEAT_MODE_OFF);
} else {
currentPlayer.seekTo(itemIndex, positionMs);
currentPlayer.setPlayWhenReady(playWhenReady);
}
}
private void maybeSetCurrentItemAndNotify(int currentItemIndex) {
if (this.currentItemIndex != currentItemIndex) {
int oldIndex = this.currentItemIndex;
this.currentItemIndex = currentItemIndex;
listener.onQueuePositionChanged(oldIndex, currentItemIndex);
}
}
private MediaSource buildMediaSource(MediaItem item) {
Uri uri = item.uri;
String mimeType = item.mimeType;
if (mimeType == null) {
throw new IllegalArgumentException("mimeType is required");
}
DrmSessionManager<ExoMediaCrypto> drmSessionManager =
DrmSessionManager.getDummyDrmSessionManager();
MediaItem.DrmConfiguration drmConfiguration = item.drmConfiguration;
if (drmConfiguration != null && Util.SDK_INT >= 18) {
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());
}
drmSessionManager =
new DefaultDrmSessionManager.Builder()
.setMultiSession(/* multiSession= */ true)
.setUuidAndExoMediaDrmProvider(
drmConfiguration.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
.build(drmCallback);
}
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);
}
return createdMediaSource;
}
}

View File

@ -24,6 +24,8 @@
<string name="cast_context_error">Failed to get Cast context. Try updating Google Play Services and restart the app.</string>
<string name="player_error_msg">Player error encountered. Select a queue item to reprepare. Check the logcat and receiver app\'s console for more info.</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

@ -1,4 +0,0 @@
# IMA demo application #
This folder contains a demo application that showcases ExoPlayer integration
with the IMA SDK.

View File

@ -1,58 +0,0 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.imademo;
import android.app.Activity;
import android.os.Bundle;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.ui.PlayerView;
/**
* Main Activity for the IMA plugin demo. {@link ExoPlayer} objects are created by
* {@link PlayerManager}, which this class instantiates.
*/
public final class MainActivity extends Activity {
private PlayerView playerView;
private PlayerManager player;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main_activity);
playerView = findViewById(R.id.player_view);
player = new PlayerManager(this);
}
@Override
public void onResume() {
super.onResume();
player.init(this, playerView);
}
@Override
public void onPause() {
super.onPause();
player.reset();
}
@Override
public void onDestroy() {
player.release();
super.onDestroy();
}
}

View File

@ -1,123 +0,0 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.imademo;
import android.content.Context;
import android.net.Uri;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.C.ContentType;
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.MediaSource;
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.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 {
private final ImaAdsLoader adsLoader;
private final DataSource.Factory dataSourceFactory;
private SimpleExoPlayer player;
private long contentPosition;
public PlayerManager(Context context) {
String adTag = context.getString(R.string.ad_tag_url);
adsLoader = new ImaAdsLoader(context, Uri.parse(adTag));
dataSourceFactory =
new DefaultDataSourceFactory(
context, Util.getUserAgent(context, context.getString(R.string.application_name)));
}
public void init(Context context, PlayerView playerView) {
// Create a player instance.
player = ExoPlayerFactory.newSimpleInstance(context);
adsLoader.setPlayer(player);
playerView.setPlayer(player);
// This is the MediaSource representing the content media (i.e. not the ad).
String contentUrl = context.getString(R.string.content_url);
MediaSource contentMediaSource = buildMediaSource(Uri.parse(contentUrl));
// Compose the content media source into a new AdsMediaSource with both ads and content.
MediaSource mediaSourceWithAds =
new AdsMediaSource(
contentMediaSource, /* adMediaSourceFactory= */ this, adsLoader, playerView);
// Prepare the player with the source.
player.seekTo(contentPosition);
player.prepare(mediaSourceWithAds);
player.setPlayWhenReady(true);
}
public void reset() {
if (player != null) {
contentPosition = player.getContentPosition();
player.release();
player = null;
adsLoader.setPlayer(null);
}
}
public void release() {
if (player != null) {
player.release();
player = null;
}
adsLoader.release();
}
// AdsMediaSource.MediaSourceFactory implementation.
@Override
public MediaSource createMediaSource(Uri uri) {
return buildMediaSource(uri);
}
@Override
public int[] getSupportedTypes() {
// IMA does not support Smooth Streaming ads.
return new int[] {C.TYPE_DASH, C.TYPE_HLS, C.TYPE_OTHER};
}
// Internal methods.
private MediaSource buildMediaSource(Uri uri) {
@ContentType int type = Util.inferContentType(uri);
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);
}
}
}

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2017 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<string name="application_name">Exo IMA Demo</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>
</resources>

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2017 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<style name="PlayerTheme" parent="android:Theme.Holo">
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>

View File

@ -26,7 +26,7 @@ android {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
targetSdkVersion project.ext.appTargetSdkVersion
}
buildTypes {
@ -62,15 +62,15 @@ android {
}
dependencies {
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.legacy:legacy-support-core-ui:1.0.0'
implementation 'androidx.fragment:fragment:1.0.0'
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion
implementation 'com.google.android.material:material:1.0.0'
implementation project(modulePrefix + 'library-core')
implementation project(modulePrefix + 'library-dash')
implementation project(modulePrefix + 'library-hls')
implementation project(modulePrefix + 'library-smoothstreaming')
implementation project(modulePrefix + 'library-ui')
withExtensionsImplementation project(path: modulePrefix + 'extension-av1')
withExtensionsImplementation project(path: modulePrefix + 'extension-ffmpeg')
withExtensionsImplementation project(path: modulePrefix + 'extension-flac')
withExtensionsImplementation project(path: modulePrefix + 'extension-ima')

View File

@ -34,6 +34,7 @@
android:banner="@drawable/ic_banner"
android:largeHeap="true"
android:allowBackup="false"
android:requestLegacyExternalStorage="true"
android:name="com.google.android.exoplayer2.demo.DemoApplication"
tools:ignore="UnusedAttribute">

View File

@ -376,44 +376,48 @@
"uri": "https://html5demos.com/assets/dizzy.mp4"
},
{
"name": "Apple AAC 10s",
"name": "Apple 10s (AAC)",
"uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear0/fileSequence0.aac"
},
{
"name": "Apple TS 10s",
"name": "Apple 10s (TS)",
"uri": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear1/fileSequence0.ts"
},
{
"name": "Android screens (Matroska)",
"name": "Android screens (MKV)",
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv"
},
{
"name": "Screens 360P (WebM,VP9,No Audio)",
"name": "Screens 360p video (WebM,VP9)",
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-vp9-360.webm"
},
{
"name": "Screens 480p (FMP4,H264,No Audio)",
"name": "Screens 480p video (FMP4,H264)",
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-avc-baseline-480.mp4"
},
{
"name": "Screens 1080p (FMP4,H264, No Audio)",
"name": "Screens 1080p video (FMP4,H264)",
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-137.mp4"
},
{
"name": "Screens (FMP4,AAC Audio)",
"name": "Screens audio (FMP4)",
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/audio-141.mp4"
},
{
"name": "Google Play (MP3 Audio)",
"name": "Google Play (MP3)",
"uri": "https://storage.googleapis.com/exoplayer-test-media-0/play.mp3"
},
{
"name": "Google Play (Ogg/Vorbis Audio)",
"name": "Google Play (Ogg/Vorbis)",
"uri": "https://storage.googleapis.com/exoplayer-test-media-1/ogg/play.ogg"
},
{
"name": "Big Buck Bunny (FLV Video)",
"name": "Big Buck Bunny video (FLV)",
"uri": "https://vod.leasewebcdn.com/bbb.flv?ri=1024&rs=150&start=0"
},
{
"name": "Big Buck Bunny 480p video (MP4,AV1)",
"uri": "https://storage.googleapis.com/downloads.webmproject.org/av1/exoplayer/bbb-av1-480p.mp4"
}
]
},
@ -447,23 +451,27 @@
},
{
"name": "Clear -> Enc -> Clear -> Enc -> Enc",
"drm_scheme": "widevine",
"drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test",
"playlist": [
{
"uri": "https://html5demos.com/assets/dizzy.mp4"
},
{
"uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd"
"uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd",
"drm_scheme": "widevine",
"drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
},
{
"uri": "https://html5demos.com/assets/dizzy.mp4"
},
{
"uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd"
"uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd",
"drm_scheme": "widevine",
"drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
},
{
"uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd"
"uri": "https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears_sd.mpd",
"drm_scheme": "widevine",
"drm_license_url": "https://proxy.uat.widevine.com/proxy?provider=widevine_test"
}
]
}
@ -578,5 +586,17 @@
"spherical_stereo_mode": "top_bottom"
}
]
},
{
"name": "Subtitles",
"samples": [
{
"name": "TTML",
"uri": "https://html5demos.com/assets/dizzy.mp4",
"subtitle_uri": "https://storage.googleapis.com/exoplayer-test-media-1/ttml/netflix_ttml_sample.xml",
"subtitle_mime_type": "application/ttml+xml",
"subtitle_language": "en"
}
]
}
]

View File

@ -28,7 +28,7 @@ import com.google.android.exoplayer2.offline.DownloaderConstructorHelper;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.upstream.FileDataSourceFactory;
import com.google.android.exoplayer2.upstream.FileDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.cache.Cache;
import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
@ -165,7 +165,7 @@ public class DemoApplication extends Application {
return new CacheDataSourceFactory(
cache,
upstreamFactory,
new FileDataSourceFactory(),
new FileDataSource.Factory(),
/* cacheWriteDataSinkFactory= */ null,
CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR,
/* eventListener= */ null);

View File

@ -18,9 +18,9 @@ package com.google.android.exoplayer2.demo;
import android.content.Context;
import android.content.DialogInterface;
import android.net.Uri;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentManager;
import android.widget.Toast;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.RenderersFactory;
import com.google.android.exoplayer2.offline.Download;
@ -30,6 +30,7 @@ import com.google.android.exoplayer2.offline.DownloadIndex;
import com.google.android.exoplayer2.offline.DownloadManager;
import com.google.android.exoplayer2.offline.DownloadRequest;
import com.google.android.exoplayer2.offline.DownloadService;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.util.Log;
@ -55,6 +56,7 @@ public class DownloadTracker {
private final CopyOnWriteArraySet<Listener> listeners;
private final HashMap<Uri, Download> downloads;
private final DownloadIndex downloadIndex;
private final DefaultTrackSelector.Parameters trackSelectorParameters;
@Nullable private StartDownloadDialogHelper startDownloadDialogHelper;
@ -65,6 +67,7 @@ public class DownloadTracker {
listeners = new CopyOnWriteArraySet<>();
downloads = new HashMap<>();
downloadIndex = downloadManager.getDownloadIndex();
trackSelectorParameters = DownloadHelper.getDefaultTrackSelectorParameters(context);
downloadManager.addListener(new DownloadManagerListener());
loadDownloads();
}
@ -82,7 +85,6 @@ public class DownloadTracker {
return download != null && download.state != Download.STATE_FAILED;
}
@SuppressWarnings("unchecked")
public DownloadRequest getDownloadRequest(Uri uri) {
Download download = downloads.get(uri);
return download != null && download.state != Download.STATE_FAILED ? download.request : null;
@ -124,13 +126,13 @@ public class DownloadTracker {
int type = Util.inferContentType(uri, extension);
switch (type) {
case C.TYPE_DASH:
return DownloadHelper.forDash(uri, dataSourceFactory, renderersFactory);
return DownloadHelper.forDash(context, uri, dataSourceFactory, renderersFactory);
case C.TYPE_SS:
return DownloadHelper.forSmoothStreaming(uri, dataSourceFactory, renderersFactory);
return DownloadHelper.forSmoothStreaming(context, uri, dataSourceFactory, renderersFactory);
case C.TYPE_HLS:
return DownloadHelper.forHls(uri, dataSourceFactory, renderersFactory);
return DownloadHelper.forHls(context, uri, dataSourceFactory, renderersFactory);
case C.TYPE_OTHER:
return DownloadHelper.forProgressive(uri);
return DownloadHelper.forProgressive(context, uri);
default:
throw new IllegalStateException("Unsupported type: " + type);
}
@ -203,7 +205,7 @@ public class DownloadTracker {
TrackSelectionDialog.createForMappedTrackInfoAndParameters(
/* titleId= */ R.string.exo_download_description,
mappedTrackInfo,
/* initialParameters= */ DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
trackSelectorParameters,
/* allowAdaptiveSelections =*/ false,
/* allowMultipleOverrides= */ true,
/* onClickListener= */ this,
@ -213,10 +215,13 @@ public class DownloadTracker {
@Override
public void onPrepareError(DownloadHelper helper, IOException e) {
Toast.makeText(
context.getApplicationContext(), R.string.download_start_error, Toast.LENGTH_LONG)
.show();
Log.e(TAG, "Failed to start download", e);
Toast.makeText(context, R.string.download_start_error, Toast.LENGTH_LONG).show();
Log.e(
TAG,
e instanceof DownloadHelper.LiveContentUnsupportedException
? "Downloading live content unsupported"
: "Failed to start download",
e);
}
// DialogInterface.OnClickListener implementation.
@ -230,7 +235,7 @@ public class DownloadTracker {
downloadHelper.addTrackSelectionForSingleRenderer(
periodIndex,
/* rendererIndex= */ i,
DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
trackSelectorParameters,
trackSelectionDialog.getOverrides(/* rendererIndex= */ i));
}
}

View File

@ -17,11 +17,9 @@ package com.google.android.exoplayer2.demo;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.MediaDrm;
import android.net.Uri;
import android.os.Bundle;
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;
@ -30,19 +28,24 @@ import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.C.ContentType;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.Format;
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.FrameworkMediaCrypto;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
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.drm.MediaDrmCallback;
import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException;
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException;
import com.google.android.exoplayer2.offline.DownloadHelper;
@ -50,7 +53,10 @@ 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.MediaSource;
import com.google.android.exoplayer2.source.MediaSourceFactory;
import com.google.android.exoplayer2.source.MergingMediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.SingleSampleMediaSource;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.source.ads.AdsLoader;
import com.google.android.exoplayer2.source.ads.AdsMediaSource;
@ -66,7 +72,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.ui.DebugTextViewHelper;
import com.google.android.exoplayer2.ui.PlayerControlView;
import com.google.android.exoplayer2.ui.PlayerView;
import com.google.android.exoplayer2.ui.spherical.SphericalSurfaceView;
import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.util.ErrorMessageProvider;
@ -76,41 +82,51 @@ import java.lang.reflect.Constructor;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.util.UUID;
/** An activity that plays media using {@link SimpleExoPlayer}. */
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 IS_LIVE_EXTRA = "is_live";
public static final String DRM_SCHEME_EXTRA = "drm_scheme";
public static final String DRM_LICENSE_URL_EXTRA = "drm_license_url";
public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties";
public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session";
public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders";
public static final String TUNNELING_EXTRA = "tunneling";
public static final String AD_TAG_URI_EXTRA = "ad_tag_uri";
public static final String SUBTITLE_URI_EXTRA = "subtitle_uri";
public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type";
public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language";
// For backwards compatibility only.
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,7 +146,6 @@ public class PlayerActivity extends AppCompatActivity
private DataSource.Factory dataSourceFactory;
private SimpleExoPlayer player;
private FrameworkMediaDrm mediaDrm;
private MediaSource mediaSource;
private DefaultTrackSelector trackSelector;
private DefaultTrackSelector.Parameters trackSelectorParameters;
@ -150,7 +165,8 @@ public class PlayerActivity extends AppCompatActivity
@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);
}
@ -183,7 +199,7 @@ public class PlayerActivity extends AppCompatActivity
finish();
return;
}
((SphericalSurfaceView) playerView.getVideoSurfaceView()).setDefaultStereoMode(stereoMode);
((SphericalGLSurfaceView) playerView.getVideoSurfaceView()).setDefaultStereoMode(stereoMode);
}
if (savedInstanceState != null) {
@ -192,7 +208,13 @@ public class PlayerActivity extends AppCompatActivity
startWindow = savedInstanceState.getInt(KEY_WINDOW);
startPosition = savedInstanceState.getLong(KEY_POSITION);
} else {
trackSelectorParameters = new DefaultTrackSelector.ParametersBuilder().build();
DefaultTrackSelector.ParametersBuilder builder =
new DefaultTrackSelector.ParametersBuilder(/* context= */ this);
boolean tunneling = intent.getBooleanExtra(TUNNELING_EXTRA, false);
if (Util.SDK_INT >= 21 && tunneling) {
builder.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(/* context= */ this));
}
trackSelectorParameters = builder.build();
clearStartPosition();
}
}
@ -326,67 +348,10 @@ public class PlayerActivity extends AppCompatActivity
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;
}
mediaSource = createTopLevelMediaSource(intent);
if (mediaSource == null) {
return;
}
TrackSelection.Factory trackSelectionFactory;
@ -406,13 +371,14 @@ public class PlayerActivity extends AppCompatActivity
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);
new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory)
.setTrackSelector(trackSelector)
.build();
player.addListener(new PlayerEventListener());
player.setPlayWhenReady(startAutoPlay);
player.addAnalyticsListener(new EventLogger(trackSelector));
@ -420,28 +386,8 @@ public class PlayerActivity extends AppCompatActivity
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;
@ -452,34 +398,141 @@ public class PlayerActivity extends AppCompatActivity
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]);
Sample.SubtitleInfo subtitleInfo = samples[i].subtitleInfo;
if (subtitleInfo != null) {
Format subtitleFormat =
Format.createTextSampleFormat(
/* id= */ null,
subtitleInfo.mimeType,
C.SELECTION_FLAG_DEFAULT,
subtitleInfo.language);
MediaSource subtitleMediaSource =
new SingleSampleMediaSource.Factory(dataSourceFactory)
.createMediaSource(subtitleInfo.uri, subtitleFormat, C.TIME_UNSET);
mediaSources[i] = new MergingMediaSource(mediaSources[i], subtitleMediaSource);
}
}
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;
}
private MediaSource buildMediaSource(Uri uri, @Nullable String overrideExtension) {
private MediaSource createLeafMediaSource(UriSample parameters) {
Sample.DrmInfo drmInfo = parameters.drmInfo;
int errorStringId = R.string.error_drm_unknown;
DrmSessionManager<ExoMediaCrypto> drmSessionManager = null;
if (drmInfo == null) {
drmSessionManager = DrmSessionManager.getDummyDrmSessionManager();
} else if (Util.SDK_INT < 18) {
errorStringId = R.string.error_drm_unsupported_before_api_18;
} else if (!MediaDrm.isCryptoSchemeSupported(drmInfo.drmScheme)) {
errorStringId = R.string.error_drm_unsupported_scheme;
} else {
MediaDrmCallback mediaDrmCallback =
createMediaDrmCallback(drmInfo.drmLicenseUrl, drmInfo.drmKeyRequestProperties);
drmSessionManager =
new DefaultDrmSessionManager.Builder()
.setUuidAndExoMediaDrmProvider(drmInfo.drmScheme, FrameworkMediaDrm.DEFAULT_PROVIDER)
.setMultiSession(drmInfo.drmMultiSession)
.build(mediaDrmCallback);
}
if (drmSessionManager == null) {
showToast(errorStringId);
finish();
return null;
}
DownloadRequest downloadRequest =
((DemoApplication) getApplication()).getDownloadTracker().getDownloadRequest(uri);
((DemoApplication) getApplication())
.getDownloadTracker()
.getDownloadRequest(parameters.uri);
if (downloadRequest != null) {
return DownloadHelper.createMediaSource(downloadRequest, dataSourceFactory);
}
@ContentType int type = Util.inferContentType(uri, overrideExtension);
return createLeafMediaSource(parameters.uri, parameters.extension, drmSessionManager);
}
private MediaSource createLeafMediaSource(
Uri uri, String extension, DrmSessionManager<ExoMediaCrypto> drmSessionManager) {
@ContentType int type = Util.inferContentType(uri, extension);
switch (type) {
case C.TYPE_DASH:
return new DashMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
return new DashMediaSource.Factory(dataSourceFactory)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
case C.TYPE_SS:
return new SsMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
return new SsMediaSource.Factory(dataSourceFactory)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
case C.TYPE_HLS:
return new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
return new HlsMediaSource.Factory(dataSourceFactory)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
case C.TYPE_OTHER:
return new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
return new ProgressiveMediaSource.Factory(dataSourceFactory)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
default:
throw new IllegalStateException("Unsupported type: " + type);
}
}
private DefaultDrmSessionManager<FrameworkMediaCrypto> buildDrmSessionManagerV18(
UUID uuid, String licenseUrl, String[] keyRequestPropertiesArray, boolean multiSession)
throws UnsupportedDrmException {
private HttpMediaDrmCallback createMediaDrmCallback(
String licenseUrl, String[] keyRequestPropertiesArray) {
HttpDataSource.Factory licenseDataSourceFactory =
((DemoApplication) getApplication()).buildHttpDataSourceFactory();
HttpMediaDrmCallback drmCallback =
@ -490,9 +543,7 @@ public class PlayerActivity extends AppCompatActivity
keyRequestPropertiesArray[i + 1]);
}
}
releaseMediaDrm();
mediaDrm = FrameworkMediaDrm.newInstance(uuid);
return new DefaultDrmSessionManager<>(uuid, mediaDrm, drmCallback, null, multiSession);
return drmCallback;
}
private void releasePlayer() {
@ -509,14 +560,6 @@ public class PlayerActivity extends AppCompatActivity
if (adsLoader != null) {
adsLoader.setPlayer(null);
}
releaseMediaDrm();
}
private void releaseMediaDrm() {
if (mediaDrm != null) {
mediaDrm.release();
mediaDrm = null;
}
}
private void releaseAdsLoader() {
@ -554,7 +597,8 @@ public class PlayerActivity extends AppCompatActivity
}
/** 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 {
@ -569,12 +613,12 @@ public class PlayerActivity extends AppCompatActivity
// LINT.ThenChange(../../../../../../../../proguard-rules.txt)
adsLoader = loaderConstructor.newInstance(this, adTagUri);
}
adsLoader.setPlayer(player);
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
@ -627,7 +671,7 @@ public class PlayerActivity extends AppCompatActivity
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();
}
@ -677,7 +721,7 @@ public class PlayerActivity extends AppCompatActivity
// 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) {
@ -692,12 +736,11 @@ public class PlayerActivity extends AppCompatActivity
errorString =
getString(
R.string.error_instantiating_decoder,
decoderInitializationException.decoderName);
decoderInitializationException.codecInfo.name);
}
}
}
return Pair.create(0, errorString);
}
}
}

View File

@ -0,0 +1,236 @@
/*
* 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.IS_LIVE_EXTRA;
import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_LANGUAGE_EXTRA;
import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_MIME_TYPE_EXTRA;
import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_URI_EXTRA;
import static com.google.android.exoplayer2.demo.PlayerActivity.URI_EXTRA;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.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);
boolean isLive =
intent.getBooleanExtra(IS_LIVE_EXTRA + extrasKeySuffix, /* defaultValue= */ false);
Uri adTagUri = adsTagUriString != null ? Uri.parse(adsTagUriString) : null;
return new UriSample(
/* name= */ null,
uri,
extension,
isLive,
DrmInfo.createFromIntent(intent, extrasKeySuffix),
adTagUri,
/* sphericalStereoMode= */ null,
SubtitleInfo.createFromIntent(intent, extrasKeySuffix));
}
public final Uri uri;
public final String extension;
public final boolean isLive;
public final DrmInfo drmInfo;
public final Uri adTagUri;
@Nullable public final String sphericalStereoMode;
@Nullable SubtitleInfo subtitleInfo;
public UriSample(
String name,
Uri uri,
String extension,
boolean isLive,
DrmInfo drmInfo,
Uri adTagUri,
@Nullable String sphericalStereoMode,
@Nullable SubtitleInfo subtitleInfo) {
super(name);
this.uri = uri;
this.extension = extension;
this.isLive = isLive;
this.drmInfo = drmInfo;
this.adTagUri = adTagUri;
this.sphericalStereoMode = sphericalStereoMode;
this.subtitleInfo = subtitleInfo;
}
@Override
public void addToIntent(Intent intent) {
intent.setAction(PlayerActivity.ACTION_VIEW).setData(uri);
intent.putExtra(PlayerActivity.IS_LIVE_EXTRA, isLive);
intent.putExtra(PlayerActivity.SPHERICAL_STEREO_MODE_EXTRA, sphericalStereoMode);
addPlayerConfigToIntent(intent, /* extrasKeySuffix= */ "");
}
public void addToPlaylistIntent(Intent intent, String extrasKeySuffix) {
intent.putExtra(PlayerActivity.URI_EXTRA + extrasKeySuffix, uri.toString());
intent.putExtra(PlayerActivity.IS_LIVE_EXTRA + extrasKeySuffix, isLive);
addPlayerConfigToIntent(intent, extrasKeySuffix);
}
private void addPlayerConfigToIntent(Intent intent, String extrasKeySuffix) {
intent
.putExtra(EXTENSION_EXTRA + extrasKeySuffix, extension)
.putExtra(
AD_TAG_URI_EXTRA + extrasKeySuffix, adTagUri != null ? adTagUri.toString() : null);
if (drmInfo != null) {
drmInfo.addToIntent(intent, extrasKeySuffix);
}
if (subtitleInfo != null) {
subtitleInfo.addToIntent(intent, extrasKeySuffix);
}
}
}
public 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 final class SubtitleInfo {
@Nullable
public static SubtitleInfo createFromIntent(Intent intent, String extrasKeySuffix) {
if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) {
return null;
}
return new SubtitleInfo(
Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)),
intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix),
intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix));
}
public final Uri uri;
public final String mimeType;
@Nullable public final String language;
public SubtitleInfo(Uri uri, String mimeType, @Nullable String language) {
this.uri = Assertions.checkNotNull(uri);
this.mimeType = Assertions.checkNotNull(mimeType);
this.language = language;
}
public void addToIntent(Intent intent, String extrasKeySuffix) {
intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, uri.toString());
intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, mimeType);
intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, language);
}
}
public static 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

@ -21,8 +21,6 @@ import android.content.res.AssetManager;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import android.util.JsonReader;
import android.view.Menu;
import android.view.MenuInflater;
@ -36,8 +34,13 @@ import android.widget.ExpandableListView.OnChildClickListener;
import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
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;
@ -65,6 +68,7 @@ public class SampleChooserActivity extends AppCompatActivity
private SampleAdapter sampleAdapter;
private MenuItem preferExtensionDecodersMenuItem;
private MenuItem randomAbrMenuItem;
private MenuItem tunnelingMenuItem;
@Override
public void onCreate(Bundle savedInstanceState) {
@ -122,6 +126,10 @@ public class SampleChooserActivity extends AppCompatActivity
preferExtensionDecodersMenuItem = menu.findItem(R.id.prefer_extension_decoders);
preferExtensionDecodersMenuItem.setVisible(useExtensionRenderers);
randomAbrMenuItem = menu.findItem(R.id.random_abr);
tunnelingMenuItem = menu.findItem(R.id.tunneling);
if (Util.SDK_INT < 21) {
tunnelingMenuItem.setEnabled(false);
}
return true;
}
@ -161,13 +169,18 @@ public class SampleChooserActivity extends AppCompatActivity
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);
intent.putExtra(PlayerActivity.TUNNELING_EXTRA, isNonNullAndChecked(tunnelingMenuItem));
sample.addToIntent(intent);
startActivity(intent);
return true;
}
@ -198,6 +211,9 @@ public class SampleChooserActivity extends AppCompatActivity
if (uriSample.drmInfo != null) {
return R.string.download_drm_unsupported;
}
if (uriSample.isLive) {
return R.string.download_live_unsupported;
}
if (uriSample.adTagUri != null) {
return R.string.download_ads_unsupported;
}
@ -287,6 +303,7 @@ public class SampleChooserActivity extends AppCompatActivity
String sampleName = null;
Uri uri = null;
String extension = null;
boolean isLive = false;
String drmScheme = null;
String drmLicenseUrl = null;
String[] drmKeyRequestProperties = null;
@ -294,6 +311,10 @@ public class SampleChooserActivity extends AppCompatActivity
ArrayList<UriSample> playlistSamples = null;
String adTagUri = null;
String sphericalStereoMode = null;
List<Sample.SubtitleInfo> subtitleInfos = new ArrayList<>();
Uri subtitleUri = null;
String subtitleMimeType = null;
String subtitleLanguage = null;
reader.beginObject();
while (reader.hasNext()) {
@ -309,17 +330,15 @@ public class SampleChooserActivity extends AppCompatActivity
extension = reader.nextString();
break;
case "drm_scheme":
Assertions.checkState(!insidePlaylist, "Invalid attribute on nested item: drm_scheme");
drmScheme = reader.nextString();
break;
case "is_live":
isLive = reader.nextBoolean();
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()) {
@ -337,7 +356,7 @@ public class SampleChooserActivity extends AppCompatActivity
playlistSamples = new ArrayList<>();
reader.beginArray();
while (reader.hasNext()) {
playlistSamples.add((UriSample) readEntry(reader, true));
playlistSamples.add((UriSample) readEntry(reader, /* insidePlaylist= */ true));
}
reader.endArray();
break;
@ -349,6 +368,15 @@ public class SampleChooserActivity extends AppCompatActivity
!insidePlaylist, "Invalid attribute on nested item: spherical_stereo_mode");
sphericalStereoMode = reader.nextString();
break;
case "subtitle_uri":
subtitleUri = Uri.parse(reader.nextString());
break;
case "subtitle_mime_type":
subtitleMimeType = reader.nextString();
break;
case "subtitle_language":
subtitleLanguage = reader.nextString();
break;
default:
throw new ParserException("Unsupported attribute name: " + name);
}
@ -357,18 +385,32 @@ public class SampleChooserActivity extends AppCompatActivity
DrmInfo drmInfo =
drmScheme == null
? null
: new DrmInfo(drmScheme, drmLicenseUrl, drmKeyRequestProperties, drmMultiSession);
: new DrmInfo(
Util.getDrmUuid(drmScheme),
drmLicenseUrl,
drmKeyRequestProperties,
drmMultiSession);
Sample.SubtitleInfo subtitleInfo =
subtitleUri == null
? null
: new Sample.SubtitleInfo(
subtitleUri,
Assertions.checkNotNull(
subtitleMimeType, "subtitle_mime_type is required if subtitle_uri is set."),
subtitleLanguage);
if (playlistSamples != null) {
UriSample[] playlistSamplesArray = playlistSamples.toArray(new UriSample[0]);
return new PlaylistSample(sampleName, drmInfo, playlistSamplesArray);
return new PlaylistSample(sampleName, playlistSamplesArray);
} else {
return new UriSample(
sampleName,
drmInfo,
uri,
extension,
adTagUri,
sphericalStereoMode);
isLive,
drmInfo,
adTagUri != null ? Uri.parse(adTagUri) : null,
sphericalStereoMode,
subtitleInfo);
}
}
@ -480,7 +522,7 @@ public class SampleChooserActivity extends AppCompatActivity
ImageButton downloadButton = view.findViewById(R.id.download_button);
downloadButton.setTag(sample);
downloadButton.setColorFilter(
canDownload ? (isDownloaded ? 0xFF42A5F5 : 0xFFBDBDBD) : 0xFFEEEEEE);
canDownload ? (isDownloaded ? 0xFF42A5F5 : 0xFFBDBDBD) : 0xFF666666);
downloadButton.setImageResource(
isDownloaded ? R.drawable.ic_download_done : R.drawable.ic_download);
}
@ -497,116 +539,4 @@ public class SampleChooserActivity extends AppCompatActivity
}
}
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

@ -19,19 +19,18 @@ 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.viewpager.widget.ViewPager;
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.annotation.Nullable;
import androidx.appcompat.app.AppCompatDialog;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentPagerAdapter;
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;
@ -39,6 +38,7 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.Selecti
import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
import com.google.android.exoplayer2.ui.TrackSelectionView;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.material.tabs.TabLayout;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

View File

@ -23,4 +23,8 @@
android:title="@string/random_abr"
android:checkable="true"
app:showAsAction="never"/>
<item android:id="@+id/tunneling"
android:title="@string/tunneling"
android:checkable="true"
app:showAsAction="never"/>
</menu>

View File

@ -29,7 +29,7 @@
<string name="error_unrecognized_stereo_mode">Unrecognized stereo mode</string>
<string name="error_drm_not_supported">Protected content not supported on API levels below 18</string>
<string name="error_drm_unsupported_before_api_18">Protected content not supported on API levels below 18</string>
<string name="error_drm_unsupported_scheme">This device does not support the required DRM scheme</string>
@ -53,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>
@ -61,10 +63,14 @@
<string name="download_scheme_unsupported">This demo app only supports downloading http streams</string>
<string name="download_live_unsupported">This demo app does not support downloading live content</string>
<string name="download_ads_unsupported">IMA does not support offline ads</string>
<string name="prefer_extension_decoders">Prefer extension decoders</string>
<string name="random_abr">Enable random ABR</string>
<string name="tunneling">Request multimedia tunneling</string>
</resources>

View File

@ -24,7 +24,7 @@
</style>
<style name="PlayerTheme.Spherical">
<item name="surface_type">spherical_view</item>
<item name="surface_type">spherical_gl_surface_view</item>
</style>
</resources>

21
demos/surface/README.md Normal file
View File

@ -0,0 +1,21 @@
# ExoPlayer SurfaceControl demo
This app demonstrates how to use the [SurfaceControl][] API to redirect video
output from ExoPlayer between different views or off-screen. `SurfaceControl`
is new in Android 10, so the app requires `minSdkVersion` 29.
The app layout has a grid of `SurfaceViews`. Initially video is output to one
of the views. Tap a `SurfaceView` to move video output to it. You can also tap
the buttons at the top of the activity to move video output off-screen, to a
full-screen `SurfaceView` or to a new activity.
When using `SurfaceControl`, the `MediaCodec` always has the same surface
attached to it, which can be freely 'reparented' to any `SurfaceView` (or
off-screen) without any interruptions to playback. This works better than
calling `MediaCodec.setOutputSurface` to change the output surface of the codec
because `MediaCodec` does not re-render its last frame when that method is
called, and because you can move output off-screen easily (`setOutputSurface`
can't take a `null` surface, so the player has to use a `DummySurface`, which
doesn't handle protected output on all devices).
[SurfaceControl]: https://developer.android.com/reference/android/view/SurfaceControl

View File

@ -1,4 +1,4 @@
// 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.
@ -25,8 +25,8 @@ android {
defaultConfig {
versionName project.ext.releaseVersion
versionCode project.ext.releaseVersionCode
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
minSdkVersion 29
targetSdkVersion project.ext.appTargetSdkVersion
}
buildTypes {
@ -35,14 +35,11 @@ android {
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'
// This demo app does not have translations.
disable 'MissingTranslation'
}
}
@ -50,10 +47,5 @@ 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-ima')
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
}
apply plugin: 'com.google.android.gms.strict-version-matcher-plugin'

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
@ -14,25 +14,28 @@
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.android.exoplayer2.imademo">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
package="com.google.android.exoplayer2.surfacedemo">
<uses-sdk/>
<application android:label="@string/application_name" android:icon="@mipmap/ic_launcher"
android:largeHeap="true" android:allowBackup="false">
<activity android:name="com.google.android.exoplayer2.imademo.MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:label="@string/application_name"
android:theme="@style/PlayerTheme">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/application_name">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="com.google.android.exoplayer.surfacedemo.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="http"/>
<data android:scheme="https"/>
<data android:scheme="content"/>
<data android:scheme="asset"/>
<data android:scheme="file"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,282 @@
/*
* 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.surfacedemo;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.Surface;
import android.view.SurfaceControl;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.widget.Button;
import android.widget.GridLayout;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.dash.DashMediaSource;
import com.google.android.exoplayer2.ui.PlayerControlView;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import java.util.UUID;
/** Activity that demonstrates use of {@link SurfaceControl} with ExoPlayer. */
public final class MainActivity extends Activity {
private static final String DEFAULT_MEDIA_URI =
"https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv";
private static final String SURFACE_CONTROL_NAME = "surfacedemo";
private static final String ACTION_VIEW = "com.google.android.exoplayer.surfacedemo.action.VIEW";
private static final String EXTENSION_EXTRA = "extension";
private static final String DRM_SCHEME_EXTRA = "drm_scheme";
private static final String DRM_LICENSE_URL_EXTRA = "drm_license_url";
private static final String OWNER_EXTRA = "owner";
private boolean isOwner;
@Nullable private PlayerControlView playerControlView;
@Nullable private SurfaceView fullScreenView;
@Nullable private SurfaceView nonFullScreenView;
@Nullable private SurfaceView currentOutputView;
@Nullable private static SimpleExoPlayer player;
@Nullable private static SurfaceControl surfaceControl;
@Nullable private static Surface videoSurface;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main_activity);
playerControlView = findViewById(R.id.player_control_view);
fullScreenView = findViewById(R.id.full_screen_view);
fullScreenView.setOnClickListener(
v -> {
setCurrentOutputView(nonFullScreenView);
Assertions.checkNotNull(fullScreenView).setVisibility(View.GONE);
});
attachSurfaceListener(fullScreenView);
isOwner = getIntent().getBooleanExtra(OWNER_EXTRA, /* defaultValue= */ true);
GridLayout gridLayout = findViewById(R.id.grid_layout);
for (int i = 0; i < 9; i++) {
View view;
if (i == 0) {
Button button = new Button(/* context= */ this);
view = button;
button.setText(getString(R.string.no_output_label));
button.setOnClickListener(v -> reparent(/* surfaceView= */ null));
} else if (i == 1) {
Button button = new Button(/* context= */ this);
view = button;
button.setText(getString(R.string.full_screen_label));
button.setOnClickListener(
v -> {
setCurrentOutputView(fullScreenView);
Assertions.checkNotNull(fullScreenView).setVisibility(View.VISIBLE);
});
} else if (i == 2) {
Button button = new Button(/* context= */ this);
view = button;
button.setText(getString(R.string.new_activity_label));
button.setOnClickListener(
v ->
startActivity(
new Intent(MainActivity.this, MainActivity.class)
.putExtra(OWNER_EXTRA, /* value= */ false)));
} else {
SurfaceView surfaceView = new SurfaceView(this);
view = surfaceView;
attachSurfaceListener(surfaceView);
surfaceView.setOnClickListener(
v -> {
setCurrentOutputView(surfaceView);
nonFullScreenView = surfaceView;
});
if (nonFullScreenView == null) {
nonFullScreenView = surfaceView;
}
}
gridLayout.addView(view);
GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams();
layoutParams.width = 0;
layoutParams.height = 0;
layoutParams.columnSpec = GridLayout.spec(i % 3, 1f);
layoutParams.rowSpec = GridLayout.spec(i / 3, 1f);
layoutParams.bottomMargin = 10;
layoutParams.leftMargin = 10;
layoutParams.topMargin = 10;
layoutParams.rightMargin = 10;
view.setLayoutParams(layoutParams);
}
}
@Override
public void onResume() {
super.onResume();
if (isOwner && player == null) {
initializePlayer();
}
setCurrentOutputView(nonFullScreenView);
PlayerControlView playerControlView = Assertions.checkNotNull(this.playerControlView);
playerControlView.setPlayer(player);
playerControlView.show();
}
@Override
public void onPause() {
super.onPause();
Assertions.checkNotNull(playerControlView).setPlayer(null);
}
@Override
public void onDestroy() {
super.onDestroy();
if (isOwner && isFinishing()) {
if (surfaceControl != null) {
surfaceControl.release();
surfaceControl = null;
}
if (videoSurface != null) {
videoSurface.release();
videoSurface = null;
}
if (player != null) {
player.release();
player = null;
}
}
}
private void initializePlayer() {
Intent intent = getIntent();
String action = intent.getAction();
Uri uri =
ACTION_VIEW.equals(action)
? Assertions.checkNotNull(intent.getData())
: Uri.parse(DEFAULT_MEDIA_URI);
String userAgent = Util.getUserAgent(this, getString(R.string.application_name));
DrmSessionManager<ExoMediaCrypto> drmSessionManager;
if (intent.hasExtra(DRM_SCHEME_EXTRA)) {
String drmScheme = Assertions.checkNotNull(intent.getStringExtra(DRM_SCHEME_EXTRA));
String drmLicenseUrl = Assertions.checkNotNull(intent.getStringExtra(DRM_LICENSE_URL_EXTRA));
UUID drmSchemeUuid = Assertions.checkNotNull(Util.getDrmUuid(drmScheme));
HttpDataSource.Factory licenseDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent);
HttpMediaDrmCallback drmCallback =
new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory);
drmSessionManager =
new DefaultDrmSessionManager.Builder()
.setUuidAndExoMediaDrmProvider(drmSchemeUuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
.build(drmCallback);
} else {
drmSessionManager = DrmSessionManager.getDummyDrmSessionManager();
}
DataSource.Factory dataSourceFactory =
new DefaultDataSourceFactory(
this, Util.getUserAgent(this, getString(R.string.application_name)));
MediaSource mediaSource;
@C.ContentType int type = Util.inferContentType(uri, intent.getStringExtra(EXTENSION_EXTRA));
if (type == C.TYPE_DASH) {
mediaSource =
new DashMediaSource.Factory(dataSourceFactory)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
} else if (type == C.TYPE_OTHER) {
mediaSource =
new ProgressiveMediaSource.Factory(dataSourceFactory)
.setDrmSessionManager(drmSessionManager)
.createMediaSource(uri);
} else {
throw new IllegalStateException();
}
SimpleExoPlayer player = new SimpleExoPlayer.Builder(getApplicationContext()).build();
player.prepare(mediaSource);
player.setPlayWhenReady(true);
player.setRepeatMode(Player.REPEAT_MODE_ALL);
surfaceControl =
new SurfaceControl.Builder()
.setName(SURFACE_CONTROL_NAME)
.setBufferSize(/* width= */ 0, /* height= */ 0)
.build();
videoSurface = new Surface(surfaceControl);
player.setVideoSurface(videoSurface);
MainActivity.player = player;
}
private void setCurrentOutputView(@Nullable SurfaceView surfaceView) {
currentOutputView = surfaceView;
if (surfaceView != null && surfaceView.getHolder().getSurface() != null) {
reparent(surfaceView);
}
}
private void attachSurfaceListener(SurfaceView surfaceView) {
surfaceView
.getHolder()
.addCallback(
new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder surfaceHolder) {
if (surfaceView == currentOutputView) {
reparent(surfaceView);
}
}
@Override
public void surfaceChanged(
SurfaceHolder surfaceHolder, int format, int width, int height) {}
@Override
public void surfaceDestroyed(SurfaceHolder surfaceHolder) {}
});
}
private static void reparent(@Nullable SurfaceView surfaceView) {
SurfaceControl surfaceControl = Assertions.checkNotNull(MainActivity.surfaceControl);
if (surfaceView == null) {
new SurfaceControl.Transaction()
.reparent(surfaceControl, /* newParent= */ null)
.setBufferSize(surfaceControl, /* w= */ 0, /* h= */ 0)
.setVisibility(surfaceControl, /* visible= */ false)
.apply();
} else {
SurfaceControl newParentSurfaceControl = surfaceView.getSurfaceControl();
new SurfaceControl.Transaction()
.reparent(surfaceControl, newParentSurfaceControl)
.setBufferSize(surfaceControl, surfaceView.getWidth(), surfaceView.getHeight())
.setVisibility(surfaceControl, /* visible= */ true)
.apply();
}
}
}

View File

@ -0,0 +1,51 @@
<?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.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<GridLayout
android:id="@+id/grid_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:columnCount="3"/>
<com.google.android.exoplayer2.ui.PlayerControlView
android:id="@+id/player_control_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
app:show_timeout="0"/>
</LinearLayout>
<SurfaceView
android:id="@+id/full_screen_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"/>
</FrameLayout>

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2018 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.
@ -14,5 +14,10 @@
limitations under the License.
-->
<resources>
<style name="VrTheme" parent="android:Theme.Material"/>
<string name="application_name">ExoPlayer SurfaceControl demo</string>
<string name="no_output_label">No output</string>
<string name="full_screen_label">Full screen</string>
<string name="new_activity_label">New activity</string>
</resources>

126
extensions/av1/README.md Normal file
View File

@ -0,0 +1,126 @@
# ExoPlayer AV1 extension #
The AV1 extension provides `Libgav1VideoRenderer`, which uses libgav1 native
library to decode AV1 videos.
## License note ##
Please note that whilst the code in this repository is licensed under
[Apache 2.0][], using this extension also requires building and including one or
more external libraries as described below. These are licensed separately.
[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
## Build instructions (Linux, macOS) ##
To use this extension you need to clone the ExoPlayer repository and depend on
its modules locally. Instructions for doing this can be found in ExoPlayer's
[top level README][].
In addition, it's necessary to fetch cpu_features library and libgav1 with its
dependencies as follows:
* Set the following environment variables:
```
cd "<path to exoplayer checkout>"
EXOPLAYER_ROOT="$(pwd)"
AV1_EXT_PATH="${EXOPLAYER_ROOT}/extensions/av1/src/main"
```
* Fetch cpu_features library:
```
cd "${AV1_EXT_PATH}/jni" && \
git clone https://github.com/google/cpu_features
```
* Fetch libgav1:
```
cd "${AV1_EXT_PATH}/jni" && \
git clone https://chromium.googlesource.com/codecs/libgav1 libgav1
```
* Fetch Abseil:
```
cd "${AV1_EXT_PATH}/jni/libgav1" && \
git clone https://github.com/abseil/abseil-cpp.git third_party/abseil-cpp
```
* [Install CMake][].
Having followed these steps, gradle will build the extension automatically when
run on the command line or via Android Studio, using [CMake][] and [Ninja][]
to configure and build libgav1 and the extension's [JNI wrapper library][].
[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
[Install CMake]: https://developer.android.com/studio/projects/install-ndk
[CMake]: https://cmake.org/
[Ninja]: https://ninja-build.org
[JNI wrapper library]: https://github.com/google/ExoPlayer/blob/release-v2/extensions/av1/src/main/jni/gav1_jni.cc
## Build instructions (Windows) ##
We do not provide support for building this extension on Windows, however it
should be possible to follow the Linux instructions in [Windows PowerShell][].
[Windows PowerShell]: https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell
## Using the extension ##
Once you've followed the instructions above to check out, build and depend on
the extension, the next step is to tell ExoPlayer to use `Libgav1VideoRenderer`.
How you do this depends on which player API you're using:
* If you're passing a `DefaultRenderersFactory` to `SimpleExoPlayer.Builder`,
you can enable using the extension by setting the `extensionRendererMode`
parameter of the `DefaultRenderersFactory` constructor to
`EXTENSION_RENDERER_MODE_ON`. This will use `Libgav1VideoRenderer` for
playback if `MediaCodecVideoRenderer` doesn't support decoding the input AV1
stream. Pass `EXTENSION_RENDERER_MODE_PREFER` to give `Libgav1VideoRenderer`
priority over `MediaCodecVideoRenderer`.
* If you've subclassed `DefaultRenderersFactory`, add a `Libvgav1VideoRenderer`
to the output list in `buildVideoRenderers`. ExoPlayer will use the first
`Renderer` in the list that supports the input media format.
* If you've implemented your own `RenderersFactory`, return a
`Libgav1VideoRenderer` instance from `createRenderers`. ExoPlayer will use the
first `Renderer` in the returned array that supports the input media format.
* If you're using `ExoPlayer.Builder`, pass a `Libgav1VideoRenderer` in the
array of `Renderer`s. ExoPlayer will use the first `Renderer` in the list that
supports the input media format.
Note: These instructions assume you're using `DefaultTrackSelector`. If you have
a custom track selector the choice of `Renderer` is up to your implementation.
You need to make sure you are passing a `Libgav1VideoRenderer` to the player and
then you need to implement your own logic to use the renderer for a given track.
## Rendering options ##
There are two possibilities for rendering the output `Libgav1VideoRenderer`
gets from the libgav1 decoder:
* GL rendering using GL shader for color space conversion
* If you are using `SimpleExoPlayer` with `PlayerView`, enable this option by
setting `surface_type` of `PlayerView` to be
`video_decoder_gl_surface_view`.
* Otherwise, enable this option by sending `Libgav1VideoRenderer` a message
of type `C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER` with an instance of
`VideoDecoderOutputBufferRenderer` as its object.
* Native rendering using `ANativeWindow`
* If you are using `SimpleExoPlayer` with `PlayerView`, this option is enabled
by default.
* Otherwise, enable this option by sending `Libgav1VideoRenderer` a message of
type `C.MSG_SET_SURFACE` with an instance of `SurfaceView` as its object.
Note: Although the default option uses `ANativeWindow`, based on our testing the
GL rendering mode has better performance, so should be preferred
## Links ##
* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.av1.*`
belong to this module.
[Javadoc]: https://exoplayer.dev/doc/reference/index.html

View File

@ -0,0 +1,73 @@
// 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.library'
android {
compileSdkVersion project.ext.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
consumerProguardFiles 'proguard-rules.txt'
externalNativeBuild {
cmake {
// Debug CMake build type causes video frames to drop,
// so native library should always use Release build type.
arguments "-DCMAKE_BUILD_TYPE=Release"
targets "gav1JNI"
}
}
}
// This option resolves the problem of finding libgav1JNI.so
// on multiple paths. The first one found is picked.
packagingOptions {
pickFirst 'lib/arm64-v8a/libgav1JNI.so'
pickFirst 'lib/armeabi-v7a/libgav1JNI.so'
pickFirst 'lib/x86/libgav1JNI.so'
pickFirst 'lib/x86_64/libgav1JNI.so'
}
sourceSets.main {
// As native JNI library build is invoked from gradle, this is
// not needed. However, it exposes the built library and keeps
// consistency with the other extensions.
jniLibs.srcDir 'src/main/libs'
}
}
// Configure the native build only if libgav1 is present, to avoid gradle sync
// failures if libgav1 hasn't been checked out according to the README and CMake
// isn't installed.
if (project.file('src/main/jni/libgav1').exists()) {
android.externalNativeBuild.cmake.path = 'src/main/jni/CMakeLists.txt'
android.externalNativeBuild.cmake.version = '3.7.1+'
}
dependencies {
implementation project(modulePrefix + 'library-core')
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
}
ext {
javadocTitle = 'AV1 extension'
}
apply from: '../../javadoc_library.gradle'

View File

@ -0,0 +1,7 @@
# Proguard rules specific to the AV1 extension.
# This prevents the names of native methods from being obfuscated.
-keepclasseswithmembernames class * {
native <methods>;
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2018 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.
@ -14,4 +14,4 @@
limitations under the License.
-->
<manifest package="com.google.android.exoplayer2.testutil"/>
<manifest package="com.google.android.exoplayer2.ext.av1"/>

View File

@ -0,0 +1,234 @@
/*
* 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.av1;
import android.view.Surface;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.decoder.SimpleDecoder;
import com.google.android.exoplayer2.util.Util;
import com.google.android.exoplayer2.video.VideoDecoderInputBuffer;
import com.google.android.exoplayer2.video.VideoDecoderOutputBuffer;
import java.nio.ByteBuffer;
/** Gav1 decoder. */
/* package */ final class Gav1Decoder
extends SimpleDecoder<VideoDecoderInputBuffer, VideoDecoderOutputBuffer, Gav1DecoderException> {
// LINT.IfChange
private static final int GAV1_ERROR = 0;
private static final int GAV1_OK = 1;
private static final int GAV1_DECODE_ONLY = 2;
// LINT.ThenChange(../../../../../../../jni/gav1_jni.cc)
private final long gav1DecoderContext;
@C.VideoOutputMode private volatile int outputMode;
/**
* Creates a Gav1Decoder.
*
* @param numInputBuffers Number of input buffers.
* @param numOutputBuffers Number of output buffers.
* @param initialInputBufferSize The initial size of each input buffer, in bytes.
* @param threads Number of threads libgav1 will use to decode.
* @throws Gav1DecoderException Thrown if an exception occurs when initializing the decoder.
*/
public Gav1Decoder(
int numInputBuffers, int numOutputBuffers, int initialInputBufferSize, int threads)
throws Gav1DecoderException {
super(
new VideoDecoderInputBuffer[numInputBuffers],
new VideoDecoderOutputBuffer[numOutputBuffers]);
if (!Gav1Library.isAvailable()) {
throw new Gav1DecoderException("Failed to load decoder native library.");
}
gav1DecoderContext = gav1Init(threads);
if (gav1DecoderContext == GAV1_ERROR || gav1CheckError(gav1DecoderContext) == GAV1_ERROR) {
throw new Gav1DecoderException(
"Failed to initialize decoder. Error: " + gav1GetErrorMessage(gav1DecoderContext));
}
setInitialInputBufferSize(initialInputBufferSize);
}
@Override
public String getName() {
return "libgav1";
}
/**
* Sets the output mode for frames rendered by the decoder.
*
* @param outputMode The output mode.
*/
public void setOutputMode(@C.VideoOutputMode int outputMode) {
this.outputMode = outputMode;
}
@Override
protected VideoDecoderInputBuffer createInputBuffer() {
return new VideoDecoderInputBuffer();
}
@Override
protected VideoDecoderOutputBuffer createOutputBuffer() {
return new VideoDecoderOutputBuffer(this::releaseOutputBuffer);
}
@Nullable
@Override
protected Gav1DecoderException decode(
VideoDecoderInputBuffer inputBuffer, VideoDecoderOutputBuffer outputBuffer, boolean reset) {
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
int inputSize = inputData.limit();
if (gav1Decode(gav1DecoderContext, inputData, inputSize) == GAV1_ERROR) {
return new Gav1DecoderException(
"gav1Decode error: " + gav1GetErrorMessage(gav1DecoderContext));
}
boolean decodeOnly = inputBuffer.isDecodeOnly();
if (!decodeOnly) {
outputBuffer.init(inputBuffer.timeUs, outputMode, /* supplementalData= */ null);
}
// We need to dequeue the decoded frame from the decoder even when the input data is
// decode-only.
int getFrameResult = gav1GetFrame(gav1DecoderContext, outputBuffer, decodeOnly);
if (getFrameResult == GAV1_ERROR) {
return new Gav1DecoderException(
"gav1GetFrame error: " + gav1GetErrorMessage(gav1DecoderContext));
}
if (getFrameResult == GAV1_DECODE_ONLY) {
outputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY);
}
if (!decodeOnly) {
outputBuffer.colorInfo = inputBuffer.colorInfo;
}
return null;
}
@Override
protected Gav1DecoderException createUnexpectedDecodeException(Throwable error) {
return new Gav1DecoderException("Unexpected decode error", error);
}
@Override
public void release() {
super.release();
gav1Close(gav1DecoderContext);
}
@Override
protected void releaseOutputBuffer(VideoDecoderOutputBuffer buffer) {
// Decode only frames do not acquire a reference on the internal decoder buffer and thus do not
// require a call to gav1ReleaseFrame.
if (buffer.mode == C.VIDEO_OUTPUT_MODE_SURFACE_YUV && !buffer.isDecodeOnly()) {
gav1ReleaseFrame(gav1DecoderContext, buffer);
}
super.releaseOutputBuffer(buffer);
}
/**
* Renders output buffer to the given surface. Must only be called when in {@link
* C#VIDEO_OUTPUT_MODE_SURFACE_YUV} mode.
*
* @param outputBuffer Output buffer.
* @param surface Output surface.
* @throws Gav1DecoderException Thrown if called with invalid output mode or frame rendering
* fails.
*/
public void renderToSurface(VideoDecoderOutputBuffer outputBuffer, Surface surface)
throws Gav1DecoderException {
if (outputBuffer.mode != C.VIDEO_OUTPUT_MODE_SURFACE_YUV) {
throw new Gav1DecoderException("Invalid output mode.");
}
if (gav1RenderFrame(gav1DecoderContext, surface, outputBuffer) == GAV1_ERROR) {
throw new Gav1DecoderException(
"Buffer render error: " + gav1GetErrorMessage(gav1DecoderContext));
}
}
/**
* Initializes a libgav1 decoder.
*
* @param threads Number of threads to be used by a libgav1 decoder.
* @return The address of the decoder context or {@link #GAV1_ERROR} if there was an error.
*/
private native long gav1Init(int threads);
/**
* Deallocates the decoder context.
*
* @param context Decoder context.
*/
private native void gav1Close(long context);
/**
* Decodes the encoded data passed.
*
* @param context Decoder context.
* @param encodedData Encoded data.
* @param length Length of the data buffer.
* @return {@link #GAV1_OK} if successful, {@link #GAV1_ERROR} if an error occurred.
*/
private native int gav1Decode(long context, ByteBuffer encodedData, int length);
/**
* Gets the decoded frame.
*
* @param context Decoder context.
* @param outputBuffer Output buffer for the decoded frame.
* @return {@link #GAV1_OK} if successful, {@link #GAV1_DECODE_ONLY} if successful but the frame
* is decode-only, {@link #GAV1_ERROR} if an error occurred.
*/
private native int gav1GetFrame(
long context, VideoDecoderOutputBuffer outputBuffer, boolean decodeOnly);
/**
* Renders the frame to the surface. Used with {@link C#VIDEO_OUTPUT_MODE_SURFACE_YUV} only.
*
* @param context Decoder context.
* @param surface Output surface.
* @param outputBuffer Output buffer with the decoded frame.
* @return {@link #GAV1_OK} if successful, {@link #GAV1_ERROR} if an error occured.
*/
private native int gav1RenderFrame(
long context, Surface surface, VideoDecoderOutputBuffer outputBuffer);
/**
* Releases the frame. Used with {@link C#VIDEO_OUTPUT_MODE_SURFACE_YUV} only.
*
* @param context Decoder context.
* @param outputBuffer Output buffer.
*/
private native void gav1ReleaseFrame(long context, VideoDecoderOutputBuffer outputBuffer);
/**
* Returns a human-readable string describing the last error encountered in the given context.
*
* @param context Decoder context.
* @return A string describing the last encountered error.
*/
private native String gav1GetErrorMessage(long context);
/**
* Returns whether an error occured.
*
* @param context Decoder context.
* @return {@link #GAV1_OK} if there was no error, {@link #GAV1_ERROR} if an error occured.
*/
private native int gav1CheckError(long context);
}

View File

@ -0,0 +1,30 @@
/*
* 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.av1;
import com.google.android.exoplayer2.video.VideoDecoderException;
/** Thrown when a libgav1 decoder error occurs. */
public final class Gav1DecoderException extends VideoDecoderException {
/* package */ Gav1DecoderException(String message) {
super(message);
}
/* package */ Gav1DecoderException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,36 @@
/*
* 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.av1;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.util.LibraryLoader;
/** Configures and queries the underlying native library. */
public final class Gav1Library {
static {
ExoPlayerLibraryInfo.registerModule("goog.exo.gav1");
}
private static final LibraryLoader LOADER = new LibraryLoader("gav1JNI");
private Gav1Library() {}
/** Returns whether the underlying library is available, loading it if necessary. */
public static boolean isAvailable() {
return LOADER.isAvailable();
}
}

View File

@ -0,0 +1,197 @@
/*
* 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.av1;
import static java.lang.Runtime.getRuntime;
import android.os.Handler;
import android.view.Surface;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.PlayerMessage.Target;
import com.google.android.exoplayer2.RendererCapabilities;
import com.google.android.exoplayer2.decoder.SimpleDecoder;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.TraceUtil;
import com.google.android.exoplayer2.util.Util;
import com.google.android.exoplayer2.video.SimpleDecoderVideoRenderer;
import com.google.android.exoplayer2.video.VideoDecoderException;
import com.google.android.exoplayer2.video.VideoDecoderInputBuffer;
import com.google.android.exoplayer2.video.VideoDecoderOutputBuffer;
import com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer;
import com.google.android.exoplayer2.video.VideoRendererEventListener;
/**
* Decodes and renders video using libgav1 decoder.
*
* <p>This renderer accepts the following messages sent via {@link ExoPlayer#createMessage(Target)}
* on the playback thread:
*
* <ul>
* <li>Message with type {@link C#MSG_SET_SURFACE} to set the output surface. The message payload
* should be the target {@link Surface}, or null.
* <li>Message with type {@link C#MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER} to set the output
* buffer renderer. The message payload should be the target {@link
* VideoDecoderOutputBufferRenderer}, or null.
* </ul>
*/
public class Libgav1VideoRenderer extends SimpleDecoderVideoRenderer {
private static final int DEFAULT_NUM_OF_INPUT_BUFFERS = 4;
private static final int DEFAULT_NUM_OF_OUTPUT_BUFFERS = 4;
/* Default size based on 720p resolution video compressed by a factor of two. */
private static final int DEFAULT_INPUT_BUFFER_SIZE =
Util.ceilDivide(1280, 64) * Util.ceilDivide(720, 64) * (64 * 64 * 3 / 2) / 2;
/** The number of input buffers. */
private final int numInputBuffers;
/**
* The number of output buffers. The renderer may limit the minimum possible value due to
* requiring multiple output buffers to be dequeued at a time for it to make progress.
*/
private final int numOutputBuffers;
private final int threads;
@Nullable private Gav1Decoder decoder;
/**
* Creates a Libgav1VideoRenderer.
*
* @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer
* can attempt to seamlessly join an ongoing playback.
* @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
* null if delivery of events is not required.
* @param eventListener A listener of events. May be null if delivery of events is not required.
* @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between
* invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}.
*/
public Libgav1VideoRenderer(
long allowedJoiningTimeMs,
@Nullable Handler eventHandler,
@Nullable VideoRendererEventListener eventListener,
int maxDroppedFramesToNotify) {
this(
allowedJoiningTimeMs,
eventHandler,
eventListener,
maxDroppedFramesToNotify,
/* threads= */ getRuntime().availableProcessors(),
DEFAULT_NUM_OF_INPUT_BUFFERS,
DEFAULT_NUM_OF_OUTPUT_BUFFERS);
}
/**
* Creates a Libgav1VideoRenderer.
*
* @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer
* can attempt to seamlessly join an ongoing playback.
* @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
* null if delivery of events is not required.
* @param eventListener A listener of events. May be null if delivery of events is not required.
* @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between
* invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}.
* @param threads Number of threads libgav1 will use to decode.
* @param numInputBuffers Number of input buffers.
* @param numOutputBuffers Number of output buffers.
*/
public Libgav1VideoRenderer(
long allowedJoiningTimeMs,
@Nullable Handler eventHandler,
@Nullable VideoRendererEventListener eventListener,
int maxDroppedFramesToNotify,
int threads,
int numInputBuffers,
int numOutputBuffers) {
super(
allowedJoiningTimeMs,
eventHandler,
eventListener,
maxDroppedFramesToNotify,
/* drmSessionManager= */ null,
/* playClearSamplesWithoutKeys= */ false);
this.threads = threads;
this.numInputBuffers = numInputBuffers;
this.numOutputBuffers = numOutputBuffers;
}
@Override
@Capabilities
protected int supportsFormatInternal(
@Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, Format format) {
if (!MimeTypes.VIDEO_AV1.equalsIgnoreCase(format.sampleMimeType)
|| !Gav1Library.isAvailable()) {
return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
}
if (!supportsFormatDrm(drmSessionManager, format.drmInitData)) {
return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM);
}
return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_SEAMLESS, TUNNELING_NOT_SUPPORTED);
}
@Override
protected SimpleDecoder<
VideoDecoderInputBuffer,
? extends VideoDecoderOutputBuffer,
? extends VideoDecoderException>
createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto)
throws VideoDecoderException {
TraceUtil.beginSection("createGav1Decoder");
int initialInputBufferSize =
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
Gav1Decoder decoder =
new Gav1Decoder(numInputBuffers, numOutputBuffers, initialInputBufferSize, threads);
this.decoder = decoder;
TraceUtil.endSection();
return decoder;
}
@Override
protected void renderOutputBufferToSurface(VideoDecoderOutputBuffer outputBuffer, Surface surface)
throws Gav1DecoderException {
if (decoder == null) {
throw new Gav1DecoderException(
"Failed to render output buffer to surface: decoder is not initialized.");
}
decoder.renderToSurface(outputBuffer, surface);
outputBuffer.release();
}
@Override
protected void setDecoderOutputMode(@C.VideoOutputMode int outputMode) {
if (decoder != null) {
decoder.setOutputMode(outputMode);
}
}
// PlayerMessage.Target implementation.
@Override
public void handleMessage(int messageType, @Nullable Object message) throws ExoPlaybackException {
if (messageType == C.MSG_SET_SURFACE) {
setOutputSurface((Surface) message);
} else if (messageType == C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) {
setOutputBufferRenderer((VideoDecoderOutputBufferRenderer) message);
} else {
super.handleMessage(messageType, message);
}
}
}

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.av1;
import com.google.android.exoplayer2.util.NonNullApi;

View File

@ -0,0 +1,56 @@
# libgav1JNI requires modern CMake.
cmake_minimum_required(VERSION 3.7.1 FATAL_ERROR)
# libgav1JNI requires C++11.
set(CMAKE_CXX_STANDARD 11)
project(libgav1JNI C CXX)
# Devices using armeabi-v7a are not required to support
# Neon which is why Neon is disabled by default for
# armeabi-v7a build. This flag enables it.
if(${ANDROID_ABI} MATCHES "armeabi-v7a")
add_compile_options("-mfpu=neon")
add_compile_options("-fPIC")
endif()
set(libgav1_jni_root "${CMAKE_CURRENT_SOURCE_DIR}")
set(libgav1_jni_build "${CMAKE_BINARY_DIR}")
set(libgav1_jni_output_directory
${libgav1_jni_root}/../libs/${ANDROID_ABI}/)
set(libgav1_root "${libgav1_jni_root}/libgav1")
set(libgav1_build "${libgav1_jni_build}/libgav1")
set(cpu_features_root "${libgav1_jni_root}/cpu_features")
set(cpu_features_build "${libgav1_jni_build}/cpu_features")
# Build cpu_features library.
add_subdirectory("${cpu_features_root}"
"${cpu_features_build}"
EXCLUDE_FROM_ALL)
# Build libgav1.
add_subdirectory("${libgav1_root}"
"${libgav1_build}"
EXCLUDE_FROM_ALL)
# Build libgav1JNI.
add_library(gav1JNI
SHARED
gav1_jni.cc)
# Locate NDK log library.
find_library(android_log_lib log)
# Link libgav1JNI against used libraries.
target_link_libraries(gav1JNI
PRIVATE android
PRIVATE cpu_features
PRIVATE libgav1_static
PRIVATE ${android_log_lib})
# Specify output directory for libgav1JNI.
set_target_properties(gav1JNI PROPERTIES
LIBRARY_OUTPUT_DIRECTORY
${libgav1_jni_output_directory})

View File

@ -0,0 +1,754 @@
/*
* 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.
*/
#include <android/log.h>
#include <android/native_window.h>
#include <android/native_window_jni.h>
#include "cpu_features_macros.h" // NOLINT
#ifdef CPU_FEATURES_ARCH_ARM
#include "cpuinfo_arm.h" // NOLINT
#endif // CPU_FEATURES_ARCH_ARM
#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
#include <arm_neon.h>
#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
#include <jni.h>
#include <cstring>
#include <mutex> // NOLINT
#include <new>
#include "gav1/decoder.h"
#define LOG_TAG "gav1_jni"
#define LOGE(...) \
((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
#define DECODER_FUNC(RETURN_TYPE, NAME, ...) \
extern "C" { \
JNIEXPORT RETURN_TYPE \
Java_com_google_android_exoplayer2_ext_av1_Gav1Decoder_##NAME( \
JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
} \
JNIEXPORT RETURN_TYPE \
Java_com_google_android_exoplayer2_ext_av1_Gav1Decoder_##NAME( \
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
return -1;
}
return JNI_VERSION_1_6;
}
namespace {
// YUV plane indices.
const int kPlaneY = 0;
const int kPlaneU = 1;
const int kPlaneV = 2;
const int kMaxPlanes = 3;
// Android YUV format. See:
// https://developer.android.com/reference/android/graphics/ImageFormat.html#YV12.
const int kImageFormatYV12 = 0x32315659;
// LINT.IfChange
// Output modes.
const int kOutputModeYuv = 0;
const int kOutputModeSurfaceYuv = 1;
// LINT.ThenChange(../../../../../library/core/src/main/java/com/google/android/exoplayer2/C.java)
// LINT.IfChange
const int kColorSpaceUnknown = 0;
// LINT.ThenChange(../../../../../library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java)
// LINT.IfChange
// Return codes for jni methods.
const int kStatusError = 0;
const int kStatusOk = 1;
const int kStatusDecodeOnly = 2;
// LINT.ThenChange(../java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java)
// Status codes specific to the JNI wrapper code.
enum JniStatusCode {
kJniStatusOk = 0,
kJniStatusOutOfMemory = -1,
kJniStatusBufferAlreadyReleased = -2,
kJniStatusInvalidNumOfPlanes = -3,
kJniStatusBitDepth12NotSupportedWithYuv = -4,
kJniStatusHighBitDepthNotSupportedWithSurfaceYuv = -5,
kJniStatusANativeWindowError = -6,
kJniStatusBufferResizeError = -7,
kJniStatusNeonNotSupported = -8
};
const char* GetJniErrorMessage(JniStatusCode error_code) {
switch (error_code) {
case kJniStatusOutOfMemory:
return "Out of memory.";
case kJniStatusBufferAlreadyReleased:
return "JNI buffer already released.";
case kJniStatusBitDepth12NotSupportedWithYuv:
return "Bit depth 12 is not supported with YUV.";
case kJniStatusHighBitDepthNotSupportedWithSurfaceYuv:
return "High bit depth (10 or 12 bits per pixel) output format is not "
"supported with YUV surface.";
case kJniStatusInvalidNumOfPlanes:
return "Libgav1 decoded buffer has invalid number of planes.";
case kJniStatusANativeWindowError:
return "ANativeWindow error.";
case kJniStatusBufferResizeError:
return "Buffer resize failed.";
case kJniStatusNeonNotSupported:
return "Neon is not supported.";
default:
return "Unrecognized error code.";
}
}
// Manages Libgav1FrameBuffer and reference information.
class JniFrameBuffer {
public:
explicit JniFrameBuffer(int id) : id_(id), reference_count_(0) {
gav1_frame_buffer_.private_data = &id_;
}
~JniFrameBuffer() {
for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) {
delete[] gav1_frame_buffer_.data[plane_index];
}
}
void SetFrameData(const libgav1::DecoderBuffer& decoder_buffer) {
for (int plane_index = kPlaneY; plane_index < decoder_buffer.NumPlanes();
plane_index++) {
stride_[plane_index] = decoder_buffer.stride[plane_index];
plane_[plane_index] = decoder_buffer.plane[plane_index];
displayed_width_[plane_index] =
decoder_buffer.displayed_width[plane_index];
displayed_height_[plane_index] =
decoder_buffer.displayed_height[plane_index];
}
}
int Stride(int plane_index) const { return stride_[plane_index]; }
uint8_t* Plane(int plane_index) const { return plane_[plane_index]; }
int DisplayedWidth(int plane_index) const {
return displayed_width_[plane_index];
}
int DisplayedHeight(int plane_index) const {
return displayed_height_[plane_index];
}
// Methods maintaining reference count are not thread-safe. They must be
// called with a lock held.
void AddReference() { ++reference_count_; }
void RemoveReference() { reference_count_--; }
bool InUse() const { return reference_count_ != 0; }
const Libgav1FrameBuffer& GetGav1FrameBuffer() const {
return gav1_frame_buffer_;
}
// Attempts to reallocate data planes if the existing ones don't have enough
// capacity. Returns true if the allocation was successful or wasn't needed,
// false if the allocation failed.
bool MaybeReallocateGav1DataPlanes(int y_plane_min_size,
int uv_plane_min_size) {
for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) {
const int min_size =
(plane_index == kPlaneY) ? y_plane_min_size : uv_plane_min_size;
if (gav1_frame_buffer_.size[plane_index] >= min_size) continue;
delete[] gav1_frame_buffer_.data[plane_index];
gav1_frame_buffer_.data[plane_index] =
new (std::nothrow) uint8_t[min_size];
if (!gav1_frame_buffer_.data[plane_index]) {
gav1_frame_buffer_.size[plane_index] = 0;
return false;
}
gav1_frame_buffer_.size[plane_index] = min_size;
}
return true;
}
private:
int stride_[kMaxPlanes];
uint8_t* plane_[kMaxPlanes];
int displayed_width_[kMaxPlanes];
int displayed_height_[kMaxPlanes];
int id_;
int reference_count_;
Libgav1FrameBuffer gav1_frame_buffer_ = {};
};
// Manages frame buffers used by libgav1 decoder and ExoPlayer.
// Handles synchronization between libgav1 and ExoPlayer threads.
class JniBufferManager {
public:
~JniBufferManager() {
// This lock does not do anything since libgav1 has released all the frame
// buffers. It exists to merely be consistent with all other usage of
// |all_buffers_| and |all_buffer_count_|.
std::lock_guard<std::mutex> lock(mutex_);
while (all_buffer_count_--) {
delete all_buffers_[all_buffer_count_];
}
}
JniStatusCode GetBuffer(size_t y_plane_min_size, size_t uv_plane_min_size,
Libgav1FrameBuffer* frame_buffer) {
std::lock_guard<std::mutex> lock(mutex_);
JniFrameBuffer* output_buffer;
if (free_buffer_count_) {
output_buffer = free_buffers_[--free_buffer_count_];
} else if (all_buffer_count_ < kMaxFrames) {
output_buffer = new (std::nothrow) JniFrameBuffer(all_buffer_count_);
if (output_buffer == nullptr) return kJniStatusOutOfMemory;
all_buffers_[all_buffer_count_++] = output_buffer;
} else {
// Maximum number of buffers is being used.
return kJniStatusOutOfMemory;
}
if (!output_buffer->MaybeReallocateGav1DataPlanes(y_plane_min_size,
uv_plane_min_size)) {
return kJniStatusOutOfMemory;
}
output_buffer->AddReference();
*frame_buffer = output_buffer->GetGav1FrameBuffer();
return kJniStatusOk;
}
JniFrameBuffer* GetBuffer(int id) const { return all_buffers_[id]; }
void AddBufferReference(int id) {
std::lock_guard<std::mutex> lock(mutex_);
all_buffers_[id]->AddReference();
}
JniStatusCode ReleaseBuffer(int id) {
std::lock_guard<std::mutex> lock(mutex_);
JniFrameBuffer* buffer = all_buffers_[id];
if (!buffer->InUse()) {
return kJniStatusBufferAlreadyReleased;
}
buffer->RemoveReference();
if (!buffer->InUse()) {
free_buffers_[free_buffer_count_++] = buffer;
}
return kJniStatusOk;
}
private:
static const int kMaxFrames = 32;
JniFrameBuffer* all_buffers_[kMaxFrames];
int all_buffer_count_ = 0;
JniFrameBuffer* free_buffers_[kMaxFrames];
int free_buffer_count_ = 0;
std::mutex mutex_;
};
struct JniContext {
~JniContext() {
if (native_window) {
ANativeWindow_release(native_window);
}
}
bool MaybeAcquireNativeWindow(JNIEnv* env, jobject new_surface) {
if (surface == new_surface) {
return true;
}
if (native_window) {
ANativeWindow_release(native_window);
}
native_window_width = 0;
native_window_height = 0;
native_window = ANativeWindow_fromSurface(env, new_surface);
if (native_window == nullptr) {
jni_status_code = kJniStatusANativeWindowError;
surface = nullptr;
return false;
}
surface = new_surface;
return true;
}
jfieldID decoder_private_field;
jfieldID output_mode_field;
jfieldID data_field;
jmethodID init_for_private_frame_method;
jmethodID init_for_yuv_frame_method;
JniBufferManager buffer_manager;
// The libgav1 decoder instance has to be deleted before |buffer_manager| is
// destructed. This will make sure that libgav1 releases all the frame
// buffers that it might be holding references to. So this has to be declared
// after |buffer_manager| since the destruction happens in reverse order of
// declaration.
libgav1::Decoder decoder;
ANativeWindow* native_window = nullptr;
jobject surface = nullptr;
int native_window_width = 0;
int native_window_height = 0;
Libgav1StatusCode libgav1_status_code = kLibgav1StatusOk;
JniStatusCode jni_status_code = kJniStatusOk;
};
int Libgav1GetFrameBuffer(void* private_data, size_t y_plane_min_size,
size_t uv_plane_min_size,
Libgav1FrameBuffer* frame_buffer) {
JniContext* const context = reinterpret_cast<JniContext*>(private_data);
context->jni_status_code = context->buffer_manager.GetBuffer(
y_plane_min_size, uv_plane_min_size, frame_buffer);
if (context->jni_status_code != kJniStatusOk) {
LOGE("%s", GetJniErrorMessage(context->jni_status_code));
return -1;
}
return 0;
}
int Libgav1ReleaseFrameBuffer(void* private_data,
Libgav1FrameBuffer* frame_buffer) {
JniContext* const context = reinterpret_cast<JniContext*>(private_data);
const int buffer_id = *reinterpret_cast<int*>(frame_buffer->private_data);
context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id);
if (context->jni_status_code != kJniStatusOk) {
LOGE("%s", GetJniErrorMessage(context->jni_status_code));
return -1;
}
return 0;
}
constexpr int AlignTo16(int value) { return (value + 15) & (~15); }
void CopyPlane(const uint8_t* source, int source_stride, uint8_t* destination,
int destination_stride, int width, int height) {
while (height--) {
std::memcpy(destination, source, width);
source += source_stride;
destination += destination_stride;
}
}
void CopyFrameToDataBuffer(const libgav1::DecoderBuffer* decoder_buffer,
jbyte* data) {
for (int plane_index = kPlaneY; plane_index < decoder_buffer->NumPlanes();
plane_index++) {
const uint64_t length = decoder_buffer->stride[plane_index] *
decoder_buffer->displayed_height[plane_index];
memcpy(data, decoder_buffer->plane[plane_index], length);
data += length;
}
}
void Convert10BitFrameTo8BitDataBuffer(
const libgav1::DecoderBuffer* decoder_buffer, jbyte* data) {
for (int plane_index = kPlaneY; plane_index < decoder_buffer->NumPlanes();
plane_index++) {
int sample = 0;
const uint8_t* source = decoder_buffer->plane[plane_index];
for (int i = 0; i < decoder_buffer->displayed_height[plane_index]; i++) {
const uint16_t* source_16 = reinterpret_cast<const uint16_t*>(source);
for (int j = 0; j < decoder_buffer->displayed_width[plane_index]; j++) {
// Lightweight dither. Carryover the remainder of each 10->8 bit
// conversion to the next pixel.
sample += source_16[j];
data[j] = sample >> 2;
sample &= 3; // Remainder.
}
source += decoder_buffer->stride[plane_index];
data += decoder_buffer->stride[plane_index];
}
}
}
#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
void Convert10BitFrameTo8BitDataBufferNeon(
const libgav1::DecoderBuffer* decoder_buffer, jbyte* data) {
uint32x2_t lcg_value = vdup_n_u32(random());
lcg_value = vset_lane_u32(random(), lcg_value, 1);
// LCG values recommended in "Numerical Recipes".
const uint32x2_t LCG_MULT = vdup_n_u32(1664525);
const uint32x2_t LCG_INCR = vdup_n_u32(1013904223);
for (int plane_index = kPlaneY; plane_index < kMaxPlanes; plane_index++) {
const uint8_t* source = decoder_buffer->plane[plane_index];
for (int i = 0; i < decoder_buffer->displayed_height[plane_index]; i++) {
const uint16_t* source_16 = reinterpret_cast<const uint16_t*>(source);
uint8_t* destination = reinterpret_cast<uint8_t*>(data);
// Each read consumes 4 2-byte samples, but to reduce branches and
// random steps we unroll to 4 rounds, so each loop consumes 16
// samples.
const int j_max = decoder_buffer->displayed_width[plane_index] & ~15;
int j;
for (j = 0; j < j_max; j += 16) {
// Run a round of the RNG.
lcg_value = vmla_u32(LCG_INCR, lcg_value, LCG_MULT);
// Round 1.
// The lower two bits of this LCG parameterization are garbage,
// leaving streaks on the image. We access the upper bits of each
// 16-bit lane by shifting. (We use this both as an 8- and 16-bit
// vector, so the choice of which one to keep it as is arbitrary.)
uint8x8_t randvec =
vreinterpret_u8_u16(vshr_n_u16(vreinterpret_u16_u32(lcg_value), 8));
// We retrieve the values and shift them so that the bits we'll
// shift out (after biasing) are in the upper 8 bits of each 16-bit
// lane.
uint16x4_t values = vshl_n_u16(vld1_u16(source_16), 6);
// We add the bias bits in the lower 8 to the shifted values to get
// the final values in the upper 8 bits.
uint16x4_t added_1 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
source_16 += 4;
// Round 2.
// Shifting the randvec bits left by 2 bits, as an 8-bit vector,
// should leave us with enough bias to get the needed rounding
// operation.
randvec = vshl_n_u8(randvec, 2);
// Retrieve and sum the next 4 pixels.
values = vshl_n_u16(vld1_u16(source_16), 6);
uint16x4_t added_2 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
source_16 += 4;
// Reinterpret the two added vectors as 8x8, zip them together, and
// discard the lower portions.
uint8x8_t zipped =
vuzp_u8(vreinterpret_u8_u16(added_1), vreinterpret_u8_u16(added_2))
.val[1];
vst1_u8(destination, zipped);
destination += 8;
// Run it again with the next two rounds using the remaining
// entropy in randvec.
// Round 3.
randvec = vshl_n_u8(randvec, 2);
values = vshl_n_u16(vld1_u16(source_16), 6);
added_1 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
source_16 += 4;
// Round 4.
randvec = vshl_n_u8(randvec, 2);
values = vshl_n_u16(vld1_u16(source_16), 6);
added_2 = vqadd_u16(values, vreinterpret_u16_u8(randvec));
source_16 += 4;
zipped =
vuzp_u8(vreinterpret_u8_u16(added_1), vreinterpret_u8_u16(added_2))
.val[1];
vst1_u8(destination, zipped);
destination += 8;
}
uint32_t randval = 0;
// For the remaining pixels in each row - usually none, as most
// standard sizes are divisible by 32 - convert them "by hand".
for (; j < decoder_buffer->displayed_width[plane_index]; j++) {
if (!randval) randval = random();
destination[j] = (source_16[j] + (randval & 3)) >> 2;
randval >>= 2;
}
source += decoder_buffer->stride[plane_index];
data += decoder_buffer->stride[plane_index];
}
}
}
#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
} // namespace
DECODER_FUNC(jlong, gav1Init, jint threads) {
JniContext* context = new (std::nothrow) JniContext();
if (context == nullptr) {
return kStatusError;
}
#ifdef CPU_FEATURES_ARCH_ARM
// Libgav1 requires NEON with arm ABIs.
#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
const cpu_features::ArmFeatures arm_features =
cpu_features::GetArmInfo().features;
if (!arm_features.neon) {
context->jni_status_code = kJniStatusNeonNotSupported;
return reinterpret_cast<jlong>(context);
}
#else
context->jni_status_code = kJniStatusNeonNotSupported;
return reinterpret_cast<jlong>(context);
#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
#endif // CPU_FEATURES_ARCH_ARM
libgav1::DecoderSettings settings;
settings.threads = threads;
settings.get = Libgav1GetFrameBuffer;
settings.release = Libgav1ReleaseFrameBuffer;
settings.callback_private_data = context;
context->libgav1_status_code = context->decoder.Init(&settings);
if (context->libgav1_status_code != kLibgav1StatusOk) {
return reinterpret_cast<jlong>(context);
}
// Populate JNI References.
const jclass outputBufferClass = env->FindClass(
"com/google/android/exoplayer2/video/VideoDecoderOutputBuffer");
context->decoder_private_field =
env->GetFieldID(outputBufferClass, "decoderPrivate", "I");
context->output_mode_field = env->GetFieldID(outputBufferClass, "mode", "I");
context->data_field =
env->GetFieldID(outputBufferClass, "data", "Ljava/nio/ByteBuffer;");
context->init_for_private_frame_method =
env->GetMethodID(outputBufferClass, "initForPrivateFrame", "(II)V");
context->init_for_yuv_frame_method =
env->GetMethodID(outputBufferClass, "initForYuvFrame", "(IIIII)Z");
return reinterpret_cast<jlong>(context);
}
DECODER_FUNC(void, gav1Close, jlong jContext) {
JniContext* const context = reinterpret_cast<JniContext*>(jContext);
delete context;
}
DECODER_FUNC(jint, gav1Decode, jlong jContext, jobject encodedData,
jint length) {
JniContext* const context = reinterpret_cast<JniContext*>(jContext);
const uint8_t* const buffer = reinterpret_cast<const uint8_t*>(
env->GetDirectBufferAddress(encodedData));
context->libgav1_status_code =
context->decoder.EnqueueFrame(buffer, length, /*user_private_data=*/0);
if (context->libgav1_status_code != kLibgav1StatusOk) {
return kStatusError;
}
return kStatusOk;
}
DECODER_FUNC(jint, gav1GetFrame, jlong jContext, jobject jOutputBuffer,
jboolean decodeOnly) {
JniContext* const context = reinterpret_cast<JniContext*>(jContext);
const libgav1::DecoderBuffer* decoder_buffer;
context->libgav1_status_code = context->decoder.DequeueFrame(&decoder_buffer);
if (context->libgav1_status_code != kLibgav1StatusOk) {
return kStatusError;
}
if (decodeOnly || decoder_buffer == nullptr) {
// This is not an error. The input data was decode-only or no displayable
// frames are available.
return kStatusDecodeOnly;
}
const int output_mode =
env->GetIntField(jOutputBuffer, context->output_mode_field);
if (output_mode == kOutputModeYuv) {
// Resize the buffer if required. Default color conversion will be used as
// libgav1::DecoderBuffer doesn't expose color space info.
const jboolean init_result = env->CallBooleanMethod(
jOutputBuffer, context->init_for_yuv_frame_method,
decoder_buffer->displayed_width[kPlaneY],
decoder_buffer->displayed_height[kPlaneY],
decoder_buffer->stride[kPlaneY], decoder_buffer->stride[kPlaneU],
kColorSpaceUnknown);
if (env->ExceptionCheck()) {
// Exception is thrown in Java when returning from the native call.
return kStatusError;
}
if (!init_result) {
context->jni_status_code = kJniStatusBufferResizeError;
return kStatusError;
}
const jobject data_object =
env->GetObjectField(jOutputBuffer, context->data_field);
jbyte* const data =
reinterpret_cast<jbyte*>(env->GetDirectBufferAddress(data_object));
switch (decoder_buffer->bitdepth) {
case 8:
CopyFrameToDataBuffer(decoder_buffer, data);
break;
case 10:
#ifdef CPU_FEATURES_COMPILED_ANY_ARM_NEON
Convert10BitFrameTo8BitDataBufferNeon(decoder_buffer, data);
#else
Convert10BitFrameTo8BitDataBuffer(decoder_buffer, data);
#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
break;
default:
context->jni_status_code = kJniStatusBitDepth12NotSupportedWithYuv;
return kStatusError;
}
} else if (output_mode == kOutputModeSurfaceYuv) {
if (decoder_buffer->bitdepth != 8) {
context->jni_status_code =
kJniStatusHighBitDepthNotSupportedWithSurfaceYuv;
return kStatusError;
}
if (decoder_buffer->NumPlanes() > kMaxPlanes) {
context->jni_status_code = kJniStatusInvalidNumOfPlanes;
return kStatusError;
}
const int buffer_id =
*reinterpret_cast<int*>(decoder_buffer->buffer_private_data);
context->buffer_manager.AddBufferReference(buffer_id);
JniFrameBuffer* const jni_buffer =
context->buffer_manager.GetBuffer(buffer_id);
jni_buffer->SetFrameData(*decoder_buffer);
env->CallVoidMethod(jOutputBuffer, context->init_for_private_frame_method,
decoder_buffer->displayed_width[kPlaneY],
decoder_buffer->displayed_height[kPlaneY]);
if (env->ExceptionCheck()) {
// Exception is thrown in Java when returning from the native call.
return kStatusError;
}
env->SetIntField(jOutputBuffer, context->decoder_private_field, buffer_id);
}
return kStatusOk;
}
DECODER_FUNC(jint, gav1RenderFrame, jlong jContext, jobject jSurface,
jobject jOutputBuffer) {
JniContext* const context = reinterpret_cast<JniContext*>(jContext);
const int buffer_id =
env->GetIntField(jOutputBuffer, context->decoder_private_field);
JniFrameBuffer* const jni_buffer =
context->buffer_manager.GetBuffer(buffer_id);
if (!context->MaybeAcquireNativeWindow(env, jSurface)) {
return kStatusError;
}
if (context->native_window_width != jni_buffer->DisplayedWidth(kPlaneY) ||
context->native_window_height != jni_buffer->DisplayedHeight(kPlaneY)) {
if (ANativeWindow_setBuffersGeometry(
context->native_window, jni_buffer->DisplayedWidth(kPlaneY),
jni_buffer->DisplayedHeight(kPlaneY), kImageFormatYV12)) {
context->jni_status_code = kJniStatusANativeWindowError;
return kStatusError;
}
context->native_window_width = jni_buffer->DisplayedWidth(kPlaneY);
context->native_window_height = jni_buffer->DisplayedHeight(kPlaneY);
}
ANativeWindow_Buffer native_window_buffer;
if (ANativeWindow_lock(context->native_window, &native_window_buffer,
/*inOutDirtyBounds=*/nullptr) ||
native_window_buffer.bits == nullptr) {
context->jni_status_code = kJniStatusANativeWindowError;
return kStatusError;
}
// Y plane
CopyPlane(jni_buffer->Plane(kPlaneY), jni_buffer->Stride(kPlaneY),
reinterpret_cast<uint8_t*>(native_window_buffer.bits),
native_window_buffer.stride, jni_buffer->DisplayedWidth(kPlaneY),
jni_buffer->DisplayedHeight(kPlaneY));
const int y_plane_size =
native_window_buffer.stride * native_window_buffer.height;
const int32_t native_window_buffer_uv_height =
(native_window_buffer.height + 1) / 2;
const int native_window_buffer_uv_stride =
AlignTo16(native_window_buffer.stride / 2);
// TODO(b/140606738): Handle monochrome videos.
// V plane
// Since the format for ANativeWindow is YV12, V plane is being processed
// before U plane.
const int v_plane_height = std::min(native_window_buffer_uv_height,
jni_buffer->DisplayedHeight(kPlaneV));
CopyPlane(
jni_buffer->Plane(kPlaneV), jni_buffer->Stride(kPlaneV),
reinterpret_cast<uint8_t*>(native_window_buffer.bits) + y_plane_size,
native_window_buffer_uv_stride, jni_buffer->DisplayedWidth(kPlaneV),
v_plane_height);
const int v_plane_size = v_plane_height * native_window_buffer_uv_stride;
// U plane
CopyPlane(jni_buffer->Plane(kPlaneU), jni_buffer->Stride(kPlaneU),
reinterpret_cast<uint8_t*>(native_window_buffer.bits) +
y_plane_size + v_plane_size,
native_window_buffer_uv_stride, jni_buffer->DisplayedWidth(kPlaneU),
std::min(native_window_buffer_uv_height,
jni_buffer->DisplayedHeight(kPlaneU)));
if (ANativeWindow_unlockAndPost(context->native_window)) {
context->jni_status_code = kJniStatusANativeWindowError;
return kStatusError;
}
return kStatusOk;
}
DECODER_FUNC(void, gav1ReleaseFrame, jlong jContext, jobject jOutputBuffer) {
JniContext* const context = reinterpret_cast<JniContext*>(jContext);
const int buffer_id =
env->GetIntField(jOutputBuffer, context->decoder_private_field);
env->SetIntField(jOutputBuffer, context->decoder_private_field, -1);
context->jni_status_code = context->buffer_manager.ReleaseBuffer(buffer_id);
if (context->jni_status_code != kJniStatusOk) {
LOGE("%s", GetJniErrorMessage(context->jni_status_code));
}
}
DECODER_FUNC(jstring, gav1GetErrorMessage, jlong jContext) {
if (jContext == 0) {
return env->NewStringUTF("Failed to initialize JNI context.");
}
JniContext* const context = reinterpret_cast<JniContext*>(jContext);
if (context->libgav1_status_code != kLibgav1StatusOk) {
return env->NewStringUTF(
libgav1::GetErrorString(context->libgav1_status_code));
}
if (context->jni_status_code != kJniStatusOk) {
return env->NewStringUTF(GetJniErrorMessage(context->jni_status_code));
}
return env->NewStringUTF("None.");
}
DECODER_FUNC(jint, gav1CheckError, jlong jContext) {
JniContext* const context = reinterpret_cast<JniContext*>(jContext);
if (context->libgav1_status_code != kLibgav1StatusOk ||
context->jni_status_code != kJniStatusOk) {
return kStatusError;
}
return kStatusOk;
}
// TODO(b/139902005): Add functions for getting libgav1 version and build
// configuration once libgav1 ABI provides this information.

View File

@ -32,12 +32,13 @@ android {
dependencies {
api 'com.google.android.gms:play-services-cast-framework:17.0.0'
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
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-robolectric')
testImplementation project(modulePrefix + 'testutils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {

View File

@ -16,7 +16,6 @@
package com.google.android.exoplayer2.ext.cast;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.BasePlayer;
import com.google.android.exoplayer2.C;
@ -51,14 +50,14 @@ import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/**
* {@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.
* injected {@link CastContext}. 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
* alter it will be ignored. Querying the player state is possible even when no session is
@ -99,14 +98,14 @@ public final class CastPlayer extends BasePlayer {
@Nullable private SessionAvailabilityListener sessionAvailabilityListener;
// Internal state.
private final StateHolder<Boolean> playWhenReady;
private final StateHolder<Integer> repeatMode;
@Nullable private RemoteMediaClient remoteMediaClient;
private CastTimeline currentTimeline;
private TrackGroupArray currentTrackGroups;
private TrackSelectionArray currentTrackSelection;
private int playbackState;
private int repeatMode;
@Player.State private int playbackState;
private int currentWindowIndex;
private boolean playWhenReady;
private long lastReportedPositionMs;
private int pendingSeekCount;
private int pendingSeekWindowIndex;
@ -126,19 +125,20 @@ public final class CastPlayer extends BasePlayer {
notificationsBatch = new ArrayList<>();
ongoingNotificationsTasks = new ArrayDeque<>();
SessionManager sessionManager = castContext.getSessionManager();
sessionManager.addSessionManagerListener(statusListener, CastSession.class);
CastSession session = sessionManager.getCurrentCastSession();
remoteMediaClient = session != null ? session.getRemoteMediaClient() : null;
playWhenReady = new StateHolder<>(false);
repeatMode = new StateHolder<>(REPEAT_MODE_OFF);
playbackState = STATE_IDLE;
repeatMode = REPEAT_MODE_OFF;
currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE;
currentTrackGroups = TrackGroupArray.EMPTY;
currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY;
pendingSeekWindowIndex = C.INDEX_UNSET;
pendingSeekPositionMs = C.TIME_UNSET;
updateInternalState();
SessionManager sessionManager = castContext.getSessionManager();
sessionManager.addSessionManagerListener(statusListener, CastSession.class);
CastSession session = sessionManager.getCurrentCastSession();
setRemoteMediaClient(session != null ? session.getRemoteMediaClient() : null);
updateInternalStateAndNotifyIfChanged();
}
// Media Queue manipulation methods.
@ -328,6 +328,7 @@ public final class CastPlayer extends BasePlayer {
}
@Override
@Player.State
public int getPlaybackState() {
return playbackState;
}
@ -349,16 +350,29 @@ public final class CastPlayer extends BasePlayer {
if (remoteMediaClient == null) {
return;
}
if (playWhenReady) {
remoteMediaClient.play();
} else {
remoteMediaClient.pause();
}
// We update the local state and send the message to the receiver app, which will cause the
// operation to be perceived as synchronous by the user. When the operation reports a result,
// the local state will be updated to reflect the state reported by the Cast SDK.
setPlayerStateAndNotifyIfChanged(playWhenReady, playbackState);
flushNotifications();
PendingResult<MediaChannelResult> pendingResult =
playWhenReady ? remoteMediaClient.play() : remoteMediaClient.pause();
this.playWhenReady.pendingResultCallback =
new ResultCallback<MediaChannelResult>() {
@Override
public void onResult(MediaChannelResult mediaChannelResult) {
if (remoteMediaClient != null) {
updatePlayerStateAndNotifyIfChanged(this);
flushNotifications();
}
}
};
pendingResult.setResultCallback(this.playWhenReady.pendingResultCallback);
}
@Override
public boolean getPlayWhenReady() {
return playWhenReady;
return playWhenReady.value;
}
@Override
@ -434,14 +448,32 @@ public final class CastPlayer extends BasePlayer {
@Override
public void setRepeatMode(@RepeatMode int repeatMode) {
if (remoteMediaClient != null) {
remoteMediaClient.queueSetRepeatMode(getCastRepeatMode(repeatMode), null);
if (remoteMediaClient == null) {
return;
}
// We update the local state and send the message to the receiver app, which will cause the
// operation to be perceived as synchronous by the user. When the operation reports a result,
// the local state will be updated to reflect the state reported by the Cast SDK.
setRepeatModeAndNotifyIfChanged(repeatMode);
flushNotifications();
PendingResult<MediaChannelResult> pendingResult =
remoteMediaClient.queueSetRepeatMode(getCastRepeatMode(repeatMode), /* jsonObject= */ null);
this.repeatMode.pendingResultCallback =
new ResultCallback<MediaChannelResult>() {
@Override
public void onResult(MediaChannelResult mediaChannelResult) {
if (remoteMediaClient != null) {
updateRepeatModeAndNotifyIfChanged(this);
flushNotifications();
}
}
};
pendingResult.setResultCallback(this.repeatMode.pendingResultCallback);
}
@Override
@RepeatMode public int getRepeatMode() {
return repeatMode;
return repeatMode.value;
}
@Override
@ -470,11 +502,6 @@ public final class CastPlayer extends BasePlayer {
return currentTimeline;
}
@Override
@Nullable public Object getCurrentManifest() {
return null;
}
@Override
public int getCurrentPeriodIndex() {
return getCurrentWindowIndex();
@ -547,35 +574,20 @@ public final class CastPlayer extends BasePlayer {
// Internal methods.
private void updateInternalState() {
private void updateInternalStateAndNotifyIfChanged() {
if (remoteMediaClient == null) {
// There is no session. We leave the state of the player as it is now.
return;
}
boolean wasPlaying = playbackState == Player.STATE_READY && playWhenReady;
int playbackState = fetchPlaybackState(remoteMediaClient);
boolean playWhenReady = !remoteMediaClient.isPaused();
if (this.playbackState != playbackState
|| this.playWhenReady != playWhenReady) {
this.playbackState = playbackState;
this.playWhenReady = playWhenReady;
notificationsBatch.add(
new ListenerNotificationTask(
listener -> listener.onPlayerStateChanged(this.playWhenReady, this.playbackState)));
}
boolean isPlaying = playbackState == Player.STATE_READY && playWhenReady;
boolean wasPlaying = playbackState == Player.STATE_READY && playWhenReady.value;
updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null);
boolean isPlaying = playbackState == Player.STATE_READY && playWhenReady.value;
if (wasPlaying != isPlaying) {
notificationsBatch.add(
new ListenerNotificationTask(listener -> listener.onIsPlayingChanged(isPlaying)));
}
@RepeatMode int repeatMode = fetchRepeatMode(remoteMediaClient);
if (this.repeatMode != repeatMode) {
this.repeatMode = repeatMode;
notificationsBatch.add(
new ListenerNotificationTask(listener -> listener.onRepeatModeChanged(this.repeatMode)));
}
maybeUpdateTimelineAndNotify();
updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null);
updateTimelineAndNotifyIfChanged();
int currentWindowIndex = C.INDEX_UNSET;
MediaQueueItem currentItem = remoteMediaClient.getCurrentItem();
@ -593,7 +605,7 @@ public final class CastPlayer extends BasePlayer {
listener ->
listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION)));
}
if (updateTracksAndSelections()) {
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
notificationsBatch.add(
new ListenerNotificationTask(
listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelection)));
@ -601,15 +613,43 @@ public final class CastPlayer extends BasePlayer {
flushNotifications();
}
private void maybeUpdateTimelineAndNotify() {
/**
* Updates {@link #playWhenReady} and {@link #playbackState} to match the Cast {@code
* remoteMediaClient} state, and notifies listeners of any state changes.
*
* <p>This method will only update values whose {@link StateHolder#pendingResultCallback} matches
* the given {@code resultCallback}.
*/
@RequiresNonNull("remoteMediaClient")
private void updatePlayerStateAndNotifyIfChanged(@Nullable ResultCallback<?> resultCallback) {
boolean newPlayWhenReadyValue = playWhenReady.value;
if (playWhenReady.acceptsUpdate(resultCallback)) {
newPlayWhenReadyValue = !remoteMediaClient.isPaused();
playWhenReady.clearPendingResultCallback();
}
// We do not mask the playback state, so try setting it regardless of the playWhenReady masking.
setPlayerStateAndNotifyIfChanged(newPlayWhenReadyValue, fetchPlaybackState(remoteMediaClient));
}
@RequiresNonNull("remoteMediaClient")
private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback<?> resultCallback) {
if (repeatMode.acceptsUpdate(resultCallback)) {
setRepeatModeAndNotifyIfChanged(fetchRepeatMode(remoteMediaClient));
repeatMode.clearPendingResultCallback();
}
}
private void updateTimelineAndNotifyIfChanged() {
if (updateTimeline()) {
@Player.TimelineChangeReason int reason = waitingForInitialTimeline
? Player.TIMELINE_CHANGE_REASON_PREPARED : Player.TIMELINE_CHANGE_REASON_DYNAMIC;
@Player.TimelineChangeReason
int reason =
waitingForInitialTimeline
? Player.TIMELINE_CHANGE_REASON_PREPARED
: Player.TIMELINE_CHANGE_REASON_DYNAMIC;
waitingForInitialTimeline = false;
notificationsBatch.add(
new ListenerNotificationTask(
listener ->
listener.onTimelineChanged(currentTimeline, /* manifest= */ null, reason)));
listener -> listener.onTimelineChanged(currentTimeline, reason)));
}
}
@ -626,10 +666,8 @@ public final class CastPlayer extends BasePlayer {
return !oldTimeline.equals(currentTimeline);
}
/**
* Updates the internal tracks and selection and returns whether they have changed.
*/
private boolean updateTracksAndSelections() {
/** Updates the internal tracks and selection and returns whether they have changed. */
private boolean updateTracksAndSelectionsAndNotifyIfChanged() {
if (remoteMediaClient == null) {
// There is no session. We leave the state of the player as it is now.
return false;
@ -675,6 +713,25 @@ public final class CastPlayer extends BasePlayer {
return false;
}
private void setRepeatModeAndNotifyIfChanged(@Player.RepeatMode int repeatMode) {
if (this.repeatMode.value != repeatMode) {
this.repeatMode.value = repeatMode;
notificationsBatch.add(
new ListenerNotificationTask(listener -> listener.onRepeatModeChanged(repeatMode)));
}
}
private void setPlayerStateAndNotifyIfChanged(
boolean playWhenReady, @Player.State int playbackState) {
if (this.playWhenReady.value != playWhenReady || this.playbackState != playbackState) {
this.playWhenReady.value = playWhenReady;
this.playbackState = playbackState;
notificationsBatch.add(
new ListenerNotificationTask(
listener -> listener.onPlayerStateChanged(playWhenReady, playbackState)));
}
}
private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) {
if (this.remoteMediaClient == remoteMediaClient) {
// Do nothing.
@ -691,7 +748,7 @@ public final class CastPlayer extends BasePlayer {
}
remoteMediaClient.addListener(statusListener);
remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS);
updateInternalState();
updateInternalStateAndNotifyIfChanged();
} else {
if (sessionAvailabilityListener != null) {
sessionAvailabilityListener.onCastSessionUnavailable();
@ -778,8 +835,26 @@ public final class CastPlayer extends BasePlayer {
}
}
private final class StatusListener implements RemoteMediaClient.Listener,
SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
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 StatusListener
implements RemoteMediaClient.Listener,
SessionManagerListener<CastSession>,
RemoteMediaClient.ProgressListener {
// RemoteMediaClient.ProgressListener implementation.
@ -792,7 +867,7 @@ public final class CastPlayer extends BasePlayer {
@Override
public void onStatusUpdated() {
updateInternalState();
updateInternalStateAndNotifyIfChanged();
}
@Override
@ -800,7 +875,7 @@ public final class CastPlayer extends BasePlayer {
@Override
public void onQueueStatusUpdated() {
maybeUpdateTimelineAndNotify();
updateTimelineAndNotifyIfChanged();
}
@Override
@ -863,28 +938,10 @@ public final class CastPlayer extends BasePlayer {
}
// 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> {
@Override
public void onResult(@NonNull MediaChannelResult result) {
public void onResult(MediaChannelResult result) {
int statusCode = result.getStatus().getStatusCode();
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
Log.e(TAG, "Seek failed. Error code " + statusCode + ": "
@ -899,6 +956,42 @@ public final class CastPlayer extends BasePlayer {
}
}
/** Holds the value and the masking status of a specific part of the {@link CastPlayer} state. */
private static final class StateHolder<T> {
/** The user-facing value of a specific part of the {@link CastPlayer} state. */
public T value;
/**
* If {@link #value} is being masked, holds the result callback for the operation that triggered
* the masking. Or null if {@link #value} is not being masked.
*/
@Nullable public ResultCallback<MediaChannelResult> pendingResultCallback;
public StateHolder(T initialValue) {
value = initialValue;
}
public void clearPendingResultCallback() {
pendingResultCallback = null;
}
/**
* Returns whether this state holder accepts updates coming from the given result callback.
*
* <p>A null {@code resultCallback} means that the update is a regular receiver state update, in
* which case the update will only be accepted if {@link #value} is not being masked. If {@link
* #value} is being masked, the update will only be accepted if {@code resultCallback} is the
* same as the {@link #pendingResultCallback}.
*
* @param resultCallback A result callback. May be null if the update comes from a regular
* receiver status update.
*/
public boolean acceptsUpdate(@Nullable ResultCallback<?> resultCallback) {
return pendingResultCallback == resultCallback;
}
}
private final class ListenerNotificationTask {
private final Iterator<ListenerHolder> listenersSnapshot;
@ -915,5 +1008,4 @@ public final class CastPlayer extends BasePlayer {
}
}
}
}

View File

@ -15,9 +15,9 @@
*/
package com.google.android.exoplayer2.ext.cast;
import androidx.annotation.Nullable;
import android.util.SparseArray;
import android.util.SparseIntArray;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Timeline;
import java.util.Arrays;
@ -39,9 +39,14 @@ import java.util.Arrays;
* The default start position of the item in microseconds, or {@link C#TIME_UNSET} if unknown.
*/
public final long defaultPositionUs;
/** Whether the item is live content, or {@code false} if unknown. */
public final boolean isLive;
private ItemData() {
this(/* durationUs= */ C.TIME_UNSET, /* defaultPositionUs */ C.TIME_UNSET);
this(
/* durationUs= */ C.TIME_UNSET, /* defaultPositionUs */
C.TIME_UNSET,
/* isLive= */ false);
}
/**
@ -49,26 +54,29 @@ import java.util.Arrays;
*
* @param durationUs See {@link #durationsUs}.
* @param defaultPositionUs See {@link #defaultPositionUs}.
* @param isLive See {@link #isLive}.
*/
public ItemData(long durationUs, long defaultPositionUs) {
public ItemData(long durationUs, long defaultPositionUs, boolean isLive) {
this.durationUs = durationUs;
this.defaultPositionUs = defaultPositionUs;
this.isLive = isLive;
}
/** Returns an instance with the given {@link #durationsUs}. */
public ItemData copyWithDurationUs(long durationUs) {
if (durationUs == this.durationUs) {
/**
* Returns a copy of this instance with the given values.
*
* @param durationUs The duration in microseconds, or {@link C#TIME_UNSET} if unknown.
* @param defaultPositionUs The default start position in microseconds, or {@link C#TIME_UNSET}
* if unknown.
* @param isLive Whether the item is live, or {@code false} if unknown.
*/
public ItemData copyWithNewValues(long durationUs, long defaultPositionUs, boolean isLive) {
if (durationUs == this.durationUs
&& defaultPositionUs == this.defaultPositionUs
&& isLive == this.isLive) {
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);
return new ItemData(durationUs, defaultPositionUs, isLive);
}
}
@ -80,6 +88,7 @@ import java.util.Arrays;
private final int[] ids;
private final long[] durationsUs;
private final long[] defaultPositionsUs;
private final boolean[] isLive;
/**
* Creates a Cast timeline from the given data.
@ -93,12 +102,14 @@ import java.util.Arrays;
ids = Arrays.copyOf(itemIds, itemCount);
durationsUs = new long[itemCount];
defaultPositionsUs = new long[itemCount];
isLive = new boolean[itemCount];
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;
defaultPositionsUs[i] = data.defaultPositionUs == C.TIME_UNSET ? 0 : data.defaultPositionUs;
isLive[i] = data.isLive;
}
}
@ -110,17 +121,18 @@ import java.util.Arrays;
}
@Override
public Window getWindow(
int windowIndex, Window window, boolean setTag, long defaultPositionProjectionUs) {
public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
long durationUs = durationsUs[windowIndex];
boolean isDynamic = durationUs == C.TIME_UNSET;
Object tag = setTag ? ids[windowIndex] : null;
return window.set(
tag,
/* uid= */ ids[windowIndex],
/* tag= */ ids[windowIndex],
/* manifest= */ null,
/* presentationStartTimeMs= */ C.TIME_UNSET,
/* windowStartTimeMs= */ C.TIME_UNSET,
/* isSeekable= */ !isDynamic,
isDynamic,
isLive[windowIndex],
defaultPositionsUs[windowIndex],
durationUs,
/* firstPeriodIndex= */ windowIndex,
@ -161,7 +173,8 @@ import java.util.Arrays;
CastTimeline that = (CastTimeline) other;
return Arrays.equals(ids, that.ids)
&& Arrays.equals(durationsUs, that.durationsUs)
&& Arrays.equals(defaultPositionsUs, that.defaultPositionsUs);
&& Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
&& Arrays.equals(isLive, that.isLive);
}
@Override
@ -169,6 +182,7 @@ import java.util.Arrays;
int result = Arrays.hashCode(ids);
result = 31 * result + Arrays.hashCode(durationsUs);
result = 31 * result + Arrays.hashCode(defaultPositionsUs);
result = 31 * result + Arrays.hashCode(isLive);
return result;
}

View File

@ -16,7 +16,9 @@
package com.google.android.exoplayer2.ext.cast;
import android.util.SparseArray;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
@ -61,25 +63,33 @@ import java.util.HashSet;
}
int currentItemId = mediaStatus.getCurrentItemId();
long durationUs = CastUtils.getStreamDurationUs(mediaStatus.getMediaInfo());
itemIdToData.put(
currentItemId,
itemIdToData
.get(currentItemId, CastTimeline.ItemData.EMPTY)
.copyWithDurationUs(durationUs));
updateItemData(
currentItemId, mediaStatus.getMediaInfo(), /* defaultPositionUs= */ C.TIME_UNSET);
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)));
long defaultPositionUs = (long) (item.getStartTime() * C.MICROS_PER_SECOND);
updateItemData(item.getItemId(), item.getMedia(), defaultPositionUs);
}
return new CastTimeline(itemIds, itemIdToData);
}
private void updateItemData(int itemId, @Nullable MediaInfo mediaInfo, long defaultPositionUs) {
CastTimeline.ItemData previousData = itemIdToData.get(itemId, CastTimeline.ItemData.EMPTY);
long durationUs = CastUtils.getStreamDurationUs(mediaInfo);
if (durationUs == C.TIME_UNSET) {
durationUs = previousData.durationUs;
}
boolean isLive =
mediaInfo == null
? previousData.isLive
: mediaInfo.getStreamType() == MediaInfo.STREAM_TYPE_LIVE;
if (defaultPositionUs == C.TIME_UNSET) {
defaultPositionUs = previousData.defaultPositionUs;
}
itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive));
}
private void removeUnusedItemDataEntries(int[] itemIds) {
HashSet<Integer> scratchItemIds = new HashSet<>(/* initialCapacity= */ itemIds.length * 2);
for (int id : itemIds) {

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;
@ -33,7 +34,7 @@ import com.google.android.gms.cast.MediaTrack;
* @param mediaInfo The media info to get the duration from.
* @return The duration in microseconds, or {@link C#TIME_UNSET} if unknown or not applicable.
*/
public static long getStreamDurationUs(MediaInfo mediaInfo) {
public static long getStreamDurationUs(@Nullable MediaInfo mediaInfo) {
if (mediaInfo == null) {
return C.TIME_UNSET;
}

View File

@ -28,11 +28,33 @@ 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

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

@ -17,42 +17,31 @@ 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.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.checkerframework.checker.initialization.qual.UnknownInitialization;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
/** Representation of an item that can be played by a media player. */
/** Representation of a media item. */
public final class MediaItem {
/** A builder for {@link MediaItem} instances. */
public static final class Builder {
@Nullable private UUID uuid;
private String title;
private String description;
private MediaItem.UriBundle media;
@Nullable private Object attachment;
private List<MediaItem.DrmScheme> drmSchemes;
private long startPositionUs;
private long endPositionUs;
private String mimeType;
@Nullable private Uri uri;
@Nullable private String title;
@Nullable private String mimeType;
@Nullable private DrmConfiguration drmConfiguration;
/** Creates an builder with default field values. */
public Builder() {
clearInternal();
/** See {@link MediaItem#uri}. */
public Builder setUri(String uri) {
return setUri(Uri.parse(uri));
}
/** See {@link MediaItem#uuid}. */
public Builder setUuid(UUID uuid) {
this.uuid = uuid;
/** See {@link MediaItem#uri}. */
public Builder setUri(Uri uri) {
this.uri = uri;
return this;
}
@ -62,307 +51,125 @@ public final class MediaItem {
return this;
}
/** See {@link MediaItem#description}. */
public Builder setDescription(String description) {
this.description = description;
return this;
}
/** Equivalent to {@link #setMedia(UriBundle) setMedia(new UriBundle(Uri.parse(uri)))}. */
public Builder setMedia(String uri) {
return setMedia(new UriBundle(Uri.parse(uri)));
}
/** See {@link MediaItem#media}. */
public Builder setMedia(UriBundle media) {
this.media = media;
return this;
}
/** See {@link MediaItem#attachment}. */
public Builder setAttachment(Object attachment) {
this.attachment = attachment;
return this;
}
/** See {@link MediaItem#drmSchemes}. */
public Builder setDrmSchemes(List<MediaItem.DrmScheme> drmSchemes) {
this.drmSchemes = Collections.unmodifiableList(new ArrayList<>(drmSchemes));
return this;
}
/** See {@link MediaItem#startPositionUs}. */
public Builder setStartPositionUs(long startPositionUs) {
this.startPositionUs = startPositionUs;
return this;
}
/** See {@link MediaItem#endPositionUs}. */
public Builder setEndPositionUs(long endPositionUs) {
Assertions.checkArgument(endPositionUs != C.TIME_END_OF_SOURCE);
this.endPositionUs = endPositionUs;
return this;
}
/** See {@link MediaItem#mimeType}. */
public Builder setMimeType(String mimeType) {
this.mimeType = mimeType;
return this;
}
/**
* Equivalent to {@link #build()}, except it also calls {@link #clear()} after creating the
* {@link MediaItem}.
*/
public MediaItem buildAndClear() {
MediaItem item = build();
clearInternal();
return item;
}
/** Returns the builder to default values. */
public Builder clear() {
clearInternal();
/** 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. This method also
* clears any values passed to {@link #setUuid(UUID)}.
*/
/** Returns a new {@link MediaItem} instance with the current builder values. */
public MediaItem build() {
UUID uuid = this.uuid;
this.uuid = null;
return new MediaItem(
uuid != null ? uuid : UUID.randomUUID(),
title,
description,
media,
attachment,
drmSchemes,
startPositionUs,
endPositionUs,
mimeType);
}
@EnsuresNonNull({"title", "description", "media", "drmSchemes", "mimeType"})
private void clearInternal(@UnknownInitialization Builder this) {
uuid = null;
title = "";
description = "";
media = UriBundle.EMPTY;
attachment = null;
drmSchemes = Collections.emptyList();
startPositionUs = C.TIME_UNSET;
endPositionUs = C.TIME_UNSET;
mimeType = "";
Assertions.checkNotNull(uri);
return new MediaItem(uri, title, mimeType, drmConfiguration);
}
}
/** Bundles a resource's URI with headers to attach to any request to that URI. */
public static final class UriBundle {
/** An empty {@link UriBundle}. */
public static final UriBundle EMPTY = new UriBundle(Uri.EMPTY);
/** A URI. */
public final Uri uri;
/** The headers to attach to any request for the given URI. */
public final Map<String, String> requestHeaders;
/**
* Creates an instance with no request headers.
*
* @param uri See {@link #uri}.
*/
public UriBundle(Uri uri) {
this(uri, Collections.emptyMap());
}
/**
* Creates an instance with the given URI and request headers.
*
* @param uri See {@link #uri}.
* @param requestHeaders See {@link #requestHeaders}.
*/
public UriBundle(Uri uri, Map<String, String> requestHeaders) {
this.uri = uri;
this.requestHeaders = Collections.unmodifiableMap(new HashMap<>(requestHeaders));
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (other == null || getClass() != other.getClass()) {
return false;
}
UriBundle uriBundle = (UriBundle) other;
return uri.equals(uriBundle.uri) && requestHeaders.equals(uriBundle.requestHeaders);
}
@Override
public int hashCode() {
int result = uri.hashCode();
result = 31 * result + requestHeaders.hashCode();
return result;
}
}
/**
* Represents a DRM protection scheme, and optionally provides information about how to acquire
* the license for the media.
*/
public static final class DrmScheme {
/** DRM configuration for a media item. */
public static final class DrmConfiguration {
/** The UUID of the protection scheme. */
public final UUID uuid;
/**
* Optional {@link UriBundle} for the license server. If no license server is provided, the
* server must be provided by the media.
* Optional license server {@link Uri}. If {@code null} then the license server must be
* specified by the media.
*/
@Nullable public final UriBundle licenseServer;
@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 licenseServer See {@link #licenseServer}.
* @param licenseUri See {@link #licenseUri}.
* @param requestHeaders See {@link #requestHeaders}.
*/
public DrmScheme(UUID uuid, @Nullable UriBundle licenseServer) {
public DrmConfiguration(
UUID uuid, @Nullable Uri licenseUri, @Nullable Map<String, String> requestHeaders) {
this.uuid = uuid;
this.licenseServer = licenseServer;
this.licenseUri = licenseUri;
this.requestHeaders =
requestHeaders == null
? Collections.emptyMap()
: Collections.unmodifiableMap(requestHeaders);
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (other == null || getClass() != other.getClass()) {
if (obj == null || getClass() != obj.getClass()) {
return false;
}
DrmScheme drmScheme = (DrmScheme) other;
return uuid.equals(drmScheme.uuid) && Util.areEqual(licenseServer, drmScheme.licenseServer);
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 + (licenseServer != null ? licenseServer.hashCode() : 0);
result = 31 * result + (licenseUri != null ? licenseUri.hashCode() : 0);
result = 31 * result + requestHeaders.hashCode();
return result;
}
}
/**
* A UUID that identifies this item, potentially across different devices. The default value is
* obtained by calling {@link UUID#randomUUID()}.
*/
public final UUID uuid;
/** The media {@link Uri}. */
public final Uri uri;
/** The title of the item. The default value is an empty string. */
public final String title;
/** The title of the item, or {@code null} if unspecified. */
@Nullable public final String title;
/** A description for the item. The default value is an empty string. */
public final String description;
/** The mime type for the media, or {@code null} if unspecified. */
@Nullable public final String mimeType;
/**
* A {@link UriBundle} to fetch the media content. The default value is {@link UriBundle#EMPTY}.
*/
public final UriBundle media;
/** Optional {@link DrmConfiguration} for the media. */
@Nullable public final DrmConfiguration drmConfiguration;
/**
* An optional opaque object to attach to the media item. Handling of this attachment is
* implementation specific. The default value is null.
*/
@Nullable public final Object attachment;
/**
* Immutable list of {@link DrmScheme} instances sorted in decreasing order of preference. The
* default value is an empty list.
*/
public final List<DrmScheme> drmSchemes;
/**
* The position in microseconds at which playback of this media item should start. {@link
* C#TIME_UNSET} if playback should start at the default position. The default value is {@link
* C#TIME_UNSET}.
*/
public final long startPositionUs;
/**
* The position in microseconds at which playback of this media item should end. {@link
* C#TIME_UNSET} if playback should end at the end of the media. The default value is {@link
* C#TIME_UNSET}.
*/
public final long endPositionUs;
/**
* The mime type of this media item. The default value is an empty string.
*
* <p>The usage of this mime type is optional and player implementation specific.
*/
public final String mimeType;
// TODO: Add support for sideloaded tracks, artwork, icon, and subtitle.
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 other) {
if (this == other) {
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (other == null || getClass() != other.getClass()) {
if (obj == null || getClass() != obj.getClass()) {
return false;
}
MediaItem mediaItem = (MediaItem) other;
return startPositionUs == mediaItem.startPositionUs
&& endPositionUs == mediaItem.endPositionUs
&& uuid.equals(mediaItem.uuid)
&& title.equals(mediaItem.title)
&& description.equals(mediaItem.description)
&& media.equals(mediaItem.media)
&& Util.areEqual(attachment, mediaItem.attachment)
&& drmSchemes.equals(mediaItem.drmSchemes)
&& mimeType.equals(mediaItem.mimeType);
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 = uuid.hashCode();
result = 31 * result + title.hashCode();
result = 31 * result + description.hashCode();
result = 31 * result + media.hashCode();
result = 31 * result + (attachment != null ? attachment.hashCode() : 0);
result = 31 * result + drmSchemes.hashCode();
result = 31 * result + (int) (startPositionUs ^ (startPositionUs >>> 32));
result = 31 * result + (int) (endPositionUs ^ (endPositionUs >>> 32));
result = 31 * result + mimeType.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;
}
private MediaItem(
UUID uuid,
String title,
String description,
UriBundle media,
@Nullable Object attachment,
List<DrmScheme> drmSchemes,
long startPositionUs,
long endPositionUs,
String mimeType) {
this.uuid = uuid;
this.title = title;
this.description = description;
this.media = media;
this.attachment = attachment;
this.drmSchemes = drmSchemes;
this.startPositionUs = startPositionUs;
this.endPositionUs = endPositionUs;
this.mimeType = mimeType;
}
}

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

@ -1,85 +0,0 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.ext.cast;
/** Represents a sequence of {@link MediaItem MediaItems}. */
public interface MediaItemQueue {
/**
* Returns the item at the given index.
*
* @param index The index of the item to retrieve.
* @return The item at the given index.
* @throws IndexOutOfBoundsException If {@code index < 0 || index >= getSize()}.
*/
MediaItem get(int index);
/** Returns the number of items in this queue. */
int getSize();
/**
* Appends the given sequence of items to the queue.
*
* @param items The sequence of items to append.
*/
void add(MediaItem... items);
/**
* Adds the given sequence of items to the queue at the given position, so that the first of
* {@code items} is placed at the given index.
*
* @param index The index at which {@code items} will be inserted.
* @param items The sequence of items to append.
* @throws IndexOutOfBoundsException If {@code index < 0 || index > getSize()}.
*/
void add(int index, MediaItem... items);
/**
* Moves an existing item within the playlist.
*
* <p>Calling this method is equivalent to removing the item at position {@code indexFrom} and
* immediately inserting it at position {@code indexTo}. If the moved item is being played at the
* moment of the invocation, playback will stick with the moved item.
*
* @param indexFrom The index of the item to move.
* @param indexTo The index at which the item will be placed after this operation.
* @throws IndexOutOfBoundsException If for either index, {@code index < 0 || index >= getSize()}.
*/
void move(int indexFrom, int indexTo);
/**
* Removes an item from the queue.
*
* @param index The index of the item to remove from the queue.
* @throws IndexOutOfBoundsException If {@code index < 0 || index >= getSize()}.
*/
void remove(int index);
/**
* Removes a range of items from the queue.
*
* <p>Does nothing if an empty range ({@code from == exclusiveTo}) is passed.
*
* @param from The inclusive index at which the range to remove starts.
* @param exclusiveTo The exclusive index at which the range to remove ends.
* @throws IndexOutOfBoundsException If {@code from < 0 || exclusiveTo > getSize() || from >
* exclusiveTo}.
*/
void removeRange(int from, int exclusiveTo);
/** Removes all items in the queue. */
void clear();
}

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

@ -0,0 +1,185 @@
/*
* 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 static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.Player;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.cast.framework.CastSession;
import com.google.android.gms.cast.framework.SessionManager;
import com.google.android.gms.cast.framework.media.MediaQueue;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import com.google.android.gms.common.api.PendingResult;
import com.google.android.gms.common.api.ResultCallback;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.Mockito;
/** Tests for {@link CastPlayer}. */
@RunWith(AndroidJUnit4.class)
public class CastPlayerTest {
private CastPlayer castPlayer;
private RemoteMediaClient.Listener remoteMediaClientListener;
@Mock private RemoteMediaClient mockRemoteMediaClient;
@Mock private MediaStatus mockMediaStatus;
@Mock private MediaQueue mockMediaQueue;
@Mock private CastContext mockCastContext;
@Mock private SessionManager mockSessionManager;
@Mock private CastSession mockCastSession;
@Mock private Player.EventListener mockListener;
@Mock private PendingResult<RemoteMediaClient.MediaChannelResult> mockPendingResult;
@Captor
private ArgumentCaptor<ResultCallback<RemoteMediaClient.MediaChannelResult>>
setResultCallbackArgumentCaptor;
@Captor private ArgumentCaptor<RemoteMediaClient.Listener> listenerArgumentCaptor;
@Before
public void setUp() {
initMocks(this);
when(mockCastContext.getSessionManager()).thenReturn(mockSessionManager);
when(mockSessionManager.getCurrentCastSession()).thenReturn(mockCastSession);
when(mockCastSession.getRemoteMediaClient()).thenReturn(mockRemoteMediaClient);
when(mockRemoteMediaClient.getMediaStatus()).thenReturn(mockMediaStatus);
when(mockRemoteMediaClient.getMediaQueue()).thenReturn(mockMediaQueue);
when(mockMediaQueue.getItemIds()).thenReturn(new int[0]);
// Make the remote media client present the same default values as ExoPlayer:
when(mockRemoteMediaClient.isPaused()).thenReturn(true);
when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_OFF);
castPlayer = new CastPlayer(mockCastContext);
castPlayer.addListener(mockListener);
verify(mockRemoteMediaClient).addListener(listenerArgumentCaptor.capture());
remoteMediaClientListener = listenerArgumentCaptor.getValue();
}
@Test
public void testSetPlayWhenReady_masksRemoteState() {
when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult);
assertThat(castPlayer.getPlayWhenReady()).isFalse();
castPlayer.setPlayWhenReady(true);
verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
assertThat(castPlayer.getPlayWhenReady()).isTrue();
verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
// There is a status update in the middle, which should be hidden by masking.
remoteMediaClientListener.onStatusUpdated();
verifyNoMoreInteractions(mockListener);
// Upon result, the remoteMediaClient has updated its state according to the play() call.
when(mockRemoteMediaClient.isPaused()).thenReturn(false);
setResultCallbackArgumentCaptor
.getValue()
.onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
verifyNoMoreInteractions(mockListener);
}
@Test
public void testSetPlayWhenReadyMasking_updatesUponResultChange() {
when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult);
assertThat(castPlayer.getPlayWhenReady()).isFalse();
castPlayer.setPlayWhenReady(true);
verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
assertThat(castPlayer.getPlayWhenReady()).isTrue();
verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
// Upon result, the remote media client is still paused. The state should reflect that.
setResultCallbackArgumentCaptor
.getValue()
.onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
verify(mockListener).onPlayerStateChanged(false, Player.STATE_IDLE);
assertThat(castPlayer.getPlayWhenReady()).isFalse();
}
@Test
public void testPlayWhenReady_changesOnStatusUpdates() {
assertThat(castPlayer.getPlayWhenReady()).isFalse();
when(mockRemoteMediaClient.isPaused()).thenReturn(false);
remoteMediaClientListener.onStatusUpdated();
verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
assertThat(castPlayer.getPlayWhenReady()).isTrue();
}
@Test
public void testSetRepeatMode_masksRemoteState() {
when(mockRemoteMediaClient.queueSetRepeatMode(anyInt(), any())).thenReturn(mockPendingResult);
assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_OFF);
castPlayer.setRepeatMode(Player.REPEAT_MODE_ONE);
verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE);
verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE);
// There is a status update in the middle, which should be hidden by masking.
when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_ALL);
remoteMediaClientListener.onStatusUpdated();
verifyNoMoreInteractions(mockListener);
// Upon result, the mediaStatus now exposes the new repeat mode.
when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_SINGLE);
setResultCallbackArgumentCaptor
.getValue()
.onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
verifyNoMoreInteractions(mockListener);
}
@Test
public void testSetRepeatMode_updatesUponResultChange() {
when(mockRemoteMediaClient.queueSetRepeatMode(anyInt(), any())).thenReturn(mockPendingResult);
castPlayer.setRepeatMode(Player.REPEAT_MODE_ONE);
verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE);
verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE);
// There is a status update in the middle, which should be hidden by masking.
when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_ALL);
remoteMediaClientListener.onStatusUpdated();
verifyNoMoreInteractions(mockListener);
// Upon result, the repeat mode is ALL. The state should reflect that.
setResultCallbackArgumentCaptor
.getValue()
.onResult(Mockito.mock(RemoteMediaClient.MediaChannelResult.class));
verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ALL);
assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL);
}
@Test
public void testRepeatMode_changesOnStatusUpdates() {
assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_OFF);
when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_SINGLE);
remoteMediaClientListener.onStatusUpdated();
verify(mockListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE);
assertThat(castPlayer.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE);
}
}

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

@ -21,10 +21,7 @@ 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.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import org.junit.Test;
import org.junit.runner.RunWith;
@ -32,113 +29,58 @@ import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class MediaItemTest {
@Test
public void buildMediaItem_resetsUuid() {
MediaItem.Builder builder = new MediaItem.Builder();
UUID uuid = new UUID(1, 1);
MediaItem item1 = builder.setUuid(uuid).build();
MediaItem item2 = builder.build();
MediaItem item3 = builder.build();
assertThat(item1.uuid).isEqualTo(uuid);
assertThat(item2.uuid).isNotEqualTo(uuid);
assertThat(item3.uuid).isNotEqualTo(item2.uuid);
assertThat(item3.uuid).isNotEqualTo(uuid);
}
@Test
public void buildMediaItem_doesNotChangeState() {
MediaItem.Builder builder = new MediaItem.Builder();
MediaItem item1 =
builder
.setUuid(new UUID(0, 1))
.setMedia("http://example.com")
.setUri(Uri.parse("http://example.com"))
.setTitle("title")
.setMimeType(MimeTypes.AUDIO_MP4)
.setStartPositionUs(3)
.setEndPositionUs(4)
.build();
MediaItem item2 = builder.setUuid(new UUID(0, 1)).build();
MediaItem item2 = builder.build();
assertThat(item1).isEqualTo(item2);
}
@Test
public void buildMediaItem_assertDefaultValues() {
assertDefaultValues(new MediaItem.Builder().build());
}
@Test
public void buildAndClear_assertDefaultValues() {
MediaItem.Builder builder = new MediaItem.Builder();
builder
.setMedia("http://example.com")
.setTitle("title")
.setMimeType(MimeTypes.AUDIO_MP4)
.setStartPositionUs(3)
.setEndPositionUs(4)
.buildAndClear();
assertDefaultValues(builder.build());
}
@Test
public void equals_withEqualDrmSchemes_returnsTrue() {
MediaItem.Builder builder = new MediaItem.Builder();
MediaItem.Builder builder1 = new MediaItem.Builder();
MediaItem mediaItem1 =
builder
.setUuid(new UUID(0, 1))
.setMedia("www.google.com")
.setDrmSchemes(createDummyDrmSchemes(1))
.buildAndClear();
builder1
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(1))
.build();
MediaItem.Builder builder2 = new MediaItem.Builder();
MediaItem mediaItem2 =
builder
.setUuid(new UUID(0, 1))
.setMedia("www.google.com")
.setDrmSchemes(createDummyDrmSchemes(1))
.buildAndClear();
builder2
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(1))
.build();
assertThat(mediaItem1).isEqualTo(mediaItem2);
}
@Test
public void equals_withDifferentDrmRequestHeaders_returnsFalse() {
MediaItem.Builder builder = new MediaItem.Builder();
MediaItem.Builder builder1 = new MediaItem.Builder();
MediaItem mediaItem1 =
builder
.setUuid(new UUID(0, 1))
.setMedia("www.google.com")
.setDrmSchemes(createDummyDrmSchemes(1))
.buildAndClear();
builder1
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(1))
.build();
MediaItem.Builder builder2 = new MediaItem.Builder();
MediaItem mediaItem2 =
builder
.setUuid(new UUID(0, 1))
.setMedia("www.google.com")
.setDrmSchemes(createDummyDrmSchemes(2))
.buildAndClear();
builder2
.setUri(Uri.parse("www.google.com"))
.setDrmConfiguration(buildDrmConfiguration(2))
.build();
assertThat(mediaItem1).isNotEqualTo(mediaItem2);
}
private static void assertDefaultValues(MediaItem item) {
assertThat(item.title).isEmpty();
assertThat(item.description).isEmpty();
assertThat(item.media.uri).isEqualTo(Uri.EMPTY);
assertThat(item.attachment).isNull();
assertThat(item.drmSchemes).isEmpty();
assertThat(item.startPositionUs).isEqualTo(C.TIME_UNSET);
assertThat(item.endPositionUs).isEqualTo(C.TIME_UNSET);
assertThat(item.mimeType).isEmpty();
}
private static List<MediaItem.DrmScheme> createDummyDrmSchemes(int seed) {
HashMap<String, String> requestHeaders1 = new HashMap<>();
requestHeaders1.put("key1", "value1");
requestHeaders1.put("key2", "value1");
MediaItem.UriBundle uriBundle1 =
new MediaItem.UriBundle(Uri.parse("www.uri1.com"), requestHeaders1);
MediaItem.DrmScheme drmScheme1 = new MediaItem.DrmScheme(C.WIDEVINE_UUID, uriBundle1);
HashMap<String, String> requestHeaders2 = new HashMap<>();
requestHeaders2.put("key3", "value3");
requestHeaders2.put("key4", "valueWithSeed" + seed);
MediaItem.UriBundle uriBundle2 =
new MediaItem.UriBundle(Uri.parse("www.uri2.com"), requestHeaders2);
MediaItem.DrmScheme drmScheme2 = new MediaItem.DrmScheme(C.PLAYREADY_UUID, uriBundle2);
return Arrays.asList(drmScheme1, drmScheme2);
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

@ -31,11 +31,13 @@ android {
}
dependencies {
api 'org.chromium.net:cronet-embedded:75.3770.101'
api 'org.chromium.net:cronet-embedded:76.3809.111'
implementation project(modulePrefix + 'library-core')
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
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,12 +15,13 @@
*/
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 androidx.annotation.Nullable;
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;
@ -35,12 +36,14 @@ import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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;
@ -51,7 +54,9 @@ import org.chromium.net.UrlResponseInfo;
/**
* DataSource without intermediate buffer based on Cronet API set using UrlRequest.
*
* <p>This class's methods are organized in the sequence of expected calls.
* <p>Note: HTTP request headers will be set using all parameters passed via (in order of decreasing
* priority) the {@code dataSpec}, {@link #setRequestProperty} and the default parameters used to
* construct the instance.
*/
public class CronetDataSource extends BaseDataSource implements HttpDataSource {
@ -113,16 +118,17 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
private final CronetEngine cronetEngine;
private final Executor executor;
@Nullable 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;
@ -130,18 +136,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;
@ -155,7 +161,78 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* handling is a fast operation when using a direct executor.
*/
public CronetDataSource(CronetEngine cronetEngine, Executor executor) {
this(cronetEngine, executor, /* contentTypePredicate= */ null);
this(
cronetEngine,
executor,
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 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.
*/
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
@Nullable RequestProperties defaultRequestProperties) {
this(
cronetEngine,
executor,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
Clock.DEFAULT,
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 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.
*/
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
@Nullable RequestProperties defaultRequestProperties,
boolean handleSetCookieRequests) {
this(
cronetEngine,
executor,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
Clock.DEFAULT,
defaultRequestProperties,
handleSetCookieRequests);
}
/**
@ -168,7 +245,10 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* @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,
@ -179,9 +259,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
contentTypePredicate,
DEFAULT_CONNECT_TIMEOUT_MILLIS,
DEFAULT_READ_TIMEOUT_MILLIS,
false,
null,
false);
/* resetTimeoutOnRedirects= */ false,
/* defaultRequestProperties= */ null);
}
/**
@ -197,8 +276,12 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* @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.
* @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
* RequestProperties)} and {@link #setContentTypePredicate(Predicate)}.
*/
@Deprecated
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
@ -206,7 +289,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
RequestProperties defaultRequestProperties) {
@Nullable RequestProperties defaultRequestProperties) {
this(
cronetEngine,
executor,
@ -214,9 +297,8 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
Clock.DEFAULT,
defaultRequestProperties,
false);
/* handleSetCookieRequests= */ false);
}
/**
@ -232,10 +314,14 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* @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.
* @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
* RequestProperties, boolean)} and {@link #setContentTypePredicate(Predicate)}.
*/
@Deprecated
public CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
@ -243,35 +329,33 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
int connectTimeoutMs,
int readTimeoutMs,
boolean resetTimeoutOnRedirects,
RequestProperties defaultRequestProperties,
@Nullable RequestProperties defaultRequestProperties,
boolean handleSetCookieRequests) {
this(
cronetEngine,
executor,
contentTypePredicate,
connectTimeoutMs,
readTimeoutMs,
resetTimeoutOnRedirects,
Clock.DEFAULT,
defaultRequestProperties,
handleSetCookieRequests);
this.contentTypePredicate = contentTypePredicate;
}
/* package */ CronetDataSource(
CronetEngine cronetEngine,
Executor executor,
@Nullable 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;
@ -282,6 +366,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
@ -312,6 +407,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
}
@Override
@Nullable
public Uri getUri() {
return responseInfo == null ? null : Uri.parse(responseInfo.getUrl());
}
@ -324,22 +420,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();
@ -347,6 +444,7 @@ 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 =
@ -354,7 +452,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
responseCode,
responseInfo.getHttpStatusText(),
responseInfo.getAllHeaders(),
currentDataSpec);
dataSpec);
if (responseCode == 416) {
exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE));
}
@ -362,11 +460,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);
}
}
@ -376,7 +475,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 {
@ -385,7 +484,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;
@ -404,37 +503,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 {
@ -459,6 +540,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) {
@ -497,29 +687,25 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
cronetEngine
.newUrlRequestBuilder(dataSpec.uri.toString(), urlRequestCallback, executor)
.allowDirectExecutor();
// Set the headers.
boolean isContentTypeHeaderSet = false;
Map<String, String> requestHeaders = new HashMap<>();
if (defaultRequestProperties != null) {
for (Entry<String, String> headerEntry : defaultRequestProperties.getSnapshot().entrySet()) {
String key = headerEntry.getKey();
isContentTypeHeaderSet = isContentTypeHeaderSet || CONTENT_TYPE.equals(key);
requestBuilder.addHeader(key, headerEntry.getValue());
}
requestHeaders.putAll(defaultRequestProperties.getSnapshot());
}
Map<String, String> requestPropertiesSnapshot = requestProperties.getSnapshot();
for (Entry<String, String> headerEntry : requestPropertiesSnapshot.entrySet()) {
requestHeaders.putAll(requestProperties.getSnapshot());
requestHeaders.putAll(dataSpec.httpRequestHeaders);
for (Entry<String, String> headerEntry : requestHeaders.entrySet()) {
String key = headerEntry.getKey();
isContentTypeHeaderSet = isContentTypeHeaderSet || CONTENT_TYPE.equals(key);
requestBuilder.addHeader(key, headerEntry.getValue());
String value = headerEntry.getValue();
requestBuilder.addHeader(key, value);
}
if (dataSpec.httpBody != null && !isContentTypeHeaderSet) {
if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) {
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();
@ -531,7 +717,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)) {
@ -560,7 +746,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");
@ -638,10 +866,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
@ -650,13 +890,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.getHttpStatusText(), info.getAllHeaders(), currentDataSpec);
responseCode, info.getHttpStatusText(), info.getAllHeaders(), dataSpec);
operation.open();
return;
}
@ -665,40 +907,47 @@ 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,
dataSpec.httpRequestHeaders);
} 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

@ -20,9 +20,7 @@ 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

@ -17,6 +17,7 @@ package com.google.android.exoplayer2.ext.cronet;
import android.content.Context;
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;
@ -37,8 +38,8 @@ 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
@ -144,7 +145,8 @@ public final class CronetEngineWrapper {
*
* @return A {@link CronetEngineSource} value.
*/
public @CronetEngineSource int getCronetEngineSource() {
@CronetEngineSource
public int getCronetEngineSource() {
return cronetEngineSource;
}
@ -153,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

@ -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;
@ -38,7 +38,6 @@ 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;
@ -61,6 +60,7 @@ import org.chromium.net.impl.UrlResponseInfoImpl;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@ -85,7 +85,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 +94,25 @@ public final class CronetDataSourceTest {
private boolean redirectCalled;
@Before
public void setUp() throws Exception {
public void setUp() {
MockitoAnnotations.initMocks(this);
HttpDataSource.RequestProperties defaultRequestProperties =
new HttpDataSource.RequestProperties();
defaultRequestProperties.set("defaultHeader1", "defaultValue1");
defaultRequestProperties.set("defaultHeader2", "defaultValue2");
dataSourceUnderTest =
new CronetDataSource(
mockCronetEngine,
mockExecutor,
mockContentTypePredicate,
TEST_CONNECT_TIMEOUT_MS,
TEST_READ_TIMEOUT_MS,
true, // resetTimeoutOnRedirects
/* resetTimeoutOnRedirects= */ true,
Clock.DEFAULT,
null,
false);
defaultRequestProperties,
/* handleSetCookieRequests= */ false);
dataSourceUnderTest.addTransferListener(mockTransferListener);
when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true);
when(mockCronetEngine.newUrlRequestBuilder(
anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
.thenReturn(mockUrlRequestBuilder);
@ -193,18 +196,59 @@ public final class CronetDataSourceTest {
}
@Test
public void testRequestHeadersSet() throws HttpDataSourceException {
public void testRequestSetsRangeHeader() throws HttpDataSourceException {
testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
mockResponseStartSuccess();
dataSourceUnderTest.setRequestProperty("firstHeader", "firstValue");
dataSourceUnderTest.setRequestProperty("secondHeader", "secondValue");
dataSourceUnderTest.open(testDataSpec);
// The header value to add is current position to current position + length - 1.
verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999");
verify(mockUrlRequestBuilder).addHeader("firstHeader", "firstValue");
verify(mockUrlRequestBuilder).addHeader("secondHeader", "secondValue");
}
@Test
public void testRequestHeadersSet() throws HttpDataSourceException {
Map<String, String> headersSet = new HashMap<>();
doAnswer(
(invocation) -> {
String key = invocation.getArgument(0);
String value = invocation.getArgument(1);
headersSet.put(key, value);
return null;
})
.when(mockUrlRequestBuilder)
.addHeader(ArgumentMatchers.anyString(), ArgumentMatchers.anyString());
dataSourceUnderTest.setRequestProperty("defaultHeader2", "dataSourceOverridesDefault");
dataSourceUnderTest.setRequestProperty("dataSourceHeader1", "dataSourceValue1");
dataSourceUnderTest.setRequestProperty("dataSourceHeader2", "dataSourceValue2");
Map<String, String> dataSpecRequestProperties = new HashMap<>();
dataSpecRequestProperties.put("defaultHeader3", "dataSpecOverridesAll");
dataSpecRequestProperties.put("dataSourceHeader2", "dataSpecOverridesDataSource");
dataSpecRequestProperties.put("dataSpecHeader1", "dataSpecValue1");
testDataSpec =
new DataSpec(
/* uri= */ Uri.parse(TEST_URL),
/* httpMethod= */ DataSpec.HTTP_METHOD_GET,
/* httpBody= */ null,
/* absoluteStreamPosition= */ 1000,
/* position= */ 1000,
/* length= */ 5000,
/* key= */ null,
/* flags= */ 0,
dataSpecRequestProperties);
mockResponseStartSuccess();
dataSourceUnderTest.open(testDataSpec);
assertThat(headersSet.get("defaultHeader1")).isEqualTo("defaultValue1");
assertThat(headersSet.get("defaultHeader2")).isEqualTo("dataSourceOverridesDefault");
assertThat(headersSet.get("defaultHeader3")).isEqualTo("dataSpecOverridesAll");
assertThat(headersSet.get("dataSourceHeader1")).isEqualTo("dataSourceValue1");
assertThat(headersSet.get("dataSourceHeader2")).isEqualTo("dataSpecOverridesDataSource");
assertThat(headersSet.get("dataSpecHeader1")).isEqualTo("dataSpecValue1");
verify(mockUrlRequest).start();
}
@ -245,6 +289,26 @@ public final class CronetDataSourceTest {
}
}
@Test
public void open_ifBodyIsSetWithoutContentTypeHeader_fails() {
testDataSpec =
new DataSpec(
/* uri= */ Uri.parse(TEST_URL),
/* postBody= */ new byte[1024],
/* absoluteStreamPosition= */ 200,
/* position= */ 200,
/* length= */ 1024,
/* key= */ "key",
/* flags= */ 0);
try {
dataSourceUnderTest.open(testDataSpec);
fail();
} catch (IOException expected) {
// Expected
}
}
@Test
public void testRequestOpenFailDueToDnsFailure() {
mockResponseStartFailure();
@ -283,7 +347,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 +362,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 +622,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 +1059,6 @@ public final class CronetDataSourceTest {
new CronetDataSource(
mockCronetEngine,
mockExecutor,
mockContentTypePredicate,
TEST_CONNECT_TIMEOUT_MS,
TEST_READ_TIMEOUT_MS,
true, // resetTimeoutOnRedirects
@ -765,13 +1089,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 +1127,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 +1177,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 +1238,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 +1447,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

@ -11,7 +11,7 @@ more external libraries as described below. These are licensed separately.
[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
## Build instructions ##
## Build instructions (Linux, macOS) ##
To use this extension you need to clone the ExoPlayer repository and depend on
its modules locally. Instructions for doing this can be found in ExoPlayer's
@ -21,15 +21,15 @@ for more information).
In addition, it's necessary to build the extension's native components as
follows:
* Set the following environment variables:
* Set the following shell variable:
```
cd "<path to exoplayer checkout>"
FFMPEG_EXT_PATH="$(pwd)/extensions/ffmpeg/src/main/jni"
```
* Download the [Android NDK][] and set its location in an environment variable.
Only versions up to NDK 15c are supported currently.
* Download the [Android NDK][] and set its location in a shell variable.
This build configuration has been tested on NDK r20.
```
NDK_PATH="<path to Android NDK>"
@ -41,70 +41,21 @@ NDK_PATH="<path to Android NDK>"
HOST_PLATFORM="linux-x86_64"
```
* Fetch and build FFmpeg. The configuration flags determine which formats will
be supported. See the [Supported formats][] page for more details of the
available flags.
For example, to fetch and build FFmpeg release 4.0 for armeabi-v7a,
arm64-v8a and x86 on Linux x86_64:
* Configure the formats supported by adapting the following variable if needed
and by setting it. See the [Supported formats][] page for more details of the
formats.
```
ENABLED_DECODERS=(vorbis opus flac)
```
* Fetch and build FFmpeg. For example, executing script `build_ffmpeg.sh` will
fetch and build FFmpeg release 4.2 for armeabi-v7a, arm64-v8a and x86:
```
COMMON_OPTIONS="\
--target-os=android \
--disable-static \
--enable-shared \
--disable-doc \
--disable-programs \
--disable-everything \
--disable-avdevice \
--disable-avformat \
--disable-swscale \
--disable-postproc \
--disable-avfilter \
--disable-symver \
--disable-swresample \
--enable-avresample \
--enable-decoder=vorbis \
--enable-decoder=opus \
--enable-decoder=flac \
" && \
cd "${FFMPEG_EXT_PATH}" && \
(git -C ffmpeg pull || git clone git://source.ffmpeg.org/ffmpeg ffmpeg) && \
cd ffmpeg && git checkout release/4.0 && \
./configure \
--libdir=android-libs/armeabi-v7a \
--arch=arm \
--cpu=armv7-a \
--cross-prefix="${NDK_PATH}/toolchains/arm-linux-androideabi-4.9/prebuilt/${HOST_PLATFORM}/bin/arm-linux-androideabi-" \
--sysroot="${NDK_PATH}/platforms/android-9/arch-arm/" \
--extra-cflags="-march=armv7-a -mfloat-abi=softfp" \
--extra-ldflags="-Wl,--fix-cortex-a8" \
--extra-ldexeflags=-pie \
${COMMON_OPTIONS} \
&& \
make -j4 && make install-libs && \
make clean && ./configure \
--libdir=android-libs/arm64-v8a \
--arch=aarch64 \
--cpu=armv8-a \
--cross-prefix="${NDK_PATH}/toolchains/aarch64-linux-android-4.9/prebuilt/${HOST_PLATFORM}/bin/aarch64-linux-android-" \
--sysroot="${NDK_PATH}/platforms/android-21/arch-arm64/" \
--extra-ldexeflags=-pie \
${COMMON_OPTIONS} \
&& \
make -j4 && make install-libs && \
make clean && ./configure \
--libdir=android-libs/x86 \
--arch=x86 \
--cpu=i686 \
--cross-prefix="${NDK_PATH}/toolchains/x86-4.9/prebuilt/${HOST_PLATFORM}/bin/i686-linux-android-" \
--sysroot="${NDK_PATH}/platforms/android-9/arch-x86/" \
--extra-ldexeflags=-pie \
--disable-asm \
${COMMON_OPTIONS} \
&& \
make -j4 && make install-libs && \
make clean
./build_ffmpeg.sh \
"${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}"
```
* Build the JNI native libraries, setting `APP_ABI` to include the architectures
@ -115,28 +66,35 @@ cd "${FFMPEG_EXT_PATH}" && \
${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86" -j4
```
## Build instructions (Windows) ##
We do not provide support for building this extension on Windows, however it
should be possible to follow the Linux instructions in [Windows PowerShell][].
[Windows PowerShell]: https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell
## Using the extension ##
Once you've followed the instructions above to check out, build and depend on
the extension, the next step is to tell ExoPlayer to use `FfmpegAudioRenderer`.
How you do this depends on which player API you're using:
* If you're passing a `DefaultRenderersFactory` to
`ExoPlayerFactory.newSimpleInstance`, you can enable using the extension by
setting the `extensionRendererMode` parameter of the `DefaultRenderersFactory`
constructor to `EXTENSION_RENDERER_MODE_ON`. This will use
`FfmpegAudioRenderer` for playback if `MediaCodecAudioRenderer` doesn't
support the input format. Pass `EXTENSION_RENDERER_MODE_PREFER` to give
`FfmpegAudioRenderer` priority over `MediaCodecAudioRenderer`.
* If you're passing a `DefaultRenderersFactory` to `SimpleExoPlayer.Builder`,
you can enable using the extension by setting the `extensionRendererMode`
parameter of the `DefaultRenderersFactory` constructor to
`EXTENSION_RENDERER_MODE_ON`. This will use `FfmpegAudioRenderer` for playback
if `MediaCodecAudioRenderer` doesn't support the input format. Pass
`EXTENSION_RENDERER_MODE_PREFER` to give `FfmpegAudioRenderer` priority over
`MediaCodecAudioRenderer`.
* If you've subclassed `DefaultRenderersFactory`, add an `FfmpegAudioRenderer`
to the output list in `buildAudioRenderers`. ExoPlayer will use the first
`Renderer` in the list that supports the input media format.
* If you've implemented your own `RenderersFactory`, return an
`FfmpegAudioRenderer` instance from `createRenderers`. ExoPlayer will use the
first `Renderer` in the returned array that supports the input media format.
* If you're using `ExoPlayerFactory.newInstance`, pass an `FfmpegAudioRenderer`
in the array of `Renderer`s. ExoPlayer will use the first `Renderer` in the
list that supports the input media format.
* If you're using `ExoPlayer.Builder`, pass an `FfmpegAudioRenderer` in the
array of `Renderer`s. ExoPlayer will use the first `Renderer` in the list that
supports the input media format.
Note: These instructions assume you're using `DefaultTrackSelector`. If you have
a custom track selector the choice of `Renderer` is up to your implementation,

View File

@ -38,9 +38,10 @@ android {
dependencies {
implementation project(modulePrefix + 'library-core')
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
testImplementation project(modulePrefix + 'testutils-robolectric')
testImplementation project(modulePrefix + 'testutils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {

View File

@ -92,8 +92,9 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
}
@Override
protected int supportsFormatInternal(DrmSessionManager<ExoMediaCrypto> drmSessionManager,
Format format) {
@FormatSupport
protected int supportsFormatInternal(
@Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, Format format) {
Assertions.checkNotNull(format.sampleMimeType);
if (!FfmpegLibrary.isAvailable()) {
return FORMAT_UNSUPPORTED_TYPE;
@ -108,12 +109,13 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
}
@Override
@AdaptiveSupport
public final int supportsMixedMimeTypeAdaptation() throws ExoPlaybackException {
return ADAPTIVE_NOT_SEAMLESS;
}
@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;

View File

@ -24,6 +24,7 @@ import com.google.android.exoplayer2.decoder.SimpleOutputBuffer;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.android.exoplayer2.util.Util;
import java.nio.ByteBuffer;
import java.util.List;
@ -42,7 +43,7 @@ import java.util.List;
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,7 +107,7 @@ import java.util.List;
return new FfmpegDecoderException("Error resetting (see logcat).");
}
}
ByteBuffer inputData = inputBuffer.data;
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
int inputSize = inputData.limit();
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, outputBufferSize);
@ -132,8 +133,8 @@ import java.util.List;
}
hasOutputFormat = true;
}
outputBuffer.data.position(0);
outputBuffer.data.limit(result);
outputData.position(0);
outputData.limit(result);
return null;
}

View File

@ -34,7 +34,7 @@ public final class FfmpegLibrary {
private static final String TAG = "FfmpegLibrary";
private static final LibraryLoader LOADER =
new LibraryLoader("avutil", "avresample", "avcodec", "ffmpeg");
new LibraryLoader("avutil", "avresample", "swresample", "avcodec", "ffmpeg");
private FfmpegLibrary() {}

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

@ -22,12 +22,17 @@ LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libavutil
LOCAL_MODULE := libavresample
LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libavresample
LOCAL_MODULE := libswresample
LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
include $(CLEAR_VARS)
LOCAL_MODULE := libavutil
LOCAL_SRC_FILES := ffmpeg/android-libs/$(TARGET_ARCH_ABI)/$(LOCAL_MODULE).so
include $(PREBUILT_SHARED_LIBRARY)
@ -35,6 +40,6 @@ include $(CLEAR_VARS)
LOCAL_MODULE := ffmpeg
LOCAL_SRC_FILES := ffmpeg_jni.cc
LOCAL_C_INCLUDES := ffmpeg
LOCAL_SHARED_LIBRARIES := libavcodec libavresample libavutil
LOCAL_SHARED_LIBRARIES := libavcodec libavresample libswresample libavutil
LOCAL_LDLIBS := -Lffmpeg/android-libs/$(TARGET_ARCH_ABI) -llog
include $(BUILD_SHARED_LIBRARY)

View File

@ -15,6 +15,6 @@
#
APP_OPTIM := release
APP_STL := gnustl_static
APP_STL := c++_static
APP_CPPFLAGS := -frtti
APP_PLATFORM := android-9

View File

@ -0,0 +1,85 @@
#!/bin/bash
#
# 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.
#
FFMPEG_EXT_PATH=$1
NDK_PATH=$2
HOST_PLATFORM=$3
ENABLED_DECODERS=("${@:4}")
COMMON_OPTIONS="
--target-os=android
--disable-static
--enable-shared
--disable-doc
--disable-programs
--disable-everything
--disable-avdevice
--disable-avformat
--disable-swscale
--disable-postproc
--disable-avfilter
--disable-symver
--enable-avresample
--enable-swresample
"
TOOLCHAIN_PREFIX="${NDK_PATH}/toolchains/llvm/prebuilt/${HOST_PLATFORM}/bin"
for decoder in "${ENABLED_DECODERS[@]}"
do
COMMON_OPTIONS="${COMMON_OPTIONS} --enable-decoder=${decoder}"
done
cd "${FFMPEG_EXT_PATH}"
(git -C ffmpeg pull || git clone git://source.ffmpeg.org/ffmpeg ffmpeg)
cd ffmpeg
git checkout release/4.2
./configure \
--libdir=android-libs/armeabi-v7a \
--arch=arm \
--cpu=armv7-a \
--cross-prefix="${TOOLCHAIN_PREFIX}/armv7a-linux-androideabi16-" \
--nm="${TOOLCHAIN_PREFIX}/arm-linux-androideabi-nm" \
--strip="${TOOLCHAIN_PREFIX}/arm-linux-androideabi-strip" \
--extra-cflags="-march=armv7-a -mfloat-abi=softfp" \
--extra-ldflags="-Wl,--fix-cortex-a8" \
--extra-ldexeflags=-pie \
${COMMON_OPTIONS}
make -j4
make install-libs
make clean
./configure \
--libdir=android-libs/arm64-v8a \
--arch=aarch64 \
--cpu=armv8-a \
--cross-prefix="${TOOLCHAIN_PREFIX}/aarch64-linux-android21-" \
--nm="${TOOLCHAIN_PREFIX}/aarch64-linux-android-nm" \
--strip="${TOOLCHAIN_PREFIX}/aarch64-linux-android-strip" \
--extra-ldexeflags=-pie \
${COMMON_OPTIONS}
make -j4
make install-libs
make clean
./configure \
--libdir=android-libs/x86 \
--arch=x86 \
--cpu=i686 \
--cross-prefix="${TOOLCHAIN_PREFIX}/i686-linux-android16-" \
--nm="${TOOLCHAIN_PREFIX}/i686-linux-android-nm" \
--strip="${TOOLCHAIN_PREFIX}/i686-linux-android-strip" \
--extra-ldexeflags=-pie \
--disable-asm \
${COMMON_OPTIONS}
make -j4
make install-libs
make clean

View File

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

View File

@ -11,7 +11,7 @@ more external libraries as described below. These are licensed separately.
[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
## Build instructions ##
## Build instructions (Linux, macOS) ##
To use this extension you need to clone the ExoPlayer repository and depend on
its modules locally. Instructions for doing this can be found in ExoPlayer's
@ -28,8 +28,8 @@ EXOPLAYER_ROOT="$(pwd)"
FLAC_EXT_PATH="${EXOPLAYER_ROOT}/extensions/flac/src/main"
```
* Download the [Android NDK][] (version <= 17c) and set its location in an
environment variable:
* Download the [Android NDK][] and set its location in an environment variable.
This build configuration has been tested on NDK r20.
```
NDK_PATH="<path to Android NDK>"
@ -53,6 +53,13 @@ ${NDK_PATH}/ndk-build APP_ABI=all -j4
[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
## Build instructions (Windows) ##
We do not provide support for building this extension on Windows, however it
should be possible to follow the Linux instructions in [Windows PowerShell][].
[Windows PowerShell]: https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell
## Using the extension ##
Once you've followed the instructions above to check out, build and depend on
@ -68,22 +75,22 @@ renderer.
### Using `LibflacAudioRenderer` ###
* If you're passing a `DefaultRenderersFactory` to
`ExoPlayerFactory.newSimpleInstance`, you can enable using the extension by
setting the `extensionRendererMode` parameter of the `DefaultRenderersFactory`
constructor to `EXTENSION_RENDERER_MODE_ON`. This will use
`LibflacAudioRenderer` for playback if `MediaCodecAudioRenderer` doesn't
support the input format. Pass `EXTENSION_RENDERER_MODE_PREFER` to give
`LibflacAudioRenderer` priority over `MediaCodecAudioRenderer`.
* If you're passing a `DefaultRenderersFactory` to `SimpleExoPlayer.Builder`,
you can enable using the extension by setting the `extensionRendererMode`
parameter of the `DefaultRenderersFactory` constructor to
`EXTENSION_RENDERER_MODE_ON`. This will use `LibflacAudioRenderer` for
playback if `MediaCodecAudioRenderer` doesn't support the input format. Pass
`EXTENSION_RENDERER_MODE_PREFER` to give `LibflacAudioRenderer` priority over
`MediaCodecAudioRenderer`.
* If you've subclassed `DefaultRenderersFactory`, add a `LibflacAudioRenderer`
to the output list in `buildAudioRenderers`. ExoPlayer will use the first
`Renderer` in the list that supports the input media format.
* If you've implemented your own `RenderersFactory`, return a
`LibflacAudioRenderer` instance from `createRenderers`. ExoPlayer will use the
first `Renderer` in the returned array that supports the input media format.
* If you're using `ExoPlayerFactory.newInstance`, pass a `LibflacAudioRenderer`
in the array of `Renderer`s. ExoPlayer will use the first `Renderer` in the
list that supports the input media format.
* If you're using `ExoPlayer.Builder`, pass a `LibflacAudioRenderer` in the
array of `Renderer`s. ExoPlayer will use the first `Renderer` in the list that
supports the input media format.
Note: These instructions assume you're using `DefaultTrackSelector`. If you have
a custom track selector the choice of `Renderer` is up to your implementation,

View File

@ -39,11 +39,14 @@ android {
dependencies {
implementation project(modulePrefix + 'library-core')
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
androidTestImplementation project(modulePrefix + 'testutils')
androidTestImplementation 'androidx.test:runner:' + androidXTestVersion
testImplementation project(modulePrefix + 'testutils-robolectric')
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
testImplementation 'androidx.test:core:' + androidxTestCoreVersion
testImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
testImplementation project(modulePrefix + 'testutils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {

View File

@ -25,6 +25,7 @@ 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.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link FlacBinarySearchSeeker}. */
@ -41,6 +42,7 @@ public final class FlacBinarySearchSeekerTest {
}
}
@Test
public void testGetSeekMap_returnsSeekMapWithCorrectDuration()
throws IOException, FlacDecoderException, InterruptedException {
byte[] data =
@ -63,6 +65,7 @@ public final class FlacBinarySearchSeekerTest {
assertThat(seekMap.isSeekable()).isTrue();
}
@Test
public void testSetSeekTargetUs_returnsSeekPending()
throws IOException, FlacDecoderException, InterruptedException {
byte[] data =

View File

@ -41,6 +41,7 @@ import java.io.IOException;
import java.util.List;
import java.util.Random;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Seeking tests for {@link FlacExtractor} when the FLAC stream does not have a SEEKTABLE. */
@ -76,6 +77,7 @@ public final class FlacExtractorSeekTest {
positionHolder = new PositionHolder();
}
@Test
public void testFlacExtractorReads_nonSeekTableFile_returnSeekableSeekMap()
throws IOException, InterruptedException {
FlacExtractor extractor = new FlacExtractor();
@ -87,6 +89,7 @@ public final class FlacExtractorSeekTest {
assertThat(seekMap.isSeekable()).isTrue();
}
@Test
public void testHandlePendingSeek_handlesSeekingToPositionInFile_extractsCorrectFrame()
throws IOException, InterruptedException {
FlacExtractor extractor = new FlacExtractor();
@ -103,6 +106,7 @@ public final class FlacExtractorSeekTest {
trackOutput, targetSeekTimeUs, extractedFrameIndex);
}
@Test
public void testHandlePendingSeek_handlesSeekToEoF_extractsLastFrame()
throws IOException, InterruptedException {
FlacExtractor extractor = new FlacExtractor();
@ -120,6 +124,7 @@ public final class FlacExtractorSeekTest {
trackOutput, targetSeekTimeUs, extractedFrameIndex);
}
@Test
public void testHandlePendingSeek_handlesSeekingBackward_extractsCorrectFrame()
throws IOException, InterruptedException {
FlacExtractor extractor = new FlacExtractor();
@ -139,6 +144,7 @@ public final class FlacExtractorSeekTest {
trackOutput, targetSeekTimeUs, extractedFrameIndex);
}
@Test
public void testHandlePendingSeek_handlesSeekingForward_extractsCorrectFrame()
throws IOException, InterruptedException {
FlacExtractor extractor = new FlacExtractor();
@ -158,6 +164,7 @@ public final class FlacExtractorSeekTest {
trackOutput, targetSeekTimeUs, extractedFrameIndex);
}
@Test
public void testHandlePendingSeek_handlesRandomSeeks_extractsCorrectFrame()
throws IOException, InterruptedException {
FlacExtractor extractor = new FlacExtractor();
@ -228,7 +235,8 @@ public final class FlacExtractorSeekTest {
}
}
private @Nullable SeekMap extractSeekMap(FlacExtractor extractor, FakeExtractorOutput output)
@Nullable
private SeekMap extractSeekMap(FlacExtractor extractor, FakeExtractorOutput output)
throws IOException, InterruptedException {
try {
ExtractorInput input = getExtractorInputFromPosition(0);

View File

@ -21,6 +21,7 @@ import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.testutil.ExtractorAsserts;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link FlacExtractor}. */
@ -34,11 +35,13 @@ public class FlacExtractorTest {
}
}
@Test
public void testExtractFlacSample() throws Exception {
ExtractorAsserts.assertBehavior(
FlacExtractor::new, "bear.flac", ApplicationProvider.getApplicationContext());
}
@Test
public void testExtractFlacSampleWithId3Header() throws Exception {
ExtractorAsserts.assertBehavior(
FlacExtractor::new, "bear_with_id3.flac", ApplicationProvider.getApplicationContext());

View File

@ -24,13 +24,10 @@ import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Renderer;
import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import org.junit.Before;
import org.junit.Test;
@ -82,8 +79,7 @@ public class FlacPlaybackTest {
public void run() {
Looper.prepare();
LibflacAudioRenderer audioRenderer = new LibflacAudioRenderer();
DefaultTrackSelector trackSelector = new DefaultTrackSelector();
player = ExoPlayerFactory.newInstance(context, new Renderer[] {audioRenderer}, trackSelector);
player = new ExoPlayer.Builder(context, audioRenderer).build();
player.addListener(this);
MediaSource mediaSource =
new ProgressiveMediaSource.Factory(
@ -101,7 +97,7 @@ public class FlacPlaybackTest {
}
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
if (playbackState == Player.STATE_ENDED
|| (playbackState == Player.STATE_IDLE && playbackException != null)) {
player.release();

View File

@ -22,6 +22,7 @@ import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
import com.google.android.exoplayer2.decoder.SimpleDecoder;
import com.google.android.exoplayer2.decoder.SimpleOutputBuffer;
import com.google.android.exoplayer2.util.FlacStreamMetadata;
import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.List;
@ -101,7 +102,7 @@ import java.util.List;
if (reset) {
decoderJni.flush();
}
decoderJni.setData(inputBuffer.data);
decoderJni.setData(Util.castNonNull(inputBuffer.data));
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, maxOutputBufferSize);
try {
decoderJni.decodeSample(outputData);

View File

@ -51,6 +51,12 @@ import java.nio.ByteBuffer;
@Nullable private byte[] tempBuffer;
private boolean endOfExtractorInput;
// the constructor does not initialize fields: tempBuffer
// call to flacInit() not allowed on the given receiver.
@SuppressWarnings({
"nullness:initialization.fields.uninitialized",
"nullness:method.invocation.invalid"
})
public FlacDecoderJni() throws FlacDecoderException {
if (!FlacLibrary.isAvailable()) {
throw new FlacDecoderException("Failed to load decoder native libraries.");

View File

@ -16,6 +16,7 @@
package com.google.android.exoplayer2.ext.flac;
import android.os.Handler;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.audio.AudioProcessor;
@ -33,7 +34,7 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer {
private static final int NUM_BUFFERS = 16;
public LibflacAudioRenderer() {
this(null, null);
this(/* eventHandler= */ null, /* eventListener= */ null);
}
/**
@ -43,15 +44,16 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer {
* @param audioProcessors Optional {@link AudioProcessor}s that will process audio before output.
*/
public LibflacAudioRenderer(
Handler eventHandler,
AudioRendererEventListener eventListener,
@Nullable Handler eventHandler,
@Nullable AudioRendererEventListener eventListener,
AudioProcessor... audioProcessors) {
super(eventHandler, eventListener, audioProcessors);
}
@Override
protected int supportsFormatInternal(DrmSessionManager<ExoMediaCrypto> drmSessionManager,
Format format) {
@FormatSupport
protected int supportsFormatInternal(
@Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, Format format) {
if (!FlacLibrary.isAvailable()
|| !MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType)) {
return FORMAT_UNSUPPORTED_TYPE;
@ -65,7 +67,7 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer {
}
@Override
protected FlacDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto)
protected FlacDecoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto)
throws FlacDecoderException {
return new FlacDecoder(
NUM_BUFFERS, NUM_BUFFERS, format.maxInputSize, format.initializationData);

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.flac;
import com.google.android.exoplayer2.util.NonNullApi;

View File

@ -15,6 +15,6 @@
#
APP_OPTIM := release
APP_STL := gnustl_static
APP_STL := c++_static
APP_CPPFLAGS := -frtti
APP_PLATFORM := android-14

View File

@ -265,11 +265,11 @@ FLACParser::FLACParser(DataSource *source)
: mDataSource(source),
mCopy(copyTrespass),
mDecoder(NULL),
mSeekTable(NULL),
firstFrameOffset(0LL),
mCurrentPos(0LL),
mEOF(false),
mStreamInfoValid(false),
mSeekTable(NULL),
firstFrameOffset(0LL),
mVorbisCommentsValid(false),
mPicturesValid(false),
mWriteRequested(false),
@ -456,6 +456,9 @@ bool FLACParser::getSeekPositions(int64_t timeUs,
for (unsigned i = length; i != 0; i--) {
int64_t sampleNumber = points[i - 1].sample_number;
if (sampleNumber == -1) { // placeholder
continue;
}
if (sampleNumber <= targetSampleNumber) {
result[0] = (sampleNumber * 1000000LL) / sampleRate;
result[1] = firstFrameOffset + points[i - 1].stream_offset;

View File

@ -62,7 +62,9 @@ class FLACParser {
bool areVorbisCommentsValid() const { return mVorbisCommentsValid; }
std::vector<std::string> getVorbisComments() { return mVorbisComments; }
const std::vector<std::string>& getVorbisComments() const {
return mVorbisComments;
}
bool arePicturesValid() const { return mPicturesValid; }

View File

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

View File

@ -1,11 +1,15 @@
# ExoPlayer GVR extension #
**DEPRECATED - If you still need this extension, please contact us by filing an
issue on our [issue tracker][].**
The GVR extension wraps the [Google VR SDK for Android][]. It provides a
GvrAudioProcessor, which uses [GvrAudioSurround][] to provide binaural rendering
of surround sound and ambisonic soundfields.
[Google VR SDK for Android]: https://developers.google.com/vr/android/
[GvrAudioSurround]: https://developers.google.com/vr/android/reference/com/google/vr/sdk/audio/GvrAudioSurround
[issue tracker]: https://github.com/google/ExoPlayer/issues
## Getting the extension ##

View File

@ -33,7 +33,7 @@ android {
dependencies {
implementation project(modulePrefix + 'library-core')
implementation project(modulePrefix + 'library-ui')
implementation 'androidx.annotation:annotation:1.1.0'
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
api 'com.google.vr:sdk-base:1.190.0'
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
}

View File

@ -18,7 +18,6 @@ package com.google.android.exoplayer2.ext.gvr;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.audio.AudioProcessor;
import com.google.android.exoplayer2.util.Assertions;
import com.google.vr.sdk.audio.GvrAudioSurround;
@ -28,7 +27,11 @@ import java.nio.ByteOrder;
/**
* An {@link AudioProcessor} that uses {@code GvrAudioSurround} to provide binaural rendering of
* surround sound and ambisonic soundfields.
*
* @deprecated If you still need this component, please contact us by filing an issue on our <a
* href="https://github.com/google/ExoPlayer/issues">issue tracker</a>.
*/
@Deprecated
public final class GvrAudioProcessor implements AudioProcessor {
static {
@ -40,8 +43,7 @@ public final class GvrAudioProcessor implements AudioProcessor {
private static final int OUTPUT_FRAME_SIZE = OUTPUT_CHANNEL_COUNT * 2; // 16-bit stereo output.
private static final int NO_SURROUND_FORMAT = GvrAudioSurround.SurroundFormat.INVALID;
private int sampleRateHz;
private int channelCount;
private AudioFormat pendingInputAudioFormat;
private int pendingGvrAudioSurroundFormat;
@Nullable private GvrAudioSurround gvrAudioSurround;
private ByteBuffer buffer;
@ -56,8 +58,7 @@ public final class GvrAudioProcessor implements AudioProcessor {
public GvrAudioProcessor() {
// Use the identity for the initial orientation.
w = 1f;
sampleRateHz = Format.NO_VALUE;
channelCount = Format.NO_VALUE;
pendingInputAudioFormat = AudioFormat.NOT_SET;
buffer = EMPTY_BUFFER;
pendingGvrAudioSurroundFormat = NO_SURROUND_FORMAT;
}
@ -83,19 +84,13 @@ public final class GvrAudioProcessor implements AudioProcessor {
@SuppressWarnings("ReferenceEquality")
@Override
public synchronized boolean configure(
int sampleRateHz, int channelCount, @C.Encoding int encoding)
throws UnhandledFormatException {
if (encoding != C.ENCODING_PCM_16BIT) {
public synchronized AudioFormat configure(AudioFormat inputAudioFormat)
throws UnhandledAudioFormatException {
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) {
maybeReleaseGvrAudioSurround();
throw new UnhandledFormatException(sampleRateHz, channelCount, encoding);
throw new UnhandledAudioFormatException(inputAudioFormat);
}
if (this.sampleRateHz == sampleRateHz && this.channelCount == channelCount) {
return false;
}
this.sampleRateHz = sampleRateHz;
this.channelCount = channelCount;
switch (channelCount) {
switch (inputAudioFormat.channelCount) {
case 1:
pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.SURROUND_MONO;
break;
@ -115,13 +110,14 @@ public final class GvrAudioProcessor implements AudioProcessor {
pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.THIRD_ORDER_AMBISONICS;
break;
default:
throw new UnhandledFormatException(sampleRateHz, channelCount, encoding);
throw new UnhandledAudioFormatException(inputAudioFormat);
}
if (buffer == EMPTY_BUFFER) {
buffer = ByteBuffer.allocateDirect(FRAMES_PER_OUTPUT_BUFFER * OUTPUT_FRAME_SIZE)
.order(ByteOrder.nativeOrder());
}
return true;
pendingInputAudioFormat = inputAudioFormat;
return new AudioFormat(inputAudioFormat.sampleRate, OUTPUT_CHANNEL_COUNT, C.ENCODING_PCM_16BIT);
}
@Override
@ -129,21 +125,6 @@ public final class GvrAudioProcessor implements AudioProcessor {
return pendingGvrAudioSurroundFormat != NO_SURROUND_FORMAT || gvrAudioSurround != null;
}
@Override
public int getOutputChannelCount() {
return OUTPUT_CHANNEL_COUNT;
}
@Override
public int getOutputEncoding() {
return C.ENCODING_PCM_16BIT;
}
@Override
public int getOutputSampleRateHz() {
return sampleRateHz;
}
@Override
public void queueInput(ByteBuffer input) {
int position = input.position();
@ -182,7 +163,10 @@ public final class GvrAudioProcessor implements AudioProcessor {
maybeReleaseGvrAudioSurround();
gvrAudioSurround =
new GvrAudioSurround(
pendingGvrAudioSurroundFormat, sampleRateHz, channelCount, FRAMES_PER_OUTPUT_BUFFER);
pendingGvrAudioSurroundFormat,
pendingInputAudioFormat.sampleRate,
pendingInputAudioFormat.channelCount,
FRAMES_PER_OUTPUT_BUFFER);
gvrAudioSurround.updateNativeOrientation(w, x, y, z);
pendingGvrAudioSurroundFormat = NO_SURROUND_FORMAT;
} else if (gvrAudioSurround != null) {
@ -196,8 +180,7 @@ public final class GvrAudioProcessor implements AudioProcessor {
maybeReleaseGvrAudioSurround();
updateOrientation(/* w= */ 1f, /* x= */ 0f, /* y= */ 0f, /* z= */ 0f);
inputEnded = false;
sampleRateHz = Format.NO_VALUE;
channelCount = Format.NO_VALUE;
pendingInputAudioFormat = AudioFormat.NOT_SET;
buffer = EMPTY_BUFFER;
pendingGvrAudioSurroundFormat = NO_SURROUND_FORMAT;
}

View File

@ -1,355 +0,0 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.ext.gvr;
import android.content.Context;
import android.content.Intent;
import android.graphics.SurfaceTexture;
import android.opengl.Matrix;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.BinderThread;
import androidx.annotation.CallSuper;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import android.view.ContextThemeWrapper;
import android.view.MotionEvent;
import android.view.Surface;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ui.PlayerControlView;
import com.google.android.exoplayer2.ui.spherical.GlViewGroup;
import com.google.android.exoplayer2.ui.spherical.PointerRenderer;
import com.google.android.exoplayer2.ui.spherical.SceneRenderer;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import com.google.vr.ndk.base.DaydreamApi;
import com.google.vr.sdk.base.AndroidCompat;
import com.google.vr.sdk.base.Eye;
import com.google.vr.sdk.base.GvrActivity;
import com.google.vr.sdk.base.GvrView;
import com.google.vr.sdk.base.HeadTransform;
import com.google.vr.sdk.base.Viewport;
import com.google.vr.sdk.controller.Controller;
import com.google.vr.sdk.controller.ControllerManager;
import javax.microedition.khronos.egl.EGLConfig;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** Base activity for VR 360 video playback. */
public abstract class GvrPlayerActivity extends GvrActivity {
private static final int EXIT_FROM_VR_REQUEST_CODE = 42;
private final Handler mainHandler;
@Nullable private Player player;
@MonotonicNonNull private GlViewGroup glView;
@MonotonicNonNull private ControllerManager controllerManager;
@MonotonicNonNull private SurfaceTexture surfaceTexture;
@MonotonicNonNull private Surface surface;
@MonotonicNonNull private SceneRenderer scene;
@MonotonicNonNull private PlayerControlView playerControl;
public GvrPlayerActivity() {
mainHandler = new Handler(Looper.getMainLooper());
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setScreenAlwaysOn(true);
GvrView gvrView = new GvrView(this);
// Since videos typically have fewer pixels per degree than the phones, reducing the render
// target scaling factor reduces the work required to render the scene.
gvrView.setRenderTargetScale(.5f);
// If a custom theme isn't specified, the Context's theme is used. For VR Activities, this is
// the old Android default theme rather than a modern theme. Override this with a custom theme.
Context theme = new ContextThemeWrapper(this, R.style.VrTheme);
glView = new GlViewGroup(theme, R.layout.vr_ui);
playerControl = Assertions.checkNotNull(glView.findViewById(R.id.controller));
playerControl.setShowVrButton(true);
playerControl.setVrButtonListener(v -> exit());
PointerRenderer pointerRenderer = new PointerRenderer();
scene = new SceneRenderer();
Renderer renderer = new Renderer(scene, glView, pointerRenderer);
// Attach glView to gvrView in order to properly handle UI events.
gvrView.addView(glView, 0);
// Standard GvrView configuration
gvrView.setEGLConfigChooser(
8, 8, 8, 8, // RGBA bits.
16, // Depth bits.
0); // Stencil bits.
gvrView.setRenderer(renderer);
setContentView(gvrView);
// Most Daydream phones can render a 4k video at 60fps in sustained performance mode. These
// options can be tweaked along with the render target scale.
if (gvrView.setAsyncReprojectionEnabled(true)) {
AndroidCompat.setSustainedPerformanceMode(this, true);
}
// Handle the user clicking on the 'X' in the top left corner. Since this is done when the user
// has taken the headset out of VR, it should launch the app's exit flow directly rather than
// using the transition flow.
gvrView.setOnCloseButtonListener(this::finish);
ControllerManager.EventListener listener =
new ControllerManager.EventListener() {
@Override
public void onApiStatusChanged(int status) {
// Do nothing.
}
@Override
public void onRecentered() {
// TODO if in cardboard mode call gvrView.recenterHeadTracker();
glView.post(() -> Util.castNonNull(playerControl).show());
}
};
controllerManager = new ControllerManager(this, listener);
Controller controller = controllerManager.getController();
ControllerEventListener controllerEventListener =
new ControllerEventListener(controller, pointerRenderer, glView);
controller.setEventListener(controllerEventListener);
}
/**
* Sets the {@link Player} to use.
*
* @param newPlayer The {@link Player} to use, or {@code null} to detach the current player.
*/
protected void setPlayer(@Nullable Player newPlayer) {
Assertions.checkNotNull(scene);
if (player == newPlayer) {
return;
}
if (player != null) {
Player.VideoComponent videoComponent = player.getVideoComponent();
if (videoComponent != null) {
if (surface != null) {
videoComponent.clearVideoSurface(surface);
}
videoComponent.clearVideoFrameMetadataListener(scene);
videoComponent.clearCameraMotionListener(scene);
}
}
player = newPlayer;
if (player != null) {
Player.VideoComponent videoComponent = player.getVideoComponent();
if (videoComponent != null) {
videoComponent.setVideoFrameMetadataListener(scene);
videoComponent.setCameraMotionListener(scene);
videoComponent.setVideoSurface(surface);
}
}
Assertions.checkNotNull(playerControl).setPlayer(player);
}
/**
* Sets the default stereo mode. If the played video doesn't contain a stereo mode the default one
* is used.
*
* @param stereoMode A {@link C.StereoMode} value.
*/
protected void setDefaultStereoMode(@C.StereoMode int stereoMode) {
Assertions.checkNotNull(scene).setDefaultStereoMode(stereoMode);
}
@CallSuper
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent unused) {
if (requestCode == EXIT_FROM_VR_REQUEST_CODE && resultCode == RESULT_OK) {
finish();
}
}
@Override
protected void onResume() {
super.onResume();
Util.castNonNull(controllerManager).start();
}
@Override
protected void onPause() {
Util.castNonNull(controllerManager).stop();
super.onPause();
}
@Override
protected void onDestroy() {
setPlayer(null);
releaseSurface(surfaceTexture, surface);
super.onDestroy();
}
/** Tries to exit gracefully from VR using a VR transition dialog. */
@SuppressWarnings("nullness:argument.type.incompatible")
protected void exit() {
// This needs to use GVR's exit transition to avoid disorienting the user.
DaydreamApi api = DaydreamApi.create(this);
if (api != null) {
api.exitFromVr(this, EXIT_FROM_VR_REQUEST_CODE, null);
// Eventually, the Activity's onActivityResult will be called.
api.close();
} else {
finish();
}
}
/** Toggles PlayerControl visibility. */
@UiThread
protected void togglePlayerControlVisibility() {
if (Assertions.checkNotNull(playerControl).isVisible()) {
playerControl.hide();
} else {
playerControl.show();
}
}
// Called on GL thread.
private void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture) {
mainHandler.post(
() -> {
SurfaceTexture oldSurfaceTexture = this.surfaceTexture;
Surface oldSurface = this.surface;
this.surfaceTexture = surfaceTexture;
this.surface = new Surface(surfaceTexture);
if (player != null) {
Player.VideoComponent videoComponent = player.getVideoComponent();
if (videoComponent != null) {
videoComponent.setVideoSurface(surface);
}
}
releaseSurface(oldSurfaceTexture, oldSurface);
});
}
private static void releaseSurface(
@Nullable SurfaceTexture oldSurfaceTexture, @Nullable Surface oldSurface) {
if (oldSurfaceTexture != null) {
oldSurfaceTexture.release();
}
if (oldSurface != null) {
oldSurface.release();
}
}
private class Renderer implements GvrView.StereoRenderer {
private static final float Z_NEAR = .1f;
private static final float Z_FAR = 100;
private final float[] viewProjectionMatrix = new float[16];
private final SceneRenderer scene;
private final GlViewGroup glView;
private final PointerRenderer pointerRenderer;
public Renderer(SceneRenderer scene, GlViewGroup glView, PointerRenderer pointerRenderer) {
this.scene = scene;
this.glView = glView;
this.pointerRenderer = pointerRenderer;
}
@Override
public void onNewFrame(HeadTransform headTransform) {}
@Override
public void onDrawEye(Eye eye) {
Matrix.multiplyMM(
viewProjectionMatrix, 0, eye.getPerspective(Z_NEAR, Z_FAR), 0, eye.getEyeView(), 0);
scene.drawFrame(viewProjectionMatrix, eye.getType() == Eye.Type.RIGHT);
if (glView.isVisible()) {
glView.getRenderer().draw(viewProjectionMatrix);
pointerRenderer.draw(viewProjectionMatrix);
}
}
@Override
public void onFinishFrame(Viewport viewport) {}
@Override
public void onSurfaceCreated(EGLConfig config) {
onSurfaceTextureAvailable(scene.init());
glView.getRenderer().init();
pointerRenderer.init();
}
@Override
public void onSurfaceChanged(int width, int height) {}
@Override
public void onRendererShutdown() {
glView.getRenderer().shutdown();
pointerRenderer.shutdown();
scene.shutdown();
}
}
private class ControllerEventListener extends Controller.EventListener {
private final Controller controller;
private final PointerRenderer pointerRenderer;
private final GlViewGroup glView;
private final float[] controllerOrientationMatrix;
private boolean clickButtonDown;
private boolean appButtonDown;
public ControllerEventListener(
Controller controller, PointerRenderer pointerRenderer, GlViewGroup glView) {
this.controller = controller;
this.pointerRenderer = pointerRenderer;
this.glView = glView;
controllerOrientationMatrix = new float[16];
}
@Override
@BinderThread
public void onUpdate() {
controller.update();
controller.orientation.toRotationMatrix(controllerOrientationMatrix);
pointerRenderer.setControllerOrientation(controllerOrientationMatrix);
if (clickButtonDown || controller.clickButtonState) {
int action;
if (clickButtonDown != controller.clickButtonState) {
clickButtonDown = controller.clickButtonState;
action = clickButtonDown ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP;
} else {
action = MotionEvent.ACTION_MOVE;
}
glView.post(
() -> {
float[] angles = controller.orientation.toYawPitchRollRadians(new float[3]);
boolean clickedOnView = glView.simulateClick(action, angles[0], angles[1]);
if (action == MotionEvent.ACTION_DOWN && !clickedOnView) {
togglePlayerControlVisibility();
}
});
} else if (!appButtonDown && controller.appButtonState) {
glView.post(GvrPlayerActivity.this::togglePlayerControlVisibility);
}
appButtonDown = controller.appButtonState;
}
}
}

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