Video: Mobile UI + overlay for keyboard shortcut feedback (#5119)
Co-authored-by: import <>
This commit is contained in:
parent
4a88b3f847
commit
04fbde49ec
10 changed files with 1103 additions and 29 deletions
|
@ -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
|
||||
|
|
99
ui/component/viewers/videoViewer/internal/overlays.js
Normal file
99
ui/component/viewers/videoViewer/internal/overlays.js
Normal 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');
|
||||
}
|
|
@ -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.
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue