// @flow import 'videojs-contrib-ads'; // must be loaded in this order import 'videojs-ima'; // loads directly after contrib-ads import 'videojs-vtt-thumbnails'; 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'; import '@silvermine/videojs-airplay/dist/silvermine-videojs-airplay.css'; import * as ICONS from 'constants/icons'; import { VIDEO_PLAYBACK_RATES } from 'constants/player'; 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-shortcuts'; import LbryPlaybackRateMenuButton from './lbry-playback-rate'; 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 i18n from './plugins/videojs-i18n/plugin'; import recsys from './plugins/videojs-recsys/plugin'; // import runAds from './ads'; import videojs from 'video.js'; import { useIsMobile } from 'effects/use-screensize'; import { platform } from 'util/platform'; import usePersistedState from 'effects/use-persisted-state'; require('@silvermine/videojs-chromecast')(videojs); require('@silvermine/videojs-airplay')(videojs); export type Player = { // -- custom -- claimSrcOriginal: ?{ src: string, type: string }, claimSrcVhs: ?{ src: string, type: string }, isLivestream?: boolean, // -- plugins --- mobileUi: (any) => void, chromecast: (any) => void, overlay: (any) => void, hlsQualitySelector: ?any, i18n: (any) => void, // -- base videojs -- controlBar: { addChild: (string | any, ?any, ?number) => void, getChild: (string) => void, removeChild: (string) => void, }, loadingSpinner: any, autoplay: (any) => boolean, tech: (?boolean) => { vhs: ?any }, currentTime: (?number) => number, dispose: () => void, duration: () => number, ended: () => boolean, error: () => any, exitFullscreen: () => boolean, getChild: (string) => any, isFullscreen: () => boolean, muted: (?boolean) => boolean, on: (string, (any) => void) => void, off: (string, (any) => void) => void, one: (string, (any) => void) => void, play: () => Promise, playbackRate: (?number) => number, readyState: () => number, requestFullscreen: () => boolean, src: ({ src: string, type: string }) => ?string, currentSrc: () => string, userActive: (?boolean) => boolean, volume: (?number) => number, }; type Props = { adUrl: ?string, allowPreRoll: ?boolean, autoplay: boolean, claimId: ?string, title: ?string, channelTitle: string, embedded: boolean, internalFeatureEnabled: ?boolean, isAudio: boolean, poster: ?string, replay: boolean, shareTelemetry: boolean, source: string, sourceType: string, startMuted: boolean, userId: ?number, defaultQuality: ?string, onPlayerReady: (Player, any) => void, playNext: () => void, playPrevious: () => void, toggleVideoTheaterMode: () => void, claimRewards: () => void, doAnalyticsView: (string, number) => void, doAnalyticsBuffer: (string, any) => void, uri: string, claimValues: any, isLivestreamClaim: boolean, userClaimId: ?string, activeLivestreamForChannel: any, doToast: ({ message: string, linkText: string, linkTarget: string }) => void, }; const VIDEOJS_VOLUME_PANEL_CLASS = 'VolumePanel'; const IS_IOS = platform.isIOS(); const IS_MOBILE = platform.isMobile(); const PLUGIN_MAP = { eventTracking: eventTracking, hlsQualitySelector: hlsQualitySelector, qualityLevels: qualityLevels, recsys: recsys, i18n: i18n, }; Object.entries(PLUGIN_MAP).forEach(([pluginName, plugin]) => { if (!Object.keys(videojs.getPlugins()).includes(pluginName)) { videojs.registerPlugin(pluginName, plugin); } }); // **************************************************************************** // VideoJs // **************************************************************************** /* properties for this component should be kept to ONLY those that if changed should REQUIRE an entirely new videojs element */ export default React.memo(function VideoJs(props: Props) { const { // adUrl, // TODO: this ad functionality isn't used, can be pulled out // allowPreRoll, autoplay, claimId, title, channelTitle, embedded, // internalFeatureEnabled, // for people on the team to test new features internally isAudio, poster, replay, shareTelemetry, source, sourceType, startMuted, userId, defaultQuality, onPlayerReady, playNext, playPrevious, toggleVideoTheaterMode, claimValues, doAnalyticsView, doAnalyticsBuffer, claimRewards, uri, userClaimId, isLivestreamClaim, activeLivestreamForChannel, doToast, } = props; // used to notify about default quality setting // if already has a quality set, no need to notify const [initialQualityChange, setInitialQualityChange] = usePersistedState( 'initial-quality-change', Boolean(defaultQuality) ); const isMobile = useIsMobile(); const playerRef = useRef(); const containerRef = useRef(); const tapToUnmuteRef = useRef(); const tapToRetryRef = useRef(); const playerServerRef = useRef(); const volumePanelRef = useRef(); const keyDownHandlerRef = useRef(); const videoScrollHandlerRef = useRef(); const volumePanelScrollHandlerRef = useRef(); const { url: livestreamVideoUrl } = activeLivestreamForChannel || {}; const overrideNativeVhs = !platform.isIPhone(); const showQualitySelector = (!isLivestreamClaim && overrideNativeVhs) || livestreamVideoUrl; // initiate keyboard shortcuts const { createKeyDownShortcutsHandler, createVideoScrollShortcutsHandler, createVolumePanelScrollShortcutsHandler, } = keyboardShorcuts({ isMobile, isLivestreamClaim, toggleVideoTheaterMode, playNext, playPrevious, }); const [reload, setReload] = useState('initial'); const { createVideoPlayerDOM } = functions({ isAudio }); const { unmuteAndHideHint, retryVideoAfterFailure, initializeEvents } = events({ tapToUnmuteRef, tapToRetryRef, setReload, playerRef, replay, claimValues, userId, claimId, embedded, doAnalyticsView, doAnalyticsBuffer, claimRewards, uri, playerServerRef, isLivestreamClaim, channelTitle, }); const videoJsOptions = { preload: 'auto', playbackRates: VIDEO_PLAYBACK_RATES, responsive: true, controls: true, html5: { vhs: { overrideNative: overrideNativeVhs, // !videojs.browser.IS_ANY_SAFARI, enableLowInitialPlaylist: false, fastQualityChange: true, useDtsForTimestampOffset: true, }, }, liveTracker: { trackingThreshold: 0, liveTolerance: 10, }, inactivityTimeout: 2000, muted: startMuted, plugins: { eventTracking: true, overlay: OVERLAY.OVERLAY_DATA }, controlBar: { currentTimeDisplay: !isLivestreamClaim, timeDivider: !isLivestreamClaim, durationDisplay: !isLivestreamClaim, remainingTimeDisplay: !isLivestreamClaim, subsCapsButton: !IS_IOS, }, techOrder: ['chromecast', 'html5'], ...Chromecast.getOptions(), bigPlayButton: embedded, // only show big play button if embedded liveui: isLivestreamClaim, suppressNotSupportedError: true, }; // TODO: would be nice to pull this out into functions file // Initialize video.js function initializeVideoPlayer(domElement) { if (!domElement) return; const vjs = videojs(domElement, 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; // runAds(internalFeatureEnabled, allowPreRoll, player, embedded); LbryVolumeBarClass.replaceExisting(player); LbryPlaybackRateMenuButton.replaceExisting(player); // Add reloadSourceOnError plugin player.reloadSourceOnError({ errorInterval: 10 }); // Initialize mobile UI. player.mobileUi({ fullscreen: { enterOnRotate: false, }, touchControls: { seekSeconds: 10, }, }); player.i18n(); // Add quality selector to player if (showQualitySelector) { player.hlsQualitySelector({ displayCurrentQuality: true, originalHeight: claimValues?.video?.height, defaultQuality, initialQualityChange, setInitialQualityChange, doToast, }); } // Add recsys plugin if (shareTelemetry) { player.recsys({ videoId: claimId, userId: userId, embedded: embedded, }); } // set playsinline for mobile player.children_[0].setAttribute('playsinline', ''); // immediately show control bar while video is loading player.userActive(true); adapter.ready(); Chromecast.initialize(player); 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; } // useEffect(() => { // if (showQualitySelector) { // // Add quality selector to player // const player = playerRef.current; // if (player) player.hlsQualitySelector({ displayCurrentQuality: true }); // } // }, [showQualitySelector]); useEffect(() => { Chromecast.updateTitles(title, channelTitle); }, [title, channelTitle]); // This lifecycle hook is only called once (on mount), or when `isAudio` or `source` changes. useEffect(() => { (async function () { let vjsPlayer; const vjsParent = document.querySelector('.video-js-parent'); let canUseOldPlayer = window.oldSavedDiv && vjsParent; const isLivestream = isLivestreamClaim && userClaimId; // make an additional check and reinstantiate if switching between player types // switching between types on iOS causes issues and this is a faster solution if (vjsParent && window.player) { const oldVideoType = window.player.isLivestream ? 'livestream' : 'video'; const switchFromLivestreamToVideo = oldVideoType === 'livestream' && !isLivestream; const switchFromVideoToLivestream = oldVideoType === 'video' && isLivestream; if (switchFromLivestreamToVideo || switchFromVideoToLivestream) { canUseOldPlayer = false; window.player.dispose(); } } // initialize videojs if it hasn't been done yet if (!canUseOldPlayer) { const vjsElement = createVideoPlayerDOM(containerRef.current); vjsPlayer = initializeVideoPlayer(vjsElement); if (!vjsPlayer) { return; } // Add reference to player to global scope window.player = vjsPlayer; } else { vjsPlayer = window.player; } // Add recsys plugin if (shareTelemetry) { vjsPlayer.recsys.options_ = { videoId: claimId, userId: userId, embedded: embedded, }; vjsPlayer.recsys.lastTimeUpdate = null; vjsPlayer.recsys.currentTimeUpdate = null; vjsPlayer.recsys.inPause = false; vjsPlayer.recsys.watchedDuration = { total: 0, lastTimestamp: -1 }; } if (!embedded) { vjsPlayer.bigPlayButton && window.player.bigPlayButton.hide(); } else { // $FlowIssue vjsPlayer.bigPlayButton?.show(); } // I think this is a callback function const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); // add theatre and autoplay next button and initiate player events onPlayerReady(vjsPlayer, videoNode); // Set reference in component state playerRef.current = vjsPlayer; initializeEvents(); // volume control div, used for changing volume when scrolled over // $FlowIssue volumePanelRef.current = playerRef.current?.controlBar?.getChild(VIDEOJS_VOLUME_PANEL_CLASS)?.el(); const keyDownHandler = createKeyDownShortcutsHandler(playerRef, containerRef); const videoScrollHandler = createVideoScrollShortcutsHandler(playerRef, containerRef); const volumePanelHandler = createVolumePanelScrollShortcutsHandler(volumePanelRef, playerRef, containerRef); window.addEventListener('keydown', keyDownHandler); const containerDiv = containerRef.current; containerDiv && containerDiv.addEventListener('wheel', videoScrollHandler); if (volumePanelRef.current) volumePanelRef.current.addEventListener('wheel', volumePanelHandler); keyDownHandlerRef.current = keyDownHandler; videoScrollHandlerRef.current = videoScrollHandler; volumePanelScrollHandlerRef.current = volumePanelHandler; // $FlowIssue vjsPlayer.controlBar?.show(); vjsPlayer.poster(poster); let contentUrl; // TODO: pull this function into videojs-functions // determine which source to use and load it if (isLivestream) { vjsPlayer.isLivestream = true; vjsPlayer.addClass('livestreamPlayer'); vjsPlayer.src({ type: 'application/x-mpegURL', src: livestreamVideoUrl }); } else { vjsPlayer.isLivestream = false; 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'); vjsPlayer.claimSrcOriginal = { type: sourceType, src: source }; if (response && response.redirected && response.url && response.url.endsWith('m3u8')) { vjsPlayer.claimSrcVhs = { type: 'application/x-mpegURL', src: response.url }; vjsPlayer.src(vjsPlayer.claimSrcVhs); contentUrl = response.url; } else { vjsPlayer.src(vjsPlayer.claimSrcOriginal); } } // bugfix thumbnails showing up if new video doesn't have them if (typeof vjsPlayer.vttThumbnails.detach === 'function') { vjsPlayer.vttThumbnails.detach(); } // initialize hover thumbnails if (contentUrl) { const trimmedPath = contentUrl.substring(0, contentUrl.lastIndexOf('/')); const thumbnailPath = trimmedPath + '/stream_sprite.vtt'; // progress bar hover thumbnails if (!IS_MOBILE) { // if src is a function, it's already been initialized if (typeof vjsPlayer.vttThumbnails.src === 'function') { vjsPlayer.vttThumbnails.src(thumbnailPath); } else { // otherwise, initialize plugin vjsPlayer.vttThumbnails({ src: thumbnailPath, showTimestamp: true, }); } } } vjsPlayer.load(); if (canUseOldPlayer) { // $FlowIssue document.querySelector('.video-js-parent')?.append(window.oldSavedDiv); } // allow tap to unmute if no perms on iOS if (autoplay && !embedded) { const promise = vjsPlayer.play(); window.player.userActive(true); if (promise !== undefined) { promise .then((_) => { // $FlowIssue vjsPlayer?.controlBar.el().classList.add('vjs-transitioning-video'); }) .catch((error) => { const noPermissionError = typeof error === 'object' && error.name && error.name === 'NotAllowedError'; if (noPermissionError) { if (IS_IOS) { // autoplay not allowed, mute video, play and show 'tap to unmute' button // $FlowIssue vjsPlayer?.muted(true); // $FlowIssue vjsPlayer?.play(); // $FlowIssue document.querySelector('.video-js--tap-to-unmute')?.style.setProperty('visibility', 'visible'); // $FlowIssue document .querySelector('.video-js--tap-to-unmute') ?.style.setProperty('display', 'inline', 'important'); } else { // $FlowIssue vjsPlayer?.bigPlayButton?.show(); } } }); } } // fix invisible vidcrunch overlay on IOS << TODO: does not belong here. Move to ads.jsx (#739) 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); } } })(); // Cleanup return () => { window.removeEventListener('keydown', keyDownHandlerRef.current); const containerDiv = containerRef.current; // $FlowFixMe containerDiv && containerDiv.removeEventListener('wheel', videoScrollHandlerRef.current); if (volumePanelRef.current) { volumePanelRef.current.removeEventListener('wheel', volumePanelScrollHandlerRef.current); } const chapterMarkers = document.getElementsByClassName('vjs-chapter-marker'); while (chapterMarkers.length > 0) { // $FlowIssue chapterMarkers[0].parentNode?.removeChild(chapterMarkers[0]); } const player = playerRef.current; if (player) { try { window.cast.framework.CastContext.getInstance().getCurrentSession().endSession(false); } catch {} window.player.switchedFromDefaultQuality = false; window.player.userActive(false); window.player.pause(); if (IS_IOS) { // $FlowIssue window.player.controlBar?.playToggle?.hide(); } // $FlowIssue window.player?.controlBar?.getChild('ChaptersButton')?.hide(); // this solves an issue with portrait videos // $FlowIssue const videoDiv = window.player?.tech_?.el(); // video element if (videoDiv) videoDiv.style.top = '0px'; window.player.controlBar.el().classList.add('vjs-transitioning-video'); window.oldSavedDiv = window.player.el(); window.player.trigger('playerClosed'); window.player.currentTime(0); // stop streams running in background window.player.loadTech_('html5', null); window.player.claimSrcVhs = null; } }; }, [isAudio, source, reload, userClaimId, isLivestreamClaim]); return (
); });