TouchTracker.java

/*
 * 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 androidx.media3.exoplayer.video.spherical;

import android.content.Context;
import android.graphics.PointF;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.BinderThread;

/**
 * Basic touch input system.
 *
 * <p>Mixing touch input and gyro input results in a complicated UI so this should be used
 * carefully. This touch system implements a basic (X, Y) -> (yaw, pitch) transform. This works for
 * basic UI but fails in edge cases where the user tries to drag scene up or down. There is no good
 * UX solution for this. The least bad solution is to disable pitch manipulation and only let the
 * user adjust yaw. This example tries to limit the awkwardness by restricting pitch manipulation to
 * +/- 45 degrees.
 *
 * <p>It is also important to get the order of operations correct. To match what users expect, touch
 * interaction manipulates the scene by rotating the world by the yaw offset and tilting the camera
 * by the pitch offset. If the order of operations is incorrect, the sensors & touch rotations will
 * have strange interactions. The roll of the phone is also tracked so that the x & y are correctly
 * mapped to yaw & pitch no matter how the user holds their phone.
 *
 * <p>This class doesn't handle any scrolling inertia but Android's
 * com.google.vr.sdk.widgets.common.TouchTracker.FlingGestureListener can be used with this code for
 * a nicer UI. An even more advanced UI would reproject the user's touch point into 3D and drag the
 * Mesh as the user moves their finger. However, that requires quaternion interpolation.
 */
/* package */ final class TouchTracker extends GestureDetector.SimpleOnGestureListener
    implements View.OnTouchListener, OrientationListener.Listener {

  public interface Listener {
    void onScrollChange(PointF scrollOffsetDegrees);

    default boolean onSingleTapUp(MotionEvent event) {
      return false;
    }
  }

  // Touch input won't change the pitch beyond +/- 45 degrees. This reduces awkward situations
  // where the touch-based pitch and gyro-based pitch interact badly near the poles.
  /* package */ static final float MAX_PITCH_DEGREES = 45;

  // With every touch event, update the accumulated degrees offset by the new pixel amount.
  private final PointF previousTouchPointPx = new PointF();
  private final PointF accumulatedTouchOffsetDegrees = new PointF();

  private final Listener listener;
  private final float pxPerDegrees;
  private final GestureDetector gestureDetector;
  // The conversion from touch to yaw & pitch requires compensating for device roll. This is set
  // on the sensor thread and read on the UI thread.
  private volatile float roll;

  @SuppressWarnings({"nullness:assignment", "nullness:argument"})
  public TouchTracker(Context context, Listener listener, float pxPerDegrees) {
    this.listener = listener;
    this.pxPerDegrees = pxPerDegrees;
    gestureDetector = new GestureDetector(context, this);
    roll = SphericalGLSurfaceView.UPRIGHT_ROLL;
  }

  /**
   * Converts ACTION_MOVE events to pitch & yaw events while compensating for device roll.
   *
   * @return true if we handled the event
   */
  @Override
  public boolean onTouch(View v, MotionEvent event) {
    return gestureDetector.onTouchEvent(event);
  }

  @Override
  public boolean onDown(MotionEvent e) {
    // Initialize drag gesture.
    previousTouchPointPx.set(e.getX(), e.getY());
    return true;
  }

  // Incompatible parameter type for e1.
  @SuppressWarnings("nullness:override.param.invalid")
  @Override
  public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
    // Calculate the touch delta in screen space.
    float touchX = (e2.getX() - previousTouchPointPx.x) / pxPerDegrees;
    float touchY = (e2.getY() - previousTouchPointPx.y) / pxPerDegrees;
    previousTouchPointPx.set(e2.getX(), e2.getY());

    float r = roll; // Copy volatile state.
    float cr = (float) Math.cos(r);
    float sr = (float) Math.sin(r);
    // To convert from screen space to the 3D space, we need to adjust the drag vector based
    // on the roll of the phone. This is standard rotationMatrix(roll) * vector math but has
    // an inverted y-axis due to the screen-space coordinates vs GL coordinates.
    // Handle yaw.
    accumulatedTouchOffsetDegrees.x -= cr * touchX - sr * touchY;
    // Handle pitch and limit it to 45 degrees.
    accumulatedTouchOffsetDegrees.y += sr * touchX + cr * touchY;
    accumulatedTouchOffsetDegrees.y =
        Math.max(-MAX_PITCH_DEGREES, Math.min(MAX_PITCH_DEGREES, accumulatedTouchOffsetDegrees.y));

    listener.onScrollChange(accumulatedTouchOffsetDegrees);
    return true;
  }

  @Override
  public boolean onSingleTapUp(MotionEvent e) {
    return listener.onSingleTapUp(e);
  }

  @Override
  @BinderThread
  public void onOrientationChange(float[] deviceOrientationMatrix, float roll) {
    // We compensate for roll by rotating in the opposite direction.
    this.roll = -roll;
  }
}