From 1440784a3b7f0231d51231433d03c4a8a2a2a9fb Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Sun, 30 Jul 2017 17:58:30 -0700 Subject: [PATCH] Implemented minified controls --- css/amp.css | 92 +++++++ extensions/amp-video/0.1/amp-video.js | 5 +- src/event-helper-listen.js | 30 ++- src/event-helper.js | 11 +- src/service/video-manager-impl.js | 361 ++++++++++++++++++++------ 5 files changed, 405 insertions(+), 94 deletions(-) diff --git a/css/amp.css b/css/amp.css index 002c40c99ca19..42ea2439d3764 100644 --- a/css/amp.css +++ b/css/amp.css @@ -708,14 +708,106 @@ i-amphtml-video-mask, i-amp-video-mask { margin: initial !important; } +i-amphtml-dragging-mask { + position: fixed; + z-index: 17; + background: transparent; + border-radius: 6px; +} + +i-amphtml-dockable-video-controls{ + width: 100%; + height: 100%; + background: rgba(0,0,0,0.7); + align-items: center; + text-align: center; + border-radius: 6px; + display: none; + opacity: 0; +} + i-amphtml-dockable-video-control-btn { flex-grow: 1; font-size: 40px; color: white; + width: 100%; + height: 100%; + background-position: center; + background-repeat: no-repeat; + background-size: 65%; +} + +i-amphtml-dockable-video-control-btn:first-child { + background-position: 75% center !important; +} + +i-amphtml-dockable-video-control-btn:last-child { + background-position: 25% center !important; } i-amphtml-dockable-video-control-btn.play { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +i-amphtml-dockable-video-control-btn.pause { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} +i-amphtml-dockable-video-control-btn.mute { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +i-amphtml-dockable-video-control-btn.unmute { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +i-amphtml-dockable-video-control-btn.enterfullscreen { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +progress.i-amphtml-dockable-video-progress { + /* Reset the default appearance */ + -webkit-appearance: none; + appearance: none; + position: absolute; + width: 100%; + height: 5px; + bottom: 0px; + border: 0; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; +} + +progress.i-amphtml-dockable-video-progress::-webkit-progress-bar { + border: 0; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + background: rgba(0, 0, 0, 0.2); +} +progress.i-amphtml-dockable-video-progress::-webkit-progress-value { + background: rgba(255, 255, 255, 1.0); + border: 0; + border-bottom-left-radius: 10px; +} + +progress.i-amphtml-dockable-video-progress[value="100"]::-webkit-progress-value { + border-bottom-right-radius: 10px; +} + +i-amphtml-dockable-video-close { + display: block; + width: 48px; + height: 48px; + background-color: rgba(255,255,255,.98); + background-image: url('data:image/svg+xml;charset=utf-8,'); + border-radius: 50%; + color: white; + position: absolute; + top: -16px; + right: -16px; + box-shadow: -1px 3px 14px 2px rgba(0,0,0,.3); + background-repeat: no-repeat; + background-position: center; } /** diff --git a/extensions/amp-video/0.1/amp-video.js b/extensions/amp-video/0.1/amp-video.js index 901d13177ce4e..c7a1e551a05ed 100644 --- a/extensions/amp-video/0.1/amp-video.js +++ b/extensions/amp-video/0.1/amp-video.js @@ -27,7 +27,7 @@ import {dev} from '../../../src/log'; import { installVideoManagerForDoc, } from '../../../src/service/video-manager-impl'; -import {VideoEvents} from '../../../src/video-interface'; +import {VideoEvents, VideoAnalyticsEvents} from '../../../src/video-interface'; import {Services} from '../../../src/services'; import {assertHttpsUrl} from '../../../src/url'; @@ -203,6 +203,9 @@ class AmpVideo extends AMP.BaseElement { listen(video, 'ended', () => { this.element.dispatchCustomEvent(VideoEvents.PAUSE); }); + listen(video, 'timeupdate', () => { + this.element.dispatchCustomEvent(VideoAnalyticsEvents.SECONDS_PLAYED); + }); } /** @override */ diff --git a/src/event-helper-listen.js b/src/event-helper-listen.js index fbdfaba11854f..f75b57fd354cd 100644 --- a/src/event-helper-listen.js +++ b/src/event-helper-listen.js @@ -25,10 +25,11 @@ * @param {string} eventType * @param {function(!Event)} listener * @param {boolean=} opt_capture + * @param {boolean=} opt_passive * @return {!UnlistenDef} */ export function internalListenImplementation(element, eventType, listener, - opt_capture) { + opt_capture, opt_passive) { let localElement = element; let localListener = listener; /** @type {?Function} */ @@ -41,11 +42,34 @@ export function internalListenImplementation(element, eventType, listener, throw e; } }; + + // Test whether browser supports the passive option or not + let passiveSupported = false; + try { + const options = Object.defineProperty({}, 'passive', { + get: function() { + passiveSupported = true; + }, + }); + self.addEventListener('test-passive', null, options); + } catch (err) { + // Passive is not supported + } + const capture = opt_capture || false; - localElement.addEventListener(eventType, wrapped, capture); + const passive = opt_passive || false; + localElement.addEventListener( + eventType, + wrapped, + passiveSupported ? {'capture': capture, 'passive': passive} : capture + ); return () => { if (localElement) { - localElement.removeEventListener(eventType, wrapped, capture); + localElement.removeEventListener( + eventType, + wrapped, + passiveSupported ? {'capture': capture, 'passive': passive} : capture + ); } // Ensure these are GC'd localListener = null; diff --git a/src/event-helper.js b/src/event-helper.js index 950a890281af4..03f50b65949f2 100644 --- a/src/event-helper.js +++ b/src/event-helper.js @@ -50,11 +50,12 @@ export function createCustomEvent(win, type, detail, opt_eventInit) { * @param {string} eventType * @param {function(!Event)} listener * @param {boolean=} opt_capture + * @param {boolean=} opt_passive * @return {!UnlistenDef} */ -export function listen(element, eventType, listener, opt_capture) { +export function listen(element, eventType, listener, opt_capture, opt_passive) { return internalListenImplementation( - element, eventType, listener, opt_capture); + element, eventType, listener, opt_capture, opt_passive); } /** @@ -73,9 +74,11 @@ export function getData(event) { * @param {string} eventType * @param {function(!Event)} listener * @param {boolean=} opt_capture + * @param {boolean=} opt_passive * @return {!UnlistenDef} */ -export function listenOnce(element, eventType, listener, opt_capture) { +export function listenOnce(element, eventType, listener, + opt_capture, opt_passive) { let localListener = listener; const unlisten = internalListenImplementation(element, eventType, event => { try { @@ -85,7 +88,7 @@ export function listenOnce(element, eventType, listener, opt_capture) { localListener = null; unlisten(); } - }, opt_capture); + }, opt_capture, opt_passive); return unlisten; } diff --git a/src/service/video-manager-impl.js b/src/service/video-manager-impl.js index b2c878d52fe60..2b527a506e27c 100644 --- a/src/service/video-manager-impl.js +++ b/src/service/video-manager-impl.js @@ -514,6 +514,9 @@ class VideoEntry { /** @private {boolean} */ this.isDismissed_ = false; + /** @private {boolean} */ + this.isShowingControls_ = false; + /** @private {Object} */ this.dragCoordinates_ = { mouse: {x: 0, y: 0}, @@ -527,6 +530,15 @@ class VideoEntry { /** @private {Array} */ this.dragUnlisteners_ = []; + /** @private {Node} */ + this.lastTouchedElement_ = null; + + /** @private {Node} */ + this.miniControls_ = null; + + /** @private {number} */ + this.controlsTimeout_ = null; + this.hasDocking = element.hasAttribute(VideoAttributes.DOCK); this.hasAutoplay = element.hasAttribute(VideoAttributes.AUTOPLAY); @@ -923,15 +935,8 @@ class VideoEntry { 'right': cloneStyle('right'), 'transform': cloneStyle('transform'), 'transform-origin': cloneStyle('transform-origin'), - 'borderRadius': cloneStyle('borderRadius'), 'width': cloneStyle('width'), 'height': cloneStyle('height'), - 'position': 'fixed', - 'z-index': '17', - 'background': 'rgba(0,0,0,0.4)', - 'display': 'flex', - 'align-items': 'center', - 'text-align': 'center', }); }); } @@ -1203,21 +1208,25 @@ class VideoEntry { * @param {function(!Event)} listener * @private */ - addDragListener_(element, eventType, listener) { - this.dragUnlisteners_.push( - listen( - element, - eventType, - listener - ) - ); + addListener_(element, eventType, listener) { + eventType.split(' ').map(e => { + this.dragUnlisteners_.push( + listen( + element, + e, + listener, + /*opt_capture*/true, + /*opt_passive*/false + ) + ); + }); } /** * Removes all listeners for touch and mouse events * @private */ - unlistenToDragEvents_() { + unlistenAll_() { let unlistener = this.dragUnlisteners_.pop(); while (unlistener) { unlistener.call(); @@ -1242,64 +1251,6 @@ class VideoEntry { }, mutate: () => { this.createDraggingMask_(); - - // Desktop listeners - this.addDragListener_( - dev().assertElement(this.draggingMask_), - 'mousedown', - e => { - e.preventDefault(); - this.isTouched_ = true; - this.isDragging_ = false; - this.mouse_(e, true); - } - ); - this.addDragListener_(this.ampdoc_.win.document, 'mouseup', () => { - this.isTouched_ = false; - this.isDragging_ = false; - // Call drag one last time to see if the velocity is still not null - // in which case, drag would call itself again to finish the animation - this.drag_(); - }); - this.addDragListener_(this.ampdoc_.win.document, 'mousemove', e => { - this.isDragging_ = this.isTouched_; - if (this.isDragging_) { - e.preventDefault(); - // Start dragging - this.dockState_ = DockStates.DRAGGABLE; - this.drag_(); - } - this.mouse_(e); - }); - // Touch listeners - this.addDragListener_( - dev().assertElement(this.draggingMask_), - 'touchstart', - e => { - e.preventDefault(); - this.isTouched_ = true; - this.isDragging_ = false; - this.mouse_(e, true); - } - ); - this.addDragListener_(this.ampdoc_.win.document, 'touchend', () => { - this.isTouched_ = false; - this.isDragging_ = false; - // Call drag one last time to see if the velocity is still not null - // in which case, drag would call itself again to finish the animation - this.drag_(); - }); - this.addDragListener_(this.ampdoc_.win.document, 'touchmove', e => { - this.isDragging_ = this.isTouched_; - if (this.isDragging_) { - e.preventDefault(); - // Start dragging - this.dockState_ = DockStates.DRAGGABLE; - this.drag_(); - } - this.mouse_(e); - }); - this.dragListenerInstalled_ = true; }, }); } @@ -1431,7 +1382,7 @@ class VideoEntry { */ finishDragging_() { this.vsync_.mutate(() => { - this.unlistenToDragEvents_(); + this.unlistenAll_(); this.removeDraggingMask_(); }); } @@ -1609,19 +1560,257 @@ class VideoEntry { createDraggingMask_() { const doc = this.ampdoc_.win.document; this.draggingMask_ = doc.createElement('i-amphtml-dragging-mask'); - const play_btn = doc.createElement('i-amphtml-dockable-video-control-btn'); - play_btn.classList.toggle('play', !this.isPlaying_); - play_btn.classList.toggle('pause', this.isPlaying_); - const mute_btn = doc.createElement('i-amphtml-dockable-video-control-btn'); - mute_btn.classList.toggle('mute', !this.muted_); - mute_btn.classList.toggle('unmute', this.muted_); - const fs_btn = doc.createElement('i-amphtml-dockable-video-control-btn'); - fs_btn.classList.add('fullscreen'); - this.draggingMask_.appendChild(play_btn); - this.draggingMask_.appendChild(mute_btn); - this.draggingMask_.appendChild(fs_btn); + + // Controls container + this.miniControls_ = doc.createElement('i-amphtml-dockable-video-controls'); + + // Play/Pause button + this.createControlBtn_( + null, + () => { this.video.play(/*autoplay*/ false); }, + () => { this.video.pause(); }, + () => { return this.isPlaying_; }, + 'play', 'pause', + [VideoEvents.PLAYING, VideoEvents.PAUSE], + [ + () => { this.hideMiniControlsDeferred_(); }, + () => { clearTimeout(this.controlsTimeout_); }, + ] + ); + + // Mute/unmute button + this.createControlBtn_( + () => { this.hideMiniControlsDeferred_(); }, + () => { this.video.mute(); }, + () => { this.video.unmute(); }, + () => { return this.muted_; }, + 'unmute', 'mute', + [VideoEvents.MUTED, VideoEvents.UNMUTED] + ); + + // Fullscreen button + this.createControlBtn_( + () => { this.hideMiniControlsDeferred_(); }, + () => { this.video.fullscreenEnter(); }, + () => { this.video.fullscreenExit(); }, + () => { return this.video.isFullscreen(); }, + 'enterfullscreen', 'exitfullscreen' + ); + + // Create progress bar + const progress = doc.createElement('progress'); + progress.classList.add('i-amphtml-dockable-video-progress'); + progress.setAttribute('value', 0); + progress.setAttribute('max', 100); + const updateVideoProgress = () => { + const current = this.video.getCurrentTime(); + const duration = this.video.getDuration(); + progress.setAttribute('value', + Math.floor(current * (100 / duration)) + ); + }; + this.addListener_(dev().assertElement(this.video.element), + VideoAnalyticsEvents.SECONDS_PLAYED, + () => { + updateVideoProgress(); + } + ); + + // Create close button + const closeBtn = doc.createElement('i-amphtml-dockable-video-close'); + this.addListener_(dev().assertElement(closeBtn), + 'click', + () => { + this.video.pause(); + this.finishDocking_(); + } + ); + + // Update the played time for the first time + updateVideoProgress(); + + // Add controls and progress-bar to the mask + this.miniControls_.appendChild(closeBtn); + this.draggingMask_.appendChild(this.miniControls_); + this.draggingMask_.appendChild(progress); + + // Align mask with docked video this.realignDraggingMask_(); + + // Add the mask and its events this.video.element.appendChild(this.draggingMask_); + + // Touch/Mouse listeners + this.addListener_( + dev().assertElement(this.draggingMask_), + 'mousedown touchstart', + e => { + if (e.type == 'touchstart') { + this.lastTouchedElement_ = e.target; + } else if (e.type == 'mousedown' + && this.lastTouchedElement_ + && e.target != this.lastTouchedElement_) { + e.preventDefault(); + e.stopPropagation(); + return; + } + this.isTouched_ = true; + this.isDragging_ = false; + this.mouse_(e, true); + // Time-out to make sure we don't show controls + // when user wanted to drag rather than show controls + setTimeout(() => { if (!this.isShowingControls_) { + this.showMiniControls_(); + } }, 200); + } + ); + + this.addListener_( + dev().assertElement(this.draggingMask_), + 'click', + e => { + if (e.target != this.draggingMask_) { + return; + } + e.preventDefault(); + e.stopPropagation(); + return; + } + ); + + this.addListener_(this.ampdoc_.win.document, 'mouseup touchend', e => { + if (e.type == 'mouseup' + && this.lastTouchedElement_ + && e.target != this.lastTouchedElement_) { + e.preventDefault(); + e.stopPropagation(); + return; + } + this.isTouched_ = false; + this.isDragging_ = false; + // Call drag one last time to see if the velocity is still not null + // in which case, drag would call itself again to finish the animation + this.drag_(); + }); + + this.addListener_(this.ampdoc_.win.document, 'mousemove touchmove', e => { + if (e.type == 'mousemove' + && this.lastTouchedElement_ + && e.target != this.lastTouchedElement_) { + e.preventDefault(); + e.stopPropagation(); + return; + } + this.isDragging_ = this.isTouched_; + if (this.isDragging_) { + e.preventDefault(); + // Start dragging + this.dockState_ = DockStates.DRAGGABLE; + this.drag_(); + } + this.mouse_(e); + }); + + this.dragListenerInstalled_ = true; + } + + /** + * Hides the minimized controls after a certain time has passed + * @private + */ + hideMiniControlsDeferred_() { + // After a second we hide the controls again + this.controlsTimeout_ = setTimeout(() => { + // Fade Out + Animation.animate(this.miniControls_, + tr.setStyles(dev().assertElement(this.miniControls_), { + 'opacity': tr.numeric(1, 0), + }), 200) + .thenAlways(() => { + st.setStyles(dev().assertElement(this.miniControls_), { + 'display': 'none', + }); + this.isShowingControls_ = false; + }); + }, 1500); + } + + /** + * Makes minimized video controls fade in + * @param {boolean} persistent + * @private + */ + showMiniControls_(persistent) { + // If the video is moving then don't show controls + if (this.isDragging_ + || this.isSnapping_ + || Math.abs(this.dragCoordinates_.velocity.x) >= STOP_THRESHOLD + || Math.abs(this.dragCoordinates_.velocity.y) >= STOP_THRESHOLD) { + return; + } + if (this.controlsTimeout_) { + clearTimeout(this.controlsTimeout_); + } + // Show controls + this.isShowingControls_ = true; + st.setStyles(dev().assertElement(this.miniControls_), { + 'display': 'flex', + }); + // Fade In + Animation.animate(dev().assertElement(this.miniControls_), + tr.setStyles(dev().assertElement(this.miniControls_), { + 'opacity': tr.numeric(0, 1), + }), 200); + if (!persistent) { + this.hideMiniControlsDeferred_(); + } + } + + /** + * Create a minimized controls button + * @private + */ + createControlBtn_(before, action1, action2, state, class1, class2, + events, afterEachEventOccured) { + const doc = this.ampdoc_.win.document; + const button = doc.createElement('i-amphtml-dockable-video-control-btn'); + button.classList.toggle(class1, !state()); + button.classList.toggle(class2, state()); + if (events) { + this.addListener_(this.video.element, + events.join(' '), e => { + button.classList.toggle(class1, !state()); + button.classList.toggle(class2, state()); + if (afterEachEventOccured + && afterEachEventOccured[events.indexOf(e.type)]) { + afterEachEventOccured[events.indexOf(e.type)](); + } + } + ); + } + this.miniControls_.appendChild(button); + this.addListener_(button, 'click', e => { + if (this.lastTouchedElement_ + && e.target != this.lastTouchedElement_) { + e.preventDefault(); + e.stopPropagation(); + return; + } + if (e.target != button) { + return; + } + if (before) { + before(); + } + if (!state()) { + action1(); + } else { + action2(); + } + e.preventDefault(); + e.stopPropagation(); + return; + }); } /**