downloads) {
+ return notificationHelper.buildProgressNotification(
+ R.drawable.ic_download, /* contentIntent= */ null, /* message= */ null, downloads);
+ }
+
+ /**
+ * Creates and displays notifications for downloads when they complete or fail.
+ *
+ * This helper will outlive the lifespan of a single instance of {@link DemoDownloadService}.
+ * It is static to avoid leaking the first {@link DemoDownloadService} instance.
+ */
+ private static final class TerminalStateNotificationHelper implements DownloadManager.Listener {
+
+ private final Context context;
+ private final DownloadNotificationHelper notificationHelper;
+
+ private int nextNotificationId;
+
+ public TerminalStateNotificationHelper(
+ Context context, DownloadNotificationHelper notificationHelper, int firstNotificationId) {
+ this.context = context.getApplicationContext();
+ this.notificationHelper = notificationHelper;
+ nextNotificationId = firstNotificationId;
+ }
+
+ @Override
+ public void onDownloadChanged(DownloadManager manager, Download download) {
+ Notification notification;
+ if (download.state == Download.STATE_COMPLETED) {
+ notification =
+ notificationHelper.buildDownloadCompletedNotification(
+ R.drawable.ic_download_done,
+ /* contentIntent= */ null,
+ Util.fromUtf8Bytes(download.request.data));
+ } else if (download.state == Download.STATE_FAILED) {
+ notification =
+ notificationHelper.buildDownloadFailedNotification(
+ R.drawable.ic_download_done,
+ /* contentIntent= */ null,
+ Util.fromUtf8Bytes(download.request.data));
+ } else {
+ return;
+ }
+ NotificationUtil.setNotification(context, nextNotificationId++, notification);
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java
new file mode 100644
index 0000000000..143eda93df
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java
@@ -0,0 +1,274 @@
+/*
+ * 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.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 com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.RenderersFactory;
+import com.google.android.exoplayer2.offline.Download;
+import com.google.android.exoplayer2.offline.DownloadCursor;
+import com.google.android.exoplayer2.offline.DownloadHelper;
+import com.google.android.exoplayer2.offline.DownloadIndex;
+import com.google.android.exoplayer2.offline.DownloadManager;
+import com.google.android.exoplayer2.offline.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;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/** Tracks media that has been downloaded. */
+public class DownloadTracker {
+
+ /** Listens for changes in the tracked downloads. */
+ public interface Listener {
+
+ /** Called when the tracked downloads changed. */
+ void onDownloadsChanged();
+ }
+
+ private static final String TAG = "DownloadTracker";
+
+ private final Context context;
+ private final DataSource.Factory dataSourceFactory;
+ private final CopyOnWriteArraySet listeners;
+ private final HashMap downloads;
+ private final DownloadIndex downloadIndex;
+ private final DefaultTrackSelector.Parameters trackSelectorParameters;
+
+ @Nullable private StartDownloadDialogHelper startDownloadDialogHelper;
+
+ public DownloadTracker(
+ Context context, DataSource.Factory dataSourceFactory, DownloadManager downloadManager) {
+ this.context = context.getApplicationContext();
+ this.dataSourceFactory = dataSourceFactory;
+ listeners = new CopyOnWriteArraySet<>();
+ downloads = new HashMap<>();
+ downloadIndex = downloadManager.getDownloadIndex();
+ trackSelectorParameters = DownloadHelper.getDefaultTrackSelectorParameters(context);
+ downloadManager.addListener(new DownloadManagerListener());
+ loadDownloads();
+ }
+
+ public void addListener(Listener listener) {
+ listeners.add(listener);
+ }
+
+ public void removeListener(Listener listener) {
+ listeners.remove(listener);
+ }
+
+ public boolean isDownloaded(Uri uri) {
+ Download download = downloads.get(uri);
+ return download != null && download.state != Download.STATE_FAILED;
+ }
+
+ public DownloadRequest getDownloadRequest(Uri uri) {
+ Download download = downloads.get(uri);
+ return download != null && download.state != Download.STATE_FAILED ? download.request : null;
+ }
+
+ public void toggleDownload(
+ FragmentManager fragmentManager,
+ String name,
+ Uri uri,
+ String extension,
+ RenderersFactory renderersFactory) {
+ Download download = downloads.get(uri);
+ if (download != null) {
+ DownloadService.sendRemoveDownload(
+ context, DemoDownloadService.class, download.request.id, /* foreground= */ false);
+ } else {
+ if (startDownloadDialogHelper != null) {
+ startDownloadDialogHelper.release();
+ }
+ startDownloadDialogHelper =
+ new StartDownloadDialogHelper(
+ fragmentManager, getDownloadHelper(uri, extension, renderersFactory), name);
+ }
+ }
+
+ private void loadDownloads() {
+ try (DownloadCursor loadedDownloads = downloadIndex.getDownloads()) {
+ while (loadedDownloads.moveToNext()) {
+ Download download = loadedDownloads.getDownload();
+ downloads.put(download.request.uri, download);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to query downloads", e);
+ }
+ }
+
+ private DownloadHelper getDownloadHelper(
+ Uri uri, String extension, RenderersFactory renderersFactory) {
+ int type = Util.inferContentType(uri, extension);
+ switch (type) {
+ case C.TYPE_DASH:
+ return DownloadHelper.forDash(context, uri, dataSourceFactory, renderersFactory);
+ case C.TYPE_SS:
+ return DownloadHelper.forSmoothStreaming(context, uri, dataSourceFactory, renderersFactory);
+ case C.TYPE_HLS:
+ return DownloadHelper.forHls(context, uri, dataSourceFactory, renderersFactory);
+ case C.TYPE_OTHER:
+ return DownloadHelper.forProgressive(context, uri);
+ default:
+ throw new IllegalStateException("Unsupported type: " + type);
+ }
+ }
+
+ private class DownloadManagerListener implements DownloadManager.Listener {
+
+ @Override
+ public void onDownloadChanged(DownloadManager downloadManager, Download download) {
+ downloads.put(download.request.uri, download);
+ for (Listener listener : listeners) {
+ listener.onDownloadsChanged();
+ }
+ }
+
+ @Override
+ public void onDownloadRemoved(DownloadManager downloadManager, Download download) {
+ downloads.remove(download.request.uri);
+ for (Listener listener : listeners) {
+ listener.onDownloadsChanged();
+ }
+ }
+ }
+
+ private final class StartDownloadDialogHelper
+ implements DownloadHelper.Callback,
+ DialogInterface.OnClickListener,
+ DialogInterface.OnDismissListener {
+
+ private final FragmentManager fragmentManager;
+ private final DownloadHelper downloadHelper;
+ private final String name;
+
+ private TrackSelectionDialog trackSelectionDialog;
+ private MappedTrackInfo mappedTrackInfo;
+
+ public StartDownloadDialogHelper(
+ FragmentManager fragmentManager, DownloadHelper downloadHelper, String name) {
+ this.fragmentManager = fragmentManager;
+ this.downloadHelper = downloadHelper;
+ this.name = name;
+ downloadHelper.prepare(this);
+ }
+
+ public void release() {
+ downloadHelper.release();
+ if (trackSelectionDialog != null) {
+ trackSelectionDialog.dismiss();
+ }
+ }
+
+ // DownloadHelper.Callback implementation.
+
+ @Override
+ public void onPrepared(DownloadHelper helper) {
+ if (helper.getPeriodCount() == 0) {
+ Log.d(TAG, "No periods found. Downloading entire stream.");
+ startDownload();
+ downloadHelper.release();
+ return;
+ }
+ mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0);
+ if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) {
+ Log.d(TAG, "No dialog content. Downloading entire stream.");
+ startDownload();
+ downloadHelper.release();
+ return;
+ }
+ trackSelectionDialog =
+ TrackSelectionDialog.createForMappedTrackInfoAndParameters(
+ /* titleId= */ R.string.exo_download_description,
+ mappedTrackInfo,
+ trackSelectorParameters,
+ /* allowAdaptiveSelections =*/ false,
+ /* allowMultipleOverrides= */ true,
+ /* onClickListener= */ this,
+ /* onDismissListener= */ this);
+ trackSelectionDialog.show(fragmentManager, /* tag= */ null);
+ }
+
+ @Override
+ public void onPrepareError(DownloadHelper helper, IOException e) {
+ Toast.makeText(context, R.string.download_start_error, Toast.LENGTH_LONG).show();
+ Log.e(
+ TAG,
+ e instanceof DownloadHelper.LiveContentUnsupportedException
+ ? "Downloading live content unsupported"
+ : "Failed to start download",
+ e);
+ }
+
+ // DialogInterface.OnClickListener implementation.
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ for (int periodIndex = 0; periodIndex < downloadHelper.getPeriodCount(); periodIndex++) {
+ downloadHelper.clearTrackSelections(periodIndex);
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ if (!trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i)) {
+ downloadHelper.addTrackSelectionForSingleRenderer(
+ periodIndex,
+ /* rendererIndex= */ i,
+ trackSelectorParameters,
+ trackSelectionDialog.getOverrides(/* rendererIndex= */ i));
+ }
+ }
+ }
+ DownloadRequest downloadRequest = buildDownloadRequest();
+ if (downloadRequest.streamKeys.isEmpty()) {
+ // All tracks were deselected in the dialog. Don't start the download.
+ return;
+ }
+ startDownload(downloadRequest);
+ }
+
+ // DialogInterface.OnDismissListener implementation.
+
+ @Override
+ public void onDismiss(DialogInterface dialogInterface) {
+ trackSelectionDialog = null;
+ downloadHelper.release();
+ }
+
+ // Internal methods.
+
+ private void startDownload() {
+ startDownload(buildDownloadRequest());
+ }
+
+ private void startDownload(DownloadRequest downloadRequest) {
+ DownloadService.sendAddDownload(
+ context, DemoDownloadService.class, downloadRequest, /* foreground= */ false);
+ }
+
+ private DownloadRequest buildDownloadRequest() {
+ return downloadHelper.getDownloadRequest(Util.getUtf8Bytes(name));
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java
new file mode 100644
index 0000000000..6aa56341f5
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java
@@ -0,0 +1,764 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.media.MediaDrm;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Pair;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.View.OnClickListener;
+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.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.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.MediaDrmCallback;
+import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer.DecoderInitializationException;
+import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException;
+import com.google.android.exoplayer2.offline.DownloadHelper;
+import com.google.android.exoplayer2.offline.DownloadRequest;
+import com.google.android.exoplayer2.source.BehindLiveWindowException;
+import com.google.android.exoplayer2.source.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;
+import com.google.android.exoplayer2.source.dash.DashMediaSource;
+import com.google.android.exoplayer2.source.hls.HlsMediaSource;
+import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
+import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
+import com.google.android.exoplayer2.trackselection.RandomTrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.ui.DebugTextViewHelper;
+import com.google.android.exoplayer2.ui.PlayerControlView;
+import com.google.android.exoplayer2.ui.PlayerView;
+import com.google.android.exoplayer2.ui.spherical.SphericalGLSurfaceView;
+import com.google.android.exoplayer2.upstream.DataSource;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.util.ErrorMessageProvider;
+import com.google.android.exoplayer2.util.EventLogger;
+import com.google.android.exoplayer2.util.Util;
+import java.lang.reflect.Constructor;
+import java.net.CookieHandler;
+import java.net.CookieManager;
+import java.net.CookiePolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** An activity that plays media using {@link SimpleExoPlayer}. */
+public class PlayerActivity extends AppCompatActivity
+ implements OnClickListener, PlaybackPreparer, PlayerControlView.VisibilityListener {
+
+ // Activity extras.
+
+ public static final String SPHERICAL_STEREO_MODE_EXTRA = "spherical_stereo_mode";
+ public static final String SPHERICAL_STEREO_MODE_MONO = "mono";
+ public static final String SPHERICAL_STEREO_MODE_TOP_BOTTOM = "top_bottom";
+ public static final String SPHERICAL_STEREO_MODE_LEFT_RIGHT = "left_right";
+
+ // Actions.
+
+ public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW";
+ public static final String ACTION_VIEW_LIST =
+ "com.google.android.exoplayer.demo.action.VIEW_LIST";
+
+ // Player configuration extras.
+
+ public static final String ABR_ALGORITHM_EXTRA = "abr_algorithm";
+ public static final String ABR_ALGORITHM_DEFAULT = "default";
+ public static final String ABR_ALGORITHM_RANDOM = "random";
+
+ // Media item configuration extras.
+
+ public static final String URI_EXTRA = "uri";
+ public static final String EXTENSION_EXTRA = "extension";
+ public static final String IS_LIVE_EXTRA = "is_live";
+
+ public static final String DRM_SCHEME_EXTRA = "drm_scheme";
+ public static final String DRM_LICENSE_URL_EXTRA = "drm_license_url";
+ public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties";
+ public static final String DRM_SESSION_FOR_CLEAR_TYPES_EXTRA = "drm_session_for_clear_types";
+ public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session";
+ public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders";
+ public static final String TUNNELING_EXTRA = "tunneling";
+ public static final String AD_TAG_URI_EXTRA = "ad_tag_uri";
+ public static final String SUBTITLE_URI_EXTRA = "subtitle_uri";
+ public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type";
+ public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language";
+ // For backwards compatibility only.
+ public static final String DRM_SCHEME_UUID_EXTRA = "drm_scheme_uuid";
+
+ // 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";
+ private static final String KEY_AUTO_PLAY = "auto_play";
+
+ private static final CookieManager DEFAULT_COOKIE_MANAGER;
+ static {
+ DEFAULT_COOKIE_MANAGER = new CookieManager();
+ DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
+ }
+
+ private PlayerView playerView;
+ private LinearLayout debugRootView;
+ private Button selectTracksButton;
+ private TextView debugTextView;
+ private boolean isShowingTrackSelectionDialog;
+
+ private DataSource.Factory dataSourceFactory;
+ private SimpleExoPlayer player;
+ private List mediaSources;
+ private DefaultTrackSelector trackSelector;
+ private DefaultTrackSelector.Parameters trackSelectorParameters;
+ private DebugTextViewHelper debugViewHelper;
+ private TrackGroupArray lastSeenTrackGroupArray;
+
+ private boolean startAutoPlay;
+ private int startWindow;
+ private long startPosition;
+
+ // Fields used only for ad playback. The ads loader is loaded via reflection.
+
+ private AdsLoader adsLoader;
+ private Uri loadedAdTagUri;
+
+ // Activity lifecycle
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Intent intent = getIntent();
+ String sphericalStereoMode = intent.getStringExtra(SPHERICAL_STEREO_MODE_EXTRA);
+ if (sphericalStereoMode != null) {
+ setTheme(R.style.PlayerTheme_Spherical);
+ }
+ super.onCreate(savedInstanceState);
+ dataSourceFactory = buildDataSourceFactory();
+ if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) {
+ CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER);
+ }
+
+ setContentView(R.layout.player_activity);
+ debugRootView = findViewById(R.id.controls_root);
+ debugTextView = findViewById(R.id.debug_text_view);
+ selectTracksButton = findViewById(R.id.select_tracks_button);
+ selectTracksButton.setOnClickListener(this);
+
+ playerView = findViewById(R.id.player_view);
+ playerView.setControllerVisibilityListener(this);
+ playerView.setErrorMessageProvider(new PlayerErrorMessageProvider());
+ playerView.requestFocus();
+ if (sphericalStereoMode != null) {
+ int stereoMode;
+ if (SPHERICAL_STEREO_MODE_MONO.equals(sphericalStereoMode)) {
+ stereoMode = C.STEREO_MODE_MONO;
+ } else if (SPHERICAL_STEREO_MODE_TOP_BOTTOM.equals(sphericalStereoMode)) {
+ stereoMode = C.STEREO_MODE_TOP_BOTTOM;
+ } else if (SPHERICAL_STEREO_MODE_LEFT_RIGHT.equals(sphericalStereoMode)) {
+ stereoMode = C.STEREO_MODE_LEFT_RIGHT;
+ } else {
+ showToast(R.string.error_unrecognized_stereo_mode);
+ finish();
+ return;
+ }
+ ((SphericalGLSurfaceView) playerView.getVideoSurfaceView()).setDefaultStereoMode(stereoMode);
+ }
+
+ if (savedInstanceState != null) {
+ trackSelectorParameters = savedInstanceState.getParcelable(KEY_TRACK_SELECTOR_PARAMETERS);
+ startAutoPlay = savedInstanceState.getBoolean(KEY_AUTO_PLAY);
+ startWindow = savedInstanceState.getInt(KEY_WINDOW);
+ startPosition = savedInstanceState.getLong(KEY_POSITION);
+ } else {
+ DefaultTrackSelector.ParametersBuilder builder =
+ new DefaultTrackSelector.ParametersBuilder(/* context= */ this);
+ boolean tunneling = intent.getBooleanExtra(TUNNELING_EXTRA, false);
+ if (Util.SDK_INT >= 21 && tunneling) {
+ builder.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(/* context= */ this));
+ }
+ trackSelectorParameters = builder.build();
+ clearStartPosition();
+ }
+ }
+
+ @Override
+ public void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ releasePlayer();
+ releaseAdsLoader();
+ clearStartPosition();
+ setIntent(intent);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (Util.SDK_INT > 23) {
+ initializePlayer();
+ if (playerView != null) {
+ playerView.onResume();
+ }
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (Util.SDK_INT <= 23 || player == null) {
+ initializePlayer();
+ if (playerView != null) {
+ playerView.onResume();
+ }
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (Util.SDK_INT <= 23) {
+ if (playerView != null) {
+ playerView.onPause();
+ }
+ releasePlayer();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (Util.SDK_INT > 23) {
+ if (playerView != null) {
+ playerView.onPause();
+ }
+ releasePlayer();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ releaseAdsLoader();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ if (grantResults.length == 0) {
+ // Empty results are triggered if a permission is requested while another request was already
+ // pending and can be safely ignored in this case.
+ return;
+ }
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ initializePlayer();
+ } else {
+ showToast(R.string.storage_permission_denied);
+ finish();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ updateTrackSelectorParameters();
+ updateStartPosition();
+ outState.putParcelable(KEY_TRACK_SELECTOR_PARAMETERS, trackSelectorParameters);
+ outState.putBoolean(KEY_AUTO_PLAY, startAutoPlay);
+ outState.putInt(KEY_WINDOW, startWindow);
+ outState.putLong(KEY_POSITION, startPosition);
+ }
+
+ // Activity input
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ // See whether the player view wants to handle media or DPAD keys events.
+ return playerView.dispatchKeyEvent(event) || super.dispatchKeyEvent(event);
+ }
+
+ // OnClickListener methods
+
+ @Override
+ public void onClick(View view) {
+ if (view == selectTracksButton
+ && !isShowingTrackSelectionDialog
+ && TrackSelectionDialog.willHaveContent(trackSelector)) {
+ isShowingTrackSelectionDialog = true;
+ TrackSelectionDialog trackSelectionDialog =
+ TrackSelectionDialog.createForTrackSelector(
+ trackSelector,
+ /* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false);
+ trackSelectionDialog.show(getSupportFragmentManager(), /* tag= */ null);
+ }
+ }
+
+ // PlaybackControlView.PlaybackPreparer implementation
+
+ @Override
+ public void preparePlayback() {
+ player.retry();
+ }
+
+ // PlaybackControlView.VisibilityListener implementation
+
+ @Override
+ public void onVisibilityChange(int visibility) {
+ debugRootView.setVisibility(visibility);
+ }
+
+ // Internal methods
+
+ private void initializePlayer() {
+ if (player == null) {
+ Intent intent = getIntent();
+ mediaSources = createTopLevelMediaSources(intent);
+ if (mediaSources.isEmpty()) {
+ return;
+ }
+ TrackSelection.Factory trackSelectionFactory;
+ String abrAlgorithm = intent.getStringExtra(ABR_ALGORITHM_EXTRA);
+ if (abrAlgorithm == null || ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) {
+ trackSelectionFactory = new AdaptiveTrackSelection.Factory();
+ } else if (ABR_ALGORITHM_RANDOM.equals(abrAlgorithm)) {
+ trackSelectionFactory = new RandomTrackSelection.Factory();
+ } else {
+ showToast(R.string.error_unrecognized_abr_algorithm);
+ finish();
+ return;
+ }
+
+ boolean preferExtensionDecoders =
+ intent.getBooleanExtra(PREFER_EXTENSION_DECODERS_EXTRA, false);
+ RenderersFactory renderersFactory =
+ ((DemoApplication) getApplication()).buildRenderersFactory(preferExtensionDecoders);
+
+ trackSelector = new DefaultTrackSelector(/* context= */ this, trackSelectionFactory);
+ trackSelector.setParameters(trackSelectorParameters);
+ lastSeenTrackGroupArray = null;
+
+ player =
+ new SimpleExoPlayer.Builder(/* context= */ this, renderersFactory)
+ .setTrackSelector(trackSelector)
+ .build();
+ player.addListener(new PlayerEventListener());
+ player.setPlayWhenReady(startAutoPlay);
+ player.addAnalyticsListener(new EventLogger(trackSelector));
+ playerView.setPlayer(player);
+ playerView.setPlaybackPreparer(this);
+ debugViewHelper = new DebugTextViewHelper(player, debugTextView);
+ debugViewHelper.start();
+ if (adsLoader != null) {
+ adsLoader.setPlayer(player);
+ }
+ }
+ boolean haveStartPosition = startWindow != C.INDEX_UNSET;
+ if (haveStartPosition) {
+ player.seekTo(startWindow, startPosition);
+ }
+ player.setMediaSources(mediaSources, /* resetPosition= */ !haveStartPosition);
+ player.prepare();
+ updateButtonVisibility();
+ }
+
+ private List createTopLevelMediaSources(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 Collections.emptyList();
+ }
+
+ 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 Collections.emptyList();
+ }
+ if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, sample.uri)) {
+ // The player will be reinitialized if the permission is granted.
+ return Collections.emptyList();
+ }
+ }
+
+ List mediaSources = new ArrayList<>();
+ for (UriSample sample : samples) {
+ MediaSource mediaSource = createLeafMediaSource(sample);
+ if (mediaSource == null) {
+ continue;
+ }
+ Sample.SubtitleInfo subtitleInfo = sample.subtitleInfo;
+ if (subtitleInfo != null) {
+ if (Util.maybeRequestReadExternalStoragePermission(
+ /* activity= */ this, subtitleInfo.uri)) {
+ // The player will be reinitialized if the permission is granted.
+ return Collections.emptyList();
+ }
+ 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);
+ mediaSource = new MergingMediaSource(mediaSource, subtitleMediaSource);
+ }
+ mediaSources.add(mediaSource);
+ }
+ if (seenAdsTagUri && mediaSources.size() == 1) {
+ Uri adTagUri = samples[0].adTagUri;
+ if (!adTagUri.equals(loadedAdTagUri)) {
+ releaseAdsLoader();
+ loadedAdTagUri = adTagUri;
+ }
+ MediaSource adsMediaSource = createAdsMediaSource(mediaSources.get(0), adTagUri);
+ if (adsMediaSource != null) {
+ mediaSources.set(0, adsMediaSource);
+ } else {
+ showToast(R.string.ima_not_loaded);
+ }
+ } else if (seenAdsTagUri && mediaSources.size() > 1) {
+ showToast(R.string.unsupported_ads_in_concatenation);
+ releaseAdsLoader();
+ } else {
+ releaseAdsLoader();
+ }
+
+ return mediaSources;
+ }
+
+ @Nullable
+ private MediaSource createLeafMediaSource(UriSample parameters) {
+ Sample.DrmInfo drmInfo = parameters.drmInfo;
+ int errorStringId = R.string.error_drm_unknown;
+ DrmSessionManager 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)
+ .setUseDrmSessionsForClearContent(drmInfo.drmSessionForClearTypes)
+ .build(mediaDrmCallback);
+ }
+
+ if (drmSessionManager == null) {
+ showToast(errorStringId);
+ finish();
+ return null;
+ }
+
+ DownloadRequest downloadRequest =
+ ((DemoApplication) getApplication())
+ .getDownloadTracker()
+ .getDownloadRequest(parameters.uri);
+ if (downloadRequest != null) {
+ return DownloadHelper.createMediaSource(downloadRequest, dataSourceFactory);
+ }
+ return createLeafMediaSource(parameters.uri, parameters.extension, drmSessionManager);
+ }
+
+ private MediaSource createLeafMediaSource(
+ Uri uri, String extension, DrmSessionManager> drmSessionManager) {
+ @ContentType int type = Util.inferContentType(uri, extension);
+ switch (type) {
+ case C.TYPE_DASH:
+ return new DashMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(uri);
+ case C.TYPE_SS:
+ return new SsMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(uri);
+ case C.TYPE_HLS:
+ return new HlsMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(uri);
+ case C.TYPE_OTHER:
+ return new ProgressiveMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManager(drmSessionManager)
+ .createMediaSource(uri);
+ default:
+ throw new IllegalStateException("Unsupported type: " + type);
+ }
+ }
+
+ private HttpMediaDrmCallback createMediaDrmCallback(
+ String licenseUrl, String[] keyRequestPropertiesArray) {
+ HttpDataSource.Factory licenseDataSourceFactory =
+ ((DemoApplication) getApplication()).buildHttpDataSourceFactory();
+ HttpMediaDrmCallback drmCallback =
+ new HttpMediaDrmCallback(licenseUrl, licenseDataSourceFactory);
+ if (keyRequestPropertiesArray != null) {
+ for (int i = 0; i < keyRequestPropertiesArray.length - 1; i += 2) {
+ drmCallback.setKeyRequestProperty(keyRequestPropertiesArray[i],
+ keyRequestPropertiesArray[i + 1]);
+ }
+ }
+ return drmCallback;
+ }
+
+ private void releasePlayer() {
+ if (player != null) {
+ updateTrackSelectorParameters();
+ updateStartPosition();
+ debugViewHelper.stop();
+ debugViewHelper = null;
+ player.release();
+ player = null;
+ mediaSources = null;
+ trackSelector = null;
+ }
+ if (adsLoader != null) {
+ adsLoader.setPlayer(null);
+ }
+ }
+
+ private void releaseAdsLoader() {
+ if (adsLoader != null) {
+ adsLoader.release();
+ adsLoader = null;
+ loadedAdTagUri = null;
+ playerView.getOverlayFrameLayout().removeAllViews();
+ }
+ }
+
+ private void updateTrackSelectorParameters() {
+ if (trackSelector != null) {
+ trackSelectorParameters = trackSelector.getParameters();
+ }
+ }
+
+ private void updateStartPosition() {
+ if (player != null) {
+ startAutoPlay = player.getPlayWhenReady();
+ startWindow = player.getCurrentWindowIndex();
+ startPosition = Math.max(0, player.getContentPosition());
+ }
+ }
+
+ private void clearStartPosition() {
+ startAutoPlay = true;
+ startWindow = C.INDEX_UNSET;
+ startPosition = C.TIME_UNSET;
+ }
+
+ /** Returns a new DataSource factory. */
+ private DataSource.Factory buildDataSourceFactory() {
+ return ((DemoApplication) getApplication()).buildDataSourceFactory();
+ }
+
+ /** Returns an ads media source, reusing the ads loader if one exists. */
+ @Nullable
+ private MediaSource createAdsMediaSource(MediaSource mediaSource, Uri adTagUri) {
+ // Load the extension source using reflection so the demo app doesn't have to depend on it.
+ // The ads loader is reused for multiple playbacks, so that ad playback can resume.
+ try {
+ Class> loaderClass = Class.forName("com.google.android.exoplayer2.ext.ima.ImaAdsLoader");
+ if (adsLoader == null) {
+ // Full class names used so the lint rule triggers should any of the classes move.
+ // LINT.IfChange
+ Constructor extends AdsLoader> loaderConstructor =
+ loaderClass
+ .asSubclass(AdsLoader.class)
+ .getConstructor(android.content.Context.class, android.net.Uri.class);
+ // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
+ adsLoader = loaderConstructor.newInstance(this, adTagUri);
+ }
+ MediaSourceFactory adMediaSourceFactory =
+ new MediaSourceFactory() {
+
+ private DrmSessionManager> drmSessionManager =
+ DrmSessionManager.getDummyDrmSessionManager();
+
+ @Override
+ public MediaSourceFactory setDrmSessionManager(DrmSessionManager> drmSessionManager) {
+ this.drmSessionManager = drmSessionManager;
+ return this;
+ }
+
+ @Override
+ public MediaSource createMediaSource(Uri uri) {
+ return PlayerActivity.this.createLeafMediaSource(
+ uri, /* extension=*/ null, drmSessionManager);
+ }
+
+ @Override
+ public int[] getSupportedTypes() {
+ return new int[] {C.TYPE_DASH, C.TYPE_SS, C.TYPE_HLS, C.TYPE_OTHER};
+ }
+ };
+ return new AdsMediaSource(mediaSource, adMediaSourceFactory, adsLoader, playerView);
+ } catch (ClassNotFoundException e) {
+ // IMA extension not loaded.
+ return null;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // User controls
+
+ private void updateButtonVisibility() {
+ selectTracksButton.setEnabled(
+ player != null && TrackSelectionDialog.willHaveContent(trackSelector));
+ }
+
+ private void showControls() {
+ debugRootView.setVisibility(View.VISIBLE);
+ }
+
+ private void showToast(int messageId) {
+ showToast(getString(messageId));
+ }
+
+ private void showToast(String message) {
+ Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show();
+ }
+
+ private static boolean isBehindLiveWindow(ExoPlaybackException e) {
+ if (e.type != ExoPlaybackException.TYPE_SOURCE) {
+ return false;
+ }
+ Throwable cause = e.getSourceException();
+ while (cause != null) {
+ if (cause instanceof BehindLiveWindowException) {
+ return true;
+ }
+ cause = cause.getCause();
+ }
+ return false;
+ }
+
+ private class PlayerEventListener implements Player.EventListener {
+
+ @Override
+ public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
+ if (playbackState == Player.STATE_ENDED) {
+ showControls();
+ }
+ updateButtonVisibility();
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException e) {
+ if (isBehindLiveWindow(e)) {
+ clearStartPosition();
+ initializePlayer();
+ } else {
+ updateButtonVisibility();
+ showControls();
+ }
+ }
+
+ @Override
+ @SuppressWarnings("ReferenceEquality")
+ public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
+ updateButtonVisibility();
+ if (trackGroups != lastSeenTrackGroupArray) {
+ MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
+ if (mappedTrackInfo != null) {
+ if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_VIDEO)
+ == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
+ showToast(R.string.error_unsupported_video);
+ }
+ if (mappedTrackInfo.getTypeSupport(C.TRACK_TYPE_AUDIO)
+ == MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) {
+ showToast(R.string.error_unsupported_audio);
+ }
+ }
+ lastSeenTrackGroupArray = trackGroups;
+ }
+ }
+ }
+
+ private class PlayerErrorMessageProvider implements ErrorMessageProvider {
+
+ @Override
+ public Pair getErrorMessage(ExoPlaybackException e) {
+ String errorString = getString(R.string.error_generic);
+ if (e.type == ExoPlaybackException.TYPE_RENDERER) {
+ Exception cause = e.getRendererException();
+ if (cause instanceof DecoderInitializationException) {
+ // Special case for decoder initialization failures.
+ DecoderInitializationException decoderInitializationException =
+ (DecoderInitializationException) cause;
+ if (decoderInitializationException.codecInfo == null) {
+ if (decoderInitializationException.getCause() instanceof DecoderQueryException) {
+ errorString = getString(R.string.error_querying_decoders);
+ } else if (decoderInitializationException.secureDecoderRequired) {
+ errorString =
+ getString(
+ R.string.error_no_secure_decoder, decoderInitializationException.mimeType);
+ } else {
+ errorString =
+ getString(R.string.error_no_decoder, decoderInitializationException.mimeType);
+ }
+ } else {
+ errorString =
+ getString(
+ R.string.error_instantiating_decoder,
+ decoderInitializationException.codecInfo.name);
+ }
+ }
+ }
+ return Pair.create(0, errorString);
+ }
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java
new file mode 100644
index 0000000000..0bf0d2a80c
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/Sample.java
@@ -0,0 +1,277 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import static com.google.android.exoplayer2.demo.PlayerActivity.ACTION_VIEW_LIST;
+import static com.google.android.exoplayer2.demo.PlayerActivity.AD_TAG_URI_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_KEY_REQUEST_PROPERTIES_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_LICENSE_URL_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_MULTI_SESSION_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SCHEME_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SCHEME_UUID_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.DRM_SESSION_FOR_CLEAR_TYPES_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.EXTENSION_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.IS_LIVE_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_LANGUAGE_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_MIME_TYPE_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.SUBTITLE_URI_EXTRA;
+import static com.google.android.exoplayer2.demo.PlayerActivity.URI_EXTRA;
+
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.HashSet;
+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);
+ String[] drmSessionForClearTypesExtra =
+ intent.getStringArrayExtra(DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix);
+ int[] drmSessionForClearTypes = toTrackTypeArray(drmSessionForClearTypesExtra);
+ boolean drmMultiSession =
+ intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false);
+ return new DrmInfo(
+ drmScheme,
+ drmLicenseUrl,
+ keyRequestPropertiesArray,
+ drmSessionForClearTypes,
+ drmMultiSession);
+ }
+
+ public final UUID drmScheme;
+ public final String drmLicenseUrl;
+ public final String[] drmKeyRequestProperties;
+ public final int[] drmSessionForClearTypes;
+ public final boolean drmMultiSession;
+
+ public DrmInfo(
+ UUID drmScheme,
+ String drmLicenseUrl,
+ String[] drmKeyRequestProperties,
+ int[] drmSessionForClearTypes,
+ boolean drmMultiSession) {
+ this.drmScheme = drmScheme;
+ this.drmLicenseUrl = drmLicenseUrl;
+ this.drmKeyRequestProperties = drmKeyRequestProperties;
+ this.drmSessionForClearTypes = drmSessionForClearTypes;
+ this.drmMultiSession = drmMultiSession;
+ }
+
+ public void addToIntent(Intent intent, String extrasKeySuffix) {
+ Assertions.checkNotNull(intent);
+ intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmScheme.toString());
+ intent.putExtra(DRM_LICENSE_URL_EXTRA + extrasKeySuffix, drmLicenseUrl);
+ intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties);
+ ArrayList typeStrings = new ArrayList<>();
+ for (int type : drmSessionForClearTypes) {
+ // Only audio and video are supported.
+ typeStrings.add(type == C.TRACK_TYPE_AUDIO ? "audio" : "video");
+ }
+ intent.putExtra(
+ DRM_SESSION_FOR_CLEAR_TYPES_EXTRA + extrasKeySuffix, typeStrings.toArray(new String[0]));
+ intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmMultiSession);
+ }
+ }
+
+ public static final class SubtitleInfo {
+
+ @Nullable
+ public static SubtitleInfo createFromIntent(Intent intent, String extrasKeySuffix) {
+ if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) {
+ return null;
+ }
+ return new SubtitleInfo(
+ Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)),
+ intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix),
+ intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix));
+ }
+
+ public final Uri uri;
+ public final String mimeType;
+ @Nullable public final String language;
+
+ public SubtitleInfo(Uri uri, String mimeType, @Nullable String language) {
+ this.uri = Assertions.checkNotNull(uri);
+ this.mimeType = Assertions.checkNotNull(mimeType);
+ this.language = language;
+ }
+
+ public void addToIntent(Intent intent, String extrasKeySuffix) {
+ intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, uri.toString());
+ intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, mimeType);
+ intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, language);
+ }
+ }
+
+ public static int[] toTrackTypeArray(@Nullable String[] trackTypeStringsArray) {
+ if (trackTypeStringsArray == null) {
+ return new int[0];
+ }
+ HashSet trackTypes = new HashSet<>();
+ for (String trackTypeString : trackTypeStringsArray) {
+ switch (Util.toLowerInvariant(trackTypeString)) {
+ case "audio":
+ trackTypes.add(C.TRACK_TYPE_AUDIO);
+ break;
+ case "video":
+ trackTypes.add(C.TRACK_TYPE_VIDEO);
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid track type: " + trackTypeString);
+ }
+ }
+ return Util.toArray(new ArrayList<>(trackTypes));
+ }
+
+ public static Sample createFromIntent(Intent intent) {
+ if (ACTION_VIEW_LIST.equals(intent.getAction())) {
+ ArrayList intentUris = new ArrayList<>();
+ int index = 0;
+ while (intent.hasExtra(URI_EXTRA + "_" + index)) {
+ intentUris.add(intent.getStringExtra(URI_EXTRA + "_" + index));
+ index++;
+ }
+ UriSample[] children = new UriSample[intentUris.size()];
+ for (int i = 0; i < children.length; i++) {
+ Uri uri = Uri.parse(intentUris.get(i));
+ children[i] = UriSample.createFromIntent(uri, intent, /* extrasKeySuffix= */ "_" + i);
+ }
+ return new PlaylistSample(/* name= */ null, children);
+ } else {
+ return UriSample.createFromIntent(intent.getData(), intent, /* extrasKeySuffix= */ "");
+ }
+ }
+
+ @Nullable public final String name;
+
+ public Sample(String name) {
+ this.name = name;
+ }
+
+ public abstract void addToIntent(Intent intent);
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java
new file mode 100644
index 0000000000..66bf4bad5a
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/SampleChooserActivity.java
@@ -0,0 +1,553 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.AssetManager;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.util.JsonReader;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.ExpandableListView;
+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;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.DefaultDataSource;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/** An activity for selecting from a list of media samples. */
+public class SampleChooserActivity extends AppCompatActivity
+ implements DownloadTracker.Listener, OnChildClickListener {
+
+ private static final String TAG = "SampleChooserActivity";
+
+ private boolean useExtensionRenderers;
+ private DownloadTracker downloadTracker;
+ private SampleAdapter sampleAdapter;
+ private MenuItem preferExtensionDecodersMenuItem;
+ private MenuItem randomAbrMenuItem;
+ private MenuItem tunnelingMenuItem;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.sample_chooser_activity);
+ sampleAdapter = new SampleAdapter();
+ ExpandableListView sampleListView = findViewById(R.id.sample_list);
+ sampleListView.setAdapter(sampleAdapter);
+ sampleListView.setOnChildClickListener(this);
+
+ Intent intent = getIntent();
+ String dataUri = intent.getDataString();
+ String[] uris;
+ if (dataUri != null) {
+ uris = new String[] {dataUri};
+ } else {
+ ArrayList uriList = new ArrayList<>();
+ AssetManager assetManager = getAssets();
+ try {
+ for (String asset : assetManager.list("")) {
+ if (asset.endsWith(".exolist.json")) {
+ uriList.add("asset:///" + asset);
+ }
+ }
+ } catch (IOException e) {
+ Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG)
+ .show();
+ }
+ uris = new String[uriList.size()];
+ uriList.toArray(uris);
+ Arrays.sort(uris);
+ }
+
+ DemoApplication application = (DemoApplication) getApplication();
+ useExtensionRenderers = application.useExtensionRenderers();
+ downloadTracker = application.getDownloadTracker();
+ SampleListLoader loaderTask = new SampleListLoader();
+ loaderTask.execute(uris);
+
+ // Start the download service if it should be running but it's not currently.
+ // Starting the service in the foreground causes notification flicker if there is no scheduled
+ // action. Starting it in the background throws an exception if the app is in the background too
+ // (e.g. if device screen is locked).
+ try {
+ DownloadService.start(this, DemoDownloadService.class);
+ } catch (IllegalStateException e) {
+ DownloadService.startForeground(this, DemoDownloadService.class);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.sample_chooser_menu, menu);
+ preferExtensionDecodersMenuItem = menu.findItem(R.id.prefer_extension_decoders);
+ preferExtensionDecodersMenuItem.setVisible(useExtensionRenderers);
+ randomAbrMenuItem = menu.findItem(R.id.random_abr);
+ tunnelingMenuItem = menu.findItem(R.id.tunneling);
+ if (Util.SDK_INT < 21) {
+ tunnelingMenuItem.setEnabled(false);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ item.setChecked(!item.isChecked());
+ return true;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ downloadTracker.addListener(this);
+ sampleAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void onStop() {
+ downloadTracker.removeListener(this);
+ super.onStop();
+ }
+
+ @Override
+ public void onDownloadsChanged() {
+ sampleAdapter.notifyDataSetChanged();
+ }
+
+ private void onSampleGroups(final List groups, boolean sawError) {
+ if (sawError) {
+ Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG)
+ .show();
+ }
+ sampleAdapter.setSampleGroups(groups);
+ }
+
+ @Override
+ public boolean onChildClick(
+ ExpandableListView parent, View view, int groupPosition, int childPosition, long id) {
+ Sample sample = (Sample) view.getTag();
+ 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;
+ }
+
+ private void onSampleDownloadButtonClicked(Sample sample) {
+ int downloadUnsupportedStringId = getDownloadUnsupportedStringId(sample);
+ if (downloadUnsupportedStringId != 0) {
+ Toast.makeText(getApplicationContext(), downloadUnsupportedStringId, Toast.LENGTH_LONG)
+ .show();
+ } else {
+ UriSample uriSample = (UriSample) sample;
+ RenderersFactory renderersFactory =
+ ((DemoApplication) getApplication())
+ .buildRenderersFactory(isNonNullAndChecked(preferExtensionDecodersMenuItem));
+ downloadTracker.toggleDownload(
+ getSupportFragmentManager(),
+ sample.name,
+ uriSample.uri,
+ uriSample.extension,
+ renderersFactory);
+ }
+ }
+
+ private int getDownloadUnsupportedStringId(Sample sample) {
+ if (sample instanceof PlaylistSample) {
+ return R.string.download_playlist_unsupported;
+ }
+ UriSample uriSample = (UriSample) sample;
+ if (uriSample.drmInfo != null) {
+ return R.string.download_drm_unsupported;
+ }
+ if (uriSample.isLive) {
+ return R.string.download_live_unsupported;
+ }
+ if (uriSample.adTagUri != null) {
+ return R.string.download_ads_unsupported;
+ }
+ String scheme = uriSample.uri.getScheme();
+ if (!("http".equals(scheme) || "https".equals(scheme))) {
+ return R.string.download_scheme_unsupported;
+ }
+ return 0;
+ }
+
+ private static boolean isNonNullAndChecked(@Nullable MenuItem menuItem) {
+ // Temporary workaround for layouts that do not inflate the options menu.
+ return menuItem != null && menuItem.isChecked();
+ }
+
+ private final class SampleListLoader extends AsyncTask> {
+
+ private boolean sawError;
+
+ @Override
+ protected List doInBackground(String... uris) {
+ List result = new ArrayList<>();
+ Context context = getApplicationContext();
+ String userAgent = Util.getUserAgent(context, "ExoPlayerDemo");
+ DataSource dataSource =
+ new DefaultDataSource(context, userAgent, /* allowCrossProtocolRedirects= */ false);
+ for (String uri : uris) {
+ DataSpec dataSpec = new DataSpec(Uri.parse(uri));
+ InputStream inputStream = new DataSourceInputStream(dataSource, dataSpec);
+ try {
+ readSampleGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result);
+ } catch (Exception e) {
+ Log.e(TAG, "Error loading sample list: " + uri, e);
+ sawError = true;
+ } finally {
+ Util.closeQuietly(dataSource);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ protected void onPostExecute(List result) {
+ onSampleGroups(result, sawError);
+ }
+
+ private void readSampleGroups(JsonReader reader, List groups) throws IOException {
+ reader.beginArray();
+ while (reader.hasNext()) {
+ readSampleGroup(reader, groups);
+ }
+ reader.endArray();
+ }
+
+ private void readSampleGroup(JsonReader reader, List groups) throws IOException {
+ String groupName = "";
+ ArrayList samples = new ArrayList<>();
+
+ reader.beginObject();
+ while (reader.hasNext()) {
+ String name = reader.nextName();
+ switch (name) {
+ case "name":
+ groupName = reader.nextString();
+ break;
+ case "samples":
+ reader.beginArray();
+ while (reader.hasNext()) {
+ samples.add(readEntry(reader, false));
+ }
+ reader.endArray();
+ break;
+ case "_comment":
+ reader.nextString(); // Ignore.
+ break;
+ default:
+ throw new ParserException("Unsupported name: " + name);
+ }
+ }
+ reader.endObject();
+
+ SampleGroup group = getGroup(groupName, groups);
+ group.samples.addAll(samples);
+ }
+
+ private Sample readEntry(JsonReader reader, boolean insidePlaylist) throws IOException {
+ String sampleName = null;
+ Uri uri = null;
+ String extension = null;
+ boolean isLive = false;
+ String drmScheme = null;
+ String drmLicenseUrl = null;
+ String[] drmKeyRequestProperties = null;
+ String[] drmSessionForClearTypes = null;
+ boolean drmMultiSession = false;
+ ArrayList playlistSamples = null;
+ String adTagUri = null;
+ String sphericalStereoMode = null;
+ List subtitleInfos = new ArrayList<>();
+ Uri subtitleUri = null;
+ String subtitleMimeType = null;
+ String subtitleLanguage = null;
+
+ reader.beginObject();
+ while (reader.hasNext()) {
+ String name = reader.nextName();
+ switch (name) {
+ case "name":
+ sampleName = reader.nextString();
+ break;
+ case "uri":
+ uri = Uri.parse(reader.nextString());
+ break;
+ case "extension":
+ extension = reader.nextString();
+ break;
+ case "drm_scheme":
+ drmScheme = reader.nextString();
+ break;
+ case "is_live":
+ isLive = reader.nextBoolean();
+ break;
+ case "drm_license_url":
+ drmLicenseUrl = reader.nextString();
+ break;
+ case "drm_key_request_properties":
+ ArrayList drmKeyRequestPropertiesList = new ArrayList<>();
+ reader.beginObject();
+ while (reader.hasNext()) {
+ drmKeyRequestPropertiesList.add(reader.nextName());
+ drmKeyRequestPropertiesList.add(reader.nextString());
+ }
+ reader.endObject();
+ drmKeyRequestProperties = drmKeyRequestPropertiesList.toArray(new String[0]);
+ break;
+ case "drm_session_for_clear_types":
+ ArrayList drmSessionForClearTypesList = new ArrayList<>();
+ reader.beginArray();
+ while (reader.hasNext()) {
+ drmSessionForClearTypesList.add(reader.nextString());
+ }
+ reader.endArray();
+ drmSessionForClearTypes = drmSessionForClearTypesList.toArray(new String[0]);
+ break;
+ case "drm_multi_session":
+ drmMultiSession = reader.nextBoolean();
+ break;
+ case "playlist":
+ Assertions.checkState(!insidePlaylist, "Invalid nesting of playlists");
+ playlistSamples = new ArrayList<>();
+ reader.beginArray();
+ while (reader.hasNext()) {
+ playlistSamples.add((UriSample) readEntry(reader, /* insidePlaylist= */ true));
+ }
+ reader.endArray();
+ break;
+ case "ad_tag_uri":
+ adTagUri = reader.nextString();
+ break;
+ case "spherical_stereo_mode":
+ Assertions.checkState(
+ !insidePlaylist, "Invalid attribute on nested item: spherical_stereo_mode");
+ sphericalStereoMode = reader.nextString();
+ 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);
+ }
+ }
+ reader.endObject();
+ DrmInfo drmInfo =
+ drmScheme == null
+ ? null
+ : new DrmInfo(
+ Util.getDrmUuid(drmScheme),
+ drmLicenseUrl,
+ drmKeyRequestProperties,
+ Sample.toTrackTypeArray(drmSessionForClearTypes),
+ drmMultiSession);
+ Sample.SubtitleInfo subtitleInfo =
+ subtitleUri == null
+ ? null
+ : new Sample.SubtitleInfo(
+ 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, playlistSamplesArray);
+ } else {
+ return new UriSample(
+ sampleName,
+ uri,
+ extension,
+ isLive,
+ drmInfo,
+ adTagUri != null ? Uri.parse(adTagUri) : null,
+ sphericalStereoMode,
+ subtitleInfo);
+ }
+ }
+
+ private SampleGroup getGroup(String groupName, List groups) {
+ for (int i = 0; i < groups.size(); i++) {
+ if (Util.areEqual(groupName, groups.get(i).title)) {
+ return groups.get(i);
+ }
+ }
+ SampleGroup group = new SampleGroup(groupName);
+ groups.add(group);
+ return group;
+ }
+
+ }
+
+ private final class SampleAdapter extends BaseExpandableListAdapter implements OnClickListener {
+
+ private List sampleGroups;
+
+ public SampleAdapter() {
+ sampleGroups = Collections.emptyList();
+ }
+
+ public void setSampleGroups(List sampleGroups) {
+ this.sampleGroups = sampleGroups;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public Sample getChild(int groupPosition, int childPosition) {
+ return getGroup(groupPosition).samples.get(childPosition);
+ }
+
+ @Override
+ public long getChildId(int groupPosition, int childPosition) {
+ return childPosition;
+ }
+
+ @Override
+ public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
+ View convertView, ViewGroup parent) {
+ View view = convertView;
+ if (view == null) {
+ view = getLayoutInflater().inflate(R.layout.sample_list_item, parent, false);
+ View downloadButton = view.findViewById(R.id.download_button);
+ downloadButton.setOnClickListener(this);
+ downloadButton.setFocusable(false);
+ }
+ initializeChildView(view, getChild(groupPosition, childPosition));
+ return view;
+ }
+
+ @Override
+ public int getChildrenCount(int groupPosition) {
+ return getGroup(groupPosition).samples.size();
+ }
+
+ @Override
+ public SampleGroup getGroup(int groupPosition) {
+ return sampleGroups.get(groupPosition);
+ }
+
+ @Override
+ public long getGroupId(int groupPosition) {
+ return groupPosition;
+ }
+
+ @Override
+ public View getGroupView(int groupPosition, boolean isExpanded, View convertView,
+ ViewGroup parent) {
+ View view = convertView;
+ if (view == null) {
+ view =
+ getLayoutInflater()
+ .inflate(android.R.layout.simple_expandable_list_item_1, parent, false);
+ }
+ ((TextView) view).setText(getGroup(groupPosition).title);
+ return view;
+ }
+
+ @Override
+ public int getGroupCount() {
+ return sampleGroups.size();
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+
+ @Override
+ public boolean isChildSelectable(int groupPosition, int childPosition) {
+ return true;
+ }
+
+ @Override
+ public void onClick(View view) {
+ onSampleDownloadButtonClicked((Sample) view.getTag());
+ }
+
+ private void initializeChildView(View view, Sample sample) {
+ view.setTag(sample);
+ TextView sampleTitle = view.findViewById(R.id.sample_title);
+ sampleTitle.setText(sample.name);
+
+ boolean canDownload = getDownloadUnsupportedStringId(sample) == 0;
+ boolean isDownloaded = canDownload && downloadTracker.isDownloaded(((UriSample) sample).uri);
+ ImageButton downloadButton = view.findViewById(R.id.download_button);
+ downloadButton.setTag(sample);
+ downloadButton.setColorFilter(
+ canDownload ? (isDownloaded ? 0xFF42A5F5 : 0xFFBDBDBD) : 0xFF666666);
+ downloadButton.setImageResource(
+ isDownloaded ? R.drawable.ic_download_done : R.drawable.ic_download);
+ }
+ }
+
+ private static final class SampleGroup {
+
+ public final String title;
+ public final List samples;
+
+ public SampleGroup(String title) {
+ this.title = title;
+ this.samples = new ArrayList<>();
+ }
+
+ }
+}
diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java
new file mode 100644
index 0000000000..9e8009388e
--- /dev/null
+++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/TrackSelectionDialog.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.demo;
+
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.res.Resources;
+import android.os.Bundle;
+import 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;
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride;
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
+import com.google.android.exoplayer2.ui.TrackSelectionView;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.material.tabs.TabLayout;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Dialog to select tracks. */
+public final class TrackSelectionDialog extends DialogFragment {
+
+ private final SparseArray tabFragments;
+ private final ArrayList tabTrackTypes;
+
+ private int titleId;
+ private DialogInterface.OnClickListener onClickListener;
+ private DialogInterface.OnDismissListener onDismissListener;
+
+ /**
+ * Returns whether a track selection dialog will have content to display if initialized with the
+ * specified {@link DefaultTrackSelector} in its current state.
+ */
+ public static boolean willHaveContent(DefaultTrackSelector trackSelector) {
+ MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo();
+ return mappedTrackInfo != null && willHaveContent(mappedTrackInfo);
+ }
+
+ /**
+ * Returns whether a track selection dialog will have content to display if initialized with the
+ * specified {@link MappedTrackInfo}.
+ */
+ public static boolean willHaveContent(MappedTrackInfo mappedTrackInfo) {
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ if (showTabForRenderer(mappedTrackInfo, i)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Creates a dialog for a given {@link DefaultTrackSelector}, whose parameters will be
+ * automatically updated when tracks are selected.
+ *
+ * @param trackSelector The {@link DefaultTrackSelector}.
+ * @param onDismissListener A {@link DialogInterface.OnDismissListener} to call when the dialog is
+ * dismissed.
+ */
+ public static TrackSelectionDialog createForTrackSelector(
+ DefaultTrackSelector trackSelector, DialogInterface.OnDismissListener onDismissListener) {
+ MappedTrackInfo mappedTrackInfo =
+ Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo());
+ TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog();
+ DefaultTrackSelector.Parameters parameters = trackSelector.getParameters();
+ trackSelectionDialog.init(
+ /* titleId= */ R.string.track_selection_title,
+ mappedTrackInfo,
+ /* initialParameters = */ parameters,
+ /* allowAdaptiveSelections =*/ true,
+ /* allowMultipleOverrides= */ false,
+ /* onClickListener= */ (dialog, which) -> {
+ DefaultTrackSelector.ParametersBuilder builder = parameters.buildUpon();
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ builder
+ .clearSelectionOverrides(/* rendererIndex= */ i)
+ .setRendererDisabled(
+ /* rendererIndex= */ i,
+ trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i));
+ List overrides =
+ trackSelectionDialog.getOverrides(/* rendererIndex= */ i);
+ if (!overrides.isEmpty()) {
+ builder.setSelectionOverride(
+ /* rendererIndex= */ i,
+ mappedTrackInfo.getTrackGroups(/* rendererIndex= */ i),
+ overrides.get(0));
+ }
+ }
+ trackSelector.setParameters(builder);
+ },
+ onDismissListener);
+ return trackSelectionDialog;
+ }
+
+ /**
+ * Creates a dialog for given {@link MappedTrackInfo} and {@link DefaultTrackSelector.Parameters}.
+ *
+ * @param titleId The resource id of the dialog title.
+ * @param mappedTrackInfo The {@link MappedTrackInfo} to display.
+ * @param initialParameters The {@link DefaultTrackSelector.Parameters} describing the initial
+ * track selection.
+ * @param allowAdaptiveSelections Whether adaptive selections (consisting of more than one track)
+ * can be made.
+ * @param allowMultipleOverrides Whether tracks from multiple track groups can be selected.
+ * @param onClickListener {@link DialogInterface.OnClickListener} called when tracks are selected.
+ * @param onDismissListener {@link DialogInterface.OnDismissListener} called when the dialog is
+ * dismissed.
+ */
+ public static TrackSelectionDialog createForMappedTrackInfoAndParameters(
+ int titleId,
+ MappedTrackInfo mappedTrackInfo,
+ DefaultTrackSelector.Parameters initialParameters,
+ boolean allowAdaptiveSelections,
+ boolean allowMultipleOverrides,
+ DialogInterface.OnClickListener onClickListener,
+ DialogInterface.OnDismissListener onDismissListener) {
+ TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog();
+ trackSelectionDialog.init(
+ titleId,
+ mappedTrackInfo,
+ initialParameters,
+ allowAdaptiveSelections,
+ allowMultipleOverrides,
+ onClickListener,
+ onDismissListener);
+ return trackSelectionDialog;
+ }
+
+ public TrackSelectionDialog() {
+ tabFragments = new SparseArray<>();
+ tabTrackTypes = new ArrayList<>();
+ // Retain instance across activity re-creation to prevent losing access to init data.
+ setRetainInstance(true);
+ }
+
+ private void init(
+ int titleId,
+ MappedTrackInfo mappedTrackInfo,
+ DefaultTrackSelector.Parameters initialParameters,
+ boolean allowAdaptiveSelections,
+ boolean allowMultipleOverrides,
+ DialogInterface.OnClickListener onClickListener,
+ DialogInterface.OnDismissListener onDismissListener) {
+ this.titleId = titleId;
+ this.onClickListener = onClickListener;
+ this.onDismissListener = onDismissListener;
+ for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) {
+ if (showTabForRenderer(mappedTrackInfo, i)) {
+ int trackType = mappedTrackInfo.getRendererType(/* rendererIndex= */ i);
+ TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i);
+ TrackSelectionViewFragment tabFragment = new TrackSelectionViewFragment();
+ tabFragment.init(
+ mappedTrackInfo,
+ /* rendererIndex= */ i,
+ initialParameters.getRendererDisabled(/* rendererIndex= */ i),
+ initialParameters.getSelectionOverride(/* rendererIndex= */ i, trackGroupArray),
+ allowAdaptiveSelections,
+ allowMultipleOverrides);
+ tabFragments.put(i, tabFragment);
+ tabTrackTypes.add(trackType);
+ }
+ }
+ }
+
+ /**
+ * Returns whether a renderer is disabled.
+ *
+ * @param rendererIndex Renderer index.
+ * @return Whether the renderer is disabled.
+ */
+ public boolean getIsDisabled(int rendererIndex) {
+ TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex);
+ return rendererView != null && rendererView.isDisabled;
+ }
+
+ /**
+ * Returns the list of selected track selection overrides for the specified renderer. There will
+ * be at most one override for each track group.
+ *
+ * @param rendererIndex Renderer index.
+ * @return The list of track selection overrides for this renderer.
+ */
+ public List getOverrides(int rendererIndex) {
+ TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex);
+ return rendererView == null ? Collections.emptyList() : rendererView.overrides;
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ // We need to own the view to let tab layout work correctly on all API levels. We can't use
+ // AlertDialog because it owns the view itself, so we use AppCompatDialog instead, themed using
+ // the AlertDialog theme overlay with force-enabled title.
+ AppCompatDialog dialog =
+ new AppCompatDialog(getActivity(), R.style.TrackSelectionDialogThemeOverlay);
+ dialog.setTitle(titleId);
+ return dialog;
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ super.onDismiss(dialog);
+ onDismissListener.onDismiss(dialog);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+
+ View dialogView = inflater.inflate(R.layout.track_selection_dialog, container, false);
+ TabLayout tabLayout = dialogView.findViewById(R.id.track_selection_dialog_tab_layout);
+ ViewPager viewPager = dialogView.findViewById(R.id.track_selection_dialog_view_pager);
+ Button cancelButton = dialogView.findViewById(R.id.track_selection_dialog_cancel_button);
+ Button okButton = dialogView.findViewById(R.id.track_selection_dialog_ok_button);
+ viewPager.setAdapter(new FragmentAdapter(getChildFragmentManager()));
+ tabLayout.setupWithViewPager(viewPager);
+ tabLayout.setVisibility(tabFragments.size() > 1 ? View.VISIBLE : View.GONE);
+ cancelButton.setOnClickListener(view -> dismiss());
+ okButton.setOnClickListener(
+ view -> {
+ onClickListener.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE);
+ dismiss();
+ });
+ return dialogView;
+ }
+
+ private static boolean showTabForRenderer(MappedTrackInfo mappedTrackInfo, int rendererIndex) {
+ TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex);
+ if (trackGroupArray.length == 0) {
+ return false;
+ }
+ int trackType = mappedTrackInfo.getRendererType(rendererIndex);
+ return isSupportedTrackType(trackType);
+ }
+
+ private static boolean isSupportedTrackType(int trackType) {
+ switch (trackType) {
+ case C.TRACK_TYPE_VIDEO:
+ case C.TRACK_TYPE_AUDIO:
+ case C.TRACK_TYPE_TEXT:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static String getTrackTypeString(Resources resources, int trackType) {
+ switch (trackType) {
+ case C.TRACK_TYPE_VIDEO:
+ return resources.getString(R.string.exo_track_selection_title_video);
+ case C.TRACK_TYPE_AUDIO:
+ return resources.getString(R.string.exo_track_selection_title_audio);
+ case C.TRACK_TYPE_TEXT:
+ return resources.getString(R.string.exo_track_selection_title_text);
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ private final class FragmentAdapter extends FragmentPagerAdapter {
+
+ public FragmentAdapter(FragmentManager fragmentManager) {
+ super(fragmentManager);
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ return tabFragments.valueAt(position);
+ }
+
+ @Override
+ public int getCount() {
+ return tabFragments.size();
+ }
+
+ @Nullable
+ @Override
+ public CharSequence getPageTitle(int position) {
+ return getTrackTypeString(getResources(), tabTrackTypes.get(position));
+ }
+ }
+
+ /** Fragment to show a track selection in tab of the track selection dialog. */
+ public static final class TrackSelectionViewFragment extends Fragment
+ implements TrackSelectionView.TrackSelectionListener {
+
+ private MappedTrackInfo mappedTrackInfo;
+ private int rendererIndex;
+ private boolean allowAdaptiveSelections;
+ private boolean allowMultipleOverrides;
+
+ /* package */ boolean isDisabled;
+ /* package */ List overrides;
+
+ public TrackSelectionViewFragment() {
+ // Retain instance across activity re-creation to prevent losing access to init data.
+ setRetainInstance(true);
+ }
+
+ public void init(
+ MappedTrackInfo mappedTrackInfo,
+ int rendererIndex,
+ boolean initialIsDisabled,
+ @Nullable SelectionOverride initialOverride,
+ boolean allowAdaptiveSelections,
+ boolean allowMultipleOverrides) {
+ this.mappedTrackInfo = mappedTrackInfo;
+ this.rendererIndex = rendererIndex;
+ this.isDisabled = initialIsDisabled;
+ this.overrides =
+ initialOverride == null
+ ? Collections.emptyList()
+ : Collections.singletonList(initialOverride);
+ this.allowAdaptiveSelections = allowAdaptiveSelections;
+ this.allowMultipleOverrides = allowMultipleOverrides;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View rootView =
+ inflater.inflate(
+ R.layout.exo_track_selection_dialog, container, /* attachToRoot= */ false);
+ TrackSelectionView trackSelectionView = rootView.findViewById(R.id.exo_track_selection_view);
+ trackSelectionView.setShowDisableOption(true);
+ trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides);
+ trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections);
+ trackSelectionView.init(
+ mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this);
+ return rootView;
+ }
+
+ @Override
+ public void onTrackSelectionChanged(boolean isDisabled, List overrides) {
+ this.isDisabled = isDisabled;
+ this.overrides = overrides;
+ }
+ }
+}
diff --git a/demos/main/src/main/res/drawable-hdpi/ic_download.png b/demos/main/src/main/res/drawable-hdpi/ic_download.png
new file mode 100644
index 0000000000..fa3ebbb310
Binary files /dev/null and b/demos/main/src/main/res/drawable-hdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-hdpi/ic_download_done.png b/demos/main/src/main/res/drawable-hdpi/ic_download_done.png
new file mode 100644
index 0000000000..fa0ec9dd68
Binary files /dev/null and b/demos/main/src/main/res/drawable-hdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-mdpi/ic_download.png b/demos/main/src/main/res/drawable-mdpi/ic_download.png
new file mode 100644
index 0000000000..c8a2039c58
Binary files /dev/null and b/demos/main/src/main/res/drawable-mdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-mdpi/ic_download_done.png b/demos/main/src/main/res/drawable-mdpi/ic_download_done.png
new file mode 100644
index 0000000000..08073a2a6d
Binary files /dev/null and b/demos/main/src/main/res/drawable-mdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_banner.png b/demos/main/src/main/res/drawable-xhdpi/ic_banner.png
new file mode 100644
index 0000000000..09de177387
Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_banner.png differ
diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_download.png b/demos/main/src/main/res/drawable-xhdpi/ic_download.png
new file mode 100644
index 0000000000..671e0b3ece
Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-xhdpi/ic_download_done.png b/demos/main/src/main/res/drawable-xhdpi/ic_download_done.png
new file mode 100644
index 0000000000..2339c0bf16
Binary files /dev/null and b/demos/main/src/main/res/drawable-xhdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-xxhdpi/ic_download.png b/demos/main/src/main/res/drawable-xxhdpi/ic_download.png
new file mode 100644
index 0000000000..4e04a30198
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxhdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-xxhdpi/ic_download_done.png b/demos/main/src/main/res/drawable-xxhdpi/ic_download_done.png
new file mode 100644
index 0000000000..b631a00088
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxhdpi/ic_download_done.png differ
diff --git a/demos/main/src/main/res/drawable-xxxhdpi/ic_download.png b/demos/main/src/main/res/drawable-xxxhdpi/ic_download.png
new file mode 100644
index 0000000000..f9bfb5edba
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxxhdpi/ic_download.png differ
diff --git a/demos/main/src/main/res/drawable-xxxhdpi/ic_download_done.png b/demos/main/src/main/res/drawable-xxxhdpi/ic_download_done.png
new file mode 100644
index 0000000000..52fe8f6990
Binary files /dev/null and b/demos/main/src/main/res/drawable-xxxhdpi/ic_download_done.png differ
diff --git a/demo/src/main/res/layout/player_activity.xml b/demos/main/src/main/res/layout/player_activity.xml
similarity index 89%
rename from demo/src/main/res/layout/player_activity.xml
rename to demos/main/src/main/res/layout/player_activity.xml
index 3f8cdaa7d6..ea3de257e2 100644
--- a/demo/src/main/res/layout/player_activity.xml
+++ b/demos/main/src/main/res/layout/player_activity.xml
@@ -20,7 +20,7 @@
android:layout_height="match_parent"
android:keepScreenOn="true">
-
@@ -44,11 +44,11 @@
android:orientation="horizontal"
android:visibility="gone">
-
+ android:text="@string/track_selection_title"
+ android:enabled="false"/>
diff --git a/demo/src/main/res/layout/sample_chooser_activity.xml b/demos/main/src/main/res/layout/sample_chooser_activity.xml
similarity index 100%
rename from demo/src/main/res/layout/sample_chooser_activity.xml
rename to demos/main/src/main/res/layout/sample_chooser_activity.xml
diff --git a/demos/main/src/main/res/layout/sample_list_item.xml b/demos/main/src/main/res/layout/sample_list_item.xml
new file mode 100644
index 0000000000..cdb0058688
--- /dev/null
+++ b/demos/main/src/main/res/layout/sample_list_item.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
diff --git a/demos/main/src/main/res/layout/track_selection_dialog.xml b/demos/main/src/main/res/layout/track_selection_dialog.xml
new file mode 100644
index 0000000000..7f6c45e131
--- /dev/null
+++ b/demos/main/src/main/res/layout/track_selection_dialog.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/main/src/main/res/menu/sample_chooser_menu.xml b/demos/main/src/main/res/menu/sample_chooser_menu.xml
new file mode 100644
index 0000000000..f95c0b6460
--- /dev/null
+++ b/demos/main/src/main/res/menu/sample_chooser_menu.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/demos/main/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/demos/main/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/demos/main/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/demos/main/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/demos/main/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demo/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml
similarity index 56%
rename from demo/src/main/res/values/strings.xml
rename to demos/main/src/main/res/values/strings.xml
index 4eb2b89324..671303a522 100644
--- a/demo/src/main/res/values/strings.xml
+++ b/demos/main/src/main/res/values/strings.xml
@@ -13,28 +13,23 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
ExoPlayer
- Video
-
- Audio
-
- Text
-
- Retry
-
- Disabled
-
- Default
+ Select tracks
Unexpected intent action: %1$s
- Enable random adaptation
+ Cleartext traffic not permitted
- Protected content not supported on API levels below 18
+ Playback failed
+
+ Unrecognized ABR algorithm
+
+ Unrecognized stereo mode
+
+ Protected content not supported on API levels below 18
This device does not support the required DRM scheme
@@ -56,4 +51,26 @@
One or more sample lists failed to load
+ Playing sample without ads, as the IMA extension was not loaded
+
+ Playing sample without ads, as ads are not supported in concatenations
+
+ Failed to start download
+
+ This demo app does not support downloading playlists
+
+ This demo app does not support downloading protected content
+
+ This demo app only supports downloading http streams
+
+ This demo app does not support downloading live content
+
+ IMA does not support offline ads
+
+ Prefer extension decoders
+
+ Enable random ABR
+
+ Request multimedia tunneling
+
diff --git a/demo/src/main/res/values/styles.xml b/demos/main/src/main/res/values/styles.xml
similarity index 71%
rename from demo/src/main/res/values/styles.xml
rename to demos/main/src/main/res/values/styles.xml
index 751a224210..a2ebde37bd 100644
--- a/demo/src/main/res/values/styles.xml
+++ b/demos/main/src/main/res/values/styles.xml
@@ -13,12 +13,18 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
+
+
+
+
diff --git a/demos/surface/README.md b/demos/surface/README.md
new file mode 100644
index 0000000000..312259dbf6
--- /dev/null
+++ b/demos/surface/README.md
@@ -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
diff --git a/demo/build.gradle b/demos/surface/build.gradle
similarity index 55%
rename from demo/build.gradle
rename to demos/surface/build.gradle
index be5e52a25c..bff05901b5 100644
--- a/demo/build.gradle
+++ b/demos/surface/build.gradle
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 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.
@@ -11,15 +11,22 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
+apply from: '../../constants.gradle'
apply plugin: 'com.android.application'
android {
compileSdkVersion project.ext.compileSdkVersion
- buildToolsVersion project.ext.buildToolsVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
defaultConfig {
- minSdkVersion 16
- targetSdkVersion project.ext.targetSdkVersion
+ versionName project.ext.releaseVersion
+ versionCode project.ext.releaseVersionCode
+ minSdkVersion 29
+ targetSdkVersion project.ext.appTargetSdkVersion
}
buildTypes {
@@ -28,30 +35,17 @@ android {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt')
}
- debug {
- jniDebuggable = true
- }
}
lintOptions {
- // The demo app does not have translations.
+ // This demo app does not have translations.
disable 'MissingTranslation'
}
-
- productFlavors {
- noExtensions
- withExtensions
- }
}
dependencies {
- compile project(':library-core')
- compile project(':library-dash')
- compile project(':library-hls')
- compile project(':library-smoothstreaming')
- compile project(':library-ui')
- withExtensionsCompile project(path: ':extension-ffmpeg')
- withExtensionsCompile project(path: ':extension-flac')
- withExtensionsCompile project(path: ':extension-opus')
- withExtensionsCompile project(path: ':extension-vp9')
+ implementation project(modulePrefix + 'library-core')
+ implementation project(modulePrefix + 'library-ui')
+ implementation project(modulePrefix + 'library-dash')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
}
diff --git a/demos/surface/src/main/AndroidManifest.xml b/demos/surface/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..c33a9e646b
--- /dev/null
+++ b/demos/surface/src/main/AndroidManifest.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java
new file mode 100644
index 0000000000..402a71ebb3
--- /dev/null
+++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java
@@ -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 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.play();
+ 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();
+ }
+ }
+}
diff --git a/demos/surface/src/main/res/layout/main_activity.xml b/demos/surface/src/main/res/layout/main_activity.xml
new file mode 100644
index 0000000000..829602275d
--- /dev/null
+++ b/demos/surface/src/main/res/layout/main_activity.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/surface/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/surface/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/library/ui/src/main/res/values-et-rEE/strings.xml b/demos/surface/src/main/res/values/strings.xml
similarity index 52%
rename from library/ui/src/main/res/values-et-rEE/strings.xml
rename to demos/surface/src/main/res/values/strings.xml
index 7a01bd9d5a..9ba24bd368 100644
--- a/library/ui/src/main/res/values-et-rEE/strings.xml
+++ b/demos/surface/src/main/res/values/strings.xml
@@ -1,6 +1,5 @@
-
-
- "Eelmine lugu"
- "Järgmine lugu"
- "Peata"
- "Esita"
- "Peata"
- "Keri tagasi"
- "Keri edasi"
+
+ ExoPlayer SurfaceControl demo
+ No output
+ Full screen
+ New activity
+
diff --git a/extensions/README.md b/extensions/README.md
new file mode 100644
index 0000000000..bf0effb358
--- /dev/null
+++ b/extensions/README.md
@@ -0,0 +1,5 @@
+# ExoPlayer extensions #
+
+ExoPlayer extensions are modules that depend on external libraries to provide
+additional functionality. Browse the individual extensions and their READMEs to
+learn more.
diff --git a/extensions/av1/README.md b/extensions/av1/README.md
new file mode 100644
index 0000000000..54e27a3b87
--- /dev/null
+++ b/extensions/av1/README.md
@@ -0,0 +1,134 @@
+# 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 ""
+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.
+
+## Using the extension in the demo application ##
+
+To try out playback using the extension in the [demo application][], see
+[enabling extension decoders][].
+
+[demo application]: https://exoplayer.dev/demo-application.html
+[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders
+
+## 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
diff --git a/extensions/av1/build.gradle b/extensions/av1/build.gradle
new file mode 100644
index 0000000000..0b539d551b
--- /dev/null
+++ b/extensions/av1/build.gradle
@@ -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'
diff --git a/extensions/av1/proguard-rules.txt b/extensions/av1/proguard-rules.txt
new file mode 100644
index 0000000000..9d73f7e2b5
--- /dev/null
+++ b/extensions/av1/proguard-rules.txt
@@ -0,0 +1,7 @@
+# Proguard rules specific to the AV1 extension.
+
+# This prevents the names of native methods from being obfuscated.
+-keepclasseswithmembernames class * {
+ native ;
+}
+
diff --git a/extensions/av1/src/main/AndroidManifest.xml b/extensions/av1/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..af85bacdf6
--- /dev/null
+++ b/extensions/av1/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java
new file mode 100644
index 0000000000..d47055b4fe
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Decoder.java
@@ -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 {
+
+ // 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 occurred.
+ */
+ 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 occurred.
+ *
+ * @param context Decoder context.
+ * @return {@link #GAV1_OK} if there was no error, {@link #GAV1_ERROR} if an error occurred.
+ */
+ private native int gav1CheckError(long context);
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1DecoderException.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1DecoderException.java
new file mode 100644
index 0000000000..9d8692c581
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1DecoderException.java
@@ -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);
+ }
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Library.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Library.java
new file mode 100644
index 0000000000..7907fa4623
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Gav1Library.java
@@ -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();
+ }
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java
new file mode 100644
index 0000000000..fc4d527c29
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/Libgav1VideoRenderer.java
@@ -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.
+ *
+ * This renderer accepts the following messages sent via {@link ExoPlayer#createMessage(Target)}
+ * on the playback thread:
+ *
+ *
+ * Message with type {@link #MSG_SET_SURFACE} to set the output surface. The message payload
+ * should be the target {@link Surface}, or null.
+ * Message with type {@link #MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER} to set the output
+ * buffer renderer. The message payload should be the target {@link
+ * VideoDecoderOutputBufferRenderer}, or null.
+ *
+ */
+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 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 == MSG_SET_SURFACE) {
+ setOutputSurface((Surface) message);
+ } else if (messageType == MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) {
+ setOutputBufferRenderer((VideoDecoderOutputBufferRenderer) message);
+ } else {
+ super.handleMessage(messageType, message);
+ }
+ }
+}
diff --git a/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/package-info.java b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/package-info.java
new file mode 100644
index 0000000000..2e289b27fa
--- /dev/null
+++ b/extensions/av1/src/main/java/com/google/android/exoplayer2/ext/av1/package-info.java
@@ -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;
diff --git a/extensions/av1/src/main/jni/CMakeLists.txt b/extensions/av1/src/main/jni/CMakeLists.txt
new file mode 100644
index 0000000000..c7989d4ef2
--- /dev/null
+++ b/extensions/av1/src/main/jni/CMakeLists.txt
@@ -0,0 +1,62 @@
+# 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("-marm")
+ add_compile_options("-fPIC")
+endif()
+
+string(TOLOWER "${CMAKE_BUILD_TYPE}" build_type)
+if(build_type MATCHES "^rel")
+ add_compile_options("-O2")
+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})
diff --git a/extensions/av1/src/main/jni/gav1_jni.cc b/extensions/av1/src/main/jni/gav1_jni.cc
new file mode 100644
index 0000000000..9ac3ea5cd2
--- /dev/null
+++ b/extensions/av1/src/main/jni/gav1_jni.cc
@@ -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
+#include
+#include
+
+#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
+#endif // CPU_FEATURES_COMPILED_ANY_ARM_NEON
+#include
+
+#include
+#include // NOLINT
+#include
+
+#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(&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 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 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 lock(mutex_);
+ all_buffers_[id]->AddReference();
+ }
+
+ JniStatusCode ReleaseBuffer(int id) {
+ std::lock_guard 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(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(private_data);
+ const int buffer_id = *reinterpret_cast(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(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(source);
+ uint8_t* destination = reinterpret_cast(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(context);
+ }
+#else
+ context->jni_status_code = kJniStatusNeonNotSupported;
+ return reinterpret_cast(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(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(context);
+}
+
+DECODER_FUNC(void, gav1Close, jlong jContext) {
+ JniContext* const context = reinterpret_cast(jContext);
+ delete context;
+}
+
+DECODER_FUNC(jint, gav1Decode, jlong jContext, jobject encodedData,
+ jint length) {
+ JniContext* const context = reinterpret_cast(jContext);
+ const uint8_t* const buffer = reinterpret_cast(
+ 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(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(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(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(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(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(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(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(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(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(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.
diff --git a/extensions/cast/README.md b/extensions/cast/README.md
new file mode 100644
index 0000000000..1c0d7ac56f
--- /dev/null
+++ b/extensions/cast/README.md
@@ -0,0 +1,30 @@
+# ExoPlayer Cast extension #
+
+## Description ##
+
+The cast extension is a [Player][] implementation that controls playback on a
+Cast receiver app.
+
+[Player]: https://exoplayer.dev/doc/reference/index.html?com/google/android/exoplayer2/Player.html
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-cast:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Using the extension ##
+
+Create a `CastPlayer` and use it to integrate Cast into your app using
+ExoPlayer's common `Player` interface.
diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle
new file mode 100644
index 0000000000..0d7d96db4c
--- /dev/null
+++ b/extensions/cast/build.gradle
@@ -0,0 +1,53 @@
+// 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.
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ minSdkVersion project.ext.minSdkVersion
+ targetSdkVersion project.ext.targetSdkVersion
+ }
+
+ testOptions.unitTests.includeAndroidResources = true
+}
+
+dependencies {
+ api 'com.google.android.gms:play-services-cast-framework:17.0.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')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
+}
+
+ext {
+ javadocTitle = 'Cast extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-cast'
+ releaseDescription = 'Cast extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/cast/src/main/AndroidManifest.xml b/extensions/cast/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..c12fc1289f
--- /dev/null
+++ b/extensions/cast/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java
new file mode 100644
index 0000000000..1202cf1c81
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java
@@ -0,0 +1,1022 @@
+/*
+ * 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.ext.cast;
+
+import android.os.Looper;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.BasePlayer;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.PlaybackParameters;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.TrackGroup;
+import com.google.android.exoplayer2.source.TrackGroupArray;
+import com.google.android.exoplayer2.trackselection.FixedTrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelection;
+import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.gms.cast.CastStatusCodes;
+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.MediaTrack;
+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.SessionManagerListener;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult;
+import com.google.android.gms.common.api.PendingResult;
+import com.google.android.gms.common.api.ResultCallback;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/**
+ * {@link Player} implementation that communicates with a Cast receiver app.
+ *
+ * The behavior of this class depends on the underlying Cast session, which is obtained from the
+ * 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.
+ *
+ *
If no session is available, the player state will remain unchanged and calls to methods that
+ * alter it will be ignored. Querying the player state is possible even when no session is
+ * available, in which case, the last observed receiver app state is reported.
+ *
+ *
Methods should be called on the application's main thread.
+ */
+public final class CastPlayer extends BasePlayer {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.cast");
+ }
+
+ private static final String TAG = "CastPlayer";
+
+ private static final int RENDERER_COUNT = 3;
+ private static final int RENDERER_INDEX_VIDEO = 0;
+ private static final int RENDERER_INDEX_AUDIO = 1;
+ private static final int RENDERER_INDEX_TEXT = 2;
+ private static final long PROGRESS_REPORT_PERIOD_MS = 1000;
+ private static final TrackSelectionArray EMPTY_TRACK_SELECTION_ARRAY =
+ new TrackSelectionArray(null, null, null);
+ private static final long[] EMPTY_TRACK_ID_ARRAY = new long[0];
+
+ private final CastContext castContext;
+ // TODO: Allow custom implementations of CastTimelineTracker.
+ private final CastTimelineTracker timelineTracker;
+ private final Timeline.Period period;
+
+ // Result callbacks.
+ private final StatusListener statusListener;
+ private final SeekResultCallback seekResultCallback;
+
+ // Listeners and notification.
+ private final CopyOnWriteArrayList listeners;
+ private final ArrayList notificationsBatch;
+ private final ArrayDeque ongoingNotificationsTasks;
+ @Nullable private SessionAvailabilityListener sessionAvailabilityListener;
+
+ // Internal state.
+ private final StateHolder playWhenReady;
+ private final StateHolder repeatMode;
+ @Nullable private RemoteMediaClient remoteMediaClient;
+ private CastTimeline currentTimeline;
+ private TrackGroupArray currentTrackGroups;
+ private TrackSelectionArray currentTrackSelection;
+ @Player.State private int playbackState;
+ private int currentWindowIndex;
+ private long lastReportedPositionMs;
+ private int pendingSeekCount;
+ private int pendingSeekWindowIndex;
+ private long pendingSeekPositionMs;
+
+ /**
+ * @param castContext The context from which the cast session is obtained.
+ */
+ public CastPlayer(CastContext castContext) {
+ this.castContext = castContext;
+ timelineTracker = new CastTimelineTracker();
+ period = new Timeline.Period();
+ statusListener = new StatusListener();
+ seekResultCallback = new SeekResultCallback();
+ listeners = new CopyOnWriteArrayList<>();
+ notificationsBatch = new ArrayList<>();
+ ongoingNotificationsTasks = new ArrayDeque<>();
+
+ playWhenReady = new StateHolder<>(false);
+ repeatMode = new StateHolder<>(REPEAT_MODE_OFF);
+ playbackState = STATE_IDLE;
+ currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE;
+ currentTrackGroups = TrackGroupArray.EMPTY;
+ currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY;
+ pendingSeekWindowIndex = C.INDEX_UNSET;
+ pendingSeekPositionMs = C.TIME_UNSET;
+
+ SessionManager sessionManager = castContext.getSessionManager();
+ sessionManager.addSessionManagerListener(statusListener, CastSession.class);
+ CastSession session = sessionManager.getCurrentCastSession();
+ setRemoteMediaClient(session != null ? session.getRemoteMediaClient() : null);
+ updateInternalStateAndNotifyIfChanged();
+ }
+
+ // Media Queue manipulation methods.
+
+ /**
+ * Loads a single item media queue. If no session is available, does nothing.
+ *
+ * @param item The item to load.
+ * @param positionMs The position at which the playback should start in milliseconds relative to
+ * the start of the item at {@code startIndex}. If {@link C#TIME_UNSET} is passed, playback
+ * starts at position 0.
+ * @return The Cast {@code PendingResult}, or null if no session is available.
+ */
+ @Nullable
+ public PendingResult loadItem(MediaQueueItem item, long positionMs) {
+ return loadItems(new MediaQueueItem[] {item}, 0, positionMs, REPEAT_MODE_OFF);
+ }
+
+ /**
+ * Loads a media queue. If no session is available, does nothing.
+ *
+ * @param items The items to load.
+ * @param startIndex The index of the item at which playback should start.
+ * @param positionMs The position at which the playback should start in milliseconds relative to
+ * the start of the item at {@code startIndex}. If {@link C#TIME_UNSET} is passed, playback
+ * starts at position 0.
+ * @param repeatMode The repeat mode for the created media queue.
+ * @return The Cast {@code PendingResult}, or null if no session is available.
+ */
+ @Nullable
+ public PendingResult loadItems(
+ MediaQueueItem[] items, int startIndex, long positionMs, @RepeatMode int repeatMode) {
+ if (remoteMediaClient != null) {
+ positionMs = positionMs != C.TIME_UNSET ? positionMs : 0;
+ return remoteMediaClient.queueLoad(items, startIndex, getCastRepeatMode(repeatMode),
+ positionMs, null);
+ }
+ return null;
+ }
+
+ /**
+ * Appends a sequence of items to the media queue. If no media queue exists, does nothing.
+ *
+ * @param items The items to append.
+ * @return The Cast {@code PendingResult}, or null if no media queue exists.
+ */
+ @Nullable
+ public PendingResult addItems(MediaQueueItem... items) {
+ return addItems(MediaQueueItem.INVALID_ITEM_ID, items);
+ }
+
+ /**
+ * Inserts a sequence of items into the media queue. If no media queue or period with id {@code
+ * periodId} exist, does nothing.
+ *
+ * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item
+ * that will follow immediately after the inserted items.
+ * @param items The items to insert.
+ * @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code
+ * periodId} exist.
+ */
+ @Nullable
+ public PendingResult addItems(int periodId, MediaQueueItem... items) {
+ if (getMediaStatus() != null && (periodId == MediaQueueItem.INVALID_ITEM_ID
+ || currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET)) {
+ return remoteMediaClient.queueInsertItems(items, periodId, null);
+ }
+ return null;
+ }
+
+ /**
+ * Removes an item from the media queue. If no media queue or period with id {@code periodId}
+ * exist, does nothing.
+ *
+ * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item
+ * to remove.
+ * @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code
+ * periodId} exist.
+ */
+ @Nullable
+ public PendingResult removeItem(int periodId) {
+ if (getMediaStatus() != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) {
+ return remoteMediaClient.queueRemoveItem(periodId, null);
+ }
+ return null;
+ }
+
+ /**
+ * Moves an existing item within the media queue. If no media queue or period with id {@code
+ * periodId} exist, does nothing.
+ *
+ * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item
+ * to move.
+ * @param newIndex The target index of the item in the media queue. Must be in the range 0 <=
+ * index < {@link Timeline#getPeriodCount()}, as provided by {@link #getCurrentTimeline()}.
+ * @return The Cast {@code PendingResult}, or null if no media queue or no period with id {@code
+ * periodId} exist.
+ */
+ @Nullable
+ public PendingResult moveItem(int periodId, int newIndex) {
+ Assertions.checkArgument(newIndex >= 0 && newIndex < currentTimeline.getPeriodCount());
+ if (getMediaStatus() != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET) {
+ return remoteMediaClient.queueMoveItemToNewIndex(periodId, newIndex, null);
+ }
+ return null;
+ }
+
+ /**
+ * Returns the item that corresponds to the period with the given id, or null if no media queue or
+ * period with id {@code periodId} exist.
+ *
+ * @param periodId The id of the period ({@link #getCurrentTimeline}) that corresponds to the item
+ * to get.
+ * @return The item that corresponds to the period with the given id, or null if no media queue or
+ * period with id {@code periodId} exist.
+ */
+ @Nullable
+ public MediaQueueItem getItem(int periodId) {
+ MediaStatus mediaStatus = getMediaStatus();
+ return mediaStatus != null && currentTimeline.getIndexOfPeriod(periodId) != C.INDEX_UNSET
+ ? mediaStatus.getItemById(periodId) : null;
+ }
+
+ // CastSession methods.
+
+ /**
+ * Returns whether a cast session is available.
+ */
+ public boolean isCastSessionAvailable() {
+ return remoteMediaClient != null;
+ }
+
+ /**
+ * Sets a listener for updates on the cast session availability.
+ *
+ * @param listener The {@link SessionAvailabilityListener}, or null to clear the listener.
+ */
+ public void setSessionAvailabilityListener(@Nullable SessionAvailabilityListener listener) {
+ sessionAvailabilityListener = listener;
+ }
+
+ // Player implementation.
+
+ @Override
+ @Nullable
+ public AudioComponent getAudioComponent() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public VideoComponent getVideoComponent() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public TextComponent getTextComponent() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public MetadataComponent getMetadataComponent() {
+ return null;
+ }
+
+ @Override
+ public Looper getApplicationLooper() {
+ return Looper.getMainLooper();
+ }
+
+ @Override
+ public void addListener(EventListener listener) {
+ listeners.addIfAbsent(new ListenerHolder(listener));
+ }
+
+ @Override
+ public void removeListener(EventListener listener) {
+ for (ListenerHolder listenerHolder : listeners) {
+ if (listenerHolder.listener.equals(listener)) {
+ listenerHolder.release();
+ listeners.remove(listenerHolder);
+ }
+ }
+ }
+
+ @Override
+ @Player.State
+ public int getPlaybackState() {
+ return playbackState;
+ }
+
+ @Override
+ @PlaybackSuppressionReason
+ public int getPlaybackSuppressionReason() {
+ return Player.PLAYBACK_SUPPRESSION_REASON_NONE;
+ }
+
+ @Override
+ @Nullable
+ public ExoPlaybackException getPlaybackError() {
+ return null;
+ }
+
+ @Override
+ public void setPlayWhenReady(boolean playWhenReady) {
+ 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.
+ setPlayerStateAndNotifyIfChanged(
+ playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState);
+ flushNotifications();
+ PendingResult pendingResult =
+ playWhenReady ? remoteMediaClient.play() : remoteMediaClient.pause();
+ this.playWhenReady.pendingResultCallback =
+ new ResultCallback() {
+ @Override
+ public void onResult(MediaChannelResult mediaChannelResult) {
+ if (remoteMediaClient != null) {
+ updatePlayerStateAndNotifyIfChanged(this);
+ flushNotifications();
+ }
+ }
+ };
+ pendingResult.setResultCallback(this.playWhenReady.pendingResultCallback);
+ }
+
+ @Override
+ public boolean getPlayWhenReady() {
+ return playWhenReady.value;
+ }
+
+ @Override
+ public void seekTo(int windowIndex, long positionMs) {
+ MediaStatus mediaStatus = getMediaStatus();
+ // We assume the default position is 0. There is no support for seeking to the default position
+ // in RemoteMediaClient.
+ positionMs = positionMs != C.TIME_UNSET ? positionMs : 0;
+ if (mediaStatus != null) {
+ if (getCurrentWindowIndex() != windowIndex) {
+ remoteMediaClient.queueJumpToItem((int) currentTimeline.getPeriod(windowIndex, period).uid,
+ positionMs, null).setResultCallback(seekResultCallback);
+ } else {
+ remoteMediaClient.seek(positionMs).setResultCallback(seekResultCallback);
+ }
+ pendingSeekCount++;
+ pendingSeekWindowIndex = windowIndex;
+ pendingSeekPositionMs = positionMs;
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)));
+ } else if (pendingSeekCount == 0) {
+ notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed));
+ }
+ flushNotifications();
+ }
+
+ @Override
+ public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) {
+ // Unsupported by the RemoteMediaClient API. Do nothing.
+ }
+
+ @Override
+ public PlaybackParameters getPlaybackParameters() {
+ return PlaybackParameters.DEFAULT;
+ }
+
+ @Override
+ public void stop(boolean reset) {
+ playbackState = STATE_IDLE;
+ if (remoteMediaClient != null) {
+ // TODO(b/69792021): Support or emulate stop without position reset.
+ remoteMediaClient.stop();
+ }
+ }
+
+ @Override
+ public void release() {
+ SessionManager sessionManager = castContext.getSessionManager();
+ sessionManager.removeSessionManagerListener(statusListener, CastSession.class);
+ sessionManager.endCurrentSession(false);
+ }
+
+ @Override
+ public int getRendererCount() {
+ // We assume there are three renderers: video, audio, and text.
+ return RENDERER_COUNT;
+ }
+
+ @Override
+ public int getRendererType(int index) {
+ switch (index) {
+ case RENDERER_INDEX_VIDEO:
+ return C.TRACK_TYPE_VIDEO;
+ case RENDERER_INDEX_AUDIO:
+ return C.TRACK_TYPE_AUDIO;
+ case RENDERER_INDEX_TEXT:
+ return C.TRACK_TYPE_TEXT;
+ default:
+ throw new IndexOutOfBoundsException();
+ }
+ }
+
+ @Override
+ public void setRepeatMode(@RepeatMode int repeatMode) {
+ 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 pendingResult =
+ remoteMediaClient.queueSetRepeatMode(getCastRepeatMode(repeatMode), /* jsonObject= */ null);
+ this.repeatMode.pendingResultCallback =
+ new ResultCallback() {
+ @Override
+ public void onResult(MediaChannelResult mediaChannelResult) {
+ if (remoteMediaClient != null) {
+ updateRepeatModeAndNotifyIfChanged(this);
+ flushNotifications();
+ }
+ }
+ };
+ pendingResult.setResultCallback(this.repeatMode.pendingResultCallback);
+ }
+
+ @Override
+ @RepeatMode public int getRepeatMode() {
+ return repeatMode.value;
+ }
+
+ @Override
+ public void setShuffleModeEnabled(boolean shuffleModeEnabled) {
+ // TODO: Support shuffle mode.
+ }
+
+ @Override
+ public boolean getShuffleModeEnabled() {
+ // TODO: Support shuffle mode.
+ return false;
+ }
+
+ @Override
+ public TrackSelectionArray getCurrentTrackSelections() {
+ return currentTrackSelection;
+ }
+
+ @Override
+ public TrackGroupArray getCurrentTrackGroups() {
+ return currentTrackGroups;
+ }
+
+ @Override
+ public Timeline getCurrentTimeline() {
+ return currentTimeline;
+ }
+
+ @Override
+ public int getCurrentPeriodIndex() {
+ return getCurrentWindowIndex();
+ }
+
+ @Override
+ public int getCurrentWindowIndex() {
+ return pendingSeekWindowIndex != C.INDEX_UNSET ? pendingSeekWindowIndex : currentWindowIndex;
+ }
+
+ // TODO: Fill the cast timeline information with ProgressListener's duration updates.
+ // See [Internal: b/65152553].
+ @Override
+ public long getDuration() {
+ return getContentDuration();
+ }
+
+ @Override
+ public long getCurrentPosition() {
+ return pendingSeekPositionMs != C.TIME_UNSET
+ ? pendingSeekPositionMs
+ : remoteMediaClient != null
+ ? remoteMediaClient.getApproximateStreamPosition()
+ : lastReportedPositionMs;
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ return getCurrentPosition();
+ }
+
+ @Override
+ public long getTotalBufferedDuration() {
+ long bufferedPosition = getBufferedPosition();
+ long currentPosition = getCurrentPosition();
+ return bufferedPosition == C.TIME_UNSET || currentPosition == C.TIME_UNSET
+ ? 0
+ : bufferedPosition - currentPosition;
+ }
+
+ @Override
+ public boolean isPlayingAd() {
+ return false;
+ }
+
+ @Override
+ public int getCurrentAdGroupIndex() {
+ return C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getCurrentAdIndexInAdGroup() {
+ return C.INDEX_UNSET;
+ }
+
+ @Override
+ public boolean isLoading() {
+ return false;
+ }
+
+ @Override
+ public long getContentPosition() {
+ return getCurrentPosition();
+ }
+
+ @Override
+ public long getContentBufferedPosition() {
+ return getBufferedPosition();
+ }
+
+ // Internal methods.
+
+ 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.value;
+ updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null);
+ boolean isPlaying = playbackState == Player.STATE_READY && playWhenReady.value;
+ if (wasPlaying != isPlaying) {
+ notificationsBatch.add(
+ new ListenerNotificationTask(listener -> listener.onIsPlayingChanged(isPlaying)));
+ }
+ updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null);
+ updateTimelineAndNotifyIfChanged();
+
+ int currentWindowIndex = C.INDEX_UNSET;
+ MediaQueueItem currentItem = remoteMediaClient.getCurrentItem();
+ if (currentItem != null) {
+ currentWindowIndex = currentTimeline.getIndexOfPeriod(currentItem.getItemId());
+ }
+ if (currentWindowIndex == C.INDEX_UNSET) {
+ // The timeline is empty. Fall back to index 0, which is what ExoPlayer would do.
+ currentWindowIndex = 0;
+ }
+ if (this.currentWindowIndex != currentWindowIndex && pendingSeekCount == 0) {
+ this.currentWindowIndex = currentWindowIndex;
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener ->
+ listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION)));
+ }
+ if (updateTracksAndSelectionsAndNotifyIfChanged()) {
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelection)));
+ }
+ flushNotifications();
+ }
+
+ /**
+ * Updates {@link #playWhenReady} and {@link #playbackState} to match the Cast {@code
+ * remoteMediaClient} state, and notifies listeners of any state changes.
+ *
+ * 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();
+ }
+ @PlayWhenReadyChangeReason
+ int playWhenReadyChangeReason =
+ newPlayWhenReadyValue != playWhenReady.value
+ ? PLAY_WHEN_READY_CHANGE_REASON_REMOTE
+ : PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST;
+ // We do not mask the playback state, so try setting it regardless of the playWhenReady masking.
+ setPlayerStateAndNotifyIfChanged(
+ newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient));
+ }
+
+ @RequiresNonNull("remoteMediaClient")
+ private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback> resultCallback) {
+ if (repeatMode.acceptsUpdate(resultCallback)) {
+ setRepeatModeAndNotifyIfChanged(fetchRepeatMode(remoteMediaClient));
+ repeatMode.clearPendingResultCallback();
+ }
+ }
+
+ private void updateTimelineAndNotifyIfChanged() {
+ if (updateTimeline()) {
+ // TODO: Differentiate TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED and
+ // TIMELINE_CHANGE_REASON_SOURCE_UPDATE [see internal: b/65152553].
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener ->
+ listener.onTimelineChanged(
+ currentTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)));
+ }
+ }
+
+ /**
+ * Updates the current timeline and returns whether it has changed.
+ */
+ private boolean updateTimeline() {
+ CastTimeline oldTimeline = currentTimeline;
+ MediaStatus status = getMediaStatus();
+ currentTimeline =
+ status != null
+ ? timelineTracker.getCastTimeline(remoteMediaClient)
+ : CastTimeline.EMPTY_CAST_TIMELINE;
+ return !oldTimeline.equals(currentTimeline);
+ }
+
+ /** 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;
+ }
+
+ MediaStatus mediaStatus = getMediaStatus();
+ MediaInfo mediaInfo = mediaStatus != null ? mediaStatus.getMediaInfo() : null;
+ List castMediaTracks = mediaInfo != null ? mediaInfo.getMediaTracks() : null;
+ if (castMediaTracks == null || castMediaTracks.isEmpty()) {
+ boolean hasChanged = !currentTrackGroups.isEmpty();
+ currentTrackGroups = TrackGroupArray.EMPTY;
+ currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY;
+ return hasChanged;
+ }
+ long[] activeTrackIds = mediaStatus.getActiveTrackIds();
+ if (activeTrackIds == null) {
+ activeTrackIds = EMPTY_TRACK_ID_ARRAY;
+ }
+
+ TrackGroup[] trackGroups = new TrackGroup[castMediaTracks.size()];
+ TrackSelection[] trackSelections = new TrackSelection[RENDERER_COUNT];
+ for (int i = 0; i < castMediaTracks.size(); i++) {
+ MediaTrack mediaTrack = castMediaTracks.get(i);
+ trackGroups[i] = new TrackGroup(CastUtils.mediaTrackToFormat(mediaTrack));
+
+ long id = mediaTrack.getId();
+ int trackType = MimeTypes.getTrackType(mediaTrack.getContentType());
+ int rendererIndex = getRendererIndexForTrackType(trackType);
+ if (isTrackActive(id, activeTrackIds) && rendererIndex != C.INDEX_UNSET
+ && trackSelections[rendererIndex] == null) {
+ trackSelections[rendererIndex] = new FixedTrackSelection(trackGroups[i], 0);
+ }
+ }
+ TrackGroupArray newTrackGroups = new TrackGroupArray(trackGroups);
+ TrackSelectionArray newTrackSelections = new TrackSelectionArray(trackSelections);
+
+ if (!newTrackGroups.equals(currentTrackGroups)
+ || !newTrackSelections.equals(currentTrackSelection)) {
+ currentTrackSelection = new TrackSelectionArray(trackSelections);
+ currentTrackGroups = new TrackGroupArray(trackGroups);
+ return true;
+ }
+ 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.PlayWhenReadyChangeReason int playWhenReadyChangeReason,
+ @Player.State int playbackState) {
+ boolean playWhenReadyChanged = this.playWhenReady.value != playWhenReady;
+ if (playWhenReadyChanged || this.playbackState != playbackState) {
+ this.playbackState = playbackState;
+ this.playWhenReady.value = playWhenReady;
+ notificationsBatch.add(
+ new ListenerNotificationTask(
+ listener -> {
+ listener.onPlayerStateChanged(playWhenReady, playbackState);
+ if (playWhenReadyChanged) {
+ listener.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason);
+ }
+ }));
+ }
+ }
+
+ private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) {
+ if (this.remoteMediaClient == remoteMediaClient) {
+ // Do nothing.
+ return;
+ }
+ if (this.remoteMediaClient != null) {
+ this.remoteMediaClient.removeListener(statusListener);
+ this.remoteMediaClient.removeProgressListener(statusListener);
+ }
+ this.remoteMediaClient = remoteMediaClient;
+ if (remoteMediaClient != null) {
+ if (sessionAvailabilityListener != null) {
+ sessionAvailabilityListener.onCastSessionAvailable();
+ }
+ remoteMediaClient.addListener(statusListener);
+ remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS);
+ updateInternalStateAndNotifyIfChanged();
+ } else {
+ if (sessionAvailabilityListener != null) {
+ sessionAvailabilityListener.onCastSessionUnavailable();
+ }
+ }
+ }
+
+ @Nullable
+ private MediaStatus getMediaStatus() {
+ return remoteMediaClient != null ? remoteMediaClient.getMediaStatus() : null;
+ }
+
+ /**
+ * Retrieves the playback state from {@code remoteMediaClient} and maps it into a {@link Player}
+ * state
+ */
+ private static int fetchPlaybackState(RemoteMediaClient remoteMediaClient) {
+ int receiverAppStatus = remoteMediaClient.getPlayerState();
+ switch (receiverAppStatus) {
+ case MediaStatus.PLAYER_STATE_BUFFERING:
+ return STATE_BUFFERING;
+ case MediaStatus.PLAYER_STATE_PLAYING:
+ case MediaStatus.PLAYER_STATE_PAUSED:
+ return STATE_READY;
+ case MediaStatus.PLAYER_STATE_IDLE:
+ case MediaStatus.PLAYER_STATE_UNKNOWN:
+ default:
+ return STATE_IDLE;
+ }
+ }
+
+ /**
+ * Retrieves the repeat mode from {@code remoteMediaClient} and maps it into a
+ * {@link Player.RepeatMode}.
+ */
+ @RepeatMode
+ private static int fetchRepeatMode(RemoteMediaClient remoteMediaClient) {
+ MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
+ if (mediaStatus == null) {
+ // No media session active, yet.
+ return REPEAT_MODE_OFF;
+ }
+ int castRepeatMode = mediaStatus.getQueueRepeatMode();
+ switch (castRepeatMode) {
+ case MediaStatus.REPEAT_MODE_REPEAT_SINGLE:
+ return REPEAT_MODE_ONE;
+ case MediaStatus.REPEAT_MODE_REPEAT_ALL:
+ case MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE:
+ return REPEAT_MODE_ALL;
+ case MediaStatus.REPEAT_MODE_REPEAT_OFF:
+ return REPEAT_MODE_OFF;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ private static boolean isTrackActive(long id, long[] activeTrackIds) {
+ for (long activeTrackId : activeTrackIds) {
+ if (activeTrackId == id) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static int getRendererIndexForTrackType(int trackType) {
+ return trackType == C.TRACK_TYPE_VIDEO
+ ? RENDERER_INDEX_VIDEO
+ : trackType == C.TRACK_TYPE_AUDIO
+ ? RENDERER_INDEX_AUDIO
+ : trackType == C.TRACK_TYPE_TEXT ? RENDERER_INDEX_TEXT : C.INDEX_UNSET;
+ }
+
+ private static int getCastRepeatMode(@RepeatMode int repeatMode) {
+ switch (repeatMode) {
+ case REPEAT_MODE_ONE:
+ return MediaStatus.REPEAT_MODE_REPEAT_SINGLE;
+ case REPEAT_MODE_ALL:
+ return MediaStatus.REPEAT_MODE_REPEAT_ALL;
+ case REPEAT_MODE_OFF:
+ return MediaStatus.REPEAT_MODE_REPEAT_OFF;
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ 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,
+ RemoteMediaClient.ProgressListener {
+
+ // RemoteMediaClient.ProgressListener implementation.
+
+ @Override
+ public void onProgressUpdated(long progressMs, long unusedDurationMs) {
+ lastReportedPositionMs = progressMs;
+ }
+
+ // RemoteMediaClient.Listener implementation.
+
+ @Override
+ public void onStatusUpdated() {
+ updateInternalStateAndNotifyIfChanged();
+ }
+
+ @Override
+ public void onMetadataUpdated() {}
+
+ @Override
+ public void onQueueStatusUpdated() {
+ updateTimelineAndNotifyIfChanged();
+ }
+
+ @Override
+ public void onPreloadStatusUpdated() {}
+
+ @Override
+ public void onSendingRemoteMediaRequest() {}
+
+ @Override
+ public void onAdBreakStatusUpdated() {}
+
+ // SessionManagerListener implementation.
+
+ @Override
+ public void onSessionStarted(CastSession castSession, String s) {
+ setRemoteMediaClient(castSession.getRemoteMediaClient());
+ }
+
+ @Override
+ public void onSessionResumed(CastSession castSession, boolean b) {
+ setRemoteMediaClient(castSession.getRemoteMediaClient());
+ }
+
+ @Override
+ public void onSessionEnded(CastSession castSession, int i) {
+ setRemoteMediaClient(null);
+ }
+
+ @Override
+ public void onSessionSuspended(CastSession castSession, int i) {
+ setRemoteMediaClient(null);
+ }
+
+ @Override
+ public void onSessionResumeFailed(CastSession castSession, int statusCode) {
+ Log.e(TAG, "Session resume failed. Error code " + statusCode + ": "
+ + CastUtils.getLogString(statusCode));
+ }
+
+ @Override
+ public void onSessionStarting(CastSession castSession) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onSessionStartFailed(CastSession castSession, int statusCode) {
+ Log.e(TAG, "Session start failed. Error code " + statusCode + ": "
+ + CastUtils.getLogString(statusCode));
+ }
+
+ @Override
+ public void onSessionEnding(CastSession castSession) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onSessionResuming(CastSession castSession, String s) {
+ // Do nothing.
+ }
+
+ }
+
+ private final class SeekResultCallback implements ResultCallback {
+
+ @Override
+ 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 + ": "
+ + CastUtils.getLogString(statusCode));
+ }
+ if (--pendingSeekCount == 0) {
+ pendingSeekWindowIndex = C.INDEX_UNSET;
+ pendingSeekPositionMs = C.TIME_UNSET;
+ notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed));
+ flushNotifications();
+ }
+ }
+ }
+
+ /** Holds the value and the masking status of a specific part of the {@link CastPlayer} state. */
+ private static final class StateHolder {
+
+ /** 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 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.
+ *
+ * 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 listenersSnapshot;
+ private final ListenerInvocation listenerInvocation;
+
+ private ListenerNotificationTask(ListenerInvocation listenerInvocation) {
+ this.listenersSnapshot = listeners.iterator();
+ this.listenerInvocation = listenerInvocation;
+ }
+
+ public void execute() {
+ while (listenersSnapshot.hasNext()) {
+ listenersSnapshot.next().invoke(listenerInvocation);
+ }
+ }
+ }
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java
new file mode 100644
index 0000000000..38a7a692b2
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java
@@ -0,0 +1,190 @@
+/*
+ * 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.ext.cast;
+
+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;
+
+/**
+ * A {@link Timeline} for Cast media queues.
+ */
+/* package */ final class CastTimeline extends Timeline {
+
+ /** Holds {@link Timeline} related data for a Cast media item. */
+ public static final class ItemData {
+
+ /** Holds no media information. */
+ public static final ItemData EMPTY = new ItemData();
+
+ /** The duration of the item in microseconds, or {@link C#TIME_UNSET} if unknown. */
+ public final long durationUs;
+ /**
+ * The default start position of the item in microseconds, or {@link C#TIME_UNSET} if unknown.
+ */
+ public final long defaultPositionUs;
+ /** 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,
+ /* isLive= */ false);
+ }
+
+ /**
+ * Creates an instance.
+ *
+ * @param durationUs See {@link #durationsUs}.
+ * @param defaultPositionUs See {@link #defaultPositionUs}.
+ * @param isLive See {@link #isLive}.
+ */
+ public ItemData(long durationUs, long defaultPositionUs, boolean isLive) {
+ this.durationUs = durationUs;
+ this.defaultPositionUs = defaultPositionUs;
+ this.isLive = isLive;
+ }
+
+ /**
+ * 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, isLive);
+ }
+ }
+
+ /** {@link Timeline} for a cast queue that has no items. */
+ public static final CastTimeline EMPTY_CAST_TIMELINE =
+ new CastTimeline(new int[0], new SparseArray<>());
+
+ private final SparseIntArray idsToIndex;
+ private final int[] ids;
+ private final long[] durationsUs;
+ private final long[] defaultPositionsUs;
+ private final boolean[] isLive;
+
+ /**
+ * Creates a Cast timeline from the given data.
+ *
+ * @param itemIds The ids of the items in the timeline.
+ * @param itemIdToData Maps item ids to {@link ItemData}.
+ */
+ public CastTimeline(int[] itemIds, SparseArray itemIdToData) {
+ int itemCount = itemIds.length;
+ idsToIndex = new SparseIntArray(itemCount);
+ 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 == C.TIME_UNSET ? 0 : data.defaultPositionUs;
+ isLive[i] = data.isLive;
+ }
+ }
+
+ // Timeline implementation.
+
+ @Override
+ public int getWindowCount() {
+ return ids.length;
+ }
+
+ @Override
+ public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
+ long durationUs = durationsUs[windowIndex];
+ boolean isDynamic = durationUs == C.TIME_UNSET;
+ return window.set(
+ /* uid= */ ids[windowIndex],
+ /* tag= */ ids[windowIndex],
+ /* manifest= */ null,
+ /* presentationStartTimeMs= */ C.TIME_UNSET,
+ /* windowStartTimeMs= */ C.TIME_UNSET,
+ /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET,
+ /* isSeekable= */ !isDynamic,
+ isDynamic,
+ isLive[windowIndex],
+ defaultPositionsUs[windowIndex],
+ durationUs,
+ /* firstPeriodIndex= */ windowIndex,
+ /* lastPeriodIndex= */ windowIndex,
+ /* positionInFirstPeriodUs= */ 0);
+ }
+
+ @Override
+ public int getPeriodCount() {
+ return ids.length;
+ }
+
+ @Override
+ public Period getPeriod(int periodIndex, Period period, boolean setIds) {
+ int id = ids[periodIndex];
+ return period.set(id, id, periodIndex, durationsUs[periodIndex], 0);
+ }
+
+ @Override
+ public int getIndexOfPeriod(Object uid) {
+ return uid instanceof Integer ? idsToIndex.get((int) uid, C.INDEX_UNSET) : C.INDEX_UNSET;
+ }
+
+ @Override
+ public Integer getUidOfPeriod(int periodIndex) {
+ return ids[periodIndex];
+ }
+
+ // equals and hashCode implementations.
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ } else if (!(other instanceof CastTimeline)) {
+ return false;
+ }
+ CastTimeline that = (CastTimeline) other;
+ return Arrays.equals(ids, that.ids)
+ && Arrays.equals(durationsUs, that.durationsUs)
+ && Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
+ && Arrays.equals(isLive, that.isLive);
+ }
+
+ @Override
+ public int hashCode() {
+ 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;
+ }
+
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java
new file mode 100644
index 0000000000..3ebd89c8fc
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import android.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;
+import java.util.HashSet;
+
+/**
+ * Creates {@link CastTimeline CastTimelines} from cast receiver app status updates.
+ *
+ * This class keeps track of the duration reported by the current item to fill any missing
+ * durations in the media queue items [See internal: b/65152553].
+ */
+/* package */ final class CastTimelineTracker {
+
+ private final SparseArray itemIdToData;
+
+ public CastTimelineTracker() {
+ itemIdToData = new SparseArray<>();
+ }
+
+ /**
+ * Returns a {@link CastTimeline} that represents the state of the given {@code
+ * remoteMediaClient}.
+ *
+ * Returned timelines may contain values obtained from {@code remoteMediaClient} in previous
+ * invocations of this method.
+ *
+ * @param remoteMediaClient The Cast media client.
+ * @return A {@link CastTimeline} that represents the given {@code remoteMediaClient} status.
+ */
+ public CastTimeline getCastTimeline(RemoteMediaClient remoteMediaClient) {
+ int[] itemIds = remoteMediaClient.getMediaQueue().getItemIds();
+ if (itemIds.length > 0) {
+ // Only remove unused items when there is something in the queue to avoid removing all entries
+ // if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
+ removeUnusedItemDataEntries(itemIds);
+ }
+
+ // TODO: Reset state when the app instance changes [Internal ref: b/129672468].
+ MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
+ if (mediaStatus == null) {
+ return CastTimeline.EMPTY_CAST_TIMELINE;
+ }
+
+ int currentItemId = mediaStatus.getCurrentItemId();
+ updateItemData(
+ currentItemId, mediaStatus.getMediaInfo(), /* defaultPositionUs= */ C.TIME_UNSET);
+
+ for (MediaQueueItem item : mediaStatus.getQueueItems()) {
+ 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 scratchItemIds = new HashSet<>(/* initialCapacity= */ itemIds.length * 2);
+ for (int id : itemIds) {
+ scratchItemIds.add(id);
+ }
+
+ int index = 0;
+ while (index < itemIdToData.size()) {
+ if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
+ itemIdToData.removeAt(index);
+ } else {
+ index++;
+ }
+ }
+ }
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java
new file mode 100644
index 0000000000..1dc25576a0
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java
@@ -0,0 +1,121 @@
+/*
+ * 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.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;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaTrack;
+
+/**
+ * Utility methods for ExoPlayer/Cast integration.
+ */
+/* package */ final class CastUtils {
+
+ /**
+ * Returns the duration in microseconds advertised by a media info, or {@link C#TIME_UNSET} if
+ * unknown or not applicable.
+ *
+ * @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(@Nullable MediaInfo mediaInfo) {
+ if (mediaInfo == null) {
+ return C.TIME_UNSET;
+ }
+ long durationMs = mediaInfo.getStreamDuration();
+ return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET;
+ }
+
+ /**
+ * Returns a descriptive log string for the given {@code statusCode}, or "Unknown." if not one of
+ * {@link CastStatusCodes}.
+ *
+ * @param statusCode A Cast API status code.
+ * @return A descriptive log string for the given {@code statusCode}, or "Unknown." if not one of
+ * {@link CastStatusCodes}.
+ */
+ public static String getLogString(int statusCode) {
+ switch (statusCode) {
+ case CastStatusCodes.APPLICATION_NOT_FOUND:
+ return "A requested application could not be found.";
+ case CastStatusCodes.APPLICATION_NOT_RUNNING:
+ return "A requested application is not currently running.";
+ case CastStatusCodes.AUTHENTICATION_FAILED:
+ return "Authentication failure.";
+ case CastStatusCodes.CANCELED:
+ return "An in-progress request has been canceled, most likely because another action has "
+ + "preempted it.";
+ case CastStatusCodes.ERROR_SERVICE_CREATION_FAILED:
+ return "The Cast Remote Display service could not be created.";
+ case CastStatusCodes.ERROR_SERVICE_DISCONNECTED:
+ return "The Cast Remote Display service was disconnected.";
+ case CastStatusCodes.FAILED:
+ return "The in-progress request failed.";
+ case CastStatusCodes.INTERNAL_ERROR:
+ return "An internal error has occurred.";
+ case CastStatusCodes.INTERRUPTED:
+ return "A blocking call was interrupted while waiting and did not run to completion.";
+ case CastStatusCodes.INVALID_REQUEST:
+ return "An invalid request was made.";
+ case CastStatusCodes.MESSAGE_SEND_BUFFER_TOO_FULL:
+ return "A message could not be sent because there is not enough room in the send buffer at "
+ + "this time.";
+ case CastStatusCodes.MESSAGE_TOO_LARGE:
+ return "A message could not be sent because it is too large.";
+ case CastStatusCodes.NETWORK_ERROR:
+ return "Network I/O error.";
+ case CastStatusCodes.NOT_ALLOWED:
+ return "The request was disallowed and could not be completed.";
+ case CastStatusCodes.REPLACED:
+ return "The request's progress is no longer being tracked because another request of the "
+ + "same type has been made before the first request completed.";
+ case CastStatusCodes.SUCCESS:
+ return "Success.";
+ case CastStatusCodes.TIMEOUT:
+ return "An operation has timed out.";
+ case CastStatusCodes.UNKNOWN_ERROR:
+ return "An unknown, unexpected error has occurred.";
+ default:
+ return CastStatusCodes.getStatusCodeString(statusCode);
+ }
+ }
+
+ /**
+ * Creates a {@link Format} instance containing all information contained in the given
+ * {@link MediaTrack} object.
+ *
+ * @param mediaTrack The {@link MediaTrack}.
+ * @return The equivalent {@link Format}.
+ */
+ public static Format mediaTrackToFormat(MediaTrack mediaTrack) {
+ return Format.createContainerFormat(
+ mediaTrack.getContentId(),
+ /* label= */ null,
+ mediaTrack.getContentType(),
+ /* sampleMimeType= */ null,
+ /* codecs= */ null,
+ /* bitrate= */ Format.NO_VALUE,
+ /* selectionFlags= */ 0,
+ /* roleFlags= */ 0,
+ mediaTrack.getLanguage());
+ }
+
+ private CastUtils() {}
+
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java
new file mode 100644
index 0000000000..ebadb0a08a
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java
@@ -0,0 +1,65 @@
+/*
+ * 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.ext.cast;
+
+import android.content.Context;
+import com.google.android.gms.cast.CastMediaControlIntent;
+import com.google.android.gms.cast.framework.CastOptions;
+import com.google.android.gms.cast.framework.OptionsProvider;
+import com.google.android.gms.cast.framework.SessionProvider;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A convenience {@link OptionsProvider} to target the default cast receiver app.
+ */
+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.
+ *
+ * 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.
+ *
+ *
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(APP_ID_DEFAULT_RECEIVER_WITH_DRM)
+ .setStopReceiverApplicationWhenEndingSession(true)
+ .build();
+ }
+
+ @Override
+ public List getAdditionalSessionProviders(Context context) {
+ return Collections.emptyList();
+ }
+
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java
new file mode 100644
index 0000000000..098803a512
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverter.java
@@ -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 requestHeaders = new HashMap<>();
+ for (Iterator 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;
+ }
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItem.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItem.java
new file mode 100644
index 0000000000..7ac0da7078
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItem.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+
+/** Representation of a media item. */
+public final class MediaItem {
+
+ /** A builder for {@link MediaItem} instances. */
+ public static final class Builder {
+
+ @Nullable private Uri uri;
+ @Nullable private String title;
+ @Nullable private String mimeType;
+ @Nullable private DrmConfiguration drmConfiguration;
+
+ /** See {@link MediaItem#uri}. */
+ public Builder setUri(String uri) {
+ return setUri(Uri.parse(uri));
+ }
+
+ /** See {@link MediaItem#uri}. */
+ public Builder setUri(Uri uri) {
+ this.uri = uri;
+ return this;
+ }
+
+ /** See {@link MediaItem#title}. */
+ public Builder setTitle(String title) {
+ this.title = title;
+ return this;
+ }
+
+ /** See {@link MediaItem#mimeType}. */
+ public Builder setMimeType(String mimeType) {
+ this.mimeType = mimeType;
+ return this;
+ }
+
+ /** See {@link MediaItem#drmConfiguration}. */
+ public Builder setDrmConfiguration(DrmConfiguration drmConfiguration) {
+ this.drmConfiguration = drmConfiguration;
+ return this;
+ }
+
+ /** Returns a new {@link MediaItem} instance with the current builder values. */
+ public MediaItem build() {
+ Assertions.checkNotNull(uri);
+ return new MediaItem(uri, title, mimeType, drmConfiguration);
+ }
+ }
+
+ /** DRM configuration for a media item. */
+ public static final class DrmConfiguration {
+
+ /** The UUID of the protection scheme. */
+ public final UUID uuid;
+
+ /**
+ * Optional license server {@link Uri}. If {@code null} then the license server must be
+ * specified by the media.
+ */
+ @Nullable public final Uri licenseUri;
+
+ /** Headers that should be attached to any license requests. */
+ public final Map requestHeaders;
+
+ /**
+ * Creates an instance.
+ *
+ * @param uuid See {@link #uuid}.
+ * @param licenseUri See {@link #licenseUri}.
+ * @param requestHeaders See {@link #requestHeaders}.
+ */
+ public DrmConfiguration(
+ UUID uuid, @Nullable Uri licenseUri, @Nullable Map requestHeaders) {
+ this.uuid = uuid;
+ this.licenseUri = licenseUri;
+ this.requestHeaders =
+ requestHeaders == null
+ ? Collections.emptyMap()
+ : Collections.unmodifiableMap(requestHeaders);
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || getClass() != obj.getClass()) {
+ return false;
+ }
+
+ DrmConfiguration other = (DrmConfiguration) obj;
+ return uuid.equals(other.uuid)
+ && Util.areEqual(licenseUri, other.licenseUri)
+ && requestHeaders.equals(other.requestHeaders);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = uuid.hashCode();
+ result = 31 * result + (licenseUri != null ? licenseUri.hashCode() : 0);
+ result = 31 * result + requestHeaders.hashCode();
+ return result;
+ }
+ }
+
+ /** The media {@link Uri}. */
+ public final Uri uri;
+
+ /** The title of the item, or {@code null} if unspecified. */
+ @Nullable public final String title;
+
+ /** The mime type for the media, or {@code null} if unspecified. */
+ @Nullable public final String mimeType;
+
+ /** Optional {@link DrmConfiguration} for the media. */
+ @Nullable public final DrmConfiguration drmConfiguration;
+
+ private MediaItem(
+ Uri uri,
+ @Nullable String title,
+ @Nullable String mimeType,
+ @Nullable DrmConfiguration drmConfiguration) {
+ this.uri = uri;
+ this.title = title;
+ this.mimeType = mimeType;
+ this.drmConfiguration = drmConfiguration;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || getClass() != obj.getClass()) {
+ return false;
+ }
+ MediaItem other = (MediaItem) obj;
+ return uri.equals(other.uri)
+ && Util.areEqual(title, other.title)
+ && Util.areEqual(mimeType, other.mimeType)
+ && Util.areEqual(drmConfiguration, other.drmConfiguration);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = uri.hashCode();
+ result = 31 * result + (title == null ? 0 : title.hashCode());
+ result = 31 * result + (drmConfiguration == null ? 0 : drmConfiguration.hashCode());
+ result = 31 * result + (mimeType == null ? 0 : mimeType.hashCode());
+ return result;
+ }
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemConverter.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemConverter.java
new file mode 100644
index 0000000000..23633aa4d2
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemConverter.java
@@ -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);
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java
new file mode 100644
index 0000000000..c686c496c6
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/SessionAvailabilityListener.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+/** Listener of changes in the cast session availability. */
+public interface SessionAvailabilityListener {
+
+ /** Called when a cast session becomes available to the player. */
+ void onCastSessionAvailable();
+
+ /** Called when the cast session becomes unavailable. */
+ void onCastSessionUnavailable();
+}
diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/package-info.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/package-info.java
new file mode 100644
index 0000000000..07055905a6
--- /dev/null
+++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/package-info.java
@@ -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;
diff --git a/extensions/cast/src/test/AndroidManifest.xml b/extensions/cast/src/test/AndroidManifest.xml
new file mode 100644
index 0000000000..35a5150a47
--- /dev/null
+++ b/extensions/cast/src/test/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java
new file mode 100644
index 0000000000..55a9b22f9b
--- /dev/null
+++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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 mockPendingResult;
+
+ @Captor
+ private ArgumentCaptor>
+ setResultCallbackArgumentCaptor;
+
+ @Captor private ArgumentCaptor 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.play();
+ verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
+ assertThat(castPlayer.getPlayWhenReady()).isTrue();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+
+ // There is a status update in the middle, which should be hidden by masking.
+ remoteMediaClientListener.onStatusUpdated();
+ 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.play();
+ verify(mockPendingResult).setResultCallback(setResultCallbackArgumentCaptor.capture());
+ assertThat(castPlayer.getPlayWhenReady()).isTrue();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+
+ // 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);
+ verify(mockListener).onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+ }
+
+ @Test
+ public void testSetPlayWhenReady_correctChangeReasonOnPause() {
+ when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult);
+ when(mockRemoteMediaClient.pause()).thenReturn(mockPendingResult);
+ castPlayer.play();
+ assertThat(castPlayer.getPlayWhenReady()).isTrue();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+
+ castPlayer.pause();
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+ verify(mockListener).onPlayerStateChanged(false, Player.STATE_IDLE);
+ verify(mockListener)
+ .onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+ }
+
+ @Test
+ public void testPlayWhenReady_changesOnStatusUpdates() {
+ assertThat(castPlayer.getPlayWhenReady()).isFalse();
+ when(mockRemoteMediaClient.isPaused()).thenReturn(false);
+ remoteMediaClientListener.onStatusUpdated();
+ verify(mockListener).onPlayerStateChanged(true, Player.STATE_IDLE);
+ verify(mockListener).onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE);
+ 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);
+ }
+}
diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java
new file mode 100644
index 0000000000..69b25e4456
--- /dev/null
+++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.testutil.TimelineAsserts;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaStatus;
+import com.google.android.gms.cast.framework.media.MediaQueue;
+import com.google.android.gms.cast.framework.media.RemoteMediaClient;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+/** Tests for {@link CastTimelineTracker}. */
+@RunWith(AndroidJUnit4.class)
+public class CastTimelineTrackerTest {
+
+ private static final long DURATION_2_MS = 2000;
+ private static final long DURATION_3_MS = 3000;
+ private static final long DURATION_4_MS = 4000;
+ private static final long DURATION_5_MS = 5000;
+
+ /** Tests that duration of the current media info is correctly propagated to the timeline. */
+ @Test
+ public void testGetCastTimelinePersistsDuration() {
+ CastTimelineTracker tracker = new CastTimelineTracker();
+
+ RemoteMediaClient remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3, 4, 5},
+ /* currentItemId= */ 2,
+ /* currentDurationMs= */ DURATION_2_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.msToUs(DURATION_2_MS),
+ C.TIME_UNSET,
+ C.TIME_UNSET,
+ C.TIME_UNSET);
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3},
+ /* currentItemId= */ 3,
+ /* currentDurationMs= */ DURATION_3_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.msToUs(DURATION_2_MS),
+ C.msToUs(DURATION_3_MS));
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 3},
+ /* currentItemId= */ 3,
+ /* currentDurationMs= */ DURATION_3_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient), C.TIME_UNSET, C.msToUs(DURATION_3_MS));
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3, 4, 5},
+ /* currentItemId= */ 4,
+ /* currentDurationMs= */ DURATION_4_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.TIME_UNSET,
+ C.msToUs(DURATION_3_MS),
+ C.msToUs(DURATION_4_MS),
+ C.TIME_UNSET);
+
+ remoteMediaClient =
+ mockRemoteMediaClient(
+ /* itemIds= */ new int[] {1, 2, 3, 4, 5},
+ /* currentItemId= */ 5,
+ /* currentDurationMs= */ DURATION_5_MS);
+ TimelineAsserts.assertPeriodDurations(
+ tracker.getCastTimeline(remoteMediaClient),
+ C.TIME_UNSET,
+ C.TIME_UNSET,
+ C.msToUs(DURATION_3_MS),
+ C.msToUs(DURATION_4_MS),
+ C.msToUs(DURATION_5_MS));
+ }
+
+ private static RemoteMediaClient mockRemoteMediaClient(
+ int[] itemIds, int currentItemId, long currentDurationMs) {
+ RemoteMediaClient remoteMediaClient = Mockito.mock(RemoteMediaClient.class);
+ MediaStatus status = Mockito.mock(MediaStatus.class);
+ Mockito.when(status.getQueueItems()).thenReturn(Collections.emptyList());
+ Mockito.when(remoteMediaClient.getMediaStatus()).thenReturn(status);
+ Mockito.when(status.getMediaInfo()).thenReturn(getMediaInfo(currentDurationMs));
+ Mockito.when(status.getCurrentItemId()).thenReturn(currentItemId);
+ MediaQueue mediaQueue = mockMediaQueue(itemIds);
+ Mockito.when(remoteMediaClient.getMediaQueue()).thenReturn(mediaQueue);
+ return remoteMediaClient;
+ }
+
+ private static MediaQueue mockMediaQueue(int[] itemIds) {
+ MediaQueue mediaQueue = Mockito.mock(MediaQueue.class);
+ Mockito.when(mediaQueue.getItemIds()).thenReturn(itemIds);
+ return mediaQueue;
+ }
+
+ private static MediaInfo getMediaInfo(long durationMs) {
+ return new MediaInfo.Builder(/*contentId= */ "")
+ .setStreamDuration(durationMs)
+ .setContentType(MimeTypes.APPLICATION_MP4)
+ .setStreamType(MediaInfo.STREAM_TYPE_NONE)
+ .build();
+ }
+}
diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java
new file mode 100644
index 0000000000..cf9b9d3496
--- /dev/null
+++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/DefaultMediaItemConverterTest.java
@@ -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);
+ }
+}
diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/MediaItemTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/MediaItemTest.java
new file mode 100644
index 0000000000..7b410a8fbc
--- /dev/null
+++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/MediaItemTest.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cast;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.util.MimeTypes;
+import java.util.HashMap;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test for {@link MediaItem}. */
+@RunWith(AndroidJUnit4.class)
+public class MediaItemTest {
+
+ @Test
+ public void buildMediaItem_doesNotChangeState() {
+ MediaItem.Builder builder = new MediaItem.Builder();
+ MediaItem item1 =
+ builder
+ .setUri(Uri.parse("http://example.com"))
+ .setTitle("title")
+ .setMimeType(MimeTypes.AUDIO_MP4)
+ .build();
+ MediaItem item2 = builder.build();
+ assertThat(item1).isEqualTo(item2);
+ }
+
+ @Test
+ public void equals_withEqualDrmSchemes_returnsTrue() {
+ MediaItem.Builder builder1 = new MediaItem.Builder();
+ MediaItem mediaItem1 =
+ builder1
+ .setUri(Uri.parse("www.google.com"))
+ .setDrmConfiguration(buildDrmConfiguration(1))
+ .build();
+ MediaItem.Builder builder2 = new MediaItem.Builder();
+ MediaItem mediaItem2 =
+ builder2
+ .setUri(Uri.parse("www.google.com"))
+ .setDrmConfiguration(buildDrmConfiguration(1))
+ .build();
+ assertThat(mediaItem1).isEqualTo(mediaItem2);
+ }
+
+ @Test
+ public void equals_withDifferentDrmRequestHeaders_returnsFalse() {
+ MediaItem.Builder builder1 = new MediaItem.Builder();
+ MediaItem mediaItem1 =
+ builder1
+ .setUri(Uri.parse("www.google.com"))
+ .setDrmConfiguration(buildDrmConfiguration(1))
+ .build();
+ MediaItem.Builder builder2 = new MediaItem.Builder();
+ MediaItem mediaItem2 =
+ builder2
+ .setUri(Uri.parse("www.google.com"))
+ .setDrmConfiguration(buildDrmConfiguration(2))
+ .build();
+ assertThat(mediaItem1).isNotEqualTo(mediaItem2);
+ }
+
+ private static MediaItem.DrmConfiguration buildDrmConfiguration(int seed) {
+ HashMap 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);
+ }
+}
diff --git a/extensions/cronet/README.md b/extensions/cronet/README.md
index a570385a52..dc64b862b6 100644
--- a/extensions/cronet/README.md
+++ b/extensions/cronet/README.md
@@ -1,32 +1,55 @@
-# ExoPlayer Cronet Extension #
+# ExoPlayer Cronet extension #
-## Description ##
+The Cronet extension is an [HttpDataSource][] implementation using [Cronet][].
-[Cronet][] is Chromium's Networking stack packaged as a library.
-
-The Cronet Extension is an [HttpDataSource][] implementation using [Cronet][].
-
-[HttpDataSource]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer/upstream/HttpDataSource.html
+[HttpDataSource]: https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/upstream/HttpDataSource.html
[Cronet]: https://chromium.googlesource.com/chromium/src/+/master/components/cronet?autodive=0%2F%2F
-## Build Instructions ##
+## Getting the extension ##
-* Checkout ExoPlayer along with Extensions:
+The easiest way to use the extension is to add it as a gradle dependency:
-```
-git clone https://github.com/google/ExoPlayer.git
+```gradle
+implementation 'com.google.android.exoplayer:extension-cronet:2.X.X'
```
-* Get the Cronet libraries:
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
-1. Find the latest Cronet release [here][] and navigate to its `Release/cronet`
- directory
-1. Download `cronet_api.jar`, `cronet_impl_common_java.jar`,
- `cronet_impl_native_java.jar` and the `libs` directory
-1. Copy the three jar files into the `libs` directory of this extension
-1. Copy the content of the downloaded `libs` directory into the `jniLibs`
- directory of this extension
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
-* In ExoPlayer's `settings.gradle` file, uncomment the Cronet extension
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
-[here]: https://console.cloud.google.com/storage/browser/chromium-cronet/android
+## Using the extension ##
+
+ExoPlayer requests data through `DataSource` instances. These instances are
+either instantiated and injected from application code, or obtained from
+instances of `DataSource.Factory` that are instantiated and injected from
+application code.
+
+If your application only needs to play http(s) content, using the Cronet
+extension is as simple as updating any `DataSource`s and `DataSource.Factory`
+instantiations in your application code to use `CronetDataSource` and
+`CronetDataSourceFactory` respectively. If your application also needs to play
+non-http(s) content such as local files, use
+```
+new DefaultDataSource(
+ ...
+ new CronetDataSource(...) /* baseDataSource argument */);
+```
+and
+```
+new DefaultDataSourceFactory(
+ ...
+ new CronetDataSourceFactory(...) /* baseDataSourceFactory argument */);
+```
+respectively.
+
+## Links ##
+
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.cronet.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/cronet/build.gradle b/extensions/cronet/build.gradle
index 5611817b2e..d5b7a99f96 100644
--- a/extensions/cronet/build.gradle
+++ b/extensions/cronet/build.gradle
@@ -11,36 +11,42 @@
// WITHOUT 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
- buildToolsVersion project.ext.buildToolsVersion
defaultConfig {
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
- testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
- sourceSets.main {
- jniLibs.srcDirs = ['jniLibs']
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
}
+
+ testOptions.unitTests.includeAndroidResources = true
}
dependencies {
- compile project(':library-core')
- compile files('libs/cronet_api.jar')
- compile files('libs/cronet_impl_common_java.jar')
- compile files('libs/cronet_impl_native_java.jar')
- androidTestCompile project(':library')
- androidTestCompile 'com.google.dexmaker:dexmaker:' + dexmakerVersion
- androidTestCompile 'com.google.dexmaker:dexmaker-mockito:' + dexmakerVersion
- androidTestCompile 'org.mockito:mockito-core:' + mockitoVersion
- androidTestCompile 'com.android.support.test:runner:' + testSupportLibraryVersion
+ api 'org.chromium.net:cronet-embedded:76.3809.111'
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ testImplementation project(modulePrefix + 'library')
+ testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {
javadocTitle = 'Cronet extension'
}
apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-cronet'
+ releaseDescription = 'Cronet extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/cronet/jniLibs/README.md b/extensions/cronet/jniLibs/README.md
deleted file mode 100644
index e9f0717ae6..0000000000
--- a/extensions/cronet/jniLibs/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Copy folders containing architecture specific .so files here.
diff --git a/extensions/cronet/libs/README.md b/extensions/cronet/libs/README.md
deleted file mode 100644
index 641a80db18..0000000000
--- a/extensions/cronet/libs/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Copy cronet.jar and cronet_api.jar here.
diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
deleted file mode 100644
index 246e23e172..0000000000
--- a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
+++ /dev/null
@@ -1,809 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.exoplayer2.ext.cronet;
-
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.spy;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-import static org.mockito.MockitoAnnotations.initMocks;
-
-import android.net.Uri;
-import android.os.ConditionVariable;
-import android.support.test.InstrumentationRegistry;
-import android.support.test.runner.AndroidJUnit4;
-import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.upstream.DataSpec;
-import com.google.android.exoplayer2.upstream.HttpDataSource;
-import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException;
-import com.google.android.exoplayer2.upstream.TransferListener;
-import com.google.android.exoplayer2.util.Clock;
-import com.google.android.exoplayer2.util.Predicate;
-import java.io.IOException;
-import java.net.SocketTimeoutException;
-import java.net.UnknownHostException;
-import java.nio.ByteBuffer;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.Executor;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.chromium.net.CronetEngine;
-import org.chromium.net.NetworkException;
-import org.chromium.net.UrlRequest;
-import org.chromium.net.UrlResponseInfo;
-import org.chromium.net.impl.UrlResponseInfoImpl;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.stubbing.Answer;
-
-/**
- * Tests for {@link CronetDataSource}.
- */
-@RunWith(AndroidJUnit4.class)
-public final class CronetDataSourceTest {
-
- private static final int TEST_CONNECT_TIMEOUT_MS = 100;
- private static final int TEST_READ_TIMEOUT_MS = 50;
- private static final String TEST_URL = "http://google.com";
- private static final String TEST_CONTENT_TYPE = "test/test";
- private static final byte[] TEST_POST_BODY = "test post body".getBytes();
- private static final long TEST_CONTENT_LENGTH = 16000L;
- private static final int TEST_CONNECTION_STATUS = 5;
-
- private DataSpec testDataSpec;
- private DataSpec testPostDataSpec;
- private Map testResponseHeader;
- private UrlResponseInfo testUrlResponseInfo;
-
- @Mock private UrlRequest.Builder mockUrlRequestBuilder;
- @Mock
- private UrlRequest mockUrlRequest;
- @Mock
- private Predicate mockContentTypePredicate;
- @Mock
- private TransferListener mockTransferListener;
- @Mock
- private Clock mockClock;
- @Mock
- private Executor mockExecutor;
- @Mock
- private NetworkException mockNetworkException;
- @Mock private CronetEngine mockCronetEngine;
-
- private CronetDataSource dataSourceUnderTest;
-
- @Before
- public void setUp() throws Exception {
- System.setProperty("dexmaker.dexcache",
- InstrumentationRegistry.getTargetContext().getCacheDir().getPath());
- initMocks(this);
- dataSourceUnderTest = spy(
- new CronetDataSource(
- mockCronetEngine,
- mockExecutor,
- mockContentTypePredicate,
- mockTransferListener,
- TEST_CONNECT_TIMEOUT_MS,
- TEST_READ_TIMEOUT_MS,
- true, // resetTimeoutOnRedirects
- mockClock,
- null));
- when(mockContentTypePredicate.evaluate(anyString())).thenReturn(true);
- when(mockCronetEngine.newUrlRequestBuilder(
- anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
- .thenReturn(mockUrlRequestBuilder);
- when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest);
- mockStatusResponse();
-
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, C.LENGTH_UNSET, null);
- testPostDataSpec = new DataSpec(
- Uri.parse(TEST_URL), TEST_POST_BODY, 0, 0, C.LENGTH_UNSET, null, 0);
- testResponseHeader = new HashMap<>();
- testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE);
- // This value can be anything since the DataSpec is unset.
- testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH));
- testUrlResponseInfo = createUrlResponseInfo(200); // statusCode
- }
-
- private UrlResponseInfo createUrlResponseInfo(int statusCode) {
- ArrayList> responseHeaderList = new ArrayList<>();
- responseHeaderList.addAll(testResponseHeader.entrySet());
- return new UrlResponseInfoImpl(
- Collections.singletonList(TEST_URL),
- statusCode,
- null, // httpStatusText
- responseHeaderList,
- false, // wasCached
- null, // negotiatedProtocol
- null); // proxyServer
- }
-
- @Test(expected = IllegalStateException.class)
- public void testOpeningTwiceThrows() throws HttpDataSourceException {
- mockResponseStartSuccess();
- dataSourceUnderTest.open(testDataSpec);
- dataSourceUnderTest.open(testDataSpec);
- }
-
- @Test
- public void testCallbackFromPreviousRequest() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- dataSourceUnderTest.open(testDataSpec);
- dataSourceUnderTest.close();
- // Prepare a mock UrlRequest to be used in the second open() call.
- final UrlRequest mockUrlRequest2 = mock(UrlRequest.class);
- when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2);
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- // Invoke the callback for the previous request.
- dataSourceUnderTest.onFailed(
- mockUrlRequest,
- testUrlResponseInfo,
- mockNetworkException);
- dataSourceUnderTest.onResponseStarted(
- mockUrlRequest2,
- testUrlResponseInfo);
- return null;
- }
- }).when(mockUrlRequest2).start();
- dataSourceUnderTest.open(testDataSpec);
- }
-
- @Test
- public void testRequestStartCalled() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- dataSourceUnderTest.open(testDataSpec);
- verify(mockCronetEngine)
- .newUrlRequestBuilder(eq(TEST_URL), any(UrlRequest.Callback.class), any(Executor.class));
- verify(mockUrlRequest).start();
- }
-
- @Test
- public void testRequestHeadersSet() 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");
- verify(mockUrlRequest).start();
- }
-
- @Test
- public void testRequestOpen() throws HttpDataSourceException {
- mockResponseStartSuccess();
- assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testDataSpec));
- verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
-
- @Test
- public void testRequestOpenGzippedCompressedReturnsDataSpecLength()
- throws HttpDataSourceException {
- testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 5000, null);
- testResponseHeader.put("Content-Encoding", "gzip");
- testResponseHeader.put("Content-Length", Long.toString(50L));
- mockResponseStartSuccess();
-
- assertEquals(5000 /* contentLength */, dataSourceUnderTest.open(testDataSpec));
- verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
-
- @Test
- public void testRequestOpenFail() {
- mockResponseStartFailure();
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- // Check for connection not automatically closed.
- assertFalse(e.getCause() instanceof UnknownHostException);
- verify(mockUrlRequest, never()).cancel();
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
- }
-
- @Test
- public void testRequestOpenFailDueToDnsFailure() {
- mockResponseStartFailure();
- when(mockNetworkException.getErrorCode()).thenReturn(
- NetworkException.ERROR_HOSTNAME_NOT_RESOLVED);
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- // Check for connection not automatically closed.
- assertTrue(e.getCause() instanceof UnknownHostException);
- verify(mockUrlRequest, never()).cancel();
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
- }
-
- @Test
- public void testRequestOpenValidatesStatusCode() {
- mockResponseStartSuccess();
- testUrlResponseInfo = createUrlResponseInfo(500); // statusCode
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- assertTrue(e instanceof HttpDataSource.InvalidResponseCodeException);
- // Check for connection not automatically closed.
- verify(mockUrlRequest, never()).cancel();
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
- }
-
- @Test
- public void testRequestOpenValidatesContentTypePredicate() {
- mockResponseStartSuccess();
- when(mockContentTypePredicate.evaluate(anyString())).thenReturn(false);
-
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- assertTrue(e instanceof HttpDataSource.InvalidContentTypeException);
- // Check for connection not automatically closed.
- verify(mockUrlRequest, never()).cancel();
- verify(mockContentTypePredicate).evaluate(TEST_CONTENT_TYPE);
- }
- }
-
- @Test
- public void testPostRequestOpen() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
- assertEquals(TEST_CONTENT_LENGTH, dataSourceUnderTest.open(testPostDataSpec));
- verify(mockTransferListener).onTransferStart(dataSourceUnderTest, testPostDataSpec);
- }
-
- @Test
- public void testPostRequestOpenValidatesContentType() {
- mockResponseStartSuccess();
-
- try {
- dataSourceUnderTest.open(testPostDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- verify(mockUrlRequest, never()).start();
- }
- }
-
- @Test
- public void testPostRequestOpenRejects307Redirects() {
- mockResponseStartSuccess();
- mockResponseStartRedirect();
-
- try {
- dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
- dataSourceUnderTest.open(testPostDataSpec);
- fail("HttpDataSource.HttpDataSourceException expected");
- } catch (HttpDataSourceException e) {
- verify(mockUrlRequest, never()).followRedirect();
- }
- }
-
- @Test
- public void testRequestReadTwice() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[8];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer);
- assertEquals(8, bytesRead);
-
- returnedBuffer = new byte[8];
- bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertArrayEquals(buildTestDataArray(8, 8), returnedBuffer);
- assertEquals(8, bytesRead);
-
- // Should have only called read on cronet once.
- verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
- verify(mockTransferListener, times(2)).onBytesTransferred(dataSourceUnderTest, 8);
- }
-
- @Test
- public void testSecondRequestNoContentLength() throws HttpDataSourceException {
- mockResponseStartSuccess();
- testResponseHeader.put("Content-Length", Long.toString(1L));
- mockReadSuccess(0, 16);
-
- // First request.
- dataSourceUnderTest.open(testDataSpec);
- byte[] returnedBuffer = new byte[8];
- dataSourceUnderTest.read(returnedBuffer, 0, 1);
- dataSourceUnderTest.close();
-
- testResponseHeader.remove("Content-Length");
- mockReadSuccess(0, 16);
-
- // Second request.
- dataSourceUnderTest.open(testDataSpec);
- returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
- assertEquals(10, bytesRead);
- bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
- assertEquals(6, bytesRead);
- bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
- assertEquals(C.RESULT_END_OF_INPUT, bytesRead);
- }
-
- @Test
- public void testReadWithOffset() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
- assertEquals(8, bytesRead);
- assertArrayEquals(prefixZeros(buildTestDataArray(0, 8), 16), returnedBuffer);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 8);
- }
-
- @Test
- public void testRangeRequestWith206Response() 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);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
- assertEquals(16, bytesRead);
- assertArrayEquals(buildTestDataArray(1000, 16), returnedBuffer);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16);
- }
-
- @Test
- public void testRangeRequestWith200Response() throws HttpDataSourceException {
- 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);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
- assertEquals(16, bytesRead);
- assertArrayEquals(buildTestDataArray(1000, 16), returnedBuffer);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16);
- }
-
- @Test
- public void testReadWithUnsetLength() throws HttpDataSourceException {
- testResponseHeader.remove("Content-Length");
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[16];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
- assertArrayEquals(prefixZeros(buildTestDataArray(0, 8), 16), returnedBuffer);
- assertEquals(8, bytesRead);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 8);
- }
-
- @Test
- public void testReadReturnsWhatItCan() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[24];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24);
- assertArrayEquals(suffixZeros(buildTestDataArray(0, 16), 24), returnedBuffer);
- assertEquals(16, bytesRead);
- verify(mockTransferListener).onBytesTransferred(dataSourceUnderTest, 16);
- }
-
- @Test
- public void testClosedMeansClosed() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadSuccess(0, 16);
-
- int bytesRead = 0;
- dataSourceUnderTest.open(testDataSpec);
-
- byte[] returnedBuffer = new byte[8];
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer);
- assertEquals(8, bytesRead);
-
- dataSourceUnderTest.close();
- verify(mockTransferListener).onTransferEnd(dataSourceUnderTest);
-
- try {
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
- fail();
- } catch (IllegalStateException e) {
- // Expected.
- }
-
- // 16 bytes were attempted but only 8 should have been successfully read.
- assertEquals(8, bytesRead);
- }
-
- @Test
- public void testOverread() 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);
-
- byte[] returnedBuffer = new byte[8];
- int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertEquals(8, bytesRead);
- assertArrayEquals(buildTestDataArray(0, 8), returnedBuffer);
-
- // The current buffer is kept if not completely consumed by DataSource reader.
- returnedBuffer = new byte[8];
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6);
- assertEquals(14, bytesRead);
- assertArrayEquals(suffixZeros(buildTestDataArray(8, 6), 8), returnedBuffer);
-
- // 2 bytes left at this point.
- returnedBuffer = new byte[8];
- bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
- assertEquals(16, bytesRead);
- assertArrayEquals(suffixZeros(buildTestDataArray(14, 2), 8), returnedBuffer);
-
- // Should have only called read on cronet once.
- verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
- verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 8);
- verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 6);
- verify(mockTransferListener, times(1)).onBytesTransferred(dataSourceUnderTest, 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 = new byte[16];
- int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
- assertEquals(C.RESULT_END_OF_INPUT, bytesOverRead);
- assertArrayEquals(new byte[16], returnedBuffer);
- // C.RESULT_END_OF_INPUT should not be reported though the TransferListener.
- verify(mockTransferListener, never()).onBytesTransferred(dataSourceUnderTest,
- C.RESULT_END_OF_INPUT);
- // There should still be only one call to read on cronet.
- verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
- // Check for connection not automatically closed.
- verify(mockUrlRequest, never()).cancel();
- assertEquals(16, bytesRead);
- }
-
- @Test
- public void testConnectTimeout() {
- when(mockClock.elapsedRealtime()).thenReturn(0L);
- final ConditionVariable startCondition = buildUrlRequestStartedCondition();
- final ConditionVariable timedOutCondition = new ConditionVariable();
-
- new Thread() {
- @Override
- public void run() {
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail();
- } catch (HttpDataSourceException e) {
- // Expected.
- assertTrue(e instanceof CronetDataSource.OpenException);
- assertTrue(e.getCause() instanceof SocketTimeoutException);
- assertEquals(
- TEST_CONNECTION_STATUS,
- ((CronetDataSource.OpenException) e).cronetConnectionStatus);
- timedOutCondition.open();
- }
- }
- }.start();
- startCondition.block();
-
- // We should still be trying to open.
- assertFalse(timedOutCondition.block(50));
- // We should still be trying to open as we approach the timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1);
- assertFalse(timedOutCondition.block(50));
- // Now we timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS);
- timedOutCondition.block();
-
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- }
-
- @Test
- public void testConnectResponseBeforeTimeout() {
- when(mockClock.elapsedRealtime()).thenReturn(0L);
- final ConditionVariable startCondition = buildUrlRequestStartedCondition();
- final ConditionVariable openCondition = new ConditionVariable();
-
- new Thread() {
- @Override
- public void run() {
- try {
- dataSourceUnderTest.open(testDataSpec);
- openCondition.open();
- } catch (HttpDataSourceException e) {
- fail();
- }
- }
- }.start();
- startCondition.block();
-
- // We should still be trying to open.
- assertFalse(openCondition.block(50));
- // We should still be trying to open as we approach the timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1);
- assertFalse(openCondition.block(50));
- // The response arrives just in time.
- dataSourceUnderTest.onResponseStarted(mockUrlRequest, testUrlResponseInfo);
- openCondition.block();
- }
-
- @Test
- public void testRedirectIncreasesConnectionTimeout() throws InterruptedException {
- when(mockClock.elapsedRealtime()).thenReturn(0L);
- final ConditionVariable startCondition = buildUrlRequestStartedCondition();
- final ConditionVariable timedOutCondition = new ConditionVariable();
- final AtomicInteger openExceptions = new AtomicInteger(0);
-
- new Thread() {
- @Override
- public void run() {
- try {
- dataSourceUnderTest.open(testDataSpec);
- fail();
- } catch (HttpDataSourceException e) {
- // Expected.
- assertTrue(e instanceof CronetDataSource.OpenException);
- assertTrue(e.getCause() instanceof SocketTimeoutException);
- openExceptions.getAndIncrement();
- timedOutCondition.open();
- }
- }
- }.start();
- startCondition.block();
-
- // We should still be trying to open.
- assertFalse(timedOutCondition.block(50));
- // We should still be trying to open as we approach the timeout.
- when(mockClock.elapsedRealtime()).thenReturn((long) TEST_CONNECT_TIMEOUT_MS - 1);
- assertFalse(timedOutCondition.block(50));
- // A redirect arrives just in time.
- dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo,
- "RandomRedirectedUrl1");
-
- long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1;
- when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1);
- // Give the thread some time to run.
- assertFalse(timedOutCondition.block(newTimeoutMs));
- // We should still be trying to open as we approach the new timeout.
- assertFalse(timedOutCondition.block(50));
- // A redirect arrives just in time.
- dataSourceUnderTest.onRedirectReceived(mockUrlRequest, testUrlResponseInfo,
- "RandomRedirectedUrl2");
-
- newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2;
- when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs - 1);
- // Give the thread some time to run.
- assertFalse(timedOutCondition.block(newTimeoutMs));
- // We should still be trying to open as we approach the new timeout.
- assertFalse(timedOutCondition.block(50));
- // Now we timeout.
- when(mockClock.elapsedRealtime()).thenReturn(newTimeoutMs);
- timedOutCondition.block();
-
- verify(mockTransferListener, never()).onTransferStart(dataSourceUnderTest, testDataSpec);
- assertEquals(1, openExceptions.get());
- }
-
- @Test
- public void testExceptionFromTransferListener() throws HttpDataSourceException {
- mockResponseStartSuccess();
-
- // Make mockTransferListener throw an exception in CronetDataSource.close(). Ensure that
- // the subsequent open() call succeeds.
- doThrow(new NullPointerException()).when(mockTransferListener).onTransferEnd(
- dataSourceUnderTest);
- dataSourceUnderTest.open(testDataSpec);
- try {
- dataSourceUnderTest.close();
- fail("NullPointerException expected");
- } catch (NullPointerException e) {
- // Expected.
- }
- // Open should return successfully.
- dataSourceUnderTest.open(testDataSpec);
- }
-
- @Test
- public void testReadFailure() throws HttpDataSourceException {
- mockResponseStartSuccess();
- mockReadFailure();
-
- dataSourceUnderTest.open(testDataSpec);
- byte[] returnedBuffer = new byte[8];
- try {
- dataSourceUnderTest.read(returnedBuffer, 0, 8);
- fail("dataSourceUnderTest.read() returned, but IOException expected");
- } catch (IOException e) {
- // Expected.
- }
- }
-
- // Helper methods.
-
- private void mockStatusResponse() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- UrlRequest.StatusListener statusListener =
- (UrlRequest.StatusListener) invocation.getArguments()[0];
- statusListener.onStatus(TEST_CONNECTION_STATUS);
- return null;
- }
- }).when(mockUrlRequest).getStatus(any(UrlRequest.StatusListener.class));
- }
-
- private void mockResponseStartSuccess() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onResponseStarted(
- mockUrlRequest,
- testUrlResponseInfo);
- return null;
- }
- }).when(mockUrlRequest).start();
- }
-
- private void mockResponseStartRedirect() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onRedirectReceived(
- mockUrlRequest,
- createUrlResponseInfo(307), // statusCode
- "http://redirect.location.com");
- return null;
- }
- }).when(mockUrlRequest).start();
- }
-
- private void mockResponseStartFailure() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onFailed(
- mockUrlRequest,
- createUrlResponseInfo(500), // statusCode
- mockNetworkException);
- return null;
- }
- }).when(mockUrlRequest).start();
- }
-
- private void mockReadSuccess(int position, int length) {
- final int[] positionAndRemaining = new int[] {position, length};
- doAnswer(new Answer() {
- @Override
- public Void answer(InvocationOnMock invocation) throws Throwable {
- if (positionAndRemaining[1] == 0) {
- dataSourceUnderTest.onSucceeded(mockUrlRequest, testUrlResponseInfo);
- } else {
- ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0];
- int readLength = Math.min(positionAndRemaining[1], inputBuffer.remaining());
- inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength));
- positionAndRemaining[0] += readLength;
- positionAndRemaining[1] -= readLength;
- dataSourceUnderTest.onReadCompleted(
- mockUrlRequest,
- testUrlResponseInfo,
- inputBuffer);
- }
- return null;
- }
- }).when(mockUrlRequest).read(any(ByteBuffer.class));
- }
-
- private void mockReadFailure() {
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- dataSourceUnderTest.onFailed(
- mockUrlRequest,
- createUrlResponseInfo(500), // statusCode
- mockNetworkException);
- return null;
- }
- }).when(mockUrlRequest).read(any(ByteBuffer.class));
- }
-
- private ConditionVariable buildUrlRequestStartedCondition() {
- final ConditionVariable startedCondition = new ConditionVariable();
- doAnswer(new Answer() {
- @Override
- public Object answer(InvocationOnMock invocation) throws Throwable {
- startedCondition.open();
- return null;
- }
- }).when(mockUrlRequest).start();
- return startedCondition;
- }
-
- private static byte[] buildTestDataArray(int position, int length) {
- return buildTestDataBuffer(position, length).array();
- }
-
- public static byte[] prefixZeros(byte[] data, int requiredLength) {
- byte[] prefixedData = new byte[requiredLength];
- System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length);
- return prefixedData;
- }
-
- public static byte[] suffixZeros(byte[] data, int requiredLength) {
- return Arrays.copyOf(data, requiredLength);
- }
-
- private static ByteBuffer buildTestDataBuffer(int position, int length) {
- ByteBuffer testBuffer = ByteBuffer.allocate(length);
- for (int i = 0; i < length; i++) {
- testBuffer.put((byte) (position + i));
- }
- testBuffer.flip();
- return testBuffer;
- }
-
-}
diff --git a/extensions/cronet/src/main/AndroidManifest.xml b/extensions/cronet/src/main/AndroidManifest.xml
index c81d95f104..5ba54999f4 100644
--- a/extensions/cronet/src/main/AndroidManifest.xml
+++ b/extensions/cronet/src/main/AndroidManifest.xml
@@ -14,7 +14,7 @@
-->
+ package="com.google.android.exoplayer2.ext.cronet">
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java
index 4f15a6eabc..1903e33995 100644
--- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSource.java
@@ -15,29 +15,35 @@
*/
package com.google.android.exoplayer2.ext.cronet;
+import static com.google.android.exoplayer2.util.Util.castNonNull;
+
import android.net.Uri;
-import android.os.ConditionVariable;
import android.text.TextUtils;
-import android.util.Log;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.HttpDataSource;
-import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Clock;
+import com.google.android.exoplayer2.util.ConditionVariable;
+import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Predicate;
-import com.google.android.exoplayer2.util.SystemClock;
import java.io.IOException;
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;
@@ -47,9 +53,12 @@ import org.chromium.net.UrlResponseInfo;
/**
* DataSource without intermediate buffer based on Cronet API set using UrlRequest.
- * This class's methods are organized in the sequence of expected calls.
+ *
+ *
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 UrlRequest.Callback implements HttpDataSource {
+public class CronetDataSource extends BaseDataSource implements HttpDataSource {
/**
* Thrown when an error is encountered when trying to open a {@link CronetDataSource}.
@@ -74,6 +83,18 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
}
+ /** Thrown on catching an InterruptedException. */
+ public static final class InterruptedIOException extends IOException {
+
+ public InterruptedIOException(InterruptedException e) {
+ super(e);
+ }
+ }
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.cronet");
+ }
+
/**
* The default connection timeout, in milliseconds.
*/
@@ -83,8 +104,13 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
*/
public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000;
+ /* package */ final UrlRequest.Callback urlRequestCallback;
+
private static final String TAG = "CronetDataSource";
private static final String CONTENT_TYPE = "Content-Type";
+ private static final String SET_COOKIE = "Set-Cookie";
+ private static final String COOKIE = "Cookie";
+
private static final Pattern CONTENT_RANGE_HEADER_PATTERN =
Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$");
// The size of read buffer passed to cronet UrlRequest.read().
@@ -92,16 +118,17 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
private final CronetEngine cronetEngine;
private final Executor executor;
- private final Predicate contentTypePredicate;
- private final TransferListener super CronetDataSource> listener;
private final int connectTimeoutMs;
private final int readTimeoutMs;
private final boolean resetTimeoutOnRedirects;
- private final RequestProperties defaultRequestProperties;
+ private final boolean handleSetCookieRequests;
+ @Nullable private final RequestProperties defaultRequestProperties;
private final RequestProperties requestProperties;
private final ConditionVariable operation;
private final Clock clock;
+ @Nullable private Predicate contentTypePredicate;
+
// Accessed by the calling thread only.
private boolean opened;
private long bytesToSkip;
@@ -109,73 +136,247 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
// 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;
/**
* @param cronetEngine A CronetEngine.
- * @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 #open(DataSpec)}.
- * @param listener An optional listener.
+ * @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.
*/
- public CronetDataSource(CronetEngine cronetEngine, Executor executor,
- Predicate contentTypePredicate, TransferListener super CronetDataSource> listener) {
- this(cronetEngine, executor, contentTypePredicate, listener, DEFAULT_CONNECT_TIMEOUT_MILLIS,
- DEFAULT_READ_TIMEOUT_MILLIS, false, null);
+ public CronetDataSource(CronetEngine cronetEngine, Executor executor) {
+ 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 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 #open(DataSpec)}.
- * @param listener An optional listener.
+ * @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 The default request properties to be used.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
*/
- public CronetDataSource(CronetEngine cronetEngine, Executor executor,
- Predicate contentTypePredicate, TransferListener super CronetDataSource> listener,
- int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects,
- RequestProperties defaultRequestProperties) {
- this(cronetEngine, executor, contentTypePredicate, listener, connectTimeoutMs,
- readTimeoutMs, resetTimeoutOnRedirects, new SystemClock(), defaultRequestProperties);
+ 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);
}
- /* package */ CronetDataSource(CronetEngine cronetEngine, Executor executor,
- Predicate contentTypePredicate, TransferListener super CronetDataSource> listener,
- int connectTimeoutMs, int readTimeoutMs, boolean resetTimeoutOnRedirects, Clock clock,
- RequestProperties defaultRequestProperties) {
+ /**
+ * @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);
+ }
+
+ /**
+ * @param cronetEngine A CronetEngine.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then an {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor)} and {@link
+ * #setContentTypePredicate(Predicate)}.
+ */
+ @Deprecated
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ @Nullable Predicate contentTypePredicate) {
+ this(
+ cronetEngine,
+ executor,
+ contentTypePredicate,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ /* resetTimeoutOnRedirects= */ false,
+ /* defaultRequestProperties= */ null);
+ }
+
+ /**
+ * @param cronetEngine A CronetEngine.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then an {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
+ * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
+ * RequestProperties)} and {@link #setContentTypePredicate(Predicate)}.
+ */
+ @Deprecated
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ @Nullable Predicate contentTypePredicate,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ @Nullable RequestProperties defaultRequestProperties) {
+ this(
+ cronetEngine,
+ executor,
+ contentTypePredicate,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ defaultRequestProperties,
+ /* handleSetCookieRequests= */ false);
+ }
+
+ /**
+ * @param cronetEngine A CronetEngine.
+ * @param executor The {@link java.util.concurrent.Executor} that will handle responses. This may
+ * be a direct executor (i.e. executes tasks on the calling thread) in order to avoid a thread
+ * hop from Cronet's internal network thread to the response handling thread. However, to
+ * avoid slowing down overall network performance, care must be taken to make sure response
+ * handling is a fast operation when using a direct executor.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then an {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @param connectTimeoutMs The connection timeout, in milliseconds.
+ * @param readTimeoutMs The read timeout, in milliseconds.
+ * @param resetTimeoutOnRedirects Whether the connect timeout is reset when a redirect occurs.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
+ * @param handleSetCookieRequests Whether "Set-Cookie" requests on redirect should be forwarded to
+ * the redirect url in the "Cookie" header.
+ * @deprecated Use {@link #CronetDataSource(CronetEngine, Executor, int, int, boolean,
+ * RequestProperties, boolean)} and {@link #setContentTypePredicate(Predicate)}.
+ */
+ @Deprecated
+ public CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ @Nullable Predicate contentTypePredicate,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ @Nullable RequestProperties defaultRequestProperties,
+ boolean handleSetCookieRequests) {
+ this(
+ cronetEngine,
+ executor,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ Clock.DEFAULT,
+ defaultRequestProperties,
+ handleSetCookieRequests);
+ this.contentTypePredicate = contentTypePredicate;
+ }
+
+ /* package */ CronetDataSource(
+ CronetEngine cronetEngine,
+ Executor executor,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ Clock clock,
+ @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.listener = listener;
this.connectTimeoutMs = connectTimeoutMs;
this.readTimeoutMs = readTimeoutMs;
this.resetTimeoutOnRedirects = resetTimeoutOnRedirects;
this.clock = Assertions.checkNotNull(clock);
this.defaultRequestProperties = defaultRequestProperties;
+ this.handleSetCookieRequests = handleSetCookieRequests;
requestProperties = new RequestProperties();
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 contentTypePredicate) {
+ this.contentTypePredicate = contentTypePredicate;
+ }
+
// HttpDataSource implementation.
@Override
@@ -194,11 +395,19 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
}
@Override
- public Map> getResponseHeaders() {
- return responseInfo == null ? null : responseInfo.getAllHeaders();
+ public int getResponseCode() {
+ return responseInfo == null || responseInfo.getHttpStatusCode() <= 0
+ ? -1
+ : responseInfo.getHttpStatusCode();
}
@Override
+ public Map> getResponseHeaders() {
+ return responseInfo == null ? Collections.emptyMap() : responseInfo.getAllHeaders();
+ }
+
+ @Override
+ @Nullable
public Uri getUri() {
return responseInfo == null ? null : Uri.parse(responseInfo.getUrl());
}
@@ -211,22 +420,39 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
operation.close();
resetConnectTimeout();
currentDataSpec = dataSpec;
- currentUrlRequest = buildRequest(dataSpec);
- currentUrlRequest.start();
- boolean requestStarted = blockUntilConnectTimeout();
+ UrlRequest urlRequest;
+ try {
+ urlRequest = buildRequestBuilder(dataSpec).build();
+ currentUrlRequest = urlRequest;
+ } catch (IOException e) {
+ throw new OpenException(e, dataSpec, Status.IDLE);
+ }
+ urlRequest.start();
- if (exception != null) {
- throw new OpenException(exception, currentDataSpec, getStatus(currentUrlRequest));
- } else if (!requestStarted) {
- // The timeout was reached before the connection was opened.
- throw new OpenException(new SocketTimeoutException(), dataSpec, getStatus(currentUrlRequest));
+ transferInitializing(dataSpec);
+ try {
+ boolean connectionOpened = blockUntilConnectTimeout();
+ if (exception != null) {
+ 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(urlRequest));
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new OpenException(new InterruptedIOException(e), dataSpec, Status.INVALID);
}
// Check for a valid response code.
+ UrlResponseInfo responseInfo = Assertions.checkNotNull(this.responseInfo);
int responseCode = responseInfo.getHttpStatusCode();
if (responseCode < 200 || responseCode > 299) {
- InvalidResponseCodeException exception = new InvalidResponseCodeException(responseCode,
- responseInfo.getAllHeaders(), currentDataSpec);
+ InvalidResponseCodeException exception =
+ new InvalidResponseCodeException(
+ responseCode,
+ responseInfo.getHttpStatusText(),
+ responseInfo.getAllHeaders(),
+ dataSpec);
if (responseCode == 416) {
exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE));
}
@@ -234,11 +460,12 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
}
// Check for a valid content type.
+ Predicate contentTypePredicate = this.contentTypePredicate;
if (contentTypePredicate != null) {
List 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);
}
}
@@ -248,7 +475,7 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
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 {
@@ -257,13 +484,11 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
} 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;
- if (listener != null) {
- listener.onTransferStart(this, dataSpec);
- }
+ transferStarted(dataSpec);
return bytesRemaining;
}
@@ -278,26 +503,20 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
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);
- if (!operation.block(readTimeoutMs)) {
- // We're timing out, but since the operation is still ongoing we'll need to replace
- // readBuffer to avoid the possibility of it being written to by this operation during a
- // subsequent request.
- readBuffer = null;
- throw new HttpDataSourceException(
- new SocketTimeoutException(), currentDataSpec, HttpDataSourceException.TYPE_READ);
- } else if (exception != null) {
- throw new HttpDataSourceException(exception, currentDataSpec,
- HttpDataSourceException.TYPE_READ);
- } else if (finished) {
+ readInternal(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.
@@ -317,9 +536,116 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= bytesRead;
}
- if (listener != null) {
- listener.onBytesTransferred(this, bytesRead);
+ bytesTransferred(bytesRead);
+ 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.
+ *
+ * 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.
+ *
+ *
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.
+ *
+ *
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;
}
@@ -338,128 +664,75 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
finished = false;
if (opened) {
opened = false;
- if (listener != null) {
- listener.onTransferEnd(this);
- }
+ transferEnded();
}
}
- // UrlRequest.Callback implementation
-
- @Override
- public synchronized void onRedirectReceived(UrlRequest request, UrlResponseInfo info,
- String newLocationUrl) {
- if (request != currentUrlRequest) {
- return;
- }
- if (currentDataSpec.postBody != null) {
- int responseCode = info.getHttpStatusCode();
- // The industry standard is to disregard POST redirects when the status code is 307 or 308.
- // For other redirect response codes the POST request is converted to a GET request and the
- // redirect is followed.
- if (responseCode == 307 || responseCode == 308) {
- exception = new InvalidResponseCodeException(responseCode, info.getAllHeaders(),
- currentDataSpec);
- operation.open();
- return;
- }
- }
- if (resetTimeoutOnRedirects) {
- resetConnectTimeout();
- }
- request.followRedirect();
+ /** Returns current {@link UrlRequest}. May be null if the data source is not opened. */
+ @Nullable
+ protected UrlRequest getCurrentUrlRequest() {
+ return currentUrlRequest;
}
- @Override
- public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) {
- if (request != currentUrlRequest) {
- return;
- }
- responseInfo = info;
- operation.open();
- }
-
- @Override
- public synchronized void onReadCompleted(UrlRequest request, UrlResponseInfo info,
- ByteBuffer buffer) {
- if (request != currentUrlRequest) {
- return;
- }
- operation.open();
- }
-
- @Override
- public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) {
- if (request != currentUrlRequest) {
- return;
- }
- finished = true;
- operation.open();
- }
-
- @Override
- public synchronized void onFailed(UrlRequest request, UrlResponseInfo info,
- CronetException error) {
- if (request != currentUrlRequest) {
- return;
- }
- if (error instanceof NetworkException
- && ((NetworkException) error).getErrorCode()
- == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) {
- exception = new UnknownHostException();
- } else {
- exception = error;
- }
- operation.open();
+ /** Returns current {@link UrlResponseInfo}. May be null if the data source is not opened. */
+ @Nullable
+ protected UrlResponseInfo getCurrentUrlResponseInfo() {
+ return responseInfo;
}
// Internal methods.
- private UrlRequest buildRequest(DataSpec dataSpec) throws OpenException {
- UrlRequest.Builder requestBuilder = cronetEngine.newUrlRequestBuilder(dataSpec.uri.toString(),
- this, executor);
+ private UrlRequest.Builder buildRequestBuilder(DataSpec dataSpec) throws IOException {
+ UrlRequest.Builder requestBuilder =
+ cronetEngine
+ .newUrlRequestBuilder(dataSpec.uri.toString(), urlRequestCallback, executor)
+ .allowDirectExecutor();
+
// Set the headers.
- boolean isContentTypeHeaderSet = false;
+ Map requestHeaders = new HashMap<>();
if (defaultRequestProperties != null) {
- for (Entry headerEntry : defaultRequestProperties.getSnapshot().entrySet()) {
- String key = headerEntry.getKey();
- isContentTypeHeaderSet = isContentTypeHeaderSet || CONTENT_TYPE.equals(key);
- requestBuilder.addHeader(key, headerEntry.getValue());
- }
+ requestHeaders.putAll(defaultRequestProperties.getSnapshot());
}
- Map requestPropertiesSnapshot = requestProperties.getSnapshot();
- for (Entry headerEntry : requestPropertiesSnapshot.entrySet()) {
+ requestHeaders.putAll(requestProperties.getSnapshot());
+ requestHeaders.putAll(dataSpec.httpRequestHeaders);
+
+ for (Entry 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.postBody != null && dataSpec.postBody.length != 0 && !isContentTypeHeaderSet) {
- throw new OpenException("POST request with non-empty body must set Content-Type", dataSpec,
- Status.IDLE);
+
+ if (dataSpec.httpBody != null && !requestHeaders.containsKey(CONTENT_TYPE)) {
+ throw new IOException("HTTP request with non-empty body must set Content-Type");
}
+
// Set the Range header.
- if (currentDataSpec.position != 0 || currentDataSpec.length != C.LENGTH_UNSET) {
+ if (dataSpec.position != 0 || dataSpec.length != C.LENGTH_UNSET) {
StringBuilder rangeValue = new StringBuilder();
rangeValue.append("bytes=");
- rangeValue.append(currentDataSpec.position);
+ rangeValue.append(dataSpec.position);
rangeValue.append("-");
- if (currentDataSpec.length != C.LENGTH_UNSET) {
- rangeValue.append(currentDataSpec.position + currentDataSpec.length - 1);
+ if (dataSpec.length != C.LENGTH_UNSET) {
+ rangeValue.append(dataSpec.position + dataSpec.length - 1);
}
requestBuilder.addHeader("Range", rangeValue.toString());
}
+ // 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)) {
+ // requestBuilder.addHeader("Accept-Encoding", "identity");
+ // }
// Set the method and (if non-empty) the body.
- if (dataSpec.postBody != null) {
- requestBuilder.setHttpMethod("POST");
- if (dataSpec.postBody.length != 0) {
- requestBuilder.setUploadDataProvider(new ByteArrayUploadDataProvider(dataSpec.postBody),
- executor);
- }
+ requestBuilder.setHttpMethod(dataSpec.getHttpMethodString());
+ if (dataSpec.httpBody != null) {
+ requestBuilder.setUploadDataProvider(
+ new ByteArrayUploadDataProvider(dataSpec.httpBody), executor);
}
- return requestBuilder.build();
+ return requestBuilder;
}
- private boolean blockUntilConnectTimeout() {
+ private boolean blockUntilConnectTimeout() throws InterruptedException {
long now = clock.elapsedRealtime();
boolean opened = false;
while (!opened && now < currentConnectTimeoutMs) {
@@ -473,7 +746,49 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
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 entry : info.getAllHeadersAsList()) {
if (entry.getKey().equalsIgnoreCase("Content-Encoding")) {
return !entry.getValue().equalsIgnoreCase("identity");
@@ -526,7 +841,18 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
return contentLength;
}
- private static int getStatus(UrlRequest request) {
+ private static String parseCookies(List setCookieHeaders) {
+ return TextUtils.join(";", setCookieHeaders);
+ }
+
+ private static void attachCookies(UrlRequest.Builder requestBuilder, String cookies) {
+ if (TextUtils.isEmpty(cookies)) {
+ return;
+ }
+ requestBuilder.addHeader(COOKIE, cookies);
+ }
+
+ private static int getStatus(UrlRequest request) throws InterruptedException {
final ConditionVariable conditionVariable = new ConditionVariable();
final int[] statusHolder = new int[1];
request.getStatus(new UrlRequest.StatusListener() {
@@ -540,8 +866,131 @@ public class CronetDataSource extends UrlRequest.Callback implements HttpDataSou
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
+ public synchronized void onRedirectReceived(
+ UrlRequest request, UrlResponseInfo info, String newLocationUrl) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ 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(), dataSpec);
+ operation.open();
+ return;
+ }
+ }
+ if (resetTimeoutOnRedirects) {
+ resetConnectTimeout();
+ }
+
+ if (!handleSetCookieRequests) {
+ request.followRedirect();
+ return;
+ }
+
+ List 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
+ public synchronized void onResponseStarted(UrlRequest request, UrlResponseInfo info) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ responseInfo = info;
+ operation.open();
+ }
+
+ @Override
+ public synchronized void onReadCompleted(
+ UrlRequest request, UrlResponseInfo info, ByteBuffer buffer) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ operation.open();
+ }
+
+ @Override
+ public synchronized void onSucceeded(UrlRequest request, UrlResponseInfo info) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ finished = true;
+ operation.open();
+ }
+
+ @Override
+ public synchronized void onFailed(
+ UrlRequest request, UrlResponseInfo info, CronetException error) {
+ if (request != currentUrlRequest) {
+ return;
+ }
+ if (error instanceof NetworkException
+ && ((NetworkException) error).getErrorCode()
+ == NetworkException.ERROR_HOSTNAME_NOT_RESOLVED) {
+ exception = new UnknownHostException();
+ } else {
+ exception = error;
+ }
+ operation.open();
+ }
+ }
}
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
index 2ad6da6a54..4086011b4f 100644
--- a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceFactory.java
@@ -15,12 +15,12 @@
*/
package com.google.android.exoplayer2.ext.cronet;
-import com.google.android.exoplayer2.upstream.DataSource;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory;
import com.google.android.exoplayer2.upstream.HttpDataSource.Factory;
import com.google.android.exoplayer2.upstream.TransferListener;
-import com.google.android.exoplayer2.util.Predicate;
import java.util.concurrent.Executor;
import org.chromium.net.CronetEngine;
@@ -34,45 +34,299 @@ public final class CronetDataSourceFactory extends BaseFactory {
*/
public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS =
CronetDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS;
+
/**
* The default read timeout, in milliseconds.
*/
public static final int DEFAULT_READ_TIMEOUT_MILLIS =
CronetDataSource.DEFAULT_READ_TIMEOUT_MILLIS;
- private final CronetEngine cronetEngine;
+ private final CronetEngineWrapper cronetEngineWrapper;
private final Executor executor;
- private final Predicate contentTypePredicate;
- private final TransferListener super DataSource> transferListener;
+ @Nullable private final TransferListener transferListener;
private final int connectTimeoutMs;
private final int readTimeoutMs;
private final boolean resetTimeoutOnRedirects;
+ private final HttpDataSource.Factory fallbackFactory;
- public CronetDataSourceFactory(CronetEngine cronetEngine,
- Executor executor, Predicate contentTypePredicate,
- TransferListener super DataSource> transferListener) {
- this(cronetEngine, executor, contentTypePredicate, transferListener,
- DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS, false);
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
+ * cross-protocol redirects.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @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,
+ HttpDataSource.Factory fallbackFactory) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false,
+ fallbackFactory);
}
- public CronetDataSourceFactory(CronetEngine cronetEngine,
- Executor executor, Predicate contentTypePredicate,
- TransferListener super DataSource> transferListener, int connectTimeoutMs,
- int readTimeoutMs, boolean resetTimeoutOnRedirects) {
- this.cronetEngine = cronetEngine;
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ * If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
+ * cross-protocol redirects.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ String userAgent) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false,
+ new DefaultHttpDataSourceFactory(
+ userAgent,
+ /* listener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false));
+ }
+
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @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 userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ String userAgent) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ resetTimeoutOnRedirects,
+ new DefaultHttpDataSourceFactory(
+ userAgent,
+ /* listener= */ null,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects));
+ }
+
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @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 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,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ HttpDataSource.Factory fallbackFactory) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ /* transferListener= */ null,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ fallbackFactory);
+ }
+
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
+ * cross-protocol redirects.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @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.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener,
+ HttpDataSource.Factory fallbackFactory) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ transferListener,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ false,
+ fallbackFactory);
+ }
+
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ *
Sets {@link CronetDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout,
+ * {@link CronetDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables
+ * cross-protocol redirects.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ * @param userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener,
+ String userAgent) {
+ 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));
+ }
+
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, a {@link
+ * DefaultHttpDataSourceFactory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ * @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 userAgent A user agent used to create a fallback HttpDataSource if needed.
+ */
+ public CronetDataSourceFactory(
+ CronetEngineWrapper cronetEngineWrapper,
+ Executor executor,
+ @Nullable TransferListener transferListener,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ String userAgent) {
+ this(
+ cronetEngineWrapper,
+ executor,
+ transferListener,
+ DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DEFAULT_READ_TIMEOUT_MILLIS,
+ resetTimeoutOnRedirects,
+ new DefaultHttpDataSourceFactory(
+ userAgent, transferListener, connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects));
+ }
+
+ /**
+ * Constructs a CronetDataSourceFactory.
+ *
+ *
If the {@link CronetEngineWrapper} fails to provide a {@link CronetEngine}, the provided
+ * fallback {@link HttpDataSource.Factory} will be used instead.
+ *
+ * @param cronetEngineWrapper A {@link CronetEngineWrapper}.
+ * @param executor The {@link java.util.concurrent.Executor} that will perform the requests.
+ * @param transferListener An optional listener.
+ * @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 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,
+ @Nullable TransferListener transferListener,
+ int connectTimeoutMs,
+ int readTimeoutMs,
+ boolean resetTimeoutOnRedirects,
+ HttpDataSource.Factory fallbackFactory) {
+ this.cronetEngineWrapper = cronetEngineWrapper;
this.executor = executor;
- this.contentTypePredicate = contentTypePredicate;
this.transferListener = transferListener;
this.connectTimeoutMs = connectTimeoutMs;
this.readTimeoutMs = readTimeoutMs;
this.resetTimeoutOnRedirects = resetTimeoutOnRedirects;
+ this.fallbackFactory = fallbackFactory;
}
@Override
- protected CronetDataSource createDataSourceInternal(HttpDataSource.RequestProperties
+ protected HttpDataSource createDataSourceInternal(HttpDataSource.RequestProperties
defaultRequestProperties) {
- return new CronetDataSource(cronetEngine, executor, contentTypePredicate, transferListener,
- connectTimeoutMs, readTimeoutMs, resetTimeoutOnRedirects, defaultRequestProperties);
+ CronetEngine cronetEngine = cronetEngineWrapper.getCronetEngine();
+ if (cronetEngine == null) {
+ return fallbackFactory.createDataSource();
+ }
+ CronetDataSource dataSource =
+ new CronetDataSource(
+ cronetEngine,
+ executor,
+ connectTimeoutMs,
+ readTimeoutMs,
+ resetTimeoutOnRedirects,
+ defaultRequestProperties);
+ if (transferListener != null) {
+ dataSource.addTransferListener(transferListener);
+ }
+ return dataSource;
}
}
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
new file mode 100644
index 0000000000..7d549be7cb
--- /dev/null
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/CronetEngineWrapper.java
@@ -0,0 +1,248 @@
+/*
+ * 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.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;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.chromium.net.CronetEngine;
+import org.chromium.net.CronetProvider;
+
+/**
+ * A wrapper class for a {@link CronetEngine}.
+ */
+public final class CronetEngineWrapper {
+
+ private static final String TAG = "CronetEngineWrapper";
+
+ @Nullable private final CronetEngine cronetEngine;
+ @CronetEngineSource private final int cronetEngineSource;
+
+ /**
+ * Source of {@link CronetEngine}. One of {@link #SOURCE_NATIVE}, {@link #SOURCE_GMS}, {@link
+ * #SOURCE_UNKNOWN}, {@link #SOURCE_USER_PROVIDED} or {@link #SOURCE_UNAVAILABLE}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SOURCE_NATIVE, SOURCE_GMS, SOURCE_UNKNOWN, SOURCE_USER_PROVIDED, SOURCE_UNAVAILABLE})
+ public @interface CronetEngineSource {}
+ /**
+ * Natively bundled Cronet implementation.
+ */
+ public static final int SOURCE_NATIVE = 0;
+ /**
+ * Cronet implementation from GMSCore.
+ */
+ public static final int SOURCE_GMS = 1;
+ /**
+ * Other (unknown) Cronet implementation.
+ */
+ public static final int SOURCE_UNKNOWN = 2;
+ /**
+ * User-provided Cronet engine.
+ */
+ public static final int SOURCE_USER_PROVIDED = 3;
+ /**
+ * No Cronet implementation available. Fallback Http provider is used if possible.
+ */
+ public static final int SOURCE_UNAVAILABLE = 4;
+
+ /**
+ * Creates a wrapper for a {@link CronetEngine} which automatically selects the most suitable
+ * {@link CronetProvider}. Sets wrapper to prefer natively bundled Cronet over GMSCore Cronet
+ * if both are available.
+ *
+ * @param context A context.
+ */
+ public CronetEngineWrapper(Context context) {
+ this(context, false);
+ }
+
+ /**
+ * Creates a wrapper for a {@link CronetEngine} which automatically selects the most suitable
+ * {@link CronetProvider} based on user preference.
+ *
+ * @param context A context.
+ * @param preferGMSCoreCronet Whether Cronet from GMSCore should be preferred over natively
+ * bundled Cronet if both are available.
+ */
+ public CronetEngineWrapper(Context context, boolean preferGMSCoreCronet) {
+ CronetEngine cronetEngine = null;
+ @CronetEngineSource int cronetEngineSource = SOURCE_UNAVAILABLE;
+ List cronetProviders = new ArrayList<>(CronetProvider.getAllProviders(context));
+ // Remove disabled and fallback Cronet providers from list
+ for (int i = cronetProviders.size() - 1; i >= 0; i--) {
+ if (!cronetProviders.get(i).isEnabled()
+ || CronetProvider.PROVIDER_NAME_FALLBACK.equals(cronetProviders.get(i).getName())) {
+ cronetProviders.remove(i);
+ }
+ }
+ // Sort remaining providers by type and version.
+ CronetProviderComparator providerComparator = new CronetProviderComparator(preferGMSCoreCronet);
+ Collections.sort(cronetProviders, providerComparator);
+ for (int i = 0; i < cronetProviders.size() && cronetEngine == null; i++) {
+ String providerName = cronetProviders.get(i).getName();
+ try {
+ cronetEngine = cronetProviders.get(i).createBuilder().build();
+ if (providerComparator.isNativeProvider(providerName)) {
+ cronetEngineSource = SOURCE_NATIVE;
+ } else if (providerComparator.isGMSCoreProvider(providerName)) {
+ cronetEngineSource = SOURCE_GMS;
+ } else {
+ cronetEngineSource = SOURCE_UNKNOWN;
+ }
+ Log.d(TAG, "CronetEngine built using " + providerName);
+ } catch (SecurityException e) {
+ Log.w(TAG, "Failed to build CronetEngine. Please check if current process has "
+ + "android.permission.ACCESS_NETWORK_STATE.");
+ } catch (UnsatisfiedLinkError e) {
+ Log.w(TAG, "Failed to link Cronet binaries. Please check if native Cronet binaries are "
+ + "bundled into your app.");
+ }
+ }
+ if (cronetEngine == null) {
+ Log.w(TAG, "Cronet not available. Using fallback provider.");
+ }
+ this.cronetEngine = cronetEngine;
+ this.cronetEngineSource = cronetEngineSource;
+ }
+
+ /**
+ * Creates a wrapper for an existing CronetEngine.
+ *
+ * @param cronetEngine An existing CronetEngine.
+ */
+ public CronetEngineWrapper(CronetEngine cronetEngine) {
+ this.cronetEngine = cronetEngine;
+ this.cronetEngineSource = SOURCE_USER_PROVIDED;
+ }
+
+ /**
+ * Returns the source of the wrapped {@link CronetEngine}.
+ *
+ * @return A {@link CronetEngineSource} value.
+ */
+ @CronetEngineSource
+ public int getCronetEngineSource() {
+ return cronetEngineSource;
+ }
+
+ /**
+ * Returns the wrapped {@link CronetEngine}.
+ *
+ * @return The CronetEngine, or null if no CronetEngine is available.
+ */
+ @Nullable
+ /* package */ CronetEngine getCronetEngine() {
+ return cronetEngine;
+ }
+
+ private static class CronetProviderComparator implements Comparator {
+
+ @Nullable private final String gmsCoreCronetName;
+ private final boolean preferGMSCoreCronet;
+
+ // Multi-catch can only be used for API 19+ in this case.
+ @SuppressWarnings("UseMultiCatch")
+ public CronetProviderComparator(boolean preferGMSCoreCronet) {
+ // GMSCore CronetProvider classes are only available in some configurations.
+ // Thus, we use reflection to copy static name.
+ String gmsCoreVersionString = null;
+ try {
+ Class> cronetProviderInstallerClass =
+ Class.forName("com.google.android.gms.net.CronetProviderInstaller");
+ Field providerNameField = cronetProviderInstallerClass.getDeclaredField("PROVIDER_NAME");
+ gmsCoreVersionString = (String) providerNameField.get(null);
+ } catch (ClassNotFoundException e) {
+ // GMSCore CronetProvider not available.
+ } catch (NoSuchFieldException e) {
+ // GMSCore CronetProvider not available.
+ } catch (IllegalAccessException e) {
+ // GMSCore CronetProvider not available.
+ }
+ gmsCoreCronetName = gmsCoreVersionString;
+ this.preferGMSCoreCronet = preferGMSCoreCronet;
+ }
+
+ @Override
+ public int compare(CronetProvider providerLeft, CronetProvider providerRight) {
+ int typePreferenceLeft = evaluateCronetProviderType(providerLeft.getName());
+ int typePreferenceRight = evaluateCronetProviderType(providerRight.getName());
+ if (typePreferenceLeft != typePreferenceRight) {
+ return typePreferenceLeft - typePreferenceRight;
+ }
+ return -compareVersionStrings(providerLeft.getVersion(), providerRight.getVersion());
+ }
+
+ public boolean isNativeProvider(String providerName) {
+ return CronetProvider.PROVIDER_NAME_APP_PACKAGED.equals(providerName);
+ }
+
+ public boolean isGMSCoreProvider(String providerName) {
+ return gmsCoreCronetName != null && gmsCoreCronetName.equals(providerName);
+ }
+
+ /**
+ * Convert Cronet provider name into a sortable preference value.
+ * Smaller values are preferred.
+ */
+ private int evaluateCronetProviderType(String providerName) {
+ if (isNativeProvider(providerName)) {
+ return 1;
+ }
+ if (isGMSCoreProvider(providerName)) {
+ return preferGMSCoreCronet ? 0 : 2;
+ }
+ // Unknown provider type.
+ return -1;
+ }
+
+ /**
+ * Compares version strings of format "12.123.35.23".
+ */
+ private static int compareVersionStrings(String versionLeft, String versionRight) {
+ if (versionLeft == null || versionRight == null) {
+ return 0;
+ }
+ String[] versionStringsLeft = Util.split(versionLeft, "\\.");
+ String[] versionStringsRight = Util.split(versionRight, "\\.");
+ int minLength = Math.min(versionStringsLeft.length, versionStringsRight.length);
+ for (int i = 0; i < minLength; i++) {
+ if (!versionStringsLeft[i].equals(versionStringsRight[i])) {
+ try {
+ int versionIntLeft = Integer.parseInt(versionStringsLeft[i]);
+ int versionIntRight = Integer.parseInt(versionStringsRight[i]);
+ return versionIntLeft - versionIntRight;
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+ }
+ return 0;
+ }
+ }
+
+}
diff --git a/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/package-info.java b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/package-info.java
new file mode 100644
index 0000000000..ec0cf8df05
--- /dev/null
+++ b/extensions/cronet/src/main/java/com/google/android/exoplayer2/ext/cronet/package-info.java
@@ -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;
diff --git a/library/ui/src/main/res/values-v11/styles.xml b/extensions/cronet/src/test/AndroidManifest.xml
similarity index 68%
rename from library/ui/src/main/res/values-v11/styles.xml
rename to extensions/cronet/src/test/AndroidManifest.xml
index 6f77440287..d6e09107a7 100644
--- a/library/ui/src/main/res/values-v11/styles.xml
+++ b/extensions/cronet/src/test/AndroidManifest.xml
@@ -13,12 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
-
-
+
+
+
diff --git a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
similarity index 72%
rename from extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
rename to extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
index 4282244a7a..244ba9083b 100644
--- a/extensions/cronet/src/androidTest/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
+++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/ByteArrayUploadDataProviderTest.java
@@ -13,20 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
package com.google.android.exoplayer2.ext.cronet;
-import static org.junit.Assert.assertArrayEquals;
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
-import static org.mockito.MockitoAnnotations.initMocks;
-
-import android.annotation.TargetApi;
-import android.os.Build.VERSION_CODES;
-import android.support.test.InstrumentationRegistry;
-import android.support.test.runner.AndroidJUnit4;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
@@ -35,10 +28,9 @@ import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
-/**
- * Tests for {@link ByteArrayUploadDataProvider}.
- */
+/** Tests for {@link ByteArrayUploadDataProvider}. */
@RunWith(AndroidJUnit4.class)
public final class ByteArrayUploadDataProviderTest {
@@ -50,38 +42,35 @@ public final class ByteArrayUploadDataProviderTest {
@Before
public void setUp() {
- System.setProperty("dexmaker.dexcache",
- InstrumentationRegistry.getTargetContext().getCacheDir().getPath());
- initMocks(this);
+ MockitoAnnotations.initMocks(this);
byteBuffer = ByteBuffer.allocate(TEST_DATA.length);
byteArrayUploadDataProvider = new ByteArrayUploadDataProvider(TEST_DATA);
}
@Test
public void testGetLength() {
- assertEquals(TEST_DATA.length, byteArrayUploadDataProvider.getLength());
+ assertThat(byteArrayUploadDataProvider.getLength()).isEqualTo(TEST_DATA.length);
}
@Test
public void testReadFullBuffer() throws IOException {
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(TEST_DATA, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(TEST_DATA);
}
- @TargetApi(VERSION_CODES.GINGERBREAD)
@Test
public void testReadPartialBuffer() throws IOException {
- byte[] firstHalf = Arrays.copyOfRange(TEST_DATA, 0, TEST_DATA.length / 2);
+ byte[] firstHalf = Arrays.copyOf(TEST_DATA, TEST_DATA.length / 2);
byte[] secondHalf = Arrays.copyOfRange(TEST_DATA, TEST_DATA.length / 2, TEST_DATA.length);
byteBuffer = ByteBuffer.allocate(TEST_DATA.length / 2);
// Read half of the data.
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(firstHalf, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(firstHalf);
// Read the second half of the data.
byteBuffer.rewind();
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(secondHalf, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(secondHalf);
verify(mockUploadDataSink, times(2)).onReadSucceeded(false);
}
@@ -89,14 +78,13 @@ public final class ByteArrayUploadDataProviderTest {
public void testRewind() throws IOException {
// Read all the data.
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(TEST_DATA, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(TEST_DATA);
// Rewind and make sure it can be read again.
byteBuffer.clear();
byteArrayUploadDataProvider.rewind(mockUploadDataSink);
byteArrayUploadDataProvider.read(mockUploadDataSink, byteBuffer);
- assertArrayEquals(TEST_DATA, byteBuffer.array());
+ assertThat(byteBuffer.array()).isEqualTo(TEST_DATA);
verify(mockUploadDataSink).onRewindSucceeded();
}
-
}
diff --git a/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
new file mode 100644
index 0000000000..dadf5a9ecf
--- /dev/null
+++ b/extensions/cronet/src/test/java/com/google/android/exoplayer2/ext/cronet/CronetDataSourceTest.java
@@ -0,0 +1,1463 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.cronet;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import android.os.ConditionVariable;
+import android.os.SystemClock;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.HttpDataSource;
+import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException;
+import com.google.android.exoplayer2.upstream.TransferListener;
+import com.google.android.exoplayer2.util.Clock;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.chromium.net.CronetEngine;
+import org.chromium.net.NetworkException;
+import org.chromium.net.UrlRequest;
+import org.chromium.net.UrlResponseInfo;
+import org.chromium.net.impl.UrlResponseInfoImpl;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/** Tests for {@link CronetDataSource}. */
+@RunWith(AndroidJUnit4.class)
+public final class CronetDataSourceTest {
+
+ private static final int TEST_CONNECT_TIMEOUT_MS = 100;
+ private static final int TEST_READ_TIMEOUT_MS = 100;
+ private static final String TEST_URL = "http://google.com";
+ private static final String TEST_CONTENT_TYPE = "test/test";
+ private static final byte[] TEST_POST_BODY = Util.getUtf8Bytes("test post body");
+ private static final long TEST_CONTENT_LENGTH = 16000L;
+ private static final int TEST_CONNECTION_STATUS = 5;
+ private static final int TEST_INVALID_CONNECTION_STATUS = -1;
+
+ private DataSpec testDataSpec;
+ private DataSpec testPostDataSpec;
+ private DataSpec testHeadDataSpec;
+ private Map testResponseHeader;
+ private UrlResponseInfo testUrlResponseInfo;
+
+ @Mock private UrlRequest.Builder mockUrlRequestBuilder;
+ @Mock private UrlRequest mockUrlRequest;
+ @Mock private TransferListener mockTransferListener;
+ @Mock private Executor mockExecutor;
+ @Mock private NetworkException mockNetworkException;
+ @Mock private CronetEngine mockCronetEngine;
+
+ private CronetDataSource dataSourceUnderTest;
+ private boolean redirectCalled;
+
+ @Before
+ 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,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ /* resetTimeoutOnRedirects= */ true,
+ Clock.DEFAULT,
+ defaultRequestProperties,
+ /* handleSetCookieRequests= */ false);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ when(mockCronetEngine.newUrlRequestBuilder(
+ anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
+ .thenReturn(mockUrlRequestBuilder);
+ when(mockUrlRequestBuilder.allowDirectExecutor()).thenReturn(mockUrlRequestBuilder);
+ when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest);
+ mockStatusResponse();
+
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, C.LENGTH_UNSET, null);
+ testPostDataSpec =
+ new DataSpec(Uri.parse(TEST_URL), TEST_POST_BODY, 0, 0, C.LENGTH_UNSET, null, 0);
+ testHeadDataSpec =
+ new DataSpec(
+ Uri.parse(TEST_URL), DataSpec.HTTP_METHOD_HEAD, null, 0, 0, C.LENGTH_UNSET, null, 0);
+ testResponseHeader = new HashMap<>();
+ testResponseHeader.put("Content-Type", TEST_CONTENT_TYPE);
+ // This value can be anything since the DataSpec is unset.
+ testResponseHeader.put("Content-Length", Long.toString(TEST_CONTENT_LENGTH));
+ testUrlResponseInfo = createUrlResponseInfo(200); // statusCode
+ }
+
+ private UrlResponseInfo createUrlResponseInfo(int statusCode) {
+ return createUrlResponseInfoWithUrl(TEST_URL, statusCode);
+ }
+
+ private UrlResponseInfo createUrlResponseInfoWithUrl(String url, int statusCode) {
+ ArrayList> responseHeaderList = new ArrayList<>();
+ responseHeaderList.addAll(testResponseHeader.entrySet());
+ return new UrlResponseInfoImpl(
+ Collections.singletonList(url),
+ statusCode,
+ null, // httpStatusText
+ responseHeaderList,
+ false, // wasCached
+ null, // negotiatedProtocol
+ null); // proxyServer
+ }
+
+ @Test
+ public void testOpeningTwiceThrows() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ dataSourceUnderTest.open(testDataSpec);
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("Expected IllegalStateException.");
+ } catch (IllegalStateException e) {
+ // Expected.
+ }
+ }
+
+ @Test
+ public void testCallbackFromPreviousRequest() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ dataSourceUnderTest.close();
+ // Prepare a mock UrlRequest to be used in the second open() call.
+ final UrlRequest mockUrlRequest2 = mock(UrlRequest.class);
+ when(mockUrlRequestBuilder.build()).thenReturn(mockUrlRequest2);
+ doAnswer(
+ invocation -> {
+ // Invoke the callback for the previous request.
+ dataSourceUnderTest.urlRequestCallback.onFailed(
+ mockUrlRequest, testUrlResponseInfo, mockNetworkException);
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest2, testUrlResponseInfo);
+ return null;
+ })
+ .when(mockUrlRequest2)
+ .start();
+ dataSourceUnderTest.open(testDataSpec);
+ }
+
+ @Test
+ public void testRequestStartCalled() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockCronetEngine)
+ .newUrlRequestBuilder(eq(TEST_URL), any(UrlRequest.Callback.class), any(Executor.class));
+ verify(mockUrlRequest).start();
+ }
+
+ @Test
+ public void testRequestSetsRangeHeader() throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ // The header value to add is current position to current position + length - 1.
+ verify(mockUrlRequestBuilder).addHeader("Range", "bytes=1000-5999");
+ }
+
+ @Test
+ public void testRequestHeadersSet() throws HttpDataSourceException {
+
+ Map 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 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();
+ }
+
+ @Test
+ public void testRequestOpen() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(TEST_CONTENT_LENGTH);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void testRequestOpenGzippedCompressedReturnsDataSpecLength()
+ throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 0, 5000, null);
+ testResponseHeader.put("Content-Encoding", "gzip");
+ testResponseHeader.put("Content-Length", Long.toString(50L));
+ mockResponseStartSuccess();
+
+ assertThat(dataSourceUnderTest.open(testDataSpec)).isEqualTo(5000 /* contentLength */);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void testRequestOpenFail() {
+ mockResponseStartFailure();
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ // Check for connection not automatically closed.
+ assertThat(e.getCause() instanceof UnknownHostException).isFalse();
+ verify(mockUrlRequest, never()).cancel();
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+ }
+
+ @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();
+ when(mockNetworkException.getErrorCode())
+ .thenReturn(NetworkException.ERROR_HOSTNAME_NOT_RESOLVED);
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ // Check for connection not automatically closed.
+ assertThat(e.getCause() instanceof UnknownHostException).isTrue();
+ verify(mockUrlRequest, never()).cancel();
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+ }
+
+ @Test
+ public void testRequestOpenValidatesStatusCode() {
+ mockResponseStartSuccess();
+ testUrlResponseInfo = createUrlResponseInfo(500); // statusCode
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ assertThat(e instanceof HttpDataSource.InvalidResponseCodeException).isTrue();
+ // Check for connection not automatically closed.
+ verify(mockUrlRequest, never()).cancel();
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+ }
+
+ @Test
+ public void testRequestOpenValidatesContentTypePredicate() {
+ mockResponseStartSuccess();
+
+ ArrayList testedContentTypes = new ArrayList<>();
+ dataSourceUnderTest.setContentTypePredicate(
+ (String input) -> {
+ testedContentTypes.add(input);
+ return false;
+ });
+
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ assertThat(e instanceof HttpDataSource.InvalidContentTypeException).isTrue();
+ // Check for connection not automatically closed.
+ verify(mockUrlRequest, never()).cancel();
+ assertThat(testedContentTypes).hasSize(1);
+ assertThat(testedContentTypes.get(0)).isEqualTo(TEST_CONTENT_TYPE);
+ }
+ }
+
+ @Test
+ public void testPostRequestOpen() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+ assertThat(dataSourceUnderTest.open(testPostDataSpec)).isEqualTo(TEST_CONTENT_LENGTH);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testPostDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void testPostRequestOpenValidatesContentType() {
+ mockResponseStartSuccess();
+
+ try {
+ dataSourceUnderTest.open(testPostDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ verify(mockUrlRequest, never()).start();
+ }
+ }
+
+ @Test
+ public void testPostRequestOpenRejects307Redirects() {
+ mockResponseStartSuccess();
+ mockResponseStartRedirect();
+
+ try {
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+ dataSourceUnderTest.open(testPostDataSpec);
+ fail("HttpDataSource.HttpDataSourceException expected");
+ } catch (HttpDataSourceException e) {
+ verify(mockUrlRequest, never()).followRedirect();
+ }
+ }
+
+ @Test
+ public void testHeadRequestOpen() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ dataSourceUnderTest.open(testHeadDataSpec);
+ verify(mockTransferListener)
+ .onTransferStart(dataSourceUnderTest, testHeadDataSpec, /* isNetwork= */ true);
+ dataSourceUnderTest.close();
+ }
+
+ @Test
+ public void testRequestReadTwice() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[8];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ returnedBuffer = new byte[8];
+ bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(8, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ // Should have only called read on cronet once.
+ verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
+ verify(mockTransferListener, times(2))
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void testSecondRequestNoContentLength() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ testResponseHeader.put("Content-Length", Long.toString(1L));
+ mockReadSuccess(0, 16);
+
+ // First request.
+ dataSourceUnderTest.open(testDataSpec);
+ byte[] returnedBuffer = new byte[8];
+ dataSourceUnderTest.read(returnedBuffer, 0, 1);
+ dataSourceUnderTest.close();
+
+ testResponseHeader.remove("Content-Length");
+ mockReadSuccess(0, 16);
+
+ // Second request.
+ dataSourceUnderTest.open(testDataSpec);
+ returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
+ assertThat(bytesRead).isEqualTo(10);
+ bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
+ assertThat(bytesRead).isEqualTo(6);
+ bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 10);
+ assertThat(bytesRead).isEqualTo(C.RESULT_END_OF_INPUT);
+ }
+
+ @Test
+ public void testReadWithOffset() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
+ assertThat(bytesRead).isEqualTo(8);
+ assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void testRangeRequestWith206Response() 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);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
+ assertThat(bytesRead).isEqualTo(16);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void testRangeRequestWith200Response() throws HttpDataSourceException {
+ 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);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
+ assertThat(bytesRead).isEqualTo(16);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(1000, 16));
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void testReadWithUnsetLength() throws HttpDataSourceException {
+ testResponseHeader.remove("Content-Length");
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[16];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 8, 8);
+ assertThat(returnedBuffer).isEqualTo(prefixZeros(buildTestDataArray(0, 8), 16));
+ assertThat(bytesRead).isEqualTo(8);
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 8);
+ }
+
+ @Test
+ public void testReadReturnsWhatItCan() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[24];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 24);
+ assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(0, 16), 24));
+ assertThat(bytesRead).isEqualTo(16);
+ verify(mockTransferListener)
+ .onBytesTransferred(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true, 16);
+ }
+
+ @Test
+ public void testClosedMeansClosed() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadSuccess(0, 16);
+
+ int bytesRead = 0;
+ dataSourceUnderTest.open(testDataSpec);
+
+ byte[] returnedBuffer = new byte[8];
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8));
+ assertThat(bytesRead).isEqualTo(8);
+
+ dataSourceUnderTest.close();
+ verify(mockTransferListener)
+ .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+
+ try {
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ fail();
+ } catch (IllegalStateException e) {
+ // Expected.
+ }
+
+ // 16 bytes were attempted but only 8 should have been successfully read.
+ assertThat(bytesRead).isEqualTo(8);
+ }
+
+ @Test
+ public void testOverread() 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);
+
+ byte[] returnedBuffer = new byte[8];
+ int bytesRead = dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(bytesRead).isEqualTo(8);
+ assertThat(returnedBuffer).isEqualTo(buildTestDataArray(0, 8));
+
+ // The current buffer is kept if not completely consumed by DataSource reader.
+ returnedBuffer = new byte[8];
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 6);
+ assertThat(bytesRead).isEqualTo(14);
+ assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(8, 6), 8));
+
+ // 2 bytes left at this point.
+ returnedBuffer = new byte[8];
+ bytesRead += dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ assertThat(bytesRead).isEqualTo(16);
+ assertThat(returnedBuffer).isEqualTo(suffixZeros(buildTestDataArray(14, 2), 8));
+
+ // Should have only called read on cronet once.
+ verify(mockUrlRequest, times(1)).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 = new byte[16];
+ int bytesOverRead = dataSourceUnderTest.read(returnedBuffer, 0, 16);
+ assertThat(bytesOverRead).isEqualTo(C.RESULT_END_OF_INPUT);
+ assertThat(returnedBuffer).isEqualTo(new byte[16]);
+ // 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);
+ // There should still be only one call to read on cronet.
+ verify(mockUrlRequest, times(1)).read(any(ByteBuffer.class));
+ // Check for connection not automatically closed.
+ verify(mockUrlRequest, never()).cancel();
+ 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();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e instanceof CronetDataSource.OpenException).isTrue();
+ assertThat(e.getCause() instanceof SocketTimeoutException).isTrue();
+ assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus)
+ .isEqualTo(TEST_CONNECTION_STATUS);
+ timedOutLatch.countDown();
+ }
+ }
+ }.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(timedOutLatch);
+ // We should still be trying to open as we approach the timeout.
+ SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(timedOutLatch);
+ // Now we timeout.
+ SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS + 10);
+ timedOutLatch.await();
+
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void testConnectInterrupted() throws InterruptedException {
+ long startTimeMs = SystemClock.elapsedRealtime();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+
+ Thread thread =
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e instanceof CronetDataSource.OpenException).isTrue();
+ assertThat(e.getCause() instanceof CronetDataSource.InterruptedIOException).isTrue();
+ assertThat(((CronetDataSource.OpenException) e).cronetConnectionStatus)
+ .isEqualTo(TEST_INVALID_CONNECTION_STATUS);
+ timedOutLatch.countDown();
+ }
+ }
+ };
+ thread.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(timedOutLatch);
+ // We should still be trying to open as we approach the timeout.
+ SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(timedOutLatch);
+ // Now we interrupt.
+ thread.interrupt();
+ timedOutLatch.await();
+
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ }
+
+ @Test
+ public void testConnectResponseBeforeTimeout() throws Exception {
+ long startTimeMs = SystemClock.elapsedRealtime();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch openLatch = new CountDownLatch(1);
+
+ AtomicReference exceptionOnTestThread = new AtomicReference<>();
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ } catch (HttpDataSourceException e) {
+ exceptionOnTestThread.set(e);
+ } finally {
+ openLatch.countDown();
+ }
+ }
+ }.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(openLatch);
+ // We should still be trying to open as we approach the timeout.
+ SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(openLatch);
+ // The response arrives just in time.
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(mockUrlRequest, testUrlResponseInfo);
+ openLatch.await();
+ assertThat(exceptionOnTestThread.get()).isNull();
+ }
+
+ @Test
+ public void testRedirectIncreasesConnectionTimeout() throws Exception {
+ long startTimeMs = SystemClock.elapsedRealtime();
+ final ConditionVariable startCondition = buildUrlRequestStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+ final AtomicInteger openExceptions = new AtomicInteger(0);
+
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.open(testDataSpec);
+ fail();
+ } catch (HttpDataSourceException e) {
+ // Expected.
+ assertThat(e instanceof CronetDataSource.OpenException).isTrue();
+ assertThat(e.getCause() instanceof SocketTimeoutException).isTrue();
+ openExceptions.getAndIncrement();
+ timedOutLatch.countDown();
+ }
+ }
+ }.start();
+ startCondition.block();
+
+ // We should still be trying to open.
+ assertNotCountedDown(timedOutLatch);
+ // We should still be trying to open as we approach the timeout.
+ SystemClock.setCurrentTimeMillis(startTimeMs + TEST_CONNECT_TIMEOUT_MS - 1);
+ assertNotCountedDown(timedOutLatch);
+ // A redirect arrives just in time.
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl1");
+
+ long newTimeoutMs = 2 * TEST_CONNECT_TIMEOUT_MS - 1;
+ SystemClock.setCurrentTimeMillis(startTimeMs + newTimeoutMs - 1);
+ // We should still be trying to open as we approach the new timeout.
+ assertNotCountedDown(timedOutLatch);
+ // A redirect arrives just in time.
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest, testUrlResponseInfo, "RandomRedirectedUrl2");
+
+ newTimeoutMs = 3 * TEST_CONNECT_TIMEOUT_MS - 2;
+ SystemClock.setCurrentTimeMillis(startTimeMs + newTimeoutMs - 1);
+ // We should still be trying to open as we approach the new timeout.
+ assertNotCountedDown(timedOutLatch);
+ // Now we timeout.
+ SystemClock.setCurrentTimeMillis(startTimeMs + newTimeoutMs + 10);
+ timedOutLatch.await();
+
+ verify(mockTransferListener, never())
+ .onTransferStart(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ assertThat(openExceptions.get()).isEqualTo(1);
+ }
+
+ @Test
+ public void testRedirectParseAndAttachCookie_dataSourceDoesNotHandleSetCookie_followsRedirect()
+ throws HttpDataSourceException {
+ mockSingleRedirectSuccess();
+ mockFollowRedirectSuccess();
+
+ testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequest).followRedirect();
+ }
+
+ @Test
+ public void
+ testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeaders()
+ throws HttpDataSourceException {
+ dataSourceUnderTest =
+ new CronetDataSource(
+ mockCronetEngine,
+ mockExecutor,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ true, // resetTimeoutOnRedirects
+ Clock.DEFAULT,
+ null,
+ true);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+
+ mockSingleRedirectSuccess();
+
+ testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Range"), any(String.class));
+ verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE);
+ verify(mockUrlRequest, never()).followRedirect();
+ verify(mockUrlRequest, times(2)).start();
+ }
+
+ @Test
+ public void
+ testRedirectParseAndAttachCookie_dataSourceHandlesSetCookie_andPreservesOriginalRequestHeadersIncludingByteRangeHeader()
+ throws HttpDataSourceException {
+ testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000, null);
+ dataSourceUnderTest =
+ new CronetDataSource(
+ mockCronetEngine,
+ mockExecutor,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ /* resetTimeoutOnRedirects= */ true,
+ Clock.DEFAULT,
+ /* defaultRequestProperties= */ null,
+ /* handleSetCookieRequests= */ true);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
+
+ mockSingleRedirectSuccess();
+
+ testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequestBuilder, times(2)).addHeader("Range", "bytes=1000-5999");
+ verify(mockUrlRequestBuilder, times(2)).addHeader("Content-Type", TEST_CONTENT_TYPE);
+ verify(mockUrlRequest, never()).followRedirect();
+ verify(mockUrlRequest, times(2)).start();
+ }
+
+ @Test
+ public void testRedirectNoSetCookieFollowsRedirect() throws HttpDataSourceException {
+ mockSingleRedirectSuccess();
+ mockFollowRedirectSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequest).followRedirect();
+ }
+
+ @Test
+ public void testRedirectNoSetCookieFollowsRedirect_dataSourceHandlesSetCookie()
+ throws HttpDataSourceException {
+ dataSourceUnderTest =
+ new CronetDataSource(
+ mockCronetEngine,
+ mockExecutor,
+ TEST_CONNECT_TIMEOUT_MS,
+ TEST_READ_TIMEOUT_MS,
+ /* resetTimeoutOnRedirects= */ true,
+ Clock.DEFAULT,
+ /* defaultRequestProperties= */ null,
+ /* handleSetCookieRequests= */ true);
+ dataSourceUnderTest.addTransferListener(mockTransferListener);
+ mockSingleRedirectSuccess();
+ mockFollowRedirectSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder, never()).addHeader(eq("Cookie"), any(String.class));
+ verify(mockUrlRequest).followRedirect();
+ }
+
+ @Test
+ public void testExceptionFromTransferListener() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+
+ // Make mockTransferListener throw an exception in CronetDataSource.close(). Ensure that
+ // the subsequent open() call succeeds.
+ doThrow(new NullPointerException())
+ .when(mockTransferListener)
+ .onTransferEnd(dataSourceUnderTest, testDataSpec, /* isNetwork= */ true);
+ dataSourceUnderTest.open(testDataSpec);
+ try {
+ dataSourceUnderTest.close();
+ fail("NullPointerException expected");
+ } catch (NullPointerException e) {
+ // Expected.
+ }
+ // Open should return successfully.
+ dataSourceUnderTest.open(testDataSpec);
+ }
+
+ @Test
+ public void testReadFailure() throws HttpDataSourceException {
+ mockResponseStartSuccess();
+ mockReadFailure();
+
+ dataSourceUnderTest.open(testDataSpec);
+ byte[] returnedBuffer = new byte[8];
+ try {
+ dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ fail("dataSourceUnderTest.read() returned, but IOException expected");
+ } catch (IOException e) {
+ // Expected.
+ }
+ }
+
+ @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();
+ dataSourceUnderTest.open(testDataSpec);
+
+ final ConditionVariable startCondition = buildReadStartedCondition();
+ final CountDownLatch timedOutLatch = new CountDownLatch(1);
+ byte[] returnedBuffer = new byte[8];
+ Thread thread =
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ dataSourceUnderTest.read(returnedBuffer, 0, 8);
+ 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 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);
+ mockResponseStartSuccess();
+
+ dataSourceUnderTest.open(testDataSpec);
+ verify(mockUrlRequestBuilder).allowDirectExecutor();
+ }
+
+ // Helper methods.
+
+ private void mockStatusResponse() {
+ doAnswer(
+ invocation -> {
+ UrlRequest.StatusListener statusListener =
+ (UrlRequest.StatusListener) invocation.getArguments()[0];
+ statusListener.onStatus(TEST_CONNECTION_STATUS);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .getStatus(any(UrlRequest.StatusListener.class));
+ }
+
+ private void mockResponseStartSuccess() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest, testUrlResponseInfo);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockResponseStartRedirect() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest,
+ createUrlResponseInfo(307), // statusCode
+ "http://redirect.location.com");
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockSingleRedirectSuccess() {
+ doAnswer(
+ invocation -> {
+ if (!redirectCalled) {
+ redirectCalled = true;
+ dataSourceUnderTest.urlRequestCallback.onRedirectReceived(
+ mockUrlRequest,
+ createUrlResponseInfoWithUrl("http://example.com/video", 300),
+ "http://example.com/video/redirect");
+ } else {
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest, testUrlResponseInfo);
+ }
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockFollowRedirectSuccess() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onResponseStarted(
+ mockUrlRequest, testUrlResponseInfo);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .followRedirect();
+ }
+
+ private void mockResponseStartFailure() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onFailed(
+ mockUrlRequest,
+ createUrlResponseInfo(500), // statusCode
+ mockNetworkException);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ }
+
+ private void mockReadSuccess(int position, int length) {
+ final int[] positionAndRemaining = new int[] {position, length};
+ doAnswer(
+ invocation -> {
+ if (positionAndRemaining[1] == 0) {
+ dataSourceUnderTest.urlRequestCallback.onSucceeded(
+ mockUrlRequest, testUrlResponseInfo);
+ } else {
+ ByteBuffer inputBuffer = (ByteBuffer) invocation.getArguments()[0];
+ int readLength = Math.min(positionAndRemaining[1], inputBuffer.remaining());
+ inputBuffer.put(buildTestDataBuffer(positionAndRemaining[0], readLength));
+ positionAndRemaining[0] += readLength;
+ positionAndRemaining[1] -= readLength;
+ dataSourceUnderTest.urlRequestCallback.onReadCompleted(
+ mockUrlRequest, testUrlResponseInfo, inputBuffer);
+ }
+ return null;
+ })
+ .when(mockUrlRequest)
+ .read(any(ByteBuffer.class));
+ }
+
+ private void mockReadFailure() {
+ doAnswer(
+ invocation -> {
+ dataSourceUnderTest.urlRequestCallback.onFailed(
+ mockUrlRequest,
+ createUrlResponseInfo(500), // statusCode
+ mockNetworkException);
+ return null;
+ })
+ .when(mockUrlRequest)
+ .read(any(ByteBuffer.class));
+ }
+
+ private ConditionVariable buildReadStartedCondition() {
+ final ConditionVariable startedCondition = new ConditionVariable();
+ doAnswer(
+ invocation -> {
+ startedCondition.open();
+ return null;
+ })
+ .when(mockUrlRequest)
+ .read(any(ByteBuffer.class));
+ return startedCondition;
+ }
+
+ private ConditionVariable buildUrlRequestStartedCondition() {
+ final ConditionVariable startedCondition = new ConditionVariable();
+ doAnswer(
+ invocation -> {
+ startedCondition.open();
+ return null;
+ })
+ .when(mockUrlRequest)
+ .start();
+ return startedCondition;
+ }
+
+ private void assertNotCountedDown(CountDownLatch countDownLatch) throws InterruptedException {
+ // We are asserting that another thread does not count down the latch. We therefore sleep some
+ // time to give the other thread the chance to fail this test.
+ Thread.sleep(50);
+ assertThat(countDownLatch.getCount()).isGreaterThan(0L);
+ }
+
+ private static byte[] buildTestDataArray(int position, int length) {
+ return buildTestDataBuffer(position, length).array();
+ }
+
+ public static byte[] prefixZeros(byte[] data, int requiredLength) {
+ byte[] prefixedData = new byte[requiredLength];
+ System.arraycopy(data, 0, prefixedData, requiredLength - data.length, data.length);
+ return prefixedData;
+ }
+
+ public static byte[] suffixZeros(byte[] data, int requiredLength) {
+ return Arrays.copyOf(data, requiredLength);
+ }
+
+ private static ByteBuffer buildTestDataBuffer(int position, int length) {
+ ByteBuffer testBuffer = ByteBuffer.allocate(length);
+ for (int i = 0; i < length; i++) {
+ testBuffer.put((byte) (position + i));
+ }
+ 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;
+ }
+}
diff --git a/extensions/ffmpeg/README.md b/extensions/ffmpeg/README.md
index 4ce9173ec9..1b2db8f0f4 100644
--- a/extensions/ffmpeg/README.md
+++ b/extensions/ffmpeg/README.md
@@ -1,31 +1,35 @@
-# FfmpegAudioRenderer #
+# ExoPlayer FFmpeg extension #
-## Description ##
+The FFmpeg extension provides `FfmpegAudioRenderer`, which uses FFmpeg for
+decoding and can render audio encoded in a variety of formats.
-The FFmpeg extension is a [Renderer][] implementation that uses FFmpeg to decode
-audio.
+## License note ##
-[Renderer]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Renderer.html
+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.
-## Build instructions ##
+[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
-* Checkout ExoPlayer along with Extensions
+## Build instructions (Linux, macOS) ##
-```
-git clone https://github.com/google/ExoPlayer.git
-```
+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][]. The extension is not provided via JCenter (see [#2781][]
+for more information).
-* Set the following environment variables:
+In addition, it's necessary to build the extension's native components as
+follows:
+
+* Set the following shell variable:
```
cd ""
-EXOPLAYER_ROOT="$(pwd)"
-FFMPEG_EXT_PATH="${EXOPLAYER_ROOT}/extensions/ffmpeg/src/main"
+FFMPEG_EXT_PATH="$(pwd)/extensions/ffmpeg/src/main/jni"
```
-* Download the [Android NDK][] and set its location in an environment variable:
-
-[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
+* Download the [Android NDK][] and set its location in a shell variable.
+ This build configuration has been tested on NDK r20.
```
NDK_PATH=""
@@ -37,89 +41,84 @@ NDK_PATH=""
HOST_PLATFORM="linux-x86_64"
```
-* Fetch and build FFmpeg. For example, to fetch and build 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.
```
-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}/jni" && \
-git clone git://source.ffmpeg.org/ffmpeg ffmpeg && cd ffmpeg && \
-./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
+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:
+
+```
+cd "${FFMPEG_EXT_PATH}" && \
+./build_ffmpeg.sh \
+ "${FFMPEG_EXT_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ENABLED_DECODERS[@]}"
```
* Build the JNI native libraries, setting `APP_ABI` to include the architectures
built in the previous step. For example:
```
-cd "${FFMPEG_EXT_PATH}"/jni && \
+cd "${FFMPEG_EXT_PATH}" && \
${NDK_PATH}/ndk-build APP_ABI="armeabi-v7a arm64-v8a x86" -j4
```
-* In your project, you can add a dependency on the extension by using a rule
- like this:
+## Build instructions (Windows) ##
-```
-// in settings.gradle
-include ':..:ExoPlayer:library'
-include ':..:ExoPlayer:extension-ffmpeg'
+We do not provide support for building this extension on Windows, however it
+should be possible to follow the Linux instructions in [Windows PowerShell][].
-// in build.gradle
-dependencies {
- compile project(':..:ExoPlayer:library')
- compile project(':..:ExoPlayer:extension-ffmpeg')
-}
-```
+[Windows PowerShell]: https://docs.microsoft.com/en-us/powershell/scripting/getting-started/getting-started-with-windows-powershell
-* Now, when you build your app, the extension will be built and the native
- libraries will be packaged along with the APK.
+## 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 `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 `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,
+so you need to make sure you are passing an `FfmpegAudioRenderer` to the player,
+then implement your own logic to use the renderer for a given track.
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
+[#2781]: https://github.com/google/ExoPlayer/issues/2781
+[Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension
+
+## Using the extension in the demo application ##
+
+To try out playback using the extension in the [demo application][], see
+[enabling extension decoders][].
+
+[demo application]: https://exoplayer.dev/demo-application.html
+[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders
+
+## Links ##
+
+* [Troubleshooting using extensions][]
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.ffmpeg.*`
+ belong to this module.
+
+[Troubleshooting using extensions]: https://exoplayer.dev/troubleshooting.html#how-can-i-get-a-decoding-extension-to-load-and-be-used-for-playback
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/ffmpeg/build.gradle b/extensions/ffmpeg/build.gradle
index 0eddd017a4..657fa75c24 100644
--- a/extensions/ffmpeg/build.gradle
+++ b/extensions/ffmpeg/build.gradle
@@ -11,11 +11,16 @@
// WITHOUT 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
- buildToolsVersion project.ext.buildToolsVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
defaultConfig {
minSdkVersion project.ext.minSdkVersion
@@ -27,10 +32,16 @@ android {
jniLibs.srcDir 'src/main/libs'
jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio.
}
+
+ testOptions.unitTests.includeAndroidResources = true
}
dependencies {
- compile project(':library-core')
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
index 8d75ca3dbb..0673f7893a 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegAudioRenderer.java
@@ -16,27 +16,38 @@
package com.google.android.exoplayer2.ext.ffmpeg;
import android.os.Handler;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.audio.AudioProcessor;
import com.google.android.exoplayer2.audio.AudioRendererEventListener;
+import com.google.android.exoplayer2.audio.AudioSink;
+import com.google.android.exoplayer2.audio.DefaultAudioSink;
import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer;
+import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
+import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.MimeTypes;
+import java.util.Collections;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* Decodes and renders audio using FFmpeg.
*/
public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
+ /** The number of input and output buffers. */
private static final int NUM_BUFFERS = 16;
- private static final int INITIAL_INPUT_BUFFER_SIZE = 960 * 6;
+ /** The default input buffer size. */
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
- private FfmpegDecoder decoder;
+ private final boolean enableFloatOutput;
+
+ private @MonotonicNonNull FfmpegDecoder decoder;
public FfmpegAudioRenderer() {
- this(null, null);
+ this(/* eventHandler= */ null, /* eventListener= */ null);
}
/**
@@ -45,40 +56,118 @@ public final class FfmpegAudioRenderer extends SimpleDecoderAudioRenderer {
* @param eventListener A listener of events. May be null if delivery of events is not required.
* @param audioProcessors Optional {@link AudioProcessor}s that will process audio before output.
*/
- public FfmpegAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener,
+ public FfmpegAudioRenderer(
+ @Nullable Handler eventHandler,
+ @Nullable AudioRendererEventListener eventListener,
AudioProcessor... audioProcessors) {
- super(eventHandler, eventListener, audioProcessors);
+ this(
+ eventHandler,
+ eventListener,
+ new DefaultAudioSink(/* audioCapabilities= */ null, audioProcessors),
+ /* enableFloatOutput= */ false);
+ }
+
+ /**
+ * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
+ * null if delivery of events is not required.
+ * @param eventListener A listener of events. May be null if delivery of events is not required.
+ * @param audioSink The sink to which audio will be output.
+ * @param enableFloatOutput Whether to enable 32-bit float audio format, if supported on the
+ * device/build and if the input format may have bit depth higher than 16-bit. When using
+ * 32-bit float output, any audio processing will be disabled, including playback speed/pitch
+ * adjustment.
+ */
+ public FfmpegAudioRenderer(
+ @Nullable Handler eventHandler,
+ @Nullable AudioRendererEventListener eventListener,
+ AudioSink audioSink,
+ boolean enableFloatOutput) {
+ super(
+ eventHandler,
+ eventListener,
+ /* drmSessionManager= */ null,
+ /* playClearSamplesWithoutKeys= */ false,
+ audioSink);
+ this.enableFloatOutput = enableFloatOutput;
}
@Override
- protected int supportsFormatInternal(Format format) {
+ @FormatSupport
+ protected int supportsFormatInternal(
+ @Nullable DrmSessionManager drmSessionManager, Format format) {
+ Assertions.checkNotNull(format.sampleMimeType);
if (!FfmpegLibrary.isAvailable()) {
return FORMAT_UNSUPPORTED_TYPE;
+ } else if (!FfmpegLibrary.supportsFormat(format.sampleMimeType) || !isOutputSupported(format)) {
+ return FORMAT_UNSUPPORTED_SUBTYPE;
+ } else if (!supportsFormatDrm(drmSessionManager, format.drmInitData)) {
+ return FORMAT_UNSUPPORTED_DRM;
+ } else {
+ return FORMAT_HANDLED;
}
- String mimeType = format.sampleMimeType;
- return FfmpegLibrary.supportsFormat(mimeType) ? FORMAT_HANDLED
- : MimeTypes.isAudio(mimeType) ? FORMAT_UNSUPPORTED_SUBTYPE : FORMAT_UNSUPPORTED_TYPE;
}
@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 {
- decoder = new FfmpegDecoder(NUM_BUFFERS, NUM_BUFFERS, INITIAL_INPUT_BUFFER_SIZE,
- format.sampleMimeType, format.initializationData);
+ int initialInputBufferSize =
+ format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
+ decoder =
+ new FfmpegDecoder(
+ NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, format, shouldUseFloatOutput(format));
return decoder;
}
@Override
public Format getOutputFormat() {
+ Assertions.checkNotNull(decoder);
int channelCount = decoder.getChannelCount();
int sampleRate = decoder.getSampleRate();
- return Format.createAudioSampleFormat(null, MimeTypes.AUDIO_RAW, null, Format.NO_VALUE,
- Format.NO_VALUE, channelCount, sampleRate, C.ENCODING_PCM_16BIT, null, null, 0, null);
+ @C.PcmEncoding int encoding = decoder.getEncoding();
+ return Format.createAudioSampleFormat(
+ /* id= */ null,
+ MimeTypes.AUDIO_RAW,
+ /* codecs= */ null,
+ Format.NO_VALUE,
+ Format.NO_VALUE,
+ channelCount,
+ sampleRate,
+ encoding,
+ Collections.emptyList(),
+ /* drmInitData= */ null,
+ /* selectionFlags= */ 0,
+ /* language= */ null);
+ }
+
+ private boolean isOutputSupported(Format inputFormat) {
+ return shouldUseFloatOutput(inputFormat)
+ || supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_16BIT);
+ }
+
+ private boolean shouldUseFloatOutput(Format inputFormat) {
+ Assertions.checkNotNull(inputFormat.sampleMimeType);
+ if (!enableFloatOutput || !supportsOutput(inputFormat.channelCount, C.ENCODING_PCM_FLOAT)) {
+ return false;
+ }
+ switch (inputFormat.sampleMimeType) {
+ case MimeTypes.AUDIO_RAW:
+ // For raw audio, output in 32-bit float encoding if the bit depth is > 16-bit.
+ return inputFormat.pcmEncoding == C.ENCODING_PCM_24BIT
+ || inputFormat.pcmEncoding == C.ENCODING_PCM_32BIT
+ || inputFormat.pcmEncoding == C.ENCODING_PCM_FLOAT;
+ case MimeTypes.AUDIO_AC3:
+ // AC-3 is always 16-bit, so there is no point outputting in 32-bit float encoding.
+ return false;
+ default:
+ // For all other formats, assume that it's worth using 32-bit float encoding.
+ return true;
+ }
}
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java
index 2af2101ee7..6fa3d888db 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoder.java
@@ -15,11 +15,16 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Format;
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.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;
@@ -29,26 +34,42 @@ import java.util.List;
/* package */ final class FfmpegDecoder extends
SimpleDecoder {
- // Space for 64 ms of 6 channel 48 kHz 16-bit PCM audio.
- private static final int OUTPUT_BUFFER_SIZE = 1536 * 6 * 2 * 2;
+ // Output buffer sizes when decoding PCM mu-law streams, which is the maximum FFmpeg outputs.
+ private static final int OUTPUT_BUFFER_SIZE_16BIT = 65536;
+ private static final int OUTPUT_BUFFER_SIZE_32BIT = OUTPUT_BUFFER_SIZE_16BIT * 2;
+
+ // Error codes matching ffmpeg_jni.cc.
+ private static final int DECODER_ERROR_INVALID_DATA = -1;
+ private static final int DECODER_ERROR_OTHER = -2;
private final String codecName;
- private final byte[] extraData;
+ @Nullable private final byte[] extraData;
+ private final @C.Encoding int encoding;
+ private final int outputBufferSize;
private long nativeContext; // May be reassigned on resetting the codec.
private boolean hasOutputFormat;
private volatile int channelCount;
private volatile int sampleRate;
- public FfmpegDecoder(int numInputBuffers, int numOutputBuffers, int initialInputBufferSize,
- String mimeType, List initializationData) throws FfmpegDecoderException {
+ public FfmpegDecoder(
+ int numInputBuffers,
+ int numOutputBuffers,
+ int initialInputBufferSize,
+ Format format,
+ boolean outputFloat)
+ throws FfmpegDecoderException {
super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]);
if (!FfmpegLibrary.isAvailable()) {
throw new FfmpegDecoderException("Failed to load decoder native libraries.");
}
- codecName = FfmpegLibrary.getCodecName(mimeType);
- extraData = getExtraData(mimeType, initializationData);
- nativeContext = ffmpegInitialize(codecName, extraData);
+ Assertions.checkNotNull(format.sampleMimeType);
+ codecName = Assertions.checkNotNull(FfmpegLibrary.getCodecName(format.sampleMimeType));
+ extraData = getExtraData(format.sampleMimeType, format.initializationData);
+ encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
+ outputBufferSize = outputFloat ? OUTPUT_BUFFER_SIZE_32BIT : OUTPUT_BUFFER_SIZE_16BIT;
+ nativeContext =
+ ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount);
if (nativeContext == 0) {
throw new FfmpegDecoderException("Initialization failed.");
}
@@ -61,35 +82,47 @@ import java.util.List;
}
@Override
- public DecoderInputBuffer createInputBuffer() {
+ protected DecoderInputBuffer createInputBuffer() {
return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT);
}
@Override
- public SimpleOutputBuffer createOutputBuffer() {
+ protected SimpleOutputBuffer createOutputBuffer() {
return new SimpleOutputBuffer(this);
}
@Override
- public FfmpegDecoderException decode(DecoderInputBuffer inputBuffer,
- SimpleOutputBuffer outputBuffer, boolean reset) {
+ protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) {
+ return new FfmpegDecoderException("Unexpected decode error", error);
+ }
+
+ @Override
+ protected @Nullable FfmpegDecoderException decode(
+ DecoderInputBuffer inputBuffer, SimpleOutputBuffer outputBuffer, boolean reset) {
if (reset) {
nativeContext = ffmpegReset(nativeContext, extraData);
if (nativeContext == 0) {
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, OUTPUT_BUFFER_SIZE);
- int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, OUTPUT_BUFFER_SIZE);
- if (result < 0) {
- return new FfmpegDecoderException("Error decoding (see logcat). Code: " + result);
+ ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
+ int result = ffmpegDecode(nativeContext, inputData, inputSize, outputData, outputBufferSize);
+ if (result == DECODER_ERROR_INVALID_DATA) {
+ // Treat invalid data errors as non-fatal to match the behavior of MediaCodec. No output will
+ // be produced for this buffer, so mark it as decode-only to ensure that the audio sink's
+ // position is reset when more audio is produced.
+ outputBuffer.setFlags(C.BUFFER_FLAG_DECODE_ONLY);
+ return null;
+ } else if (result == DECODER_ERROR_OTHER) {
+ return new FfmpegDecoderException("Error decoding (see logcat).");
}
if (!hasOutputFormat) {
channelCount = ffmpegGetChannelCount(nativeContext);
sampleRate = ffmpegGetSampleRate(nativeContext);
if (sampleRate == 0 && "alac".equals(codecName)) {
+ Assertions.checkNotNull(extraData);
// ALAC decoder did not set the sample rate in earlier versions of FFMPEG.
// See https://trac.ffmpeg.org/ticket/6096
ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
@@ -98,8 +131,8 @@ import java.util.List;
}
hasOutputFormat = true;
}
- outputBuffer.data.position(0);
- outputBuffer.data.limit(result);
+ outputData.position(0);
+ outputData.limit(result);
return null;
}
@@ -110,55 +143,87 @@ import java.util.List;
nativeContext = 0;
}
- /**
- * Returns the channel count of output audio. May only be called after {@link #decode}.
- */
+ /** Returns the channel count of output audio. */
public int getChannelCount() {
return channelCount;
}
- /**
- * Returns the sample rate of output audio. May only be called after {@link #decode}.
- */
+ /** Returns the sample rate of output audio. */
public int getSampleRate() {
return sampleRate;
}
+ /**
+ * Returns the encoding of output audio.
+ */
+ public @C.Encoding int getEncoding() {
+ return encoding;
+ }
+
/**
* Returns FFmpeg-compatible codec-specific initialization data ("extra data"), or {@code null} if
* not required.
*/
- private static byte[] getExtraData(String mimeType, List initializationData) {
+ private static @Nullable byte[] getExtraData(String mimeType, List initializationData) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
- case MimeTypes.AUDIO_ALAC:
case MimeTypes.AUDIO_OPUS:
return initializationData.get(0);
+ case MimeTypes.AUDIO_ALAC:
+ return getAlacExtraData(initializationData);
case MimeTypes.AUDIO_VORBIS:
- byte[] header0 = initializationData.get(0);
- byte[] header1 = initializationData.get(1);
- byte[] extraData = new byte[header0.length + header1.length + 6];
- extraData[0] = (byte) (header0.length >> 8);
- extraData[1] = (byte) (header0.length & 0xFF);
- System.arraycopy(header0, 0, extraData, 2, header0.length);
- extraData[header0.length + 2] = 0;
- extraData[header0.length + 3] = 0;
- extraData[header0.length + 4] = (byte) (header1.length >> 8);
- extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
- System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
- return extraData;
+ return getVorbisExtraData(initializationData);
default:
// Other codecs do not require extra data.
return null;
}
}
- private native long ffmpegInitialize(String codecName, byte[] extraData);
+ private static byte[] getAlacExtraData(List initializationData) {
+ // FFmpeg's ALAC decoder expects an ALAC atom, which contains the ALAC "magic cookie", as extra
+ // data. initializationData[0] contains only the magic cookie, and so we need to package it into
+ // an ALAC atom. See:
+ // https://ffmpeg.org/doxygen/0.6/alac_8c.html
+ // https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt
+ byte[] magicCookie = initializationData.get(0);
+ int alacAtomLength = 12 + magicCookie.length;
+ ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
+ alacAtom.putInt(alacAtomLength);
+ alacAtom.putInt(0x616c6163); // type=alac
+ alacAtom.putInt(0); // version=0, flags=0
+ alacAtom.put(magicCookie, /* offset= */ 0, magicCookie.length);
+ return alacAtom.array();
+ }
+
+ private static byte[] getVorbisExtraData(List initializationData) {
+ byte[] header0 = initializationData.get(0);
+ byte[] header1 = initializationData.get(1);
+ byte[] extraData = new byte[header0.length + header1.length + 6];
+ extraData[0] = (byte) (header0.length >> 8);
+ extraData[1] = (byte) (header0.length & 0xFF);
+ System.arraycopy(header0, 0, extraData, 2, header0.length);
+ extraData[header0.length + 2] = 0;
+ extraData[header0.length + 3] = 0;
+ extraData[header0.length + 4] = (byte) (header1.length >> 8);
+ extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
+ System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
+ return extraData;
+ }
+
+ private native long ffmpegInitialize(
+ String codecName,
+ @Nullable byte[] extraData,
+ boolean outputFloat,
+ int rawSampleRate,
+ int rawChannelCount);
+
private native int ffmpegDecode(long context, ByteBuffer inputData, int inputSize,
ByteBuffer outputData, int outputSize);
private native int ffmpegGetChannelCount(long context);
private native int ffmpegGetSampleRate(long context);
- private native long ffmpegReset(long context, byte[] extraData);
+
+ private native long ffmpegReset(long context, @Nullable byte[] extraData);
+
private native void ffmpegRelease(long context);
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java
index b4cf327198..d6b5a62450 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegDecoderException.java
@@ -26,4 +26,7 @@ public final class FfmpegDecoderException extends AudioDecoderException {
super(message);
}
+ /* package */ FfmpegDecoderException(String message, Throwable cause) {
+ super(message, cause);
+ }
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
index 4992bcbb3e..4639851263 100644
--- a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java
@@ -15,7 +15,10 @@
*/
package com.google.android.exoplayer2.ext.ffmpeg;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.util.LibraryLoader;
+import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.MimeTypes;
/**
@@ -23,8 +26,14 @@ import com.google.android.exoplayer2.util.MimeTypes;
*/
public final class FfmpegLibrary {
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.ffmpeg");
+ }
+
+ 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() {}
@@ -32,6 +41,8 @@ public final class FfmpegLibrary {
* Override the names of the FFmpeg native libraries. If an application wishes to call this
* method, it must do so before calling any other method defined by this class, and before
* instantiating a {@link FfmpegAudioRenderer} instance.
+ *
+ * @param libraries The names of the FFmpeg native libraries.
*/
public static void setLibraries(String... libraries) {
LOADER.setLibraries(libraries);
@@ -44,28 +55,36 @@ public final class FfmpegLibrary {
return LOADER.isAvailable();
}
- /**
- * Returns the version of the underlying library if available, or null otherwise.
- */
- public static String getVersion() {
+ /** Returns the version of the underlying library if available, or null otherwise. */
+ public static @Nullable String getVersion() {
return isAvailable() ? ffmpegGetVersion() : null;
}
/**
* Returns whether the underlying library supports the specified MIME type.
+ *
+ * @param mimeType The MIME type to check.
*/
public static boolean supportsFormat(String mimeType) {
if (!isAvailable()) {
return false;
}
String codecName = getCodecName(mimeType);
- return codecName != null && ffmpegHasDecoder(codecName);
+ if (codecName == null) {
+ return false;
+ }
+ if (!ffmpegHasDecoder(codecName)) {
+ Log.w(TAG, "No " + codecName + " decoder available. Check the FFmpeg build configuration.");
+ return false;
+ }
+ return true;
}
/**
- * Returns the name of the FFmpeg decoder that could be used to decode {@code mimeType}.
+ * Returns the name of the FFmpeg decoder that could be used to decode the format, or {@code null}
+ * if it's unsupported.
*/
- /* package */ static String getCodecName(String mimeType) {
+ /* package */ static @Nullable String getCodecName(String mimeType) {
switch (mimeType) {
case MimeTypes.AUDIO_AAC:
return "aac";
@@ -76,6 +95,7 @@ public final class FfmpegLibrary {
case MimeTypes.AUDIO_AC3:
return "ac3";
case MimeTypes.AUDIO_E_AC3:
+ case MimeTypes.AUDIO_E_AC3_JOC:
return "eac3";
case MimeTypes.AUDIO_TRUEHD:
return "truehd";
@@ -94,6 +114,10 @@ public final class FfmpegLibrary {
return "flac";
case MimeTypes.AUDIO_ALAC:
return "alac";
+ case MimeTypes.AUDIO_MLAW:
+ return "pcm_mulaw";
+ case MimeTypes.AUDIO_ALAW:
+ return "pcm_alaw";
default:
return null;
}
diff --git a/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/package-info.java b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/package-info.java
new file mode 100644
index 0000000000..a9fedb19cb
--- /dev/null
+++ b/extensions/ffmpeg/src/main/java/com/google/android/exoplayer2/ext/ffmpeg/package-info.java
@@ -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;
diff --git a/extensions/ffmpeg/src/main/jni/Android.mk b/extensions/ffmpeg/src/main/jni/Android.mk
index 046f90a5b2..22a4edcdae 100644
--- a/extensions/ffmpeg/src/main/jni/Android.mk
+++ b/extensions/ffmpeg/src/main/jni/Android.mk
@@ -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)
diff --git a/extensions/ffmpeg/src/main/jni/Application.mk b/extensions/ffmpeg/src/main/jni/Application.mk
index 59bf5f8f87..7d6f732548 100644
--- a/extensions/ffmpeg/src/main/jni/Application.mk
+++ b/extensions/ffmpeg/src/main/jni/Application.mk
@@ -15,6 +15,6 @@
#
APP_OPTIM := release
-APP_STL := gnustl_static
+APP_STL := c++_static
APP_CPPFLAGS := -frtti
APP_PLATFORM := android-9
diff --git a/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh
new file mode 100755
index 0000000000..a76fa0e589
--- /dev/null
+++ b/extensions/ffmpeg/src/main/jni/build_ffmpeg.sh
@@ -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
diff --git a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
index fa615f2ec1..dcd4560e4a 100644
--- a/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
+++ b/extensions/ffmpeg/src/main/jni/ffmpeg_jni.cc
@@ -27,6 +27,7 @@ extern "C" {
#endif
#include
#include
+#include
#include
#include
}
@@ -57,8 +58,14 @@ extern "C" {
#define ERROR_STRING_BUFFER_LENGTH 256
-// Request a format corresponding to AudioFormat.ENCODING_PCM_16BIT.
-static const AVSampleFormat OUTPUT_FORMAT = AV_SAMPLE_FMT_S16;
+// Output format corresponding to AudioFormat.ENCODING_PCM_16BIT.
+static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16;
+// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT.
+static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT;
+
+// Error codes matching FfmpegDecoder.java.
+static const int DECODER_ERROR_INVALID_DATA = -1;
+static const int DECODER_ERROR_OTHER = -2;
/**
* Returns the AVCodec with the specified name, or NULL if it is not available.
@@ -70,12 +77,13 @@ AVCodec *getCodecByName(JNIEnv* env, jstring codecName);
* provided extraData as initialization data for the decoder if it is non-NULL.
* Returns the created context.
*/
-AVCodecContext *createContext(JNIEnv *env, AVCodec *codec,
- jbyteArray extraData);
+AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData,
+ jboolean outputFloat, jint rawSampleRate,
+ jint rawChannelCount);
/**
* Decodes the packet into the output buffer, returning the number of bytes
- * written, or a negative value in the case of an error.
+ * written, or a negative DECODER_ERROR constant value in the case of an error.
*/
int decodePacket(AVCodecContext *context, AVPacket *packet,
uint8_t *outputBuffer, int outputSize);
@@ -107,13 +115,15 @@ LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) {
return getCodecByName(env, codecName) != NULL;
}
-DECODER_FUNC(jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData) {
+DECODER_FUNC(jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData,
+ jboolean outputFloat, jint rawSampleRate, jint rawChannelCount) {
AVCodec *codec = getCodecByName(env, codecName);
if (!codec) {
LOGE("Codec not found.");
return 0L;
}
- return (jlong) createContext(env, codec, extraData);
+ return (jlong)createContext(env, codec, extraData, outputFloat, rawSampleRate,
+ rawChannelCount);
}
DECODER_FUNC(jint, ffmpegDecode, jlong context, jobject inputData,
@@ -177,7 +187,11 @@ DECODER_FUNC(jlong, ffmpegReset, jlong jContext, jbyteArray extraData) {
LOGE("Unexpected error finding codec %d.", codecId);
return 0L;
}
- return (jlong) createContext(env, codec, extraData);
+ jboolean outputFloat =
+ (jboolean)(context->request_sample_fmt == OUTPUT_FORMAT_PCM_FLOAT);
+ return (jlong)createContext(env, codec, extraData, outputFloat,
+ /* rawSampleRate= */ -1,
+ /* rawChannelCount= */ -1);
}
avcodec_flush_buffers(context);
@@ -200,14 +214,16 @@ AVCodec *getCodecByName(JNIEnv* env, jstring codecName) {
return codec;
}
-AVCodecContext *createContext(JNIEnv *env, AVCodec *codec,
- jbyteArray extraData) {
+AVCodecContext *createContext(JNIEnv *env, AVCodec *codec, jbyteArray extraData,
+ jboolean outputFloat, jint rawSampleRate,
+ jint rawChannelCount) {
AVCodecContext *context = avcodec_alloc_context3(codec);
if (!context) {
LOGE("Failed to allocate context.");
return NULL;
}
- context->request_sample_fmt = OUTPUT_FORMAT;
+ context->request_sample_fmt =
+ outputFloat ? OUTPUT_FORMAT_PCM_FLOAT : OUTPUT_FORMAT_PCM_16BIT;
if (extraData) {
jsize size = env->GetArrayLength(extraData);
context->extradata_size = size;
@@ -220,6 +236,13 @@ AVCodecContext *createContext(JNIEnv *env, AVCodec *codec,
}
env->GetByteArrayRegion(extraData, 0, size, (jbyte *) context->extradata);
}
+ if (context->codec_id == AV_CODEC_ID_PCM_MULAW ||
+ context->codec_id == AV_CODEC_ID_PCM_ALAW) {
+ context->sample_rate = rawSampleRate;
+ context->channels = rawChannelCount;
+ context->channel_layout = av_get_default_channel_layout(rawChannelCount);
+ }
+ context->err_recognition = AV_EF_IGNORE_ERR;
int result = avcodec_open2(context, codec, NULL);
if (result < 0) {
logError("avcodec_open2", result);
@@ -236,7 +259,8 @@ int decodePacket(AVCodecContext *context, AVPacket *packet,
result = avcodec_send_packet(context, packet);
if (result) {
logError("avcodec_send_packet", result);
- return result;
+ return result == AVERROR_INVALIDDATA ? DECODER_ERROR_INVALID_DATA
+ : DECODER_ERROR_OTHER;
}
// Dequeue output data until it runs out.
@@ -275,7 +299,9 @@ int decodePacket(AVCodecContext *context, AVPacket *packet,
av_opt_set_int(resampleContext, "in_sample_rate", sampleRate, 0);
av_opt_set_int(resampleContext, "out_sample_rate", sampleRate, 0);
av_opt_set_int(resampleContext, "in_sample_fmt", sampleFormat, 0);
- av_opt_set_int(resampleContext, "out_sample_fmt", OUTPUT_FORMAT, 0);
+ // The output format is always the requested format.
+ av_opt_set_int(resampleContext, "out_sample_fmt",
+ context->request_sample_fmt, 0);
result = avresample_open(resampleContext);
if (result < 0) {
logError("avresample_open", result);
@@ -285,7 +311,7 @@ int decodePacket(AVCodecContext *context, AVPacket *packet,
context->opaque = resampleContext;
}
int inSampleSize = av_get_bytes_per_sample(sampleFormat);
- int outSampleSize = av_get_bytes_per_sample(OUTPUT_FORMAT);
+ int outSampleSize = av_get_bytes_per_sample(context->request_sample_fmt);
int outSamples = avresample_get_out_samples(resampleContext, sampleCount);
int bufferOutSize = outSampleSize * channelCount * outSamples;
if (outSize + bufferOutSize > outputSize) {
diff --git a/extensions/ffmpeg/src/test/AndroidManifest.xml b/extensions/ffmpeg/src/test/AndroidManifest.xml
new file mode 100644
index 0000000000..6ec1cea289
--- /dev/null
+++ b/extensions/ffmpeg/src/test/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java
new file mode 100644
index 0000000000..a52d1b1d7a
--- /dev/null
+++ b/extensions/ffmpeg/src/test/java/com/google/android/exoplayer2/ext/ffmpeg/DefaultRenderersFactoryTest.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ffmpeg;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.testutil.DefaultRenderersFactoryAsserts;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link DefaultRenderersFactoryTest} with {@link FfmpegAudioRenderer}. */
+@RunWith(AndroidJUnit4.class)
+public final class DefaultRenderersFactoryTest {
+
+ @Test
+ public void createRenderers_instantiatesVpxRenderer() {
+ DefaultRenderersFactoryAsserts.assertExtensionRendererCreated(
+ FfmpegAudioRenderer.class, C.TRACK_TYPE_AUDIO);
+ }
+}
diff --git a/extensions/flac/README.md b/extensions/flac/README.md
index 2f3b067d6f..a9d4c3094e 100644
--- a/extensions/flac/README.md
+++ b/extensions/flac/README.md
@@ -1,20 +1,24 @@
-# ExoPlayer Flac Extension #
+# ExoPlayer Flac extension #
-## Description ##
+The Flac extension provides `FlacExtractor` and `LibflacAudioRenderer`, which
+use libFLAC (the Flac decoding library) to extract and decode FLAC audio.
-The Flac Extension is a [Renderer][] implementation that helps you bundle
-libFLAC (the Flac decoding library) into your app and use it along with
-ExoPlayer to play Flac audio on Android devices.
+## License note ##
-[Renderer]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Renderer.html
+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.
-## Build Instructions ##
+[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
-* Checkout ExoPlayer along with Extensions:
+## Build instructions (Linux, macOS) ##
-```
-git clone https://github.com/google/ExoPlayer.git
-```
+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 build the extension's native components as
+follows:
* Set the following environment variables:
@@ -24,20 +28,19 @@ EXOPLAYER_ROOT="$(pwd)"
FLAC_EXT_PATH="${EXOPLAYER_ROOT}/extensions/flac/src/main"
```
-* Download the [Android NDK][] and set its location in an environment variable:
-
-[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
+* Download the [Android NDK][] and set its location in an environment variable.
+ This build configuration has been tested on NDK r20.
```
NDK_PATH=""
```
-* Download and extract flac-1.3.1 as "${FLAC_EXT_PATH}/jni/flac" folder:
+* Download and extract flac-1.3.2 as "${FLAC_EXT_PATH}/jni/flac" folder:
```
cd "${FLAC_EXT_PATH}/jni" && \
-curl http://downloads.xiph.org/releases/flac/flac-1.3.1.tar.xz | tar xJ && \
-mv flac-1.3.1 flac
+curl https://ftp.osuosl.org/pub/xiph/releases/flac/flac-1.3.2.tar.xz | tar xJ && \
+mv flac-1.3.2 flac
```
* Build the JNI native libraries from the command line:
@@ -47,20 +50,64 @@ cd "${FLAC_EXT_PATH}"/jni && \
${NDK_PATH}/ndk-build APP_ABI=all -j4
```
-* In your project, you can add a dependency to the Flac Extension by using a
- rule like this:
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
-```
-// in settings.gradle
-include ':..:ExoPlayer:library'
-include ':..:ExoPlayer:extension-flac'
+## Build instructions (Windows) ##
-// in build.gradle
-dependencies {
- compile project(':..:ExoPlayer:library')
- compile project(':..:ExoPlayer:extension-flac')
-}
-```
+We do not provide support for building this extension on Windows, however it
+should be possible to follow the Linux instructions in [Windows PowerShell][].
-* Now, when you build your app, the Flac extension will be built and the native
- libraries will be packaged along with the APK.
+[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 the extractor and/or
+renderer.
+
+### Using `FlacExtractor` ###
+
+`FlacExtractor` is used via `ExtractorMediaSource`. If you're using
+`DefaultExtractorsFactory`, `FlacExtractor` will automatically be used to read
+`.flac` files. If you're not using `DefaultExtractorsFactory`, return a
+`FlacExtractor` from your `ExtractorsFactory.createExtractors` implementation.
+
+### Using `LibflacAudioRenderer` ###
+
+* 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 `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,
+so you need to make sure you are passing an `LibflacAudioRenderer` to the
+player, then implement your own logic to use the renderer for a given track.
+
+## Using the extension in the demo application ##
+
+To try out playback using the extension in the [demo application][], see
+[enabling extension decoders][].
+
+[demo application]: https://exoplayer.dev/demo-application.html
+[enabling extension decoders]: https://exoplayer.dev/demo-application.html#enabling-extension-decoders
+
+## Links ##
+
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.flac.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/flac/build.gradle b/extensions/flac/build.gradle
index 4a6b8e0e5a..4a326ac646 100644
--- a/extensions/flac/build.gradle
+++ b/extensions/flac/build.gradle
@@ -11,27 +11,42 @@
// WITHOUT 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
- buildToolsVersion project.ext.buildToolsVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
defaultConfig {
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
consumerProguardFiles 'proguard-rules.txt'
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
sourceSets.main {
jniLibs.srcDir 'src/main/libs'
jni.srcDirs = [] // Disable the automatic ndk-build call by Android Studio.
}
+
+ testOptions.unitTests.includeAndroidResources = true
}
dependencies {
- compile project(':library-core')
- androidTestCompile project(':testutils')
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ androidTestImplementation project(modulePrefix + 'testutils')
+ 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 {
diff --git a/extensions/flac/proguard-rules.txt b/extensions/flac/proguard-rules.txt
index ee0a9fa5b5..436ac9b0c7 100644
--- a/extensions/flac/proguard-rules.txt
+++ b/extensions/flac/proguard-rules.txt
@@ -9,6 +9,9 @@
-keep class com.google.android.exoplayer2.ext.flac.FlacDecoderJni {
*;
}
--keep class com.google.android.exoplayer2.util.FlacStreamInfo {
+-keep class com.google.android.exoplayer2.extractor.FlacStreamMetadata {
+ *;
+}
+-keep class com.google.android.exoplayer2.metadata.flac.PictureFrame {
*;
}
diff --git a/extensions/flac/src/androidTest/AndroidManifest.xml b/extensions/flac/src/androidTest/AndroidManifest.xml
index 0a62db3bb5..9e1133b34d 100644
--- a/extensions/flac/src/androidTest/AndroidManifest.xml
+++ b/extensions/flac/src/androidTest/AndroidManifest.xml
@@ -18,17 +18,15 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.google.android.exoplayer2.ext.flac.test">
-
+
+
-
-
-
+ tools:ignore="MissingApplicationIcon,HardcodedDebugMode"/>
+ android:name="androidx.test.runner.AndroidJUnitRunner"/>
diff --git a/extensions/flac/src/androidTest/assets/bear-flac.mka b/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka
similarity index 100%
rename from extensions/flac/src/androidTest/assets/bear-flac.mka
rename to extensions/flac/src/androidTest/assets/bear-flac-16bit.mka
diff --git a/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka.audiosink.dump b/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka.audiosink.dump
new file mode 100644
index 0000000000..9615ff0a6e
--- /dev/null
+++ b/extensions/flac/src/androidTest/assets/bear-flac-16bit.mka.audiosink.dump
@@ -0,0 +1,91 @@
+config:
+ encoding = 2 (16 bit)
+ channel count = 2
+ sample rate = 48000
+buffer:
+ time = 1000
+ data = 1217833679
+buffer:
+ time = 97000
+ data = 558614672
+buffer:
+ time = 193000
+ data = -709714787
+buffer:
+ time = 289000
+ data = 1367870571
+buffer:
+ time = 385000
+ data = -141229457
+buffer:
+ time = 481000
+ data = 1287758361
+buffer:
+ time = 577000
+ data = 1125289147
+buffer:
+ time = 673000
+ data = -1677383475
+buffer:
+ time = 769000
+ data = 2130742861
+buffer:
+ time = 865000
+ data = -1292320253
+buffer:
+ time = 961000
+ data = -456587163
+buffer:
+ time = 1057000
+ data = 748981534
+buffer:
+ time = 1153000
+ data = 1550456016
+buffer:
+ time = 1249000
+ data = 1657906039
+buffer:
+ time = 1345000
+ data = -762677083
+buffer:
+ time = 1441000
+ data = -1343810763
+buffer:
+ time = 1537000
+ data = 1137318783
+buffer:
+ time = 1633000
+ data = -1891318229
+buffer:
+ time = 1729000
+ data = -472068495
+buffer:
+ time = 1825000
+ data = 832315001
+buffer:
+ time = 1921000
+ data = 2054935175
+buffer:
+ time = 2017000
+ data = 57921641
+buffer:
+ time = 2113000
+ data = 2132759067
+buffer:
+ time = 2209000
+ data = -1742540521
+buffer:
+ time = 2305000
+ data = 1657024301
+buffer:
+ time = 2401000
+ data = -585080145
+buffer:
+ time = 2497000
+ data = 427271397
+buffer:
+ time = 2593000
+ data = -364201340
+buffer:
+ time = 2689000
+ data = -627965287
diff --git a/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka
new file mode 100644
index 0000000000..e6d124e0ce
Binary files /dev/null and b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka differ
diff --git a/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka.audiosink.dump b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka.audiosink.dump
new file mode 100644
index 0000000000..efc3e0e9d0
--- /dev/null
+++ b/extensions/flac/src/androidTest/assets/bear-flac-24bit.mka.audiosink.dump
@@ -0,0 +1,91 @@
+config:
+ encoding = 536870912 (24 bit)
+ channel count = 2
+ sample rate = 48000
+buffer:
+ time = 0
+ data = 225023649
+buffer:
+ time = 96000
+ data = 455106306
+buffer:
+ time = 192000
+ data = 2025727297
+buffer:
+ time = 288000
+ data = 758514657
+buffer:
+ time = 384000
+ data = 1044986473
+buffer:
+ time = 480000
+ data = -2030029695
+buffer:
+ time = 576000
+ data = 1907053281
+buffer:
+ time = 672000
+ data = -1974954431
+buffer:
+ time = 768000
+ data = -206248383
+buffer:
+ time = 864000
+ data = 1484984417
+buffer:
+ time = 960000
+ data = -1306117439
+buffer:
+ time = 1056000
+ data = 692829792
+buffer:
+ time = 1152000
+ data = 1070563058
+buffer:
+ time = 1248000
+ data = -1444096479
+buffer:
+ time = 1344000
+ data = 1753016419
+buffer:
+ time = 1440000
+ data = 1947797953
+buffer:
+ time = 1536000
+ data = 266121411
+buffer:
+ time = 1632000
+ data = 1275494369
+buffer:
+ time = 1728000
+ data = 372077825
+buffer:
+ time = 1824000
+ data = -993079679
+buffer:
+ time = 1920000
+ data = 177307937
+buffer:
+ time = 2016000
+ data = 2037083009
+buffer:
+ time = 2112000
+ data = -435776287
+buffer:
+ time = 2208000
+ data = 1867447329
+buffer:
+ time = 2304000
+ data = 1884495937
+buffer:
+ time = 2400000
+ data = -804673375
+buffer:
+ time = 2496000
+ data = -588531007
+buffer:
+ time = 2592000
+ data = -1064642970
+buffer:
+ time = 2688000
+ data = -1771406207
diff --git a/extensions/flac/src/androidTest/assets/bear.flac.0.dump b/extensions/flac/src/androidTest/assets/bear.flac.0.dump
index b03636f2bb..d562052a4f 100644
--- a/extensions/flac/src/androidTest/assets/bear.flac.0.dump
+++ b/extensions/flac/src/androidTest/assets/bear.flac.0.dump
@@ -1,30 +1,32 @@
seekMap:
isSeekable = true
duration = 2741000
- getPosition(0) = 8880
+ getPosition(0) = [[timeUs=0, position=8880]]
numberOfTracks = 1
track 0:
format:
- bitrate = 768000
+ bitrate = 1536000
id = null
containerMimeType = null
sampleMimeType = audio/raw
- maxInputSize = -1
+ maxInputSize = 16384
width = -1
height = -1
frameRate = -1.0
- rotationDegrees = -1
- pixelWidthHeightRatio = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
channelCount = 2
sampleRate = 48000
pcmEncoding = 2
- encoderDelay = -1
- encoderPadding = -1
+ encoderDelay = 0
+ encoderPadding = 0
subsampleOffsetUs = 9223372036854775807
selectionFlags = 0
language = null
drmInitData = -
+ metadata = null
initializationData:
+ total output bytes = 526272
sample count = 33
sample 0:
time = 0
diff --git a/extensions/flac/src/androidTest/assets/bear.flac.1.dump b/extensions/flac/src/androidTest/assets/bear.flac.1.dump
index 4e8388dba8..93f38227b8 100644
--- a/extensions/flac/src/androidTest/assets/bear.flac.1.dump
+++ b/extensions/flac/src/androidTest/assets/bear.flac.1.dump
@@ -1,30 +1,32 @@
seekMap:
isSeekable = true
duration = 2741000
- getPosition(0) = 8880
+ getPosition(0) = [[timeUs=0, position=8880]]
numberOfTracks = 1
track 0:
format:
- bitrate = 768000
+ bitrate = 1536000
id = null
containerMimeType = null
sampleMimeType = audio/raw
- maxInputSize = -1
+ maxInputSize = 16384
width = -1
height = -1
frameRate = -1.0
- rotationDegrees = -1
- pixelWidthHeightRatio = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
channelCount = 2
sampleRate = 48000
pcmEncoding = 2
- encoderDelay = -1
- encoderPadding = -1
+ encoderDelay = 0
+ encoderPadding = 0
subsampleOffsetUs = 9223372036854775807
selectionFlags = 0
language = null
drmInitData = -
+ metadata = null
initializationData:
+ total output bytes = 362432
sample count = 23
sample 0:
time = 853333
diff --git a/extensions/flac/src/androidTest/assets/bear.flac.2.dump b/extensions/flac/src/androidTest/assets/bear.flac.2.dump
index 0860c36cef..9c53a95b06 100644
--- a/extensions/flac/src/androidTest/assets/bear.flac.2.dump
+++ b/extensions/flac/src/androidTest/assets/bear.flac.2.dump
@@ -1,30 +1,32 @@
seekMap:
isSeekable = true
duration = 2741000
- getPosition(0) = 8880
+ getPosition(0) = [[timeUs=0, position=8880]]
numberOfTracks = 1
track 0:
format:
- bitrate = 768000
+ bitrate = 1536000
id = null
containerMimeType = null
sampleMimeType = audio/raw
- maxInputSize = -1
+ maxInputSize = 16384
width = -1
height = -1
frameRate = -1.0
- rotationDegrees = -1
- pixelWidthHeightRatio = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
channelCount = 2
sampleRate = 48000
pcmEncoding = 2
- encoderDelay = -1
- encoderPadding = -1
+ encoderDelay = 0
+ encoderPadding = 0
subsampleOffsetUs = 9223372036854775807
selectionFlags = 0
language = null
drmInitData = -
+ metadata = null
initializationData:
+ total output bytes = 182208
sample count = 12
sample 0:
time = 1792000
diff --git a/extensions/flac/src/androidTest/assets/bear.flac.3.dump b/extensions/flac/src/androidTest/assets/bear.flac.3.dump
index 6f7f72b806..82e23a21c1 100644
--- a/extensions/flac/src/androidTest/assets/bear.flac.3.dump
+++ b/extensions/flac/src/androidTest/assets/bear.flac.3.dump
@@ -1,30 +1,32 @@
seekMap:
isSeekable = true
duration = 2741000
- getPosition(0) = 8880
+ getPosition(0) = [[timeUs=0, position=8880]]
numberOfTracks = 1
track 0:
format:
- bitrate = 768000
+ bitrate = 1536000
id = null
containerMimeType = null
sampleMimeType = audio/raw
- maxInputSize = -1
+ maxInputSize = 16384
width = -1
height = -1
frameRate = -1.0
- rotationDegrees = -1
- pixelWidthHeightRatio = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
channelCount = 2
sampleRate = 48000
pcmEncoding = 2
- encoderDelay = -1
- encoderPadding = -1
+ encoderDelay = 0
+ encoderPadding = 0
subsampleOffsetUs = 9223372036854775807
selectionFlags = 0
language = null
drmInitData = -
+ metadata = null
initializationData:
+ total output bytes = 18368
sample count = 2
sample 0:
time = 2645333
diff --git a/extensions/flac/src/androidTest/assets/bear_no_seek.flac b/extensions/flac/src/androidTest/assets/bear_no_seek.flac
new file mode 100644
index 0000000000..cd3271178b
Binary files /dev/null and b/extensions/flac/src/androidTest/assets/bear_no_seek.flac differ
diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac b/extensions/flac/src/androidTest/assets/bear_with_id3.flac
new file mode 100644
index 0000000000..fc945f14ad
Binary files /dev/null and b/extensions/flac/src/androidTest/assets/bear_with_id3.flac differ
diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump
new file mode 100644
index 0000000000..59a9f37443
--- /dev/null
+++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.0.dump
@@ -0,0 +1,163 @@
+seekMap:
+ isSeekable = true
+ duration = 2741000
+ getPosition(0) = [[timeUs=0, position=55284]]
+numberOfTracks = 1
+track 0:
+ format:
+ bitrate = 1536000
+ id = null
+ containerMimeType = null
+ sampleMimeType = audio/raw
+ maxInputSize = 16384
+ width = -1
+ height = -1
+ frameRate = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
+ channelCount = 2
+ sampleRate = 48000
+ pcmEncoding = 2
+ encoderDelay = 0
+ encoderPadding = 0
+ subsampleOffsetUs = 9223372036854775807
+ selectionFlags = 0
+ language = null
+ drmInitData = -
+ metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=]
+ initializationData:
+ total output bytes = 526272
+ sample count = 33
+ sample 0:
+ time = 0
+ flags = 1
+ data = length 16384, hash 61D2C5C2
+ sample 1:
+ time = 85333
+ flags = 1
+ data = length 16384, hash E6D7F214
+ sample 2:
+ time = 170666
+ flags = 1
+ data = length 16384, hash 59BF0D5D
+ sample 3:
+ time = 256000
+ flags = 1
+ data = length 16384, hash 3625F468
+ sample 4:
+ time = 341333
+ flags = 1
+ data = length 16384, hash F66A323
+ sample 5:
+ time = 426666
+ flags = 1
+ data = length 16384, hash CDBAE629
+ sample 6:
+ time = 512000
+ flags = 1
+ data = length 16384, hash 536F3A91
+ sample 7:
+ time = 597333
+ flags = 1
+ data = length 16384, hash D4F35C9C
+ sample 8:
+ time = 682666
+ flags = 1
+ data = length 16384, hash EE04CEBF
+ sample 9:
+ time = 768000
+ flags = 1
+ data = length 16384, hash 647E2A67
+ sample 10:
+ time = 853333
+ flags = 1
+ data = length 16384, hash 31583F2C
+ sample 11:
+ time = 938666
+ flags = 1
+ data = length 16384, hash E433A93D
+ sample 12:
+ time = 1024000
+ flags = 1
+ data = length 16384, hash 5E1C7051
+ sample 13:
+ time = 1109333
+ flags = 1
+ data = length 16384, hash 43E6E358
+ sample 14:
+ time = 1194666
+ flags = 1
+ data = length 16384, hash 5DC1B256
+ sample 15:
+ time = 1280000
+ flags = 1
+ data = length 16384, hash 3D9D95CF
+ sample 16:
+ time = 1365333
+ flags = 1
+ data = length 16384, hash 2A5BD2C0
+ sample 17:
+ time = 1450666
+ flags = 1
+ data = length 16384, hash 93E25061
+ sample 18:
+ time = 1536000
+ flags = 1
+ data = length 16384, hash B81793D8
+ sample 19:
+ time = 1621333
+ flags = 1
+ data = length 16384, hash 1A3BD49F
+ sample 20:
+ time = 1706666
+ flags = 1
+ data = length 16384, hash FB672FF1
+ sample 21:
+ time = 1792000
+ flags = 1
+ data = length 16384, hash 48AB8B45
+ sample 22:
+ time = 1877333
+ flags = 1
+ data = length 16384, hash 13C9640A
+ sample 23:
+ time = 1962666
+ flags = 1
+ data = length 16384, hash 499E4A0B
+ sample 24:
+ time = 2048000
+ flags = 1
+ data = length 16384, hash F9A783E6
+ sample 25:
+ time = 2133333
+ flags = 1
+ data = length 16384, hash D2B77598
+ sample 26:
+ time = 2218666
+ flags = 1
+ data = length 16384, hash CE5B826C
+ sample 27:
+ time = 2304000
+ flags = 1
+ data = length 16384, hash E99EE956
+ sample 28:
+ time = 2389333
+ flags = 1
+ data = length 16384, hash F2DB1486
+ sample 29:
+ time = 2474666
+ flags = 1
+ data = length 16384, hash 1636EAB
+ sample 30:
+ time = 2560000
+ flags = 1
+ data = length 16384, hash 23457C08
+ sample 31:
+ time = 2645333
+ flags = 1
+ data = length 16384, hash 30EB8381
+ sample 32:
+ time = 2730666
+ flags = 1
+ data = length 1984, hash 59CFDE1B
+tracksEnded = true
diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump
new file mode 100644
index 0000000000..a2ad67c9e4
--- /dev/null
+++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.1.dump
@@ -0,0 +1,123 @@
+seekMap:
+ isSeekable = true
+ duration = 2741000
+ getPosition(0) = [[timeUs=0, position=55284]]
+numberOfTracks = 1
+track 0:
+ format:
+ bitrate = 1536000
+ id = null
+ containerMimeType = null
+ sampleMimeType = audio/raw
+ maxInputSize = 16384
+ width = -1
+ height = -1
+ frameRate = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
+ channelCount = 2
+ sampleRate = 48000
+ pcmEncoding = 2
+ encoderDelay = 0
+ encoderPadding = 0
+ subsampleOffsetUs = 9223372036854775807
+ selectionFlags = 0
+ language = null
+ drmInitData = -
+ metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=]
+ initializationData:
+ total output bytes = 362432
+ sample count = 23
+ sample 0:
+ time = 853333
+ flags = 1
+ data = length 16384, hash 31583F2C
+ sample 1:
+ time = 938666
+ flags = 1
+ data = length 16384, hash E433A93D
+ sample 2:
+ time = 1024000
+ flags = 1
+ data = length 16384, hash 5E1C7051
+ sample 3:
+ time = 1109333
+ flags = 1
+ data = length 16384, hash 43E6E358
+ sample 4:
+ time = 1194666
+ flags = 1
+ data = length 16384, hash 5DC1B256
+ sample 5:
+ time = 1280000
+ flags = 1
+ data = length 16384, hash 3D9D95CF
+ sample 6:
+ time = 1365333
+ flags = 1
+ data = length 16384, hash 2A5BD2C0
+ sample 7:
+ time = 1450666
+ flags = 1
+ data = length 16384, hash 93E25061
+ sample 8:
+ time = 1536000
+ flags = 1
+ data = length 16384, hash B81793D8
+ sample 9:
+ time = 1621333
+ flags = 1
+ data = length 16384, hash 1A3BD49F
+ sample 10:
+ time = 1706666
+ flags = 1
+ data = length 16384, hash FB672FF1
+ sample 11:
+ time = 1792000
+ flags = 1
+ data = length 16384, hash 48AB8B45
+ sample 12:
+ time = 1877333
+ flags = 1
+ data = length 16384, hash 13C9640A
+ sample 13:
+ time = 1962666
+ flags = 1
+ data = length 16384, hash 499E4A0B
+ sample 14:
+ time = 2048000
+ flags = 1
+ data = length 16384, hash F9A783E6
+ sample 15:
+ time = 2133333
+ flags = 1
+ data = length 16384, hash D2B77598
+ sample 16:
+ time = 2218666
+ flags = 1
+ data = length 16384, hash CE5B826C
+ sample 17:
+ time = 2304000
+ flags = 1
+ data = length 16384, hash E99EE956
+ sample 18:
+ time = 2389333
+ flags = 1
+ data = length 16384, hash F2DB1486
+ sample 19:
+ time = 2474666
+ flags = 1
+ data = length 16384, hash 1636EAB
+ sample 20:
+ time = 2560000
+ flags = 1
+ data = length 16384, hash 23457C08
+ sample 21:
+ time = 2645333
+ flags = 1
+ data = length 16384, hash 30EB8381
+ sample 22:
+ time = 2730666
+ flags = 1
+ data = length 1984, hash 59CFDE1B
+tracksEnded = true
diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump
new file mode 100644
index 0000000000..067d67f9b8
--- /dev/null
+++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.2.dump
@@ -0,0 +1,79 @@
+seekMap:
+ isSeekable = true
+ duration = 2741000
+ getPosition(0) = [[timeUs=0, position=55284]]
+numberOfTracks = 1
+track 0:
+ format:
+ bitrate = 1536000
+ id = null
+ containerMimeType = null
+ sampleMimeType = audio/raw
+ maxInputSize = 16384
+ width = -1
+ height = -1
+ frameRate = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
+ channelCount = 2
+ sampleRate = 48000
+ pcmEncoding = 2
+ encoderDelay = 0
+ encoderPadding = 0
+ subsampleOffsetUs = 9223372036854775807
+ selectionFlags = 0
+ language = null
+ drmInitData = -
+ metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=]
+ initializationData:
+ total output bytes = 182208
+ sample count = 12
+ sample 0:
+ time = 1792000
+ flags = 1
+ data = length 16384, hash 48AB8B45
+ sample 1:
+ time = 1877333
+ flags = 1
+ data = length 16384, hash 13C9640A
+ sample 2:
+ time = 1962666
+ flags = 1
+ data = length 16384, hash 499E4A0B
+ sample 3:
+ time = 2048000
+ flags = 1
+ data = length 16384, hash F9A783E6
+ sample 4:
+ time = 2133333
+ flags = 1
+ data = length 16384, hash D2B77598
+ sample 5:
+ time = 2218666
+ flags = 1
+ data = length 16384, hash CE5B826C
+ sample 6:
+ time = 2304000
+ flags = 1
+ data = length 16384, hash E99EE956
+ sample 7:
+ time = 2389333
+ flags = 1
+ data = length 16384, hash F2DB1486
+ sample 8:
+ time = 2474666
+ flags = 1
+ data = length 16384, hash 1636EAB
+ sample 9:
+ time = 2560000
+ flags = 1
+ data = length 16384, hash 23457C08
+ sample 10:
+ time = 2645333
+ flags = 1
+ data = length 16384, hash 30EB8381
+ sample 11:
+ time = 2730666
+ flags = 1
+ data = length 1984, hash 59CFDE1B
+tracksEnded = true
diff --git a/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump
new file mode 100644
index 0000000000..6edec0017d
--- /dev/null
+++ b/extensions/flac/src/androidTest/assets/bear_with_id3.flac.3.dump
@@ -0,0 +1,39 @@
+seekMap:
+ isSeekable = true
+ duration = 2741000
+ getPosition(0) = [[timeUs=0, position=55284]]
+numberOfTracks = 1
+track 0:
+ format:
+ bitrate = 1536000
+ id = null
+ containerMimeType = null
+ sampleMimeType = audio/raw
+ maxInputSize = 16384
+ width = -1
+ height = -1
+ frameRate = -1.0
+ rotationDegrees = 0
+ pixelWidthHeightRatio = 1.0
+ channelCount = 2
+ sampleRate = 48000
+ pcmEncoding = 2
+ encoderDelay = 0
+ encoderPadding = 0
+ subsampleOffsetUs = 9223372036854775807
+ selectionFlags = 0
+ language = null
+ drmInitData = -
+ metadata = entries=[TXXX: description=ID: value=105519843, TIT2: description=null: value=那么爱你为什么, TPE1: description=null: value=阿强, TALB: description=null: value=华丽的外衣, TXXX: description=ID: value=105519843, APIC: mimeType=image/jpeg, description=]
+ initializationData:
+ total output bytes = 18368
+ sample count = 2
+ sample 0:
+ time = 2645333
+ flags = 1
+ data = length 16384, hash 30EB8381
+ sample 1:
+ time = 2730666
+ flags = 1
+ data = length 1984, hash 59CFDE1B
+tracksEnded = true
diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java
new file mode 100644
index 0000000000..a18202f4e2
--- /dev/null
+++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeekerTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.flac;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.ext.flac.FlacBinarySearchSeeker.OutputFrameHolder;
+import com.google.android.exoplayer2.extractor.SeekMap;
+import com.google.android.exoplayer2.testutil.FakeExtractorInput;
+import com.google.android.exoplayer2.testutil.TestUtil;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link FlacBinarySearchSeeker}. */
+@RunWith(AndroidJUnit4.class)
+public final class FlacBinarySearchSeekerTest {
+
+ private static final String NOSEEKTABLE_FLAC = "bear_no_seek.flac";
+ private static final int DURATION_US = 2_741_000;
+
+ @Before
+ public void setUp() {
+ if (!FlacLibrary.isAvailable()) {
+ fail("Flac library not available.");
+ }
+ }
+
+ @Test
+ public void testGetSeekMap_returnsSeekMapWithCorrectDuration()
+ throws IOException, FlacDecoderException, InterruptedException {
+ byte[] data =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), NOSEEKTABLE_FLAC);
+ FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build();
+ FlacDecoderJni decoderJni = new FlacDecoderJni();
+ decoderJni.setData(input);
+ OutputFrameHolder outputFrameHolder = new OutputFrameHolder(ByteBuffer.allocateDirect(0));
+
+ FlacBinarySearchSeeker seeker =
+ new FlacBinarySearchSeeker(
+ decoderJni.decodeStreamMetadata(),
+ /* firstFramePosition= */ 0,
+ data.length,
+ decoderJni,
+ outputFrameHolder);
+ SeekMap seekMap = seeker.getSeekMap();
+
+ assertThat(seekMap).isNotNull();
+ assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US);
+ assertThat(seekMap.isSeekable()).isTrue();
+ }
+
+ @Test
+ public void testSetSeekTargetUs_returnsSeekPending()
+ throws IOException, FlacDecoderException, InterruptedException {
+ byte[] data =
+ TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), NOSEEKTABLE_FLAC);
+ FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build();
+ FlacDecoderJni decoderJni = new FlacDecoderJni();
+ decoderJni.setData(input);
+ OutputFrameHolder outputFrameHolder = new OutputFrameHolder(ByteBuffer.allocateDirect(0));
+
+ FlacBinarySearchSeeker seeker =
+ new FlacBinarySearchSeeker(
+ decoderJni.decodeStreamMetadata(),
+ /* firstFramePosition= */ 0,
+ data.length,
+ decoderJni,
+ outputFrameHolder);
+ seeker.setSeekTargetUs(/* timeUs= */ 1000);
+
+ assertThat(seeker.isSeeking()).isTrue();
+ }
+}
diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java
new file mode 100644
index 0000000000..a64a52b411
--- /dev/null
+++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorSeekTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.flac;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.extractor.DefaultExtractorInput;
+import com.google.android.exoplayer2.extractor.Extractor;
+import com.google.android.exoplayer2.extractor.ExtractorInput;
+import com.google.android.exoplayer2.extractor.PositionHolder;
+import com.google.android.exoplayer2.extractor.SeekMap;
+import com.google.android.exoplayer2.testutil.FakeExtractorInput;
+import com.google.android.exoplayer2.testutil.FakeExtractorOutput;
+import com.google.android.exoplayer2.testutil.FakeTrackOutput;
+import com.google.android.exoplayer2.testutil.TestUtil;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.upstream.DefaultDataSource;
+import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.util.Util;
+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. */
+@RunWith(AndroidJUnit4.class)
+public final class FlacExtractorSeekTest {
+
+ private static final String NO_SEEKTABLE_FLAC = "bear_no_seek.flac";
+ private static final int DURATION_US = 2_741_000;
+ private static final Uri FILE_URI = Uri.parse("file:///android_asset/" + NO_SEEKTABLE_FLAC);
+ private static final Random RANDOM = new Random(1234L);
+
+ private FakeExtractorOutput expectedOutput;
+ private FakeTrackOutput expectedTrackOutput;
+
+ private DefaultDataSource dataSource;
+ private PositionHolder positionHolder;
+ private long totalInputLength;
+
+ @Before
+ public void setUp() throws Exception {
+ if (!FlacLibrary.isAvailable()) {
+ fail("Flac library not available.");
+ }
+ expectedOutput = new FakeExtractorOutput();
+ extractAllSamplesFromFileToExpectedOutput(
+ ApplicationProvider.getApplicationContext(), NO_SEEKTABLE_FLAC);
+ expectedTrackOutput = expectedOutput.trackOutputs.get(0);
+
+ dataSource =
+ new DefaultDataSourceFactory(ApplicationProvider.getApplicationContext(), "UserAgent")
+ .createDataSource();
+ totalInputLength = readInputLength();
+ positionHolder = new PositionHolder();
+ }
+
+ @Test
+ public void testFlacExtractorReads_nonSeekTableFile_returnSeekableSeekMap()
+ throws IOException, InterruptedException {
+ FlacExtractor extractor = new FlacExtractor();
+
+ SeekMap seekMap = extractSeekMap(extractor, new FakeExtractorOutput());
+
+ assertThat(seekMap).isNotNull();
+ assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US);
+ assertThat(seekMap.isSeekable()).isTrue();
+ }
+
+ @Test
+ public void testHandlePendingSeek_handlesSeekingToPositionInFile_extractsCorrectFrame()
+ throws IOException, InterruptedException {
+ FlacExtractor extractor = new FlacExtractor();
+
+ FakeExtractorOutput extractorOutput = new FakeExtractorOutput();
+ SeekMap seekMap = extractSeekMap(extractor, extractorOutput);
+ FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
+
+ long targetSeekTimeUs = 987_000;
+ int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput);
+
+ assertThat(extractedFrameIndex).isNotEqualTo(-1);
+ assertFirstFrameAfterSeekContainTargetSeekTime(
+ trackOutput, targetSeekTimeUs, extractedFrameIndex);
+ }
+
+ @Test
+ public void testHandlePendingSeek_handlesSeekToEoF_extractsLastFrame()
+ throws IOException, InterruptedException {
+ FlacExtractor extractor = new FlacExtractor();
+
+ FakeExtractorOutput extractorOutput = new FakeExtractorOutput();
+ SeekMap seekMap = extractSeekMap(extractor, extractorOutput);
+ FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
+
+ long targetSeekTimeUs = seekMap.getDurationUs();
+
+ int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput);
+
+ assertThat(extractedFrameIndex).isNotEqualTo(-1);
+ assertFirstFrameAfterSeekContainTargetSeekTime(
+ trackOutput, targetSeekTimeUs, extractedFrameIndex);
+ }
+
+ @Test
+ public void testHandlePendingSeek_handlesSeekingBackward_extractsCorrectFrame()
+ throws IOException, InterruptedException {
+ FlacExtractor extractor = new FlacExtractor();
+
+ FakeExtractorOutput extractorOutput = new FakeExtractorOutput();
+ SeekMap seekMap = extractSeekMap(extractor, extractorOutput);
+ FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
+
+ long firstSeekTimeUs = 987_000;
+ seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput);
+
+ long targetSeekTimeUs = 0;
+ int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput);
+
+ assertThat(extractedFrameIndex).isNotEqualTo(-1);
+ assertFirstFrameAfterSeekContainTargetSeekTime(
+ trackOutput, targetSeekTimeUs, extractedFrameIndex);
+ }
+
+ @Test
+ public void testHandlePendingSeek_handlesSeekingForward_extractsCorrectFrame()
+ throws IOException, InterruptedException {
+ FlacExtractor extractor = new FlacExtractor();
+
+ FakeExtractorOutput extractorOutput = new FakeExtractorOutput();
+ SeekMap seekMap = extractSeekMap(extractor, extractorOutput);
+ FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
+
+ long firstSeekTimeUs = 987_000;
+ seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput);
+
+ long targetSeekTimeUs = 1_234_000;
+ int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput);
+
+ assertThat(extractedFrameIndex).isNotEqualTo(-1);
+ assertFirstFrameAfterSeekContainTargetSeekTime(
+ trackOutput, targetSeekTimeUs, extractedFrameIndex);
+ }
+
+ @Test
+ public void testHandlePendingSeek_handlesRandomSeeks_extractsCorrectFrame()
+ throws IOException, InterruptedException {
+ FlacExtractor extractor = new FlacExtractor();
+
+ FakeExtractorOutput extractorOutput = new FakeExtractorOutput();
+ SeekMap seekMap = extractSeekMap(extractor, extractorOutput);
+ FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
+
+ long numSeek = 100;
+ for (long i = 0; i < numSeek; i++) {
+ long targetSeekTimeUs = RANDOM.nextInt(DURATION_US + 1);
+ int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput);
+
+ assertThat(extractedFrameIndex).isNotEqualTo(-1);
+ assertFirstFrameAfterSeekContainTargetSeekTime(
+ trackOutput, targetSeekTimeUs, extractedFrameIndex);
+ }
+ }
+
+ // Internal methods
+
+ private long readInputLength() throws IOException {
+ DataSpec dataSpec = new DataSpec(FILE_URI, 0, C.LENGTH_UNSET, null);
+ long totalInputLength = dataSource.open(dataSpec);
+ Util.closeQuietly(dataSource);
+ return totalInputLength;
+ }
+
+ /**
+ * Seeks to the given seek time and keeps reading from input until we can extract at least one
+ * frame from the seek position, or until end-of-input is reached.
+ *
+ * @return The index of the first extracted frame written to the given {@code trackOutput} after
+ * the seek is completed, or -1 if the seek is completed without any extracted frame.
+ */
+ private int seekToTimeUs(
+ FlacExtractor flacExtractor, SeekMap seekMap, long seekTimeUs, FakeTrackOutput trackOutput)
+ throws IOException, InterruptedException {
+ int numSampleBeforeSeek = trackOutput.getSampleCount();
+ SeekMap.SeekPoints seekPoints = seekMap.getSeekPoints(seekTimeUs);
+
+ long initialSeekLoadPosition = seekPoints.first.position;
+ flacExtractor.seek(initialSeekLoadPosition, seekTimeUs);
+
+ positionHolder.position = C.POSITION_UNSET;
+ ExtractorInput extractorInput = getExtractorInputFromPosition(initialSeekLoadPosition);
+ int extractorReadResult = Extractor.RESULT_CONTINUE;
+ while (true) {
+ try {
+ // Keep reading until we can read at least one frame after seek
+ while (extractorReadResult == Extractor.RESULT_CONTINUE
+ && trackOutput.getSampleCount() == numSampleBeforeSeek) {
+ extractorReadResult = flacExtractor.read(extractorInput, positionHolder);
+ }
+ } finally {
+ Util.closeQuietly(dataSource);
+ }
+
+ if (extractorReadResult == Extractor.RESULT_SEEK) {
+ extractorInput = getExtractorInputFromPosition(positionHolder.position);
+ extractorReadResult = Extractor.RESULT_CONTINUE;
+ } else if (extractorReadResult == Extractor.RESULT_END_OF_INPUT) {
+ return -1;
+ } else if (trackOutput.getSampleCount() > numSampleBeforeSeek) {
+ // First index after seek = num sample before seek.
+ return numSampleBeforeSeek;
+ }
+ }
+ }
+
+ @Nullable
+ private SeekMap extractSeekMap(FlacExtractor extractor, FakeExtractorOutput output)
+ throws IOException, InterruptedException {
+ try {
+ ExtractorInput input = getExtractorInputFromPosition(0);
+ extractor.init(output);
+ while (output.seekMap == null) {
+ extractor.read(input, positionHolder);
+ }
+ } finally {
+ Util.closeQuietly(dataSource);
+ }
+ return output.seekMap;
+ }
+
+ private void assertFirstFrameAfterSeekContainTargetSeekTime(
+ FakeTrackOutput trackOutput, long seekTimeUs, int firstFrameIndexAfterSeek) {
+ int expectedSampleIndex = findTargetFrameInExpectedOutput(seekTimeUs);
+ // Assert that after seeking, the first sample frame written to output contains the sample
+ // at seek time.
+ trackOutput.assertSample(
+ firstFrameIndexAfterSeek,
+ expectedTrackOutput.getSampleData(expectedSampleIndex),
+ expectedTrackOutput.getSampleTimeUs(expectedSampleIndex),
+ expectedTrackOutput.getSampleFlags(expectedSampleIndex),
+ expectedTrackOutput.getSampleCryptoData(expectedSampleIndex));
+ }
+
+ private int findTargetFrameInExpectedOutput(long seekTimeUs) {
+ List sampleTimes = expectedTrackOutput.getSampleTimesUs();
+ for (int i = 0; i < sampleTimes.size() - 1; i++) {
+ long currentSampleTime = sampleTimes.get(i);
+ long nextSampleTime = sampleTimes.get(i + 1);
+ if (currentSampleTime <= seekTimeUs && nextSampleTime > seekTimeUs) {
+ return i;
+ }
+ }
+ return sampleTimes.size() - 1;
+ }
+
+ private ExtractorInput getExtractorInputFromPosition(long position) throws IOException {
+ DataSpec dataSpec = new DataSpec(FILE_URI, position, totalInputLength, /* key= */ null);
+ dataSource.open(dataSpec);
+ return new DefaultExtractorInput(dataSource, position, totalInputLength);
+ }
+
+ private void extractAllSamplesFromFileToExpectedOutput(Context context, String fileName)
+ throws IOException, InterruptedException {
+ byte[] data = TestUtil.getByteArray(context, fileName);
+
+ FlacExtractor extractor = new FlacExtractor();
+ extractor.init(expectedOutput);
+ FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build();
+
+ while (extractor.read(input, new PositionHolder()) != Extractor.RESULT_END_OF_INPUT) {}
+ }
+}
diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
index 4196f1ea63..c8033e04d3 100644
--- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
+++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacExtractorTest.java
@@ -15,21 +15,35 @@
*/
package com.google.android.exoplayer2.ext.flac;
-import android.test.InstrumentationTestCase;
-import com.google.android.exoplayer2.extractor.Extractor;
-import com.google.android.exoplayer2.testutil.TestUtil;
+import static org.junit.Assert.fail;
-/**
- * Unit test for {@link FlacExtractor}.
- */
-public class FlacExtractorTest extends InstrumentationTestCase {
+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;
- public void testSample() throws Exception {
- TestUtil.assertOutput(new TestUtil.ExtractorFactory() {
- @Override
- public Extractor create() {
- return new FlacExtractor();
- }
- }, "bear.flac", getInstrumentation());
+/** Unit test for {@link FlacExtractor}. */
+@RunWith(AndroidJUnit4.class)
+public class FlacExtractorTest {
+
+ @Before
+ public void setUp() {
+ if (!FlacLibrary.isAvailable()) {
+ fail("Flac library not available.");
+ }
+ }
+
+ @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());
}
}
diff --git a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java
index 21f01f0cca..f596231014 100644
--- a/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java
+++ b/extensions/flac/src/androidTest/java/com/google/android/exoplayer2/ext/flac/FlacPlaybackTest.java
@@ -15,122 +15,118 @@
*/
package com.google.android.exoplayer2.ext.flac;
+import static org.junit.Assert.fail;
+
import android.content.Context;
import android.net.Uri;
import android.os.Looper;
-import android.test.InstrumentationTestCase;
+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.PlaybackParameters;
-import com.google.android.exoplayer2.Renderer;
-import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.audio.AudioProcessor;
+import com.google.android.exoplayer2.audio.AudioSink;
+import com.google.android.exoplayer2.audio.DefaultAudioSink;
import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor;
-import com.google.android.exoplayer2.source.ExtractorMediaSource;
-import com.google.android.exoplayer2.source.TrackGroupArray;
-import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
-import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.ProgressiveMediaSource;
+import com.google.android.exoplayer2.testutil.CapturingAudioSink;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
-/**
- * Playback tests using {@link LibflacAudioRenderer}.
- */
-public class FlacPlaybackTest extends InstrumentationTestCase {
+/** Playback tests using {@link LibflacAudioRenderer}. */
+@RunWith(AndroidJUnit4.class)
+public class FlacPlaybackTest {
- private static final String BEAR_FLAC_URI = "asset:///bear-flac.mka";
+ private static final String BEAR_FLAC_16BIT = "bear-flac-16bit.mka";
+ private static final String BEAR_FLAC_24BIT = "bear-flac-24bit.mka";
- public void testBasicPlayback() throws ExoPlaybackException {
- playUri(BEAR_FLAC_URI);
+ @Before
+ public void setUp() {
+ if (!FlacLibrary.isAvailable()) {
+ fail("Flac library not available.");
+ }
}
- private void playUri(String uri) throws ExoPlaybackException {
- TestPlaybackThread thread = new TestPlaybackThread(Uri.parse(uri),
- getInstrumentation().getContext());
+ @Test
+ public void test16BitPlayback() throws Exception {
+ playAndAssertAudioSinkInput(BEAR_FLAC_16BIT);
+ }
+
+ @Test
+ public void test24BitPlayback() throws Exception {
+ playAndAssertAudioSinkInput(BEAR_FLAC_24BIT);
+ }
+
+ private static void playAndAssertAudioSinkInput(String fileName) throws Exception {
+ CapturingAudioSink audioSink =
+ new CapturingAudioSink(
+ new DefaultAudioSink(/* audioCapabilities= */ null, new AudioProcessor[0]));
+
+ TestPlaybackRunnable testPlaybackRunnable =
+ new TestPlaybackRunnable(
+ Uri.parse("asset:///" + fileName),
+ ApplicationProvider.getApplicationContext(),
+ audioSink);
+ Thread thread = new Thread(testPlaybackRunnable);
thread.start();
- try {
- thread.join();
- } catch (InterruptedException e) {
- fail(); // Should never happen.
- }
- if (thread.playbackException != null) {
- throw thread.playbackException;
+ thread.join();
+ if (testPlaybackRunnable.playbackException != null) {
+ throw testPlaybackRunnable.playbackException;
}
+
+ audioSink.assertOutput(
+ ApplicationProvider.getApplicationContext(), fileName + ".audiosink.dump");
}
- private static class TestPlaybackThread extends Thread implements ExoPlayer.EventListener {
+ private static class TestPlaybackRunnable implements Player.EventListener, Runnable {
private final Context context;
private final Uri uri;
+ private final AudioSink audioSink;
private ExoPlayer player;
private ExoPlaybackException playbackException;
- public TestPlaybackThread(Uri uri, Context context) {
+ public TestPlaybackRunnable(Uri uri, Context context, AudioSink audioSink) {
this.uri = uri;
this.context = context;
+ this.audioSink = audioSink;
}
@Override
public void run() {
Looper.prepare();
- LibflacAudioRenderer audioRenderer = new LibflacAudioRenderer();
- DefaultTrackSelector trackSelector = new DefaultTrackSelector();
- player = ExoPlayerFactory.newInstance(new Renderer[] {audioRenderer}, trackSelector);
+ LibflacAudioRenderer audioRenderer =
+ new LibflacAudioRenderer(/* eventHandler= */ null, /* eventListener= */ null, audioSink);
+ player = new ExoPlayer.Builder(context, audioRenderer).build();
player.addListener(this);
- ExtractorMediaSource mediaSource = new ExtractorMediaSource(
- uri,
- new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest"),
- MatroskaExtractor.FACTORY,
- null,
- null);
- player.prepare(mediaSource);
- player.setPlayWhenReady(true);
+ MediaSource mediaSource =
+ new ProgressiveMediaSource.Factory(
+ new DefaultDataSourceFactory(context, "ExoPlayerExtFlacTest"),
+ MatroskaExtractor.FACTORY)
+ .createMediaSource(uri);
+ player.setMediaSource(mediaSource);
+ player.prepare();
+ player.play();
Looper.loop();
}
- @Override
- public void onLoadingChanged(boolean isLoading) {
- // Do nothing.
- }
-
- @Override
- public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
- // Do nothing.
- }
-
- @Override
- public void onPositionDiscontinuity() {
- // Do nothing.
- }
-
- @Override
- public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
- // Do nothing.
- }
-
- @Override
- public void onTimelineChanged(Timeline timeline, Object manifest) {
- // Do nothing.
- }
-
@Override
public void onPlayerError(ExoPlaybackException error) {
playbackException = error;
}
@Override
- public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
- if (playbackState == ExoPlayer.STATE_ENDED
- || (playbackState == ExoPlayer.STATE_IDLE && playbackException != null)) {
- releasePlayerAndQuitLooper();
+ public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
+ if (playbackState == Player.STATE_ENDED
+ || (playbackState == Player.STATE_IDLE && playbackException != null)) {
+ player.release();
+ Looper.myLooper().quit();
}
}
-
- private void releasePlayerAndQuitLooper() {
- player.release();
- Looper.myLooper().quit();
- }
-
}
-
}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java
new file mode 100644
index 0000000000..4053c17163
--- /dev/null
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java
@@ -0,0 +1,140 @@
+/*
+ * 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.flac;
+
+import com.google.android.exoplayer2.extractor.BinarySearchSeeker;
+import com.google.android.exoplayer2.extractor.ExtractorInput;
+import com.google.android.exoplayer2.extractor.FlacStreamMetadata;
+import com.google.android.exoplayer2.extractor.SeekMap;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.FlacConstants;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+/**
+ * A {@link SeekMap} implementation for FLAC stream using binary search.
+ *
+ * This seeker performs seeking by using binary search within the stream, until it finds the
+ * frame that contains the target sample.
+ */
+/* package */ final class FlacBinarySearchSeeker extends BinarySearchSeeker {
+
+ /**
+ * Holds a frame extracted from a stream, together with the time stamp of the frame in
+ * microseconds.
+ */
+ public static final class OutputFrameHolder {
+
+ public final ByteBuffer byteBuffer;
+ public long timeUs;
+
+ /** Constructs an instance, wrapping the given byte buffer. */
+ public OutputFrameHolder(ByteBuffer outputByteBuffer) {
+ this.timeUs = 0;
+ this.byteBuffer = outputByteBuffer;
+ }
+ }
+
+ private final FlacDecoderJni decoderJni;
+
+ /**
+ * Creates a {@link FlacBinarySearchSeeker}.
+ *
+ * @param streamMetadata The stream metadata.
+ * @param firstFramePosition The byte offset of the first frame in the stream.
+ * @param inputLength The length of the stream in bytes.
+ * @param decoderJni The FLAC JNI decoder.
+ * @param outputFrameHolder A holder used to retrieve the frame found by a seeking operation.
+ */
+ public FlacBinarySearchSeeker(
+ FlacStreamMetadata streamMetadata,
+ long firstFramePosition,
+ long inputLength,
+ FlacDecoderJni decoderJni,
+ OutputFrameHolder outputFrameHolder) {
+ super(
+ /* seekTimestampConverter= */ streamMetadata::getSampleNumber,
+ new FlacTimestampSeeker(decoderJni, outputFrameHolder),
+ streamMetadata.getDurationUs(),
+ /* floorTimePosition= */ 0,
+ /* ceilingTimePosition= */ streamMetadata.totalSamples,
+ /* floorBytePosition= */ firstFramePosition,
+ /* ceilingBytePosition= */ inputLength,
+ /* approxBytesPerFrame= */ streamMetadata.getApproxBytesPerFrame(),
+ /* minimumSearchRange= */ Math.max(
+ FlacConstants.MIN_FRAME_HEADER_SIZE, streamMetadata.minFrameSize));
+ this.decoderJni = Assertions.checkNotNull(decoderJni);
+ }
+
+ @Override
+ protected void onSeekOperationFinished(boolean foundTargetFrame, long resultPosition) {
+ if (!foundTargetFrame) {
+ // If we can't find the target frame (sample), we need to reset the decoder jni so that
+ // it can continue from the result position.
+ decoderJni.reset(resultPosition);
+ }
+ }
+
+ private static final class FlacTimestampSeeker implements TimestampSeeker {
+
+ private final FlacDecoderJni decoderJni;
+ private final OutputFrameHolder outputFrameHolder;
+
+ private FlacTimestampSeeker(FlacDecoderJni decoderJni, OutputFrameHolder outputFrameHolder) {
+ this.decoderJni = decoderJni;
+ this.outputFrameHolder = outputFrameHolder;
+ }
+
+ @Override
+ public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetSampleIndex)
+ throws IOException, InterruptedException {
+ ByteBuffer outputBuffer = outputFrameHolder.byteBuffer;
+ long searchPosition = input.getPosition();
+ decoderJni.reset(searchPosition);
+ try {
+ decoderJni.decodeSampleWithBacktrackPosition(
+ outputBuffer, /* retryPosition= */ searchPosition);
+ } catch (FlacDecoderJni.FlacFrameDecodeException e) {
+ // For some reasons, the extractor can't find a frame mid-stream.
+ // Stop the seeking and let it re-try playing at the last search position.
+ return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT;
+ }
+ if (outputBuffer.limit() == 0) {
+ return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT;
+ }
+
+ long lastFrameSampleIndex = decoderJni.getLastFrameFirstSampleIndex();
+ long nextFrameSampleIndex = decoderJni.getNextFrameFirstSampleIndex();
+ long nextFrameSamplePosition = decoderJni.getDecodePosition();
+
+ boolean targetSampleInLastFrame =
+ lastFrameSampleIndex <= targetSampleIndex && nextFrameSampleIndex > targetSampleIndex;
+
+ if (targetSampleInLastFrame) {
+ // We are holding the target frame in outputFrameHolder. Set its presentation time now.
+ outputFrameHolder.timeUs = decoderJni.getLastFrameTimestamp();
+ // The input position is passed even though it does not indicate the frame containing the
+ // target sample because the extractor must continue to read from this position.
+ return TimestampSearchResult.targetFoundResult(input.getPosition());
+ } else if (nextFrameSampleIndex <= targetSampleIndex) {
+ return TimestampSearchResult.underestimatedResult(
+ nextFrameSampleIndex, nextFrameSamplePosition);
+ } else {
+ return TimestampSearchResult.overestimatedResult(lastFrameSampleIndex, searchPosition);
+ }
+ }
+ }
+}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java
index 3ecccd8246..31aacfd0df 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoder.java
@@ -15,10 +15,14 @@
*/
package com.google.android.exoplayer2.ext.flac;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.ParserException;
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.FlacStreamInfo;
+import com.google.android.exoplayer2.extractor.FlacStreamMetadata;
+import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.List;
@@ -29,7 +33,7 @@ import java.util.List;
/* package */ final class FlacDecoder extends
SimpleDecoder {
- private final int maxOutputBufferSize;
+ private final FlacStreamMetadata streamMetadata;
private final FlacDecoderJni decoderJni;
/**
@@ -37,11 +41,17 @@ import java.util.List;
*
* @param numInputBuffers The number of input buffers.
* @param numOutputBuffers The number of output buffers.
+ * @param maxInputBufferSize The maximum required input buffer size if known, or {@link
+ * Format#NO_VALUE} otherwise.
* @param initializationData Codec-specific initialization data. It should contain only one entry
- * which is the flac file header.
+ * which is the flac file header.
* @throws FlacDecoderException Thrown if an exception occurs when initializing the decoder.
*/
- public FlacDecoder(int numInputBuffers, int numOutputBuffers, List initializationData)
+ public FlacDecoder(
+ int numInputBuffers,
+ int numOutputBuffers,
+ int maxInputBufferSize,
+ List initializationData)
throws FlacDecoderException {
super(new DecoderInputBuffer[numInputBuffers], new SimpleOutputBuffer[numOutputBuffers]);
if (initializationData.size() != 1) {
@@ -49,19 +59,18 @@ import java.util.List;
}
decoderJni = new FlacDecoderJni();
decoderJni.setData(ByteBuffer.wrap(initializationData.get(0)));
- FlacStreamInfo streamInfo;
try {
- streamInfo = decoderJni.decodeMetadata();
+ streamMetadata = decoderJni.decodeStreamMetadata();
+ } catch (ParserException e) {
+ throw new FlacDecoderException("Failed to decode StreamInfo", e);
} catch (IOException | InterruptedException e) {
// Never happens.
throw new IllegalStateException(e);
}
- if (streamInfo == null) {
- throw new FlacDecoderException("Metadata decoding failed");
- }
- setInitialInputBufferSize(streamInfo.maxFrameSize);
- maxOutputBufferSize = streamInfo.maxDecodedFrameSize();
+ int initialInputBufferSize =
+ maxInputBufferSize != Format.NO_VALUE ? maxInputBufferSize : streamMetadata.maxFrameSize;
+ setInitialInputBufferSize(initialInputBufferSize);
}
@Override
@@ -70,35 +79,38 @@ import java.util.List;
}
@Override
- public DecoderInputBuffer createInputBuffer() {
+ protected DecoderInputBuffer createInputBuffer() {
return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
}
@Override
- public SimpleOutputBuffer createOutputBuffer() {
+ protected SimpleOutputBuffer createOutputBuffer() {
return new SimpleOutputBuffer(this);
}
@Override
- public FlacDecoderException decode(DecoderInputBuffer inputBuffer,
- SimpleOutputBuffer outputBuffer, boolean reset) {
+ protected FlacDecoderException createUnexpectedDecodeException(Throwable error) {
+ return new FlacDecoderException("Unexpected decode error", error);
+ }
+
+ @Override
+ @Nullable
+ protected FlacDecoderException decode(
+ DecoderInputBuffer inputBuffer, SimpleOutputBuffer outputBuffer, boolean reset) {
if (reset) {
decoderJni.flush();
}
- decoderJni.setData(inputBuffer.data);
- ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, maxOutputBufferSize);
- int result;
+ decoderJni.setData(Util.castNonNull(inputBuffer.data));
+ ByteBuffer outputData =
+ outputBuffer.init(inputBuffer.timeUs, streamMetadata.getMaxDecodedFrameSize());
try {
- result = decoderJni.decodeSample(outputData);
+ decoderJni.decodeSample(outputData);
+ } catch (FlacDecoderJni.FlacFrameDecodeException e) {
+ return new FlacDecoderException("Frame decoding failed", e);
} catch (IOException | InterruptedException e) {
// Never happens.
throw new IllegalStateException(e);
}
- if (result < 0) {
- return new FlacDecoderException("Frame decoding failed");
- }
- outputData.position(0);
- outputData.limit(result);
return null;
}
@@ -108,4 +120,8 @@ import java.util.List;
decoderJni.release();
}
+ /** Returns the {@link FlacStreamMetadata} decoded from the initialization data. */
+ public FlacStreamMetadata getStreamMetadata() {
+ return streamMetadata;
+ }
}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java
index 2bdff62935..95d7f87c05 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderException.java
@@ -26,4 +26,7 @@ public final class FlacDecoderException extends AudioDecoderException {
super(message);
}
+ /* package */ FlacDecoderException(String message, Throwable cause) {
+ super(message, cause);
+ }
}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java
index ce787712da..6e8f394c19 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacDecoderJni.java
@@ -15,9 +15,14 @@
*/
package com.google.android.exoplayer2.ext.flac;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.extractor.ExtractorInput;
-import com.google.android.exoplayer2.util.FlacStreamInfo;
+import com.google.android.exoplayer2.extractor.FlacStreamMetadata;
+import com.google.android.exoplayer2.extractor.SeekMap;
+import com.google.android.exoplayer2.extractor.SeekPoint;
+import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.nio.ByteBuffer;
@@ -26,14 +31,25 @@ import java.nio.ByteBuffer;
*/
/* package */ final class FlacDecoderJni {
- private static final int TEMP_BUFFER_SIZE = 8192; // The same buffer size which libflac has
+ /** Exception to be thrown if {@link #decodeSample(ByteBuffer)} fails to decode a frame. */
+ public static final class FlacFrameDecodeException extends Exception {
+
+ public final int errorCode;
+
+ public FlacFrameDecodeException(String message, int errorCode) {
+ super(message);
+ this.errorCode = errorCode;
+ }
+ }
+
+ private static final int TEMP_BUFFER_SIZE = 8192; // The same buffer size as libflac.
private final long nativeDecoderContext;
- private ByteBuffer byteBufferData;
- private ExtractorInput extractorInput;
+ @Nullable private ByteBuffer byteBufferData;
+ @Nullable private ExtractorInput extractorInput;
+ @Nullable private byte[] tempBuffer;
private boolean endOfExtractorInput;
- private byte[] tempBuffer;
public FlacDecoderJni() throws FlacDecoderException {
if (!FlacLibrary.isAvailable()) {
@@ -46,67 +62,79 @@ import java.nio.ByteBuffer;
}
/**
- * Sets data to be parsed by libflac.
- * @param byteBufferData Source {@link ByteBuffer}
+ * Sets the data to be parsed.
+ *
+ * @param byteBufferData Source {@link ByteBuffer}.
*/
public void setData(ByteBuffer byteBufferData) {
this.byteBufferData = byteBufferData;
this.extractorInput = null;
- this.tempBuffer = null;
}
/**
- * Sets data to be parsed by libflac.
- * @param extractorInput Source {@link ExtractorInput}
+ * Sets the data to be parsed.
+ *
+ * @param extractorInput Source {@link ExtractorInput}.
*/
public void setData(ExtractorInput extractorInput) {
this.byteBufferData = null;
this.extractorInput = extractorInput;
- if (tempBuffer == null) {
- this.tempBuffer = new byte[TEMP_BUFFER_SIZE];
- }
endOfExtractorInput = false;
+ if (tempBuffer == null) {
+ tempBuffer = new byte[TEMP_BUFFER_SIZE];
+ }
}
+ /**
+ * Returns whether the end of the data to be parsed has been reached, or true if no data was set.
+ */
public boolean isEndOfData() {
if (byteBufferData != null) {
return byteBufferData.remaining() == 0;
} else if (extractorInput != null) {
return endOfExtractorInput;
+ } else {
+ return true;
}
- return true;
+ }
+
+ /** Clears the data to be parsed. */
+ public void clearData() {
+ byteBufferData = null;
+ extractorInput = null;
}
/**
* Reads up to {@code length} bytes from the data source.
- *
- * This method blocks until at least one byte of data can be read, the end of the input is
+ *
+ *
This method blocks until at least one byte of data can be read, the end of the input is
* detected or an exception is thrown.
- *
- * This method is called from the native code.
*
* @param target A target {@link ByteBuffer} into which data should be written.
- * @return Returns the number of bytes read, or -1 on failure. It's not an error if this returns
- * zero; it just means all the data read from the source.
+ * @return Returns the number of bytes read, or -1 on failure. If all of the data has already been
+ * read from the source, then 0 is returned.
*/
+ @SuppressWarnings("unused") // Called from native code.
public int read(ByteBuffer target) throws IOException, InterruptedException {
int byteCount = target.remaining();
if (byteBufferData != null) {
byteCount = Math.min(byteCount, byteBufferData.remaining());
int originalLimit = byteBufferData.limit();
byteBufferData.limit(byteBufferData.position() + byteCount);
-
target.put(byteBufferData);
-
byteBufferData.limit(originalLimit);
} else if (extractorInput != null) {
+ ExtractorInput extractorInput = this.extractorInput;
+ byte[] tempBuffer = Util.castNonNull(this.tempBuffer);
byteCount = Math.min(byteCount, TEMP_BUFFER_SIZE);
- int read = readFromExtractorInput(0, byteCount);
+ int read = readFromExtractorInput(extractorInput, tempBuffer, /* offset= */ 0, byteCount);
if (read < 4) {
// Reading less than 4 bytes, most of the time, happens because of getting the bytes left in
// the buffer of the input. Do another read to reduce the number of calls to this method
// from the native code.
- read += readFromExtractorInput(read, byteCount - read);
+ read +=
+ readFromExtractorInput(
+ extractorInput, tempBuffer, read, /* length= */ byteCount - read);
}
byteCount = read;
target.put(tempBuffer, 0, byteCount);
@@ -116,14 +144,55 @@ import java.nio.ByteBuffer;
return byteCount;
}
- public FlacStreamInfo decodeMetadata() throws IOException, InterruptedException {
- return flacDecodeMetadata(nativeDecoderContext);
+ /** Decodes and consumes the metadata from the FLAC stream. */
+ public FlacStreamMetadata decodeStreamMetadata() throws IOException, InterruptedException {
+ FlacStreamMetadata streamMetadata = flacDecodeMetadata(nativeDecoderContext);
+ if (streamMetadata == null) {
+ throw new ParserException("Failed to decode stream metadata");
+ }
+ return streamMetadata;
}
- public int decodeSample(ByteBuffer output) throws IOException, InterruptedException {
- return output.isDirect()
- ? flacDecodeToBuffer(nativeDecoderContext, output)
- : flacDecodeToArray(nativeDecoderContext, output.array());
+ /**
+ * Decodes and consumes the next frame from the FLAC stream into the given byte buffer. If any IO
+ * error occurs, resets the stream and input to the given {@code retryPosition}.
+ *
+ * @param output The byte buffer to hold the decoded frame.
+ * @param retryPosition If any error happens, the input will be rewound to {@code retryPosition}.
+ */
+ public void decodeSampleWithBacktrackPosition(ByteBuffer output, long retryPosition)
+ throws InterruptedException, IOException, FlacFrameDecodeException {
+ try {
+ decodeSample(output);
+ } catch (IOException e) {
+ if (retryPosition >= 0) {
+ reset(retryPosition);
+ if (extractorInput != null) {
+ extractorInput.setRetryPosition(retryPosition, e);
+ }
+ }
+ throw e;
+ }
+ }
+
+ /** Decodes and consumes the next sample from the FLAC stream into the given byte buffer. */
+ @SuppressWarnings("ByteBufferBackingArray")
+ public void decodeSample(ByteBuffer output)
+ throws IOException, InterruptedException, FlacFrameDecodeException {
+ output.clear();
+ int frameSize =
+ output.isDirect()
+ ? flacDecodeToBuffer(nativeDecoderContext, output)
+ : flacDecodeToArray(nativeDecoderContext, output.array());
+ if (frameSize < 0) {
+ if (!isDecoderAtEndOfInput()) {
+ throw new FlacFrameDecodeException("Cannot decode FLAC frame", frameSize);
+ }
+ // The decoder has read to EOI. Return a 0-size frame to indicate the EOI.
+ output.limit(0);
+ } else {
+ output.limit(frameSize);
+ }
}
/**
@@ -133,26 +202,52 @@ import java.nio.ByteBuffer;
return flacGetDecodePosition(nativeDecoderContext);
}
- public long getLastSampleTimestamp() {
- return flacGetLastTimestamp(nativeDecoderContext);
+ /** Returns the timestamp for the first sample in the last decoded frame. */
+ public long getLastFrameTimestamp() {
+ return flacGetLastFrameTimestamp(nativeDecoderContext);
+ }
+
+ /** Returns the first sample index of the last extracted frame. */
+ public long getLastFrameFirstSampleIndex() {
+ return flacGetLastFrameFirstSampleIndex(nativeDecoderContext);
+ }
+
+ /** Returns the first sample index of the frame to be extracted next. */
+ public long getNextFrameFirstSampleIndex() {
+ return flacGetNextFrameFirstSampleIndex(nativeDecoderContext);
}
/**
- * Maps a seek position in microseconds to a corresponding position (byte offset) in the flac
+ * Maps a seek position in microseconds to the corresponding {@link SeekMap.SeekPoints} in the
* stream.
*
* @param timeUs A seek position in microseconds.
- * @return The corresponding position (byte offset) in the flac stream or -1 if the stream doesn't
- * have a seek table.
+ * @return The corresponding {@link SeekMap.SeekPoints} obtained from the seek table, or {@code
+ * null} if the stream doesn't have a seek table.
*/
- public long getSeekPosition(long timeUs) {
- return flacGetSeekPosition(nativeDecoderContext, timeUs);
+ @Nullable
+ public SeekMap.SeekPoints getSeekPoints(long timeUs) {
+ long[] seekPoints = new long[4];
+ if (!flacGetSeekPoints(nativeDecoderContext, timeUs, seekPoints)) {
+ return null;
+ }
+ SeekPoint firstSeekPoint = new SeekPoint(seekPoints[0], seekPoints[1]);
+ SeekPoint secondSeekPoint =
+ seekPoints[2] == seekPoints[0]
+ ? firstSeekPoint
+ : new SeekPoint(seekPoints[2], seekPoints[3]);
+ return new SeekMap.SeekPoints(firstSeekPoint, secondSeekPoint);
}
public String getStateString() {
return flacGetStateString(nativeDecoderContext);
}
+ /** Returns whether the decoder has read to the end of the input. */
+ public boolean isDecoderAtEndOfInput() {
+ return flacIsDecoderAtEndOfStream(nativeDecoderContext);
+ }
+
public void flush() {
flacFlush(nativeDecoderContext);
}
@@ -170,7 +265,8 @@ import java.nio.ByteBuffer;
flacRelease(nativeDecoderContext);
}
- private int readFromExtractorInput(int offset, int length)
+ private int readFromExtractorInput(
+ ExtractorInput extractorInput, byte[] tempBuffer, int offset, int length)
throws IOException, InterruptedException {
int read = extractorInput.read(tempBuffer, offset, length);
if (read == C.RESULT_END_OF_INPUT) {
@@ -181,18 +277,34 @@ import java.nio.ByteBuffer;
}
private native long flacInit();
- private native FlacStreamInfo flacDecodeMetadata(long context)
+
+ private native FlacStreamMetadata flacDecodeMetadata(long context)
throws IOException, InterruptedException;
+
private native int flacDecodeToBuffer(long context, ByteBuffer outputBuffer)
throws IOException, InterruptedException;
+
private native int flacDecodeToArray(long context, byte[] outputArray)
throws IOException, InterruptedException;
+
private native long flacGetDecodePosition(long context);
- private native long flacGetLastTimestamp(long context);
- private native long flacGetSeekPosition(long context, long timeUs);
+
+ private native long flacGetLastFrameTimestamp(long context);
+
+ private native long flacGetLastFrameFirstSampleIndex(long context);
+
+ private native long flacGetNextFrameFirstSampleIndex(long context);
+
+ private native boolean flacGetSeekPoints(long context, long timeUs, long[] outSeekPoints);
+
private native String flacGetStateString(long context);
+
+ private native boolean flacIsDecoderAtEndOfStream(long context);
+
private native void flacFlush(long context);
+
private native void flacReset(long context, long newPosition);
+
private native void flacRelease(long context);
}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java
index d13194793e..922680ebcd 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacExtractor.java
@@ -15,54 +15,90 @@
*/
package com.google.android.exoplayer2.ext.flac;
+import static com.google.android.exoplayer2.util.Util.getPcmEncoding;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.ext.flac.FlacBinarySearchSeeker.OutputFrameHolder;
import com.google.android.exoplayer2.extractor.Extractor;
import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.extractor.ExtractorOutput;
import com.google.android.exoplayer2.extractor.ExtractorsFactory;
+import com.google.android.exoplayer2.extractor.FlacMetadataReader;
+import com.google.android.exoplayer2.extractor.FlacStreamMetadata;
import com.google.android.exoplayer2.extractor.PositionHolder;
import com.google.android.exoplayer2.extractor.SeekMap;
+import com.google.android.exoplayer2.extractor.SeekPoint;
import com.google.android.exoplayer2.extractor.TrackOutput;
-import com.google.android.exoplayer2.util.FlacStreamInfo;
+import com.google.android.exoplayer2.metadata.Metadata;
+import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.ParsableByteArray;
import java.io.IOException;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
import java.nio.ByteBuffer;
-import java.util.Arrays;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/**
* Facilitates the extraction of data from the FLAC container format.
*/
public final class FlacExtractor implements Extractor {
- /**
- * Factory that returns one extractor which is a {@link FlacExtractor}.
- */
- public static final ExtractorsFactory FACTORY = new ExtractorsFactory() {
-
- @Override
- public Extractor[] createExtractors() {
- return new Extractor[] {new FlacExtractor()};
- }
-
- };
+ /** Factory that returns one extractor which is a {@link FlacExtractor}. */
+ public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()};
/**
- * FLAC signature: first 4 is the signature word, second 4 is the sizeof STREAMINFO. 0x22 is the
- * mandatory STREAMINFO.
+ * Flags controlling the behavior of the extractor. Possible flag value is {@link
+ * #FLAG_DISABLE_ID3_METADATA}.
*/
- private static final byte[] FLAC_SIGNATURE = {'f', 'L', 'a', 'C', 0, 0, 0, 0x22};
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {FLAG_DISABLE_ID3_METADATA})
+ public @interface Flags {}
- private ExtractorOutput extractorOutput;
- private TrackOutput trackOutput;
+ /**
+ * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not
+ * required.
+ */
+ public static final int FLAG_DISABLE_ID3_METADATA = 1;
- private FlacDecoderJni decoderJni;
+ private final ParsableByteArray outputBuffer;
+ private final boolean id3MetadataDisabled;
- private boolean metadataParsed;
+ @Nullable private FlacDecoderJni decoderJni;
+ private @MonotonicNonNull ExtractorOutput extractorOutput;
+ private @MonotonicNonNull TrackOutput trackOutput;
- private ParsableByteArray outputBuffer;
- private ByteBuffer outputByteBuffer;
+ private boolean streamMetadataDecoded;
+ private @MonotonicNonNull FlacStreamMetadata streamMetadata;
+ private @MonotonicNonNull OutputFrameHolder outputFrameHolder;
+
+ @Nullable private Metadata id3Metadata;
+ @Nullable private FlacBinarySearchSeeker binarySearchSeeker;
+
+ /** Constructs an instance with {@code flags = 0}. */
+ public FlacExtractor() {
+ this(/* flags= */ 0);
+ }
+
+ /**
+ * Constructs an instance.
+ *
+ * @param flags Flags that control the extractor's behavior. Possible flags are described by
+ * {@link Flags}.
+ */
+ public FlacExtractor(int flags) {
+ outputBuffer = new ParsableByteArray();
+ id3MetadataDisabled = (flags & FLAG_DISABLE_ID3_METADATA) != 0;
+ }
@Override
public void init(ExtractorOutput output) {
@@ -78,94 +114,210 @@ public final class FlacExtractor implements Extractor {
@Override
public boolean sniff(ExtractorInput input) throws IOException, InterruptedException {
- byte[] header = new byte[FLAC_SIGNATURE.length];
- input.peekFully(header, 0, FLAC_SIGNATURE.length);
- return Arrays.equals(header, FLAC_SIGNATURE);
+ id3Metadata = FlacMetadataReader.peekId3Metadata(input, /* parseData= */ !id3MetadataDisabled);
+ return FlacMetadataReader.checkAndPeekStreamMarker(input);
}
@Override
public int read(final ExtractorInput input, PositionHolder seekPosition)
throws IOException, InterruptedException {
- decoderJni.setData(input);
-
- if (!metadataParsed) {
- final FlacStreamInfo streamInfo;
- try {
- streamInfo = decoderJni.decodeMetadata();
- if (streamInfo == null) {
- throw new IOException("Metadata decoding failed");
- }
- } catch (IOException e) {
- decoderJni.reset(0);
- input.setRetryPosition(0, e);
- throw e; // never executes
- }
- metadataParsed = true;
-
- extractorOutput.seekMap(new SeekMap() {
- final boolean isSeekable = decoderJni.getSeekPosition(0) != -1;
- final long durationUs = streamInfo.durationUs();
-
- @Override
- public boolean isSeekable() {
- return isSeekable;
- }
-
- @Override
- public long getPosition(long timeUs) {
- return isSeekable ? decoderJni.getSeekPosition(timeUs) : 0;
- }
-
- @Override
- public long getDurationUs() {
- return durationUs;
- }
-
- });
-
- Format mediaFormat = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_RAW, null,
- streamInfo.bitRate(), Format.NO_VALUE, streamInfo.channels, streamInfo.sampleRate,
- C.ENCODING_PCM_16BIT, null, null, 0, null);
- trackOutput.format(mediaFormat);
-
- outputBuffer = new ParsableByteArray(streamInfo.maxDecodedFrameSize());
- outputByteBuffer = ByteBuffer.wrap(outputBuffer.data);
+ if (input.getPosition() == 0 && !id3MetadataDisabled && id3Metadata == null) {
+ id3Metadata = FlacMetadataReader.peekId3Metadata(input, /* parseData= */ true);
}
- outputBuffer.reset();
- long lastDecodePosition = decoderJni.getDecodePosition();
- int size;
+ FlacDecoderJni decoderJni = initDecoderJni(input);
try {
- size = decoderJni.decodeSample(outputByteBuffer);
- } catch (IOException e) {
- if (lastDecodePosition >= 0) {
- decoderJni.reset(lastDecodePosition);
- input.setRetryPosition(lastDecodePosition, e);
- }
- throw e;
- }
- if (size <= 0) {
- return RESULT_END_OF_INPUT;
- }
- trackOutput.sampleData(outputBuffer, size);
- trackOutput.sampleMetadata(decoderJni.getLastSampleTimestamp(), C.BUFFER_FLAG_KEY_FRAME, size,
- 0, null);
+ decodeStreamMetadata(input);
- return decoderJni.isEndOfData() ? RESULT_END_OF_INPUT : RESULT_CONTINUE;
+ if (binarySearchSeeker != null && binarySearchSeeker.isSeeking()) {
+ return handlePendingSeek(input, seekPosition, outputBuffer, outputFrameHolder, trackOutput);
+ }
+
+ ByteBuffer outputByteBuffer = outputFrameHolder.byteBuffer;
+ long lastDecodePosition = decoderJni.getDecodePosition();
+ try {
+ decoderJni.decodeSampleWithBacktrackPosition(outputByteBuffer, lastDecodePosition);
+ } catch (FlacDecoderJni.FlacFrameDecodeException e) {
+ throw new IOException("Cannot read frame at position " + lastDecodePosition, e);
+ }
+ int outputSize = outputByteBuffer.limit();
+ if (outputSize == 0) {
+ return RESULT_END_OF_INPUT;
+ }
+
+ outputSample(outputBuffer, outputSize, decoderJni.getLastFrameTimestamp(), trackOutput);
+ return decoderJni.isEndOfData() ? RESULT_END_OF_INPUT : RESULT_CONTINUE;
+ } finally {
+ decoderJni.clearData();
+ }
}
@Override
public void seek(long position, long timeUs) {
if (position == 0) {
- metadataParsed = false;
+ streamMetadataDecoded = false;
+ }
+ if (decoderJni != null) {
+ decoderJni.reset(position);
+ }
+ if (binarySearchSeeker != null) {
+ binarySearchSeeker.setSeekTargetUs(timeUs);
}
- decoderJni.reset(position);
}
@Override
public void release() {
- decoderJni.release();
- decoderJni = null;
+ binarySearchSeeker = null;
+ if (decoderJni != null) {
+ decoderJni.release();
+ decoderJni = null;
+ }
}
+ @EnsuresNonNull({"decoderJni", "extractorOutput", "trackOutput"}) // Ensures initialized.
+ @SuppressWarnings({"contracts.postcondition.not.satisfied"})
+ private FlacDecoderJni initDecoderJni(ExtractorInput input) {
+ FlacDecoderJni decoderJni = Assertions.checkNotNull(this.decoderJni);
+ decoderJni.setData(input);
+ return decoderJni;
+ }
+
+ @RequiresNonNull({"decoderJni", "extractorOutput", "trackOutput"}) // Requires initialized.
+ @EnsuresNonNull({"streamMetadata", "outputFrameHolder"}) // Ensures stream metadata decoded.
+ @SuppressWarnings({"contracts.postcondition.not.satisfied"})
+ private void decodeStreamMetadata(ExtractorInput input) throws InterruptedException, IOException {
+ if (streamMetadataDecoded) {
+ return;
+ }
+
+ FlacDecoderJni flacDecoderJni = decoderJni;
+ FlacStreamMetadata streamMetadata;
+ try {
+ streamMetadata = flacDecoderJni.decodeStreamMetadata();
+ } catch (IOException e) {
+ flacDecoderJni.reset(/* newPosition= */ 0);
+ input.setRetryPosition(/* position= */ 0, e);
+ throw e;
+ }
+
+ streamMetadataDecoded = true;
+ if (this.streamMetadata == null) {
+ this.streamMetadata = streamMetadata;
+ outputBuffer.reset(streamMetadata.getMaxDecodedFrameSize());
+ outputFrameHolder = new OutputFrameHolder(ByteBuffer.wrap(outputBuffer.data));
+ binarySearchSeeker =
+ outputSeekMap(
+ flacDecoderJni,
+ streamMetadata,
+ input.getLength(),
+ extractorOutput,
+ outputFrameHolder);
+ @Nullable
+ Metadata metadata = streamMetadata.getMetadataCopyWithAppendedEntriesFrom(id3Metadata);
+ outputFormat(streamMetadata, metadata, trackOutput);
+ }
+ }
+
+ @RequiresNonNull("binarySearchSeeker")
+ private int handlePendingSeek(
+ ExtractorInput input,
+ PositionHolder seekPosition,
+ ParsableByteArray outputBuffer,
+ OutputFrameHolder outputFrameHolder,
+ TrackOutput trackOutput)
+ throws InterruptedException, IOException {
+ int seekResult = binarySearchSeeker.handlePendingSeek(input, seekPosition);
+ ByteBuffer outputByteBuffer = outputFrameHolder.byteBuffer;
+ if (seekResult == RESULT_CONTINUE && outputByteBuffer.limit() > 0) {
+ outputSample(outputBuffer, outputByteBuffer.limit(), outputFrameHolder.timeUs, trackOutput);
+ }
+ return seekResult;
+ }
+
+ /**
+ * Outputs a {@link SeekMap} and returns a {@link FlacBinarySearchSeeker} if one is required to
+ * handle seeks.
+ */
+ @Nullable
+ private static FlacBinarySearchSeeker outputSeekMap(
+ FlacDecoderJni decoderJni,
+ FlacStreamMetadata streamMetadata,
+ long streamLength,
+ ExtractorOutput output,
+ OutputFrameHolder outputFrameHolder) {
+ boolean haveSeekTable = decoderJni.getSeekPoints(/* timeUs= */ 0) != null;
+ FlacBinarySearchSeeker binarySearchSeeker = null;
+ SeekMap seekMap;
+ if (haveSeekTable) {
+ seekMap = new FlacSeekMap(streamMetadata.getDurationUs(), decoderJni);
+ } else if (streamLength != C.LENGTH_UNSET) {
+ long firstFramePosition = decoderJni.getDecodePosition();
+ binarySearchSeeker =
+ new FlacBinarySearchSeeker(
+ streamMetadata, firstFramePosition, streamLength, decoderJni, outputFrameHolder);
+ seekMap = binarySearchSeeker.getSeekMap();
+ } else {
+ seekMap = new SeekMap.Unseekable(streamMetadata.getDurationUs());
+ }
+ output.seekMap(seekMap);
+ return binarySearchSeeker;
+ }
+
+ private static void outputFormat(
+ FlacStreamMetadata streamMetadata, @Nullable Metadata metadata, TrackOutput output) {
+ Format mediaFormat =
+ Format.createAudioSampleFormat(
+ /* id= */ null,
+ MimeTypes.AUDIO_RAW,
+ /* codecs= */ null,
+ streamMetadata.getBitRate(),
+ streamMetadata.getMaxDecodedFrameSize(),
+ streamMetadata.channels,
+ streamMetadata.sampleRate,
+ getPcmEncoding(streamMetadata.bitsPerSample),
+ /* encoderDelay= */ 0,
+ /* encoderPadding= */ 0,
+ /* initializationData= */ null,
+ /* drmInitData= */ null,
+ /* selectionFlags= */ 0,
+ /* language= */ null,
+ metadata);
+ output.format(mediaFormat);
+ }
+
+ private static void outputSample(
+ ParsableByteArray sampleData, int size, long timeUs, TrackOutput output) {
+ sampleData.setPosition(0);
+ output.sampleData(sampleData, size);
+ output.sampleMetadata(
+ timeUs, C.BUFFER_FLAG_KEY_FRAME, size, /* offset= */ 0, /* encryptionData= */ null);
+ }
+
+ /** A {@link SeekMap} implementation using a SeekTable within the Flac stream. */
+ private static final class FlacSeekMap implements SeekMap {
+
+ private final long durationUs;
+ private final FlacDecoderJni decoderJni;
+
+ public FlacSeekMap(long durationUs, FlacDecoderJni decoderJni) {
+ this.durationUs = durationUs;
+ this.decoderJni = decoderJni;
+ }
+
+ @Override
+ public boolean isSeekable() {
+ return true;
+ }
+
+ @Override
+ public SeekPoints getSeekPoints(long timeUs) {
+ @Nullable SeekPoints seekPoints = decoderJni.getSeekPoints(timeUs);
+ return seekPoints == null ? new SeekPoints(SeekPoint.START) : seekPoints;
+ }
+
+ @Override
+ public long getDurationUs() {
+ return durationUs;
+ }
+ }
}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java
index ca18051207..d8b9b808a6 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacLibrary.java
@@ -15,6 +15,7 @@
*/
package com.google.android.exoplayer2.ext.flac;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.util.LibraryLoader;
/**
@@ -22,6 +23,10 @@ import com.google.android.exoplayer2.util.LibraryLoader;
*/
public final class FlacLibrary {
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.flac");
+ }
+
private static final LibraryLoader LOADER = new LibraryLoader("flacJNI");
private FlacLibrary() {}
@@ -30,6 +35,8 @@ public final class FlacLibrary {
* Override the names of the Flac native libraries. If an application wishes to call this method,
* it must do so before calling any other method defined by this class, and before instantiating
* any {@link LibflacAudioRenderer} and {@link FlacExtractor} instances.
+ *
+ * @param libraries The names of the Flac native libraries.
*/
public static void setLibraries(String... libraries) {
LOADER.setLibraries(libraries);
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java
index 246cde9d2f..6b01fd45a1 100644
--- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/LibflacAudioRenderer.java
@@ -16,22 +16,31 @@
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;
import com.google.android.exoplayer2.audio.AudioRendererEventListener;
+import com.google.android.exoplayer2.audio.AudioSink;
import com.google.android.exoplayer2.audio.SimpleDecoderAudioRenderer;
+import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.ExoMediaCrypto;
+import com.google.android.exoplayer2.extractor.FlacStreamMetadata;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.FlacConstants;
import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.exoplayer2.util.Util;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
-/**
- * Decodes and renders audio using the native Flac decoder.
- */
-public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer {
+/** Decodes and renders audio using the native Flac decoder. */
+public final class LibflacAudioRenderer extends SimpleDecoderAudioRenderer {
private static final int NUM_BUFFERS = 16;
+ private @MonotonicNonNull FlacStreamMetadata streamMetadata;
+
public LibflacAudioRenderer() {
- this(null, null);
+ this(/* eventHandler= */ null, /* eventListener= */ null);
}
/**
@@ -40,21 +49,87 @@ public class LibflacAudioRenderer extends SimpleDecoderAudioRenderer {
* @param eventListener A listener of events. May be null if delivery of events is not required.
* @param audioProcessors Optional {@link AudioProcessor}s that will process audio before output.
*/
- public LibflacAudioRenderer(Handler eventHandler, AudioRendererEventListener eventListener,
+ public LibflacAudioRenderer(
+ @Nullable Handler eventHandler,
+ @Nullable AudioRendererEventListener eventListener,
AudioProcessor... audioProcessors) {
super(eventHandler, eventListener, audioProcessors);
}
- @Override
- protected int supportsFormatInternal(Format format) {
- return FlacLibrary.isAvailable() && MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType)
- ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_TYPE;
+ /**
+ * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
+ * null if delivery of events is not required.
+ * @param eventListener A listener of events. May be null if delivery of events is not required.
+ * @param audioSink The sink to which audio will be output.
+ */
+ public LibflacAudioRenderer(
+ @Nullable Handler eventHandler,
+ @Nullable AudioRendererEventListener eventListener,
+ AudioSink audioSink) {
+ super(
+ eventHandler,
+ eventListener,
+ /* drmSessionManager= */ null,
+ /* playClearSamplesWithoutKeys= */ false,
+ audioSink);
}
@Override
- protected FlacDecoder createDecoder(Format format, ExoMediaCrypto mediaCrypto)
+ @FormatSupport
+ protected int supportsFormatInternal(
+ @Nullable DrmSessionManager drmSessionManager, Format format) {
+ if (!FlacLibrary.isAvailable()
+ || !MimeTypes.AUDIO_FLAC.equalsIgnoreCase(format.sampleMimeType)) {
+ return FORMAT_UNSUPPORTED_TYPE;
+ }
+ // Compute the PCM encoding that the FLAC decoder will output.
+ @C.PcmEncoding int pcmEncoding;
+ if (format.initializationData.isEmpty()) {
+ // The initialization data might not be set if the format was obtained from a manifest (e.g.
+ // for DASH playbacks) rather than directly from the media. In this case we assume
+ // ENCODING_PCM_16BIT. If the actual encoding is different then playback will still succeed as
+ // long as the AudioSink supports it, which will always be true when using DefaultAudioSink.
+ pcmEncoding = C.ENCODING_PCM_16BIT;
+ } else {
+ int streamMetadataOffset =
+ FlacConstants.STREAM_MARKER_SIZE + FlacConstants.METADATA_BLOCK_HEADER_SIZE;
+ FlacStreamMetadata streamMetadata =
+ new FlacStreamMetadata(format.initializationData.get(0), streamMetadataOffset);
+ pcmEncoding = Util.getPcmEncoding(streamMetadata.bitsPerSample);
+ }
+ if (!supportsOutput(format.channelCount, pcmEncoding)) {
+ return FORMAT_UNSUPPORTED_SUBTYPE;
+ } else if (!supportsFormatDrm(drmSessionManager, format.drmInitData)) {
+ return FORMAT_UNSUPPORTED_DRM;
+ } else {
+ return FORMAT_HANDLED;
+ }
+ }
+
+ @Override
+ protected FlacDecoder createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto)
throws FlacDecoderException {
- return new FlacDecoder(NUM_BUFFERS, NUM_BUFFERS, format.initializationData);
+ FlacDecoder decoder =
+ new FlacDecoder(NUM_BUFFERS, NUM_BUFFERS, format.maxInputSize, format.initializationData);
+ streamMetadata = decoder.getStreamMetadata();
+ return decoder;
}
+ @Override
+ protected Format getOutputFormat() {
+ Assertions.checkNotNull(streamMetadata);
+ return Format.createAudioSampleFormat(
+ /* id= */ null,
+ MimeTypes.AUDIO_RAW,
+ /* codecs= */ null,
+ /* bitrate= */ Format.NO_VALUE,
+ /* maxInputSize= */ Format.NO_VALUE,
+ streamMetadata.channels,
+ streamMetadata.sampleRate,
+ Util.getPcmEncoding(streamMetadata.bitsPerSample),
+ /* initializationData= */ null,
+ /* drmInitData= */ null,
+ /* selectionFlags= */ 0,
+ /* language= */ null);
+ }
}
diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/package-info.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/package-info.java
new file mode 100644
index 0000000000..ef6da7e3c6
--- /dev/null
+++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/package-info.java
@@ -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;
diff --git a/extensions/flac/src/main/jni/Android.mk b/extensions/flac/src/main/jni/Android.mk
index ff54c1b3c0..69520a16e5 100644
--- a/extensions/flac/src/main/jni/Android.mk
+++ b/extensions/flac/src/main/jni/Android.mk
@@ -30,9 +30,9 @@ LOCAL_C_INCLUDES := \
$(LOCAL_PATH)/flac/src/libFLAC/include
LOCAL_SRC_FILES := $(FLAC_SOURCES)
-LOCAL_CFLAGS += '-DVERSION="1.3.1"' -DFLAC__NO_MD5 -DFLAC__INTEGER_ONLY_LIBRARY -DFLAC__NO_ASM
+LOCAL_CFLAGS += '-DPACKAGE_VERSION="1.3.2"' -DFLAC__NO_MD5 -DFLAC__INTEGER_ONLY_LIBRARY
LOCAL_CFLAGS += -D_REENTRANT -DPIC -DU_COMMON_IMPLEMENTATION -fPIC -DHAVE_SYS_PARAM_H
-LOCAL_CFLAGS += -O3 -funroll-loops -finline-functions
+LOCAL_CFLAGS += -O3 -funroll-loops -finline-functions -DFLAC__NO_ASM '-DFLAC__HAS_OGG=0'
LOCAL_LDLIBS := -llog -lz -lm
include $(BUILD_SHARED_LIBRARY)
diff --git a/extensions/flac/src/main/jni/Application.mk b/extensions/flac/src/main/jni/Application.mk
index 59bf5f8f87..e33070e121 100644
--- a/extensions/flac/src/main/jni/Application.mk
+++ b/extensions/flac/src/main/jni/Application.mk
@@ -15,6 +15,6 @@
#
APP_OPTIM := release
-APP_STL := gnustl_static
+APP_STL := c++_static
APP_CPPFLAGS := -frtti
-APP_PLATFORM := android-9
+APP_PLATFORM := android-14
diff --git a/extensions/flac/src/main/jni/flac_jni.cc b/extensions/flac/src/main/jni/flac_jni.cc
index c9e5d7ab36..850f6883bf 100644
--- a/extensions/flac/src/main/jni/flac_jni.cc
+++ b/extensions/flac/src/main/jni/flac_jni.cc
@@ -14,9 +14,13 @@
* limitations under the License.
*/
-#include
#include
+#include
+
+#include
#include
+#include
+
#include "include/flac_parser.h"
#define LOG_TAG "flac_jni"
@@ -43,17 +47,16 @@ class JavaDataSource : public DataSource {
if (mid == NULL) {
jclass cls = env->GetObjectClass(flacDecoderJni);
mid = env->GetMethodID(cls, "read", "(Ljava/nio/ByteBuffer;)I");
- env->DeleteLocalRef(cls);
}
}
ssize_t readAt(off64_t offset, void *const data, size_t size) {
jobject byteBuffer = env->NewDirectByteBuffer(data, size);
int result = env->CallIntMethod(flacDecoderJni, mid, byteBuffer);
- if (env->ExceptionOccurred()) {
+ if (env->ExceptionCheck()) {
+ // Exception is thrown in Java when returning from the native call.
result = -1;
}
- env->DeleteLocalRef(byteBuffer);
return result;
}
@@ -94,19 +97,68 @@ DECODER_FUNC(jobject, flacDecodeMetadata, jlong jContext) {
return NULL;
}
+ jclass arrayListClass = env->FindClass("java/util/ArrayList");
+ jmethodID arrayListConstructor =
+ env->GetMethodID(arrayListClass, "", "()V");
+ jobject commentList = env->NewObject(arrayListClass, arrayListConstructor);
+ jmethodID arrayListAddMethod =
+ env->GetMethodID(arrayListClass, "add", "(Ljava/lang/Object;)Z");
+
+ if (context->parser->areVorbisCommentsValid()) {
+ std::vector vorbisComments =
+ context->parser->getVorbisComments();
+ for (std::vector::const_iterator vorbisComment =
+ vorbisComments.begin();
+ vorbisComment != vorbisComments.end(); ++vorbisComment) {
+ jstring commentString = env->NewStringUTF((*vorbisComment).c_str());
+ env->CallBooleanMethod(commentList, arrayListAddMethod, commentString);
+ env->DeleteLocalRef(commentString);
+ }
+ }
+
+ jobject pictureFrames = env->NewObject(arrayListClass, arrayListConstructor);
+ bool picturesValid = context->parser->arePicturesValid();
+ if (picturesValid) {
+ std::vector pictures = context->parser->getPictures();
+ jclass pictureFrameClass = env->FindClass(
+ "com/google/android/exoplayer2/metadata/flac/PictureFrame");
+ jmethodID pictureFrameConstructor =
+ env->GetMethodID(pictureFrameClass, "",
+ "(ILjava/lang/String;Ljava/lang/String;IIII[B)V");
+ for (std::vector::const_iterator picture = pictures.begin();
+ picture != pictures.end(); ++picture) {
+ jstring mimeType = env->NewStringUTF(picture->mimeType.c_str());
+ jstring description = env->NewStringUTF(picture->description.c_str());
+ jbyteArray pictureData = env->NewByteArray(picture->data.size());
+ env->SetByteArrayRegion(pictureData, 0, picture->data.size(),
+ (signed char *)&picture->data[0]);
+ jobject pictureFrame = env->NewObject(
+ pictureFrameClass, pictureFrameConstructor, picture->type, mimeType,
+ description, picture->width, picture->height, picture->depth,
+ picture->colors, pictureData);
+ env->CallBooleanMethod(pictureFrames, arrayListAddMethod, pictureFrame);
+ env->DeleteLocalRef(mimeType);
+ env->DeleteLocalRef(description);
+ env->DeleteLocalRef(pictureData);
+ }
+ }
+
const FLAC__StreamMetadata_StreamInfo &streamInfo =
context->parser->getStreamInfo();
- jclass cls = env->FindClass(
- "com/google/android/exoplayer2/util/"
- "FlacStreamInfo");
- jmethodID constructor = env->GetMethodID(cls, "", "(IIIIIIIJ)V");
+ jclass flacStreamMetadataClass = env->FindClass(
+ "com/google/android/exoplayer2/extractor/"
+ "FlacStreamMetadata");
+ jmethodID flacStreamMetadataConstructor =
+ env->GetMethodID(flacStreamMetadataClass, "",
+ "(IIIIIIIJLjava/util/ArrayList;Ljava/util/ArrayList;)V");
- return env->NewObject(cls, constructor, streamInfo.min_blocksize,
- streamInfo.max_blocksize, streamInfo.min_framesize,
- streamInfo.max_framesize, streamInfo.sample_rate,
- streamInfo.channels, streamInfo.bits_per_sample,
- streamInfo.total_samples);
+ return env->NewObject(flacStreamMetadataClass, flacStreamMetadataConstructor,
+ streamInfo.min_blocksize, streamInfo.max_blocksize,
+ streamInfo.min_framesize, streamInfo.max_framesize,
+ streamInfo.sample_rate, streamInfo.channels,
+ streamInfo.bits_per_sample, streamInfo.total_samples,
+ commentList, pictureFrames);
}
DECODER_FUNC(jint, flacDecodeToBuffer, jlong jContext, jobject jOutputBuffer) {
@@ -132,14 +184,30 @@ DECODER_FUNC(jlong, flacGetDecodePosition, jlong jContext) {
return context->parser->getDecodePosition();
}
-DECODER_FUNC(jlong, flacGetLastTimestamp, jlong jContext) {
+DECODER_FUNC(jlong, flacGetLastFrameTimestamp, jlong jContext) {
Context *context = reinterpret_cast(jContext);
- return context->parser->getLastTimestamp();
+ return context->parser->getLastFrameTimestamp();
}
-DECODER_FUNC(jlong, flacGetSeekPosition, jlong jContext, jlong timeUs) {
+DECODER_FUNC(jlong, flacGetLastFrameFirstSampleIndex, jlong jContext) {
Context *context = reinterpret_cast(jContext);
- return context->parser->getSeekPosition(timeUs);
+ return context->parser->getLastFrameFirstSampleIndex();
+}
+
+DECODER_FUNC(jlong, flacGetNextFrameFirstSampleIndex, jlong jContext) {
+ Context *context = reinterpret_cast(jContext);
+ return context->parser->getNextFrameFirstSampleIndex();
+}
+
+DECODER_FUNC(jboolean, flacGetSeekPoints, jlong jContext, jlong timeUs,
+ jlongArray outSeekPoints) {
+ Context *context = reinterpret_cast(jContext);
+ std::array result;
+ bool success = context->parser->getSeekPositions(timeUs, result);
+ if (success) {
+ env->SetLongArrayRegion(outSeekPoints, 0, result.size(), result.data());
+ }
+ return success;
}
DECODER_FUNC(jstring, flacGetStateString, jlong jContext) {
@@ -148,6 +216,11 @@ DECODER_FUNC(jstring, flacGetStateString, jlong jContext) {
return env->NewStringUTF(str);
}
+DECODER_FUNC(jboolean, flacIsDecoderAtEndOfStream, jlong jContext) {
+ Context *context = reinterpret_cast(jContext);
+ return context->parser->isDecoderAtEndOfStream();
+}
+
DECODER_FUNC(void, flacFlush, jlong jContext) {
Context *context = reinterpret_cast(jContext);
context->parser->flush();
diff --git a/extensions/flac/src/main/jni/flac_parser.cc b/extensions/flac/src/main/jni/flac_parser.cc
index 6c6e57f5f7..f39e4bd1f7 100644
--- a/extensions/flac/src/main/jni/flac_parser.cc
+++ b/extensions/flac/src/main/jni/flac_parser.cc
@@ -42,6 +42,9 @@
#define CHECK(x) \
if (!(x)) ALOGE("Check failed: %s ", #x)
+const int endian = 1;
+#define isBigEndian() (*(reinterpret_cast(&endian)) == 0)
+
// The FLAC parser calls our C++ static callbacks using C calling conventions,
// inside FLAC__stream_decoder_process_until_end_of_metadata
// and FLAC__stream_decoder_process_single.
@@ -169,6 +172,43 @@ void FLACParser::metadataCallback(const FLAC__StreamMetadata *metadata) {
case FLAC__METADATA_TYPE_SEEKTABLE:
mSeekTable = &metadata->data.seek_table;
break;
+ case FLAC__METADATA_TYPE_VORBIS_COMMENT:
+ if (!mVorbisCommentsValid) {
+ FLAC__StreamMetadata_VorbisComment vorbisComment =
+ metadata->data.vorbis_comment;
+ for (FLAC__uint32 i = 0; i < vorbisComment.num_comments; ++i) {
+ FLAC__StreamMetadata_VorbisComment_Entry vorbisCommentEntry =
+ vorbisComment.comments[i];
+ if (vorbisCommentEntry.entry != NULL) {
+ std::string comment(
+ reinterpret_cast(vorbisCommentEntry.entry),
+ vorbisCommentEntry.length);
+ mVorbisComments.push_back(comment);
+ }
+ }
+ mVorbisCommentsValid = true;
+ } else {
+ ALOGE("FLACParser::metadataCallback unexpected VORBISCOMMENT");
+ }
+ break;
+ case FLAC__METADATA_TYPE_PICTURE: {
+ const FLAC__StreamMetadata_Picture *parsedPicture =
+ &metadata->data.picture;
+ FlacPicture picture;
+ picture.mimeType.assign(std::string(parsedPicture->mime_type));
+ picture.description.assign(
+ std::string((char *)parsedPicture->description));
+ picture.data.assign(parsedPicture->data,
+ parsedPicture->data + parsedPicture->data_length);
+ picture.width = parsedPicture->width;
+ picture.height = parsedPicture->height;
+ picture.depth = parsedPicture->depth;
+ picture.colors = parsedPicture->colors;
+ picture.type = parsedPicture->type;
+ mPictures.push_back(picture);
+ mPicturesValid = true;
+ break;
+ }
default:
ALOGE("FLACParser::metadataCallback unexpected type %u", metadata->type);
break;
@@ -180,85 +220,42 @@ void FLACParser::errorCallback(FLAC__StreamDecoderErrorStatus status) {
mErrorStatus = status;
}
-// Copy samples from FLAC native 32-bit non-interleaved to 16-bit interleaved.
+// Copy samples from FLAC native 32-bit non-interleaved to
+// correct bit-depth (non-zero padded), interleaved.
// These are candidates for optimization if needed.
-
-static void copyMono8(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned /* nChannels */) {
- for (unsigned i = 0; i < nSamples; ++i) {
- *dst++ = src[0][i] << 8;
- }
-}
-
-static void copyStereo8(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned /* nChannels */) {
- for (unsigned i = 0; i < nSamples; ++i) {
- *dst++ = src[0][i] << 8;
- *dst++ = src[1][i] << 8;
- }
-}
-
-static void copyMultiCh8(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned nChannels) {
+static void copyToByteArrayBigEndian(int8_t *dst, const int *const *src,
+ unsigned bytesPerSample, unsigned nSamples,
+ unsigned nChannels) {
for (unsigned i = 0; i < nSamples; ++i) {
for (unsigned c = 0; c < nChannels; ++c) {
- *dst++ = src[c][i] << 8;
+ // point to the first byte of the source address
+ // and then skip the first few bytes (most significant bytes)
+ // depending on the bit depth
+ const int8_t *byteSrc =
+ reinterpret_cast(&src[c][i]) + 4 - bytesPerSample;
+ memcpy(dst, byteSrc, bytesPerSample);
+ dst = dst + bytesPerSample;
}
}
}
-static void copyMono16(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned /* nChannels */) {
+static void copyToByteArrayLittleEndian(int8_t *dst, const int *const *src,
+ unsigned bytesPerSample,
+ unsigned nSamples, unsigned nChannels) {
for (unsigned i = 0; i < nSamples; ++i) {
- *dst++ = src[0][i];
+ for (unsigned c = 0; c < nChannels; ++c) {
+ // with little endian, the most significant bytes will be at the end
+ // copy the bytes in little endian will remove the most significant byte
+ // so we are good here.
+ memcpy(dst, &(src[c][i]), bytesPerSample);
+ dst = dst + bytesPerSample;
+ }
}
}
-static void copyStereo16(int16_t *dst, const int *const *src, unsigned nSamples,
+static void copyTrespass(int8_t * /* dst */, const int *const * /* src */,
+ unsigned /* bytesPerSample */, unsigned /* nSamples */,
unsigned /* nChannels */) {
- for (unsigned i = 0; i < nSamples; ++i) {
- *dst++ = src[0][i];
- *dst++ = src[1][i];
- }
-}
-
-static void copyMultiCh16(int16_t *dst, const int *const *src,
- unsigned nSamples, unsigned nChannels) {
- for (unsigned i = 0; i < nSamples; ++i) {
- for (unsigned c = 0; c < nChannels; ++c) {
- *dst++ = src[c][i];
- }
- }
-}
-
-// 24-bit versions should do dithering or noise-shaping, here or in AudioFlinger
-
-static void copyMono24(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned /* nChannels */) {
- for (unsigned i = 0; i < nSamples; ++i) {
- *dst++ = src[0][i] >> 8;
- }
-}
-
-static void copyStereo24(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned /* nChannels */) {
- for (unsigned i = 0; i < nSamples; ++i) {
- *dst++ = src[0][i] >> 8;
- *dst++ = src[1][i] >> 8;
- }
-}
-
-static void copyMultiCh24(int16_t *dst, const int *const *src,
- unsigned nSamples, unsigned nChannels) {
- for (unsigned i = 0; i < nSamples; ++i) {
- for (unsigned c = 0; c < nChannels; ++c) {
- *dst++ = src[c][i] >> 8;
- }
- }
-}
-
-static void copyTrespass(int16_t * /* dst */, const int *const * /* src */,
- unsigned /* nSamples */, unsigned /* nChannels */) {
TRESPASS();
}
@@ -268,11 +265,13 @@ 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),
mWriteCompleted(false),
mWriteBuffer(NULL),
@@ -306,6 +305,10 @@ bool FLACParser::init() {
FLAC__METADATA_TYPE_STREAMINFO);
FLAC__stream_decoder_set_metadata_respond(mDecoder,
FLAC__METADATA_TYPE_SEEKTABLE);
+ FLAC__stream_decoder_set_metadata_respond(mDecoder,
+ FLAC__METADATA_TYPE_VORBIS_COMMENT);
+ FLAC__stream_decoder_set_metadata_respond(mDecoder,
+ FLAC__METADATA_TYPE_PICTURE);
FLAC__StreamDecoderInitStatus initStatus;
initStatus = FLAC__stream_decoder_init_stream(
mDecoder, read_callback, seek_callback, tell_callback, length_callback,
@@ -340,6 +343,7 @@ bool FLACParser::decodeMetadata() {
case 8:
case 16:
case 24:
+ case 32:
break;
default:
ALOGE("unsupported bits per sample %u", getBitsPerSample());
@@ -358,28 +362,18 @@ bool FLACParser::decodeMetadata() {
case 48000:
case 88200:
case 96000:
+ case 176400:
+ case 192000:
break;
default:
ALOGE("unsupported sample rate %u", getSampleRate());
return false;
}
- // configure the appropriate copy function, defaulting to trespass
- static const struct {
- unsigned mChannels;
- unsigned mBitsPerSample;
- void (*mCopy)(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned nChannels);
- } table[] = {
- {1, 8, copyMono8}, {2, 8, copyStereo8}, {8, 8, copyMultiCh8},
- {1, 16, copyMono16}, {2, 16, copyStereo16}, {8, 16, copyMultiCh16},
- {1, 24, copyMono24}, {2, 24, copyStereo24}, {8, 24, copyMultiCh24},
- };
- for (unsigned i = 0; i < sizeof(table) / sizeof(table[0]); ++i) {
- if (table[i].mChannels >= getChannels() &&
- table[i].mBitsPerSample == getBitsPerSample()) {
- mCopy = table[i].mCopy;
- break;
- }
+ // configure the appropriate copy function based on device endianness.
+ if (isBigEndian()) {
+ mCopy = copyToByteArrayBigEndian;
+ } else {
+ mCopy = copyToByteArrayLittleEndian;
}
} else {
ALOGE("missing STREAMINFO");
@@ -424,7 +418,8 @@ size_t FLACParser::readBuffer(void *output, size_t output_size) {
return -1;
}
- size_t bufferSize = blocksize * getChannels() * sizeof(int16_t);
+ unsigned bytesPerSample = getBitsPerSample() >> 3;
+ size_t bufferSize = blocksize * getChannels() * bytesPerSample;
if (bufferSize > output_size) {
ALOGE(
"FLACParser::readBuffer not enough space in output buffer "
@@ -434,8 +429,8 @@ size_t FLACParser::readBuffer(void *output, size_t output_size) {
}
// copy PCM from FLAC write buffer to our media buffer, with interleaving.
- (*mCopy)(reinterpret_cast(output), mWriteBuffer, blocksize,
- getChannels());
+ (*mCopy)(reinterpret_cast(output), mWriteBuffer, bytesPerSample,
+ blocksize, getChannels());
// fill in buffer metadata
CHECK(mWriteHeader.number_type == FLAC__FRAME_NUMBER_TYPE_SAMPLE_NUMBER);
@@ -443,22 +438,45 @@ size_t FLACParser::readBuffer(void *output, size_t output_size) {
return bufferSize;
}
-int64_t FLACParser::getSeekPosition(int64_t timeUs) {
+bool FLACParser::getSeekPositions(int64_t timeUs,
+ std::array &result) {
if (!mSeekTable) {
- return -1;
+ return false;
}
- int64_t sample = (timeUs * getSampleRate()) / 1000000LL;
- if (sample >= getTotalSamples()) {
- sample = getTotalSamples();
+ unsigned sampleRate = getSampleRate();
+ int64_t totalSamples = getTotalSamples();
+ int64_t targetSampleNumber = (timeUs * sampleRate) / 1000000LL;
+ if (targetSampleNumber >= totalSamples) {
+ targetSampleNumber = totalSamples - 1;
}
FLAC__StreamMetadata_SeekPoint* points = mSeekTable->points;
- for (unsigned i = mSeekTable->num_points; i > 0; ) {
- i--;
- if (points[i].sample_number <= sample) {
- return firstFrameOffset + points[i].stream_offset;
+ unsigned length = mSeekTable->num_points;
+
+ 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;
+ if (sampleNumber == targetSampleNumber || i >= length ||
+ points[i].sample_number == -1) { // placeholder
+ // exact seek, or no following non-placeholder seek point
+ result[2] = result[0];
+ result[3] = result[1];
+ } else {
+ result[2] = (points[i].sample_number * 1000000LL) / sampleRate;
+ result[3] = firstFrameOffset + points[i].stream_offset;
+ }
+ return true;
}
}
- return firstFrameOffset;
+ result[0] = 0;
+ result[1] = firstFrameOffset;
+ result[2] = 0;
+ result[3] = firstFrameOffset;
+ return true;
}
diff --git a/extensions/flac/src/main/jni/include/data_source.h b/extensions/flac/src/main/jni/include/data_source.h
index 175431dd7a..88af3e1277 100644
--- a/extensions/flac/src/main/jni/include/data_source.h
+++ b/extensions/flac/src/main/jni/include/data_source.h
@@ -22,6 +22,7 @@
class DataSource {
public:
+ virtual ~DataSource() {}
// Returns the number of bytes read, or -1 on failure. It's not an error if
// this returns zero; it just means the given offset is equal to, or
// beyond, the end of the source.
diff --git a/extensions/flac/src/main/jni/include/flac_parser.h b/extensions/flac/src/main/jni/include/flac_parser.h
index 8c302adb36..44a0d08718 100644
--- a/extensions/flac/src/main/jni/include/flac_parser.h
+++ b/extensions/flac/src/main/jni/include/flac_parser.h
@@ -19,6 +19,11 @@
#include
+#include
+#include
+#include
+#include
+
// libFLAC parser
#include "FLAC/stream_decoder.h"
@@ -26,6 +31,17 @@
typedef int status_t;
+struct FlacPicture {
+ int type;
+ std::string mimeType;
+ std::string description;
+ FLAC__uint32 width;
+ FLAC__uint32 height;
+ FLAC__uint32 depth;
+ FLAC__uint32 colors;
+ std::vector data;
+};
+
class FLACParser {
public:
FLACParser(DataSource *source);
@@ -44,14 +60,32 @@ class FLACParser {
return mStreamInfo;
}
- int64_t getLastTimestamp() const {
+ bool areVorbisCommentsValid() const { return mVorbisCommentsValid; }
+
+ const std::vector& getVorbisComments() const {
+ return mVorbisComments;
+ }
+
+ bool arePicturesValid() const { return mPicturesValid; }
+
+ const std::vector &getPictures() const { return mPictures; }
+
+ int64_t getLastFrameTimestamp() const {
return (1000000LL * mWriteHeader.number.sample_number) / getSampleRate();
}
+ int64_t getLastFrameFirstSampleIndex() const {
+ return mWriteHeader.number.sample_number;
+ }
+
+ int64_t getNextFrameFirstSampleIndex() const {
+ return mWriteHeader.number.sample_number + mWriteHeader.blocksize;
+ }
+
bool decodeMetadata();
size_t readBuffer(void *output, size_t output_size);
- int64_t getSeekPosition(int64_t timeUs);
+ bool getSeekPositions(int64_t timeUs, std::array &result);
void flush() {
reset(mCurrentPos);
@@ -63,6 +97,10 @@ class FLACParser {
mEOF = false;
if (newPosition == 0) {
mStreamInfoValid = false;
+ mVorbisCommentsValid = false;
+ mPicturesValid = false;
+ mVorbisComments.clear();
+ mPictures.clear();
FLAC__stream_decoder_reset(mDecoder);
} else {
FLAC__stream_decoder_flush(mDecoder);
@@ -83,11 +121,16 @@ class FLACParser {
return FLAC__stream_decoder_get_resolved_state_string(mDecoder);
}
+ bool isDecoderAtEndOfStream() const {
+ return FLAC__stream_decoder_get_state(mDecoder) ==
+ FLAC__STREAM_DECODER_END_OF_STREAM;
+ }
+
private:
DataSource *mDataSource;
- void (*mCopy)(int16_t *dst, const int *const *src, unsigned nSamples,
- unsigned nChannels);
+ void (*mCopy)(int8_t *dst, const int *const *src, unsigned bytesPerSample,
+ unsigned nSamples, unsigned nChannels);
// handle to underlying libFLAC parser
FLAC__StreamDecoder *mDecoder;
@@ -103,6 +146,14 @@ class FLACParser {
const FLAC__StreamMetadata_SeekTable *mSeekTable;
uint64_t firstFrameOffset;
+ // cached when the VORBIS_COMMENT metadata is parsed by libFLAC
+ std::vector mVorbisComments;
+ bool mVorbisCommentsValid;
+
+ // cached when the PICTURE metadata is parsed by libFLAC
+ std::vector mPictures;
+ bool mPicturesValid;
+
// cached when a decoded PCM block is "written" by libFLAC parser
bool mWriteRequested;
bool mWriteCompleted;
diff --git a/extensions/flac/src/test/AndroidManifest.xml b/extensions/flac/src/test/AndroidManifest.xml
new file mode 100644
index 0000000000..509151aa21
--- /dev/null
+++ b/extensions/flac/src/test/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java
new file mode 100644
index 0000000000..fb20ff1114
--- /dev/null
+++ b/extensions/flac/src/test/java/com/google/android/exoplayer2/ext/flac/DefaultRenderersFactoryTest.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.flac;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.testutil.DefaultRenderersFactoryAsserts;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link DefaultRenderersFactoryTest} with {@link LibflacAudioRenderer}. */
+@RunWith(AndroidJUnit4.class)
+public final class DefaultRenderersFactoryTest {
+
+ @Test
+ public void createRenderers_instantiatesVpxRenderer() {
+ DefaultRenderersFactoryAsserts.assertExtensionRendererCreated(
+ LibflacAudioRenderer.class, C.TRACK_TYPE_AUDIO);
+ }
+}
diff --git a/extensions/gvr/README.md b/extensions/gvr/README.md
index bae5de4812..43a9e2cb62 100644
--- a/extensions/gvr/README.md
+++ b/extensions/gvr/README.md
@@ -1,38 +1,44 @@
-# ExoPlayer GVR Extension #
+# ExoPlayer GVR extension #
-## Description ##
+**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.
-## Using the extension ##
-
-The easiest way to use the extension is to add it as a gradle dependency. You
-need to make sure you have the jcenter repository included in the `build.gradle`
-file in the root of your project:
-
-```gradle
-repositories {
- jcenter()
-}
-```
-
-Next, include the following in your module's `build.gradle` file:
-
-```gradle
-compile 'com.google.android.exoplayer:extension-gvr:rX.X.X'
-```
-
-where `rX.X.X` is the version, which must match the version of the ExoPlayer
-library being used.
-
-## Using GvrAudioProcessor ##
-
-* If using SimpleExoPlayer, override SimpleExoPlayer.buildAudioProcessors to
- return a GvrAudioProcessor.
-* If constructing renderers directly, pass a GvrAudioProcessor to
- MediaCodecAudioRenderer's constructor.
-
[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 ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-gvr:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+## Using the extension ##
+
+* If using `DefaultRenderersFactory`, override
+ `DefaultRenderersFactory.buildAudioProcessors` to return a
+ `GvrAudioProcessor`.
+* If constructing renderers directly, pass a `GvrAudioProcessor` to
+ `MediaCodecAudioRenderer`'s constructor.
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Links ##
+
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.gvr.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle
index e15c8b1ad8..f8992616a2 100644
--- a/extensions/gvr/build.gradle
+++ b/extensions/gvr/build.gradle
@@ -11,21 +11,31 @@
// WITHOUT 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
- buildToolsVersion project.ext.buildToolsVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
defaultConfig {
minSdkVersion 19
targetSdkVersion project.ext.targetSdkVersion
}
+
+ testOptions.unitTests.includeAndroidResources = true
}
dependencies {
- compile project(':library-core')
- compile 'com.google.vr:sdk-audio:1.60.1'
+ implementation project(modulePrefix + 'library-core')
+ implementation project(modulePrefix + 'library-ui')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ api 'com.google.vr:sdk-base:1.190.0'
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
}
ext {
diff --git a/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java
index a56bc7f0a9..8ba33290ea 100644
--- a/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java
+++ b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ext/gvr/GvrAudioProcessor.java
@@ -15,9 +15,11 @@
*/
package com.google.android.exoplayer2.ext.gvr;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.audio.AudioProcessor;
+import com.google.android.exoplayer2.util.Assertions;
import com.google.vr.sdk.audio.GvrAudioSurround;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@@ -25,16 +27,25 @@ 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 issue tracker .
*/
+@Deprecated
public final class GvrAudioProcessor implements AudioProcessor {
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.gvr");
+ }
+
private static final int FRAMES_PER_OUTPUT_BUFFER = 1024;
private static final int OUTPUT_CHANNEL_COUNT = 2;
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 GvrAudioSurround gvrAudioSurround;
+ private AudioFormat pendingInputAudioFormat;
+ private int pendingGvrAudioSurroundFormat;
+ @Nullable private GvrAudioSurround gvrAudioSurround;
private ByteBuffer buffer;
private boolean inputEnded;
@@ -43,19 +54,23 @@ public final class GvrAudioProcessor implements AudioProcessor {
private float y;
private float z;
- /**
- * Creates a new GVR audio processor.
- */
+ /** Creates a new GVR audio processor. */
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;
}
/**
* Updates the listener head orientation. May be called on any thread. See
* {@code GvrAudioSurround.updateNativeOrientation}.
+ *
+ * @param w The w component of the quaternion.
+ * @param x The x component of the quaternion.
+ * @param y The y component of the quaternion.
+ * @param z The z component of the quaternion.
*/
public synchronized void updateOrientation(float w, float x, float y, float z) {
this.w = w;
@@ -67,82 +82,70 @@ 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;
- maybeReleaseGvrAudioSurround();
- int surroundFormat;
- switch (channelCount) {
+ switch (inputAudioFormat.channelCount) {
case 1:
- surroundFormat = GvrAudioSurround.SurroundFormat.SURROUND_MONO;
+ pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.SURROUND_MONO;
break;
case 2:
- surroundFormat = GvrAudioSurround.SurroundFormat.SURROUND_STEREO;
+ pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.SURROUND_STEREO;
break;
case 4:
- surroundFormat = GvrAudioSurround.SurroundFormat.FIRST_ORDER_AMBISONICS;
+ pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.FIRST_ORDER_AMBISONICS;
break;
case 6:
- surroundFormat = GvrAudioSurround.SurroundFormat.SURROUND_FIVE_DOT_ONE;
+ pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.SURROUND_FIVE_DOT_ONE;
break;
case 9:
- surroundFormat = GvrAudioSurround.SurroundFormat.SECOND_ORDER_AMBISONICS;
+ pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.SECOND_ORDER_AMBISONICS;
break;
case 16:
- surroundFormat = GvrAudioSurround.SurroundFormat.THIRD_ORDER_AMBISONICS;
+ pendingGvrAudioSurroundFormat = GvrAudioSurround.SurroundFormat.THIRD_ORDER_AMBISONICS;
break;
default:
- throw new UnhandledFormatException(sampleRateHz, channelCount, encoding);
+ throw new UnhandledAudioFormatException(inputAudioFormat);
}
- gvrAudioSurround = new GvrAudioSurround(surroundFormat, sampleRateHz, channelCount,
- FRAMES_PER_OUTPUT_BUFFER);
- gvrAudioSurround.updateNativeOrientation(w, x, y, z);
- if (buffer == null) {
+ 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
public boolean isActive() {
- return gvrAudioSurround != null;
- }
-
- @Override
- public int getOutputChannelCount() {
- return OUTPUT_CHANNEL_COUNT;
- }
-
- @Override
- public int getOutputEncoding() {
- return C.ENCODING_PCM_16BIT;
+ return pendingGvrAudioSurroundFormat != NO_SURROUND_FORMAT || gvrAudioSurround != null;
}
@Override
public void queueInput(ByteBuffer input) {
int position = input.position();
+ Assertions.checkNotNull(gvrAudioSurround);
int readBytes = gvrAudioSurround.addInput(input, position, input.limit() - position);
input.position(position + readBytes);
}
@Override
public void queueEndOfStream() {
+ if (gvrAudioSurround != null) {
+ gvrAudioSurround.triggerProcessing();
+ }
inputEnded = true;
- gvrAudioSurround.triggerProcessing();
}
@Override
public ByteBuffer getOutput() {
+ if (gvrAudioSurround == null) {
+ return EMPTY_BUFFER;
+ }
int writtenBytes = gvrAudioSurround.getOutput(buffer, 0, buffer.capacity());
buffer.position(0).limit(writtenBytes);
return buffer;
@@ -150,12 +153,23 @@ public final class GvrAudioProcessor implements AudioProcessor {
@Override
public boolean isEnded() {
- return inputEnded && gvrAudioSurround.getAvailableOutputSize() == 0;
+ return inputEnded
+ && (gvrAudioSurround == null || gvrAudioSurround.getAvailableOutputSize() == 0);
}
@Override
public void flush() {
- if (gvrAudioSurround != null) {
+ if (pendingGvrAudioSurroundFormat != NO_SURROUND_FORMAT) {
+ maybeReleaseGvrAudioSurround();
+ gvrAudioSurround =
+ new GvrAudioSurround(
+ pendingGvrAudioSurroundFormat,
+ pendingInputAudioFormat.sampleRate,
+ pendingInputAudioFormat.channelCount,
+ FRAMES_PER_OUTPUT_BUFFER);
+ gvrAudioSurround.updateNativeOrientation(w, x, y, z);
+ pendingGvrAudioSurroundFormat = NO_SURROUND_FORMAT;
+ } else if (gvrAudioSurround != null) {
gvrAudioSurround.flush();
}
inputEnded = false;
@@ -164,17 +178,17 @@ public final class GvrAudioProcessor implements AudioProcessor {
@Override
public synchronized void reset() {
maybeReleaseGvrAudioSurround();
+ updateOrientation(/* w= */ 1f, /* x= */ 0f, /* y= */ 0f, /* z= */ 0f);
inputEnded = false;
- buffer = null;
- sampleRateHz = Format.NO_VALUE;
- channelCount = Format.NO_VALUE;
+ pendingInputAudioFormat = AudioFormat.NOT_SET;
+ buffer = EMPTY_BUFFER;
+ pendingGvrAudioSurroundFormat = NO_SURROUND_FORMAT;
}
private void maybeReleaseGvrAudioSurround() {
- if (this.gvrAudioSurround != null) {
- GvrAudioSurround gvrAudioSurround = this.gvrAudioSurround;
- this.gvrAudioSurround = null;
+ if (gvrAudioSurround != null) {
gvrAudioSurround.release();
+ gvrAudioSurround = null;
}
}
diff --git a/extensions/gvr/src/main/res/layout/exo_vr_ui.xml b/extensions/gvr/src/main/res/layout/exo_vr_ui.xml
new file mode 100644
index 0000000000..6863da9578
--- /dev/null
+++ b/extensions/gvr/src/main/res/layout/exo_vr_ui.xml
@@ -0,0 +1,20 @@
+
+
+
diff --git a/extensions/gvr/src/main/res/values/styles.xml b/extensions/gvr/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..2affbb2f05
--- /dev/null
+++ b/extensions/gvr/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/extensions/ima/README.md b/extensions/ima/README.md
new file mode 100644
index 0000000000..4ed6a5428a
--- /dev/null
+++ b/extensions/ima/README.md
@@ -0,0 +1,64 @@
+# ExoPlayer IMA extension #
+
+The IMA extension is an [AdsLoader][] implementation wrapping the
+[Interactive Media Ads SDK for Android][IMA]. You can use it to insert ads
+alongside content.
+
+[IMA]: https://developers.google.com/interactive-media-ads/docs/sdks/android/
+[AdsLoader]: https://exoplayer.dev/doc/reference/index.html?com/google/android/exoplayer2/source/ads/AdsLoader.html
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-ima:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Using the extension ##
+
+To play ads alongside a single-window content `MediaSource`, prepare the player
+with an `AdsMediaSource` constructed using an `ImaAdsLoader`, the content
+`MediaSource` and an overlay `ViewGroup` on top of the player. Pass an ad tag
+URI from your ad campaign when creating the `ImaAdsLoader`. The IMA
+documentation includes some [sample ad tags][] for testing. Note that the IMA
+extension only supports players which are accessed on the application's main
+thread.
+
+Resuming the player after entering the background requires some special handling
+when playing ads. The player and its media source are released on entering the
+background, and are recreated when the player returns to the foreground. When
+playing ads it is necessary to persist ad playback state while in the background
+by keeping a reference to the `ImaAdsLoader`. Reuse it when resuming playback of
+the same content/ads by passing it in when constructing the new
+`AdsMediaSource`. It is also important to persist the player position when
+entering the background by storing the value of `player.getContentPosition()`.
+On returning to the foreground, seek to that position before preparing the new
+player instance. Finally, it is important to call `ImaAdsLoader.release()` when
+playback of the content/ads has finished and will not be resumed.
+
+You can try the IMA extension in the ExoPlayer demo app. To do this you must
+select and build one of the `withExtensions` build variants of the demo app in
+Android Studio. You can find IMA test content in the "IMA sample ad tags"
+section of the app. The demo app's `PlayerActivity` also shows how to persist
+the `ImaAdsLoader` instance and the player position when backgrounded during ad
+playback.
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+[sample ad tags]: https://developers.google.com/interactive-media-ads/docs/sdks/android/tags
+
+## Links ##
+
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.ima.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle
new file mode 100644
index 0000000000..e2292aed8f
--- /dev/null
+++ b/extensions/ima/build.gradle
@@ -0,0 +1,52 @@
+// 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.
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ minSdkVersion project.ext.minSdkVersion
+ targetSdkVersion project.ext.targetSdkVersion
+ consumerProguardFiles 'proguard-rules.txt'
+ }
+
+ testOptions.unitTests.includeAndroidResources = true
+}
+
+dependencies {
+ api 'com.google.ads.interactivemedia.v3:interactivemedia:3.11.3'
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ implementation 'com.google.android.gms:play-services-ads-identifier:17.0.0'
+ testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
+}
+
+ext {
+ javadocTitle = 'IMA extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-ima'
+ releaseDescription = 'Interactive Media Ads extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/ima/proguard-rules.txt b/extensions/ima/proguard-rules.txt
new file mode 100644
index 0000000000..feef3daf7a
--- /dev/null
+++ b/extensions/ima/proguard-rules.txt
@@ -0,0 +1,6 @@
+# Proguard rules specific to the IMA extension.
+
+-keep class com.google.ads.interactivemedia.** { *; }
+-keep interface com.google.ads.interactivemedia.** { *; }
+-keep class com.google.obf.** { *; }
+-keep interface com.google.obf.** { *; }
diff --git a/library/hls/src/androidTest/AndroidManifest.xml b/extensions/ima/src/main/AndroidManifest.xml
similarity index 56%
rename from library/hls/src/androidTest/AndroidManifest.xml
rename to extensions/ima/src/main/AndroidManifest.xml
index ac0857fc3f..226b15cb34 100644
--- a/library/hls/src/androidTest/AndroidManifest.xml
+++ b/extensions/ima/src/main/AndroidManifest.xml
@@ -13,22 +13,12 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
-
-
-
-
+ package="com.google.android.exoplayer2.ext.ima">
+
+
+
-
-
-
diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
new file mode 100644
index 0000000000..fd777a9e4d
--- /dev/null
+++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java
@@ -0,0 +1,1453 @@
+/*
+ * 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.ext.ima;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import com.google.ads.interactivemedia.v3.api.Ad;
+import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
+import com.google.ads.interactivemedia.v3.api.AdError;
+import com.google.ads.interactivemedia.v3.api.AdError.AdErrorCode;
+import com.google.ads.interactivemedia.v3.api.AdErrorEvent;
+import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener;
+import com.google.ads.interactivemedia.v3.api.AdEvent;
+import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener;
+import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType;
+import com.google.ads.interactivemedia.v3.api.AdPodInfo;
+import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener;
+import com.google.ads.interactivemedia.v3.api.AdsManager;
+import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
+import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
+import com.google.ads.interactivemedia.v3.api.AdsRequest;
+import com.google.ads.interactivemedia.v3.api.CompanionAdSlot;
+import com.google.ads.interactivemedia.v3.api.ImaSdkFactory;
+import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
+import com.google.ads.interactivemedia.v3.api.UiElement;
+import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider;
+import com.google.ads.interactivemedia.v3.api.player.VideoAdPlayer;
+import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.ads.AdPlaybackState;
+import com.google.android.exoplayer2.source.ads.AdPlaybackState.AdState;
+import com.google.android.exoplayer2.source.ads.AdsLoader;
+import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException;
+import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.MimeTypes;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * {@link AdsLoader} using the IMA SDK. All methods must be called on the main thread.
+ *
+ * The player instance that will play the loaded ads must be set before playback using {@link
+ * #setPlayer(Player)}. If the ads loader is no longer required, it must be released by calling
+ * {@link #release()}.
+ *
+ *
The IMA SDK can take into account video control overlay views when calculating ad viewability.
+ * For more details see {@link AdDisplayContainer#registerVideoControlsOverlay(View)} and {@link
+ * AdViewProvider#getAdOverlayViews()}.
+ */
+public final class ImaAdsLoader
+ implements Player.EventListener,
+ AdsLoader,
+ VideoAdPlayer,
+ ContentProgressProvider,
+ AdErrorListener,
+ AdsLoadedListener,
+ AdEventListener {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.ima");
+ }
+
+ /** Builder for {@link ImaAdsLoader}. */
+ public static final class Builder {
+
+ private final Context context;
+
+ @Nullable private ImaSdkSettings imaSdkSettings;
+ @Nullable private AdEventListener adEventListener;
+ @Nullable private Set adUiElements;
+ private int vastLoadTimeoutMs;
+ private int mediaLoadTimeoutMs;
+ private int mediaBitrate;
+ private boolean focusSkipButtonWhenAvailable;
+ private ImaFactory imaFactory;
+
+ /**
+ * Creates a new builder for {@link ImaAdsLoader}.
+ *
+ * @param context The context;
+ */
+ public Builder(Context context) {
+ this.context = Assertions.checkNotNull(context);
+ vastLoadTimeoutMs = TIMEOUT_UNSET;
+ mediaLoadTimeoutMs = TIMEOUT_UNSET;
+ mediaBitrate = BITRATE_UNSET;
+ focusSkipButtonWhenAvailable = true;
+ imaFactory = new DefaultImaFactory();
+ }
+
+ /**
+ * Sets the IMA SDK settings. The provided settings instance's player type and version fields
+ * may be overwritten.
+ *
+ * If this method is not called the default settings will be used.
+ *
+ * @param imaSdkSettings The {@link ImaSdkSettings}.
+ * @return This builder, for convenience.
+ */
+ public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
+ this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings);
+ return this;
+ }
+
+ /**
+ * Sets a listener for ad events that will be passed to {@link
+ * AdsManager#addAdEventListener(AdEventListener)}.
+ *
+ * @param adEventListener The ad event listener.
+ * @return This builder, for convenience.
+ */
+ public Builder setAdEventListener(AdEventListener adEventListener) {
+ this.adEventListener = Assertions.checkNotNull(adEventListener);
+ return this;
+ }
+
+ /**
+ * Sets the ad UI elements to be rendered by the IMA SDK.
+ *
+ * @param adUiElements The ad UI elements to be rendered by the IMA SDK.
+ * @return This builder, for convenience.
+ * @see AdsRenderingSettings#setUiElements(Set)
+ */
+ public Builder setAdUiElements(Set adUiElements) {
+ this.adUiElements = new HashSet<>(Assertions.checkNotNull(adUiElements));
+ return this;
+ }
+
+ /**
+ * Sets the VAST load timeout, in milliseconds.
+ *
+ * @param vastLoadTimeoutMs The VAST load timeout, in milliseconds.
+ * @return This builder, for convenience.
+ * @see AdsRequest#setVastLoadTimeout(float)
+ */
+ public Builder setVastLoadTimeoutMs(int vastLoadTimeoutMs) {
+ Assertions.checkArgument(vastLoadTimeoutMs > 0);
+ this.vastLoadTimeoutMs = vastLoadTimeoutMs;
+ return this;
+ }
+
+ /**
+ * Sets the ad media load timeout, in milliseconds.
+ *
+ * @param mediaLoadTimeoutMs The ad media load timeout, in milliseconds.
+ * @return This builder, for convenience.
+ * @see AdsRenderingSettings#setLoadVideoTimeout(int)
+ */
+ public Builder setMediaLoadTimeoutMs(int mediaLoadTimeoutMs) {
+ Assertions.checkArgument(mediaLoadTimeoutMs > 0);
+ this.mediaLoadTimeoutMs = mediaLoadTimeoutMs;
+ return this;
+ }
+
+ /**
+ * Sets the media maximum recommended bitrate for ads, in bps.
+ *
+ * @param bitrate The media maximum recommended bitrate for ads, in bps.
+ * @return This builder, for convenience.
+ * @see AdsRenderingSettings#setBitrateKbps(int)
+ */
+ public Builder setMaxMediaBitrate(int bitrate) {
+ Assertions.checkArgument(bitrate > 0);
+ this.mediaBitrate = bitrate;
+ return this;
+ }
+
+ /**
+ * Sets whether to focus the skip button (when available) on Android TV devices. The default
+ * setting is {@code true}.
+ *
+ * @param focusSkipButtonWhenAvailable Whether to focus the skip button (when available) on
+ * Android TV devices.
+ * @return This builder, for convenience.
+ * @see AdsRenderingSettings#setFocusSkipButtonWhenAvailable(boolean)
+ */
+ public Builder setFocusSkipButtonWhenAvailable(boolean focusSkipButtonWhenAvailable) {
+ this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable;
+ return this;
+ }
+
+ @VisibleForTesting
+ /* package */ Builder setImaFactory(ImaFactory imaFactory) {
+ this.imaFactory = Assertions.checkNotNull(imaFactory);
+ return this;
+ }
+
+ /**
+ * Returns a new {@link ImaAdsLoader} for the specified ad tag.
+ *
+ * @param adTagUri The URI of a compatible ad tag to load. See
+ * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for
+ * information on compatible ad tags.
+ * @return The new {@link ImaAdsLoader}.
+ */
+ public ImaAdsLoader buildForAdTag(Uri adTagUri) {
+ return new ImaAdsLoader(
+ context,
+ adTagUri,
+ imaSdkSettings,
+ null,
+ vastLoadTimeoutMs,
+ mediaLoadTimeoutMs,
+ mediaBitrate,
+ focusSkipButtonWhenAvailable,
+ adUiElements,
+ adEventListener,
+ imaFactory);
+ }
+
+ /**
+ * Returns a new {@link ImaAdsLoader} with the specified sideloaded ads response.
+ *
+ * @param adsResponse The sideloaded VAST, VMAP, or ad rules response to be used instead of
+ * making a request via an ad tag URL.
+ * @return The new {@link ImaAdsLoader}.
+ */
+ public ImaAdsLoader buildForAdsResponse(String adsResponse) {
+ return new ImaAdsLoader(
+ context,
+ null,
+ imaSdkSettings,
+ adsResponse,
+ vastLoadTimeoutMs,
+ mediaLoadTimeoutMs,
+ mediaBitrate,
+ focusSkipButtonWhenAvailable,
+ adUiElements,
+ adEventListener,
+ imaFactory);
+ }
+ }
+
+ private static final boolean DEBUG = false;
+ private static final String TAG = "ImaAdsLoader";
+
+ private static final String IMA_SDK_SETTINGS_PLAYER_TYPE = "google/exo.ext.ima";
+ private static final String IMA_SDK_SETTINGS_PLAYER_VERSION = ExoPlayerLibraryInfo.VERSION;
+
+ /** The value used in {@link VideoProgressUpdate}s to indicate an unset duration. */
+ private static final long IMA_DURATION_UNSET = -1L;
+
+ /**
+ * Threshold before the end of content at which IMA is notified that content is complete if the
+ * player buffers, in milliseconds.
+ */
+ private static final long END_OF_CONTENT_POSITION_THRESHOLD_MS = 5000;
+
+ /** The maximum duration before an ad break that IMA may start preloading the next ad. */
+ private static final long MAXIMUM_PRELOAD_DURATION_MS = 8000;
+
+ private static final int TIMEOUT_UNSET = -1;
+ private static final int BITRATE_UNSET = -1;
+
+ /** The state of ad playback. */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({IMA_AD_STATE_NONE, IMA_AD_STATE_PLAYING, IMA_AD_STATE_PAUSED})
+ private @interface ImaAdState {}
+ /**
+ * The ad playback state when IMA is not playing an ad.
+ */
+ private static final int IMA_AD_STATE_NONE = 0;
+ /**
+ * The ad playback state when IMA has called {@link #playAd()} and not {@link #pauseAd()}.
+ */
+ private static final int IMA_AD_STATE_PLAYING = 1;
+ /**
+ * The ad playback state when IMA has called {@link #pauseAd()} while playing an ad.
+ */
+ private static final int IMA_AD_STATE_PAUSED = 2;
+
+ @Nullable private final Uri adTagUri;
+ @Nullable private final String adsResponse;
+ private final int vastLoadTimeoutMs;
+ private final int mediaLoadTimeoutMs;
+ private final boolean focusSkipButtonWhenAvailable;
+ private final int mediaBitrate;
+ @Nullable private final Set adUiElements;
+ @Nullable private final AdEventListener adEventListener;
+ private final ImaFactory imaFactory;
+ private final Timeline.Period period;
+ private final List adCallbacks;
+ private final AdDisplayContainer adDisplayContainer;
+ private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader;
+
+ private boolean wasSetPlayerCalled;
+ @Nullable private Player nextPlayer;
+ private Object pendingAdRequestContext;
+ private List supportedMimeTypes;
+ @Nullable private EventListener eventListener;
+ @Nullable private Player player;
+ private VideoProgressUpdate lastContentProgress;
+ private VideoProgressUpdate lastAdProgress;
+ private int lastVolumePercentage;
+
+ private AdsManager adsManager;
+ private boolean initializedAdsManager;
+ private AdLoadException pendingAdLoadError;
+ private Timeline timeline;
+ private long contentDurationMs;
+ private int podIndexOffset;
+ private AdPlaybackState adPlaybackState;
+
+ // Fields tracking IMA's state.
+
+ /** The expected ad group index that IMA should load next. */
+ private int expectedAdGroupIndex;
+ /** The index of the current ad group that IMA is loading. */
+ private int adGroupIndex;
+ /** Whether IMA has sent an ad event to pause content since the last resume content event. */
+ private boolean imaPausedContent;
+ /** The current ad playback state. */
+ private @ImaAdState int imaAdState;
+ /**
+ * Whether {@link com.google.ads.interactivemedia.v3.api.AdsLoader#contentComplete()} has been
+ * called since starting ad playback.
+ */
+ private boolean sentContentComplete;
+
+ // Fields tracking the player/loader state.
+
+ /** Whether the player is playing an ad. */
+ private boolean playingAd;
+ /**
+ * If the player is playing an ad, stores the ad index in its ad group. {@link C#INDEX_UNSET}
+ * otherwise.
+ */
+ private int playingAdIndexInAdGroup;
+ /**
+ * Whether there's a pending ad preparation error which IMA needs to be notified of when it
+ * transitions from playing content to playing the ad.
+ */
+ private boolean shouldNotifyAdPrepareError;
+ /**
+ * If a content period has finished but IMA has not yet called {@link #playAd()}, stores the value
+ * of {@link SystemClock#elapsedRealtime()} when the content stopped playing. This can be used to
+ * determine a fake, increasing content position. {@link C#TIME_UNSET} otherwise.
+ */
+ private long fakeContentProgressElapsedRealtimeMs;
+ /**
+ * If {@link #fakeContentProgressElapsedRealtimeMs} is set, stores the offset from which the
+ * content progress should increase. {@link C#TIME_UNSET} otherwise.
+ */
+ private long fakeContentProgressOffsetMs;
+ /** Stores the pending content position when a seek operation was intercepted to play an ad. */
+ private long pendingContentPositionMs;
+ /** Whether {@link #getContentProgress()} has sent {@link #pendingContentPositionMs} to IMA. */
+ private boolean sentPendingContentPositionMs;
+
+ /**
+ * Creates a new IMA ads loader.
+ *
+ * If you need to customize the ad request, use {@link ImaAdsLoader.Builder} instead.
+ *
+ * @param context The context.
+ * @param adTagUri The {@link Uri} of an ad tag compatible with the Android IMA SDK. See
+ * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for
+ * more information.
+ */
+ public ImaAdsLoader(Context context, Uri adTagUri) {
+ this(
+ context,
+ adTagUri,
+ /* imaSdkSettings= */ null,
+ /* adsResponse= */ null,
+ /* vastLoadTimeoutMs= */ TIMEOUT_UNSET,
+ /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET,
+ /* mediaBitrate= */ BITRATE_UNSET,
+ /* focusSkipButtonWhenAvailable= */ true,
+ /* adUiElements= */ null,
+ /* adEventListener= */ null,
+ /* imaFactory= */ new DefaultImaFactory());
+ }
+
+ /**
+ * Creates a new IMA ads loader.
+ *
+ * @param context The context.
+ * @param adTagUri The {@link Uri} of an ad tag compatible with the Android IMA SDK. See
+ * https://developers.google.com/interactive-media-ads/docs/sdks/android/compatibility for
+ * more information.
+ * @param imaSdkSettings {@link ImaSdkSettings} used to configure the IMA SDK, or {@code null} to
+ * use the default settings. If set, the player type and version fields may be overwritten.
+ * @deprecated Use {@link ImaAdsLoader.Builder}.
+ */
+ @Deprecated
+ public ImaAdsLoader(Context context, Uri adTagUri, @Nullable ImaSdkSettings imaSdkSettings) {
+ this(
+ context,
+ adTagUri,
+ imaSdkSettings,
+ /* adsResponse= */ null,
+ /* vastLoadTimeoutMs= */ TIMEOUT_UNSET,
+ /* mediaLoadTimeoutMs= */ TIMEOUT_UNSET,
+ /* mediaBitrate= */ BITRATE_UNSET,
+ /* focusSkipButtonWhenAvailable= */ true,
+ /* adUiElements= */ null,
+ /* adEventListener= */ null,
+ /* imaFactory= */ new DefaultImaFactory());
+ }
+
+ private ImaAdsLoader(
+ Context context,
+ @Nullable Uri adTagUri,
+ @Nullable ImaSdkSettings imaSdkSettings,
+ @Nullable String adsResponse,
+ int vastLoadTimeoutMs,
+ int mediaLoadTimeoutMs,
+ int mediaBitrate,
+ boolean focusSkipButtonWhenAvailable,
+ @Nullable Set adUiElements,
+ @Nullable AdEventListener adEventListener,
+ ImaFactory imaFactory) {
+ Assertions.checkArgument(adTagUri != null || adsResponse != null);
+ this.adTagUri = adTagUri;
+ this.adsResponse = adsResponse;
+ this.vastLoadTimeoutMs = vastLoadTimeoutMs;
+ this.mediaLoadTimeoutMs = mediaLoadTimeoutMs;
+ this.mediaBitrate = mediaBitrate;
+ this.focusSkipButtonWhenAvailable = focusSkipButtonWhenAvailable;
+ this.adUiElements = adUiElements;
+ this.adEventListener = adEventListener;
+ this.imaFactory = imaFactory;
+ if (imaSdkSettings == null) {
+ imaSdkSettings = imaFactory.createImaSdkSettings();
+ if (DEBUG) {
+ imaSdkSettings.setDebugMode(true);
+ }
+ }
+ imaSdkSettings.setPlayerType(IMA_SDK_SETTINGS_PLAYER_TYPE);
+ imaSdkSettings.setPlayerVersion(IMA_SDK_SETTINGS_PLAYER_VERSION);
+ period = new Timeline.Period();
+ adCallbacks = new ArrayList<>(/* initialCapacity= */ 1);
+ adDisplayContainer = imaFactory.createAdDisplayContainer();
+ adDisplayContainer.setPlayer(/* videoAdPlayer= */ this);
+ adsLoader = imaFactory.createAdsLoader(context, imaSdkSettings, adDisplayContainer);
+ adsLoader.addAdErrorListener(/* adErrorListener= */ this);
+ adsLoader.addAdsLoadedListener(/* adsLoadedListener= */ this);
+ fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
+ fakeContentProgressOffsetMs = C.TIME_UNSET;
+ pendingContentPositionMs = C.TIME_UNSET;
+ adGroupIndex = C.INDEX_UNSET;
+ contentDurationMs = C.TIME_UNSET;
+ timeline = Timeline.EMPTY;
+ }
+
+ /**
+ * Returns the underlying {@code com.google.ads.interactivemedia.v3.api.AdsLoader} wrapped by
+ * this instance.
+ */
+ public com.google.ads.interactivemedia.v3.api.AdsLoader getAdsLoader() {
+ return adsLoader;
+ }
+
+ /**
+ * Returns the {@link AdDisplayContainer} used by this loader.
+ *
+ * Note: any video controls overlays registered via {@link
+ * AdDisplayContainer#registerVideoControlsOverlay(View)} will be unregistered automatically when
+ * the media source detaches from this instance. It is therefore necessary to re-register views
+ * each time the ads loader is reused. Alternatively, provide overlay views via the {@link
+ * AdsLoader.AdViewProvider} when creating the media source to benefit from automatic
+ * registration.
+ */
+ public AdDisplayContainer getAdDisplayContainer() {
+ return adDisplayContainer;
+ }
+
+ /**
+ * Sets the slots for displaying companion ads. Individual slots can be created using {@link
+ * ImaSdkFactory#createCompanionAdSlot()}.
+ *
+ * @param companionSlots Slots for displaying companion ads.
+ * @see AdDisplayContainer#setCompanionSlots(Collection)
+ * @deprecated Use {@code getAdDisplayContainer().setCompanionSlots(...)}.
+ */
+ @Deprecated
+ public void setCompanionSlots(Collection companionSlots) {
+ adDisplayContainer.setCompanionSlots(companionSlots);
+ }
+
+ /**
+ * Requests ads, if they have not already been requested. Must be called on the main thread.
+ *
+ * Ads will be requested automatically when the player is prepared if this method has not been
+ * called, so it is only necessary to call this method if you want to request ads before preparing
+ * the player.
+ *
+ * @param adViewGroup A {@link ViewGroup} on top of the player that will show any ad UI.
+ */
+ public void requestAds(ViewGroup adViewGroup) {
+ if (adPlaybackState != null || adsManager != null || pendingAdRequestContext != null) {
+ // Ads have already been requested.
+ return;
+ }
+ adDisplayContainer.setAdContainer(adViewGroup);
+ pendingAdRequestContext = new Object();
+ AdsRequest request = imaFactory.createAdsRequest();
+ if (adTagUri != null) {
+ request.setAdTagUrl(adTagUri.toString());
+ } else /* adsResponse != null */ {
+ request.setAdsResponse(adsResponse);
+ }
+ if (vastLoadTimeoutMs != TIMEOUT_UNSET) {
+ request.setVastLoadTimeout(vastLoadTimeoutMs);
+ }
+ request.setContentProgressProvider(this);
+ request.setUserRequestContext(pendingAdRequestContext);
+ adsLoader.requestAds(request);
+ }
+
+ // AdsLoader implementation.
+
+ @Override
+ public void setPlayer(@Nullable Player player) {
+ Assertions.checkState(Looper.getMainLooper() == Looper.myLooper());
+ Assertions.checkState(
+ player == null || player.getApplicationLooper() == Looper.getMainLooper());
+ nextPlayer = player;
+ wasSetPlayerCalled = true;
+ }
+
+ @Override
+ public void setSupportedContentTypes(@C.ContentType int... contentTypes) {
+ List supportedMimeTypes = new ArrayList<>();
+ for (@C.ContentType int contentType : contentTypes) {
+ if (contentType == C.TYPE_DASH) {
+ supportedMimeTypes.add(MimeTypes.APPLICATION_MPD);
+ } else if (contentType == C.TYPE_HLS) {
+ supportedMimeTypes.add(MimeTypes.APPLICATION_M3U8);
+ } else if (contentType == C.TYPE_OTHER) {
+ supportedMimeTypes.addAll(
+ Arrays.asList(
+ MimeTypes.VIDEO_MP4,
+ MimeTypes.VIDEO_WEBM,
+ MimeTypes.VIDEO_H263,
+ MimeTypes.AUDIO_MP4,
+ MimeTypes.AUDIO_MPEG));
+ } else if (contentType == C.TYPE_SS) {
+ // IMA does not support Smooth Streaming ad media.
+ }
+ }
+ this.supportedMimeTypes = Collections.unmodifiableList(supportedMimeTypes);
+ }
+
+ @Override
+ public void start(EventListener eventListener, AdViewProvider adViewProvider) {
+ Assertions.checkState(
+ wasSetPlayerCalled, "Set player using adsLoader.setPlayer before preparing the player.");
+ player = nextPlayer;
+ if (player == null) {
+ return;
+ }
+ this.eventListener = eventListener;
+ lastVolumePercentage = 0;
+ lastAdProgress = null;
+ lastContentProgress = null;
+ ViewGroup adViewGroup = adViewProvider.getAdViewGroup();
+ adDisplayContainer.setAdContainer(adViewGroup);
+ View[] adOverlayViews = adViewProvider.getAdOverlayViews();
+ for (View view : adOverlayViews) {
+ adDisplayContainer.registerVideoControlsOverlay(view);
+ }
+ player.addListener(this);
+ maybeNotifyPendingAdLoadError();
+ if (adPlaybackState != null) {
+ // Pass the ad playback state to the player, and resume ads if necessary.
+ eventListener.onAdPlaybackState(adPlaybackState);
+ if (imaPausedContent && player.getPlayWhenReady()) {
+ adsManager.resume();
+ }
+ } else if (adsManager != null) {
+ adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints()));
+ updateAdPlaybackState();
+ } else {
+ // Ads haven't loaded yet, so request them.
+ requestAds(adViewGroup);
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (player == null) {
+ return;
+ }
+ if (adsManager != null && imaPausedContent) {
+ adPlaybackState =
+ adPlaybackState.withAdResumePositionUs(
+ playingAd ? C.msToUs(player.getCurrentPosition()) : 0);
+ adsManager.pause();
+ }
+ lastVolumePercentage = getVolume();
+ lastAdProgress = getAdProgress();
+ lastContentProgress = getContentProgress();
+ adDisplayContainer.unregisterAllVideoControlsOverlays();
+ player.removeListener(this);
+ player = null;
+ eventListener = null;
+ }
+
+ @Override
+ public void release() {
+ pendingAdRequestContext = null;
+ if (adsManager != null) {
+ adsManager.removeAdErrorListener(this);
+ adsManager.removeAdEventListener(this);
+ if (adEventListener != null) {
+ adsManager.removeAdEventListener(adEventListener);
+ }
+ adsManager.destroy();
+ adsManager = null;
+ }
+ adsLoader.removeAdsLoadedListener(/* adsLoadedListener= */ this);
+ adsLoader.removeAdErrorListener(/* adErrorListener= */ this);
+ imaPausedContent = false;
+ imaAdState = IMA_AD_STATE_NONE;
+ pendingAdLoadError = null;
+ adPlaybackState = AdPlaybackState.NONE;
+ updateAdPlaybackState();
+ }
+
+ @Override
+ public void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception) {
+ if (player == null) {
+ return;
+ }
+ try {
+ handleAdPrepareError(adGroupIndex, adIndexInAdGroup, exception);
+ } catch (Exception e) {
+ maybeNotifyInternalError("handlePrepareError", e);
+ }
+ }
+
+ // com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener implementation.
+
+ @Override
+ public void onAdsManagerLoaded(AdsManagerLoadedEvent adsManagerLoadedEvent) {
+ AdsManager adsManager = adsManagerLoadedEvent.getAdsManager();
+ if (!Util.areEqual(pendingAdRequestContext, adsManagerLoadedEvent.getUserRequestContext())) {
+ adsManager.destroy();
+ return;
+ }
+ pendingAdRequestContext = null;
+ this.adsManager = adsManager;
+ adsManager.addAdErrorListener(this);
+ adsManager.addAdEventListener(this);
+ if (adEventListener != null) {
+ adsManager.addAdEventListener(adEventListener);
+ }
+ if (player != null) {
+ // If a player is attached already, start playback immediately.
+ try {
+ adPlaybackState = new AdPlaybackState(getAdGroupTimesUs(adsManager.getAdCuePoints()));
+ updateAdPlaybackState();
+ } catch (Exception e) {
+ maybeNotifyInternalError("onAdsManagerLoaded", e);
+ }
+ }
+ }
+
+ // AdEvent.AdEventListener implementation.
+
+ @Override
+ public void onAdEvent(AdEvent adEvent) {
+ AdEventType adEventType = adEvent.getType();
+ if (DEBUG) {
+ Log.d(TAG, "onAdEvent: " + adEventType);
+ }
+ if (adsManager == null) {
+ Log.w(TAG, "Ignoring AdEvent after release: " + adEvent);
+ return;
+ }
+ try {
+ handleAdEvent(adEvent);
+ } catch (Exception e) {
+ maybeNotifyInternalError("onAdEvent", e);
+ }
+ }
+
+ // AdErrorEvent.AdErrorListener implementation.
+
+ @Override
+ public void onAdError(AdErrorEvent adErrorEvent) {
+ AdError error = adErrorEvent.getError();
+ if (DEBUG) {
+ Log.d(TAG, "onAdError", error);
+ }
+ if (adsManager == null) {
+ // No ads were loaded, so allow playback to start without any ads.
+ pendingAdRequestContext = null;
+ adPlaybackState = new AdPlaybackState();
+ updateAdPlaybackState();
+ } else if (isAdGroupLoadError(error)) {
+ try {
+ handleAdGroupLoadError(error);
+ } catch (Exception e) {
+ maybeNotifyInternalError("onAdError", e);
+ }
+ }
+ if (pendingAdLoadError == null) {
+ pendingAdLoadError = AdLoadException.createForAllAds(error);
+ }
+ maybeNotifyPendingAdLoadError();
+ }
+
+ // ContentProgressProvider implementation.
+
+ @Override
+ public VideoProgressUpdate getContentProgress() {
+ if (player == null) {
+ return lastContentProgress;
+ }
+ boolean hasContentDuration = contentDurationMs != C.TIME_UNSET;
+ long contentPositionMs;
+ if (pendingContentPositionMs != C.TIME_UNSET) {
+ sentPendingContentPositionMs = true;
+ contentPositionMs = pendingContentPositionMs;
+ expectedAdGroupIndex =
+ adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs));
+ } else if (fakeContentProgressElapsedRealtimeMs != C.TIME_UNSET) {
+ long elapsedSinceEndMs = SystemClock.elapsedRealtime() - fakeContentProgressElapsedRealtimeMs;
+ contentPositionMs = fakeContentProgressOffsetMs + elapsedSinceEndMs;
+ expectedAdGroupIndex =
+ adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs));
+ } else if (imaAdState == IMA_AD_STATE_NONE && !playingAd && hasContentDuration) {
+ contentPositionMs = player.getCurrentPosition();
+ // Update the expected ad group index for the current content position. The update is delayed
+ // until MAXIMUM_PRELOAD_DURATION_MS before the ad so that an ad group load error delivered
+ // just after an ad group isn't incorrectly attributed to the next ad group.
+ int nextAdGroupIndex =
+ adPlaybackState.getAdGroupIndexAfterPositionUs(
+ C.msToUs(contentPositionMs), C.msToUs(contentDurationMs));
+ if (nextAdGroupIndex != expectedAdGroupIndex && nextAdGroupIndex != C.INDEX_UNSET) {
+ long nextAdGroupTimeMs = C.usToMs(adPlaybackState.adGroupTimesUs[nextAdGroupIndex]);
+ if (nextAdGroupTimeMs == C.TIME_END_OF_SOURCE) {
+ nextAdGroupTimeMs = contentDurationMs;
+ }
+ if (nextAdGroupTimeMs - contentPositionMs < MAXIMUM_PRELOAD_DURATION_MS) {
+ expectedAdGroupIndex = nextAdGroupIndex;
+ }
+ }
+ } else {
+ return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
+ }
+ long contentDurationMs = hasContentDuration ? this.contentDurationMs : IMA_DURATION_UNSET;
+ return new VideoProgressUpdate(contentPositionMs, contentDurationMs);
+ }
+
+ // VideoAdPlayer implementation.
+
+ @Override
+ public VideoProgressUpdate getAdProgress() {
+ if (player == null) {
+ return lastAdProgress;
+ } else if (imaAdState != IMA_AD_STATE_NONE && playingAd) {
+ long adDuration = player.getDuration();
+ return adDuration == C.TIME_UNSET ? VideoProgressUpdate.VIDEO_TIME_NOT_READY
+ : new VideoProgressUpdate(player.getCurrentPosition(), adDuration);
+ } else {
+ return VideoProgressUpdate.VIDEO_TIME_NOT_READY;
+ }
+ }
+
+ @Override
+ public int getVolume() {
+ if (player == null) {
+ return lastVolumePercentage;
+ }
+
+ Player.AudioComponent audioComponent = player.getAudioComponent();
+ if (audioComponent != null) {
+ return (int) (audioComponent.getVolume() * 100);
+ }
+
+ // Check for a selected track using an audio renderer.
+ TrackSelectionArray trackSelections = player.getCurrentTrackSelections();
+ for (int i = 0; i < player.getRendererCount() && i < trackSelections.length; i++) {
+ if (player.getRendererType(i) == C.TRACK_TYPE_AUDIO && trackSelections.get(i) != null) {
+ return 100;
+ }
+ }
+ return 0;
+ }
+
+ @Override
+ public void loadAd(String adUriString) {
+ try {
+ if (DEBUG) {
+ Log.d(TAG, "loadAd in ad group " + adGroupIndex);
+ }
+ if (adsManager == null) {
+ Log.w(TAG, "Ignoring loadAd after release");
+ return;
+ }
+ if (adGroupIndex == C.INDEX_UNSET) {
+ Log.w(
+ TAG,
+ "Unexpected loadAd without LOADED event; assuming ad group index is actually "
+ + expectedAdGroupIndex);
+ adGroupIndex = expectedAdGroupIndex;
+ adsManager.start();
+ }
+ int adIndexInAdGroup = getAdIndexInAdGroupToLoad(adGroupIndex);
+ if (adIndexInAdGroup == C.INDEX_UNSET) {
+ Log.w(TAG, "Unexpected loadAd in an ad group with no remaining unavailable ads");
+ return;
+ }
+ adPlaybackState =
+ adPlaybackState.withAdUri(adGroupIndex, adIndexInAdGroup, Uri.parse(adUriString));
+ updateAdPlaybackState();
+ } catch (Exception e) {
+ maybeNotifyInternalError("loadAd", e);
+ }
+ }
+
+ @Override
+ public void addCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
+ adCallbacks.add(videoAdPlayerCallback);
+ }
+
+ @Override
+ public void removeCallback(VideoAdPlayerCallback videoAdPlayerCallback) {
+ adCallbacks.remove(videoAdPlayerCallback);
+ }
+
+ @Override
+ public void playAd() {
+ if (DEBUG) {
+ Log.d(TAG, "playAd");
+ }
+ if (adsManager == null) {
+ Log.w(TAG, "Ignoring playAd after release");
+ return;
+ }
+ switch (imaAdState) {
+ case IMA_AD_STATE_PLAYING:
+ // IMA does not always call stopAd before resuming content.
+ // See [Internal: b/38354028, b/63320878].
+ Log.w(TAG, "Unexpected playAd without stopAd");
+ break;
+ case IMA_AD_STATE_NONE:
+ // IMA is requesting to play the ad, so stop faking the content position.
+ fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
+ fakeContentProgressOffsetMs = C.TIME_UNSET;
+ imaAdState = IMA_AD_STATE_PLAYING;
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onPlay();
+ }
+ if (shouldNotifyAdPrepareError) {
+ shouldNotifyAdPrepareError = false;
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onError();
+ }
+ }
+ break;
+ case IMA_AD_STATE_PAUSED:
+ imaAdState = IMA_AD_STATE_PLAYING;
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onResume();
+ }
+ break;
+ default:
+ throw new IllegalStateException();
+ }
+ if (player == null) {
+ // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642].
+ Log.w(TAG, "Unexpected playAd while detached");
+ } else if (!player.getPlayWhenReady()) {
+ adsManager.pause();
+ }
+ }
+
+ @Override
+ public void stopAd() {
+ if (DEBUG) {
+ Log.d(TAG, "stopAd");
+ }
+ if (adsManager == null) {
+ Log.w(TAG, "Ignoring stopAd after release");
+ return;
+ }
+ if (player == null) {
+ // Sometimes messages from IMA arrive after detaching the player. See [Internal: b/63801642].
+ Log.w(TAG, "Unexpected stopAd while detached");
+ }
+ if (imaAdState == IMA_AD_STATE_NONE) {
+ Log.w(TAG, "Unexpected stopAd");
+ return;
+ }
+ try {
+ stopAdInternal();
+ } catch (Exception e) {
+ maybeNotifyInternalError("stopAd", e);
+ }
+ }
+
+ @Override
+ public void pauseAd() {
+ if (DEBUG) {
+ Log.d(TAG, "pauseAd");
+ }
+ if (imaAdState == IMA_AD_STATE_NONE) {
+ // This method is called after content is resumed.
+ return;
+ }
+ imaAdState = IMA_AD_STATE_PAUSED;
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onPause();
+ }
+ }
+
+ @Override
+ public void resumeAd() {
+ // This method is never called. See [Internal: b/18931719].
+ maybeNotifyInternalError("resumeAd", new IllegalStateException("Unexpected call to resumeAd"));
+ }
+
+ // Player.EventListener implementation.
+
+ @Override
+ public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
+ if (timeline.isEmpty()) {
+ // The player is being reset or contains no media.
+ return;
+ }
+ Assertions.checkArgument(timeline.getPeriodCount() == 1);
+ this.timeline = timeline;
+ long contentDurationUs = timeline.getPeriod(0, period).durationUs;
+ contentDurationMs = C.usToMs(contentDurationUs);
+ if (contentDurationUs != C.TIME_UNSET) {
+ adPlaybackState = adPlaybackState.withContentDurationUs(contentDurationUs);
+ }
+ if (!initializedAdsManager && adsManager != null) {
+ initializedAdsManager = true;
+ initializeAdsManager();
+ }
+ onPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL);
+ }
+
+ @Override
+ public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
+ if (adsManager == null) {
+ return;
+ }
+
+ if (imaAdState == IMA_AD_STATE_PLAYING && !playWhenReady) {
+ adsManager.pause();
+ return;
+ }
+
+ if (imaAdState == IMA_AD_STATE_PAUSED && playWhenReady) {
+ adsManager.resume();
+ return;
+ }
+
+ if (imaAdState == IMA_AD_STATE_NONE && playbackState == Player.STATE_BUFFERING
+ && playWhenReady) {
+ checkForContentComplete();
+ } else if (imaAdState != IMA_AD_STATE_NONE && playbackState == Player.STATE_ENDED) {
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onEnded();
+ }
+ if (DEBUG) {
+ Log.d(TAG, "VideoAdPlayerCallback.onEnded in onPlayerStateChanged");
+ }
+ }
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException error) {
+ if (imaAdState != IMA_AD_STATE_NONE) {
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onError();
+ }
+ }
+ }
+
+ @Override
+ public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
+ if (adsManager == null) {
+ return;
+ }
+ if (!playingAd && !player.isPlayingAd()) {
+ checkForContentComplete();
+ if (sentContentComplete) {
+ for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
+ if (adPlaybackState.adGroupTimesUs[i] != C.TIME_END_OF_SOURCE) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
+ }
+ }
+ updateAdPlaybackState();
+ } else if (!timeline.isEmpty()) {
+ long positionMs = player.getCurrentPosition();
+ timeline.getPeriod(0, period);
+ int newAdGroupIndex = period.getAdGroupIndexForPositionUs(C.msToUs(positionMs));
+ if (newAdGroupIndex != C.INDEX_UNSET) {
+ sentPendingContentPositionMs = false;
+ pendingContentPositionMs = positionMs;
+ if (newAdGroupIndex != adGroupIndex) {
+ shouldNotifyAdPrepareError = false;
+ }
+ }
+ }
+ }
+ updateImaStateForPlayerState();
+ }
+
+ // Internal methods.
+
+ private void initializeAdsManager() {
+ AdsRenderingSettings adsRenderingSettings = imaFactory.createAdsRenderingSettings();
+ adsRenderingSettings.setEnablePreloading(true);
+ adsRenderingSettings.setMimeTypes(supportedMimeTypes);
+ if (mediaLoadTimeoutMs != TIMEOUT_UNSET) {
+ adsRenderingSettings.setLoadVideoTimeout(mediaLoadTimeoutMs);
+ }
+ if (mediaBitrate != BITRATE_UNSET) {
+ adsRenderingSettings.setBitrateKbps(mediaBitrate / 1000);
+ }
+ adsRenderingSettings.setFocusSkipButtonWhenAvailable(focusSkipButtonWhenAvailable);
+ if (adUiElements != null) {
+ adsRenderingSettings.setUiElements(adUiElements);
+ }
+
+ // Skip ads based on the start position as required.
+ long[] adGroupTimesUs = getAdGroupTimesUs(adsManager.getAdCuePoints());
+ long contentPositionMs = player.getContentPosition();
+ int adGroupIndexForPosition =
+ adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentPositionMs));
+ if (adGroupIndexForPosition > 0 && adGroupIndexForPosition != C.INDEX_UNSET) {
+ // Skip any ad groups before the one at or immediately before the playback position.
+ for (int i = 0; i < adGroupIndexForPosition; i++) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
+ }
+ // Play ads after the midpoint between the ad to play and the one before it, to avoid issues
+ // with rounding one of the two ad times.
+ long adGroupForPositionTimeUs = adGroupTimesUs[adGroupIndexForPosition];
+ long adGroupBeforeTimeUs = adGroupTimesUs[adGroupIndexForPosition - 1];
+ double midpointTimeUs = (adGroupForPositionTimeUs + adGroupBeforeTimeUs) / 2d;
+ adsRenderingSettings.setPlayAdsAfterTime(midpointTimeUs / C.MICROS_PER_SECOND);
+ }
+
+ // IMA indexes any remaining midroll ad pods from 1. A preroll (if present) has index 0.
+ // Store an index offset as we want to index all ads (including skipped ones) from 0.
+ if (adGroupIndexForPosition == 0 && adGroupTimesUs[0] == 0) {
+ // We are playing a preroll.
+ podIndexOffset = 0;
+ } else if (adGroupIndexForPosition == C.INDEX_UNSET) {
+ // There's no ad to play which means there's no preroll.
+ podIndexOffset = -1;
+ } else {
+ // We are playing a midroll and any ads before it were skipped.
+ podIndexOffset = adGroupIndexForPosition - 1;
+ }
+
+ if (adGroupIndexForPosition != C.INDEX_UNSET && hasMidrollAdGroups(adGroupTimesUs)) {
+ // Provide the player's initial position to trigger loading and playing the ad.
+ pendingContentPositionMs = contentPositionMs;
+ }
+
+ adsManager.init(adsRenderingSettings);
+ updateAdPlaybackState();
+ if (DEBUG) {
+ Log.d(TAG, "Initialized with ads rendering settings: " + adsRenderingSettings);
+ }
+ }
+
+ private void handleAdEvent(AdEvent adEvent) {
+ Ad ad = adEvent.getAd();
+ switch (adEvent.getType()) {
+ case LOADED:
+ // The ad position is not always accurate when using preloading. See [Internal: b/62613240].
+ AdPodInfo adPodInfo = ad.getAdPodInfo();
+ int podIndex = adPodInfo.getPodIndex();
+ adGroupIndex =
+ podIndex == -1 ? (adPlaybackState.adGroupCount - 1) : (podIndex + podIndexOffset);
+ int adPosition = adPodInfo.getAdPosition();
+ int adCount = adPodInfo.getTotalAds();
+ adsManager.start();
+ if (DEBUG) {
+ Log.d(TAG, "Loaded ad " + adPosition + " of " + adCount + " in group " + adGroupIndex);
+ }
+ int oldAdCount = adPlaybackState.adGroups[adGroupIndex].count;
+ if (adCount != oldAdCount) {
+ if (oldAdCount == C.LENGTH_UNSET) {
+ adPlaybackState = adPlaybackState.withAdCount(adGroupIndex, adCount);
+ updateAdPlaybackState();
+ } else {
+ // IMA sometimes unexpectedly decreases the ad count in an ad group.
+ Log.w(TAG, "Unexpected ad count in LOADED, " + adCount + ", expected " + oldAdCount);
+ }
+ }
+ if (adGroupIndex != expectedAdGroupIndex) {
+ Log.w(
+ TAG,
+ "Expected ad group index "
+ + expectedAdGroupIndex
+ + ", actual ad group index "
+ + adGroupIndex);
+ expectedAdGroupIndex = adGroupIndex;
+ }
+ break;
+ case CONTENT_PAUSE_REQUESTED:
+ // After CONTENT_PAUSE_REQUESTED, IMA will playAd/pauseAd/stopAd to show one or more ads
+ // before sending CONTENT_RESUME_REQUESTED.
+ imaPausedContent = true;
+ pauseContentInternal();
+ break;
+ case TAPPED:
+ if (eventListener != null) {
+ eventListener.onAdTapped();
+ }
+ break;
+ case CLICKED:
+ if (eventListener != null) {
+ eventListener.onAdClicked();
+ }
+ break;
+ case CONTENT_RESUME_REQUESTED:
+ imaPausedContent = false;
+ resumeContentInternal();
+ break;
+ case LOG:
+ Map adData = adEvent.getAdData();
+ String message = "AdEvent: " + adData;
+ Log.i(TAG, message);
+ if ("adLoadError".equals(adData.get("type"))) {
+ handleAdGroupLoadError(new IOException(message));
+ }
+ break;
+ case STARTED:
+ case ALL_ADS_COMPLETED:
+ default:
+ break;
+ }
+ }
+
+ private void updateImaStateForPlayerState() {
+ boolean wasPlayingAd = playingAd;
+ int oldPlayingAdIndexInAdGroup = playingAdIndexInAdGroup;
+ playingAd = player.isPlayingAd();
+ playingAdIndexInAdGroup = playingAd ? player.getCurrentAdIndexInAdGroup() : C.INDEX_UNSET;
+ boolean adFinished = wasPlayingAd && playingAdIndexInAdGroup != oldPlayingAdIndexInAdGroup;
+ if (adFinished) {
+ // IMA is waiting for the ad playback to finish so invoke the callback now.
+ // Either CONTENT_RESUME_REQUESTED will be passed next, or playAd will be called again.
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onEnded();
+ }
+ if (DEBUG) {
+ Log.d(TAG, "VideoAdPlayerCallback.onEnded in onTimelineChanged/onPositionDiscontinuity");
+ }
+ }
+ if (!sentContentComplete && !wasPlayingAd && playingAd && imaAdState == IMA_AD_STATE_NONE) {
+ int adGroupIndex = player.getCurrentAdGroupIndex();
+ // IMA hasn't called playAd yet, so fake the content position.
+ fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
+ fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
+ if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
+ fakeContentProgressOffsetMs = contentDurationMs;
+ }
+ }
+ }
+
+ private void resumeContentInternal() {
+ if (imaAdState != IMA_AD_STATE_NONE) {
+ imaAdState = IMA_AD_STATE_NONE;
+ if (DEBUG) {
+ Log.d(TAG, "Unexpected CONTENT_RESUME_REQUESTED without stopAd");
+ }
+ }
+ if (adGroupIndex != C.INDEX_UNSET) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(adGroupIndex);
+ adGroupIndex = C.INDEX_UNSET;
+ updateAdPlaybackState();
+ }
+ }
+
+ private void pauseContentInternal() {
+ imaAdState = IMA_AD_STATE_NONE;
+ if (sentPendingContentPositionMs) {
+ pendingContentPositionMs = C.TIME_UNSET;
+ sentPendingContentPositionMs = false;
+ }
+ }
+
+ private void stopAdInternal() {
+ imaAdState = IMA_AD_STATE_NONE;
+ int adIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay();
+ // TODO: Handle the skipped event so the ad can be marked as skipped rather than played.
+ adPlaybackState =
+ adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup).withAdResumePositionUs(0);
+ updateAdPlaybackState();
+ if (!playingAd) {
+ adGroupIndex = C.INDEX_UNSET;
+ }
+ }
+
+ private void handleAdGroupLoadError(Exception error) {
+ int adGroupIndex =
+ this.adGroupIndex == C.INDEX_UNSET ? expectedAdGroupIndex : this.adGroupIndex;
+ if (adGroupIndex == C.INDEX_UNSET) {
+ // Drop the error, as we don't know which ad group it relates to.
+ return;
+ }
+ AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex];
+ if (adGroup.count == C.LENGTH_UNSET) {
+ adPlaybackState =
+ adPlaybackState.withAdCount(adGroupIndex, Math.max(1, adGroup.states.length));
+ adGroup = adPlaybackState.adGroups[adGroupIndex];
+ }
+ for (int i = 0; i < adGroup.count; i++) {
+ if (adGroup.states[i] == AdPlaybackState.AD_STATE_UNAVAILABLE) {
+ if (DEBUG) {
+ Log.d(TAG, "Removing ad " + i + " in ad group " + adGroupIndex);
+ }
+ adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, i);
+ }
+ }
+ updateAdPlaybackState();
+ if (pendingAdLoadError == null) {
+ pendingAdLoadError = AdLoadException.createForAdGroup(error, adGroupIndex);
+ }
+ pendingContentPositionMs = C.TIME_UNSET;
+ fakeContentProgressElapsedRealtimeMs = C.TIME_UNSET;
+ }
+
+ private void handleAdPrepareError(int adGroupIndex, int adIndexInAdGroup, Exception exception) {
+ if (DEBUG) {
+ Log.d(
+ TAG, "Prepare error for ad " + adIndexInAdGroup + " in group " + adGroupIndex, exception);
+ }
+ if (adsManager == null) {
+ Log.w(TAG, "Ignoring ad prepare error after release");
+ return;
+ }
+ if (imaAdState == IMA_AD_STATE_NONE) {
+ // Send IMA a content position at the ad group so that it will try to play it, at which point
+ // we can notify that it failed to load.
+ fakeContentProgressElapsedRealtimeMs = SystemClock.elapsedRealtime();
+ fakeContentProgressOffsetMs = C.usToMs(adPlaybackState.adGroupTimesUs[adGroupIndex]);
+ if (fakeContentProgressOffsetMs == C.TIME_END_OF_SOURCE) {
+ fakeContentProgressOffsetMs = contentDurationMs;
+ }
+ shouldNotifyAdPrepareError = true;
+ } else {
+ // We're already playing an ad.
+ if (adIndexInAdGroup > playingAdIndexInAdGroup) {
+ // Mark the playing ad as ended so we can notify the error on the next ad and remove it,
+ // which means that the ad after will load (if any).
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onEnded();
+ }
+ }
+ playingAdIndexInAdGroup = adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay();
+ for (int i = 0; i < adCallbacks.size(); i++) {
+ adCallbacks.get(i).onError();
+ }
+ }
+ adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup);
+ updateAdPlaybackState();
+ }
+
+ private void checkForContentComplete() {
+ if (contentDurationMs != C.TIME_UNSET && pendingContentPositionMs == C.TIME_UNSET
+ && player.getContentPosition() + END_OF_CONTENT_POSITION_THRESHOLD_MS >= contentDurationMs
+ && !sentContentComplete) {
+ adsLoader.contentComplete();
+ if (DEBUG) {
+ Log.d(TAG, "adsLoader.contentComplete");
+ }
+ sentContentComplete = true;
+ // After sending content complete IMA will not poll the content position, so set the expected
+ // ad group index.
+ expectedAdGroupIndex =
+ adPlaybackState.getAdGroupIndexForPositionUs(C.msToUs(contentDurationMs));
+ }
+ }
+
+ private void updateAdPlaybackState() {
+ // Ignore updates while detached. When a player is attached it will receive the latest state.
+ if (eventListener != null) {
+ eventListener.onAdPlaybackState(adPlaybackState);
+ }
+ }
+
+ /**
+ * Returns the next ad index in the specified ad group to load, or {@link C#INDEX_UNSET} if all
+ * ads in the ad group have loaded.
+ */
+ private int getAdIndexInAdGroupToLoad(int adGroupIndex) {
+ @AdState int[] states = adPlaybackState.adGroups[adGroupIndex].states;
+ int adIndexInAdGroup = 0;
+ // IMA loads ads in order.
+ while (adIndexInAdGroup < states.length
+ && states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE) {
+ adIndexInAdGroup++;
+ }
+ return adIndexInAdGroup == states.length ? C.INDEX_UNSET : adIndexInAdGroup;
+ }
+
+ private void maybeNotifyPendingAdLoadError() {
+ if (pendingAdLoadError != null && eventListener != null) {
+ eventListener.onAdLoadError(pendingAdLoadError, new DataSpec(adTagUri));
+ pendingAdLoadError = null;
+ }
+ }
+
+ private void maybeNotifyInternalError(String name, Exception cause) {
+ String message = "Internal error in " + name;
+ Log.e(TAG, message, cause);
+ // We can't recover from an unexpected error in general, so skip all remaining ads.
+ if (adPlaybackState == null) {
+ adPlaybackState = AdPlaybackState.NONE;
+ } else {
+ for (int i = 0; i < adPlaybackState.adGroupCount; i++) {
+ adPlaybackState = adPlaybackState.withSkippedAdGroup(i);
+ }
+ }
+ updateAdPlaybackState();
+ if (eventListener != null) {
+ eventListener.onAdLoadError(
+ AdLoadException.createForUnexpected(new RuntimeException(message, cause)),
+ new DataSpec(adTagUri));
+ }
+ }
+
+ private static long[] getAdGroupTimesUs(List cuePoints) {
+ if (cuePoints.isEmpty()) {
+ // If no cue points are specified, there is a preroll ad.
+ return new long[] {0};
+ }
+
+ int count = cuePoints.size();
+ long[] adGroupTimesUs = new long[count];
+ int adGroupIndex = 0;
+ for (int i = 0; i < count; i++) {
+ double cuePoint = cuePoints.get(i);
+ if (cuePoint == -1.0) {
+ adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE;
+ } else {
+ adGroupTimesUs[adGroupIndex++] = (long) (C.MICROS_PER_SECOND * cuePoint);
+ }
+ }
+ // Cue points may be out of order, so sort them.
+ Arrays.sort(adGroupTimesUs, 0, adGroupIndex);
+ return adGroupTimesUs;
+ }
+
+ private static boolean isAdGroupLoadError(AdError adError) {
+ // TODO: Find out what other errors need to be handled (if any), and whether each one relates to
+ // a single ad, ad group or the whole timeline.
+ return adError.getErrorCode() == AdErrorCode.VAST_LINEAR_ASSET_MISMATCH
+ || adError.getErrorCode() == AdErrorCode.UNKNOWN_ERROR;
+ }
+
+ private static boolean hasMidrollAdGroups(long[] adGroupTimesUs) {
+ int count = adGroupTimesUs.length;
+ if (count == 1) {
+ return adGroupTimesUs[0] != 0 && adGroupTimesUs[0] != C.TIME_END_OF_SOURCE;
+ } else if (count == 2) {
+ return adGroupTimesUs[0] != 0 || adGroupTimesUs[1] != C.TIME_END_OF_SOURCE;
+ } else {
+ // There's at least one midroll ad group, as adGroupTimesUs is never empty.
+ return true;
+ }
+ }
+
+ /** Factory for objects provided by the IMA SDK. */
+ @VisibleForTesting
+ /* package */ interface ImaFactory {
+ /** @see ImaSdkSettings */
+ ImaSdkSettings createImaSdkSettings();
+ /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdsRenderingSettings() */
+ AdsRenderingSettings createAdsRenderingSettings();
+ /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdDisplayContainer() */
+ AdDisplayContainer createAdDisplayContainer();
+ /** @see com.google.ads.interactivemedia.v3.api.ImaSdkFactory#createAdsRequest() */
+ AdsRequest createAdsRequest();
+ /** @see ImaSdkFactory#createAdsLoader(Context, ImaSdkSettings, AdDisplayContainer) */
+ com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader(
+ Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer);
+ }
+
+ /** Default {@link ImaFactory} for non-test usage, which delegates to {@link ImaSdkFactory}. */
+ private static final class DefaultImaFactory implements ImaFactory {
+ @Override
+ public ImaSdkSettings createImaSdkSettings() {
+ return ImaSdkFactory.getInstance().createImaSdkSettings();
+ }
+
+ @Override
+ public AdsRenderingSettings createAdsRenderingSettings() {
+ return ImaSdkFactory.getInstance().createAdsRenderingSettings();
+ }
+
+ @Override
+ public AdDisplayContainer createAdDisplayContainer() {
+ return ImaSdkFactory.getInstance().createAdDisplayContainer();
+ }
+
+ @Override
+ public AdsRequest createAdsRequest() {
+ return ImaSdkFactory.getInstance().createAdsRequest();
+ }
+
+ @Override
+ public com.google.ads.interactivemedia.v3.api.AdsLoader createAdsLoader(
+ Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) {
+ return ImaSdkFactory.getInstance()
+ .createAdsLoader(context, imaSdkSettings, adDisplayContainer);
+ }
+ }
+}
diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/package-info.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/package-info.java
new file mode 100644
index 0000000000..9a382eb18f
--- /dev/null
+++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/package-info.java
@@ -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.ima;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/extensions/ima/src/test/AndroidManifest.xml b/extensions/ima/src/test/AndroidManifest.xml
new file mode 100644
index 0000000000..564c5d94dd
--- /dev/null
+++ b/extensions/ima/src/test/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java
new file mode 100644
index 0000000000..59dfc6473c
--- /dev/null
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAd.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ima;
+
+import com.google.ads.interactivemedia.v3.api.Ad;
+import com.google.ads.interactivemedia.v3.api.AdPodInfo;
+import com.google.ads.interactivemedia.v3.api.CompanionAd;
+import com.google.ads.interactivemedia.v3.api.UiElement;
+import java.util.List;
+import java.util.Set;
+
+/** A fake ad for testing. */
+/* package */ final class FakeAd implements Ad {
+
+ private final boolean skippable;
+ private final AdPodInfo adPodInfo;
+
+ public FakeAd(boolean skippable, int podIndex, int totalAds, int adPosition) {
+ this.skippable = skippable;
+ adPodInfo =
+ new AdPodInfo() {
+ @Override
+ public int getTotalAds() {
+ return totalAds;
+ }
+
+ @Override
+ public int getAdPosition() {
+ return adPosition;
+ }
+
+ @Override
+ public int getPodIndex() {
+ return podIndex;
+ }
+
+ @Override
+ public boolean isBumper() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public double getMaxDuration() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public double getTimeOffset() {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+
+ @Override
+ public int getVastMediaWidth() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int getVastMediaHeight() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int getVastMediaBitrate() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isSkippable() {
+ return skippable;
+ }
+
+ @Override
+ public AdPodInfo getAdPodInfo() {
+ return adPodInfo;
+ }
+
+ @Override
+ public String getAdId() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getCreativeId() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getCreativeAdId() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getUniversalAdIdValue() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getUniversalAdIdRegistry() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getAdSystem() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String[] getAdWrapperIds() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String[] getAdWrapperSystems() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String[] getAdWrapperCreativeIds() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isLinear() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public double getSkipTimeOffset() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isUiDisabled() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getDescription() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getTitle() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getContentType() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getAdvertiserName() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getSurveyUrl() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getDealId() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int getWidth() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int getHeight() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getTraffickingParameters() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public double getDuration() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Set getUiElements() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List getCompanionAds() {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java
new file mode 100644
index 0000000000..a8f3daae33
--- /dev/null
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsLoader.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ima;
+
+import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener;
+import com.google.ads.interactivemedia.v3.api.AdsManager;
+import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent;
+import com.google.ads.interactivemedia.v3.api.AdsRequest;
+import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
+import com.google.ads.interactivemedia.v3.api.StreamManager;
+import com.google.ads.interactivemedia.v3.api.StreamRequest;
+import com.google.android.exoplayer2.util.Assertions;
+import java.util.ArrayList;
+
+/** Fake {@link com.google.ads.interactivemedia.v3.api.AdsLoader} implementation for tests. */
+public final class FakeAdsLoader implements com.google.ads.interactivemedia.v3.api.AdsLoader {
+
+ private final ImaSdkSettings imaSdkSettings;
+ private final AdsManager adsManager;
+ private final ArrayList adsLoadedListeners;
+ private final ArrayList adErrorListeners;
+
+ public FakeAdsLoader(ImaSdkSettings imaSdkSettings, AdsManager adsManager) {
+ this.imaSdkSettings = Assertions.checkNotNull(imaSdkSettings);
+ this.adsManager = Assertions.checkNotNull(adsManager);
+ adsLoadedListeners = new ArrayList<>();
+ adErrorListeners = new ArrayList<>();
+ }
+
+ @Override
+ public void contentComplete() {
+ // Do nothing.
+ }
+
+ @Override
+ public ImaSdkSettings getSettings() {
+ return imaSdkSettings;
+ }
+
+ @Override
+ public void requestAds(AdsRequest adsRequest) {
+ for (AdsLoadedListener listener : adsLoadedListeners) {
+ listener.onAdsManagerLoaded(
+ new AdsManagerLoadedEvent() {
+ @Override
+ public AdsManager getAdsManager() {
+ return adsManager;
+ }
+
+ @Override
+ public StreamManager getStreamManager() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Object getUserRequestContext() {
+ return adsRequest.getUserRequestContext();
+ }
+ });
+ }
+ }
+
+ @Override
+ public String requestStream(StreamRequest streamRequest) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void addAdsLoadedListener(AdsLoadedListener adsLoadedListener) {
+ adsLoadedListeners.add(adsLoadedListener);
+ }
+
+ @Override
+ public void removeAdsLoadedListener(AdsLoadedListener adsLoadedListener) {
+ adsLoadedListeners.remove(adsLoadedListener);
+ }
+
+ @Override
+ public void addAdErrorListener(AdErrorListener adErrorListener) {
+ adErrorListeners.add(adErrorListener);
+ }
+
+ @Override
+ public void removeAdErrorListener(AdErrorListener adErrorListener) {
+ adErrorListeners.remove(adErrorListener);
+ }
+}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java
new file mode 100644
index 0000000000..7c2c8a6e0b
--- /dev/null
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakeAdsRequest.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ima;
+
+import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
+import com.google.ads.interactivemedia.v3.api.AdsRequest;
+import com.google.ads.interactivemedia.v3.api.player.ContentProgressProvider;
+import java.util.List;
+import java.util.Map;
+
+/** Fake {@link AdsRequest} implementation for tests. */
+public final class FakeAdsRequest implements AdsRequest {
+
+ private String adTagUrl;
+ private String adsResponse;
+ private Object userRequestContext;
+ private AdDisplayContainer adDisplayContainer;
+ private ContentProgressProvider contentProgressProvider;
+
+ @Override
+ public void setAdTagUrl(String adTagUrl) {
+ this.adTagUrl = adTagUrl;
+ }
+
+ @Override
+ public String getAdTagUrl() {
+ return adTagUrl;
+ }
+
+ @Override
+ public void setExtraParameter(String s, String s1) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getExtraParameter(String s) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Map getExtraParameters() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setUserRequestContext(Object userRequestContext) {
+ this.userRequestContext = userRequestContext;
+ }
+
+ @Override
+ public Object getUserRequestContext() {
+ return userRequestContext;
+ }
+
+ @Override
+ public AdDisplayContainer getAdDisplayContainer() {
+ return adDisplayContainer;
+ }
+
+ @Override
+ public void setAdDisplayContainer(AdDisplayContainer adDisplayContainer) {
+ this.adDisplayContainer = adDisplayContainer;
+ }
+
+ @Override
+ public ContentProgressProvider getContentProgressProvider() {
+ return contentProgressProvider;
+ }
+
+ @Override
+ public void setContentProgressProvider(ContentProgressProvider contentProgressProvider) {
+ this.contentProgressProvider = contentProgressProvider;
+ }
+
+ @Override
+ public String getAdsResponse() {
+ return adsResponse;
+ }
+
+ @Override
+ public void setAdsResponse(String adsResponse) {
+ this.adsResponse = adsResponse;
+ }
+
+ @Override
+ public void setAdWillAutoPlay(boolean b) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setAdWillPlayMuted(boolean b) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setContentDuration(float v) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setContentKeywords(List list) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setContentTitle(String s) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setVastLoadTimeout(float v) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setLiveStreamPrefetchSeconds(float v) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java
new file mode 100644
index 0000000000..b89b23516c
--- /dev/null
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ima;
+
+import android.os.Looper;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.testutil.StubExoPlayer;
+import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import java.util.ArrayList;
+
+/** A fake player for testing content/ad playback. */
+/* package */ final class FakePlayer extends StubExoPlayer {
+
+ private final ArrayList listeners;
+ private final Timeline.Period period;
+ private final Timeline timeline;
+
+ @Player.State private int state;
+ private boolean playWhenReady;
+ private long position;
+ private long contentPosition;
+ private boolean isPlayingAd;
+ private int adGroupIndex;
+ private int adIndexInAdGroup;
+
+ public FakePlayer() {
+ listeners = new ArrayList<>();
+ period = new Timeline.Period();
+ state = Player.STATE_IDLE;
+ playWhenReady = true;
+ timeline = Timeline.EMPTY;
+ }
+
+ /** Sets the timeline on this fake player, which notifies listeners with the changed timeline. */
+ public void updateTimeline(Timeline timeline, @TimelineChangeReason int reason) {
+ for (Player.EventListener listener : listeners) {
+ listener.onTimelineChanged(timeline, reason);
+ }
+ }
+
+ /**
+ * Sets the state of this player as if it were playing content at the given {@code position}. If
+ * an ad is currently playing, this will trigger a position discontinuity.
+ */
+ public void setPlayingContentPosition(long position) {
+ boolean notify = isPlayingAd;
+ isPlayingAd = false;
+ adGroupIndex = C.INDEX_UNSET;
+ adIndexInAdGroup = C.INDEX_UNSET;
+ this.position = position;
+ contentPosition = position;
+ if (notify) {
+ for (Player.EventListener listener : listeners) {
+ listener.onPositionDiscontinuity(DISCONTINUITY_REASON_AD_INSERTION);
+ }
+ }
+ }
+
+ /**
+ * Sets the state of this player as if it were playing an ad with the given indices at the given
+ * {@code position}. If the player is playing a different ad or content, this will trigger a
+ * position discontinuity.
+ */
+ public void setPlayingAdPosition(
+ int adGroupIndex, int adIndexInAdGroup, long position, long contentPosition) {
+ boolean notify = !isPlayingAd || this.adIndexInAdGroup != adIndexInAdGroup;
+ isPlayingAd = true;
+ this.adGroupIndex = adGroupIndex;
+ this.adIndexInAdGroup = adIndexInAdGroup;
+ this.position = position;
+ this.contentPosition = contentPosition;
+ if (notify) {
+ for (Player.EventListener listener : listeners) {
+ listener.onPositionDiscontinuity(DISCONTINUITY_REASON_AD_INSERTION);
+ }
+ }
+ }
+
+ /** Sets the {@link Player.State} of this player. */
+ public void setState(@Player.State int state, boolean playWhenReady) {
+ boolean playWhenReadyChanged = this.playWhenReady != playWhenReady;
+ boolean playerStateChanged = this.state != state || playWhenReadyChanged;
+ this.state = state;
+ this.playWhenReady = playWhenReady;
+ if (playerStateChanged) {
+ for (Player.EventListener listener : listeners) {
+ listener.onPlayerStateChanged(playWhenReady, state);
+ if (playWhenReadyChanged) {
+ listener.onPlayWhenReadyChanged(
+ playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
+ }
+ }
+ }
+ }
+
+ // ExoPlayer methods. Other methods are unsupported.
+
+ @Override
+ public AudioComponent getAudioComponent() {
+ return null;
+ }
+
+ @Override
+ public Looper getApplicationLooper() {
+ return Looper.getMainLooper();
+ }
+
+ @Override
+ public void addListener(Player.EventListener listener) {
+ listeners.add(listener);
+ }
+
+ @Override
+ public void removeListener(Player.EventListener listener) {
+ listeners.remove(listener);
+ }
+
+ @Override
+ @Player.State
+ public int getPlaybackState() {
+ return state;
+ }
+
+ @Override
+ public boolean getPlayWhenReady() {
+ return playWhenReady;
+ }
+
+ @Override
+ public int getRendererCount() {
+ return 0;
+ }
+
+ @Override
+ public TrackSelectionArray getCurrentTrackSelections() {
+ return new TrackSelectionArray();
+ }
+
+ @Override
+ public Timeline getCurrentTimeline() {
+ return timeline;
+ }
+
+ @Override
+ public int getCurrentPeriodIndex() {
+ return 0;
+ }
+
+ @Override
+ public int getCurrentWindowIndex() {
+ return 0;
+ }
+
+ @Override
+ public long getDuration() {
+ if (timeline.isEmpty()) {
+ return C.INDEX_UNSET;
+ }
+ if (isPlayingAd()) {
+ long adDurationUs =
+ timeline.getPeriod(0, period).getAdDurationUs(adGroupIndex, adIndexInAdGroup);
+ return C.usToMs(adDurationUs);
+ } else {
+ return timeline.getWindow(getCurrentWindowIndex(), window).getDurationMs();
+ }
+ }
+
+ @Override
+ public long getCurrentPosition() {
+ return position;
+ }
+
+ @Override
+ public boolean isPlayingAd() {
+ return isPlayingAd;
+ }
+
+ @Override
+ public int getCurrentAdGroupIndex() {
+ return adGroupIndex;
+ }
+
+ @Override
+ public int getCurrentAdIndexInAdGroup() {
+ return adIndexInAdGroup;
+ }
+
+ @Override
+ public long getContentPosition() {
+ return contentPosition;
+ }
+}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
new file mode 100644
index 0000000000..1f8d692236
--- /dev/null
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java
@@ -0,0 +1,309 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ima;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.ads.interactivemedia.v3.api.Ad;
+import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
+import com.google.ads.interactivemedia.v3.api.AdEvent;
+import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventType;
+import com.google.ads.interactivemedia.v3.api.AdsManager;
+import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
+import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.SinglePeriodTimeline;
+import com.google.android.exoplayer2.source.ads.AdPlaybackState;
+import com.google.android.exoplayer2.source.ads.AdsLoader;
+import com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException;
+import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline;
+import com.google.android.exoplayer2.upstream.DataSpec;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/** Test for {@link ImaAdsLoader}. */
+@RunWith(AndroidJUnit4.class)
+public class ImaAdsLoaderTest {
+
+ private static final long CONTENT_DURATION_US = 10 * C.MICROS_PER_SECOND;
+ private static final Timeline CONTENT_TIMELINE =
+ new SinglePeriodTimeline(
+ CONTENT_DURATION_US, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false);
+ private static final Uri TEST_URI = Uri.EMPTY;
+ private static final long TEST_AD_DURATION_US = 5 * C.MICROS_PER_SECOND;
+ private static final long[][] PREROLL_ADS_DURATIONS_US = new long[][] {{TEST_AD_DURATION_US}};
+ private static final Float[] PREROLL_CUE_POINTS_SECONDS = new Float[] {0f};
+ private static final FakeAd UNSKIPPABLE_AD =
+ new FakeAd(/* skippable= */ false, /* podIndex= */ 0, /* totalAds= */ 1, /* adPosition= */ 1);
+
+ private @Mock ImaSdkSettings imaSdkSettings;
+ private @Mock AdsRenderingSettings adsRenderingSettings;
+ private @Mock AdDisplayContainer adDisplayContainer;
+ private @Mock AdsManager adsManager;
+ private SingletonImaFactory testImaFactory;
+ private ViewGroup adViewGroup;
+ private View adOverlayView;
+ private AdsLoader.AdViewProvider adViewProvider;
+ private TestAdsLoaderListener adsLoaderListener;
+ private FakePlayer fakeExoPlayer;
+ private ImaAdsLoader imaAdsLoader;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ FakeAdsRequest fakeAdsRequest = new FakeAdsRequest();
+ FakeAdsLoader fakeAdsLoader = new FakeAdsLoader(imaSdkSettings, adsManager);
+ testImaFactory =
+ new SingletonImaFactory(
+ imaSdkSettings,
+ adsRenderingSettings,
+ adDisplayContainer,
+ fakeAdsRequest,
+ fakeAdsLoader);
+ adViewGroup = new FrameLayout(ApplicationProvider.getApplicationContext());
+ adOverlayView = new View(ApplicationProvider.getApplicationContext());
+ adViewProvider =
+ new AdsLoader.AdViewProvider() {
+ @Override
+ public ViewGroup getAdViewGroup() {
+ return adViewGroup;
+ }
+
+ @Override
+ public View[] getAdOverlayViews() {
+ return new View[] {adOverlayView};
+ }
+ };
+ }
+
+ @After
+ public void teardown() {
+ if (imaAdsLoader != null) {
+ imaAdsLoader.release();
+ }
+ }
+
+ @Test
+ public void testBuilder_overridesPlayerType() {
+ when(imaSdkSettings.getPlayerType()).thenReturn("test player type");
+ setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+
+ verify(imaSdkSettings).setPlayerType("google/exo.ext.ima");
+ }
+
+ @Test
+ public void testStart_setsAdUiViewGroup() {
+ setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ verify(adDisplayContainer, atLeastOnce()).setAdContainer(adViewGroup);
+ verify(adDisplayContainer, atLeastOnce()).registerVideoControlsOverlay(adOverlayView);
+ }
+
+ @Test
+ public void testStart_updatesAdPlaybackState() {
+ setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ new AdPlaybackState(/* adGroupTimesUs...= */ 0)
+ .withAdDurationsUs(PREROLL_ADS_DURATIONS_US)
+ .withContentDurationUs(CONTENT_DURATION_US));
+ }
+
+ @Test
+ public void testStartAfterRelease() {
+ setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ imaAdsLoader.release();
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ }
+
+ @Test
+ public void testStartAndCallbacksAfterRelease() {
+ setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ imaAdsLoader.release();
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ fakeExoPlayer.setPlayingContentPosition(/* position= */ 0);
+ fakeExoPlayer.setState(Player.STATE_READY, true);
+
+ // If callbacks are invoked there is no crash.
+ // Note: we can't currently call getContentProgress/getAdProgress as a VerifyError is thrown
+ // when using Robolectric and accessing VideoProgressUpdate.VIDEO_TIME_NOT_READY, due to the IMA
+ // SDK being proguarded.
+ imaAdsLoader.requestAds(adViewGroup);
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD));
+ imaAdsLoader.loadAd(TEST_URI.toString());
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD));
+ imaAdsLoader.playAd();
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD));
+ imaAdsLoader.pauseAd();
+ imaAdsLoader.stopAd();
+ imaAdsLoader.onPlayerError(ExoPlaybackException.createForSource(new IOException()));
+ imaAdsLoader.onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK);
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null));
+ imaAdsLoader.handlePrepareError(
+ /* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, new IOException());
+ }
+
+ @Test
+ public void testPlayback_withPrerollAd_marksAdAsPlayed() {
+ setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+
+ // Load the preroll ad.
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.LOADED, UNSKIPPABLE_AD));
+ imaAdsLoader.loadAd(TEST_URI.toString());
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_PAUSE_REQUESTED, UNSKIPPABLE_AD));
+
+ // Play the preroll ad.
+ imaAdsLoader.playAd();
+ fakeExoPlayer.setPlayingAdPosition(
+ /* adGroupIndex= */ 0,
+ /* adIndexInAdGroup= */ 0,
+ /* position= */ 0,
+ /* contentPosition= */ 0);
+ fakeExoPlayer.setState(Player.STATE_READY, true);
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.STARTED, UNSKIPPABLE_AD));
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.FIRST_QUARTILE, UNSKIPPABLE_AD));
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.MIDPOINT, UNSKIPPABLE_AD));
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.THIRD_QUARTILE, UNSKIPPABLE_AD));
+
+ // Play the content.
+ fakeExoPlayer.setPlayingContentPosition(0);
+ imaAdsLoader.stopAd();
+ imaAdsLoader.onAdEvent(getAdEvent(AdEventType.CONTENT_RESUME_REQUESTED, /* ad= */ null));
+
+ // Verify that the preroll ad has been marked as played.
+ assertThat(adsLoaderListener.adPlaybackState)
+ .isEqualTo(
+ new AdPlaybackState(/* adGroupTimesUs...= */ 0)
+ .withContentDurationUs(CONTENT_DURATION_US)
+ .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
+ .withAdUri(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, /* uri= */ TEST_URI)
+ .withAdDurationsUs(PREROLL_ADS_DURATIONS_US)
+ .withPlayedAd(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0)
+ .withAdResumePositionUs(/* adResumePositionUs= */ 0));
+ }
+
+ @Test
+ public void testStop_unregistersAllVideoControlOverlays() {
+ setupPlayback(CONTENT_TIMELINE, PREROLL_ADS_DURATIONS_US, PREROLL_CUE_POINTS_SECONDS);
+ imaAdsLoader.start(adsLoaderListener, adViewProvider);
+ imaAdsLoader.requestAds(adViewGroup);
+ imaAdsLoader.stop();
+
+ InOrder inOrder = inOrder(adDisplayContainer);
+ inOrder.verify(adDisplayContainer).registerVideoControlsOverlay(adOverlayView);
+ inOrder.verify(adDisplayContainer).unregisterAllVideoControlsOverlays();
+ }
+
+ private void setupPlayback(Timeline contentTimeline, long[][] adDurationsUs, Float[] cuePoints) {
+ fakeExoPlayer = new FakePlayer();
+ adsLoaderListener = new TestAdsLoaderListener(fakeExoPlayer, contentTimeline, adDurationsUs);
+ when(adsManager.getAdCuePoints()).thenReturn(Arrays.asList(cuePoints));
+ imaAdsLoader =
+ new ImaAdsLoader.Builder(ApplicationProvider.getApplicationContext())
+ .setImaFactory(testImaFactory)
+ .setImaSdkSettings(imaSdkSettings)
+ .buildForAdTag(TEST_URI);
+ imaAdsLoader.setPlayer(fakeExoPlayer);
+ }
+
+ private static AdEvent getAdEvent(AdEventType adEventType, @Nullable Ad ad) {
+ return new AdEvent() {
+ @Override
+ public AdEventType getType() {
+ return adEventType;
+ }
+
+ @Override
+ @Nullable
+ public Ad getAd() {
+ return ad;
+ }
+
+ @Override
+ public Map getAdData() {
+ return Collections.emptyMap();
+ }
+ };
+ }
+
+ /** Ad loader event listener that forwards ad playback state to a fake player. */
+ private static final class TestAdsLoaderListener implements AdsLoader.EventListener {
+
+ private final FakePlayer fakeExoPlayer;
+ private final Timeline contentTimeline;
+ private final long[][] adDurationsUs;
+
+ public AdPlaybackState adPlaybackState;
+
+ public TestAdsLoaderListener(
+ FakePlayer fakeExoPlayer, Timeline contentTimeline, long[][] adDurationsUs) {
+ this.fakeExoPlayer = fakeExoPlayer;
+ this.contentTimeline = contentTimeline;
+ this.adDurationsUs = adDurationsUs;
+ }
+
+ @Override
+ public void onAdPlaybackState(AdPlaybackState adPlaybackState) {
+ adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs);
+ this.adPlaybackState = adPlaybackState;
+ fakeExoPlayer.updateTimeline(
+ new SinglePeriodAdTimeline(contentTimeline, adPlaybackState),
+ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE);
+ }
+
+ @Override
+ public void onAdLoadError(AdLoadException error, DataSpec dataSpec) {
+ assertThat(error.type).isNotEqualTo(AdLoadException.TYPE_UNEXPECTED);
+ }
+
+ @Override
+ public void onAdClicked() {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAdTapped() {
+ // Do nothing.
+ }
+ }
+}
diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java
new file mode 100644
index 0000000000..4efd8cf38c
--- /dev/null
+++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/SingletonImaFactory.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2.ext.ima;
+
+import android.content.Context;
+import com.google.ads.interactivemedia.v3.api.AdDisplayContainer;
+import com.google.ads.interactivemedia.v3.api.AdsLoader;
+import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings;
+import com.google.ads.interactivemedia.v3.api.AdsRequest;
+import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
+
+/** {@link ImaAdsLoader.ImaFactory} that returns provided instances from each getter, for tests. */
+final class SingletonImaFactory implements ImaAdsLoader.ImaFactory {
+
+ private final ImaSdkSettings imaSdkSettings;
+ private final AdsRenderingSettings adsRenderingSettings;
+ private final AdDisplayContainer adDisplayContainer;
+ private final AdsRequest adsRequest;
+ private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader;
+
+ public SingletonImaFactory(
+ ImaSdkSettings imaSdkSettings,
+ AdsRenderingSettings adsRenderingSettings,
+ AdDisplayContainer adDisplayContainer,
+ AdsRequest adsRequest,
+ com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) {
+ this.imaSdkSettings = imaSdkSettings;
+ this.adsRenderingSettings = adsRenderingSettings;
+ this.adDisplayContainer = adDisplayContainer;
+ this.adsRequest = adsRequest;
+ this.adsLoader = adsLoader;
+ }
+
+ @Override
+ public ImaSdkSettings createImaSdkSettings() {
+ return imaSdkSettings;
+ }
+
+ @Override
+ public AdsRenderingSettings createAdsRenderingSettings() {
+ return adsRenderingSettings;
+ }
+
+ @Override
+ public AdDisplayContainer createAdDisplayContainer() {
+ return adDisplayContainer;
+ }
+
+ @Override
+ public AdsRequest createAdsRequest() {
+ return adsRequest;
+ }
+
+ @Override
+ public AdsLoader createAdsLoader(
+ Context context, ImaSdkSettings imaSdkSettings, AdDisplayContainer adDisplayContainer) {
+ return adsLoader;
+ }
+}
diff --git a/extensions/jobdispatcher/README.md b/extensions/jobdispatcher/README.md
new file mode 100644
index 0000000000..613277bad2
--- /dev/null
+++ b/extensions/jobdispatcher/README.md
@@ -0,0 +1,27 @@
+# ExoPlayer Firebase JobDispatcher extension #
+
+**DEPRECATED - Please use [WorkManager extension][] or [PlatformScheduler][]
+instead.**
+
+This extension provides a Scheduler implementation which uses [Firebase JobDispatcher][].
+
+[WorkManager extension]: https://github.com/google/ExoPlayer/blob/release-v2/extensions/workmanager/README.md
+[PlatformScheduler]: https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java
+[Firebase JobDispatcher]: https://github.com/firebase/firebase-jobdispatcher-android
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-jobdispatcher:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
diff --git a/extensions/jobdispatcher/build.gradle b/extensions/jobdispatcher/build.gradle
new file mode 100644
index 0000000000..d7f19d2545
--- /dev/null
+++ b/extensions/jobdispatcher/build.gradle
@@ -0,0 +1,49 @@
+/*
+ * 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.
+ */
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ minSdkVersion project.ext.minSdkVersion
+ targetSdkVersion project.ext.targetSdkVersion
+ }
+
+ testOptions.unitTests.includeAndroidResources = true
+}
+
+dependencies {
+ implementation project(modulePrefix + 'library-core')
+ implementation 'com.firebase:firebase-jobdispatcher:0.8.5'
+}
+
+ext {
+ javadocTitle = 'Firebase JobDispatcher extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-jobdispatcher'
+ releaseDescription = 'Firebase JobDispatcher extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/jobdispatcher/src/main/AndroidManifest.xml b/extensions/jobdispatcher/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..306a087e6c
--- /dev/null
+++ b/extensions/jobdispatcher/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java
new file mode 100644
index 0000000000..8841f8355f
--- /dev/null
+++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/JobDispatcherScheduler.java
@@ -0,0 +1,169 @@
+/*
+ * 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.jobdispatcher;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import com.firebase.jobdispatcher.Constraint;
+import com.firebase.jobdispatcher.FirebaseJobDispatcher;
+import com.firebase.jobdispatcher.GooglePlayDriver;
+import com.firebase.jobdispatcher.Job;
+import com.firebase.jobdispatcher.JobParameters;
+import com.firebase.jobdispatcher.JobService;
+import com.firebase.jobdispatcher.Lifetime;
+import com.google.android.exoplayer2.scheduler.Requirements;
+import com.google.android.exoplayer2.scheduler.Scheduler;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Log;
+import com.google.android.exoplayer2.util.Util;
+
+/**
+ * A {@link Scheduler} that uses {@link FirebaseJobDispatcher}. To use this scheduler, you must add
+ * {@link JobDispatcherSchedulerService} to your manifest:
+ *
+ * {@literal
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * }
+ *
+ * This Scheduler uses Google Play services but does not do any availability checks. Any uses
+ * should be guarded with a call to {@code
+ * GoogleApiAvailability#isGooglePlayServicesAvailable(android.content.Context)}
+ *
+ * @see GoogleApiAvailability
+ * @deprecated Use com.google.android.exoplayer2.ext.workmanager.WorkManagerScheduler or {@link
+ * com.google.android.exoplayer2.scheduler.PlatformScheduler}.
+ */
+@Deprecated
+public final class JobDispatcherScheduler implements Scheduler {
+
+ private static final boolean DEBUG = false;
+ private static final String TAG = "JobDispatcherScheduler";
+ private static final String KEY_SERVICE_ACTION = "service_action";
+ private static final String KEY_SERVICE_PACKAGE = "service_package";
+ private static final String KEY_REQUIREMENTS = "requirements";
+
+ private final String jobTag;
+ private final FirebaseJobDispatcher jobDispatcher;
+
+ /**
+ * @param context A context.
+ * @param jobTag A tag for jobs scheduled by this instance. If the same tag was used by a previous
+ * instance, anything scheduled by the previous instance will be canceled by this instance if
+ * {@link #schedule(Requirements, String, String)} or {@link #cancel()} are called.
+ */
+ public JobDispatcherScheduler(Context context, String jobTag) {
+ context = context.getApplicationContext();
+ this.jobDispatcher = new FirebaseJobDispatcher(new GooglePlayDriver(context));
+ this.jobTag = jobTag;
+ }
+
+ @Override
+ public boolean schedule(Requirements requirements, String servicePackage, String serviceAction) {
+ Job job = buildJob(jobDispatcher, requirements, jobTag, servicePackage, serviceAction);
+ int result = jobDispatcher.schedule(job);
+ logd("Scheduling job: " + jobTag + " result: " + result);
+ return result == FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS;
+ }
+
+ @Override
+ public boolean cancel() {
+ int result = jobDispatcher.cancel(jobTag);
+ logd("Canceling job: " + jobTag + " result: " + result);
+ return result == FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS;
+ }
+
+ private static Job buildJob(
+ FirebaseJobDispatcher dispatcher,
+ Requirements requirements,
+ String tag,
+ String servicePackage,
+ String serviceAction) {
+ Job.Builder builder =
+ dispatcher
+ .newJobBuilder()
+ .setService(JobDispatcherSchedulerService.class) // the JobService that will be called
+ .setTag(tag);
+
+ if (requirements.isUnmeteredNetworkRequired()) {
+ builder.addConstraint(Constraint.ON_UNMETERED_NETWORK);
+ } else if (requirements.isNetworkRequired()) {
+ builder.addConstraint(Constraint.ON_ANY_NETWORK);
+ }
+
+ if (requirements.isIdleRequired()) {
+ builder.addConstraint(Constraint.DEVICE_IDLE);
+ }
+ if (requirements.isChargingRequired()) {
+ builder.addConstraint(Constraint.DEVICE_CHARGING);
+ }
+ builder.setLifetime(Lifetime.FOREVER).setReplaceCurrent(true);
+
+ Bundle extras = new Bundle();
+ extras.putString(KEY_SERVICE_ACTION, serviceAction);
+ extras.putString(KEY_SERVICE_PACKAGE, servicePackage);
+ extras.putInt(KEY_REQUIREMENTS, requirements.getRequirements());
+ builder.setExtras(extras);
+
+ return builder.build();
+ }
+
+ private static void logd(String message) {
+ if (DEBUG) {
+ Log.d(TAG, message);
+ }
+ }
+
+ /** A {@link JobService} that starts the target service if the requirements are met. */
+ public static final class JobDispatcherSchedulerService extends JobService {
+ @Override
+ public boolean onStartJob(JobParameters params) {
+ logd("JobDispatcherSchedulerService is started");
+ Bundle extras = params.getExtras();
+ Assertions.checkNotNull(extras, "Service started without extras.");
+ Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS));
+ if (requirements.checkRequirements(this)) {
+ logd("Requirements are met");
+ String serviceAction = extras.getString(KEY_SERVICE_ACTION);
+ String servicePackage = extras.getString(KEY_SERVICE_PACKAGE);
+ Assertions.checkNotNull(serviceAction, "Service action missing.");
+ Assertions.checkNotNull(servicePackage, "Service package missing.");
+ Intent intent = new Intent(serviceAction).setPackage(servicePackage);
+ logd("Starting service action: " + serviceAction + " package: " + servicePackage);
+ Util.startForegroundService(this, intent);
+ } else {
+ logd("Requirements are not met");
+ jobFinished(params, /* needsReschedule */ true);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters params) {
+ return false;
+ }
+ }
+}
diff --git a/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/package-info.java b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/package-info.java
new file mode 100644
index 0000000000..a66904b505
--- /dev/null
+++ b/extensions/jobdispatcher/src/main/java/com/google/android/exoplayer2/ext/jobdispatcher/package-info.java
@@ -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.jobdispatcher;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/extensions/leanback/README.md b/extensions/leanback/README.md
new file mode 100644
index 0000000000..b6eb085247
--- /dev/null
+++ b/extensions/leanback/README.md
@@ -0,0 +1,31 @@
+# ExoPlayer Leanback extension #
+
+This [Leanback][] Extension provides a [PlayerAdapter][] implementation for
+ExoPlayer.
+
+[PlayerAdapter]: https://developer.android.com/reference/android/support/v17/leanback/media/PlayerAdapter.html
+[Leanback]: https://developer.android.com/reference/android/support/v17/leanback/package-summary.html
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-leanback:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Links ##
+
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.leanback.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/leanback/build.gradle b/extensions/leanback/build.gradle
new file mode 100644
index 0000000000..f0be172c90
--- /dev/null
+++ b/extensions/leanback/build.gradle
@@ -0,0 +1,48 @@
+// 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.
+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 17
+ targetSdkVersion project.ext.targetSdkVersion
+ }
+
+ testOptions.unitTests.includeAndroidResources = true
+}
+
+dependencies {
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ implementation 'androidx.leanback:leanback:1.0.0'
+}
+
+ext {
+ javadocTitle = 'Leanback extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-leanback'
+ releaseDescription = 'Leanback extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/leanback/src/main/AndroidManifest.xml b/extensions/leanback/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..20cc9bf285
--- /dev/null
+++ b/extensions/leanback/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
diff --git a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java
new file mode 100644
index 0000000000..7c2285c57e
--- /dev/null
+++ b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/LeanbackPlayerAdapter.java
@@ -0,0 +1,325 @@
+/*
+ * 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.ext.leanback;
+
+import android.content.Context;
+import android.os.Handler;
+import android.util.Pair;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import androidx.annotation.Nullable;
+import androidx.leanback.R;
+import androidx.leanback.media.PlaybackGlueHost;
+import androidx.leanback.media.PlayerAdapter;
+import androidx.leanback.media.SurfaceHolderGlueHost;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.DefaultControlDispatcher;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.PlaybackPreparer;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Player.DiscontinuityReason;
+import com.google.android.exoplayer2.Player.TimelineChangeReason;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.util.ErrorMessageProvider;
+import com.google.android.exoplayer2.util.Util;
+import com.google.android.exoplayer2.video.VideoListener;
+
+/** Leanback {@code PlayerAdapter} implementation for {@link Player}. */
+public final class LeanbackPlayerAdapter extends PlayerAdapter implements Runnable {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.leanback");
+ }
+
+ private final Context context;
+ private final Player player;
+ private final Handler handler;
+ private final ComponentListener componentListener;
+ private final int updatePeriodMs;
+
+ @Nullable private PlaybackPreparer playbackPreparer;
+ private ControlDispatcher controlDispatcher;
+ @Nullable private ErrorMessageProvider super ExoPlaybackException> errorMessageProvider;
+ @Nullable private SurfaceHolderGlueHost surfaceHolderGlueHost;
+ private boolean hasSurface;
+ private boolean lastNotifiedPreparedState;
+
+ /**
+ * Builds an instance. Note that the {@code PlayerAdapter} does not manage the lifecycle of the
+ * {@link Player} instance. The caller remains responsible for releasing the player when it's no
+ * longer required.
+ *
+ * @param context The current context (activity).
+ * @param player Instance of your exoplayer that needs to be configured.
+ * @param updatePeriodMs The delay between player control updates, in milliseconds.
+ */
+ public LeanbackPlayerAdapter(Context context, Player player, final int updatePeriodMs) {
+ this.context = context;
+ this.player = player;
+ this.updatePeriodMs = updatePeriodMs;
+ handler = Util.createHandler();
+ componentListener = new ComponentListener();
+ controlDispatcher = new DefaultControlDispatcher();
+ }
+
+ /**
+ * Sets the {@link PlaybackPreparer}.
+ *
+ * @param playbackPreparer The {@link PlaybackPreparer}.
+ */
+ public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) {
+ this.playbackPreparer = playbackPreparer;
+ }
+
+ /**
+ * Sets the {@link ControlDispatcher}.
+ *
+ * @param controlDispatcher The {@link ControlDispatcher}, or null to use
+ * {@link DefaultControlDispatcher}.
+ */
+ public void setControlDispatcher(@Nullable ControlDispatcher controlDispatcher) {
+ this.controlDispatcher = controlDispatcher == null ? new DefaultControlDispatcher()
+ : controlDispatcher;
+ }
+
+ /**
+ * Sets the optional {@link ErrorMessageProvider}.
+ *
+ * @param errorMessageProvider The {@link ErrorMessageProvider}.
+ */
+ public void setErrorMessageProvider(
+ @Nullable ErrorMessageProvider super ExoPlaybackException> errorMessageProvider) {
+ this.errorMessageProvider = errorMessageProvider;
+ }
+
+ // PlayerAdapter implementation.
+
+ @Override
+ public void onAttachedToHost(PlaybackGlueHost host) {
+ if (host instanceof SurfaceHolderGlueHost) {
+ surfaceHolderGlueHost = ((SurfaceHolderGlueHost) host);
+ surfaceHolderGlueHost.setSurfaceHolderCallback(componentListener);
+ }
+ notifyStateChanged();
+ player.addListener(componentListener);
+ Player.VideoComponent videoComponent = player.getVideoComponent();
+ if (videoComponent != null) {
+ videoComponent.addVideoListener(componentListener);
+ }
+ }
+
+ @Override
+ public void onDetachedFromHost() {
+ player.removeListener(componentListener);
+ Player.VideoComponent videoComponent = player.getVideoComponent();
+ if (videoComponent != null) {
+ videoComponent.removeVideoListener(componentListener);
+ }
+ if (surfaceHolderGlueHost != null) {
+ removeSurfaceHolderCallback(surfaceHolderGlueHost);
+ surfaceHolderGlueHost = null;
+ }
+ hasSurface = false;
+ Callback callback = getCallback();
+ callback.onBufferingStateChanged(this, false);
+ callback.onPlayStateChanged(this);
+ maybeNotifyPreparedStateChanged(callback);
+ }
+
+ @Override
+ public void setProgressUpdatingEnabled(boolean enabled) {
+ handler.removeCallbacks(this);
+ if (enabled) {
+ handler.post(this);
+ }
+ }
+
+ @Override
+ public boolean isPlaying() {
+ int playbackState = player.getPlaybackState();
+ return playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED
+ && player.getPlayWhenReady();
+ }
+
+ @Override
+ public long getDuration() {
+ long durationMs = player.getDuration();
+ return durationMs == C.TIME_UNSET ? -1 : durationMs;
+ }
+
+ @Override
+ public long getCurrentPosition() {
+ return player.getPlaybackState() == Player.STATE_IDLE ? -1 : player.getCurrentPosition();
+ }
+
+ @Override
+ public void play() {
+ if (player.getPlaybackState() == Player.STATE_IDLE) {
+ if (playbackPreparer != null) {
+ playbackPreparer.preparePlayback();
+ }
+ } else if (player.getPlaybackState() == Player.STATE_ENDED) {
+ controlDispatcher.dispatchSeekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET);
+ }
+ if (controlDispatcher.dispatchSetPlayWhenReady(player, true)) {
+ getCallback().onPlayStateChanged(this);
+ }
+ }
+
+ @Override
+ public void pause() {
+ if (controlDispatcher.dispatchSetPlayWhenReady(player, false)) {
+ getCallback().onPlayStateChanged(this);
+ }
+ }
+
+ @Override
+ public void seekTo(long positionMs) {
+ controlDispatcher.dispatchSeekTo(player, player.getCurrentWindowIndex(), positionMs);
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ return player.getBufferedPosition();
+ }
+
+ @Override
+ public boolean isPrepared() {
+ return player.getPlaybackState() != Player.STATE_IDLE
+ && (surfaceHolderGlueHost == null || hasSurface);
+ }
+
+ // Runnable implementation.
+
+ @Override
+ public void run() {
+ Callback callback = getCallback();
+ callback.onCurrentPositionChanged(this);
+ callback.onBufferedPositionChanged(this);
+ handler.postDelayed(this, updatePeriodMs);
+ }
+
+ // Internal methods.
+
+ /* package */ void setVideoSurface(@Nullable Surface surface) {
+ hasSurface = surface != null;
+ Player.VideoComponent videoComponent = player.getVideoComponent();
+ if (videoComponent != null) {
+ videoComponent.setVideoSurface(surface);
+ }
+ maybeNotifyPreparedStateChanged(getCallback());
+ }
+
+ /* package */ void notifyStateChanged() {
+ int playbackState = player.getPlaybackState();
+ Callback callback = getCallback();
+ maybeNotifyPreparedStateChanged(callback);
+ callback.onPlayStateChanged(this);
+ callback.onBufferingStateChanged(this, playbackState == Player.STATE_BUFFERING);
+ if (playbackState == Player.STATE_ENDED) {
+ callback.onPlayCompleted(this);
+ }
+ }
+
+ private void maybeNotifyPreparedStateChanged(Callback callback) {
+ boolean isPrepared = isPrepared();
+ if (lastNotifiedPreparedState != isPrepared) {
+ lastNotifiedPreparedState = isPrepared;
+ callback.onPreparedStateChanged(this);
+ }
+ }
+
+ @SuppressWarnings("nullness:argument.type.incompatible")
+ private static void removeSurfaceHolderCallback(SurfaceHolderGlueHost surfaceHolderGlueHost) {
+ surfaceHolderGlueHost.setSurfaceHolderCallback(null);
+ }
+
+ private final class ComponentListener
+ implements Player.EventListener, SurfaceHolder.Callback, VideoListener {
+
+ // SurfaceHolder.Callback implementation.
+
+ @Override
+ public void surfaceCreated(SurfaceHolder surfaceHolder) {
+ setVideoSurface(surfaceHolder.getSurface());
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height) {
+ // Do nothing.
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
+ setVideoSurface(null);
+ }
+
+ // Player.EventListener implementation.
+
+ @Override
+ public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
+ notifyStateChanged();
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException exception) {
+ Callback callback = getCallback();
+ if (errorMessageProvider != null) {
+ Pair errorMessage = errorMessageProvider.getErrorMessage(exception);
+ callback.onError(LeanbackPlayerAdapter.this, errorMessage.first, errorMessage.second);
+ } else {
+ callback.onError(LeanbackPlayerAdapter.this, exception.type, context.getString(
+ R.string.lb_media_player_error, exception.type, exception.rendererIndex));
+ }
+ }
+
+ @Override
+ public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) {
+ Callback callback = getCallback();
+ callback.onDurationChanged(LeanbackPlayerAdapter.this);
+ callback.onCurrentPositionChanged(LeanbackPlayerAdapter.this);
+ callback.onBufferedPositionChanged(LeanbackPlayerAdapter.this);
+ }
+
+ @Override
+ public void onPositionDiscontinuity(@DiscontinuityReason int reason) {
+ Callback callback = getCallback();
+ callback.onCurrentPositionChanged(LeanbackPlayerAdapter.this);
+ callback.onBufferedPositionChanged(LeanbackPlayerAdapter.this);
+ }
+
+ // VideoListener implementation.
+
+ @Override
+ public void onVideoSizeChanged(
+ int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {
+ // There's no way to pass pixelWidthHeightRatio to leanback, so we scale the width that we
+ // pass to take it into account. This is necessary to ensure that leanback uses the correct
+ // aspect ratio when playing content with non-square pixels.
+ int scaledWidth = Math.round(width * pixelWidthHeightRatio);
+ getCallback().onVideoSizeChanged(LeanbackPlayerAdapter.this, scaledWidth, height);
+ }
+
+ @Override
+ public void onRenderedFirstFrame() {
+ // Do nothing.
+ }
+
+ }
+
+}
diff --git a/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/package-info.java b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/package-info.java
new file mode 100644
index 0000000000..79c544fc0f
--- /dev/null
+++ b/extensions/leanback/src/main/java/com/google/android/exoplayer2/ext/leanback/package-info.java
@@ -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.leanback;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/extensions/mediasession/README.md b/extensions/mediasession/README.md
new file mode 100644
index 0000000000..64b55a8036
--- /dev/null
+++ b/extensions/mediasession/README.md
@@ -0,0 +1,32 @@
+# ExoPlayer MediaSession extension #
+
+The MediaSession extension mediates between a Player (or ExoPlayer) instance
+and a [MediaSession][]. It automatically retrieves and implements playback
+actions and syncs the player state with the state of the media session. The
+behaviour can be extended to support other playback and custom actions.
+
+[MediaSession]: https://developer.android.com/reference/android/support/v4/media/session/MediaSessionCompat.html
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-mediasession:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
+## Links ##
+
+* [Javadoc][]: Classes matching
+ `com.google.android.exoplayer2.ext.mediasession.*` belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/mediasession/build.gradle b/extensions/mediasession/build.gradle
new file mode 100644
index 0000000000..537c5ba534
--- /dev/null
+++ b/extensions/mediasession/build.gradle
@@ -0,0 +1,48 @@
+// 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.
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ minSdkVersion project.ext.minSdkVersion
+ targetSdkVersion project.ext.targetSdkVersion
+ }
+
+ testOptions.unitTests.includeAndroidResources = true
+}
+
+dependencies {
+ implementation project(modulePrefix + 'library-core')
+ api 'androidx.media:media:' + androidxMediaVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+}
+
+ext {
+ javadocTitle = 'Media session extension'
+}
+apply from: '../../javadoc_library.gradle'
+
+ext {
+ releaseArtifact = 'extension-mediasession'
+ releaseDescription = 'Media session extension for ExoPlayer.'
+}
+apply from: '../../publish.gradle'
diff --git a/extensions/mediasession/src/main/AndroidManifest.xml b/extensions/mediasession/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..8ed6ef2011
--- /dev/null
+++ b/extensions/mediasession/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
new file mode 100644
index 0000000000..b06db715e9
--- /dev/null
+++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java
@@ -0,0 +1,1412 @@
+/*
+ * 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.ext.mediasession;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.ResultReceiver;
+import android.os.SystemClock;
+import android.support.v4.media.MediaDescriptionCompat;
+import android.support.v4.media.MediaMetadataCompat;
+import android.support.v4.media.RatingCompat;
+import android.support.v4.media.session.MediaControllerCompat;
+import android.support.v4.media.session.MediaSessionCompat;
+import android.support.v4.media.session.PlaybackStateCompat;
+import android.util.Pair;
+import android.view.KeyEvent;
+import androidx.annotation.LongDef;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.DefaultControlDispatcher;
+import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.PlaybackParameters;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.ErrorMessageProvider;
+import com.google.android.exoplayer2.util.RepeatModeUtil;
+import com.google.android.exoplayer2.util.Util;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
+
+/**
+ * Connects a {@link MediaSessionCompat} to a {@link Player}.
+ *
+ * This connector does not call {@link MediaSessionCompat#setActive(boolean)}, and so
+ * application code is responsible for making the session active when desired. A session must be
+ * active for transport controls to be displayed (e.g. on the lock screen) and for it to receive
+ * media button events.
+ *
+ *
The connector listens for actions sent by the media session's controller and implements these
+ * actions by calling appropriate player methods. The playback state of the media session is
+ * automatically synced with the player. The connector can also be optionally extended by providing
+ * various collaborators:
+ *
+ *
+ * Actions to initiate media playback ({@code PlaybackStateCompat#ACTION_PREPARE_*} and {@code
+ * PlaybackStateCompat#ACTION_PLAY_*}) can be handled by a {@link PlaybackPreparer} passed to
+ * {@link #setPlaybackPreparer(PlaybackPreparer)}.
+ * Custom actions can be handled by passing one or more {@link CustomActionProvider}s to
+ * {@link #setCustomActionProviders(CustomActionProvider...)}.
+ * To enable a media queue and navigation within it, you can set a {@link QueueNavigator} by
+ * calling {@link #setQueueNavigator(QueueNavigator)}. Use of {@link TimelineQueueNavigator}
+ * is recommended for most use cases.
+ * To enable editing of the media queue, you can set a {@link QueueEditor} by calling {@link
+ * #setQueueEditor(QueueEditor)}.
+ * A {@link MediaButtonEventHandler} can be set by calling {@link
+ * #setMediaButtonEventHandler(MediaButtonEventHandler)}. By default media button events are
+ * handled by {@link MediaSessionCompat.Callback#onMediaButtonEvent(Intent)}.
+ * An {@link ErrorMessageProvider} for providing human readable error messages and
+ * corresponding error codes can be set by calling {@link
+ * #setErrorMessageProvider(ErrorMessageProvider)}.
+ * A {@link MediaMetadataProvider} can be set by calling {@link
+ * #setMediaMetadataProvider(MediaMetadataProvider)}. By default the {@link
+ * DefaultMediaMetadataProvider} is used.
+ *
+ */
+public final class MediaSessionConnector {
+
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.mediasession");
+ }
+
+ /** Playback actions supported by the connector. */
+ @LongDef(
+ flag = true,
+ value = {
+ PlaybackStateCompat.ACTION_PLAY_PAUSE,
+ PlaybackStateCompat.ACTION_PLAY,
+ PlaybackStateCompat.ACTION_PAUSE,
+ PlaybackStateCompat.ACTION_SEEK_TO,
+ PlaybackStateCompat.ACTION_FAST_FORWARD,
+ PlaybackStateCompat.ACTION_REWIND,
+ PlaybackStateCompat.ACTION_STOP,
+ PlaybackStateCompat.ACTION_SET_REPEAT_MODE,
+ PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface PlaybackActions {}
+
+ @PlaybackActions
+ public static final long ALL_PLAYBACK_ACTIONS =
+ PlaybackStateCompat.ACTION_PLAY_PAUSE
+ | PlaybackStateCompat.ACTION_PLAY
+ | PlaybackStateCompat.ACTION_PAUSE
+ | PlaybackStateCompat.ACTION_SEEK_TO
+ | PlaybackStateCompat.ACTION_FAST_FORWARD
+ | PlaybackStateCompat.ACTION_REWIND
+ | PlaybackStateCompat.ACTION_STOP
+ | PlaybackStateCompat.ACTION_SET_REPEAT_MODE
+ | PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE;
+
+ /** The default playback actions. */
+ @PlaybackActions public static final long DEFAULT_PLAYBACK_ACTIONS = ALL_PLAYBACK_ACTIONS;
+
+ /** The default fast forward increment, in milliseconds. */
+ public static final int DEFAULT_FAST_FORWARD_MS = 15000;
+ /** The default rewind increment, in milliseconds. */
+ public static final int DEFAULT_REWIND_MS = 5000;
+
+ /**
+ * The name of the {@link PlaybackStateCompat} float extra with the value of {@link
+ * PlaybackParameters#speed}.
+ */
+ public static final String EXTRAS_SPEED = "EXO_SPEED";
+ /**
+ * The name of the {@link PlaybackStateCompat} float extra with the value of {@link
+ * PlaybackParameters#pitch}.
+ */
+ public static final String EXTRAS_PITCH = "EXO_PITCH";
+
+ private static final long BASE_PLAYBACK_ACTIONS =
+ PlaybackStateCompat.ACTION_PLAY_PAUSE
+ | PlaybackStateCompat.ACTION_PLAY
+ | PlaybackStateCompat.ACTION_PAUSE
+ | PlaybackStateCompat.ACTION_STOP
+ | PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE
+ | PlaybackStateCompat.ACTION_SET_REPEAT_MODE;
+ private static final int BASE_MEDIA_SESSION_FLAGS =
+ MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
+ | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS;
+ private static final int EDITOR_MEDIA_SESSION_FLAGS =
+ BASE_MEDIA_SESSION_FLAGS | MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS;
+
+ private static final MediaMetadataCompat METADATA_EMPTY =
+ new MediaMetadataCompat.Builder().build();
+
+ /** Receiver of media commands sent by a media controller. */
+ public interface CommandReceiver {
+ /**
+ * See {@link MediaSessionCompat.Callback#onCommand(String, Bundle, ResultReceiver)}. The
+ * receiver may handle the command, but is not required to do so. Changes to the player should
+ * be made via the {@link ControlDispatcher}.
+ *
+ * @param player The player connected to the media session.
+ * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
+ * changes to the player.
+ * @param command The command name.
+ * @param extras Optional parameters for the command, may be null.
+ * @param cb A result receiver to which a result may be sent by the command, may be null.
+ * @return Whether the receiver handled the command.
+ */
+ boolean onCommand(
+ Player player,
+ ControlDispatcher controlDispatcher,
+ String command,
+ @Nullable Bundle extras,
+ @Nullable ResultReceiver cb);
+ }
+
+ /** Interface to which playback preparation and play actions are delegated. */
+ public interface PlaybackPreparer extends CommandReceiver {
+
+ long ACTIONS =
+ PlaybackStateCompat.ACTION_PREPARE
+ | PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID
+ | PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH
+ | PlaybackStateCompat.ACTION_PREPARE_FROM_URI
+ | PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
+ | PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
+ | PlaybackStateCompat.ACTION_PLAY_FROM_URI;
+
+ /**
+ * Returns the actions which are supported by the preparer. The supported actions must be a
+ * bitmask combined out of {@link PlaybackStateCompat#ACTION_PREPARE}, {@link
+ * PlaybackStateCompat#ACTION_PREPARE_FROM_MEDIA_ID}, {@link
+ * PlaybackStateCompat#ACTION_PREPARE_FROM_SEARCH}, {@link
+ * PlaybackStateCompat#ACTION_PREPARE_FROM_URI}, {@link
+ * PlaybackStateCompat#ACTION_PLAY_FROM_MEDIA_ID}, {@link
+ * PlaybackStateCompat#ACTION_PLAY_FROM_SEARCH} and {@link
+ * PlaybackStateCompat#ACTION_PLAY_FROM_URI}.
+ *
+ * @return The bitmask of the supported media actions.
+ */
+ long getSupportedPrepareActions();
+ /**
+ * See {@link MediaSessionCompat.Callback#onPrepare()}.
+ *
+ * @param playWhenReady Whether playback should be started after preparation.
+ */
+ void onPrepare(boolean playWhenReady);
+ /**
+ * See {@link MediaSessionCompat.Callback#onPrepareFromMediaId(String, Bundle)}.
+ *
+ * @param mediaId The media id of the media item to be prepared.
+ * @param playWhenReady Whether playback should be started after preparation.
+ * @param extras A {@link Bundle} of extras passed by the media controller.
+ */
+ void onPrepareFromMediaId(String mediaId, boolean playWhenReady, Bundle extras);
+ /**
+ * See {@link MediaSessionCompat.Callback#onPrepareFromSearch(String, Bundle)}.
+ *
+ * @param query The search query.
+ * @param playWhenReady Whether playback should be started after preparation.
+ * @param extras A {@link Bundle} of extras passed by the media controller.
+ */
+ void onPrepareFromSearch(String query, boolean playWhenReady, Bundle extras);
+ /**
+ * See {@link MediaSessionCompat.Callback#onPrepareFromUri(Uri, Bundle)}.
+ *
+ * @param uri The {@link Uri} of the media item to be prepared.
+ * @param playWhenReady Whether playback should be started after preparation.
+ * @param extras A {@link Bundle} of extras passed by the media controller.
+ */
+ void onPrepareFromUri(Uri uri, boolean playWhenReady, Bundle extras);
+ }
+
+ /**
+ * Handles queue navigation actions, and updates the media session queue by calling {@code
+ * MediaSessionCompat.setQueue()}.
+ */
+ public interface QueueNavigator extends CommandReceiver {
+
+ long ACTIONS =
+ PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
+ | PlaybackStateCompat.ACTION_SKIP_TO_NEXT
+ | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
+
+ /**
+ * Returns the actions which are supported by the navigator. The supported actions must be a
+ * bitmask combined out of {@link PlaybackStateCompat#ACTION_SKIP_TO_QUEUE_ITEM}, {@link
+ * PlaybackStateCompat#ACTION_SKIP_TO_NEXT}, {@link
+ * PlaybackStateCompat#ACTION_SKIP_TO_PREVIOUS}.
+ *
+ * @param player The player connected to the media session.
+ * @return The bitmask of the supported media actions.
+ */
+ long getSupportedQueueNavigatorActions(Player player);
+ /**
+ * Called when the timeline of the player has changed.
+ *
+ * @param player The player connected to the media session.
+ */
+ void onTimelineChanged(Player player);
+ /**
+ * Called when the current window index changed.
+ *
+ * @param player The player connected to the media session.
+ */
+ void onCurrentWindowIndexChanged(Player player);
+ /**
+ * Gets the id of the currently active queue item, or {@link
+ * MediaSessionCompat.QueueItem#UNKNOWN_ID} if the active item is unknown.
+ *
+ * To let the connector publish metadata for the active queue item, the queue item with the
+ * returned id must be available in the list of items returned by {@link
+ * MediaControllerCompat#getQueue()}.
+ *
+ * @param player The player connected to the media session.
+ * @return The id of the active queue item.
+ */
+ long getActiveQueueItemId(@Nullable Player player);
+ /**
+ * See {@link MediaSessionCompat.Callback#onSkipToPrevious()}.
+ *
+ * @param player The player connected to the media session.
+ * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
+ * changes to the player.
+ */
+ void onSkipToPrevious(Player player, ControlDispatcher controlDispatcher);
+ /**
+ * See {@link MediaSessionCompat.Callback#onSkipToQueueItem(long)}.
+ *
+ * @param player The player connected to the media session.
+ * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
+ * changes to the player.
+ */
+ void onSkipToQueueItem(Player player, ControlDispatcher controlDispatcher, long id);
+ /**
+ * See {@link MediaSessionCompat.Callback#onSkipToNext()}.
+ *
+ * @param player The player connected to the media session.
+ * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
+ * changes to the player.
+ */
+ void onSkipToNext(Player player, ControlDispatcher controlDispatcher);
+ }
+
+ /** Handles media session queue edits. */
+ public interface QueueEditor extends CommandReceiver {
+
+ /**
+ * See {@link MediaSessionCompat.Callback#onAddQueueItem(MediaDescriptionCompat description)}.
+ */
+ void onAddQueueItem(Player player, MediaDescriptionCompat description);
+ /**
+ * See {@link MediaSessionCompat.Callback#onAddQueueItem(MediaDescriptionCompat description, int
+ * index)}.
+ */
+ void onAddQueueItem(Player player, MediaDescriptionCompat description, int index);
+ /**
+ * See {@link MediaSessionCompat.Callback#onRemoveQueueItem(MediaDescriptionCompat
+ * description)}.
+ */
+ void onRemoveQueueItem(Player player, MediaDescriptionCompat description);
+ }
+
+ /** Callback receiving a user rating for the active media item. */
+ public interface RatingCallback extends CommandReceiver {
+
+ /** See {@link MediaSessionCompat.Callback#onSetRating(RatingCompat)}. */
+ void onSetRating(Player player, RatingCompat rating);
+
+ /** See {@link MediaSessionCompat.Callback#onSetRating(RatingCompat, Bundle)}. */
+ void onSetRating(Player player, RatingCompat rating, Bundle extras);
+ }
+
+ /** Handles requests for enabling or disabling captions. */
+ public interface CaptionCallback extends CommandReceiver {
+
+ /** See {@link MediaSessionCompat.Callback#onSetCaptioningEnabled(boolean)}. */
+ void onSetCaptioningEnabled(Player player, boolean enabled);
+
+ /**
+ * Returns whether the media currently being played has captions.
+ *
+ *
This method is called each time the media session playback state needs to be updated and
+ * published upon a player state change.
+ */
+ boolean hasCaptions(Player player);
+ }
+
+ /** Handles a media button event. */
+ public interface MediaButtonEventHandler {
+ /**
+ * See {@link MediaSessionCompat.Callback#onMediaButtonEvent(Intent)}.
+ *
+ * @param player The {@link Player}.
+ * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
+ * changes to the player.
+ * @param mediaButtonEvent The {@link Intent}.
+ * @return True if the event was handled, false otherwise.
+ */
+ boolean onMediaButtonEvent(
+ Player player, ControlDispatcher controlDispatcher, Intent mediaButtonEvent);
+ }
+
+ /**
+ * Provides a {@link PlaybackStateCompat.CustomAction} to be published and handles the action when
+ * sent by a media controller.
+ */
+ public interface CustomActionProvider {
+ /**
+ * Called when a custom action provided by this provider is sent to the media session.
+ *
+ * @param player The player connected to the media session.
+ * @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
+ * changes to the player.
+ * @param action The name of the action which was sent by a media controller.
+ * @param extras Optional extras sent by a media controller.
+ */
+ void onCustomAction(
+ Player player, ControlDispatcher controlDispatcher, String action, @Nullable Bundle extras);
+
+ /**
+ * Returns a {@link PlaybackStateCompat.CustomAction} which will be published to the media
+ * session by the connector or {@code null} if this action should not be published at the given
+ * player state.
+ *
+ * @param player The player connected to the media session.
+ * @return The custom action to be included in the session playback state or {@code null}.
+ */
+ @Nullable
+ PlaybackStateCompat.CustomAction getCustomAction(Player player);
+ }
+
+ /** Provides a {@link MediaMetadataCompat} for a given player state. */
+ public interface MediaMetadataProvider {
+ /**
+ * Gets the {@link MediaMetadataCompat} to be published to the session.
+ *
+ *
An app may need to load metadata resources like artwork bitmaps asynchronously. In such a
+ * case the app should return a {@link MediaMetadataCompat} object that does not contain these
+ * resources as a placeholder. The app should start an asynchronous operation to download the
+ * bitmap and put it into a cache. Finally, the app should call {@link
+ * #invalidateMediaSessionMetadata()}. This causes this callback to be called again and the app
+ * can now return a {@link MediaMetadataCompat} object with all the resources included.
+ *
+ * @param player The player connected to the media session.
+ * @return The {@link MediaMetadataCompat} to be published to the session.
+ */
+ MediaMetadataCompat getMetadata(Player player);
+ }
+
+ /** The wrapped {@link MediaSessionCompat}. */
+ public final MediaSessionCompat mediaSession;
+
+ private final Looper looper;
+ private final ComponentListener componentListener;
+ private final ArrayList commandReceivers;
+ private final ArrayList customCommandReceivers;
+
+ private ControlDispatcher controlDispatcher;
+ private CustomActionProvider[] customActionProviders;
+ private Map customActionMap;
+ @Nullable private MediaMetadataProvider mediaMetadataProvider;
+ @Nullable private Player player;
+ @Nullable private ErrorMessageProvider super ExoPlaybackException> errorMessageProvider;
+ @Nullable private Pair customError;
+ @Nullable private Bundle customErrorExtras;
+ @Nullable private PlaybackPreparer playbackPreparer;
+ @Nullable private QueueNavigator queueNavigator;
+ @Nullable private QueueEditor queueEditor;
+ @Nullable private RatingCallback ratingCallback;
+ @Nullable private CaptionCallback captionCallback;
+ @Nullable private MediaButtonEventHandler mediaButtonEventHandler;
+
+ private long enabledPlaybackActions;
+ private int rewindMs;
+ private int fastForwardMs;
+
+ /**
+ * Creates an instance.
+ *
+ * @param mediaSession The {@link MediaSessionCompat} to connect to.
+ */
+ public MediaSessionConnector(MediaSessionCompat mediaSession) {
+ this.mediaSession = mediaSession;
+ looper = Util.getLooper();
+ componentListener = new ComponentListener();
+ commandReceivers = new ArrayList<>();
+ customCommandReceivers = new ArrayList<>();
+ controlDispatcher = new DefaultControlDispatcher();
+ customActionProviders = new CustomActionProvider[0];
+ customActionMap = Collections.emptyMap();
+ mediaMetadataProvider =
+ new DefaultMediaMetadataProvider(
+ mediaSession.getController(), /* metadataExtrasPrefix= */ null);
+ enabledPlaybackActions = DEFAULT_PLAYBACK_ACTIONS;
+ rewindMs = DEFAULT_REWIND_MS;
+ fastForwardMs = DEFAULT_FAST_FORWARD_MS;
+ mediaSession.setFlags(BASE_MEDIA_SESSION_FLAGS);
+ mediaSession.setCallback(componentListener, new Handler(looper));
+ }
+
+ /**
+ * Sets the player to be connected to the media session. Must be called on the same thread that is
+ * used to access the player.
+ *
+ * @param player The player to be connected to the {@code MediaSession}, or {@code null} to
+ * disconnect the current player.
+ */
+ public void setPlayer(@Nullable Player player) {
+ Assertions.checkArgument(player == null || player.getApplicationLooper() == looper);
+ if (this.player != null) {
+ this.player.removeListener(componentListener);
+ }
+ this.player = player;
+ if (player != null) {
+ player.addListener(componentListener);
+ }
+ invalidateMediaSessionPlaybackState();
+ invalidateMediaSessionMetadata();
+ }
+
+ /**
+ * Sets the {@link PlaybackPreparer}.
+ *
+ * @param playbackPreparer The {@link PlaybackPreparer}.
+ */
+ public void setPlaybackPreparer(@Nullable PlaybackPreparer playbackPreparer) {
+ if (this.playbackPreparer != playbackPreparer) {
+ unregisterCommandReceiver(this.playbackPreparer);
+ this.playbackPreparer = playbackPreparer;
+ registerCommandReceiver(playbackPreparer);
+ invalidateMediaSessionPlaybackState();
+ }
+ }
+
+ /**
+ * Sets the {@link ControlDispatcher}.
+ *
+ * @param controlDispatcher The {@link ControlDispatcher}, or null to use {@link
+ * DefaultControlDispatcher}.
+ */
+ public void setControlDispatcher(@Nullable ControlDispatcher controlDispatcher) {
+ if (this.controlDispatcher != controlDispatcher) {
+ this.controlDispatcher =
+ controlDispatcher == null ? new DefaultControlDispatcher() : controlDispatcher;
+ }
+ }
+
+ /**
+ * Sets the {@link MediaButtonEventHandler}. Pass {@code null} if the media button event should be
+ * handled by {@link MediaSessionCompat.Callback#onMediaButtonEvent(Intent)}.
+ *
+ * Please note that prior to API 21 MediaButton events are not delivered to the {@link
+ * MediaSessionCompat}. Instead they are delivered as key events (see 'Responding to media
+ * buttons' ). In an {@link android.app.Activity Activity}, media button events arrive at the
+ * {@link android.app.Activity#dispatchKeyEvent(KeyEvent)} method.
+ *
+ *
If you are running the player in a foreground service (prior to API 21), you can create an
+ * intent filter and handle the {@code android.intent.action.MEDIA_BUTTON} action yourself. See
+ * Service handling ACTION_MEDIA_BUTTON for more information.
+ *
+ * @param mediaButtonEventHandler The {@link MediaButtonEventHandler}, or null to let the event be
+ * handled by {@link MediaSessionCompat.Callback#onMediaButtonEvent(Intent)}.
+ */
+ public void setMediaButtonEventHandler(
+ @Nullable MediaButtonEventHandler mediaButtonEventHandler) {
+ this.mediaButtonEventHandler = mediaButtonEventHandler;
+ }
+
+ /**
+ * Sets the enabled playback actions.
+ *
+ * @param enabledPlaybackActions The enabled playback actions.
+ */
+ public void setEnabledPlaybackActions(@PlaybackActions long enabledPlaybackActions) {
+ enabledPlaybackActions &= ALL_PLAYBACK_ACTIONS;
+ if (this.enabledPlaybackActions != enabledPlaybackActions) {
+ this.enabledPlaybackActions = enabledPlaybackActions;
+ invalidateMediaSessionPlaybackState();
+ }
+ }
+
+ /**
+ * Sets the rewind increment in milliseconds.
+ *
+ * @param rewindMs The rewind increment in milliseconds. A non-positive value will cause the
+ * rewind button to be disabled.
+ */
+ public void setRewindIncrementMs(int rewindMs) {
+ if (this.rewindMs != rewindMs) {
+ this.rewindMs = rewindMs;
+ invalidateMediaSessionPlaybackState();
+ }
+ }
+
+ /**
+ * Sets the fast forward increment in milliseconds.
+ *
+ * @param fastForwardMs The fast forward increment in milliseconds. A non-positive value will
+ * cause the fast forward button to be disabled.
+ */
+ public void setFastForwardIncrementMs(int fastForwardMs) {
+ if (this.fastForwardMs != fastForwardMs) {
+ this.fastForwardMs = fastForwardMs;
+ invalidateMediaSessionPlaybackState();
+ }
+ }
+
+ /**
+ * Sets the optional {@link ErrorMessageProvider}.
+ *
+ * @param errorMessageProvider The error message provider.
+ */
+ public void setErrorMessageProvider(
+ @Nullable ErrorMessageProvider super ExoPlaybackException> errorMessageProvider) {
+ if (this.errorMessageProvider != errorMessageProvider) {
+ this.errorMessageProvider = errorMessageProvider;
+ invalidateMediaSessionPlaybackState();
+ }
+ }
+
+ /**
+ * Sets the {@link QueueNavigator} to handle queue navigation actions {@code ACTION_SKIP_TO_NEXT},
+ * {@code ACTION_SKIP_TO_PREVIOUS} and {@code ACTION_SKIP_TO_QUEUE_ITEM}.
+ *
+ * @param queueNavigator The queue navigator.
+ */
+ public void setQueueNavigator(@Nullable QueueNavigator queueNavigator) {
+ if (this.queueNavigator != queueNavigator) {
+ unregisterCommandReceiver(this.queueNavigator);
+ this.queueNavigator = queueNavigator;
+ registerCommandReceiver(queueNavigator);
+ }
+ }
+
+ /**
+ * Sets the {@link QueueEditor} to handle queue edits sent by the media controller.
+ *
+ * @param queueEditor The queue editor.
+ */
+ public void setQueueEditor(@Nullable QueueEditor queueEditor) {
+ if (this.queueEditor != queueEditor) {
+ unregisterCommandReceiver(this.queueEditor);
+ this.queueEditor = queueEditor;
+ registerCommandReceiver(queueEditor);
+ mediaSession.setFlags(
+ queueEditor == null ? BASE_MEDIA_SESSION_FLAGS : EDITOR_MEDIA_SESSION_FLAGS);
+ }
+ }
+
+ /**
+ * Sets the {@link RatingCallback} to handle user ratings.
+ *
+ * @param ratingCallback The rating callback.
+ */
+ public void setRatingCallback(@Nullable RatingCallback ratingCallback) {
+ if (this.ratingCallback != ratingCallback) {
+ unregisterCommandReceiver(this.ratingCallback);
+ this.ratingCallback = ratingCallback;
+ registerCommandReceiver(this.ratingCallback);
+ }
+ }
+
+ /**
+ * Sets the {@link CaptionCallback} to handle requests to enable or disable captions.
+ *
+ * @param captionCallback The caption callback.
+ */
+ public void setCaptionCallback(@Nullable CaptionCallback captionCallback) {
+ if (this.captionCallback != captionCallback) {
+ unregisterCommandReceiver(this.captionCallback);
+ this.captionCallback = captionCallback;
+ registerCommandReceiver(this.captionCallback);
+ }
+ }
+
+ /**
+ * Sets a custom error on the session.
+ *
+ *
This sets the error code via {@link PlaybackStateCompat.Builder#setErrorMessage(int,
+ * CharSequence)}. By default, the error code will be set to {@link
+ * PlaybackStateCompat#ERROR_CODE_APP_ERROR}.
+ *
+ * @param message The error string to report or {@code null} to clear the error.
+ */
+ public void setCustomErrorMessage(@Nullable CharSequence message) {
+ int code = (message == null) ? 0 : PlaybackStateCompat.ERROR_CODE_APP_ERROR;
+ setCustomErrorMessage(message, code);
+ }
+
+ /**
+ * Sets a custom error on the session.
+ *
+ * @param message The error string to report or {@code null} to clear the error.
+ * @param code The error code to report. Ignored when {@code message} is {@code null}.
+ */
+ public void setCustomErrorMessage(@Nullable CharSequence message, int code) {
+ setCustomErrorMessage(message, code, /* extras= */ null);
+ }
+
+ /**
+ * Sets a custom error on the session.
+ *
+ * @param message The error string to report or {@code null} to clear the error.
+ * @param code The error code to report. Ignored when {@code message} is {@code null}.
+ * @param extras Extras to include in reported {@link PlaybackStateCompat}.
+ */
+ public void setCustomErrorMessage(
+ @Nullable CharSequence message, int code, @Nullable Bundle extras) {
+ customError = (message == null) ? null : new Pair<>(code, message);
+ customErrorExtras = (message == null) ? null : extras;
+ invalidateMediaSessionPlaybackState();
+ }
+
+ /**
+ * Sets custom action providers. The order of the {@link CustomActionProvider}s determines the
+ * order in which the actions are published.
+ *
+ * @param customActionProviders The custom action providers, or null to remove all existing custom
+ * action providers.
+ */
+ public void setCustomActionProviders(@Nullable CustomActionProvider... customActionProviders) {
+ this.customActionProviders =
+ customActionProviders == null ? new CustomActionProvider[0] : customActionProviders;
+ invalidateMediaSessionPlaybackState();
+ }
+
+ /**
+ * Sets a provider of metadata to be published to the media session. Pass {@code null} if no
+ * metadata should be published.
+ *
+ * @param mediaMetadataProvider The provider of metadata to publish, or {@code null} if no
+ * metadata should be published.
+ */
+ public void setMediaMetadataProvider(@Nullable MediaMetadataProvider mediaMetadataProvider) {
+ if (this.mediaMetadataProvider != mediaMetadataProvider) {
+ this.mediaMetadataProvider = mediaMetadataProvider;
+ invalidateMediaSessionMetadata();
+ }
+ }
+
+ /**
+ * Updates the metadata of the media session.
+ *
+ *
Apps normally only need to call this method when the backing data for a given media item has
+ * changed and the metadata should be updated immediately.
+ *
+ *
The {@link MediaMetadataCompat} which is published to the session is obtained by calling
+ * {@link MediaMetadataProvider#getMetadata(Player)}.
+ */
+ public final void invalidateMediaSessionMetadata() {
+ MediaMetadataCompat metadata =
+ mediaMetadataProvider != null && player != null
+ ? mediaMetadataProvider.getMetadata(player)
+ : METADATA_EMPTY;
+ mediaSession.setMetadata(metadata);
+ }
+
+ /**
+ * Updates the playback state of the media session.
+ *
+ *
Apps normally only need to call this method when the custom actions provided by a {@link
+ * CustomActionProvider} changed and the playback state needs to be updated immediately.
+ */
+ public final void invalidateMediaSessionPlaybackState() {
+ PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder();
+ @Nullable Player player = this.player;
+ if (player == null) {
+ builder
+ .setActions(buildPrepareActions())
+ .setState(
+ PlaybackStateCompat.STATE_NONE,
+ /* position= */ 0,
+ /* playbackSpeed= */ 0,
+ /* updateTime= */ SystemClock.elapsedRealtime());
+
+ mediaSession.setRepeatMode(PlaybackStateCompat.REPEAT_MODE_NONE);
+ mediaSession.setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_NONE);
+ mediaSession.setPlaybackState(builder.build());
+ return;
+ }
+
+ Map currentActions = new HashMap<>();
+ for (CustomActionProvider customActionProvider : customActionProviders) {
+ @Nullable
+ PlaybackStateCompat.CustomAction customAction = customActionProvider.getCustomAction(player);
+ if (customAction != null) {
+ currentActions.put(customAction.getAction(), customActionProvider);
+ builder.addCustomAction(customAction);
+ }
+ }
+ customActionMap = Collections.unmodifiableMap(currentActions);
+
+ Bundle extras = new Bundle();
+ @Nullable ExoPlaybackException playbackError = player.getPlaybackError();
+ boolean reportError = playbackError != null || customError != null;
+ int sessionPlaybackState =
+ reportError
+ ? PlaybackStateCompat.STATE_ERROR
+ : getMediaSessionPlaybackState(player.getPlaybackState(), player.getPlayWhenReady());
+ if (customError != null) {
+ builder.setErrorMessage(customError.first, customError.second);
+ if (customErrorExtras != null) {
+ extras.putAll(customErrorExtras);
+ }
+ } else if (playbackError != null && errorMessageProvider != null) {
+ Pair message = errorMessageProvider.getErrorMessage(playbackError);
+ builder.setErrorMessage(message.first, message.second);
+ }
+ long activeQueueItemId =
+ queueNavigator != null
+ ? queueNavigator.getActiveQueueItemId(player)
+ : MediaSessionCompat.QueueItem.UNKNOWN_ID;
+ PlaybackParameters playbackParameters = player.getPlaybackParameters();
+ extras.putFloat(EXTRAS_SPEED, playbackParameters.speed);
+ extras.putFloat(EXTRAS_PITCH, playbackParameters.pitch);
+ float sessionPlaybackSpeed = player.isPlaying() ? playbackParameters.speed : 0f;
+ builder
+ .setActions(buildPrepareActions() | buildPlaybackActions(player))
+ .setActiveQueueItemId(activeQueueItemId)
+ .setBufferedPosition(player.getBufferedPosition())
+ .setState(
+ sessionPlaybackState,
+ player.getCurrentPosition(),
+ sessionPlaybackSpeed,
+ /* updateTime= */ SystemClock.elapsedRealtime())
+ .setExtras(extras);
+
+ @Player.RepeatMode int repeatMode = player.getRepeatMode();
+ mediaSession.setRepeatMode(
+ repeatMode == Player.REPEAT_MODE_ONE
+ ? PlaybackStateCompat.REPEAT_MODE_ONE
+ : repeatMode == Player.REPEAT_MODE_ALL
+ ? PlaybackStateCompat.REPEAT_MODE_ALL
+ : PlaybackStateCompat.REPEAT_MODE_NONE);
+ mediaSession.setShuffleMode(
+ player.getShuffleModeEnabled()
+ ? PlaybackStateCompat.SHUFFLE_MODE_ALL
+ : PlaybackStateCompat.SHUFFLE_MODE_NONE);
+ mediaSession.setPlaybackState(builder.build());
+ }
+
+ /**
+ * Updates the queue of the media session by calling {@link
+ * QueueNavigator#onTimelineChanged(Player)}.
+ *
+ * Apps normally only need to call this method when the backing data for a given queue item has
+ * changed and the queue should be updated immediately.
+ */
+ public final void invalidateMediaSessionQueue() {
+ if (queueNavigator != null && player != null) {
+ queueNavigator.onTimelineChanged(player);
+ }
+ }
+
+ /**
+ * Registers a custom command receiver for responding to commands delivered via {@link
+ * MediaSessionCompat.Callback#onCommand(String, Bundle, ResultReceiver)}.
+ *
+ *
Commands are only dispatched to this receiver when a player is connected.
+ *
+ * @param commandReceiver The command receiver to register.
+ */
+ public void registerCustomCommandReceiver(@Nullable CommandReceiver commandReceiver) {
+ if (commandReceiver != null && !customCommandReceivers.contains(commandReceiver)) {
+ customCommandReceivers.add(commandReceiver);
+ }
+ }
+
+ /**
+ * Unregisters a previously registered custom command receiver.
+ *
+ * @param commandReceiver The command receiver to unregister.
+ */
+ public void unregisterCustomCommandReceiver(@Nullable CommandReceiver commandReceiver) {
+ if (commandReceiver != null) {
+ customCommandReceivers.remove(commandReceiver);
+ }
+ }
+
+ private void registerCommandReceiver(@Nullable CommandReceiver commandReceiver) {
+ if (commandReceiver != null && !commandReceivers.contains(commandReceiver)) {
+ commandReceivers.add(commandReceiver);
+ }
+ }
+
+ private void unregisterCommandReceiver(@Nullable CommandReceiver commandReceiver) {
+ if (commandReceiver != null) {
+ commandReceivers.remove(commandReceiver);
+ }
+ }
+
+ private long buildPrepareActions() {
+ return playbackPreparer == null
+ ? 0
+ : (PlaybackPreparer.ACTIONS & playbackPreparer.getSupportedPrepareActions());
+ }
+
+ private long buildPlaybackActions(Player player) {
+ boolean enableSeeking = false;
+ boolean enableRewind = false;
+ boolean enableFastForward = false;
+ boolean enableSetRating = false;
+ boolean enableSetCaptioningEnabled = false;
+ Timeline timeline = player.getCurrentTimeline();
+ if (!timeline.isEmpty() && !player.isPlayingAd()) {
+ enableSeeking = player.isCurrentWindowSeekable();
+ enableRewind = enableSeeking && rewindMs > 0;
+ enableFastForward = enableSeeking && fastForwardMs > 0;
+ enableSetRating = ratingCallback != null;
+ enableSetCaptioningEnabled = captionCallback != null && captionCallback.hasCaptions(player);
+ }
+
+ long playbackActions = BASE_PLAYBACK_ACTIONS;
+ if (enableSeeking) {
+ playbackActions |= PlaybackStateCompat.ACTION_SEEK_TO;
+ }
+ if (enableFastForward) {
+ playbackActions |= PlaybackStateCompat.ACTION_FAST_FORWARD;
+ }
+ if (enableRewind) {
+ playbackActions |= PlaybackStateCompat.ACTION_REWIND;
+ }
+ playbackActions &= enabledPlaybackActions;
+
+ long actions = playbackActions;
+ if (queueNavigator != null) {
+ actions |=
+ (QueueNavigator.ACTIONS & queueNavigator.getSupportedQueueNavigatorActions(player));
+ }
+ if (enableSetRating) {
+ actions |= PlaybackStateCompat.ACTION_SET_RATING;
+ }
+ if (enableSetCaptioningEnabled) {
+ actions |= PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED;
+ }
+ return actions;
+ }
+
+ @EnsuresNonNullIf(result = true, expression = "player")
+ private boolean canDispatchPlaybackAction(long action) {
+ return player != null && (enabledPlaybackActions & action) != 0;
+ }
+
+ @EnsuresNonNullIf(result = true, expression = "playbackPreparer")
+ private boolean canDispatchToPlaybackPreparer(long action) {
+ return playbackPreparer != null
+ && (playbackPreparer.getSupportedPrepareActions() & action) != 0;
+ }
+
+ @EnsuresNonNullIf(
+ result = true,
+ expression = {"player", "queueNavigator"})
+ private boolean canDispatchToQueueNavigator(long action) {
+ return player != null
+ && queueNavigator != null
+ && (queueNavigator.getSupportedQueueNavigatorActions(player) & action) != 0;
+ }
+
+ @EnsuresNonNullIf(
+ result = true,
+ expression = {"player", "ratingCallback"})
+ private boolean canDispatchSetRating() {
+ return player != null && ratingCallback != null;
+ }
+
+ @EnsuresNonNullIf(
+ result = true,
+ expression = {"player", "captionCallback"})
+ private boolean canDispatchSetCaptioningEnabled() {
+ return player != null && captionCallback != null;
+ }
+
+ @EnsuresNonNullIf(
+ result = true,
+ expression = {"player", "queueEditor"})
+ private boolean canDispatchQueueEdit() {
+ return player != null && queueEditor != null;
+ }
+
+ @EnsuresNonNullIf(
+ result = true,
+ expression = {"player", "mediaButtonEventHandler"})
+ private boolean canDispatchMediaButtonEvent() {
+ return player != null && mediaButtonEventHandler != null;
+ }
+
+ private void rewind(Player player) {
+ if (player.isCurrentWindowSeekable() && rewindMs > 0) {
+ seekToOffset(player, /* offsetMs= */ -rewindMs);
+ }
+ }
+
+ private void fastForward(Player player) {
+ if (player.isCurrentWindowSeekable() && fastForwardMs > 0) {
+ seekToOffset(player, /* offsetMs= */ fastForwardMs);
+ }
+ }
+
+ private void seekToOffset(Player player, long offsetMs) {
+ long positionMs = player.getCurrentPosition() + offsetMs;
+ long durationMs = player.getDuration();
+ if (durationMs != C.TIME_UNSET) {
+ positionMs = Math.min(positionMs, durationMs);
+ }
+ positionMs = Math.max(positionMs, 0);
+ seekTo(player, player.getCurrentWindowIndex(), positionMs);
+ }
+
+ private void seekTo(Player player, int windowIndex, long positionMs) {
+ controlDispatcher.dispatchSeekTo(player, windowIndex, positionMs);
+ }
+
+ private static int getMediaSessionPlaybackState(
+ @Player.State int exoPlayerPlaybackState, boolean playWhenReady) {
+ switch (exoPlayerPlaybackState) {
+ case Player.STATE_BUFFERING:
+ return PlaybackStateCompat.STATE_BUFFERING;
+ case Player.STATE_READY:
+ return playWhenReady ? PlaybackStateCompat.STATE_PLAYING : PlaybackStateCompat.STATE_PAUSED;
+ case Player.STATE_ENDED:
+ return PlaybackStateCompat.STATE_STOPPED;
+ case Player.STATE_IDLE:
+ default:
+ return PlaybackStateCompat.STATE_NONE;
+ }
+ }
+
+ /**
+ * Provides a default {@link MediaMetadataCompat} with properties and extras taken from the {@link
+ * MediaDescriptionCompat} of the {@link MediaSessionCompat.QueueItem} of the active queue item.
+ */
+ public static final class DefaultMediaMetadataProvider implements MediaMetadataProvider {
+
+ private final MediaControllerCompat mediaController;
+ private final String metadataExtrasPrefix;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param mediaController The {@link MediaControllerCompat}.
+ * @param metadataExtrasPrefix A string to prefix extra keys which are propagated from the
+ * active queue item to the session metadata.
+ */
+ public DefaultMediaMetadataProvider(
+ MediaControllerCompat mediaController, @Nullable String metadataExtrasPrefix) {
+ this.mediaController = mediaController;
+ this.metadataExtrasPrefix = metadataExtrasPrefix != null ? metadataExtrasPrefix : "";
+ }
+
+ @Override
+ public MediaMetadataCompat getMetadata(Player player) {
+ if (player.getCurrentTimeline().isEmpty()) {
+ return METADATA_EMPTY;
+ }
+ MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder();
+ if (player.isPlayingAd()) {
+ builder.putLong(MediaMetadataCompat.METADATA_KEY_ADVERTISEMENT, 1);
+ }
+ builder.putLong(
+ MediaMetadataCompat.METADATA_KEY_DURATION,
+ player.isCurrentWindowDynamic() || player.getDuration() == C.TIME_UNSET
+ ? -1
+ : player.getDuration());
+ long activeQueueItemId = mediaController.getPlaybackState().getActiveQueueItemId();
+ if (activeQueueItemId != MediaSessionCompat.QueueItem.UNKNOWN_ID) {
+ List queue = mediaController.getQueue();
+ for (int i = 0; queue != null && i < queue.size(); i++) {
+ MediaSessionCompat.QueueItem queueItem = queue.get(i);
+ if (queueItem.getQueueId() == activeQueueItemId) {
+ MediaDescriptionCompat description = queueItem.getDescription();
+ @Nullable Bundle extras = description.getExtras();
+ if (extras != null) {
+ for (String key : extras.keySet()) {
+ @Nullable Object value = extras.get(key);
+ if (value instanceof String) {
+ builder.putString(metadataExtrasPrefix + key, (String) value);
+ } else if (value instanceof CharSequence) {
+ builder.putText(metadataExtrasPrefix + key, (CharSequence) value);
+ } else if (value instanceof Long) {
+ builder.putLong(metadataExtrasPrefix + key, (Long) value);
+ } else if (value instanceof Integer) {
+ builder.putLong(metadataExtrasPrefix + key, (Integer) value);
+ } else if (value instanceof Bitmap) {
+ builder.putBitmap(metadataExtrasPrefix + key, (Bitmap) value);
+ } else if (value instanceof RatingCompat) {
+ builder.putRating(metadataExtrasPrefix + key, (RatingCompat) value);
+ }
+ }
+ }
+ @Nullable CharSequence title = description.getTitle();
+ if (title != null) {
+ String titleString = String.valueOf(title);
+ builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, titleString);
+ builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, titleString);
+ }
+ @Nullable CharSequence subtitle = description.getSubtitle();
+ if (subtitle != null) {
+ builder.putString(
+ MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, String.valueOf(subtitle));
+ }
+ @Nullable CharSequence displayDescription = description.getDescription();
+ if (displayDescription != null) {
+ builder.putString(
+ MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
+ String.valueOf(displayDescription));
+ }
+ @Nullable Bitmap iconBitmap = description.getIconBitmap();
+ if (iconBitmap != null) {
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, iconBitmap);
+ }
+ @Nullable Uri iconUri = description.getIconUri();
+ if (iconUri != null) {
+ builder.putString(
+ MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, String.valueOf(iconUri));
+ }
+ @Nullable String mediaId = description.getMediaId();
+ if (mediaId != null) {
+ builder.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId);
+ }
+ @Nullable Uri mediaUri = description.getMediaUri();
+ if (mediaUri != null) {
+ builder.putString(
+ MediaMetadataCompat.METADATA_KEY_MEDIA_URI, String.valueOf(mediaUri));
+ }
+ break;
+ }
+ }
+ }
+ return builder.build();
+ }
+ }
+
+ private class ComponentListener extends MediaSessionCompat.Callback
+ implements Player.EventListener {
+
+ private int currentWindowIndex;
+ private int currentWindowCount;
+
+ // Player.EventListener implementation.
+
+ @Override
+ public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
+ Player player = Assertions.checkNotNull(MediaSessionConnector.this.player);
+ int windowCount = player.getCurrentTimeline().getWindowCount();
+ int windowIndex = player.getCurrentWindowIndex();
+ if (queueNavigator != null) {
+ queueNavigator.onTimelineChanged(player);
+ invalidateMediaSessionPlaybackState();
+ } else if (currentWindowCount != windowCount || currentWindowIndex != windowIndex) {
+ // active queue item and queue navigation actions may need to be updated
+ invalidateMediaSessionPlaybackState();
+ }
+ currentWindowCount = windowCount;
+ currentWindowIndex = windowIndex;
+ invalidateMediaSessionMetadata();
+ }
+
+ @Override
+ public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) {
+ invalidateMediaSessionPlaybackState();
+ }
+
+ @Override
+ public void onIsPlayingChanged(boolean isPlaying) {
+ invalidateMediaSessionPlaybackState();
+ }
+
+ @Override
+ public void onRepeatModeChanged(@Player.RepeatMode int repeatMode) {
+ invalidateMediaSessionPlaybackState();
+ }
+
+ @Override
+ public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {
+ invalidateMediaSessionPlaybackState();
+ invalidateMediaSessionQueue();
+ }
+
+ @Override
+ public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
+ Player player = Assertions.checkNotNull(MediaSessionConnector.this.player);
+ if (currentWindowIndex != player.getCurrentWindowIndex()) {
+ if (queueNavigator != null) {
+ queueNavigator.onCurrentWindowIndexChanged(player);
+ }
+ currentWindowIndex = player.getCurrentWindowIndex();
+ // Update playback state after queueNavigator.onCurrentWindowIndexChanged has been called
+ // and before updating metadata.
+ invalidateMediaSessionPlaybackState();
+ invalidateMediaSessionMetadata();
+ return;
+ }
+ invalidateMediaSessionPlaybackState();
+ }
+
+ @Override
+ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
+ invalidateMediaSessionPlaybackState();
+ }
+
+ // MediaSessionCompat.Callback implementation.
+
+ @Override
+ public void onPlay() {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_PLAY)) {
+ if (player.getPlaybackState() == Player.STATE_IDLE) {
+ if (playbackPreparer != null) {
+ playbackPreparer.onPrepare(/* playWhenReady= */ true);
+ }
+ } else if (player.getPlaybackState() == Player.STATE_ENDED) {
+ seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET);
+ }
+ controlDispatcher.dispatchSetPlayWhenReady(
+ Assertions.checkNotNull(player), /* playWhenReady= */ true);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_PAUSE)) {
+ controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false);
+ }
+ }
+
+ @Override
+ public void onSeekTo(long positionMs) {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_SEEK_TO)) {
+ seekTo(player, player.getCurrentWindowIndex(), positionMs);
+ }
+ }
+
+ @Override
+ public void onFastForward() {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_FAST_FORWARD)) {
+ fastForward(player);
+ }
+ }
+
+ @Override
+ public void onRewind() {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_REWIND)) {
+ rewind(player);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_STOP)) {
+ controlDispatcher.dispatchStop(player, /* reset= */ true);
+ }
+ }
+
+ @Override
+ public void onSetShuffleMode(@PlaybackStateCompat.ShuffleMode int shuffleMode) {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE)) {
+ boolean shuffleModeEnabled;
+ switch (shuffleMode) {
+ case PlaybackStateCompat.SHUFFLE_MODE_ALL:
+ case PlaybackStateCompat.SHUFFLE_MODE_GROUP:
+ shuffleModeEnabled = true;
+ break;
+ case PlaybackStateCompat.SHUFFLE_MODE_NONE:
+ case PlaybackStateCompat.SHUFFLE_MODE_INVALID:
+ default:
+ shuffleModeEnabled = false;
+ break;
+ }
+ controlDispatcher.dispatchSetShuffleModeEnabled(player, shuffleModeEnabled);
+ }
+ }
+
+ @Override
+ public void onSetRepeatMode(@PlaybackStateCompat.RepeatMode int mediaSessionRepeatMode) {
+ if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_SET_REPEAT_MODE)) {
+ @RepeatModeUtil.RepeatToggleModes int repeatMode;
+ switch (mediaSessionRepeatMode) {
+ case PlaybackStateCompat.REPEAT_MODE_ALL:
+ case PlaybackStateCompat.REPEAT_MODE_GROUP:
+ repeatMode = Player.REPEAT_MODE_ALL;
+ break;
+ case PlaybackStateCompat.REPEAT_MODE_ONE:
+ repeatMode = Player.REPEAT_MODE_ONE;
+ break;
+ case PlaybackStateCompat.REPEAT_MODE_NONE:
+ case PlaybackStateCompat.REPEAT_MODE_INVALID:
+ default:
+ repeatMode = Player.REPEAT_MODE_OFF;
+ break;
+ }
+ controlDispatcher.dispatchSetRepeatMode(player, repeatMode);
+ }
+ }
+
+ @Override
+ public void onSkipToNext() {
+ if (canDispatchToQueueNavigator(PlaybackStateCompat.ACTION_SKIP_TO_NEXT)) {
+ queueNavigator.onSkipToNext(player, controlDispatcher);
+ }
+ }
+
+ @Override
+ public void onSkipToPrevious() {
+ if (canDispatchToQueueNavigator(PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)) {
+ queueNavigator.onSkipToPrevious(player, controlDispatcher);
+ }
+ }
+
+ @Override
+ public void onSkipToQueueItem(long id) {
+ if (canDispatchToQueueNavigator(PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM)) {
+ queueNavigator.onSkipToQueueItem(player, controlDispatcher, id);
+ }
+ }
+
+ @Override
+ public void onCustomAction(String action, @Nullable Bundle extras) {
+ if (player != null && customActionMap.containsKey(action)) {
+ customActionMap.get(action).onCustomAction(player, controlDispatcher, action, extras);
+ invalidateMediaSessionPlaybackState();
+ }
+ }
+
+ @Override
+ public void onCommand(String command, @Nullable Bundle extras, @Nullable ResultReceiver cb) {
+ if (player != null) {
+ for (int i = 0; i < commandReceivers.size(); i++) {
+ if (commandReceivers.get(i).onCommand(player, controlDispatcher, command, extras, cb)) {
+ return;
+ }
+ }
+ for (int i = 0; i < customCommandReceivers.size(); i++) {
+ if (customCommandReceivers
+ .get(i)
+ .onCommand(player, controlDispatcher, command, extras, cb)) {
+ return;
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onPrepare() {
+ if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE)) {
+ playbackPreparer.onPrepare(/* playWhenReady= */ false);
+ }
+ }
+
+ @Override
+ public void onPrepareFromMediaId(String mediaId, Bundle extras) {
+ if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID)) {
+ playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ false, extras);
+ }
+ }
+
+ @Override
+ public void onPrepareFromSearch(String query, Bundle extras) {
+ if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH)) {
+ playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ false, extras);
+ }
+ }
+
+ @Override
+ public void onPrepareFromUri(Uri uri, Bundle extras) {
+ if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PREPARE_FROM_URI)) {
+ playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ false, extras);
+ }
+ }
+
+ @Override
+ public void onPlayFromMediaId(String mediaId, Bundle extras) {
+ if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID)) {
+ playbackPreparer.onPrepareFromMediaId(mediaId, /* playWhenReady= */ true, extras);
+ }
+ }
+
+ @Override
+ public void onPlayFromSearch(String query, Bundle extras) {
+ if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH)) {
+ playbackPreparer.onPrepareFromSearch(query, /* playWhenReady= */ true, extras);
+ }
+ }
+
+ @Override
+ public void onPlayFromUri(Uri uri, Bundle extras) {
+ if (canDispatchToPlaybackPreparer(PlaybackStateCompat.ACTION_PLAY_FROM_URI)) {
+ playbackPreparer.onPrepareFromUri(uri, /* playWhenReady= */ true, extras);
+ }
+ }
+
+ @Override
+ public void onSetRating(RatingCompat rating) {
+ if (canDispatchSetRating()) {
+ ratingCallback.onSetRating(player, rating);
+ }
+ }
+
+ @Override
+ public void onSetRating(RatingCompat rating, Bundle extras) {
+ if (canDispatchSetRating()) {
+ ratingCallback.onSetRating(player, rating, extras);
+ }
+ }
+
+ @Override
+ public void onAddQueueItem(MediaDescriptionCompat description) {
+ if (canDispatchQueueEdit()) {
+ queueEditor.onAddQueueItem(player, description);
+ }
+ }
+
+ @Override
+ public void onAddQueueItem(MediaDescriptionCompat description, int index) {
+ if (canDispatchQueueEdit()) {
+ queueEditor.onAddQueueItem(player, description, index);
+ }
+ }
+
+ @Override
+ public void onRemoveQueueItem(MediaDescriptionCompat description) {
+ if (canDispatchQueueEdit()) {
+ queueEditor.onRemoveQueueItem(player, description);
+ }
+ }
+
+ @Override
+ public void onSetCaptioningEnabled(boolean enabled) {
+ if (canDispatchSetCaptioningEnabled()) {
+ captionCallback.onSetCaptioningEnabled(player, enabled);
+ }
+ }
+
+ @Override
+ public boolean onMediaButtonEvent(Intent mediaButtonEvent) {
+ boolean isHandled =
+ canDispatchMediaButtonEvent()
+ && mediaButtonEventHandler.onMediaButtonEvent(
+ player, controlDispatcher, mediaButtonEvent);
+ return isHandled || super.onMediaButtonEvent(mediaButtonEvent);
+ }
+ }
+}
diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/RepeatModeActionProvider.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/RepeatModeActionProvider.java
new file mode 100644
index 0000000000..87b9447f7c
--- /dev/null
+++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/RepeatModeActionProvider.java
@@ -0,0 +1,101 @@
+/*
+ * 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.ext.mediasession;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.media.session.PlaybackStateCompat;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.util.RepeatModeUtil;
+
+/** Provides a custom action for toggling repeat modes. */
+public final class RepeatModeActionProvider implements MediaSessionConnector.CustomActionProvider {
+
+ /** The default repeat toggle modes. */
+ @RepeatModeUtil.RepeatToggleModes
+ public static final int DEFAULT_REPEAT_TOGGLE_MODES =
+ RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE | RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL;
+
+ private static final String ACTION_REPEAT_MODE = "ACTION_EXO_REPEAT_MODE";
+
+ @RepeatModeUtil.RepeatToggleModes
+ private final int repeatToggleModes;
+ private final CharSequence repeatAllDescription;
+ private final CharSequence repeatOneDescription;
+ private final CharSequence repeatOffDescription;
+
+ /**
+ * Creates a new instance.
+ *
+ * Equivalent to {@code RepeatModeActionProvider(context, DEFAULT_REPEAT_TOGGLE_MODES)}.
+ *
+ * @param context The context.
+ */
+ public RepeatModeActionProvider(Context context) {
+ this(context, DEFAULT_REPEAT_TOGGLE_MODES);
+ }
+
+ /**
+ * Creates a new instance enabling the given repeat toggle modes.
+ *
+ * @param context The context.
+ * @param repeatToggleModes The toggle modes to enable.
+ */
+ public RepeatModeActionProvider(
+ Context context, @RepeatModeUtil.RepeatToggleModes int repeatToggleModes) {
+ this.repeatToggleModes = repeatToggleModes;
+ repeatAllDescription = context.getString(R.string.exo_media_action_repeat_all_description);
+ repeatOneDescription = context.getString(R.string.exo_media_action_repeat_one_description);
+ repeatOffDescription = context.getString(R.string.exo_media_action_repeat_off_description);
+ }
+
+ @Override
+ public void onCustomAction(
+ Player player, ControlDispatcher controlDispatcher, String action, @Nullable Bundle extras) {
+ int mode = player.getRepeatMode();
+ int proposedMode = RepeatModeUtil.getNextRepeatMode(mode, repeatToggleModes);
+ if (mode != proposedMode) {
+ controlDispatcher.dispatchSetRepeatMode(player, proposedMode);
+ }
+ }
+
+ @Override
+ public PlaybackStateCompat.CustomAction getCustomAction(Player player) {
+ CharSequence actionLabel;
+ int iconResourceId;
+ switch (player.getRepeatMode()) {
+ case Player.REPEAT_MODE_ONE:
+ actionLabel = repeatOneDescription;
+ iconResourceId = R.drawable.exo_media_action_repeat_one;
+ break;
+ case Player.REPEAT_MODE_ALL:
+ actionLabel = repeatAllDescription;
+ iconResourceId = R.drawable.exo_media_action_repeat_all;
+ break;
+ case Player.REPEAT_MODE_OFF:
+ default:
+ actionLabel = repeatOffDescription;
+ iconResourceId = R.drawable.exo_media_action_repeat_off;
+ break;
+ }
+ PlaybackStateCompat.CustomAction.Builder repeatBuilder = new PlaybackStateCompat.CustomAction
+ .Builder(ACTION_REPEAT_MODE, actionLabel, iconResourceId);
+ return repeatBuilder.build();
+ }
+
+}
diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java
new file mode 100644
index 0000000000..7f60d5e715
--- /dev/null
+++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java
@@ -0,0 +1,208 @@
+/*
+ * 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.ext.mediasession;
+
+import android.os.Bundle;
+import android.os.ResultReceiver;
+import android.support.v4.media.MediaDescriptionCompat;
+import android.support.v4.media.session.MediaControllerCompat;
+import android.support.v4.media.session.MediaSessionCompat;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.util.Util;
+import java.util.List;
+
+/**
+ * A {@link MediaSessionConnector.QueueEditor} implementation based on the {@link
+ * ConcatenatingMediaSource}.
+ *
+ *
This class implements the {@link MediaSessionConnector.CommandReceiver} interface and handles
+ * the {@link #COMMAND_MOVE_QUEUE_ITEM} to move a queue item instead of removing and inserting it.
+ * This allows to move the currently playing window without interrupting playback.
+ */
+public final class TimelineQueueEditor
+ implements MediaSessionConnector.QueueEditor, MediaSessionConnector.CommandReceiver {
+
+ public static final String COMMAND_MOVE_QUEUE_ITEM = "exo_move_window";
+ public static final String EXTRA_FROM_INDEX = "from_index";
+ public static final String EXTRA_TO_INDEX = "to_index";
+
+ /**
+ * Factory to create {@link MediaSource}s.
+ */
+ public interface MediaSourceFactory {
+ /**
+ * Creates a {@link MediaSource} for the given {@link MediaDescriptionCompat}.
+ *
+ * @param description The {@link MediaDescriptionCompat} to create a media source for.
+ * @return A {@link MediaSource} or {@code null} if no source can be created for the given
+ * description.
+ */
+ @Nullable MediaSource createMediaSource(MediaDescriptionCompat description);
+ }
+
+ /**
+ * Adapter to get {@link MediaDescriptionCompat} of items in the queue and to notify the
+ * application about changes in the queue to sync the data structure backing the
+ * {@link MediaSessionConnector}.
+ */
+ public interface QueueDataAdapter {
+ /**
+ * Adds a {@link MediaDescriptionCompat} at the given {@code position}.
+ *
+ * @param position The position at which to add.
+ * @param description The {@link MediaDescriptionCompat} to be added.
+ */
+ void add(int position, MediaDescriptionCompat description);
+ /**
+ * Removes the item at the given {@code position}.
+ *
+ * @param position The position at which to remove the item.
+ */
+ void remove(int position);
+ /**
+ * Moves a queue item from position {@code from} to position {@code to}.
+ *
+ * @param from The position from which to remove the item.
+ * @param to The target position to which to move the item.
+ */
+ void move(int from, int to);
+ }
+
+ /**
+ * Used to evaluate whether two {@link MediaDescriptionCompat} are considered equal.
+ */
+ interface MediaDescriptionEqualityChecker {
+ /**
+ * Returns {@code true} whether the descriptions are considered equal.
+ *
+ * @param d1 The first {@link MediaDescriptionCompat}.
+ * @param d2 The second {@link MediaDescriptionCompat}.
+ * @return {@code true} if considered equal.
+ */
+ boolean equals(MediaDescriptionCompat d1, MediaDescriptionCompat d2);
+ }
+
+ /**
+ * Media description comparator comparing the media IDs. Media IDs are considered equals if both
+ * are {@code null}.
+ */
+ public static final class MediaIdEqualityChecker implements MediaDescriptionEqualityChecker {
+
+ @Override
+ public boolean equals(MediaDescriptionCompat d1, MediaDescriptionCompat d2) {
+ return Util.areEqual(d1.getMediaId(), d2.getMediaId());
+ }
+
+ }
+
+ private final MediaControllerCompat mediaController;
+ private final QueueDataAdapter queueDataAdapter;
+ private final MediaSourceFactory sourceFactory;
+ private final MediaDescriptionEqualityChecker equalityChecker;
+ private final ConcatenatingMediaSource queueMediaSource;
+
+ /**
+ * Creates a new {@link TimelineQueueEditor} with a given mediaSourceFactory.
+ *
+ * @param mediaController A {@link MediaControllerCompat} to read the current queue.
+ * @param queueMediaSource The {@link ConcatenatingMediaSource} to manipulate.
+ * @param queueDataAdapter A {@link QueueDataAdapter} to change the backing data.
+ * @param sourceFactory The {@link MediaSourceFactory} to build media sources.
+ */
+ public TimelineQueueEditor(
+ MediaControllerCompat mediaController,
+ ConcatenatingMediaSource queueMediaSource,
+ QueueDataAdapter queueDataAdapter,
+ MediaSourceFactory sourceFactory) {
+ this(mediaController, queueMediaSource, queueDataAdapter, sourceFactory,
+ new MediaIdEqualityChecker());
+ }
+
+ /**
+ * Creates a new {@link TimelineQueueEditor} with a given mediaSourceFactory.
+ *
+ * @param mediaController A {@link MediaControllerCompat} to read the current queue.
+ * @param queueMediaSource The {@link ConcatenatingMediaSource} to manipulate.
+ * @param queueDataAdapter A {@link QueueDataAdapter} to change the backing data.
+ * @param sourceFactory The {@link MediaSourceFactory} to build media sources.
+ * @param equalityChecker The {@link MediaDescriptionEqualityChecker} to match queue items.
+ */
+ public TimelineQueueEditor(
+ MediaControllerCompat mediaController,
+ ConcatenatingMediaSource queueMediaSource,
+ QueueDataAdapter queueDataAdapter,
+ MediaSourceFactory sourceFactory,
+ MediaDescriptionEqualityChecker equalityChecker) {
+ this.mediaController = mediaController;
+ this.queueMediaSource = queueMediaSource;
+ this.queueDataAdapter = queueDataAdapter;
+ this.sourceFactory = sourceFactory;
+ this.equalityChecker = equalityChecker;
+ }
+
+ @Override
+ public void onAddQueueItem(Player player, MediaDescriptionCompat description) {
+ onAddQueueItem(player, description, player.getCurrentTimeline().getWindowCount());
+ }
+
+ @Override
+ public void onAddQueueItem(Player player, MediaDescriptionCompat description, int index) {
+ @Nullable MediaSource mediaSource = sourceFactory.createMediaSource(description);
+ if (mediaSource != null) {
+ queueDataAdapter.add(index, description);
+ queueMediaSource.addMediaSource(index, mediaSource);
+ }
+ }
+
+ @Override
+ public void onRemoveQueueItem(Player player, MediaDescriptionCompat description) {
+ List queue = mediaController.getQueue();
+ for (int i = 0; i < queue.size(); i++) {
+ if (equalityChecker.equals(queue.get(i).getDescription(), description)) {
+ queueDataAdapter.remove(i);
+ queueMediaSource.removeMediaSource(i);
+ return;
+ }
+ }
+ }
+
+ // CommandReceiver implementation.
+
+ @Override
+ public boolean onCommand(
+ Player player,
+ ControlDispatcher controlDispatcher,
+ String command,
+ @Nullable Bundle extras,
+ @Nullable ResultReceiver cb) {
+ if (!COMMAND_MOVE_QUEUE_ITEM.equals(command) || extras == null) {
+ return false;
+ }
+ int from = extras.getInt(EXTRA_FROM_INDEX, C.INDEX_UNSET);
+ int to = extras.getInt(EXTRA_TO_INDEX, C.INDEX_UNSET);
+ if (from != C.INDEX_UNSET && to != C.INDEX_UNSET) {
+ queueDataAdapter.move(from, to);
+ queueMediaSource.moveMediaSource(from, to);
+ }
+ return true;
+ }
+
+}
diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java
new file mode 100644
index 0000000000..f9d0eca353
--- /dev/null
+++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java
@@ -0,0 +1,243 @@
+/*
+ * 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.ext.mediasession;
+
+import android.os.Bundle;
+import android.os.ResultReceiver;
+import android.support.v4.media.MediaDescriptionCompat;
+import android.support.v4.media.session.MediaSessionCompat;
+import android.support.v4.media.session.PlaybackStateCompat;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ControlDispatcher;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.util.Assertions;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+
+/**
+ * An abstract implementation of the {@link MediaSessionConnector.QueueNavigator} that maps the
+ * windows of a {@link Player}'s {@link Timeline} to the media session queue.
+ */
+public abstract class TimelineQueueNavigator implements MediaSessionConnector.QueueNavigator {
+
+ public static final long MAX_POSITION_FOR_SEEK_TO_PREVIOUS = 3000;
+ public static final int DEFAULT_MAX_QUEUE_SIZE = 10;
+
+ private final MediaSessionCompat mediaSession;
+ private final Timeline.Window window;
+ private final int maxQueueSize;
+
+ private long activeQueueItemId;
+
+ /**
+ * Creates an instance for a given {@link MediaSessionCompat}.
+ *
+ * Equivalent to {@code TimelineQueueNavigator(mediaSession, DEFAULT_MAX_QUEUE_SIZE)}.
+ *
+ * @param mediaSession The {@link MediaSessionCompat}.
+ */
+ public TimelineQueueNavigator(MediaSessionCompat mediaSession) {
+ this(mediaSession, DEFAULT_MAX_QUEUE_SIZE);
+ }
+
+ /**
+ * Creates an instance for a given {@link MediaSessionCompat} and maximum queue size.
+ *
+ * If the number of windows in the {@link Player}'s {@link Timeline} exceeds {@code maxQueueSize},
+ * the media session queue will correspond to {@code maxQueueSize} windows centered on the one
+ * currently being played.
+ *
+ * @param mediaSession The {@link MediaSessionCompat}.
+ * @param maxQueueSize The maximum queue size.
+ */
+ public TimelineQueueNavigator(MediaSessionCompat mediaSession, int maxQueueSize) {
+ Assertions.checkState(maxQueueSize > 0);
+ this.mediaSession = mediaSession;
+ this.maxQueueSize = maxQueueSize;
+ activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID;
+ window = new Timeline.Window();
+ }
+
+ /**
+ * Gets the {@link MediaDescriptionCompat} for a given timeline window index.
+ *
+ *
Often artworks and icons need to be loaded asynchronously. In such a case, return a {@link
+ * MediaDescriptionCompat} without the images, load your images asynchronously off the main thread
+ * and then call {@link MediaSessionConnector#invalidateMediaSessionQueue()} to make the connector
+ * update the queue by calling this method again.
+ *
+ * @param player The current player.
+ * @param windowIndex The timeline window index for which to provide a description.
+ * @return A {@link MediaDescriptionCompat}.
+ */
+ public abstract MediaDescriptionCompat getMediaDescription(Player player, int windowIndex);
+
+ @Override
+ public long getSupportedQueueNavigatorActions(Player player) {
+ boolean enableSkipTo = false;
+ boolean enablePrevious = false;
+ boolean enableNext = false;
+ Timeline timeline = player.getCurrentTimeline();
+ if (!timeline.isEmpty() && !player.isPlayingAd()) {
+ timeline.getWindow(player.getCurrentWindowIndex(), window);
+ enableSkipTo = timeline.getWindowCount() > 1;
+ enablePrevious = window.isSeekable || !window.isDynamic || player.hasPrevious();
+ enableNext = window.isDynamic || player.hasNext();
+ }
+
+ long actions = 0;
+ if (enableSkipTo) {
+ actions |= PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM;
+ }
+ if (enablePrevious) {
+ actions |= PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
+ }
+ if (enableNext) {
+ actions |= PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
+ }
+ return actions;
+ }
+
+ @Override
+ public final void onTimelineChanged(Player player) {
+ publishFloatingQueueWindow(player);
+ }
+
+ @Override
+ public final void onCurrentWindowIndexChanged(Player player) {
+ if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID
+ || player.getCurrentTimeline().getWindowCount() > maxQueueSize) {
+ publishFloatingQueueWindow(player);
+ } else if (!player.getCurrentTimeline().isEmpty()) {
+ activeQueueItemId = player.getCurrentWindowIndex();
+ }
+ }
+
+ @Override
+ public final long getActiveQueueItemId(@Nullable Player player) {
+ return activeQueueItemId;
+ }
+
+ @Override
+ public void onSkipToPrevious(Player player, ControlDispatcher controlDispatcher) {
+ Timeline timeline = player.getCurrentTimeline();
+ if (timeline.isEmpty() || player.isPlayingAd()) {
+ return;
+ }
+ int windowIndex = player.getCurrentWindowIndex();
+ timeline.getWindow(windowIndex, window);
+ int previousWindowIndex = player.getPreviousWindowIndex();
+ if (previousWindowIndex != C.INDEX_UNSET
+ && (player.getCurrentPosition() <= MAX_POSITION_FOR_SEEK_TO_PREVIOUS
+ || (window.isDynamic && !window.isSeekable))) {
+ controlDispatcher.dispatchSeekTo(player, previousWindowIndex, C.TIME_UNSET);
+ } else {
+ controlDispatcher.dispatchSeekTo(player, windowIndex, 0);
+ }
+ }
+
+ @Override
+ public void onSkipToQueueItem(Player player, ControlDispatcher controlDispatcher, long id) {
+ Timeline timeline = player.getCurrentTimeline();
+ if (timeline.isEmpty() || player.isPlayingAd()) {
+ return;
+ }
+ int windowIndex = (int) id;
+ if (0 <= windowIndex && windowIndex < timeline.getWindowCount()) {
+ controlDispatcher.dispatchSeekTo(player, windowIndex, C.TIME_UNSET);
+ }
+ }
+
+ @Override
+ public void onSkipToNext(Player player, ControlDispatcher controlDispatcher) {
+ Timeline timeline = player.getCurrentTimeline();
+ if (timeline.isEmpty() || player.isPlayingAd()) {
+ return;
+ }
+ int windowIndex = player.getCurrentWindowIndex();
+ int nextWindowIndex = player.getNextWindowIndex();
+ if (nextWindowIndex != C.INDEX_UNSET) {
+ controlDispatcher.dispatchSeekTo(player, nextWindowIndex, C.TIME_UNSET);
+ } else if (timeline.getWindow(windowIndex, window).isDynamic) {
+ controlDispatcher.dispatchSeekTo(player, windowIndex, C.TIME_UNSET);
+ }
+ }
+
+ // CommandReceiver implementation.
+
+ @Override
+ public boolean onCommand(
+ Player player,
+ ControlDispatcher controlDispatcher,
+ String command,
+ @Nullable Bundle extras,
+ @Nullable ResultReceiver cb) {
+ return false;
+ }
+
+ // Helper methods.
+
+ private void publishFloatingQueueWindow(Player player) {
+ Timeline timeline = player.getCurrentTimeline();
+ if (timeline.isEmpty()) {
+ mediaSession.setQueue(Collections.emptyList());
+ activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID;
+ return;
+ }
+ ArrayDeque queue = new ArrayDeque<>();
+ int queueSize = Math.min(maxQueueSize, timeline.getWindowCount());
+
+ // Add the active queue item.
+ int currentWindowIndex = player.getCurrentWindowIndex();
+ queue.add(
+ new MediaSessionCompat.QueueItem(
+ getMediaDescription(player, currentWindowIndex), currentWindowIndex));
+
+ // Fill queue alternating with next and/or previous queue items.
+ int firstWindowIndex = currentWindowIndex;
+ int lastWindowIndex = currentWindowIndex;
+ boolean shuffleModeEnabled = player.getShuffleModeEnabled();
+ while ((firstWindowIndex != C.INDEX_UNSET || lastWindowIndex != C.INDEX_UNSET)
+ && queue.size() < queueSize) {
+ // Begin with next to have a longer tail than head if an even sized queue needs to be trimmed.
+ if (lastWindowIndex != C.INDEX_UNSET) {
+ lastWindowIndex =
+ timeline.getNextWindowIndex(
+ lastWindowIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled);
+ if (lastWindowIndex != C.INDEX_UNSET) {
+ queue.add(
+ new MediaSessionCompat.QueueItem(
+ getMediaDescription(player, lastWindowIndex), lastWindowIndex));
+ }
+ }
+ if (firstWindowIndex != C.INDEX_UNSET && queue.size() < queueSize) {
+ firstWindowIndex =
+ timeline.getPreviousWindowIndex(
+ firstWindowIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled);
+ if (firstWindowIndex != C.INDEX_UNSET) {
+ queue.addFirst(
+ new MediaSessionCompat.QueueItem(
+ getMediaDescription(player, firstWindowIndex), firstWindowIndex));
+ }
+ }
+ }
+ mediaSession.setQueue(new ArrayList<>(queue));
+ activeQueueItemId = currentWindowIndex;
+ }
+}
diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/package-info.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/package-info.java
new file mode 100644
index 0000000000..65c0ce080e
--- /dev/null
+++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/package-info.java
@@ -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.mediasession;
+
+import com.google.android.exoplayer2.util.NonNullApi;
diff --git a/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_all.xml b/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_all.xml
new file mode 100644
index 0000000000..dad37fa1f0
--- /dev/null
+++ b/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_all.xml
@@ -0,0 +1,23 @@
+
+
+
+
diff --git a/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_off.xml b/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_off.xml
new file mode 100644
index 0000000000..132eae0d76
--- /dev/null
+++ b/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_off.xml
@@ -0,0 +1,23 @@
+
+
+
+
diff --git a/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_one.xml b/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_one.xml
new file mode 100644
index 0000000000..d51010566a
--- /dev/null
+++ b/extensions/mediasession/src/main/res/drawable-anydpi-v21/exo_media_action_repeat_one.xml
@@ -0,0 +1,23 @@
+
+
+
+
diff --git a/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_all.png b/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_all.png
new file mode 100644
index 0000000000..2824e7847c
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_all.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_off.png b/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_off.png
new file mode 100644
index 0000000000..0b92f583da
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_off.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_one.png b/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_one.png
new file mode 100644
index 0000000000..232aa2b1cd
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-hdpi/exo_media_action_repeat_one.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_all.png b/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_all.png
new file mode 100644
index 0000000000..5c91a47519
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_all.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_off.png b/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_off.png
new file mode 100644
index 0000000000..a94abd864f
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_off.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_one.png b/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_one.png
new file mode 100644
index 0000000000..a59a985239
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-ldpi/exo_media_action_repeat_one.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_all.png b/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_all.png
new file mode 100644
index 0000000000..97f7e1cc75
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_all.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_off.png b/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_off.png
new file mode 100644
index 0000000000..6a02321702
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_off.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_one.png b/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_one.png
new file mode 100644
index 0000000000..59bac33705
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-mdpi/exo_media_action_repeat_one.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_all.png b/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_all.png
new file mode 100644
index 0000000000..2baaedecbf
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_all.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_off.png b/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_off.png
new file mode 100644
index 0000000000..2468f92f9f
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_off.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_one.png b/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_one.png
new file mode 100644
index 0000000000..4e1d53db77
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-xhdpi/exo_media_action_repeat_one.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_all.png b/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_all.png
new file mode 100644
index 0000000000..d7207ebc0d
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_all.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_off.png b/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_off.png
new file mode 100644
index 0000000000..4d6253ead6
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_off.png differ
diff --git a/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_one.png b/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_one.png
new file mode 100644
index 0000000000..d577f4ebcd
Binary files /dev/null and b/extensions/mediasession/src/main/res/drawable-xxhdpi/exo_media_action_repeat_one.png differ
diff --git a/extensions/mediasession/src/main/res/values-af/strings.xml b/extensions/mediasession/src/main/res/values-af/strings.xml
new file mode 100644
index 0000000000..92d171cfdc
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-af/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Herhaal niks
+ Herhaal een
+ Herhaal alles
+
diff --git a/extensions/mediasession/src/main/res/values-am/strings.xml b/extensions/mediasession/src/main/res/values-am/strings.xml
new file mode 100644
index 0000000000..54509a65ab
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-am/strings.xml
@@ -0,0 +1,6 @@
+
+
+ ምንም አትድገም
+ አንድ ድገም
+ ሁሉንም ድገም
+
diff --git a/extensions/mediasession/src/main/res/values-ar/strings.xml b/extensions/mediasession/src/main/res/values-ar/strings.xml
new file mode 100644
index 0000000000..707ad41a16
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ar/strings.xml
@@ -0,0 +1,6 @@
+
+
+ عدم التكرار
+ تكرار مقطع صوتي واحد
+ تكرار الكل
+
diff --git a/extensions/mediasession/src/main/res/values-az/strings.xml b/extensions/mediasession/src/main/res/values-az/strings.xml
new file mode 100644
index 0000000000..33c1f341ba
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-az/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Heç biri təkrarlanmasın
+ Biri təkrarlansın
+ Hamısı təkrarlansın
+
diff --git a/extensions/mediasession/src/main/res/values-b+sr+Latn/strings.xml b/extensions/mediasession/src/main/res/values-b+sr+Latn/strings.xml
new file mode 100644
index 0000000000..dcdcb9d977
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-b+sr+Latn/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ne ponavljaj nijednu
+ Ponovi jednu
+ Ponovi sve
+
diff --git a/extensions/mediasession/src/main/res/values-be/strings.xml b/extensions/mediasession/src/main/res/values-be/strings.xml
new file mode 100644
index 0000000000..380794f281
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-be/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Не паўтараць нічога
+ Паўтарыць адзін элемент
+ Паўтарыць усе
+
diff --git a/extensions/mediasession/src/main/res/values-bg/strings.xml b/extensions/mediasession/src/main/res/values-bg/strings.xml
new file mode 100644
index 0000000000..8a639c6cff
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-bg/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Без повтаряне
+ Повтаряне на един елемент
+ Повтаряне на всички
+
diff --git a/extensions/mediasession/src/main/res/values-bn/strings.xml b/extensions/mediasession/src/main/res/values-bn/strings.xml
new file mode 100644
index 0000000000..c39f11e570
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-bn/strings.xml
@@ -0,0 +1,6 @@
+
+
+ কোনও আইটেম আবার চালাবেন না
+ একটি আইটেম আবার চালান
+ সবগুলি আইটেম আবার চালান
+
diff --git a/extensions/mediasession/src/main/res/values-bs/strings.xml b/extensions/mediasession/src/main/res/values-bs/strings.xml
new file mode 100644
index 0000000000..44b5cb5dd6
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-bs/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ne ponavljaj
+ Ponovi jedno
+ Ponovi sve
+
diff --git a/extensions/mediasession/src/main/res/values-ca/strings.xml b/extensions/mediasession/src/main/res/values-ca/strings.xml
new file mode 100644
index 0000000000..cdb41b2b0a
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ca/strings.xml
@@ -0,0 +1,6 @@
+
+
+ No en repeteixis cap
+ Repeteix una
+ Repeteix tot
+
diff --git a/extensions/mediasession/src/main/res/values-cs/strings.xml b/extensions/mediasession/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000000..4d25b3a3ba
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-cs/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Neopakovat
+ Opakovat jednu
+ Opakovat vše
+
diff --git a/extensions/mediasession/src/main/res/values-da/strings.xml b/extensions/mediasession/src/main/res/values-da/strings.xml
new file mode 100644
index 0000000000..f74409a50b
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-da/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Gentag ingen
+ Gentag én
+ Gentag alle
+
diff --git a/extensions/mediasession/src/main/res/values-de/strings.xml b/extensions/mediasession/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000000..af3564cb41
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-de/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Keinen wiederholen
+ Einen wiederholen
+ Alle wiederholen
+
diff --git a/extensions/mediasession/src/main/res/values-el/strings.xml b/extensions/mediasession/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000000..e4f6666622
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-el/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Καμία επανάληψη
+ Επανάληψη ενός κομματιού
+ Επανάληψη όλων
+
diff --git a/extensions/mediasession/src/main/res/values-en-rAU/strings.xml b/extensions/mediasession/src/main/res/values-en-rAU/strings.xml
new file mode 100644
index 0000000000..4170902688
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-en-rAU/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Repeat none
+ Repeat one
+ Repeat all
+
diff --git a/extensions/mediasession/src/main/res/values-en-rGB/strings.xml b/extensions/mediasession/src/main/res/values-en-rGB/strings.xml
new file mode 100644
index 0000000000..4170902688
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-en-rGB/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Repeat none
+ Repeat one
+ Repeat all
+
diff --git a/extensions/mediasession/src/main/res/values-en-rIN/strings.xml b/extensions/mediasession/src/main/res/values-en-rIN/strings.xml
new file mode 100644
index 0000000000..4170902688
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-en-rIN/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Repeat none
+ Repeat one
+ Repeat all
+
diff --git a/extensions/mediasession/src/main/res/values-es-rUS/strings.xml b/extensions/mediasession/src/main/res/values-es-rUS/strings.xml
new file mode 100644
index 0000000000..700e6de4e2
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-es-rUS/strings.xml
@@ -0,0 +1,6 @@
+
+
+ No repetir
+ Repetir uno
+ Repetir todo
+
diff --git a/extensions/mediasession/src/main/res/values-es/strings.xml b/extensions/mediasession/src/main/res/values-es/strings.xml
new file mode 100644
index 0000000000..700e6de4e2
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-es/strings.xml
@@ -0,0 +1,6 @@
+
+
+ No repetir
+ Repetir uno
+ Repetir todo
+
diff --git a/extensions/mediasession/src/main/res/values-et/strings.xml b/extensions/mediasession/src/main/res/values-et/strings.xml
new file mode 100644
index 0000000000..1f629e68f5
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-et/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ära korda ühtegi
+ Korda ühte
+ Korda kõiki
+
diff --git a/extensions/mediasession/src/main/res/values-eu/strings.xml b/extensions/mediasession/src/main/res/values-eu/strings.xml
new file mode 100644
index 0000000000..34c1b9cde9
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-eu/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ez errepikatu
+ Errepikatu bat
+ Errepikatu guztiak
+
diff --git a/extensions/mediasession/src/main/res/values-fa/strings.xml b/extensions/mediasession/src/main/res/values-fa/strings.xml
new file mode 100644
index 0000000000..96e8a1e819
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-fa/strings.xml
@@ -0,0 +1,6 @@
+
+
+ تکرار هیچکدام
+ یکبار تکرار
+ تکرار همه
+
diff --git a/extensions/mediasession/src/main/res/values-fi/strings.xml b/extensions/mediasession/src/main/res/values-fi/strings.xml
new file mode 100644
index 0000000000..db1aca3f5c
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-fi/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ei uudelleentoistoa
+ Toista yksi uudelleen
+ Toista kaikki uudelleen
+
diff --git a/extensions/mediasession/src/main/res/values-fr-rCA/strings.xml b/extensions/mediasession/src/main/res/values-fr-rCA/strings.xml
new file mode 100644
index 0000000000..17e17fc8b5
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-fr-rCA/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ne rien lire en boucle
+ Lire une chanson en boucle
+ Tout lire en boucle
+
diff --git a/extensions/mediasession/src/main/res/values-fr/strings.xml b/extensions/mediasession/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000..9e35e35a0c
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-fr/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ne rien lire en boucle
+ Lire un titre en boucle
+ Tout lire en boucle
+
diff --git a/extensions/mediasession/src/main/res/values-gl/strings.xml b/extensions/mediasession/src/main/res/values-gl/strings.xml
new file mode 100644
index 0000000000..633e9669a7
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-gl/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Non repetir
+ Repetir unha pista
+ Repetir todas as pistas
+
diff --git a/extensions/mediasession/src/main/res/values-gu/strings.xml b/extensions/mediasession/src/main/res/values-gu/strings.xml
new file mode 100644
index 0000000000..ab17db814e
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-gu/strings.xml
@@ -0,0 +1,6 @@
+
+
+ કોઈ રિપીટ કરતા નહીં
+ એક રિપીટ કરો
+ બધાને રિપીટ કરો
+
diff --git a/extensions/mediasession/src/main/res/values-hi/strings.xml b/extensions/mediasession/src/main/res/values-hi/strings.xml
new file mode 100644
index 0000000000..66415ed45d
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-hi/strings.xml
@@ -0,0 +1,6 @@
+
+
+ किसी को न दोहराएं
+ एक को दोहराएं
+ सभी को दोहराएं
+
diff --git a/extensions/mediasession/src/main/res/values-hr/strings.xml b/extensions/mediasession/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000000..3b3f8170db
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-hr/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Bez ponavljanja
+ Ponovi jedno
+ Ponovi sve
+
diff --git a/extensions/mediasession/src/main/res/values-hu/strings.xml b/extensions/mediasession/src/main/res/values-hu/strings.xml
new file mode 100644
index 0000000000..392959a462
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-hu/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Nincs ismétlés
+ Egy szám ismétlése
+ Összes szám ismétlése
+
diff --git a/extensions/mediasession/src/main/res/values-hy/strings.xml b/extensions/mediasession/src/main/res/values-hy/strings.xml
new file mode 100644
index 0000000000..ba4fff8fd2
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-hy/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Չկրկնել
+ Կրկնել մեկը
+ Կրկնել բոլորը
+
diff --git a/extensions/mediasession/src/main/res/values-in/strings.xml b/extensions/mediasession/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000000..1388877293
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-in/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Jangan ulangi
+ Ulangi 1
+ Ulangi semua
+
diff --git a/extensions/mediasession/src/main/res/values-is/strings.xml b/extensions/mediasession/src/main/res/values-is/strings.xml
new file mode 100644
index 0000000000..9db4df88dd
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-is/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Endurtaka ekkert
+ Endurtaka eitt
+ Endurtaka allt
+
diff --git a/extensions/mediasession/src/main/res/values-it/strings.xml b/extensions/mediasession/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000000..8922453204
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-it/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Non ripetere nulla
+ Ripeti uno
+ Ripeti tutto
+
diff --git a/extensions/mediasession/src/main/res/values-iw/strings.xml b/extensions/mediasession/src/main/res/values-iw/strings.xml
new file mode 100644
index 0000000000..193a3ac606
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-iw/strings.xml
@@ -0,0 +1,6 @@
+
+
+ אל תחזור על אף פריט
+ חזור על פריט אחד
+ חזור על הכול
+
diff --git a/extensions/mediasession/src/main/res/values-ja/strings.xml b/extensions/mediasession/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000000..d1cd378d53
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ja/strings.xml
@@ -0,0 +1,6 @@
+
+
+ リピートなし
+ 1 曲をリピート
+ 全曲をリピート
+
diff --git a/extensions/mediasession/src/main/res/values-ka/strings.xml b/extensions/mediasession/src/main/res/values-ka/strings.xml
new file mode 100644
index 0000000000..5acf78cbf2
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ka/strings.xml
@@ -0,0 +1,6 @@
+
+
+ არცერთის გამეორება
+ ერთის გამეორება
+ ყველას გამეორება
+
diff --git a/extensions/mediasession/src/main/res/values-kk/strings.xml b/extensions/mediasession/src/main/res/values-kk/strings.xml
new file mode 100644
index 0000000000..d13ea893a0
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-kk/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ешқайсысын қайталамау
+ Біреуін қайталау
+ Барлығын қайталау
+
diff --git a/extensions/mediasession/src/main/res/values-km/strings.xml b/extensions/mediasession/src/main/res/values-km/strings.xml
new file mode 100644
index 0000000000..8cf4a2d344
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-km/strings.xml
@@ -0,0 +1,6 @@
+
+
+ មិនលេងឡើងវិញ
+ លេងឡើងវិញម្ដង
+ លេងឡើងវិញទាំងអស់
+
diff --git a/extensions/mediasession/src/main/res/values-kn/strings.xml b/extensions/mediasession/src/main/res/values-kn/strings.xml
new file mode 100644
index 0000000000..2dea20044a
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-kn/strings.xml
@@ -0,0 +1,6 @@
+
+
+ ಯಾವುದನ್ನೂ ಪುನರಾವರ್ತಿಸಬೇಡಿ
+ ಒಂದನ್ನು ಪುನರಾವರ್ತಿಸಿ
+ ಎಲ್ಲವನ್ನು ಪುನರಾವರ್ತಿಸಿ
+
diff --git a/extensions/mediasession/src/main/res/values-ko/strings.xml b/extensions/mediasession/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000000..b561abc1d7
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ko/strings.xml
@@ -0,0 +1,6 @@
+
+
+ 반복 안함
+ 현재 미디어 반복
+ 모두 반복
+
diff --git a/extensions/mediasession/src/main/res/values-ky/strings.xml b/extensions/mediasession/src/main/res/values-ky/strings.xml
new file mode 100644
index 0000000000..9352c7c4ca
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ky/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Кайталанбасын
+ Бирөөнү кайталоо
+ Баарын кайталоо
+
diff --git a/extensions/mediasession/src/main/res/values-lo/strings.xml b/extensions/mediasession/src/main/res/values-lo/strings.xml
new file mode 100644
index 0000000000..e89ee44e5e
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-lo/strings.xml
@@ -0,0 +1,6 @@
+
+
+ ບໍ່ຫຼິ້ນຊ້ຳ
+ ຫຼິ້ນຊໍ້າ
+ ຫຼິ້ນຊ້ຳທັງໝົດ
+
diff --git a/extensions/mediasession/src/main/res/values-lt/strings.xml b/extensions/mediasession/src/main/res/values-lt/strings.xml
new file mode 100644
index 0000000000..20eb0e9b1f
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-lt/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Nekartoti nieko
+ Kartoti vieną
+ Kartoti viską
+
diff --git a/extensions/mediasession/src/main/res/values-lv/strings.xml b/extensions/mediasession/src/main/res/values-lv/strings.xml
new file mode 100644
index 0000000000..44cddec124
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-lv/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Neatkārtot nevienu
+ Atkārtot vienu
+ Atkārtot visu
+
diff --git a/extensions/mediasession/src/main/res/values-mk/strings.xml b/extensions/mediasession/src/main/res/values-mk/strings.xml
new file mode 100644
index 0000000000..0906c35cc3
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-mk/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Не повторувај ниту една
+ Повтори една
+ Повтори ги сите
+
diff --git a/extensions/mediasession/src/main/res/values-ml/strings.xml b/extensions/mediasession/src/main/res/values-ml/strings.xml
new file mode 100644
index 0000000000..1f3f023c88
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ml/strings.xml
@@ -0,0 +1,6 @@
+
+
+ ഒന്നും ആവർത്തിക്കരുത്
+ ഒരെണ്ണം ആവർത്തിക്കുക
+ എല്ലാം ആവർത്തിക്കുക
+
diff --git a/extensions/mediasession/src/main/res/values-mn/strings.xml b/extensions/mediasession/src/main/res/values-mn/strings.xml
new file mode 100644
index 0000000000..4167e40548
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-mn/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Алийг нь ч дахин тоглуулахгүй
+ Одоогийн тоглуулж буй медиаг дахин тоглуулах
+ Бүгдийг нь дахин тоглуулах
+
diff --git a/extensions/mediasession/src/main/res/values-mr/strings.xml b/extensions/mediasession/src/main/res/values-mr/strings.xml
new file mode 100644
index 0000000000..fe42b346bf
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-mr/strings.xml
@@ -0,0 +1,6 @@
+
+
+ रीपीट करू नका
+ एक रीपीट करा
+ सर्व रीपीट करा
+
diff --git a/extensions/mediasession/src/main/res/values-ms/strings.xml b/extensions/mediasession/src/main/res/values-ms/strings.xml
new file mode 100644
index 0000000000..5735d50947
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ms/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Jangan ulang
+ Ulang satu
+ Ulang semua
+
diff --git a/extensions/mediasession/src/main/res/values-my/strings.xml b/extensions/mediasession/src/main/res/values-my/strings.xml
new file mode 100644
index 0000000000..11677e06f7
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-my/strings.xml
@@ -0,0 +1,6 @@
+
+
+ မည်သည်ကိုမျှ ပြန်မကျော့ရန်
+ တစ်ခုကို ပြန်ကျော့ရန်
+ အားလုံး ပြန်ကျော့ရန်
+
diff --git a/extensions/mediasession/src/main/res/values-nb/strings.xml b/extensions/mediasession/src/main/res/values-nb/strings.xml
new file mode 100644
index 0000000000..eab972792f
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-nb/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Ikke gjenta noen
+ Gjenta én
+ Gjenta alle
+
diff --git a/extensions/mediasession/src/main/res/values-ne/strings.xml b/extensions/mediasession/src/main/res/values-ne/strings.xml
new file mode 100644
index 0000000000..0ef156ed57
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ne/strings.xml
@@ -0,0 +1,6 @@
+
+
+ कुनै पनि नदोहोर्याउनुहोस्
+ एउटा दोहोर्याउनुहोस्
+ सबै दोहोर्याउनुहोस्
+
diff --git a/extensions/mediasession/src/main/res/values-nl/strings.xml b/extensions/mediasession/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000000..b1309f40d6
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-nl/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Niets herhalen
+ Eén herhalen
+ Alles herhalen
+
diff --git a/extensions/mediasession/src/main/res/values-pa/strings.xml b/extensions/mediasession/src/main/res/values-pa/strings.xml
new file mode 100644
index 0000000000..0b7d72841c
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-pa/strings.xml
@@ -0,0 +1,6 @@
+
+
+ ਕਿਸੇ ਨੂੰ ਨਾ ਦੁਹਰਾਓ
+ ਇੱਕ ਵਾਰ ਦੁਹਰਾਓ
+ ਸਾਰਿਆਂ ਨੂੰ ਦੁਹਰਾਓ
+
diff --git a/extensions/mediasession/src/main/res/values-pl/strings.xml b/extensions/mediasession/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000..5654c0f095
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-pl/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Nie powtarzaj
+ Powtórz jeden
+ Powtórz wszystkie
+
diff --git a/extensions/mediasession/src/main/res/values-pt-rPT/strings.xml b/extensions/mediasession/src/main/res/values-pt-rPT/strings.xml
new file mode 100644
index 0000000000..612be4b8f4
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-pt-rPT/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Não repetir nenhum
+ Repetir um
+ Repetir tudo
+
diff --git a/extensions/mediasession/src/main/res/values-pt/strings.xml b/extensions/mediasession/src/main/res/values-pt/strings.xml
new file mode 100644
index 0000000000..a858ea4fc6
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-pt/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Não repetir
+ Repetir uma
+ Repetir tudo
+
diff --git a/extensions/mediasession/src/main/res/values-ro/strings.xml b/extensions/mediasession/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000000..a88088fb0c
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ro/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Nu repetați niciunul
+ Repetați unul
+ Repetați-le pe toate
+
diff --git a/extensions/mediasession/src/main/res/values-ru/strings.xml b/extensions/mediasession/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000000..f350724813
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ru/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Не повторять
+ Повторять трек
+ Повторять все
+
diff --git a/extensions/mediasession/src/main/res/values-si/strings.xml b/extensions/mediasession/src/main/res/values-si/strings.xml
new file mode 100644
index 0000000000..0d86d38e7f
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-si/strings.xml
@@ -0,0 +1,6 @@
+
+
+ කිසිවක් පුනරාවර්තනය නොකරන්න
+ එකක් පුනරාවර්තනය කරන්න
+ සියල්ල නැවත කරන්න
+
diff --git a/extensions/mediasession/src/main/res/values-sk/strings.xml b/extensions/mediasession/src/main/res/values-sk/strings.xml
new file mode 100644
index 0000000000..9c0235daec
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-sk/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Neopakovať
+ Opakovať jednu
+ Opakovať všetko
+
diff --git a/extensions/mediasession/src/main/res/values-sl/strings.xml b/extensions/mediasession/src/main/res/values-sl/strings.xml
new file mode 100644
index 0000000000..9ee3add8bc
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-sl/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Brez ponavljanja
+ Ponavljanje ene
+ Ponavljanje vseh
+
diff --git a/extensions/mediasession/src/main/res/values-sq/strings.xml b/extensions/mediasession/src/main/res/values-sq/strings.xml
new file mode 100644
index 0000000000..2461dcf0ca
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-sq/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Mos përsërit asnjë
+ Përsërit një
+ Përsërit të gjitha
+
diff --git a/extensions/mediasession/src/main/res/values-sr/strings.xml b/extensions/mediasession/src/main/res/values-sr/strings.xml
new file mode 100644
index 0000000000..71edd5c341
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-sr/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Не понављај ниједну
+ Понови једну
+ Понови све
+
diff --git a/extensions/mediasession/src/main/res/values-sv/strings.xml b/extensions/mediasession/src/main/res/values-sv/strings.xml
new file mode 100644
index 0000000000..0956ac9fc7
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-sv/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Upprepa inga
+ Upprepa en
+ Upprepa alla
+
diff --git a/extensions/mediasession/src/main/res/values-sw/strings.xml b/extensions/mediasession/src/main/res/values-sw/strings.xml
new file mode 100644
index 0000000000..0010774a6f
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-sw/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Usirudie yoyote
+ Rudia moja
+ Rudia zote
+
diff --git a/extensions/mediasession/src/main/res/values-ta/strings.xml b/extensions/mediasession/src/main/res/values-ta/strings.xml
new file mode 100644
index 0000000000..b6fbcca4a1
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ta/strings.xml
@@ -0,0 +1,6 @@
+
+
+ எதையும் மீண்டும் இயக்காதே
+ இதை மட்டும் மீண்டும் இயக்கு
+ அனைத்தையும் மீண்டும் இயக்கு
+
diff --git a/extensions/mediasession/src/main/res/values-te/strings.xml b/extensions/mediasession/src/main/res/values-te/strings.xml
new file mode 100644
index 0000000000..b1249c7400
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-te/strings.xml
@@ -0,0 +1,6 @@
+
+
+ దేన్నీ పునరావృతం చేయకండి
+ ఒకదాన్ని పునరావృతం చేయండి
+ అన్నింటినీ పునరావృతం చేయండి
+
diff --git a/extensions/mediasession/src/main/res/values-th/strings.xml b/extensions/mediasession/src/main/res/values-th/strings.xml
new file mode 100644
index 0000000000..bec0410a44
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-th/strings.xml
@@ -0,0 +1,6 @@
+
+
+ ไม่เล่นซ้ำ
+ เล่นซ้ำเพลงเดียว
+ เล่นซ้ำทั้งหมด
+
diff --git a/extensions/mediasession/src/main/res/values-tl/strings.xml b/extensions/mediasession/src/main/res/values-tl/strings.xml
new file mode 100644
index 0000000000..6f8d8f4f88
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-tl/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Walang uulitin
+ Mag-ulit ng isa
+ Ulitin lahat
+
diff --git a/extensions/mediasession/src/main/res/values-tr/strings.xml b/extensions/mediasession/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000000..20c05d9fa6
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-tr/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Hiçbirini tekrarlama
+ Bir şarkıyı tekrarla
+ Tümünü tekrarla
+
diff --git a/extensions/mediasession/src/main/res/values-uk/strings.xml b/extensions/mediasession/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000000..44db07ef9c
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-uk/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Не повторювати
+ Повторити 1
+ Повторити всі
+
diff --git a/extensions/mediasession/src/main/res/values-ur/strings.xml b/extensions/mediasession/src/main/res/values-ur/strings.xml
new file mode 100644
index 0000000000..3860986e9c
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-ur/strings.xml
@@ -0,0 +1,6 @@
+
+
+ کسی کو نہ دہرائیں
+ ایک کو دہرائیں
+ سبھی کو دہرائیں
+
diff --git a/extensions/mediasession/src/main/res/values-uz/strings.xml b/extensions/mediasession/src/main/res/values-uz/strings.xml
new file mode 100644
index 0000000000..3424c9f583
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-uz/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Takrorlanmasin
+ Bittasini takrorlash
+ Hammasini takrorlash
+
diff --git a/extensions/mediasession/src/main/res/values-vi/strings.xml b/extensions/mediasession/src/main/res/values-vi/strings.xml
new file mode 100644
index 0000000000..9de007cdb9
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-vi/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Không lặp lại
+ Lặp lại một
+ Lặp lại tất cả
+
diff --git a/extensions/mediasession/src/main/res/values-zh-rCN/strings.xml b/extensions/mediasession/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..4d1f1346b9
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,6 @@
+
+
+ 不重复播放
+ 重复播放一项
+ 全部重复播放
+
diff --git a/extensions/mediasession/src/main/res/values-zh-rHK/strings.xml b/extensions/mediasession/src/main/res/values-zh-rHK/strings.xml
new file mode 100644
index 0000000000..e0ec62c533
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-zh-rHK/strings.xml
@@ -0,0 +1,6 @@
+
+
+ 不重複播放
+ 重複播放一個
+ 全部重複播放
+
diff --git a/extensions/mediasession/src/main/res/values-zh-rTW/strings.xml b/extensions/mediasession/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..5b91fbd9fe
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,6 @@
+
+
+ 不重複播放
+ 重複播放單一項目
+ 重複播放所有項目
+
diff --git a/extensions/mediasession/src/main/res/values-zu/strings.xml b/extensions/mediasession/src/main/res/values-zu/strings.xml
new file mode 100644
index 0000000000..a6299ba987
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values-zu/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Phinda okungekho
+ Phinda okukodwa
+ Phinda konke
+
diff --git a/extensions/mediasession/src/main/res/values/strings.xml b/extensions/mediasession/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..015fd04cea
--- /dev/null
+++ b/extensions/mediasession/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ Repeat none
+
+ Repeat one
+
+ Repeat all
+
diff --git a/extensions/okhttp/README.md b/extensions/okhttp/README.md
index d84dcb44ec..2f9893fe3b 100644
--- a/extensions/okhttp/README.md
+++ b/extensions/okhttp/README.md
@@ -1,30 +1,64 @@
-# ExoPlayer OkHttp Extension #
+# ExoPlayer OkHttp extension #
-## Description ##
-
-The OkHttp Extension is an [HttpDataSource][] implementation using Square's
+The OkHttp extension is an [HttpDataSource][] implementation using Square's
[OkHttp][].
+[HttpDataSource]: https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/upstream/HttpDataSource.html
+[OkHttp]: https://square.github.io/okhttp/
+
+## License note ##
+
+Please note that whilst the code in this repository is licensed under
+[Apache 2.0][], using this extension requires depending on OkHttp, which is
+licensed separately.
+
+[Apache 2.0]: https://github.com/google/ExoPlayer/blob/release-v2/LICENSE
+
+## Getting the extension ##
+
+The easiest way to use the extension is to add it as a gradle dependency:
+
+```gradle
+implementation 'com.google.android.exoplayer:extension-okhttp:2.X.X'
+```
+
+where `2.X.X` is the version, which must match the version of the ExoPlayer
+library being used.
+
+Alternatively, you can clone the ExoPlayer repository and depend on the module
+locally. Instructions for doing this can be found in ExoPlayer's
+[top level README][].
+
+[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
+
## Using the extension ##
-The easiest way to use the extension is to add it as a gradle dependency. You
-need to make sure you have the jcenter repository included in the `build.gradle`
-file in the root of your project:
+ExoPlayer requests data through `DataSource` instances. These instances are
+either instantiated and injected from application code, or obtained from
+instances of `DataSource.Factory` that are instantiated and injected from
+application code.
-```gradle
-repositories {
- jcenter()
-}
+If your application only needs to play http(s) content, using the OkHttp
+extension is as simple as updating any `DataSource`s and `DataSource.Factory`
+instantiations in your application code to use `OkHttpDataSource` and
+`OkHttpDataSourceFactory` respectively. If your application also needs to play
+non-http(s) content such as local files, use
```
-
-Next, include the following in your module's `build.gradle` file:
-
-```gradle
-compile 'com.google.android.exoplayer:extension-okhttp:rX.X.X'
+new DefaultDataSource(
+ ...
+ new OkHttpDataSource(...) /* baseDataSource argument */);
```
+and
+```
+new DefaultDataSourceFactory(
+ ...
+ new OkHttpDataSourceFactory(...) /* baseDataSourceFactory argument */);
+```
+respectively.
-where `rX.X.X` is the version, which must match the version of the ExoPlayer
-library being used.
+## Links ##
-[HttpDataSource]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/upstream/HttpDataSource.html
-[OkHttp]: https://square.github.io/okhttp/
+* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.okhttp.*`
+ belong to this module.
+
+[Javadoc]: https://exoplayer.dev/doc/reference/index.html
diff --git a/extensions/okhttp/build.gradle b/extensions/okhttp/build.gradle
index f47f1a8556..41eac7c661 100644
--- a/extensions/okhttp/build.gradle
+++ b/extensions/okhttp/build.gradle
@@ -11,28 +11,37 @@
// WITHOUT 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
- buildToolsVersion project.ext.buildToolsVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
defaultConfig {
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
+ consumerProguardFiles 'proguard-rules.txt'
}
- lintOptions {
- // See: https://github.com/square/okio/issues/58
- warning 'InvalidPackage'
- }
+ testOptions.unitTests.includeAndroidResources = true
}
dependencies {
- compile project(':library-core')
- compile('com.squareup.okhttp3:okhttp:3.6.0') {
- exclude group: 'org.json'
- }
+ implementation project(modulePrefix + 'library-core')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ testImplementation project(modulePrefix + 'testutils')
+ testImplementation 'org.robolectric:robolectric:' + robolectricVersion
+ // Do not update to 3.13.X or later until minSdkVersion is increased to 21:
+ // https://cashapp.github.io/2019-02-05/okhttp-3-13-requires-android-5
+ // Since OkHttp is distributed as a jar rather than an aar, Gradle won't
+ // stop us from making this mistake!
+ api 'com.squareup.okhttp3:okhttp:3.12.8'
}
ext {
diff --git a/extensions/okhttp/proguard-rules.txt b/extensions/okhttp/proguard-rules.txt
new file mode 100644
index 0000000000..50d774a509
--- /dev/null
+++ b/extensions/okhttp/proguard-rules.txt
@@ -0,0 +1,8 @@
+# Proguard rules specific to the OkHttp extension.
+
+# Options specified by https://github.com/square/okhttp/blob/master/README.md
+-dontwarn okhttp3.**
+-dontwarn okio.**
+-dontwarn javax.annotation.**
+-dontwarn org.conscrypt.**
+-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
diff --git a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java
index 167fc68e86..3053961f49 100644
--- a/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java
+++ b/extensions/okhttp/src/main/java/com/google/android/exoplayer2/ext/okhttp/OkHttpDataSource.java
@@ -15,23 +15,27 @@
*/
package com.google.android.exoplayer2.ext.okhttp;
+import static com.google.android.exoplayer2.util.Util.castNonNull;
+
import android.net.Uri;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
+import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
+import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.HttpDataSource;
-import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Predicate;
+import com.google.android.exoplayer2.util.Util;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.atomic.AtomicReference;
import okhttp3.CacheControl;
import okhttp3.Call;
import okhttp3.HttpUrl;
@@ -39,26 +43,34 @@ import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
+import okhttp3.ResponseBody;
/**
* An {@link HttpDataSource} that delegates to Square's {@link Call.Factory}.
+ *
+ * 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 OkHttpDataSource implements HttpDataSource {
+public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
- private static final AtomicReference skipBufferReference = new AtomicReference<>();
+ static {
+ ExoPlayerLibraryInfo.registerModule("goog.exo.okhttp");
+ }
- @NonNull private final Call.Factory callFactory;
- @NonNull private final RequestProperties requestProperties;
+ private static final byte[] SKIP_BUFFER = new byte[4096];
+
+ private final Call.Factory callFactory;
+ private final RequestProperties requestProperties;
@Nullable private final String userAgent;
- @Nullable private final Predicate contentTypePredicate;
- @Nullable private final TransferListener super OkHttpDataSource> listener;
@Nullable private final CacheControl cacheControl;
@Nullable private final RequestProperties defaultRequestProperties;
- private DataSpec dataSpec;
- private Response response;
- private InputStream responseByteStream;
+ @Nullable private Predicate contentTypePredicate;
+ @Nullable private DataSpec dataSpec;
+ @Nullable private Response response;
+ @Nullable private InputStream responseByteStream;
private boolean opened;
private long bytesToSkip;
@@ -71,62 +83,109 @@ public class OkHttpDataSource implements HttpDataSource {
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the source.
* @param userAgent An optional User-Agent string.
- * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
- * predicate then a InvalidContentTypeException} is thrown from {@link #open(DataSpec)}.
*/
- public OkHttpDataSource(@NonNull Call.Factory callFactory, @Nullable String userAgent,
- @Nullable Predicate contentTypePredicate) {
- this(callFactory, userAgent, contentTypePredicate, null);
+ public OkHttpDataSource(Call.Factory callFactory, @Nullable String userAgent) {
+ this(callFactory, userAgent, /* cacheControl= */ null, /* defaultRequestProperties= */ null);
}
/**
* @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
* by the source.
* @param userAgent An optional User-Agent string.
- * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
- * predicate then a {@link InvalidContentTypeException} is thrown from
- * {@link #open(DataSpec)}.
- * @param listener An optional listener.
- */
- public OkHttpDataSource(@NonNull Call.Factory callFactory, @Nullable String userAgent,
- @Nullable Predicate contentTypePredicate,
- @Nullable TransferListener super OkHttpDataSource> listener) {
- this(callFactory, userAgent, contentTypePredicate, listener, null, null);
- }
-
- /**
- * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
- * by the source.
- * @param userAgent An optional User-Agent string.
- * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
- * predicate then a {@link InvalidContentTypeException} is thrown from
- * {@link #open(DataSpec)}.
- * @param listener An optional listener.
* @param cacheControl An optional {@link CacheControl} for setting the Cache-Control header.
- * @param defaultRequestProperties The optional default {@link RequestProperties} to be sent to
- * the server as HTTP headers on every request.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
*/
- public OkHttpDataSource(@NonNull Call.Factory callFactory, @Nullable String userAgent,
- @Nullable Predicate contentTypePredicate,
- @Nullable TransferListener super OkHttpDataSource> listener,
- @Nullable CacheControl cacheControl, @Nullable RequestProperties defaultRequestProperties) {
+ public OkHttpDataSource(
+ Call.Factory callFactory,
+ @Nullable String userAgent,
+ @Nullable CacheControl cacheControl,
+ @Nullable RequestProperties defaultRequestProperties) {
+ super(/* isNetwork= */ true);
this.callFactory = Assertions.checkNotNull(callFactory);
this.userAgent = userAgent;
- this.contentTypePredicate = contentTypePredicate;
- this.listener = listener;
this.cacheControl = cacheControl;
this.defaultRequestProperties = defaultRequestProperties;
this.requestProperties = new RequestProperties();
}
+ /**
+ * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
+ * by the source.
+ * @param userAgent An optional User-Agent string.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then a {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @deprecated Use {@link #OkHttpDataSource(Call.Factory, String)} and {@link
+ * #setContentTypePredicate(Predicate)}.
+ */
+ @Deprecated
+ public OkHttpDataSource(
+ Call.Factory callFactory,
+ @Nullable String userAgent,
+ @Nullable Predicate contentTypePredicate) {
+ this(
+ callFactory,
+ userAgent,
+ contentTypePredicate,
+ /* cacheControl= */ null,
+ /* defaultRequestProperties= */ null);
+ }
+
+ /**
+ * @param callFactory A {@link Call.Factory} (typically an {@link okhttp3.OkHttpClient}) for use
+ * by the source.
+ * @param userAgent An optional User-Agent string.
+ * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the
+ * predicate then a {@link InvalidContentTypeException} is thrown from {@link
+ * #open(DataSpec)}.
+ * @param cacheControl An optional {@link CacheControl} for setting the Cache-Control header.
+ * @param defaultRequestProperties Optional default {@link RequestProperties} to be sent to the
+ * server as HTTP headers on every request.
+ * @deprecated Use {@link #OkHttpDataSource(Call.Factory, String, CacheControl,
+ * RequestProperties)} and {@link #setContentTypePredicate(Predicate)}.
+ */
+ @Deprecated
+ public OkHttpDataSource(
+ Call.Factory callFactory,
+ @Nullable String userAgent,
+ @Nullable Predicate contentTypePredicate,
+ @Nullable CacheControl cacheControl,
+ @Nullable RequestProperties defaultRequestProperties) {
+ super(/* isNetwork= */ true);
+ this.callFactory = Assertions.checkNotNull(callFactory);
+ this.userAgent = userAgent;
+ this.contentTypePredicate = contentTypePredicate;
+ this.cacheControl = cacheControl;
+ this.defaultRequestProperties = defaultRequestProperties;
+ this.requestProperties = new RequestProperties();
+ }
+
+ /**
+ * 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 contentTypePredicate) {
+ this.contentTypePredicate = contentTypePredicate;
+ }
+
@Override
+ @Nullable
public Uri getUri() {
return response == null ? null : Uri.parse(response.request().url().toString());
}
+ @Override
+ public int getResponseCode() {
+ return response == null ? -1 : response.code();
+ }
+
@Override
public Map> getResponseHeaders() {
- return response == null ? null : response.headers().toMultimap();
+ return response == null ? Collections.emptyMap() : response.headers().toMultimap();
}
@Override
@@ -152,23 +211,29 @@ public class OkHttpDataSource implements HttpDataSource {
this.dataSpec = dataSpec;
this.bytesRead = 0;
this.bytesSkipped = 0;
+ transferInitializing(dataSpec);
+
Request request = makeRequest(dataSpec);
+ Response response;
+ ResponseBody responseBody;
try {
- response = callFactory.newCall(request).execute();
- responseByteStream = response.body().byteStream();
+ this.response = callFactory.newCall(request).execute();
+ response = this.response;
+ responseBody = Assertions.checkNotNull(response.body());
+ responseByteStream = responseBody.byteStream();
} catch (IOException e) {
- throw new HttpDataSourceException("Unable to connect to " + dataSpec.uri.toString(), e,
- dataSpec, HttpDataSourceException.TYPE_OPEN);
+ throw new HttpDataSourceException(
+ "Unable to connect to " + dataSpec.uri, e, dataSpec, HttpDataSourceException.TYPE_OPEN);
}
int responseCode = response.code();
// Check for a valid response code.
if (!response.isSuccessful()) {
- Map> headers = request.headers().toMultimap();
+ Map> headers = response.headers().toMultimap();
closeConnectionQuietly();
- InvalidResponseCodeException exception = new InvalidResponseCodeException(
- responseCode, headers, dataSpec);
+ InvalidResponseCodeException exception =
+ new InvalidResponseCodeException(responseCode, response.message(), headers, dataSpec);
if (responseCode == 416) {
exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE));
}
@@ -176,8 +241,8 @@ public class OkHttpDataSource implements HttpDataSource {
}
// Check for a valid content type.
- MediaType mediaType = response.body().contentType();
- String contentType = mediaType != null ? mediaType.toString() : null;
+ MediaType mediaType = responseBody.contentType();
+ String contentType = mediaType != null ? mediaType.toString() : "";
if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) {
closeConnectionQuietly();
throw new InvalidContentTypeException(contentType, dataSpec);
@@ -192,14 +257,12 @@ public class OkHttpDataSource implements HttpDataSource {
if (dataSpec.length != C.LENGTH_UNSET) {
bytesToRead = dataSpec.length;
} else {
- long contentLength = response.body().contentLength();
+ long contentLength = responseBody.contentLength();
bytesToRead = contentLength != -1 ? (contentLength - bytesToSkip) : C.LENGTH_UNSET;
}
opened = true;
- if (listener != null) {
- listener.onTransferStart(this, dataSpec);
- }
+ transferStarted(dataSpec);
return bytesToRead;
}
@@ -210,7 +273,8 @@ public class OkHttpDataSource implements HttpDataSource {
skipInternal();
return readInternal(buffer, offset, readLength);
} catch (IOException e) {
- throw new HttpDataSourceException(e, dataSpec, HttpDataSourceException.TYPE_READ);
+ throw new HttpDataSourceException(
+ e, Assertions.checkNotNull(dataSpec), HttpDataSourceException.TYPE_READ);
}
}
@@ -218,9 +282,7 @@ public class OkHttpDataSource implements HttpDataSource {
public void close() throws HttpDataSourceException {
if (opened) {
opened = false;
- if (listener != null) {
- listener.onTransferEnd(this);
- }
+ transferEnded();
closeConnectionQuietly();
}
}
@@ -257,27 +319,34 @@ public class OkHttpDataSource implements HttpDataSource {
return bytesToRead == C.LENGTH_UNSET ? bytesToRead : bytesToRead - bytesRead;
}
- /**
- * Establishes a connection.
- */
- private Request makeRequest(DataSpec dataSpec) {
+ /** Establishes a connection. */
+ private Request makeRequest(DataSpec dataSpec) throws HttpDataSourceException {
long position = dataSpec.position;
long length = dataSpec.length;
- boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP);
HttpUrl url = HttpUrl.parse(dataSpec.uri.toString());
+ if (url == null) {
+ throw new HttpDataSourceException(
+ "Malformed URL", dataSpec, HttpDataSourceException.TYPE_OPEN);
+ }
+
Request.Builder builder = new Request.Builder().url(url);
if (cacheControl != null) {
builder.cacheControl(cacheControl);
}
+
+ Map