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'