diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 7b843e4f5b9..eb983e7524b 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -13,7 +13,7 @@
* Bypass sniffing in `ProgressiveMediaPeriod` in case a single extractor is
provided ([#6325](https://github.com/google/ExoPlayer/issues/6325)).
* Surface information provided by methods `isHardwareAccelerated`,
- `isSoftwareOnly` and `isVendor` added in Android Q in `MediaCodecInfo` class
+ `isSoftwareOnly` and `isVendor` added in Android 10 in `MediaCodecInfo` class
([#5839](https://github.com/google/ExoPlayer/issues/5839)).
* Update `DefaultTrackSelector` to apply a viewport constraint for the default
display by default.
@@ -33,7 +33,7 @@
`SourceInfoRefreshListener` anymore. Instead make it accessible through
`Player.getCurrentManifest()` and `Timeline.Window.manifest`. Also rename
`SourceInfoRefreshListener` to `MediaSourceCaller`.
-* Set `compileSdkVersion` to 29 to use Android Q APIs.
+* Set `compileSdkVersion` to 29 to use Android 10 APIs.
* Add `enable` and `disable` methods to `MediaSource` to improve resource
management in playlists.
* Text selection logic:
@@ -59,6 +59,8 @@
* Fix Dolby Vision fallback to AVC and HEVC.
* Add top-level playlist API
([#6161](https://github.com/google/ExoPlayer/issues/6161)).
+* Add demo app to show how to use the Android 10 `SurfaceControl` API with
+ ExoPlayer ([#677](https://github.com/google/ExoPlayer/issues/677)).
### 2.10.5 (2019-09-20) ###
diff --git a/demos/surface/README.md b/demos/surface/README.md
new file mode 100644
index 00000000000..312259dbf68
--- /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/demos/surface/build.gradle b/demos/surface/build.gradle
new file mode 100644
index 00000000000..1f653f160e0
--- /dev/null
+++ b/demos/surface/build.gradle
@@ -0,0 +1,51 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ versionName project.ext.releaseVersion
+ versionCode project.ext.releaseVersionCode
+ minSdkVersion 29
+ targetSdkVersion 29
+ }
+
+ buildTypes {
+ release {
+ shrinkResources true
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android.txt')
+ }
+ }
+
+ lintOptions {
+ // This demo app does not have translations.
+ disable 'MissingTranslation'
+ }
+}
+
+dependencies {
+ 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 00000000000..c33a9e646bc
--- /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 00000000000..8d4450b6b3a
--- /dev/null
+++ b/demos/surface/src/main/java/com/google/android/exoplayer2/surfacedemo/MainActivity.java
@@ -0,0 +1,284 @@
+/*
+ * 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.util.Log;
+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.FrameworkMediaCrypto;
+import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
+import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
+import com.google.android.exoplayer2.drm.UnsupportedDrmException;
+import com.google.android.exoplayer2.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.Util;
+import java.util.UUID;
+
+/** Activity that demonstrates use of {@link SurfaceControl} with ExoPlayer. */
+public final class MainActivity extends Activity {
+
+ private static final String TAG = "MainActivity";
+ 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;
+ private PlayerControlView playerControlView;
+ private SurfaceView fullScreenView;
+ private SurfaceView nonFullScreenView;
+ @Nullable private SurfaceView currentOutputView;
+
+ private static SimpleExoPlayer player;
+ private static FrameworkMediaDrm mediaDrm;
+ private static SurfaceControl surfaceControl;
+ private static Surface videoSurface;
+
+ @Override
+ protected void onCreate(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);
+ 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(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);
+ 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 = 400;
+ layoutParams.height = 400;
+ layoutParams.columnSpec = GridLayout.spec(i % 3);
+ layoutParams.rowSpec = GridLayout.spec(i / 3);
+ 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.setPlayer(player);
+ playerControlView.show();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ playerControlView.setPlayer(null);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (isOwner && isFinishing()) {
+ if (surfaceControl != null) {
+ surfaceControl.release();
+ surfaceControl = null;
+ videoSurface.release();
+ }
+ if (player != null) {
+ player.release();
+ player = null;
+ }
+ if (mediaDrm != null) {
+ mediaDrm.release();
+ mediaDrm = null;
+ }
+ }
+ }
+
+ private void initializePlayer() {
+ Intent intent = getIntent();
+ String action = intent.getAction();
+ Uri uri = ACTION_VIEW.equals(action) ? intent.getData() : Uri.parse(DEFAULT_MEDIA_URI);
+ String userAgent = Util.getUserAgent(this, getString(R.string.application_name));
+ DrmSessionManager drmSessionManager =
+ DrmSessionManager.getDummyDrmSessionManager();
+ if (intent.hasExtra(DRM_SCHEME_EXTRA)) {
+ String drmLicenseUrl = intent.getStringExtra(DRM_LICENSE_URL_EXTRA);
+ try {
+ UUID drmSchemeUuid = Util.getDrmUuid(intent.getStringExtra(DRM_SCHEME_EXTRA));
+ HttpDataSource.Factory licenseDataSourceFactory =
+ new DefaultHttpDataSourceFactory(userAgent);
+ HttpMediaDrmCallback drmCallback =
+ new HttpMediaDrmCallback(drmLicenseUrl, licenseDataSourceFactory);
+ mediaDrm = FrameworkMediaDrm.newInstance(drmSchemeUuid);
+ drmSessionManager =
+ new DefaultDrmSessionManager<>(
+ drmSchemeUuid, mediaDrm, drmCallback, /* optionalKeyRequestParameters= */ null);
+ } catch (UnsupportedDrmException e) {
+ Log.e(TAG, "Unsupported DRM scheme", e);
+ return;
+ }
+ }
+
+ 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();
+ }
+ player = new SimpleExoPlayer.Builder(getApplicationContext()).build();
+ player.setMediaItem(mediaSource);
+ player.prepare();
+ player.setPlayWhenReady(true);
+ player.setRepeatMode(Player.REPEAT_MODE_ALL);
+
+ surfaceControl =
+ new SurfaceControl.Builder()
+ .setName(SURFACE_CONTROL_NAME)
+ .setBufferSize(/* width= */ 0, /* height= */ 0)
+ .build();
+ videoSurface = new Surface(surfaceControl);
+ player.setVideoSurface(videoSurface);
+ }
+
+ 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 void reparent(@Nullable SurfaceView surfaceView) {
+ 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 00000000000..d4b7fc77cd9
--- /dev/null
+++ b/demos/surface/src/main/res/layout/main_activity.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
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 00000000000..adaa93220eb
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 00000000000..9b6f7d5e806
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 00000000000..2101026c9fe
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 00000000000..223ec8bd113
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 00000000000..698ed68c429
Binary files /dev/null and b/demos/surface/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/surface/src/main/res/values/strings.xml b/demos/surface/src/main/res/values/strings.xml
new file mode 100644
index 00000000000..9ba24bd3685
--- /dev/null
+++ b/demos/surface/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ ExoPlayer SurfaceControl demo
+ No output
+ Full screen
+ New activity
+
+
diff --git a/demos/surface/src/main/res/values/styles.xml b/demos/surface/src/main/res/values/styles.xml
new file mode 100644
index 00000000000..aaa1e2ef83b
--- /dev/null
+++ b/demos/surface/src/main/res/values/styles.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/settings.gradle b/settings.gradle
index 50fdb68f30f..2708596a9e4 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -22,11 +22,13 @@ include modulePrefix + 'demo'
include modulePrefix + 'demo-cast'
include modulePrefix + 'demo-ima'
include modulePrefix + 'demo-gvr'
+include modulePrefix + 'demo-surface'
include modulePrefix + 'playbacktests'
project(modulePrefix + 'demo').projectDir = new File(rootDir, 'demos/main')
project(modulePrefix + 'demo-cast').projectDir = new File(rootDir, 'demos/cast')
project(modulePrefix + 'demo-ima').projectDir = new File(rootDir, 'demos/ima')
project(modulePrefix + 'demo-gvr').projectDir = new File(rootDir, 'demos/gvr')
+project(modulePrefix + 'demo-surface').projectDir = new File(rootDir, 'demos/surface')
project(modulePrefix + 'playbacktests').projectDir = new File(rootDir, 'playbacktests')
apply from: 'core_settings.gradle'