lbry-desktop/ui/component/viewers/videoViewer/internal/videojs.jsx

466 lines
14 KiB
React
Raw Normal View History

2020-04-16 23:43:09 +02:00
// @flow
import 'videojs-contrib-ads'; // must be loaded in this order
import 'videojs-ima'; // loads directly after contrib-ads
import 'video.js/dist/alt/video-js-cdn.min.css';
import './plugins/videojs-mobile-ui/plugin';
import '@silvermine/videojs-chromecast/dist/silvermine-videojs-chromecast.css';
2022-03-25 14:16:44 +01:00
import '@silvermine/videojs-airplay/dist/silvermine-videojs-airplay.css';
import * as ICONS from 'constants/icons';
import * as OVERLAY from './overlays';
import Button from 'component/button';
import classnames from 'classnames';
import events from './videojs-events';
import eventTracking from 'videojs-event-tracking';
import functions from './videojs-functions';
import hlsQualitySelector from './plugins/videojs-hls-quality-selector/plugin';
import keyboardShorcuts from './videojs-keyboard-shortcuts';
import LbryVolumeBarClass from './lbry-volume-bar';
import Chromecast from './chromecast';
import playerjs from 'player.js';
import qualityLevels from 'videojs-contrib-quality-levels';
import React, { useEffect, useRef, useState } from 'react';
import recsys from './plugins/videojs-recsys/plugin';
2021-12-15 19:59:02 +01:00
// import runAds from './ads';
import videojs from 'video.js';
2022-03-15 17:18:08 +01:00
import { useIsMobile } from 'effects/use-screensize';
const canAutoplay = require('./plugins/canAutoplay');
2020-04-16 01:21:17 +02:00
require('@silvermine/videojs-chromecast')(videojs);
2022-03-25 14:16:44 +01:00
require('@silvermine/videojs-airplay')(videojs);
2020-04-28 21:33:30 +02:00
export type Player = {
controlBar: { addChild: (string, any) => void },
loadingSpinner: any,
autoplay: (any) => boolean,
chromecast: (any) => void,
currentTime: (?number) => number,
dispose: () => void,
duration: () => number,
ended: () => boolean,
2020-05-21 17:38:28 +02:00
error: () => any,
exitFullscreen: () => boolean,
getChild: (string) => any,
isFullscreen: () => boolean,
mobileUi: (any) => void,
muted: (?boolean) => boolean,
on: (string, (any) => void) => void,
one: (string, (any) => void) => void,
overlay: (any) => void,
play: () => Promise<any>,
playbackRate: (?number) => number,
readyState: () => number,
requestFullscreen: () => boolean,
userActive: (?boolean) => boolean,
volume: (?number) => number,
2020-04-28 21:33:30 +02:00
};
2020-04-16 01:21:17 +02:00
type Props = {
adUrl: ?string,
allowPreRoll: ?boolean,
autoplay: boolean,
autoplaySetting: boolean,
claimId: ?string,
title: ?string,
channelName: ?string,
embedded: boolean,
internalFeatureEnabled: ?boolean,
isAudio: boolean,
poster: ?string,
replay: boolean,
shareTelemetry: boolean,
source: string,
sourceType: string,
startMuted: boolean,
userId: ?number,
videoTheaterMode: boolean,
onPlayerReady: (Player, any) => void,
playNext: () => void,
playPrevious: () => void,
toggleVideoTheaterMode: () => void,
claimRewards: () => void,
doAnalyticsView: (string, number) => void,
uri: string,
claimValues: any,
clearPosition: (string) => void,
2022-03-15 17:18:08 +01:00
isLivestreamClaim: boolean,
userClaimId: ?string,
activeLivestreamForChannel: any,
2020-04-16 01:21:17 +02:00
};
const videoPlaybackRates = [0.25, 0.5, 0.75, 1, 1.1, 1.25, 1.5, 1.75, 2];
2020-05-25 16:36:17 +02:00
const IS_IOS =
(/iPad|iPhone|iPod/.test(navigator.platform) ||
// for iOS 13+ , platform is MacIntel, so use this to test
2020-05-25 16:36:17 +02:00
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) &&
!window.MSStream;
2020-04-16 01:21:17 +02:00
if (!Object.keys(videojs.getPlugins()).includes('eventTracking')) {
videojs.registerPlugin('eventTracking', eventTracking);
}
if (!Object.keys(videojs.getPlugins()).includes('hlsQualitySelector')) {
videojs.registerPlugin('hlsQualitySelector', hlsQualitySelector);
}
if (!Object.keys(videojs.getPlugins()).includes('qualityLevels')) {
videojs.registerPlugin('qualityLevels', qualityLevels);
}
if (!Object.keys(videojs.getPlugins()).includes('recsys')) {
videojs.registerPlugin('recsys', recsys);
}
// ****************************************************************************
// VideoJs
// ****************************************************************************
2020-04-16 01:21:17 +02:00
/*
properties for this component should be kept to ONLY those that if changed should REQUIRE an entirely new videojs element
*/
2020-04-28 21:33:30 +02:00
export default React.memo<Props>(function VideoJs(props: Props) {
2021-04-12 18:43:47 +02:00
const {
// adUrl, // TODO: this ad functionality isn't used, can be pulled out
2021-12-15 19:59:02 +01:00
// allowPreRoll,
2021-04-12 18:43:47 +02:00
autoplay,
autoplaySetting,
claimId,
title,
channelName,
embedded,
2021-12-15 19:59:02 +01:00
// internalFeatureEnabled, // for people on the team to test new features internally
isAudio,
poster,
replay,
shareTelemetry,
2021-04-12 18:43:47 +02:00
source,
sourceType,
startMuted,
userId,
videoTheaterMode,
onPlayerReady,
playNext,
playPrevious,
toggleVideoTheaterMode,
claimValues,
doAnalyticsView,
claimRewards,
uri,
clearPosition,
2022-03-15 17:18:08 +01:00
userClaimId,
isLivestreamClaim,
activeLivestreamForChannel,
2021-04-12 18:43:47 +02:00
} = props;
2022-03-15 17:18:08 +01:00
const isMobile = useIsMobile();
// will later store the videojs player
const playerRef = useRef();
2020-04-16 23:43:09 +02:00
const containerRef = useRef();
const tapToUnmuteRef = useRef();
const tapToRetryRef = useRef();
const playerServerRef = useRef();
const { url: livestreamVideoUrl } = activeLivestreamForChannel || {};
2022-04-13 17:30:19 +02:00
const showQualitySelector =
!isLivestreamClaim ||
(livestreamVideoUrl && (livestreamVideoUrl.includes('/transcode/') || livestreamVideoUrl.includes('cloud.odysee')));
2022-03-15 17:18:08 +01:00
// initiate keyboard shortcuts
2022-03-15 17:18:08 +01:00
const { curried_function } = keyboardShorcuts({
isMobile,
isLivestreamClaim,
toggleVideoTheaterMode,
playNext,
playPrevious,
});
const [reload, setReload] = useState('initial');
const { createVideoPlayerDOM } = functions({ isAudio });
const { unmuteAndHideHint, retryVideoAfterFailure, initializeEvents } = events({
tapToUnmuteRef,
tapToRetryRef,
setReload,
videoTheaterMode,
playerRef,
autoplaySetting,
replay,
claimValues,
userId,
claimId,
embedded,
doAnalyticsView,
claimRewards,
uri,
playerServerRef,
clearPosition,
2022-03-15 17:18:08 +01:00
isLivestreamClaim,
});
const videoJsOptions = {
preload: 'auto',
playbackRates: videoPlaybackRates,
responsive: true,
controls: true,
html5: {
2022-03-16 03:45:16 +01:00
vhs: {
overrideNative: !videojs.browser.IS_ANY_SAFARI,
2022-03-15 17:18:08 +01:00
allowSeeksWithinUnsafeLiveWindow: true,
enableLowInitialPlaylist: false,
handlePartialData: true,
2022-03-16 03:45:16 +01:00
fastQualityChange: true,
},
},
2022-03-15 17:18:08 +01:00
liveTracker: {
trackingThreshold: 0,
liveTolerance: 10,
},
inactivityTimeout: 2000,
autoplay: autoplay,
muted: startMuted,
2020-04-16 01:21:17 +02:00
poster: poster, // thumb looks bad in app, and if autoplay, flashing poster is annoying
plugins: { eventTracking: true, overlay: OVERLAY.OVERLAY_DATA },
// fixes problem of errant CC button showing up on iOS
2021-06-25 18:26:00 +02:00
// the true fix here is to fix the m3u8 file, see: https://github.com/lbryio/lbry-desktop/pull/6315
2022-03-15 17:18:08 +01:00
controlBar: {
subsCapsButton: false,
currentTimeDisplay: !isLivestreamClaim,
timeDivider: !isLivestreamClaim,
durationDisplay: !isLivestreamClaim,
remainingTimeDisplay: !isLivestreamClaim,
},
techOrder: ['chromecast', 'html5'],
chromecast: {
requestTitleFn: (src) => title || '',
requestSubtitleFn: (src) => channelName || '',
},
bigPlayButton: embedded, // only show big play button if embedded
liveui: isLivestreamClaim,
2022-03-15 17:18:08 +01:00
suppressNotSupportedError: true,
2020-04-16 01:21:17 +02:00
};
// Initialize video.js
function initializeVideoPlayer(el, canAutoplayVideo) {
if (!el) return;
const vjs = videojs(el, videoJsOptions, async () => {
const player = playerRef.current;
const adapter = new playerjs.VideoJSAdapter(player);
// this seems like a weird thing to have to check for here
if (!player) return;
2021-01-26 00:50:11 +01:00
2021-12-15 19:29:06 +01:00
// runAds(internalFeatureEnabled, allowPreRoll, player, embedded);
initializeEvents();
// Replace volume bar with custom LBRY volume bar
LbryVolumeBarClass.replaceExisting(player);
2021-01-26 00:50:11 +01:00
// Add reloadSourceOnError plugin
player.reloadSourceOnError({ errorInterval: 10 });
// Initialize mobile UI.
player.mobileUi();
if (!embedded) {
window.player.bigPlayButton && window.player.bigPlayButton.hide();
} else {
const bigPlayButton = document.querySelector('.vjs-big-play-button');
if (bigPlayButton) bigPlayButton.style.setProperty('display', 'block', 'important');
}
// Add quality selector to player
2022-03-15 17:18:08 +01:00
if (showQualitySelector) player.hlsQualitySelector({ displayCurrentQuality: true });
// Add recsys plugin
if (shareTelemetry) {
player.recsys({
videoId: claimId,
userId: userId,
embedded: embedded,
});
}
// set playsinline for mobile
player.children_[0].setAttribute('playsinline', '');
if (canAutoplayVideo === true) {
// show waiting spinner as video is loading
player.addClass('vjs-waiting');
// document.querySelector('.vjs-big-play-button').style.setProperty('display', 'none', 'important');
} else {
// $FlowFixMe
document.querySelector('.vjs-big-play-button').style.setProperty('display', 'block', 'important');
}
// I think this is a callback function
const videoNode = containerRef.current && containerRef.current.querySelector('video, audio');
onPlayerReady(player, videoNode);
adapter.ready();
// sometimes video doesnt start properly, this addresses the edge case
if (autoplay) {
const videoDiv = window.player.children_[0];
if (videoDiv) {
videoDiv.click();
}
window.player.userActive(true);
}
2022-03-15 17:18:08 +01:00
Chromecast.initialize(player);
2022-03-25 14:16:44 +01:00
player.airPlay();
});
// 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());
return vjs;
}
2022-04-13 17:30:19 +02:00
// useEffect(() => {
// if (showQualitySelector) {
// // Add quality selector to player
// const player = playerRef.current;
// if (player) player.hlsQualitySelector({ displayCurrentQuality: true });
// }
// }, [showQualitySelector]);
2022-03-15 17:18:08 +01:00
/** instantiate videoJS and dispose of it when done with code **/
// This lifecycle hook is only called once (on mount), or when `isAudio` or `source` changes.
2020-04-16 23:43:09 +02:00
useEffect(() => {
(async function () {
// test if perms to play video are available
2022-01-06 22:20:42 +01:00
let canAutoplayVideo = await canAutoplay.video({ timeout: 2000, inline: true });
canAutoplayVideo = canAutoplayVideo.result === true;
const vjsElement = createVideoPlayerDOM(containerRef.current);
2020-04-28 21:33:30 +02:00
// Initialize Video.js
const vjsPlayer = initializeVideoPlayer(vjsElement, canAutoplayVideo);
2021-01-26 00:50:11 +01:00
// Add reference to player to global scope
window.player = vjsPlayer;
// Set reference in component state
playerRef.current = vjsPlayer;
window.addEventListener('keydown', curried_function(playerRef, containerRef));
// $FlowFixMe
2022-03-16 12:35:58 +01:00
const controlBar = document.querySelector('.vjs-control-bar');
if (controlBar) controlBar.style.setProperty('opacity', '1', 'important');
2022-03-15 17:18:08 +01:00
if (isLivestreamClaim && userClaimId) {
// $FlowFixMe
vjsPlayer.addClass('livestreamPlayer');
// $FlowFixMe
vjsPlayer.src({
type: 'application/x-mpegURL',
src: livestreamVideoUrl,
});
} else {
// $FlowFixMe
2022-03-15 17:18:08 +01:00
vjsPlayer.removeClass('livestreamPlayer');
// change to m3u8 if applicable
const response = await fetch(source, { method: 'HEAD', cache: 'no-store' });
playerServerRef.current = response.headers.get('x-powered-by');
if (response && response.redirected && response.url && response.url.endsWith('m3u8')) {
// use m3u8 source
// $FlowFixMe
vjsPlayer.src({
type: 'application/x-mpegURL',
src: response.url,
});
} else {
// use original mp4 source
// $FlowFixMe
vjsPlayer.src({
type: sourceType,
src: source,
});
}
}
2022-03-15 17:18:08 +01:00
// load video once source setup
// $FlowFixMe
vjsPlayer.load();
// fix invisible vidcrunch overlay on IOS
if (IS_IOS) {
// ads video player
const adsClaimDiv = document.querySelector('.ads__claim-item');
if (adsClaimDiv) {
// hide ad video by default
adsClaimDiv.style.display = 'none';
// ad containing div, we can keep part on page
const adsClaimParentDiv = adsClaimDiv.parentNode;
// watch parent div for when it is on viewport
const observer = new IntersectionObserver(function (entries) {
// when ad div parent becomes visible by 1px, show the ad video
if (entries[0].isIntersecting === true) {
adsClaimDiv.style.display = 'block';
}
observer.disconnect();
});
// $FlowFixMe
observer.observe(adsClaimParentDiv);
}
}
})();
2020-04-16 23:43:09 +02:00
// Cleanup
return () => {
window.removeEventListener('keydown', curried_function);
2020-04-16 23:43:09 +02:00
const player = playerRef.current;
if (player) {
try {
window.cast.framework.CastContext.getInstance().getCurrentSession().endSession(false);
} catch {}
player.dispose();
window.player = undefined;
}
2021-01-26 00:50:11 +01:00
};
2022-03-15 17:18:08 +01:00
}, [isAudio, source, reload, userClaimId, isLivestreamClaim]);
2020-04-16 01:21:17 +02:00
return (
<div className={classnames('video-js-parent', { 'video-js-parent--ios': IS_IOS })} ref={containerRef}>
<Button
label={__('Tap to unmute')}
button="link"
icon={ICONS.VOLUME_MUTED}
className="video-js--tap-to-unmute"
onClick={unmuteAndHideHint}
ref={tapToUnmuteRef}
/>
<Button
label={__('Retry')}
button="link"
icon={ICONS.REFRESH}
className="video-js--tap-to-unmute"
onClick={retryVideoAfterFailure}
ref={tapToRetryRef}
/>
</div>
);
2020-04-16 18:10:47 +02:00
});