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 = (
+
+ );
+
+ 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 = (
+
+
+ {isForward ? '+' : '-'}
+ {duration}
+
+
+ );
+
+ 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
+
+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,');
+ }
+
+ &.skip.reverse {
+ background-position: 20% center;
+ background-image: url('data:image/svg+xml;utf8,');
+ }
+
+ .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,');
+ }
+
+ &.vjs-paused .vjs-icon-placeholder::before {
+ content: '';
+ background-image: url('data:image/svg+xml;utf8,');
+ }
+
+ &.vjs-ended .vjs-icon-placeholder::before {
+ content: '';
+ background-image: url('data:image/svg+xml;utf8,');
+ }
+ }
+
+ &.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(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(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(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(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(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;