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;