UNPKG

shaka-player

Version:
651 lines (586 loc) 17.8 kB
/*! @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.Dom'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.FakeEventTarget'); goog.require('shaka.util.IReleasable'); goog.requireType('shaka.Player'); goog.requireType('shaka.ui.Controls'); /** * @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 * @param {!shaka.ui.Controls} controls */ constructor(container, canvas, video, player, config, controls) { 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.ui.Controls} */ this.controls_ = controls; /** @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_(this.canvas_); /** @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.pinchDistance_ = null; /** @private {number} */ this.prevAlpha_ = 0; /** @private {number} */ this.prevBeta_ = 0; /** @private {number} */ this.prevGamma_ = 0; /** @private {?string} */ this.vrAsset_ = null; /** @private {?number} */ this.vrHfov_ = 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; this.vrHfov_ = spatialInfo.hfov; switch (spatialInfo.projection) { case 'rect': // Rectilinear content is the flat rectangular media. this.vrAsset_ = null; break; case 'equi': this.vrAsset_ = 'equirectangular'; break; case 'hequ': switch (spatialInfo.hfov) { case 360: this.vrAsset_ = 'equirectangular'; break; case 180: this.vrAsset_ = 'halfequirectangular'; break; default: if (spatialInfo.hfov == null) { this.vrAsset_ = 'halfequirectangular'; } else { this.vrAsset_ = null; unsupported = true; } break; } break; case 'fish': case 'prim': case 'aiv': // Apple Immersive Video ('aiv') uses the parametric immersive // projection ('prim'), which is fisheye-based, so all of these // are rendered as fisheye content. this.vrAsset_ = 'fisheye'; break; default: this.vrAsset_ = null; unsupported = true; break; } if (unsupported) { shaka.log.warning('Unsupported VR projection or hfov', spatialInfo); } this.checkVrStatus_(); }); this.loadEventManager_.listenMulti( player, ['nospatialvideoinfo', 'unloading'], () => { this.vrAsset_ = null; this.vrHfov_ = null; this.checkVrStatus_(); }); this.loadEventManager_.listen(this.controls_, 'caststatuschanged', () => { 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() { if (this.canvas_) { return !!this.gl_; } const canvas = shaka.util.Dom.asHTMLCanvasElement(document.createElement('canvas')); return !!this.getGL_(canvas); } /** * 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_() { const isCasting = this.controls_.getCastProxy().isCasting(); if ((this.config_.displayInVrMode || this.vrAsset_) && !isCasting) { if (!this.canvas_) { this.canvas_ = shaka.util.Dom.asHTMLCanvasElement( document.createElement('canvas')); this.canvas_.classList.add('shaka-vr-canvas-container'); this.video_.parentElement.insertBefore( this.canvas_, this.video_.nextElementSibling); this.gl_ = this.getGL_(this.canvas_); } const newProjectionMode = this.vrAsset_ || this.config_.defaultVrProjectionMode; const newHfov = this.vrHfov_ || 180; if (!this.vrWebgl_) { this.canvas_.style.display = ''; this.init_(newProjectionMode, newHfov); this.dispatchEvent(new shaka.util.FakeEvent( 'vrstatuschanged', (new Map()).set('newStatus', this.isPlayingVR()))); } else { const currentProjectionMode = this.vrWebgl_.getProjectionMode(); const hfovChanged = newProjectionMode == 'fisheye' && this.vrWebgl_.getHfov() != newHfov; if (currentProjectionMode != newProjectionMode || hfovChanged) { this.eventManager_.removeAll(); this.vrWebgl_.release(); this.init_(newProjectionMode, newHfov); // Re-initialization the status does not change. } } } else if (this.canvas_ && 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 * @param {number} hfov * @private */ init_(projectionMode, hfov) { if (this.gl_ && this.canvas_) { this.vrWebgl_ = new shaka.ui.VRWebgl( this.video_, this.player_, this.canvas_, this.gl_, projectionMode, hfov); this.setupVRListeners_(); } } /** * @param {?HTMLCanvasElement} canvas * @return {?WebGLRenderingContext} * @private */ getGL_(canvas) { if (!canvas || !window.WebGLRenderingContext) { return null; } const webglContexts = [ 'webgl2', 'webgl', ]; for (const webgl of webglContexts) { const gl = canvas.getContext(webgl); if (gl) { return /** @type {!WebGLRenderingContext} */(gl); } } return null; } /** * @private */ setupVRListeners_() { // Start this.eventManager_.listen(this.container_, 'mousedown', (event) => { if (!this.onGesture_ && event.button == 0 && this.canStartGesture_(event.target)) { this.gestureStart_(event.clientX, event.clientY); } }); if (navigator.maxTouchPoints > 0) { this.eventManager_.listen(this.container_, 'touchstart', (e) => { const event = /** @type {!TouchEvent} */(e); if (event.touches.length == 2) { // Stop panning and start a pinch zoom gesture. this.onGesture_ = false; this.pinchDistance_ = this.getPinchDistance_(event); } else if (!this.onGesture_ && this.canStartGesture_(event.target)) { this.gestureStart_( event.touches[0].clientX, event.touches[0].clientY); } }); } // Zoom this.eventManager_.listen(this.container_, 'wheel', (e) => { if (!this.onGesture_ && this.config_.enableVrWheelZoom && this.canStartGesture_(e.target)) { 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) => { const event = /** @type {!TouchEvent} */(e); if (this.pinchDistance_ != null && event.touches.length == 2) { const newPinchDistance = this.getPinchDistance_(event); this.vrWebgl_.zoom( (this.pinchDistance_ - newPinchDistance) * shaka.ui.VRManager.PINCH_ZOOM_FACTOR_); this.pinchDistance_ = newPinchDistance; e.preventDefault(); } else if (this.onGesture_) { this.gestureMove_( event.touches[0].clientX, event.touches[0].clientY); e.preventDefault(); } }); } // End this.eventManager_.listenMulti( this.container_, ['mouseleave', 'mouseup'], () => { this.onGesture_ = false; }); if (navigator.maxTouchPoints > 0) { this.eventManager_.listen(this.container_, 'touchend', (e) => { const event = /** @type {!TouchEvent} */(e); this.onGesture_ = false; if (event.touches.length == 2) { this.pinchDistance_ = this.getPinchDistance_(event); } else { this.pinchDistance_ = null; } if (event.touches.length == 1) { // Continue panning with the remaining finger. this.gestureStart_( event.touches[0].clientX, event.touches[0].clientY); } }); } // Detect device movement if (this.config_.enableVrDeviceMotion && window.DeviceOrientationEvent) { let deviceOrientationListener = false; // See: https://dev.to/li/how-to-requestpermission-for-devicemotion-and-deviceorientation-events-in-ios-13-46g2 if (typeof DeviceMotionEvent.requestPermission == 'function') { const userGestureEvents = ['click', 'mouseup']; if (navigator.maxTouchPoints > 0) { userGestureEvents.push('touchend'); } const userGestureListener = () => { DeviceMotionEvent.requestPermission().then((newPermissionState) => { if (newPermissionState !== 'granted' || deviceOrientationListener) { return; } deviceOrientationListener = true; this.setupDeviceOrientationListener_(); }); }; const listenToUserGesturesAgain = () => { this.eventManager_.unlistenMulti( this.container_, userGestureEvents, userGestureListener); this.eventManager_.listenOnceMulti( this.container_, userGestureEvents, userGestureListener); }; DeviceMotionEvent.requestPermission().then((permissionState) => { if (permissionState !== 'granted') { listenToUserGesturesAgain(); return; } this.eventManager_.unlistenMulti( this.container_, userGestureEvents, userGestureListener); deviceOrientationListener = true; this.setupDeviceOrientationListener_(); }).catch(listenToUserGesturesAgain); } else { deviceOrientationListener = true; this.setupDeviceOrientationListener_(); } } } /** * @private */ setupDeviceOrientationListener_() { this.eventManager_.listen(window, 'deviceorientation', (e) => { if (!this.vrWebgl_) { return; } 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); } }); } /** * Returns true if a VR gesture can start on this element. Gestures that * start on the controls (buttons, menus, seek bar) should not move the * view. * * @param {?EventTarget} target * @return {boolean} * @private */ canStartGesture_(target) { if (target instanceof Element) { return !target.closest('.shaka-no-propagation'); } return true; } /** * @param {!TouchEvent} event * @return {number} * @private */ getPinchDistance_(event) { return Math.hypot( event.touches[0].clientX - event.touches[1].clientX, event.touches[0].clientY - event.touches[1].clientY); } /** * @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; } }; /** * @const {number} * @private */ shaka.ui.VRManager.TO_RADIANS_ = Math.PI / 180; /** * Amount of field of view change per pixel of pinch distance change. * The value is scaled down by 50 in shaka.ui.VRWebgl.zoom, so this results * in 0.1 degrees of field of view per pixel. * * @const {number} * @private */ shaka.ui.VRManager.PINCH_ZOOM_FACTOR_ = 5;