Source: ui/vr_manager.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */


goog.provide('shaka.ui.VRManager');

goog.require('shaka.log');
goog.require('shaka.ui.VRWebgl');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.Platform');

goog.requireType('shaka.Player');


/**
 * @implements {shaka.util.IReleasable}
 */
shaka.ui.VRManager = class extends shaka.util.FakeEventTarget {
  /**
   * @param {!HTMLElement} container
   * @param {?HTMLCanvasElement} canvas
   * @param {!HTMLMediaElement} video
   * @param {!shaka.Player} player
   * @param {shaka.extern.UIConfiguration} config
   */
  constructor(container, canvas, video, player, config) {
    super();

    /** @private {!HTMLElement} */
    this.container_ = container;

    /** @private {?HTMLCanvasElement} */
    this.canvas_ = canvas;

    /** @private {!HTMLMediaElement} */
    this.video_ = video;

    /** @private {!shaka.Player} */
    this.player_ = player;

    /** @private {shaka.extern.UIConfiguration} */
    this.config_ = config;

    /** @private {shaka.util.EventManager} */
    this.loadEventManager_ = new shaka.util.EventManager();

    /** @private {shaka.util.EventManager} */
    this.eventManager_ = new shaka.util.EventManager();

    /** @private {?WebGLRenderingContext} */
    this.gl_ = this.getGL_();

    /** @private {?shaka.ui.VRWebgl} */
    this.vrWebgl_ = null;

    /** @private {boolean} */
    this.onGesture_ = false;

    /** @private {number} */
    this.prevX_ = 0;

    /** @private {number} */
    this.prevY_ = 0;

    /** @private {number} */
    this.prevAlpha_ = 0;

    /** @private {number} */
    this.prevBeta_ = 0;

    /** @private {number} */
    this.prevGamma_ = 0;

    /** @private {?string} */
    this.vrAsset_ = null;

    this.loadEventManager_.listen(player, 'loading', () => {
      if (this.vrWebgl_) {
        this.vrWebgl_.reset();
      }
      this.checkVrStatus_();
    });

    this.loadEventManager_.listen(player, 'spatialvideoinfo', (event) => {
      /** @type {shaka.extern.SpatialVideoInfo} */
      const spatialInfo = event['detail'];
      let unsupported = false;
      switch (spatialInfo.projection) {
        case 'hequ':
          unsupported = spatialInfo.hfov != 360;
          this.vrAsset_ = 'equirectangular';
          break;
        case 'fish':
          this.vrAsset_ = 'equirectangular';
          unsupported = true;
          break;
        default:
          this.vrAsset_ = null;
          break;
      }
      if (unsupported) {
        shaka.log.warning('Unsupported VR projection or hfov', spatialInfo);
      }
      this.checkVrStatus_();
    });

    this.loadEventManager_.listen(player, 'nospatialvideoinfo', () => {
      this.vrAsset_ = null;
      this.checkVrStatus_();
    });

    this.loadEventManager_.listen(player, 'unloading', () => {
      this.vrAsset_ = null;
      this.checkVrStatus_();
    });

    this.checkVrStatus_();
  }

  /**
   * @override
   */
  release() {
    if (this.loadEventManager_) {
      this.loadEventManager_.release();
      this.loadEventManager_ = null;
    }
    if (this.eventManager_) {
      this.eventManager_.release();
      this.eventManager_ = null;
    }
    if (this.vrWebgl_) {
      this.vrWebgl_.release();
      this.vrWebgl_ = null;
    }

    // FakeEventTarget implements IReleasable
    super.release();
  }

  /**
   * @param {!shaka.extern.UIConfiguration} config
   */
  configure(config) {
    this.config_ = config;
    this.checkVrStatus_();
  }

  /**
   * Returns if a VR is capable.
   *
   * @return {boolean}
   */
  canPlayVR() {
    return !!this.gl_;
  }

  /**
   * Returns if a VR is supported.
   *
   * @return {boolean}
   */
  isPlayingVR() {
    return !!this.vrWebgl_;
  }

  /**
   * Reset VR view.
   */
  reset() {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return;
    }
    this.vrWebgl_.reset();
  }

  /**
   * Get the angle of the north.
   *
   * @return {?number}
   */
  getNorth() {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return null;
    }
    return this.vrWebgl_.getNorth();
  }

  /**
   * Returns the field of view.
   *
   * @return {?number}
   */
  getFieldOfView() {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return null;
    }
    return this.vrWebgl_.getFieldOfView();
  }

  /**
   * Set the field of view.
   *
   * @param {number} fieldOfView
   */
  setFieldOfView(fieldOfView) {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return;
    }
    if (fieldOfView < 0) {
      shaka.log.alwaysWarn('Field of view should be greater than 0');
      fieldOfView = 0;
    } else if (fieldOfView > 100) {
      shaka.log.alwaysWarn('Field of view should be less than 100');
      fieldOfView = 100;
    }
    this.vrWebgl_.setFieldOfView(fieldOfView);
  }

  /**
   * Toggle stereoscopic mode.
   */
  toggleStereoscopicMode() {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return;
    }
    this.vrWebgl_.toggleStereoscopicMode();
  }

  /**
   * Returns true if stereoscopic mode is enabled.
   *
   * @return {boolean}
   */
  isStereoscopicModeEnabled() {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return false;
    }
    return this.vrWebgl_.isStereoscopicModeEnabled();
  }

  /**
   * Increment the yaw in X angle in degrees.
   *
   * @param {number} angle
   */
  incrementYaw(angle) {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return;
    }
    this.vrWebgl_.rotateViewGlobal(
        angle * shaka.ui.VRManager.TO_RADIANS_, 0, 0);
  }

  /**
   * Increment the pitch in X angle in degrees.
   *
   * @param {number} angle
   */
  incrementPitch(angle) {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return;
    }
    this.vrWebgl_.rotateViewGlobal(
        0, angle * shaka.ui.VRManager.TO_RADIANS_, 0);
  }

  /**
   * Increment the roll in X angle in degrees.
   *
   * @param {number} angle
   */
  incrementRoll(angle) {
    if (!this.vrWebgl_) {
      shaka.log.alwaysWarn('Not playing VR content');
      return;
    }
    this.vrWebgl_.rotateViewGlobal(
        0, 0, angle * shaka.ui.VRManager.TO_RADIANS_);
  }

  /**
   * @private
   */
  checkVrStatus_() {
    if (!this.canvas_) {
      return;
    }
    if ((this.config_.displayInVrMode || this.vrAsset_)) {
      const newProjectionMode =
          this.vrAsset_ || this.config_.defaultVrProjectionMode;
      if (!this.vrWebgl_) {
        this.canvas_.style.display = '';
        this.init_(newProjectionMode);
        this.dispatchEvent(new shaka.util.FakeEvent(
            'vrstatuschanged',
            (new Map()).set('newStatus', this.isPlayingVR())));
      } else {
        const currentProjectionMode = this.vrWebgl_.getProjectionMode();
        if (currentProjectionMode != newProjectionMode) {
          this.eventManager_.removeAll();
          this.vrWebgl_.release();
          this.init_(newProjectionMode);
          // Re-initialization the status does not change.
        }
      }
    } else if (!this.config_.displayInVrMode && !this.vrAsset_ &&
        this.vrWebgl_) {
      this.canvas_.style.display = 'none';
      this.eventManager_.removeAll();
      this.vrWebgl_.release();
      this.vrWebgl_ = null;
      this.dispatchEvent(new shaka.util.FakeEvent(
          'vrstatuschanged',
          (new Map()).set('newStatus', this.isPlayingVR())));
    }
  }

  /**
   * @param {string} projectionMode
   * @private
   */
  init_(projectionMode) {
    if (this.gl_ && this.canvas_) {
      this.vrWebgl_ = new shaka.ui.VRWebgl(
          this.video_, this.player_, this.canvas_, this.gl_, projectionMode);
      this.setupVRListerners_();
    }
  }

  /**
   * @return {?WebGLRenderingContext}
   * @private
   */
  getGL_() {
    if (!this.canvas_) {
      return null;
    }
    // The user interface is not intended for devices that are controlled with
    // a remote control, and WebGL may run slowly on these devices.
    if (shaka.util.Platform.isSmartTV()) {
      return null;
    }
    const webglContexts = [
      'webgl2',
      'webgl',
    ];
    for (const webgl of webglContexts) {
      const gl = this.canvas_.getContext(webgl);
      if (gl) {
        return /** @type {!WebGLRenderingContext} */(gl);
      }
    }
    return null;
  }

  /**
   * @private
   */
  setupVRListerners_() {
    // Start
    this.eventManager_.listen(this.container_, 'mousedown', (event) => {
      if (!this.onGesture_) {
        this.gestureStart_(event.clientX, event.clientY);
      }
    });
    if (navigator.maxTouchPoints > 0) {
      this.eventManager_.listen(this.container_, 'touchstart', (e) => {
        if (!this.onGesture_) {
          const event = /** @type {!TouchEvent} */(e);
          this.gestureStart_(
              event.touches[0].clientX, event.touches[0].clientY);
        }
      });
    }

    // Zoom
    this.eventManager_.listen(this.container_, 'wheel', (e) => {
      if (!this.onGesture_) {
        const event = /** @type {!WheelEvent} */(e);
        this.vrWebgl_.zoom(event.deltaY);
        event.preventDefault();
        event.stopPropagation();
      }
    });

    // Move
    this.eventManager_.listen(this.container_, 'mousemove', (event) => {
      if (this.onGesture_) {
        this.gestureMove_(event.clientX, event.clientY);
      }
    });
    if (navigator.maxTouchPoints > 0) {
      this.eventManager_.listen(this.container_, 'touchmove', (e) => {
        if (this.onGesture_) {
          const event = /** @type {!TouchEvent} */(e);
          this.gestureMove_(
              event.touches[0].clientX, event.touches[0].clientY);
        }
        e.preventDefault();
      });
    }

    // End
    this.eventManager_.listen(this.container_, 'mouseleave', () => {
      this.onGesture_ = false;
    });
    this.eventManager_.listen(this.container_, 'mouseup', () => {
      this.onGesture_ = false;
    });
    if (navigator.maxTouchPoints > 0) {
      this.eventManager_.listen(this.container_, 'touchend', () => {
        this.onGesture_ = false;
      });
    }

    // Detect device movement
    let deviceOrientationListener = false;
    if (window.DeviceOrientationEvent) {
      // See: https://dev.to/li/how-to-requestpermission-for-devicemotion-and-deviceorientation-events-in-ios-13-46g2
      if (typeof DeviceMotionEvent.requestPermission == 'function') {
        const userGestureListener = () => {
          DeviceMotionEvent.requestPermission().then((newPermissionState) => {
            if (newPermissionState !== 'granted' ||
                deviceOrientationListener) {
              return;
            }
            deviceOrientationListener = true;
            this.setupDeviceOrientationListener_();
          });
        };
        DeviceMotionEvent.requestPermission().then((permissionState) => {
          this.eventManager_.unlisten(
              this.container_, 'click', userGestureListener);
          this.eventManager_.unlisten(
              this.container_, 'mouseup', userGestureListener);
          if (navigator.maxTouchPoints > 0) {
            this.eventManager_.unlisten(
                this.container_, 'touchend', userGestureListener);
          }
          if (permissionState !== 'granted') {
            this.eventManager_.listenOnce(
                this.container_, 'click', userGestureListener);
            this.eventManager_.listenOnce(
                this.container_, 'mouseup', userGestureListener);
            if (navigator.maxTouchPoints > 0) {
              this.eventManager_.listenOnce(
                  this.container_, 'touchend', userGestureListener);
            }
            return;
          }
          deviceOrientationListener = true;
          this.setupDeviceOrientationListener_();
        }).catch(() => {
          this.eventManager_.unlisten(
              this.container_, 'click', userGestureListener);
          this.eventManager_.unlisten(
              this.container_, 'mouseup', userGestureListener);
          if (navigator.maxTouchPoints > 0) {
            this.eventManager_.unlisten(
                this.container_, 'touchend', userGestureListener);
          }
          this.eventManager_.listenOnce(
              this.container_, 'click', userGestureListener);
          this.eventManager_.listenOnce(
              this.container_, 'mouseup', userGestureListener);
          if (navigator.maxTouchPoints > 0) {
            this.eventManager_.listenOnce(
                this.container_, 'touchend', userGestureListener);
          }
        });
      } else {
        deviceOrientationListener = true;
        this.setupDeviceOrientationListener_();
      }
    }
  }

  /**
   * @private
   */
  setupDeviceOrientationListener_() {
    this.eventManager_.listen(window, 'deviceorientation', (e) => {
      const event = /** @type {!DeviceOrientationEvent} */(e);
      let alphaDif = (event.alpha || 0) - this.prevAlpha_;
      let betaDif = (event.beta || 0) - this.prevBeta_;
      let gammaDif = (event.gamma || 0) - this.prevGamma_;

      if (Math.abs(alphaDif) > 10 || Math.abs(betaDif) > 10 ||
          Math.abs(gammaDif) > 5) {
        alphaDif = 0;
        gammaDif = 0;
        betaDif = 0;
      }

      this.prevAlpha_ = event.alpha || 0;
      this.prevBeta_ = event.beta || 0;
      this.prevGamma_ = event.gamma || 0;

      const toRadians = shaka.ui.VRManager.TO_RADIANS_;

      const orientation = screen.orientation.angle;
      if (orientation == 90 || orientation == -90) {
        this.vrWebgl_.rotateViewGlobal(
            alphaDif * toRadians * -1, gammaDif * toRadians * -1, 0);
      } else {
        this.vrWebgl_.rotateViewGlobal(
            alphaDif * toRadians * -1, betaDif * toRadians, 0);
      }
    });
  }

  /**
   * @param {number} x
   * @param {number} y
   * @private
   */
  gestureStart_(x, y) {
    this.onGesture_ = true;
    this.prevX_ = x;
    this.prevY_ = y;
  }

  /**
   * @param {number} x
   * @param {number} y
   * @private
   */
  gestureMove_(x, y) {
    const touchScaleFactor = -0.60 * Math.PI / 180;
    this.vrWebgl_.rotateViewGlobal((x - this.prevX_) * touchScaleFactor,
        (y - this.prevY_) * -1 * touchScaleFactor, 0);

    this.prevX_ = x;
    this.prevY_ = y;
  }
};

/**
 * @constant {number}
 * @private
 */
shaka.ui.VRManager.TO_RADIANS_ = Math.PI / 180;