From 04fbde49ec2d1ccd79465a2b383e0581810dcc74 Mon Sep 17 00:00:00 2001 From: infinite-persistence <64950861+infinite-persistence@users.noreply.github.com> Date: Tue, 15 Dec 2020 00:40:59 +0800 Subject: [PATCH] Video: Mobile UI + overlay for keyboard shortcut feedback (#5119) Co-authored-by: import <> --- CHANGELOG.md | 2 + .../viewers/videoViewer/internal/overlays.js | 99 +++++ .../plugins/videojs-mobile-ui/LICENSE | 19 + .../plugins/videojs-mobile-ui/plugin.js | 168 ++++++++ .../plugins/videojs-mobile-ui/plugin.scss | 82 ++++ .../plugins/videojs-mobile-ui/touchOverlay.js | 158 ++++++++ .../plugins/videojs-overlay/plugin.js | 364 ++++++++++++++++++ .../plugins/videojs-overlay/plugin.scss | 85 ++++ .../viewers/videoViewer/internal/videojs.jsx | 61 +-- ui/scss/component/_file-render.scss | 94 ++++- 10 files changed, 1103 insertions(+), 29 deletions(-) create mode 100644 ui/component/viewers/videoViewer/internal/overlays.js create mode 100644 ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/LICENSE create mode 100644 ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.js create mode 100644 ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.scss create mode 100644 ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/touchOverlay.js create mode 100644 ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.js create mode 100644 ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.scss diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9b34da4..f8e2475b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Mobile video player enhancements and the ability to tap on the left and right edges to seek _community pr!_ ([#5119](https://github.com/lbryio/lbry-desktop/pull/5119)) + ### Changed ### Fixed diff --git a/ui/component/viewers/videoViewer/internal/overlays.js b/ui/component/viewers/videoViewer/internal/overlays.js new file mode 100644 index 000000000..b45e6843d --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/overlays.js @@ -0,0 +1,99 @@ +import React from 'react'; +import Icon from 'component/common/icon'; +import * as ICONS from 'constants/icons'; +import ReactDOMServer from 'react-dom/server'; + +import './plugins/videojs-overlay/plugin'; +import './plugins/videojs-overlay/plugin.scss'; + +// **************************************************************************** +// **************************************************************************** + +const OVERLAY_NAME_ONE_OFF = 'one-off'; +const OVERLAY_CLASS_PLAYBACK_RATE = 'vjs-overlay-playrate'; +const OVERLAY_CLASS_SEEKED = 'vjs-overlay-seeked'; + +// **************************************************************************** +// **************************************************************************** + +/** + * Overlays that will always be registered with the plugin. + * @type {*[]} + */ +const PERMANENT_OVERLAYS = [ + // Nothing for now. + // --- Example: --- + // { + // content: 'Video is now playing', + // start: 'play', + // end: 'pause', + // align: 'center', + // }, +]; + +export const OVERLAY_DATA = { + // https://github.com/brightcove/videojs-overlay/blob/master/README.md#documentation + overlays: [...PERMANENT_OVERLAYS], +}; + +/** + * Wrapper to hide away the complexity of adding dynamic content, which the + * plugin currently does not support. To change the 'content' of an overlay, + * we need to re-create the entire array. + * This wrapper ensures the PERMANENT_OVERLAYS (and potentially other overlays) + * don't get lost. + */ +function showOneOffOverlay(player, className, overlayJsx, align) { + // Delete existing: + OVERLAY_DATA.overlays = OVERLAY_DATA.overlays.filter(x => x.name !== OVERLAY_NAME_ONE_OFF); + // Create new one: + OVERLAY_DATA.overlays.push({ + name: OVERLAY_NAME_ONE_OFF, + class: className, + content: ReactDOMServer.renderToStaticMarkup(overlayJsx), + start: 'immediate', + align: align, + }); + // Display it: + player.overlay(OVERLAY_DATA); +} + +/** + * Displays a transient "Playback Rate" overlay. + * + * @param player The videojs instance. + * @param newRate The current playback rate value. + * @param isSpeedUp true if the change was speeding up, false otherwise. + */ +export function showPlaybackRateOverlay(player, newRate, isSpeedUp) { + const overlayJsx = ( + <div> + <p>{newRate}x</p> + <p> + <Icon icon={isSpeedUp ? ICONS.ARROW_RIGHT : ICONS.ARROW_LEFT} size={48} /> + </p> + </div> + ); + + showOneOffOverlay(player, OVERLAY_CLASS_PLAYBACK_RATE, overlayJsx, 'center'); +} + +/** + * Displays a transient "Seeked" overlay. + * + * @param player The videojs instance. + * @param duration The seek delta duration. + * @param isForward true if seeking forward, false otherwise. + */ +export function showSeekedOverlay(player, duration, isForward) { + const overlayJsx = ( + <div> + <p> + {isForward ? '+' : '-'} + {duration} + </p> + </div> + ); + + showOneOffOverlay(player, OVERLAY_CLASS_SEEKED, overlayJsx, 'center'); +} diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/LICENSE b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/LICENSE new file mode 100644 index 000000000..7d043d545 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) mister-ben <git@misterben.me> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.js new file mode 100644 index 000000000..e7cadb3d5 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.js @@ -0,0 +1,168 @@ +import videojs from 'video.js/dist/alt/video.core.novtt.min.js'; +import './touchOverlay.js'; +import window from 'global/window'; +import './plugin.scss'; + +const VERSION = '0.4.1'; + +// Default options for the plugin. +const defaults = { + fullscreen: { + enterOnRotate: true, + lockOnRotate: true, + iOS: false, + }, + touchControls: { + seekSeconds: 10, + tapTimeout: 300, + disableOnEnd: false, + }, +}; + +const screen = window.screen; + +const angle = () => { + // iOS + if (typeof window.orientation === 'number') { + return window.orientation; + } + // Android + if (screen && screen.orientation && screen.orientation.angle) { + return window.orientation; + } + videojs.log('angle unknown'); + return 0; +}; + +// Cross-compatibility for Video.js 5 and 6. +const registerPlugin = videojs.registerPlugin || videojs.plugin; + +/** + * Add UI and event listeners + * + * @function onPlayerReady + * @param {Player} player + * A Video.js player object. + * + * @param {Object} [options={}] + * A plain object containing options for the plugin. + */ +const onPlayerReady = (player, options) => { + player.addClass('vjs-mobile-ui'); + + if (options.touchControls.disableOnEnd || typeof player.endscreen === 'function') { + player.addClass('vjs-mobile-ui-disable-end'); + } + + if ( + options.fullscreen.iOS && + videojs.browser.IS_IOS && + videojs.browser.IOS_VERSION > 9 && + !player.el_.ownerDocument.querySelector('.bc-iframe') + ) { + player.tech_.el_.setAttribute('playsinline', 'playsinline'); + player.tech_.supportsFullScreen = function() { + return false; + }; + } + + const controlBar = player.getChild('ControlBar'); + + // Insert before the ControlBar: + const controlBarIdx = player.children_.indexOf(controlBar); + player.addChild('touchOverlay', options.touchControls, controlBarIdx); + + // Make the TouchOverlay the new parent of the ControlBar. + // This allows the ControlBar to listen to the same classes as TouchOverlay. + player.removeChild(controlBar); + const touchOverlay = player.getChild('touchOverlay'); + touchOverlay.addChild(controlBar); + + // Tweak controlBar to Mobile style: + controlBar.removeChild('PlayToggle'); // Use Overlay's instead. + + let locked = false; + + const rotationHandler = () => { + const currentAngle = angle(); + + if (currentAngle === 90 || currentAngle === 270 || currentAngle === -90) { + if (player.paused() === false) { + player.requestFullscreen(); + if (options.fullscreen.lockOnRotate && screen.orientation && screen.orientation.lock) { + screen.orientation + .lock('landscape') + .then(() => { + locked = true; + }) + .catch(() => { + videojs.log('orientation lock not allowed'); + }); + } + } + } + if (currentAngle === 0 || currentAngle === 180) { + if (player.isFullscreen()) { + player.exitFullscreen(); + } + } + }; + + if (videojs.browser.IS_IOS) { + window.addEventListener('orientationchange', rotationHandler); + } else { + // addEventListener('orientationchange') is not a user interaction on Android + screen.orientation.onchange = rotationHandler; + } + + player.on('ended', _ => { + if (locked === true) { + screen.orientation.unlock(); + locked = false; + } + }); +}; + +/** + * A video.js plugin. + * + * Adds a monile UI for player control, and fullscreen orientation control + * + * @function mobileUi + * @param {Object} [options={}] + * Plugin options. + * @param {Object} [options.fullscreen={}] + * Fullscreen options. + * @param {boolean} [options.fullscreen.enterOnRotate=true] + * Whether to go fullscreen when rotating to landscape + * @param {boolean} [options.fullscreen.lockOnRotate=true] + * Whether to lock orientation when rotating to landscape + * Unlocked when exiting fullscreen or on 'ended' + * @param {boolean} [options.fullscreen.iOS=false] + * Whether to disable iOS's native fullscreen so controls can work + * @param {Object} [options.touchControls={}] + * Touch UI options. + * @param {int} [options.touchControls.seekSeconds=10] + * Number of seconds to seek on double-tap + * @param {int} [options.touchControls.tapTimeout=300] + * Interval in ms to be considered a doubletap + * @param {boolean} [options.touchControls.disableOnEnd=false] + * Whether to disable when the video ends (e.g., if there is an endscreen) + * Never shows if the endscreen plugin is present + */ +const mobileUi = function(options) { + // if (videojs.browser.IS_ANDROID || videojs.browser.IS_IOS) { + if (videojs.browser.IS_ANDROID) { + this.ready(() => { + onPlayerReady(this, videojs.mergeOptions(defaults, options)); + }); + } +}; + +// Register the plugin with video.js. +registerPlugin('mobileUi', mobileUi); + +// Include the version number. +mobileUi.VERSION = VERSION; + +export default mobileUi; diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.scss b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.scss new file mode 100644 index 000000000..486b44765 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/plugin.scss @@ -0,0 +1,82 @@ +// Sass for videojs-touch-ui + +@keyframes fadeAndScale { + 0% { + opacity: 0; + } + 25% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.video-js { + // This class is added to the video.js element by the plugin by default. + &.vjs-has-started .vjs-touch-overlay { + position: absolute; + pointer-events: auto; + top: 0; + } + + .vjs-touch-overlay { + display: block; + width: 100%; + height: 100%; + pointer-events: none; + + &.skip { + opacity: 0; + animation: fadeAndScale 0.6s linear; + background-repeat: no-repeat; + background-position: 80% center; + background-size: 10%; + background-image: url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'); + } + + &.skip.reverse { + background-position: 20% center; + background-image: url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'); + } + + .vjs-play-control { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + width: 30%; + height: 80%; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; + + .vjs-icon-placeholder::before { + content: ''; + background-size: 60%; + background-position: center center; + background-repeat: no-repeat; + background-image: url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'); + } + + &.vjs-paused .vjs-icon-placeholder::before { + content: ''; + background-image: url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M8 5v14l11-7z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'); + } + + &.vjs-ended .vjs-icon-placeholder::before { + content: ''; + background-image: url('data:image/svg+xml;utf8,<svg fill="%23FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>'); + } + } + + &.show-play-toggle .vjs-play-control { + opacity: 1; + pointer-events: auto; + } + } + + &.vjs-mobile-ui-disable-end.vjs-ended .vjs-touch-overlay { + display: none; + } +} diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/touchOverlay.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/touchOverlay.js new file mode 100644 index 000000000..a0c8fa935 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-mobile-ui/touchOverlay.js @@ -0,0 +1,158 @@ +/** + * @file touchOverlay.js + * Touch UI component + */ + +import videojs from 'video.js/dist/alt/video.core.novtt.min.js'; +import window from 'global/window'; + +const Component = videojs.getComponent('Component'); +const dom = videojs.dom || videojs; + +/** + * The `TouchOverlay` is an overlay to capture tap events. + * + * @extends Component + */ +class TouchOverlay extends Component { + /** + * Creates an instance of the this class. + * + * @param {Player} player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + */ + constructor(player, options) { + super(player, options); + + this.seekSeconds = options.seekSeconds; + this.tapTimeout = options.tapTimeout; + + // Add play toggle overlay + this.addChild('playToggle', {}); + + // Clear overlay when playback starts or with control fade + player.on(['playing', 'userinactive'], e => { + if (!this.player_.paused()) { + this.removeClass('show-play-toggle'); + } + }); + + // A 0 inactivity timeout won't work here + if (this.player_.options_.inactivityTimeout === 0) { + this.player_.options_.inactivityTimeout = 5000; + } + + this.enable(); + } + + /** + * Builds the DOM element. + * + * @return {Element} + * The DOM element. + */ + createEl() { + const el = dom.createEl('div', { + className: 'vjs-touch-overlay', + // Touch overlay is not tabbable. + tabIndex: -1, + }); + + return el; + } + + /** + * Debounces to either handle a delayed single tap, or a double tap + * + * @param {Event} event + * The touch event + * + */ + handleTap(event) { + // Don't handle taps on the play button + if (event.target !== this.el_) { + return; + } + + event.preventDefault(); + + if (this.firstTapCaptured) { + this.firstTapCaptured = false; + if (this.timeout) { + window.clearTimeout(this.timeout); + } + this.handleDoubleTap(event); + } else { + this.firstTapCaptured = true; + this.timeout = window.setTimeout(() => { + this.firstTapCaptured = false; + this.handleSingleTap(event); + }, this.tapTimeout); + } + } + + /** + * Toggles display of play toggle + * + * @param {Event} event + * The touch event + * + */ + handleSingleTap(event) { + this.removeClass('skip'); + this.toggleClass('show-play-toggle'); + } + + /** + * Seeks by configured number of seconds if left or right part of video double tapped + * + * @param {Event} event + * The touch event + * + */ + handleDoubleTap(event) { + const rect = this.el_.getBoundingClientRect(); + const x = event.changedTouches[0].clientX - rect.left; + + // Check if double tap is in left or right area + if (x < rect.width * 0.4) { + this.player_.currentTime(Math.max(0, this.player_.currentTime() - this.seekSeconds)); + this.addClass('reverse'); + } else if (x > rect.width - rect.width * 0.4) { + this.player_.currentTime(Math.min(this.player_.duration(), this.player_.currentTime() + this.seekSeconds)); + this.removeClass('reverse'); + } else { + return; + } + + // Remove play toggle if showing + this.removeClass('show-play-toggle'); + + // Remove and readd class to trigger animation + this.removeClass('skip'); + window.requestAnimationFrame(() => { + this.addClass('skip'); + }); + } + + /** + * Enables touch handler + */ + enable() { + this.firstTapCaptured = false; + this.on('touchend', this.handleTap); + } + + /** + * Disables touch handler + */ + disable() { + this.off('touchend', this.handleTap); + } +} + +Component.registerComponent('TouchOverlay', TouchOverlay); +export default TouchOverlay; diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.js new file mode 100644 index 000000000..02d7fc9bc --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.js @@ -0,0 +1,364 @@ +import videojs from 'video.js/dist/alt/video.core.novtt.min.js'; +import window from 'global/window'; +const VERSION = '2.1.4'; + +const defaults = { + align: 'top-left', + class: '', + content: 'This overlay will show up while the video is playing', + debug: false, + showBackground: true, + attachToControlBar: false, + overlays: [ + { + start: 'playing', + end: 'paused', + }, + ], +}; + +const Component = videojs.getComponent('Component'); + +const dom = videojs.dom || videojs; +const registerPlugin = videojs.registerPlugin || videojs.plugin; + +/** + * Whether the value is a `Number`. + * + * Both `Infinity` and `-Infinity` are accepted, but `NaN` is not. + * + * @param {Number} n + * @return {Boolean} + */ + +/* eslint-disable no-self-compare */ +const isNumber = n => typeof n === 'number' && n === n; +/* eslint-enable no-self-compare */ + +/** + * Whether a value is a string with no whitespace. + * + * @param {String} s + * @return {Boolean} + */ +const hasNoWhitespace = s => typeof s === 'string' && /^\S+$/.test(s); + +/** + * Overlay component. + * + * @class Overlay + * @extends {videojs.Component} + */ +class Overlay extends Component { + constructor(player, options) { + super(player, options); + + ['start', 'end'].forEach(key => { + const value = this.options_[key]; + + if (isNumber(value)) { + this[key + 'Event_'] = 'timeupdate'; + } else if (hasNoWhitespace(value)) { + this[key + 'Event_'] = value; + + // An overlay MUST have a start option. Otherwise, it's pointless. + } else if (key === 'start') { + throw new Error('invalid "start" option; expected number or string'); + } + }); + + // video.js does not like components with multiple instances binding + // events to the player because it tracks them at the player level, + // not at the level of the object doing the binding. This could also be + // solved with Function.prototype.bind (but not videojs.bind because of + // its GUID magic), but the anonymous function approach avoids any issues + // caused by crappy libraries clobbering Function.prototype.bind. + // - https://github.com/videojs/video.js/issues/3097 + ['endListener_', 'rewindListener_', 'startListener_'].forEach(name => { + this[name] = e => Overlay.prototype[name].call(this, e); + }); + + // If the start event is a timeupdate, we need to watch for rewinds (i.e., + // when the user seeks backward). + if (this.startEvent_ === 'timeupdate') { + this.on(player, 'timeupdate', this.rewindListener_); + } + + this.debug( + `created, listening to "${this.startEvent_}" for "start" and "${this.endEvent_ || 'nothing'}" for "end"` + ); + + if (this.startEvent_ === 'immediate') { + this.show(); + } else { + this.hide(); + } + } + + createEl() { + const options = this.options_; + const content = options.content; + + const background = options.showBackground ? 'vjs-overlay-background' : 'vjs-overlay-no-background'; + const el = dom.createEl('div', { + className: ` + vjs-overlay + vjs-overlay-${options.align} + ${options.class} + ${background} + vjs-hidden + `, + }); + + if (typeof content === 'string') { + el.innerHTML = content; + } else if (content instanceof window.DocumentFragment) { + el.appendChild(content); + } else { + dom.appendContent(el, content); + } + + return el; + } + + /** + * Logs debug errors + * @param {...[type]} args [description] + * @return {[type]} [description] + */ + debug(...args) { + if (!this.options_.debug) { + return; + } + + const log = videojs.log; + let fn = log; + + // Support `videojs.log.foo` calls. + if (log.hasOwnProperty(args[0]) && typeof log[args[0]] === 'function') { + fn = log[args.shift()]; + } + + fn(...[`overlay#${this.id()}: `, ...args]); + } + + /** + * Overrides the inherited method to perform some event binding + * + * @return {Overlay} + */ + hide() { + super.hide(); + + this.debug('hidden'); + this.debug(`bound \`startListener_\` to "${this.startEvent_}"`); + + // Overlays without an "end" are valid. + if (this.endEvent_) { + this.debug(`unbound \`endListener_\` from "${this.endEvent_}"`); + this.off(this.player(), this.endEvent_, this.endListener_); + } + + this.on(this.player(), this.startEvent_, this.startListener_); + + return this; + } + + /** + * Determine whether or not the overlay should hide. + * + * @param {Number} time + * The current time reported by the player. + * @param {String} type + * An event type. + * @return {Boolean} + */ + shouldHide_(time, type) { + const end = this.options_.end; + + return isNumber(end) ? time >= end : end === type; + } + + /** + * Overrides the inherited method to perform some event binding + * + * @return {Overlay} + */ + show() { + super.show(); + this.off(this.player(), this.startEvent_, this.startListener_); + this.debug('shown'); + this.debug(`unbound \`startListener_\` from "${this.startEvent_}"`); + + // Overlays without an "end" are valid. + if (this.endEvent_) { + this.debug(`bound \`endListener_\` to "${this.endEvent_}"`); + this.on(this.player(), this.endEvent_, this.endListener_); + } + + return this; + } + + /** + * Determine whether or not the overlay should show. + * + * @param {Number} time + * The current time reported by the player. + * @param {String} type + * An event type. + * @return {Boolean} + */ + shouldShow_(time, type) { + const start = this.options_.start; + const end = this.options_.end; + + if (isNumber(start)) { + if (isNumber(end)) { + return time >= start && time < end; + + // In this case, the start is a number and the end is a string. We need + // to check whether or not the overlay has shown since the last seek. + } else if (!this.hasShownSinceSeek_) { + this.hasShownSinceSeek_ = true; + return time >= start; + } + + // In this case, the start is a number and the end is a string, but + // the overlay has shown since the last seek. This means that we need + // to be sure we aren't re-showing it at a later time than it is + // scheduled to appear. + return Math.floor(time) === start; + } + + return start === type; + } + + /** + * Event listener that can trigger the overlay to show. + * + * @param {Event} e + */ + startListener_(e) { + const time = this.player().currentTime(); + + if (this.shouldShow_(time, e.type)) { + this.show(); + } + } + + /** + * Event listener that can trigger the overlay to show. + * + * @param {Event} e + */ + endListener_(e) { + const time = this.player().currentTime(); + + if (this.shouldHide_(time, e.type)) { + this.hide(); + } + } + + /** + * Event listener that can looks for rewinds - that is, backward seeks + * and may hide the overlay as needed. + * + * @param {Event} e + */ + rewindListener_(e) { + const time = this.player().currentTime(); + const previous = this.previousTime_; + const start = this.options_.start; + const end = this.options_.end; + + // Did we seek backward? + if (time < previous) { + this.debug('rewind detected'); + + // The overlay remains visible if two conditions are met: the end value + // MUST be an integer and the the current time indicates that the + // overlay should NOT be visible. + if (isNumber(end) && !this.shouldShow_(time)) { + this.debug(`hiding; ${end} is an integer and overlay should not show at this time`); + this.hasShownSinceSeek_ = false; + this.hide(); + + // If the end value is an event name, we cannot reliably decide if the + // overlay should still be displayed based solely on time; so, we can + // only queue it up for showing if the seek took us to a point before + // the start time. + } else if (hasNoWhitespace(end) && time < start) { + this.debug(`hiding; show point (${start}) is before now (${time}) and end point (${end}) is an event`); + this.hasShownSinceSeek_ = false; + this.hide(); + } + } + + this.previousTime_ = time; + } +} + +videojs.registerComponent('Overlay', Overlay); + +/** + * Initialize the plugin. + * + * @function plugin + * @param {Object} [options={}] + */ +const plugin = function(options) { + const settings = videojs.mergeOptions(defaults, options); + + // De-initialize the plugin if it already has an array of overlays. + if (Array.isArray(this.overlays_)) { + this.overlays_.forEach(overlay => { + this.removeChild(overlay); + if (this.controlBar) { + this.controlBar.removeChild(overlay); + } + overlay.dispose(); + }); + } + + const overlays = settings.overlays; + + // We don't want to keep the original array of overlay options around + // because it doesn't make sense to pass it to each Overlay component. + delete settings.overlays; + + this.overlays_ = overlays.map(o => { + const mergeOptions = videojs.mergeOptions(settings, o); + const attachToControlBar = + typeof mergeOptions.attachToControlBar === 'string' || mergeOptions.attachToControlBar === true; + + if (!this.controls() || !this.controlBar) { + return this.addChild('overlay', mergeOptions); + } + + if (attachToControlBar && mergeOptions.align.indexOf('bottom') !== -1) { + let referenceChild = this.controlBar.children()[0]; + + if (this.controlBar.getChild(mergeOptions.attachToControlBar) !== undefined) { + referenceChild = this.controlBar.getChild(mergeOptions.attachToControlBar); + } + + if (referenceChild) { + const controlBarChild = this.controlBar.addChild('overlay', mergeOptions); + + this.controlBar.el().insertBefore(controlBarChild.el(), referenceChild.el()); + return controlBarChild; + } + } + + const playerChild = this.addChild('overlay', mergeOptions); + + this.el().insertBefore(playerChild.el(), this.controlBar.el()); + return playerChild; + }); +}; + +plugin.VERSION = VERSION; + +registerPlugin('overlay', plugin); + +export default plugin; diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.scss b/ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.scss new file mode 100644 index 000000000..2d9ff7e9b --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-overlay/plugin.scss @@ -0,0 +1,85 @@ +.video-js { + $bottom: 3.5em; + $nudge: 5px; + $middle: 50%; + $offset-h: -16.5%; + $offset-v: -15px; + + .vjs-overlay { + color: #fff; + position: absolute; + text-align: center; + } + + .vjs-overlay-no-background { + max-width: 33%; + } + + .vjs-overlay-background { + // IE8 + background-color: #646464; + background-color: rgba(255, 255, 255, 0.4); + border-radius: round($nudge / 2); + padding: $nudge * 2; + width: 33%; + } + + .vjs-overlay-top-left { + top: $nudge; + left: $nudge; + } + + .vjs-overlay-top { + left: $middle; + margin-left: $offset-h; + top: $nudge; + } + + .vjs-overlay-top-right { + right: $nudge; + top: $nudge; + } + + .vjs-overlay-right { + right: $nudge; + top: $middle; + transform: translateY(-50%); + } + + .vjs-overlay-bottom-right { + bottom: $bottom; + right: $nudge; + } + + .vjs-overlay-bottom { + bottom: $bottom; + left: $middle; + margin-left: $offset-h; + } + + .vjs-overlay-bottom-left { + bottom: $bottom; + left: $nudge; + } + + .vjs-overlay-left { + left: $nudge; + top: $middle; + transform: translateY(-50%); + } + + .vjs-overlay-center { + left: $middle; + margin-left: $offset-h; + top: $middle; + transform: translateY(-50%); + } + + // Fallback for IE8 and IE9 + .vjs-no-flex .vjs-overlay-left, + .vjs-no-flex .vjs-overlay-center, + .vjs-no-flex .vjs-overlay-right { + margin-top: $offset-v; + } +} + diff --git a/ui/component/viewers/videoViewer/internal/videojs.jsx b/ui/component/viewers/videoViewer/internal/videojs.jsx index 760fcf272..7be981709 100644 --- a/ui/component/viewers/videoViewer/internal/videojs.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs.jsx @@ -6,6 +6,8 @@ import classnames from 'classnames'; import videojs from 'video.js/dist/alt/video.core.novtt.min.js'; import 'video.js/dist/alt/video-js-cdn.min.css'; import eventTracking from 'videojs-event-tracking'; +import * as OVERLAY from './overlays'; +import './plugins/videojs-mobile-ui/plugin'; import isUserTyping from 'util/detect-typing'; import './adstest.js'; // import './adstest2.js'; @@ -30,6 +32,8 @@ export type Player = { getChild: string => any, playbackRate: (?number) => number, userActive: (?boolean) => boolean, + overlay: any => void, + mobileUi: any => void, }; type Props = { @@ -87,9 +91,9 @@ if (!Object.keys(videojs.getPlugins()).includes('eventTracking')) { videojs.registerPlugin('eventTracking', eventTracking); } -// ******************************************************************************************************************** +// **************************************************************************** // LbryVolumeBarClass -// ******************************************************************************************************************** +// **************************************************************************** const VIDEOJS_CONTROL_BAR_CLASS = 'ControlBar'; const VIDEOJS_VOLUME_PANEL_CLASS = 'VolumePanel'; @@ -128,8 +132,9 @@ class LbryVolumeBarClass extends videojs.getComponent(VIDEOJS_VOLUME_BAR_CLASS) } } -// ******************************************************************************************************************** -// ******************************************************************************************************************** +// **************************************************************************** +// VideoJs +// **************************************************************************** /* properties for this component should be kept to ONLY those that if changed should REQUIRE an entirely new videojs element @@ -150,7 +155,10 @@ export default React.memo<Props>(function VideoJs(props: Props) { ], autoplay: false, poster: poster, // thumb looks bad in app, and if autoplay, flashing poster is annoying - plugins: { eventTracking: true }, + plugins: { + eventTracking: true, + overlay: OVERLAY.OVERLAY_DATA, + }, }; if (adsTest) { @@ -257,7 +265,7 @@ export default React.memo<Props>(function VideoJs(props: Props) { function handleKeyDown(e: KeyboardEvent) { const videoNode: ?HTMLVideoElement = containerRef.current && containerRef.current.querySelector('video, audio'); - if (!videoNode || isUserTyping()) { + if (!videoNode || !player || isUserTyping()) { return; } @@ -266,7 +274,7 @@ export default React.memo<Props>(function VideoJs(props: Props) { } // Fullscreen toggle shortcuts - if (player && (e.keyCode === FULLSCREEN_KEYCODE || e.keyCode === F11_KEYCODE)) { + if (e.keyCode === FULLSCREEN_KEYCODE || e.keyCode === F11_KEYCODE) { if (!player.isFullscreen()) { player.requestFullscreen(); } else { @@ -280,29 +288,34 @@ export default React.memo<Props>(function VideoJs(props: Props) { } // Seeking Shortcuts - const duration = videoNode.duration; - const currentTime = videoNode.currentTime; - if (e.keyCode === SEEK_FORWARD_KEYCODE) { - const newDuration = currentTime + SEEK_STEP; - videoNode.currentTime = newDuration > duration ? duration : newDuration; - } - if (e.keyCode === SEEK_BACKWARD_KEYCODE) { - const newDuration = currentTime - SEEK_STEP; - videoNode.currentTime = newDuration < 0 ? 0 : newDuration; + if (!e.altKey) { + const duration = videoNode.duration; + const currentTime = videoNode.currentTime; + if (e.keyCode === SEEK_FORWARD_KEYCODE) { + const newDuration = currentTime + SEEK_STEP; + videoNode.currentTime = newDuration > duration ? duration : newDuration; + OVERLAY.showSeekedOverlay(player, SEEK_STEP, true); + player.userActive(true); + } else if (e.keyCode === SEEK_BACKWARD_KEYCODE) { + const newDuration = currentTime - SEEK_STEP; + videoNode.currentTime = newDuration < 0 ? 0 : newDuration; + OVERLAY.showSeekedOverlay(player, SEEK_STEP, false); + player.userActive(true); + } } // Playback-Rate Shortcuts ('>' = speed up, '<' = speed down) - if (player && e.shiftKey && (e.keyCode === PERIOD_KEYCODE || e.keyCode === COMMA_KEYCODE)) { + if (e.shiftKey && (e.keyCode === PERIOD_KEYCODE || e.keyCode === COMMA_KEYCODE)) { + const isSpeedUp = e.keyCode === PERIOD_KEYCODE; const rate = player.playbackRate(); let rateIndex = videoPlaybackRates.findIndex(x => x === rate); if (rateIndex >= 0) { - rateIndex = - e.keyCode === PERIOD_KEYCODE - ? Math.min(rateIndex + 1, videoPlaybackRates.length - 1) - : Math.max(rateIndex - 1, 0); + rateIndex = isSpeedUp ? Math.min(rateIndex + 1, videoPlaybackRates.length - 1) : Math.max(rateIndex - 1, 0); + const nextRate = videoPlaybackRates[rateIndex]; - player.userActive(true); // Bring up the ControlBar as GUI feedback. - player.playbackRate(videoPlaybackRates[rateIndex]); + OVERLAY.showPlaybackRateOverlay(player, nextRate, isSpeedUp); + player.userActive(true); + player.playbackRate(nextRate); } } } @@ -325,8 +338,8 @@ export default React.memo<Props>(function VideoJs(props: Props) { player.on('volumechange', onVolumeChange); player.on('error', onError); player.on('ended', onEnded); - LbryVolumeBarClass.replaceExisting(player); + player.mobileUi(); // Inits mobile version. No-op if Desktop. onPlayerReady(player); } diff --git a/ui/scss/component/_file-render.scss b/ui/scss/component/_file-render.scss index 5a547fe84..3d907d979 100644 --- a/ui/scss/component/_file-render.scss +++ b/ui/scss/component/_file-render.scss @@ -353,6 +353,10 @@ } } +// **************************************************************************** +// Video +// **************************************************************************** + .video-js-parent { height: 100%; width: 100%; @@ -399,6 +403,69 @@ } } +// **************************************************************************** +// Video::Overlays +// **************************************************************************** + +.video-js { + .vjs-overlay-playrate, + .vjs-overlay-seeked { + background-color: rgba(0, 0, 0, 0.5); + font-size: var(--font-large); + width: auto; + padding: 10px 30px; + margin: 0; + position: absolute; + top: 50%; + left: 50%; + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + + animation: fadeOutAnimation ease-in 0.6s; + animation-iteration-count: 1; + animation-fill-mode: forwards; + } + + @keyframes fadeOutAnimation { + 0% { + opacity: 1; + visibility: visible; + } + 100% { + opacity: 0; + visibility: hidden; + } + } +} + +// **************************************************************************** +// Video - Mobile UI +// **************************************************************************** + +.video-js.vjs-mobile-ui { + .vjs-control-bar { + background-color: transparent; + } + + .vjs-touch-overlay:not(.show-play-toggle) { + .vjs-control-bar { + // Sync the controlBar's visibility with the overlay's + display: none; + } + } + + .vjs-touch-overlay { + &.show-play-toggle, + &.skip { + background-color: rgba(0, 0, 0, 0.5); + } + } +} + +// **************************************************************************** +// Layout and control visibility +// **************************************************************************** + .video-js.vjs-fullscreen, .video-js:not(.vjs-fullscreen) { // --- Unhide desired components per layout --- @@ -439,11 +506,9 @@ } } -.video-js:hover { - .vjs-big-play-button { - background-color: var(--color-primary); - } -} +// **************************************************************************** +// Tap-to-unmute +// **************************************************************************** .video-js--tap-to-unmute { visibility: hidden; // Start off as hidden. @@ -463,6 +528,15 @@ } } +// **************************************************************************** +// **************************************************************************** + +.video-js:hover { + .vjs-big-play-button { + background-color: var(--color-primary); + } +} + .file-render { .video-js { display: flex; @@ -483,6 +557,9 @@ } } +// **************************************************************************** +// **************************************************************************** + .file-render--embed { // on embeds, do not inject our colors until interaction .video-js:hover { @@ -526,6 +603,10 @@ display: none !important; // yes this is dumb, but this was broken and the above CSS was overriding } +// **************************************************************************** +// Autoplay Countdown +// **************************************************************************** + .autoplay-countdown { display: flex; flex-direction: column; @@ -579,6 +660,9 @@ border-color: #fff; } +// **************************************************************************** +// **************************************************************************** + .file__viewdate { display: flex; justify-content: space-between;