Add pre-roll ads

change ad macro url
rework video.js load logic
fix autoplay and retry errors
yeet player.ended error
fix another race condition
fix another error message
add allowPreRoll restrictions
fix some lint issues
remove annoying lint rule
remove video.js lazy loading
more linting fixes
This commit is contained in:
DispatchCommit 2021-06-30 09:29:00 -07:00 committed by jessopb
parent 0de6be77de
commit b777669a7e
7 changed files with 316 additions and 83 deletions

View file

@ -35,6 +35,7 @@
"object-curly-spacing": 0,
"one-var": 0,
"prefer-promise-reject-errors": 0,
"promise/param-names": 0,
"react/jsx-indent": 0,
"react/jsx-no-comment-textnodes": 0,
"react-hooks/exhaustive-deps": "warn",

View file

@ -60,6 +60,8 @@
"remove-markdown": "^0.3.0",
"source-map-explorer": "^2.5.2",
"tempy": "^0.6.0",
"videojs-contrib-ads": "^6.9.0",
"videojs-ima": "^1.11.0",
"videojs-logo": "^2.1.4"
},
"devDependencies": {

View file

@ -0,0 +1,102 @@
// Created by xander on 6/21/2021
import videojs from 'video.js';
import 'videojs-ima';
const VERSION = '0.0.1';
const macroUrl =
'https://vast.aniview.com/api/adserver61/vast/?AV_PUBLISHERID=60afcbc58cfdb065440d2426&AV_CHANNELID=60b354389c7adb506d0bd9a4&AV_URL=[URL_MACRO]&cb=[TIMESTAMP_MACRO]&AV_WIDTH=[WIDTH_MACRO]&AV_HEIGHT=[HEIGHT_MACRO]&AV_SCHAIN=[SCHAIN_MACRO]&AV_CCPA=[CCPA_MACRO]&AV_GDPR=[GDPR_MACRO]&AV_CONSENT=[CONSENT_MACRO]&skip=true&skiptimer=5&usevslot=true&hidecontrols=false';
const defaults = {
adTagUrl: macroUrl,
debug: false,
};
const Component = videojs.getComponent('Component');
const registerPlugin = videojs.registerPlugin || videojs.plugin;
class AniviewPlugin extends Component {
constructor(player, options) {
super(player, options);
// Plugin started
if (options.debug) {
this.log(`Created aniview plugin.`);
}
// To help with debugging, we'll add a global vjs object with the video js player
window.aniview = player;
this.player = player;
const google = window.google;
player.ima({
// adTagUrl: macroUrl,
id: 'ad_content_video',
vpaidMode: google.ima.ImaSdkSettings.VpaidMode.INSECURE,
adTagUrl:
'https://vast.aniview.com/api/adserver61/vast/?AV_PUBLISHERID=60afcbc58cfdb065440d2426&AV_CHANNELID=60b354389c7adb506d0bd9a4',
});
// this.player.ads();
// const serverUrl = this.player.ads.adMacroReplacement(macroUrl);
// this.log(serverUrl);
// request ads whenever there's new video content
player.on('contentchanged', () => {
// in a real plugin, you might fetch your ad inventory here
player.trigger('adsready');
});
// Plugin event listeners
player.on('readyforpreroll', (event) => this.onReadyForPreroll(event));
}
onReadyForPreroll(event) {
this.player.ads.startLinearAdMode();
// play your linear ad content
// in this example, we use a static mp4
this.player.src('kitteh.mp4');
// send event when ad is playing to remove loading spinner
this.player.one('adplaying', () => {
this.player.trigger('ads-ad-started');
});
// resume content when all your linear ads have finished
this.player.one('adended', () => {
this.player.ads.endLinearAdMode();
});
}
log(...args) {
if (this.options_.debug) {
console.log(`Aniview Debug:`, JSON.stringify(args));
}
}
}
videojs.registerComponent('recsys', AniviewPlugin);
const onPlayerReady = (player, options) => {
player.aniview = new AniviewPlugin(player, options);
};
/**
* Initialize the plugin.
*
* @function plugin
* @param {Object} [options={}]
*/
const plugin = function (options) {
this.ready(() => {
onPlayerReady(this, videojs.mergeOptions(defaults, options));
});
};
plugin.VERSION = VERSION;
registerPlugin('aniview', plugin);
export default plugin;

View file

@ -13,8 +13,13 @@ import hlsQualitySelector from './plugins/videojs-hls-quality-selector/plugin';
import recsys from './plugins/videojs-recsys/plugin';
import qualityLevels from 'videojs-contrib-quality-levels';
import isUserTyping from 'util/detect-typing';
import 'videojs-contrib-ads';
import 'videojs-ima';
// import aniview from './plugins/videojs-aniview/plugin';
const isDev = process.env.NODE_ENV !== 'production';
const macroUrl =
'https://vast.aniview.com/api/adserver61/vast/?AV_PUBLISHERID=60afcbc58cfdb065440d2426&AV_CHANNELID=60b354389c7adb506d0bd9a4&AV_URL=[URL_MACRO]&cb=[TIMESTAMP_MACRO]&AV_WIDTH=[WIDTH_MACRO]&AV_HEIGHT=[HEIGHT_MACRO]&AV_SCHAIN=[SCHAIN_MACRO]&AV_CCPA=[CCPA_MACRO]&AV_GDPR=[GDPR_MACRO]&AV_CONSENT=[CONSENT_MACRO]&skip=true&skiptimer=5&usevslot=true&hidecontrols=false';
export type Player = {
on: (string, (any) => void) => void,
@ -54,6 +59,7 @@ type Props = {
adUrl: ?string,
claimId: ?string,
userId: ?number,
allowPreRoll: ?boolean,
};
type VideoJSOptions = {
@ -78,7 +84,7 @@ const VIDEO_JS_OPTIONS: VideoJSOptions = {
responsive: true,
controls: true,
html5: {
hls: {
vhs: {
overrideNative: !videojs.browser.IS_ANY_SAFARI,
},
},
@ -190,6 +196,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
adUrl,
claimId,
userId,
allowPreRoll,
} = props;
const [reload, setReload] = useState('initial');
@ -321,7 +328,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// clashes if we add a new button in the future.
// (2) We'll have to get 'makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)'
// as a prop here so we can say "Theater mode|Default mode" instead of
// "Toggle Theather mode".
// "Toggle Theater mode".
controlBar.getChild('Button').controlText(__('Toggle Theater mode (t)'));
break;
default:
@ -337,6 +344,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// The css starts as "hidden". We make it visible here without
// re-rendering the whole thing.
showTapButton(TAP.UNMUTE);
} else {
showTapButton(TAP.NONE);
}
}
@ -351,6 +360,10 @@ export default React.memo<Props>(function VideoJs(props: Props) {
const player = playerRef.current;
showTapButton(TAP.RETRY);
// reattach initial play listener in case we recover from error successfully
// $FlowFixMe
player.one('play', onInitialPlay);
if (player && player.loadingSpinner) {
player.loadingSpinner.hide();
}
@ -473,9 +486,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// Create the video DOM element and wrapper
function createVideoPlayerDOM(container) {
if (!container) {
return;
}
if (!container) return;
// This seems like a poor way to generate the DOM for video.js
const wrapper = document.createElement('div');
@ -489,19 +500,51 @@ export default React.memo<Props>(function VideoJs(props: Props) {
return el;
}
function detectFileType() {
console.log(`Detecting file type via pre-fetch...`);
return new Promise(async (res, rej) => {
try {
const response = await fetch(source, { method: 'HEAD', cache: 'no-store' });
// Temp variables to hold results
let finalType = sourceType;
let finalSource = source;
// override type if we receive an .m3u8 (transcoded mp4)
// do we need to check if explicitly redirected
// or is checking extension only a safer method
if (response && response.redirected && response.url && response.url.endsWith('m3u8')) {
finalType = 'application/x-mpegURL';
finalSource = response.url;
}
console.log(`File type is: ${finalType}`);
// Modify video source in options
videoJsOptions.sources = [
{
src: finalSource,
type: finalType,
},
];
return res(videoJsOptions);
} catch (error) {
console.error(`Failed to pre-fetch video!`);
return rej(error);
}
});
}
// Initialize video.js
function initializeVideoPlayer(el) {
if (!el) {
return;
}
if (!el) return;
const vjs = videojs(el, videoJsOptions, () => {
const player = playerRef.current;
// this seems like a weird thing to have to check for here
if (!player) {
return;
}
if (!player) return;
// Add various event listeners to player
player.one('play', onInitialPlay);
@ -517,13 +560,45 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// Replace volume bar with custom LBRY volume bar
LbryVolumeBarClass.replaceExisting(player);
// Add reloadSourceOnError plugin
player.reloadSourceOnError({ errorInterval: 10 });
// initialize mobile UI
player.mobileUi(); // Inits mobile version. No-op if Desktop.
// Add quality selector to player
player.hlsQualitySelector({
displayCurrentQuality: true,
});
// Add recsys plugin
// TODO: Add an if(odysee.com) around this function to only use recsys on odysee
player.recsys({
videoId: claimId,
userId: userId,
});
// set playsinline for mobile
// TODO: make this better
player.children_[0].setAttribute('playsinline', '');
// I think this is a callback function
onPlayerReady(player);
});
// pre-roll ads
// This must be initialized earlier than everything else
// otherwise a race condition occurs if we place this in the onReady call back
if (allowPreRoll && !SIMPLE_SITE && window.google) {
const google = window.google;
// player.aniview();
vjs.ima({
// $FlowFixMe
vpaidMode: google.ima.ImaSdkSettings.VpaidMode.INSECURE,
adTagUrl: macroUrl,
});
}
// fixes #3498 (https://github.com/lbryio/lbry-desktop/issues/3498)
// summary: on firefox the focus would stick to the fullscreen button which caused buggy behavior with spacebar
vjs.on('fullscreenchange', () => document.activeElement && document.activeElement.blur());
@ -534,6 +609,10 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// This lifecycle hook is only called once (on mount), or when `isAudio` changes.
useEffect(() => {
const vjsElement = createVideoPlayerDOM(containerRef.current);
// Detect source file type via pre-fetch (async)
detectFileType().then(() => {
// Initialize Video.js
const vjsPlayer = initializeVideoPlayer(vjsElement);
// Add reference to player to global scope
@ -544,6 +623,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// Add event listener for keyboard shortcuts
window.addEventListener('keydown', handleKeyDown);
});
// Cleanup
return () => {
@ -561,51 +641,28 @@ export default React.memo<Props>(function VideoJs(props: Props) {
useEffect(() => {
// For some reason the video player is responsible for detecting content type this way
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
const player = playerRef.current;
if (!player) {
return;
}
let type = sourceType;
let finalType = sourceType;
let finalSource = source;
// override type if we receive an .m3u8 (transcoded mp4)
// do we need to check if explicitly redirected
// or is checking extension only a safer method
if (response && response.redirected && response.url && response.url.endsWith('m3u8')) {
type = 'application/x-mpegURL';
finalType = 'application/x-mpegURL';
finalSource = response.url;
}
// Update player poster
// note: the poster prop seems to return null usually.
if (poster) player.poster(poster);
// Modify video source in options
videoJsOptions.sources = [
{
src: finalSource,
type: finalType,
},
];
// Update player source
player.src({
src: finalSource,
type: type,
});
// set playsinline for mobile
player.children_[0].setAttribute('playsinline', '');
// Add quality selector to player
player.hlsQualitySelector({
displayCurrentQuality: true,
});
// Add recsys plugin
if (SIMPLE_SITE) {
player.recsys({
videoId: claimId,
userId: userId,
});
}
// Update player source
player.src({
src: finalSource,
type: type,
});
const player = playerRef.current;
if (!player) return;
// PR #5570: Temp workaround to avoid double Play button until the next re-architecture.
if (!player.paused()) {
@ -614,6 +671,20 @@ export default React.memo<Props>(function VideoJs(props: Props) {
});
}, [source, reload]);
// Load IMA3 SDK for aniview
useEffect(() => {
const script = document.createElement('script');
script.src = `https://imasdk.googleapis.com/js/sdkloader/ima3.js`;
script.async = true;
// $FlowFixMe
document.body.appendChild(script);
return () => {
// $FlowFixMe
document.body.removeChild(script);
};
});
return (
// $FlowFixMe
<div className={classnames('video-js-parent', { 'video-js-parent--ios': IS_IOS })} ref={containerRef}>

View file

@ -178,7 +178,9 @@ function VideoViewer(props: Props) {
} else if (autoplaySetting) {
setShowAutoplayCountdown(true);
}
}, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl]);
clearPosition(uri);
}, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl, clearPosition, uri]);
function onPlay(player) {
setIsLoading(false);
@ -187,22 +189,18 @@ function VideoViewer(props: Props) {
setIsEndededEmbed(false);
}
function onPause(player) {
function onPause(event, player) {
setIsPlaying(false);
handlePosition(player);
}
function onDispose(player) {
function onDispose(event, player) {
handlePosition(player);
}
function handlePosition(player) {
if (player.ended()) {
clearPosition(uri);
} else {
savePosition(uri, player.currentTime());
}
}
function restorePlaybackRate(player) {
if (!vjsCallbackDataRef.current.embedded) {
@ -233,18 +231,13 @@ function VideoViewer(props: Props) {
Promise.race([playPromise, timeoutPromise]).catch((error) => {
if (typeof error === 'object' && error.name && error.name === 'NotAllowedError') {
// Autoplay disallowed by browser
player.play();
if (player.autoplay() && !player.muted()) {
// player.muted(true);
// another version had player.play()
}
}
// Autoplay failed
if (PLAY_TIMEOUT_ERROR) {
setIsLoading(false);
setIsPlaying(false);
} else {
setIsLoading(false);
setIsPlaying(false);
}
});
}
@ -259,10 +252,9 @@ function VideoViewer(props: Props) {
player.on('tracking:buffered', doTrackingBuffered);
player.on('tracking:firstplay', doTrackingFirstPlay);
player.on('ended', onEnded);
player.on('play', () => onPlay(player));
player.on('pause', () => onPause(player));
player.on('dispose', () => onDispose(player));
player.on('play', onPlay);
player.on('pause', (event) => onPause(event, player));
player.on('dispose', (event) => onDispose(event, player));
player.on('error', () => {
const error = player.error();
if (error) {
@ -348,6 +340,7 @@ function VideoViewer(props: Props) {
autoplay={!embedded || autoplayIfEmbedded}
claimId={claimId}
userId={userId}
allowPreRoll={!embedded && !authenticated}
/>
)}
</div>

View file

@ -571,9 +571,9 @@ video::-internal-media-controls-overlay-cast-button {
.file-render {
.video-js {
display: flex;
align-items: center;
justify-content: center;
/*display: flex;*/
/*align-items: center;*/
/*justify-content: center;*/
}
.vjs-big-play-button {

View file

@ -1266,6 +1266,25 @@
tough-cookie "^2.2.2"
tough-cookie-web-storage-store "^1.0.0"
"@hapi/boom@9.x.x":
version "9.1.2"
resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-9.1.2.tgz#48bd41d67437164a2d636e3b5bc954f8c8dc5e38"
integrity sha512-uJEJtiNHzKw80JpngDGBCGAmWjBtzxDCz17A9NO2zCi8LLBlb5Frpq4pXwyN+2JQMod4pKz5BALwyneCgDg89Q==
dependencies:
"@hapi/hoek" "9.x.x"
"@hapi/cryptiles@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@hapi/cryptiles/-/cryptiles-5.1.0.tgz#655de4cbbc052c947f696148c83b187fc2be8f43"
integrity sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==
dependencies:
"@hapi/boom" "9.x.x"
"@hapi/hoek@9.x.x":
version "9.2.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131"
integrity sha512-sqKVVVOe5ivCaXDWivIJYVSaEgdQK9ul7a4Kity5Iw7u9+wBAPbX1RMSnLLmp7O4Vzj0WOWwMAJsTL00xwaNug==
"@hot-loader/react-dom@^16.13":
version "16.13.0"
resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.13.0.tgz#de245b42358110baf80aaf47a0592153d4047997"
@ -3014,6 +3033,11 @@ camelcase@^5.0.0, camelcase@^5.2.0, camelcase@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
can-autoplay@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/can-autoplay/-/can-autoplay-3.0.0.tgz#fc2de8f1d41b36f6d860d9336b66841d30f8b62d"
integrity sha512-qQXGGYPWgF8nPjEt305o3TJ/BkN15l6/wG+VU4N93YYXD3OtYkBBx+l5un7ihIk2UU1OLytAVJjW7ZR39j/CAQ==
caniuse-api@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
@ -5157,7 +5181,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0"
is-extendable "^1.0.1"
extend@^3.0.0, extend@~3.0.1:
extend@>=3.0.2, extend@^3.0.0, extend@~3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
@ -7455,6 +7479,11 @@ lodash-es@^4.17.14, lodash-es@^4.2.1:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
lodash._reinterpolate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
@ -7504,6 +7533,21 @@ lodash.snakecase@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d"
lodash.template@>=4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
dependencies:
lodash._reinterpolate "^3.0.0"
lodash.templatesettings "^4.0.0"
lodash.templatesettings@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
dependencies:
lodash._reinterpolate "^3.0.0"
lodash.toarray@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561"
@ -7516,7 +7560,7 @@ lodash.unset@^4.5.2:
version "4.5.2"
resolved "https://registry.yarnpkg.com/lodash.unset/-/lodash.unset-4.5.2.tgz#370d1d3e85b72a7e1b0cdf2d272121306f23e4ed"
lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.6.1:
lodash@>=4.17.19, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.6.1:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -12144,6 +12188,14 @@ video.js@^7.0.0:
videojs-font "3.2.0"
videojs-vtt.js "^0.15.2"
videojs-contrib-ads@^6.6.5, videojs-contrib-ads@^6.9.0:
version "6.9.0"
resolved "https://registry.yarnpkg.com/videojs-contrib-ads/-/videojs-contrib-ads-6.9.0.tgz#c792d6fda77254b277545cc3222352fc653b5833"
integrity sha512-nzKz+jhCGMTYffSNVYrmp9p70s05v6jUMOY3Z7DpVk3iFrWK4Zi/BIkokDWrMoHpKjdmCdKzfJVBT+CrUj6Spw==
dependencies:
global "^4.3.2"
video.js "^6 || ^7"
videojs-contrib-quality-levels@^2.0.9:
version "2.0.9"
resolved "https://registry.yarnpkg.com/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-2.0.9.tgz#b5d533d5092a6fc7d29eae1b43e4597d89bd527b"
@ -12164,6 +12216,18 @@ videojs-font@3.2.0:
resolved "https://registry.yarnpkg.com/videojs-font/-/videojs-font-3.2.0.tgz#212c9d3f4e4ec3fa7345167d64316add35e92232"
integrity sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==
videojs-ima@^1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/videojs-ima/-/videojs-ima-1.11.0.tgz#26ad385e388c3da72372298d7d755b001d05a91d"
integrity sha512-ZRoWuGyJ75zamwZgpr0i/gZ6q7Evda/Q6R46gpW88WN7u0ORU7apw/lM1MSG4c3YDXW8LDENgzMAvMZUdifWhg==
dependencies:
"@hapi/cryptiles" "^5.1.0"
can-autoplay "^3.0.0"
extend ">=3.0.2"
lodash ">=4.17.19"
lodash.template ">=4.5.0"
videojs-contrib-ads "^6.6.5"
videojs-logo@^2.1.4:
version "2.1.4"
resolved "https://registry.yarnpkg.com/videojs-logo/-/videojs-logo-2.1.4.tgz#56675b3f95949910bad3c217835ea57aa6fdf212"