lbry-desktop/ui/component/viewers/videoViewer/view.jsx

413 lines
12 KiB
React
Raw Normal View History

2019-08-02 08:28:14 +02:00
// @flow
2021-10-22 16:46:59 +02:00
import React, { useEffect, useState, useCallback } from 'react';
2019-08-02 08:28:14 +02:00
import { stopContextMenu } from 'util/context-menu';
2020-04-28 21:33:30 +02:00
import type { Player } from './internal/videojs';
import VideoJs from './internal/videojs';
2020-01-22 18:19:49 +01:00
import analytics from 'analytics';
2020-04-14 01:48:11 +02:00
import classnames from 'classnames';
2020-03-27 17:49:41 +01:00
import { FORCE_CONTENT_TYPE_PLAYER } from 'constants/claim';
2020-04-14 01:48:11 +02:00
import AutoplayCountdown from 'component/autoplayCountdown';
import usePrevious from 'effects/use-previous';
2020-04-16 23:43:09 +02:00
import LoadingScreen from 'component/common/loading-screen';
2021-01-08 16:21:27 +01:00
import { addTheaterModeButton } from './internal/theater-mode';
import { addAutoplayNextButton } from './internal/autoplay-next';
import { addPlayNextButton } from './internal/play-next';
import { addPlayPreviousButton } from './internal/play-previous';
2021-04-12 18:43:47 +02:00
import { useHistory } from 'react-router';
2021-07-17 00:20:22 +02:00
import type { HomepageCat } from 'util/buildHomepage';
import debounce from 'util/debounce';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
2019-08-02 08:28:14 +02:00
2020-04-28 21:33:30 +02:00
const PLAY_TIMEOUT_ERROR = 'play_timeout_error';
const PLAY_TIMEOUT_LIMIT = 2000;
2020-04-28 21:33:30 +02:00
2019-08-02 08:28:14 +02:00
type Props = {
2019-09-06 02:26:03 +02:00
position: number,
changeVolume: (number) => void,
changeMute: (boolean) => void,
2019-08-02 08:28:14 +02:00
source: string,
contentType: string,
thumbnail: string,
2020-08-07 22:59:20 +02:00
claim: StreamClaim,
2020-04-16 23:43:09 +02:00
muted: boolean,
videoPlaybackRate: number,
2020-04-16 23:43:09 +02:00
volume: number,
2020-04-14 01:48:11 +02:00
uri: string,
autoplayNext: boolean,
desktopPlayStartTime?: number,
2020-04-14 01:48:11 +02:00
doAnalyticsView: (string, number) => Promise<any>,
2020-08-07 22:59:20 +02:00
doAnalyticsBuffer: (string, any) => void,
2020-04-14 01:48:11 +02:00
claimRewards: () => void,
savePosition: (string, number) => void,
clearPosition: (string) => void,
2021-01-08 16:21:27 +01:00
toggleVideoTheaterMode: () => void,
toggleAutoplayNext: () => void,
setVideoPlaybackRate: (number) => void,
userId: number,
2021-07-17 00:20:22 +02:00
homepageData?: { [string]: HomepageCat },
shareTelemetry: boolean,
isFloating: boolean,
doPlayUri: (string, string) => void,
collectionId: string,
nextRecommendedUri: string,
previousListUri: string,
videoTheaterMode: boolean,
isMarkdownOrComment: boolean,
2019-08-02 08:28:14 +02:00
};
2020-04-16 01:21:17 +02:00
/*
codesandbox of idealized/clean videojs and react 16+
https://codesandbox.io/s/71z2lm4ko6
*/
2019-08-02 08:28:14 +02:00
function VideoViewer(props: Props) {
const {
contentType,
source,
changeVolume,
changeMute,
videoPlaybackRate,
thumbnail,
2020-03-19 21:25:37 +01:00
position,
claim,
2020-04-14 01:48:11 +02:00
uri,
2020-04-16 23:43:09 +02:00
muted,
volume,
autoplayNext,
2020-04-14 01:48:11 +02:00
doAnalyticsView,
2020-08-07 22:59:20 +02:00
doAnalyticsBuffer,
2020-04-14 01:48:11 +02:00
claimRewards,
savePosition,
clearPosition,
desktopPlayStartTime,
2021-01-08 16:21:27 +01:00
toggleVideoTheaterMode,
toggleAutoplayNext,
setVideoPlaybackRate,
userId,
shareTelemetry,
isFloating,
doPlayUri,
collectionId,
nextRecommendedUri,
previousListUri,
videoTheaterMode,
isMarkdownOrComment,
} = props;
const permanentUrl = claim && claim.permanent_url;
2020-01-22 18:19:49 +01:00
const claimId = claim && claim.claim_id;
const isAudio = contentType.includes('audio');
2020-03-27 17:49:41 +01:00
const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType);
2021-10-22 16:46:59 +02:00
const { push } = useHistory();
const [doNavigate, setDoNavigate] = useState(false);
const [playNextUrl, setPlayNextUrl] = useState(true);
2020-04-14 01:48:11 +02:00
const [isPlaying, setIsPlaying] = useState(false);
const [ended, setEnded] = useState(false);
2020-04-14 01:48:11 +02:00
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
const vjsCallbackDataRef: any = React.useRef();
2021-04-12 18:43:47 +02:00
const previousUri = usePrevious(uri);
2020-04-26 22:32:39 +02:00
/* isLoading was designed to show loading screen on first play press, rather than completely black screen, but
breaks because some browsers (e.g. Firefox) block autoplay but leave the player.play Promise pending */
2020-04-16 23:43:09 +02:00
const [isLoading, setIsLoading] = useState(false);
const [replay, setReplay] = useState(false);
const [videoNode, setVideoNode] = useState();
const updateVolumeState = React.useCallback(
debounce((volume, muted) => {
changeVolume(volume);
changeMute(muted);
}, 500),
[]
);
2020-04-16 23:43:09 +02:00
// force everything to recent when URI changes, can cause weird corner cases otherwise (e.g. navigate while autoplay is true)
useEffect(() => {
if (uri && previousUri && uri !== previousUri) {
setShowAutoplayCountdown(false);
setIsLoading(false);
}
}, [uri, previousUri]);
// Update vjsCallbackDataRef (ensures videojs callbacks are not using stale values):
useEffect(() => {
vjsCallbackDataRef.current = {
videoPlaybackRate: videoPlaybackRate,
};
2021-10-22 16:46:59 +02:00
}, [videoPlaybackRate]);
2020-04-16 01:21:17 +02:00
function doTrackingBuffered(e: Event, data: any) {
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
data.playerPoweredBy = response.headers.get('x-powered-by');
doAnalyticsBuffer(uri, data);
});
2020-04-16 01:21:17 +02:00
}
2019-08-13 07:35:13 +02:00
2020-04-16 01:21:17 +02:00
function doTrackingFirstPlay(e: Event, data: any) {
let timeToStart = data.secondsToLoad;
if (desktopPlayStartTime !== undefined) {
const differenceToAdd = Date.now() - desktopPlayStartTime;
timeToStart += differenceToAdd;
}
2021-10-22 16:46:59 +02:00
analytics.playerStartedEvent();
// convert bytes to bits, and then divide by seconds
const contentInBits = Number(claim.value.source.size) * 8;
const durationInSeconds = claim.value.video && claim.value.video.duration;
let bitrateAsBitsPerSecond;
if (durationInSeconds) {
bitrateAsBitsPerSecond = Math.round(contentInBits / durationInSeconds);
}
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
let playerPoweredBy = response.headers.get('x-powered-by') || '';
2021-10-22 16:46:59 +02:00
analytics.videoStartEvent(
claimId,
timeToStart,
playerPoweredBy,
userId,
claim.canonical_url,
this,
bitrateAsBitsPerSecond
);
});
doAnalyticsView(uri, timeToStart).then(() => {
2020-04-16 01:21:17 +02:00
claimRewards();
});
}
2020-04-14 01:48:11 +02:00
const doPlay = useCallback(
(playUri) => {
setDoNavigate(false);
if (!isFloating) {
const navigateUrl = formatLbryUrlForWeb(playUri);
push({
pathname: navigateUrl,
search: collectionId && generateListSearchUrlParams(collectionId),
state: { collectionId, forceAutoplay: true, hideFloatingPlayer: true },
});
} else {
doPlayUri(playUri, collectionId);
}
},
[collectionId, doPlayUri, isFloating, push]
);
useEffect(() => {
if (!doNavigate) return;
if (playNextUrl) {
if (permanentUrl !== nextRecommendedUri) {
if (nextRecommendedUri) {
if (collectionId) clearPosition(permanentUrl);
doPlay(nextRecommendedUri);
}
} else {
setReplay(true);
}
} else {
if (videoNode) {
const currentTime = videoNode.currentTime;
if (currentTime <= 5) {
if (previousListUri && permanentUrl !== previousListUri) doPlay(previousListUri);
} else {
videoNode.currentTime = 0;
}
setDoNavigate(false);
}
2021-04-12 18:43:47 +02:00
}
if (!ended) setDoNavigate(false);
setEnded(false);
setPlayNextUrl(true);
}, [
clearPosition,
collectionId,
doNavigate,
doPlay,
ended,
nextRecommendedUri,
permanentUrl,
playNextUrl,
previousListUri,
videoNode,
]);
2021-04-12 18:43:47 +02:00
React.useEffect(() => {
if (!ended) return;
analytics.videoIsPlaying(false);
2021-10-22 16:46:59 +02:00
if (!collectionId && autoplayNext) {
setShowAutoplayCountdown(true);
} else if (collectionId) {
setDoNavigate(true);
}
clearPosition(uri);
2021-10-22 16:46:59 +02:00
}, [autoplayNext, clearPosition, collectionId, ended, uri]);
function onPlay(player) {
setEnded(false);
2020-04-16 23:43:09 +02:00
setIsLoading(false);
2020-04-16 01:21:17 +02:00
setIsPlaying(true);
setShowAutoplayCountdown(false);
setReplay(false);
setDoNavigate(false);
analytics.videoIsPlaying(true, player);
2020-04-16 01:21:17 +02:00
}
function onPause(event, player) {
setIsPlaying(false);
handlePosition(player);
analytics.videoIsPlaying(false, player);
}
function onDispose(event, player) {
handlePosition(player);
analytics.videoIsPlaying(false, player);
}
function handlePosition(player) {
const currTime = player.currentTime();
const durationInSeconds = claim.value.video && claim.value.video.duration;
if (Number(durationInSeconds) > Number(currTime)) {
savePosition(uri, player.currentTime());
}
}
function restorePlaybackRate(player) {
if (!vjsCallbackDataRef.current.embedded) {
player.playbackRate(vjsCallbackDataRef.current.videoPlaybackRate);
}
}
2021-10-22 16:46:59 +02:00
const playerReadyDependencyList = [uri];
2022-01-07 20:02:33 +01:00
playerReadyDependencyList.push(desktopPlayStartTime);
2021-04-12 18:43:47 +02:00
const doPlayNext = () => {
setPlayNextUrl(true);
setEnded(true);
};
const doPlayPrevious = () => {
setPlayNextUrl(false);
setEnded(true);
};
const onPlayerReady = useCallback((player: Player, videoNode: any) => {
2021-10-22 16:46:59 +02:00
setVideoNode(videoNode);
player.muted(muted);
player.volume(volume);
player.playbackRate(videoPlaybackRate);
if (!isMarkdownOrComment) {
addTheaterModeButton(player, toggleVideoTheaterMode);
if (collectionId) {
addPlayNextButton(player, doPlayNext);
addPlayPreviousButton(player, doPlayPrevious);
} else {
addAutoplayNextButton(player, toggleAutoplayNext, autoplayNext);
}
2021-04-12 18:43:47 +02:00
}
2020-04-27 21:23:07 +02:00
2021-04-12 18:43:47 +02:00
// https://blog.videojs.com/autoplay-best-practices-with-video-js/#Programmatic-Autoplay-and-Success-Failure-Detection
2021-10-22 16:46:59 +02:00
const playPromise = player.play();
const timeoutPromise = new Promise((resolve, reject) =>
setTimeout(() => reject(PLAY_TIMEOUT_ERROR), PLAY_TIMEOUT_LIMIT)
);
Promise.race([playPromise, timeoutPromise]).catch((error) => {
if (typeof error === 'object' && error.name && error.name === 'NotAllowedError') {
if (player.autoplay() && !player.muted()) {
// player.muted(true);
// another version had player.play()
}
2021-10-22 16:46:59 +02:00
}
setIsLoading(false);
setIsPlaying(false);
});
2021-10-22 16:46:59 +02:00
setIsLoading(true); // if we are here outside of an embed, we're playing
2021-04-12 18:43:47 +02:00
// PR: #5535
// Move the restoration to a later `loadedmetadata` phase to counter the
// delay from the header-fetch. This is a temp change until the next
// re-factoring.
player.on('loadedmetadata', () => restorePlaybackRate(player));
// used for tracking buffering for watchman
2021-04-12 18:43:47 +02:00
player.on('tracking:buffered', doTrackingBuffered);
// first play tracking, used for initializing the watchman api
2021-04-12 18:43:47 +02:00
player.on('tracking:firstplay', doTrackingFirstPlay);
player.on('ended', () => setEnded(true));
player.on('play', onPlay);
player.on('pause', (event) => onPause(event, player));
player.on('dispose', (event) => onDispose(event, player));
2021-04-12 18:43:47 +02:00
player.on('error', () => {
const error = player.error();
if (error) {
analytics.sentryError('Video.js error', error);
2020-04-16 23:43:09 +02:00
}
2021-04-12 18:43:47 +02:00
});
player.on('volumechange', () => {
if (player) {
updateVolumeState(player.volume(), player.muted());
2021-04-12 18:43:47 +02:00
}
});
player.on('ratechange', () => {
const HAVE_NOTHING = 0; // https://docs.videojs.com/player#readyState
if (player && player.readyState() !== HAVE_NOTHING) {
// The playbackRate occasionally resets to 1, typically when loading a fresh video or when 'src' changes.
// Videojs says it's a browser quirk (https://github.com/videojs/video.js/issues/2516).
// [x] Don't update 'videoPlaybackRate' in this scenario.
// [ ] Ideally, the controlBar should be hidden to prevent users from changing the rate while loading.
setVideoPlaybackRate(player.playbackRate());
}
});
if (position) {
player.currentTime(position);
}
}, playerReadyDependencyList); // eslint-disable-line
2020-03-19 21:25:37 +01:00
2019-08-02 08:28:14 +02:00
return (
2020-04-14 01:48:11 +02:00
<div
className={classnames('file-viewer', {
'file-viewer--is-playing': isPlaying,
})}
onContextMenu={stopContextMenu}
>
{showAutoplayCountdown && (
<AutoplayCountdown
nextRecommendedUri={nextRecommendedUri}
doNavigate={() => setDoNavigate(true)}
doReplay={() => setReplay(true)}
/>
)}
2020-04-26 22:32:39 +02:00
{/* disable this loading behavior because it breaks when player.play() promise hangs */}
2020-04-28 19:25:13 +02:00
{isLoading && <LoadingScreen status={__('Loading')} />}
2021-10-22 16:46:59 +02:00
<VideoJs
source={source}
sourceType={forcePlayer ? 'video/mp4' : contentType}
isAudio={isAudio}
poster={isAudio ? thumbnail : ''}
onPlayerReady={onPlayerReady}
toggleVideoTheaterMode={toggleVideoTheaterMode}
autoplay
autoplaySetting={autoplayNext}
claimId={claimId}
userId={userId}
shareTelemetry={shareTelemetry}
replay={replay}
videoTheaterMode={videoTheaterMode}
playNext={doPlayNext}
playPrevious={doPlayPrevious}
/>
2019-08-02 08:28:14 +02:00
</div>
);
}
export default VideoViewer;