Video: Mobile UI + overlay for keyboard shortcut feedback (#5119)

Co-authored-by: import <>
This commit is contained in:
infinite-persistence 2020-12-15 00:40:59 +08:00 committed by GitHub
parent 4a88b3f847
commit 04fbde49ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 1103 additions and 29 deletions

View file

@ -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

View file

@ -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');
}

View file

@ -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.

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;