diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index df1423e688..12e9dbb28d 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -32,6 +32,7 @@
* IMA extension:
* For preroll to live stream transitions, project forward the loading position
to avoid being behind the live window.
+* Support for playing spherical videos on Daydream.
* Fix issue where a `NullPointerException` is thrown when removing an unprepared
media source from a `ConcatenatingMediaSource` with the `useLazyPreparation`
option enabled ([#4986](https://github.com/google/ExoPlayer/issues/4986)).
diff --git a/extensions/gvr/build.gradle b/extensions/gvr/build.gradle
index af973e1345..c845cb3423 100644
--- a/extensions/gvr/build.gradle
+++ b/extensions/gvr/build.gradle
@@ -31,8 +31,12 @@ android {
dependencies {
implementation project(modulePrefix + 'library-core')
+ implementation project(modulePrefix + 'library-ui')
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
implementation 'com.google.vr:sdk-audio:1.80.0'
+ implementation 'com.google.vr:sdk-controller:1.80.0'
+ api 'com.google.vr:sdk-base:1.80.0'
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
}
ext {
diff --git a/extensions/gvr/src/main/java/com/google/android/exoplayer2/ui/spherical/BaseGvrPlayerActivity.java b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ui/spherical/BaseGvrPlayerActivity.java
new file mode 100644
index 0000000000..48acc4a9c8
--- /dev/null
+++ b/extensions/gvr/src/main/java/com/google/android/exoplayer2/ui/spherical/BaseGvrPlayerActivity.java
@@ -0,0 +1,354 @@
+/*
+ * 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.ui.spherical;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.SurfaceTexture;
+import android.opengl.Matrix;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.BinderThread;
+import android.support.annotation.CallSuper;
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+import android.view.ContextThemeWrapper;
+import android.view.MotionEvent;
+import android.view.Surface;
+import com.google.android.exoplayer2.C;
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.ext.gvr.R;
+import com.google.android.exoplayer2.ui.PlayerControlView;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import com.google.vr.ndk.base.DaydreamApi;
+import com.google.vr.sdk.base.AndroidCompat;
+import com.google.vr.sdk.base.Eye;
+import com.google.vr.sdk.base.GvrActivity;
+import com.google.vr.sdk.base.GvrView;
+import com.google.vr.sdk.base.HeadTransform;
+import com.google.vr.sdk.base.Viewport;
+import com.google.vr.sdk.controller.Controller;
+import com.google.vr.sdk.controller.ControllerManager;
+import javax.microedition.khronos.egl.EGLConfig;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/** VR 360 video player base activity class. */
+public abstract class BaseGvrPlayerActivity extends GvrActivity {
+ private static final String TAG = "GvrPlayerActivity";
+
+ private static final int EXIT_FROM_VR_REQUEST_CODE = 42;
+
+ private final Handler mainHandler;
+
+ @Nullable private Player player;
+ @MonotonicNonNull private GlViewGroup glView;
+ @MonotonicNonNull private ControllerManager controllerManager;
+ @MonotonicNonNull private SurfaceTexture surfaceTexture;
+ @MonotonicNonNull private Surface surface;
+ @MonotonicNonNull private SceneRenderer scene;
+ @MonotonicNonNull private PlayerControlView playerControl;
+
+ public BaseGvrPlayerActivity() {
+ mainHandler = new Handler(Looper.getMainLooper());
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setScreenAlwaysOn(true);
+
+ GvrView gvrView = new GvrView(this);
+ // Since videos typically have fewer pixels per degree than the phones, reducing the render
+ // target scaling factor reduces the work required to render the scene.
+ gvrView.setRenderTargetScale(.5f);
+
+ // If a custom theme isn't specified, the Context's theme is used. For VR Activities, this is
+ // the old Android default theme rather than a modern theme. Override this with a custom theme.
+ Context theme = new ContextThemeWrapper(this, R.style.VrTheme);
+ glView = new GlViewGroup(theme, R.layout.vr_ui);
+
+ playerControl = Assertions.checkNotNull(glView.findViewById(R.id.controller));
+ playerControl.setShowVrButton(true);
+ playerControl.setVrButtonListener(v -> exit());
+
+ PointerRenderer pointerRenderer = new PointerRenderer();
+ scene = new SceneRenderer();
+ Renderer renderer = new Renderer(scene, glView, pointerRenderer);
+
+ // Attach glView to gvrView in order to properly handle UI events.
+ gvrView.addView(glView, 0);
+
+ // Standard GvrView configuration
+ gvrView.setEGLConfigChooser(
+ 8, 8, 8, 8, // RGBA bits.
+ 16, // Depth bits.
+ 0); // Stencil bits.
+ gvrView.setRenderer(renderer);
+ setContentView(gvrView);
+
+ // Most Daydream phones can render a 4k video at 60fps in sustained performance mode. These
+ // options can be tweaked along with the render target scale.
+ if (gvrView.setAsyncReprojectionEnabled(true)) {
+ AndroidCompat.setSustainedPerformanceMode(this, true);
+ }
+
+ // Handle the user clicking on the 'X' in the top left corner. Since this is done when the user
+ // has taken the headset out of VR, it should launch the app's exit flow directly rather than
+ // using the transition flow.
+ gvrView.setOnCloseButtonListener(this::finish);
+
+ ControllerManager.EventListener listener =
+ new ControllerManager.EventListener() {
+ @Override
+ public void onApiStatusChanged(int status) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onRecentered() {
+ // TODO if in cardboard mode call gvrView.recenterHeadTracker();
+ glView.post(() -> Util.castNonNull(playerControl).show());
+ }
+ };
+ controllerManager = new ControllerManager(this, listener);
+
+ Controller controller = controllerManager.getController();
+ ControllerEventListener controllerEventListener =
+ new ControllerEventListener(controller, pointerRenderer, glView);
+ controller.setEventListener(controllerEventListener);
+ }
+
+ /**
+ * Sets the {@link Player} to use.
+ *
+ * @param newPlayer The {@link Player} to use, or {@code null} to detach the current player.
+ */
+ protected void setPlayer(@Nullable Player newPlayer) {
+ Assertions.checkNotNull(scene);
+ if (player == newPlayer) {
+ return;
+ }
+ if (player != null) {
+ Player.VideoComponent videoComponent = player.getVideoComponent();
+ if (videoComponent != null) {
+ if (surface != null) {
+ videoComponent.clearVideoSurface(surface);
+ }
+ videoComponent.clearVideoFrameMetadataListener(scene);
+ videoComponent.clearCameraMotionListener(scene);
+ }
+ }
+ player = newPlayer;
+ if (player != null) {
+ Player.VideoComponent videoComponent = player.getVideoComponent();
+ if (videoComponent != null) {
+ videoComponent.setVideoFrameMetadataListener(scene);
+ videoComponent.setCameraMotionListener(scene);
+ videoComponent.setVideoSurface(surface);
+ }
+ }
+ Assertions.checkNotNull(playerControl).setPlayer(player);
+ }
+
+ /**
+ * Sets the default stereo mode. If the played video doesn't contain a stereo mode the default one
+ * is used.
+ *
+ * @param stereoMode A {@link C.StereoMode} value.
+ */
+ protected void setDefaultStereoMode(@C.StereoMode int stereoMode) {
+ Assertions.checkNotNull(scene).setDefaultStereoMode(stereoMode);
+ }
+
+ @CallSuper
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent unused) {
+ if (requestCode == EXIT_FROM_VR_REQUEST_CODE && resultCode == RESULT_OK) {
+ finish();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ Util.castNonNull(controllerManager).start();
+ }
+
+ @Override
+ protected void onPause() {
+ Util.castNonNull(controllerManager).stop();
+ super.onPause();
+ }
+
+ @Override
+ protected void onDestroy() {
+ setPlayer(null);
+ releaseSurface(surfaceTexture, surface);
+ super.onDestroy();
+ }
+
+ /** Tries to exit gracefully from VR using a VR transition dialog. */
+ @SuppressWarnings("nullness:argument.type.incompatible")
+ protected void exit() {
+ // This needs to use GVR's exit transition to avoid disorienting the user.
+ DaydreamApi api = DaydreamApi.create(this);
+ if (api != null) {
+ api.exitFromVr(this, EXIT_FROM_VR_REQUEST_CODE, null);
+ // Eventually, the Activity's onActivityResult will be called.
+ api.close();
+ } else {
+ finish();
+ }
+ }
+
+ /** Toggles PlayerControl visibility. */
+ @UiThread
+ protected void togglePlayerControlVisibility() {
+ if (Assertions.checkNotNull(playerControl).isVisible()) {
+ playerControl.hide();
+ } else {
+ playerControl.show();
+ }
+ }
+
+ // Called on GL thread.
+ private void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture) {
+ mainHandler.post(
+ () -> {
+ SurfaceTexture oldSurfaceTexture = this.surfaceTexture;
+ Surface oldSurface = this.surface;
+ this.surfaceTexture = surfaceTexture;
+ this.surface = new Surface(surfaceTexture);
+ if (player != null) {
+ Player.VideoComponent videoComponent = player.getVideoComponent();
+ if (videoComponent != null) {
+ videoComponent.setVideoSurface(surface);
+ }
+ }
+ releaseSurface(oldSurfaceTexture, oldSurface);
+ });
+ }
+
+ private static void releaseSurface(
+ @Nullable SurfaceTexture oldSurfaceTexture, @Nullable Surface oldSurface) {
+ if (oldSurfaceTexture != null) {
+ oldSurfaceTexture.release();
+ }
+ if (oldSurface != null) {
+ oldSurface.release();
+ }
+ }
+
+ private class Renderer implements GvrView.StereoRenderer {
+ private static final float Z_NEAR = .1f;
+ private static final float Z_FAR = 100;
+
+ private final float[] viewProjectionMatrix = new float[16];
+ private final SceneRenderer scene;
+ private final GlViewGroup glView;
+ private final PointerRenderer pointerRenderer;
+
+ public Renderer(SceneRenderer scene, GlViewGroup glView, PointerRenderer pointerRenderer) {
+ this.scene = scene;
+ this.glView = glView;
+ this.pointerRenderer = pointerRenderer;
+ }
+
+ @Override
+ public void onNewFrame(HeadTransform headTransform) {}
+
+ @Override
+ public void onDrawEye(Eye eye) {
+ Matrix.multiplyMM(
+ viewProjectionMatrix, 0, eye.getPerspective(Z_NEAR, Z_FAR), 0, eye.getEyeView(), 0);
+ scene.drawFrame(viewProjectionMatrix, eye.getType() == Eye.Type.RIGHT);
+ if (glView.isVisible()) {
+ glView.getRenderer().draw(viewProjectionMatrix);
+ pointerRenderer.draw(viewProjectionMatrix);
+ }
+ }
+
+ @Override
+ public void onFinishFrame(Viewport viewport) {}
+
+ @Override
+ public void onSurfaceCreated(EGLConfig config) {
+ onSurfaceTextureAvailable(scene.init());
+ glView.getRenderer().init();
+ pointerRenderer.init();
+ }
+
+ @Override
+ public void onSurfaceChanged(int width, int height) {}
+
+ @Override
+ public void onRendererShutdown() {
+ glView.getRenderer().shutdown();
+ pointerRenderer.shutdown();
+ scene.shutdown();
+ }
+ }
+
+ private class ControllerEventListener extends Controller.EventListener {
+
+ private final Controller controller;
+ private final PointerRenderer pointerRenderer;
+ private final GlViewGroup glView;
+ private final float[] controllerOrientationMatrix;
+ private boolean clickButtonDown;
+ private boolean appButtonDown;
+
+ public ControllerEventListener(
+ Controller controller, PointerRenderer pointerRenderer, GlViewGroup glView) {
+ this.controller = controller;
+ this.pointerRenderer = pointerRenderer;
+ this.glView = glView;
+ controllerOrientationMatrix = new float[16];
+ }
+
+ @Override
+ @BinderThread
+ public void onUpdate() {
+ controller.update();
+ controller.orientation.toRotationMatrix(controllerOrientationMatrix);
+ pointerRenderer.setControllerOrientation(controllerOrientationMatrix);
+
+ if (clickButtonDown || controller.clickButtonState) {
+ int action;
+ if (clickButtonDown != controller.clickButtonState) {
+ clickButtonDown = controller.clickButtonState;
+ action = clickButtonDown ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP;
+ } else {
+ action = MotionEvent.ACTION_MOVE;
+ }
+ glView.post(
+ () -> {
+ float[] angles = controller.orientation.toYawPitchRollRadians(new float[3]);
+ boolean clickedOnView = glView.simulateClick(action, angles[0], angles[1]);
+ if (action == MotionEvent.ACTION_DOWN && !clickedOnView) {
+ togglePlayerControlVisibility();
+ }
+ });
+ } else if (!appButtonDown && controller.appButtonState) {
+ glView.post(BaseGvrPlayerActivity.this::togglePlayerControlVisibility);
+ }
+ appButtonDown = controller.appButtonState;
+ }
+ }
+}
diff --git a/extensions/gvr/src/main/res/layout/vr_ui.xml b/extensions/gvr/src/main/res/layout/vr_ui.xml
new file mode 100644
index 0000000000..84e7ac7c6f
--- /dev/null
+++ b/extensions/gvr/src/main/res/layout/vr_ui.xml
@@ -0,0 +1,27 @@
+
+
+
A CanvasRenderer can be created on any thread, but {@link #init()} needs to be called on the + * GL thread before it can be rendered. + */ +@TargetApi(15) +/* package */ final class CanvasRenderer { + + private static final float WIDTH_UNIT = 0.8f; + private static final float DISTANCE_UNIT = 1f; + private static final float X_UNIT = -WIDTH_UNIT / 2; + private static final float Y_UNIT = -0.3f; + + // Standard vertex shader that passes through the texture data. + private static final String[] VERTEX_SHADER_CODE = { + "uniform mat4 uMvpMatrix;", + // 3D position data. + "attribute vec3 aPosition;", + // 2D UV vertices. + "attribute vec2 aTexCoords;", + "varying vec2 vTexCoords;", + + // Standard transformation. + "void main() {", + " gl_Position = uMvpMatrix * vec4(aPosition, 1);", + " vTexCoords = aTexCoords;", + "}" + }; + + private static final String[] FRAGMENT_SHADER_CODE = { + // This is required since the texture data is GL_TEXTURE_EXTERNAL_OES. + "#extension GL_OES_EGL_image_external : require", + "precision mediump float;", + "uniform samplerExternalOES uTexture;", + "varying vec2 vTexCoords;", + "void main() {", + " gl_FragColor = texture2D(uTexture, vTexCoords);", + "}" + }; + + // The quad has 2 triangles built from 4 total vertices. Each vertex has 3 position & 2 texture + // coordinates. + private static final int POSITION_COORDS_PER_VERTEX = 3; + private static final int TEXTURE_COORDS_PER_VERTEX = 2; + private static final int COORDS_PER_VERTEX = + POSITION_COORDS_PER_VERTEX + TEXTURE_COORDS_PER_VERTEX; + private static final int VERTEX_STRIDE_BYTES = COORDS_PER_VERTEX * C.BYTES_PER_FLOAT; + private static final int VERTEX_COUNT = 4; + private static final float HALF_PI = (float) (Math.PI / 2); + + private final FloatBuffer vertexBuffer; + private final AtomicBoolean surfaceDirty; + + private int width; + private int height; + private float heightUnit; + + // Program-related GL items. These are only valid if program != 0. + private int program = 0; + private int mvpMatrixHandle; + private int positionHandle; + private int textureCoordsHandle; + private int textureHandle; + private int textureId; + + // Components used to manage the Canvas that the View is rendered to. These are only valid after + // GL initialization. The client of this class acquires a Canvas from the Surface, writes to it + // and posts it. This marks the Surface as dirty. The GL code then updates the SurfaceTexture + // when rendering only if it is dirty. + @MonotonicNonNull private SurfaceTexture displaySurfaceTexture; + @MonotonicNonNull private Surface displaySurface; + + public CanvasRenderer() { + vertexBuffer = GlUtil.createBuffer(COORDS_PER_VERTEX * VERTEX_COUNT); + surfaceDirty = new AtomicBoolean(); + } + + public void setSize(int width, int height) { + this.width = width; + this.height = height; + heightUnit = WIDTH_UNIT * height / width; + + float[] vertexData = new float[COORDS_PER_VERTEX * VERTEX_COUNT]; + int vertexDataIndex = 0; + for (int y = 0; y < 2; y++) { + for (int x = 0; x < 2; x++) { + vertexData[vertexDataIndex++] = X_UNIT + (WIDTH_UNIT * x); + vertexData[vertexDataIndex++] = Y_UNIT + (heightUnit * y); + vertexData[vertexDataIndex++] = -DISTANCE_UNIT; + vertexData[vertexDataIndex++] = x; + vertexData[vertexDataIndex++] = 1 - y; + } + } + vertexBuffer.position(0); + vertexBuffer.put(vertexData); + } + + /** + * Calls {@link Surface#lockCanvas(Rect)}. + * + * @return {@link Canvas} for the View to render to or {@code null} if {@link #init()} has not yet + * been called. + */ + @Nullable + public Canvas lockCanvas() { + return displaySurface == null ? null : displaySurface.lockCanvas(/* inOutDirty= */ null); + } + + /** + * Calls {@link Surface#unlockCanvasAndPost(Canvas)} and marks the SurfaceTexture as dirty. + * + * @param canvas the canvas returned from {@link #lockCanvas()} + */ + public void unlockCanvasAndPost(@Nullable Canvas canvas) { + if (canvas == null || displaySurface == null) { + // glInit() hasn't run yet. + return; + } + displaySurface.unlockCanvasAndPost(canvas); + } + + /** Finishes constructing this object on the GL Thread. */ + /* package */ void init() { + if (program != 0) { + return; + } + + // Create the program. + program = GlUtil.compileProgram(VERTEX_SHADER_CODE, FRAGMENT_SHADER_CODE); + mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMvpMatrix"); + positionHandle = GLES20.glGetAttribLocation(program, "aPosition"); + textureCoordsHandle = GLES20.glGetAttribLocation(program, "aTexCoords"); + textureHandle = GLES20.glGetUniformLocation(program, "uTexture"); + textureId = GlUtil.createExternalTexture(); + checkGlError(); + + // Create the underlying SurfaceTexture with the appropriate size. + displaySurfaceTexture = new SurfaceTexture(textureId); + displaySurfaceTexture.setOnFrameAvailableListener(surfaceTexture -> surfaceDirty.set(true)); + displaySurfaceTexture.setDefaultBufferSize(width, height); + displaySurface = new Surface(displaySurfaceTexture); + } + + /** + * Renders the quad. + * + * @param viewProjectionMatrix Array of floats containing the quad's 4x4 perspective matrix in the + * {@link android.opengl.Matrix} format. + */ + /* package */ void draw(float[] viewProjectionMatrix) { + if (displaySurfaceTexture == null) { + return; + } + + GLES20.glUseProgram(program); + checkGlError(); + + GLES20.glEnableVertexAttribArray(positionHandle); + GLES20.glEnableVertexAttribArray(textureCoordsHandle); + checkGlError(); + + GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, viewProjectionMatrix, 0); + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId); + GLES20.glUniform1i(textureHandle, 0); + checkGlError(); + + // Load position data. + vertexBuffer.position(0); + GLES20.glVertexAttribPointer( + positionHandle, + POSITION_COORDS_PER_VERTEX, + GLES20.GL_FLOAT, + false, + VERTEX_STRIDE_BYTES, + vertexBuffer); + checkGlError(); + + // Load texture data. + vertexBuffer.position(POSITION_COORDS_PER_VERTEX); + GLES20.glVertexAttribPointer( + textureCoordsHandle, + TEXTURE_COORDS_PER_VERTEX, + GLES20.GL_FLOAT, + false, + VERTEX_STRIDE_BYTES, + vertexBuffer); + checkGlError(); + + if (surfaceDirty.compareAndSet(true, false)) { + // If the Surface has been written to, get the new data onto the SurfaceTexture. + displaySurfaceTexture.updateTexImage(); + } + + // Render. + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, VERTEX_COUNT); + checkGlError(); + + GLES20.glDisableVertexAttribArray(positionHandle); + GLES20.glDisableVertexAttribArray(textureCoordsHandle); + } + + /** Frees GL resources. */ + /* package */ void shutdown() { + if (program != 0) { + GLES20.glDeleteProgram(program); + GLES20.glDeleteTextures(1, new int[] {textureId}, 0); + } + + if (displaySurfaceTexture != null) { + displaySurfaceTexture.release(); + } + if (displaySurface != null) { + displaySurface.release(); + } + } + + /** + * Translates an orientation into pixel coordinates on the canvas. + * + *
This is a minimal hit detection system that works for this quad because it has no model
+ * matrix. All the math is based on the fact that its size & distance are hard-coded into this
+ * class. For a more complex 3D mesh, a general bounding box & ray collision system would be
+ * required.
+ *
+ * @param yaw Yaw of the orientation in radians.
+ * @param pitch Pitch of the orientation in radians.
+ * @return A {@link PointF} which contains the translated coordinate, or null if the point is
+ * outside of the quad's bounds.
+ */
+ @Nullable
+ public PointF translateClick(float yaw, float pitch) {
+ return internalTranslateClick(
+ yaw, pitch, X_UNIT, Y_UNIT, WIDTH_UNIT, heightUnit, width, height);
+ }
+
+ @Nullable
+ /*package*/ static PointF internalTranslateClick(
+ float yaw,
+ float pitch,
+ float xUnit,
+ float yUnit,
+ float widthUnit,
+ float heightUnit,
+ int widthPixel,
+ int heightPixel) {
+ if (yaw >= HALF_PI || yaw <= -HALF_PI || pitch >= HALF_PI || pitch <= -HALF_PI) {
+ return null;
+ }
+ double clickXUnit = Math.tan(yaw) * DISTANCE_UNIT - xUnit;
+ double clickYUnit = Math.tan(pitch) * DISTANCE_UNIT - yUnit;
+ if (clickXUnit < 0 || clickXUnit > widthUnit || clickYUnit < 0 || clickYUnit > heightUnit) {
+ return null;
+ }
+ // Convert from the polar coordinates of the controller to the rectangular coordinates of the
+ // View. Note the negative yaw & pitch used to generate Android-compliant x & y coordinates.
+ float clickXPixel = (float) (widthPixel - clickXUnit * widthPixel / widthUnit);
+ float clickYPixel = (float) (heightPixel - clickYUnit * heightPixel / heightUnit);
+ return new PointF(clickXPixel, clickYPixel);
+ }
+}
diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/GlViewGroup.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/GlViewGroup.java
new file mode 100644
index 0000000000..09e2f22207
--- /dev/null
+++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/GlViewGroup.java
@@ -0,0 +1,115 @@
+/*
+ * 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.ui.spherical;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.PointF;
+import android.graphics.PorterDuff;
+import android.os.SystemClock;
+import android.support.annotation.AnyThread;
+import android.support.annotation.UiThread;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import com.google.android.exoplayer2.util.Assertions;
+
+/** This View uses standard Android APIs to render its child Views to a texture. */
+/* package */ final class GlViewGroup extends FrameLayout {
+
+ private final CanvasRenderer canvasRenderer;
+
+ /**
+ * @param context The Context the view is running in, through which it can access the current
+ * theme, resources, etc.
+ * @param layoutId ID for an XML layout resource to load (e.g., * R.layout.main_page
)
+ */
+ public GlViewGroup(Context context, int layoutId) {
+ super(context);
+ this.canvasRenderer = new CanvasRenderer();
+
+ LayoutInflater.from(context).inflate(layoutId, this);
+
+ measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ int width = getMeasuredWidth();
+ int height = getMeasuredHeight();
+ Assertions.checkState(width > 0 && height > 0);
+ canvasRenderer.setSize(width, height);
+ setLayoutParams(new FrameLayout.LayoutParams(width, height));
+ }
+
+ /** Returns whether the view is currently visible. */
+ @UiThread
+ public boolean isVisible() {
+ int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ if (getChildAt(i).getVisibility() == VISIBLE) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void dispatchDraw(Canvas notUsed) {
+ Canvas glCanvas = canvasRenderer.lockCanvas();
+ if (glCanvas == null) {
+ // This happens if Android tries to draw this View before GL initialization completes. We need
+ // to retry until the draw call happens after GL invalidation.
+ postInvalidate();
+ return;
+ }
+
+ // Clear the canvas first.
+ glCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
+ // Have Android render the child views.
+ super.dispatchDraw(glCanvas);
+ // Commit the changes.
+ canvasRenderer.unlockCanvasAndPost(glCanvas);
+ }
+
+ /**
+ * Simulates a click on the view.
+ *
+ * @param action Click action.
+ * @param yaw Yaw of the click's orientation in radians.
+ * @param pitch Pitch of the click's orientation in radians.
+ * @return Whether the click was simulated. If false then the view is not visible or the click was
+ * outside of its bounds.
+ */
+ @UiThread
+ public boolean simulateClick(int action, float yaw, float pitch) {
+ if (!isVisible()) {
+ return false;
+ }
+ PointF point = canvasRenderer.translateClick(yaw, pitch);
+ if (point == null) {
+ return false;
+ }
+ long now = SystemClock.uptimeMillis();
+ MotionEvent event = MotionEvent.obtain(now, now, action, point.x, point.y, /* metaState= */ 1);
+ dispatchTouchEvent(event);
+ return true;
+ }
+
+ @AnyThread
+ public CanvasRenderer getRenderer() {
+ return canvasRenderer;
+ }
+}
diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/PointerRenderer.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/PointerRenderer.java
new file mode 100644
index 0000000000..d0a4d4c882
--- /dev/null
+++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/PointerRenderer.java
@@ -0,0 +1,146 @@
+/*
+ * 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.ui.spherical;
+
+import static com.google.android.exoplayer2.util.GlUtil.checkGlError;
+
+import android.opengl.GLES20;
+import android.opengl.Matrix;
+import com.google.android.exoplayer2.util.GlUtil;
+import java.nio.FloatBuffer;
+
+/** Renders a pointer. */
+/* package */ final class PointerRenderer {
+ // The pointer quad is 2 * SIZE units.
+ private static final float SIZE = .01f;
+ private static final float DISTANCE = 1;
+
+ // Standard vertex shader.
+ private static final String[] VERTEX_SHADER_CODE =
+ new String[] {
+ "uniform mat4 uMvpMatrix;",
+ "attribute vec3 aPosition;",
+ "varying vec2 vCoords;",
+
+ // Pass through normalized vertex coordinates.
+ "void main() {",
+ " gl_Position = uMvpMatrix * vec4(aPosition, 1);",
+ " vCoords = aPosition.xy / vec2(" + SIZE + ", " + SIZE + ");",
+ "}"
+ };
+
+ // Procedurally render a ring on the quad between the specified radii.
+ private static final String[] FRAGMENT_SHADER_CODE =
+ new String[] {
+ "precision mediump float;",
+ "varying vec2 vCoords;",
+
+ // Simple ring shader that is white between the radii and transparent elsewhere.
+ "void main() {",
+ " float r = length(vCoords);",
+ // Blend the edges of the ring at .55 +/- .05 and .85 +/- .05.
+ " float alpha = smoothstep(0.5, 0.6, r) * (1.0 - smoothstep(0.8, 0.9, r));",
+ " if (alpha == 0.0) {",
+ " discard;",
+ " } else {",
+ " gl_FragColor = vec4(alpha);",
+ " }",
+ "}"
+ };
+
+ // Simple quad mesh.
+ private static final int COORDS_PER_VERTEX = 3;
+ private static final float[] VERTEX_DATA = {
+ -SIZE, -SIZE, -DISTANCE, SIZE, -SIZE, -DISTANCE, -SIZE, SIZE, -DISTANCE, SIZE, SIZE, -DISTANCE,
+ };
+ private final FloatBuffer vertexBuffer;
+
+ // The pointer doesn't have a real modelMatrix. Its distance is baked into the mesh and it
+ // uses a rotation matrix when rendered.
+ private final float[] modelViewProjectionMatrix;
+ // This is accessed on the binder & GL Threads.
+ private final float[] controllerOrientationMatrix;
+
+ // Program-related GL items. These are only valid if program != 0.
+ private int program = 0;
+ private int mvpMatrixHandle;
+ private int positionHandle;
+
+ public PointerRenderer() {
+ vertexBuffer = GlUtil.createBuffer(VERTEX_DATA);
+ modelViewProjectionMatrix = new float[16];
+ controllerOrientationMatrix = new float[16];
+ Matrix.setIdentityM(controllerOrientationMatrix, 0);
+ }
+
+ /** Finishes initialization of this object on the GL thread. */
+ public void init() {
+ if (program != 0) {
+ return;
+ }
+
+ program = GlUtil.compileProgram(VERTEX_SHADER_CODE, FRAGMENT_SHADER_CODE);
+ mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMvpMatrix");
+ positionHandle = GLES20.glGetAttribLocation(program, "aPosition");
+ checkGlError();
+ }
+
+ /**
+ * Renders the pointer.
+ *
+ * @param viewProjectionMatrix Scene's view projection matrix.
+ */
+ public void draw(float[] viewProjectionMatrix) {
+ // Configure shader.
+ GLES20.glUseProgram(program);
+ checkGlError();
+
+ synchronized (controllerOrientationMatrix) {
+ Matrix.multiplyMM(
+ modelViewProjectionMatrix, 0, viewProjectionMatrix, 0, controllerOrientationMatrix, 0);
+ }
+ GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, modelViewProjectionMatrix, 0);
+ checkGlError();
+
+ // Render quad.
+ GLES20.glEnableVertexAttribArray(positionHandle);
+ checkGlError();
+
+ GLES20.glVertexAttribPointer(
+ positionHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, /* stride= */ 0, vertexBuffer);
+ checkGlError();
+
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, VERTEX_DATA.length / COORDS_PER_VERTEX);
+ checkGlError();
+
+ GLES20.glDisableVertexAttribArray(positionHandle);
+ }
+
+ /** Frees GL resources. */
+ public void shutdown() {
+ if (program != 0) {
+ GLES20.glDeleteProgram(program);
+ }
+ }
+
+ /** Updates the pointer's position with the latest Controller pose. */
+ public void setControllerOrientation(float[] rotationMatrix) {
+ synchronized (controllerOrientationMatrix) {
+ System.arraycopy(rotationMatrix, 0, controllerOrientationMatrix, 0, rotationMatrix.length);
+ }
+ }
+}
diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java
index 5099b2187d..b3b1acb7e4 100644
--- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java
+++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/spherical/SceneRenderer.java
@@ -36,7 +36,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** Renders a GL Scene. */
-/* package */ class SceneRenderer implements VideoFrameMetadataListener, CameraMotionListener {
+/* package */ final class SceneRenderer
+ implements VideoFrameMetadataListener, CameraMotionListener {
private final AtomicBoolean frameAvailable;
private final AtomicBoolean resetRotationAtNextFrame;
@@ -131,6 +132,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
projectionRenderer.draw(textureId, tempMatrix, rightEye);
}
+ /** Cleans up the GL resources. */
+ public void shutdown() {
+ projectionRenderer.shutdown();
+ }
+
// Methods called on playback thread.
// VideoFrameMetadataListener implementation.
diff --git a/library/ui/src/main/res/layout/exo_playback_control_view.xml b/library/ui/src/main/res/layout/exo_playback_control_view.xml
index 534655f2f4..ed2fb8e2b2 100644
--- a/library/ui/src/main/res/layout/exo_playback_control_view.xml
+++ b/library/ui/src/main/res/layout/exo_playback_control_view.xml
@@ -54,6 +54,9 @@