lbry-desktop/ui/component/viewers/videoViewer/view.jsx
Rafael Saes 83dbe8ec7c
Playlists v2: Refactors, touch ups + Queue Mode (#1604)
* Playlists v2

* Style pass

* Change playlist items arrange icon

* Playlist card body open by default

* Refactor collectionEdit components

* Paginate & Refactor bid field

* Collection page changes

* Add Thumbnail optional

* Replace extra info for description on collection page

* Playlist card right below video on medium screen

* Allow editing private collections

* Add edit option to menus

* Allow deleting a public playlist but keeping a private version

* Add queue to Save menu, remove edit option from Builtin pages, show queue on playlists page

* Fix scroll to recent persisting on medium screen

* Fix adding to queue from menu

* Fixes for delete

* PublishList: delay mounting Items tab to prevent lock-up (#1783)

For a large list, the playlist publish form is unusable (super-slow typing) due to the entire list being mounted despite the tab is not active.
The full solution is still to paginate it, but for now, don't mount the tab until it is selected. Add a spinner to indicate something is loading. It's not prefect, but it's throwaway code anyway. At least we can fill in the fields properly now.

* Batch-resolve private collections (#1782)

* makeSelectClaimForClaimId --> selectClaimForClaimId

Move away from the problematic `makeSelect*`, especially in large loops.

* Batch-resolve private collections
1758

This alleviates the lock-up that is caused by large number of invidual resolves. There will still be some minor stutter due to the large DOM that React needs to handle -- that is logged in 1758 and will be handled separately.

At least the stutter is short (1-2s) and the app is still usable.
Private list items are being resolve individually, super slow if the list is large (>100). Published lists doesn't have this issue.
doFetchItemsInCollections contains most of the useful logic, but it isn't called for private/built-in lists because it's not an actual claim.
Tweaked doFetchItemsInCollections to handle private (UUID-based) collections.

* Use persisted state for floating player playlist card body
- I find it annoying being open everytime

* Fix removing edits from published playlist

* Fix scroll on mobile

* Allow going editing items from toast

* Fix ClaimShareButton

* Prevent edit/publish of builtin

* Fix async inside forEach

* Fix sync on queue edit

* Fix autoplayCountdown replay

* Fix deleting an item scrolling the playlist

* CreatedAt fixes

* Remove repost for now

* Anon publish fixes

* Fix mature case on floating

Co-authored-by: infinite-persistence <64950861+infinite-persistence@users.noreply.github.com>
2022-07-13 10:59:59 -03:00

631 lines
21 KiB
JavaScript

// @flow
import { ENABLE_PREROLL_ADS } from 'config';
import * as PAGES from 'constants/pages';
import * as ICONS from 'constants/icons';
import React, { useEffect, useState, useContext, useCallback } from 'react';
import { stopContextMenu } from 'util/context-menu';
import * as Chapters from './internal/chapters';
import type { Player } from './internal/videojs';
import VideoJs from './internal/videojs';
import analytics from 'analytics';
import { EmbedContext } from 'page/embedWrapper/view';
import classnames from 'classnames';
import { FORCE_CONTENT_TYPE_PLAYER } from 'constants/claim';
import AutoplayCountdown from 'component/autoplayCountdown';
import usePrevious from 'effects/use-previous';
import FileViewerEmbeddedEnded from 'web/component/fileViewerEmbeddedEnded';
import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle';
import useAutoplayNext from './internal/effects/use-autoplay-next';
import useTheaterMode from './internal/effects/use-theater-mode';
import { addPlayNextButton } from './internal/play-next';
import { addPlayPreviousButton } from './internal/play-previous';
import { useGetAds } from 'effects/use-get-ads';
import Button from 'component/button';
import I18nMessage from 'component/i18nMessage';
import { useHistory } from 'react-router';
import { getAllIds } from 'util/buildHomepage';
import type { HomepageCat } from 'util/buildHomepage';
import debounce from 'util/debounce';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import useInterval from 'effects/use-interval';
import { lastBandwidthSelector } from './internal/plugins/videojs-http-streaming--override/playlist-selectors';
import { platform } from 'util/platform';
// const PLAY_TIMEOUT_ERROR = 'play_timeout_error';
// const PLAY_TIMEOUT_LIMIT = 2000;
const PLAY_POSITION_SAVE_INTERVAL_MS = 15000;
const IS_IOS = platform.isIOS();
type Props = {
position: number,
changeVolume: (number) => void,
changeMute: (boolean) => void,
source: string,
contentType: string,
thumbnail: string,
claim: StreamClaim,
muted: boolean,
videoPlaybackRate: number,
volume: number,
uri: string,
autoplayNext: boolean,
autoplayIfEmbedded: boolean,
doAnalyticsBuffer: (string, any) => void,
savePosition: (string, number) => void,
clearPosition: (string) => void,
toggleVideoTheaterMode: () => void,
toggleAutoplayNext: () => void,
setVideoPlaybackRate: (number) => void,
authenticated: boolean,
userId: number,
internalFeature: boolean,
homepageData?: { [string]: HomepageCat },
shareTelemetry: boolean,
isFloating: boolean,
doPlayUri: (params: { uri: string, collection: { collectionId: ?string } }) => void,
collectionId: string,
nextRecommendedUri: string,
nextPlaylistUri: string,
previousListUri: string,
videoTheaterMode: boolean,
isMarkdownOrComment: boolean,
doAnalyticsView: (string, number) => void,
claimRewards: () => void,
isLivestreamClaim: boolean,
activeLivestreamForChannel: any,
defaultQuality: ?string,
doToast: ({ message: string, linkText: string, linkTarget: string }) => void,
doSetContentHistoryItem: (uri: string) => void,
doClearContentHistoryUri: (uri: string) => void,
currentPlaylistItemIndex: ?number,
};
/*
codesandbox of idealized/clean videojs and react 16+
https://codesandbox.io/s/71z2lm4ko6
*/
function VideoViewer(props: Props) {
const {
contentType,
source,
changeVolume,
changeMute,
videoPlaybackRate,
thumbnail,
position,
claim,
uri,
muted,
volume,
autoplayNext,
autoplayIfEmbedded,
doAnalyticsBuffer,
doAnalyticsView,
claimRewards,
savePosition,
clearPosition,
toggleVideoTheaterMode,
toggleAutoplayNext,
setVideoPlaybackRate,
homepageData,
authenticated,
userId,
internalFeature,
shareTelemetry,
isFloating,
doPlayUri,
collectionId,
nextRecommendedUri,
nextPlaylistUri,
previousListUri,
videoTheaterMode,
isMarkdownOrComment,
isLivestreamClaim,
activeLivestreamForChannel,
defaultQuality,
doToast,
doSetContentHistoryItem,
currentPlaylistItemIndex,
} = props;
const playerEndedDuration = React.useRef();
// in case the current playing item is deleted, use the previous state
// for "play next"
const prevNextItem = React.useRef(nextPlaylistUri || (autoplayNext && nextRecommendedUri));
const nextPlaylistItem = React.useMemo(() => {
if (nextPlaylistUri) {
// handles current playing item is deleted case: stores the previous value for the next item
if (currentPlaylistItemIndex !== null) {
prevNextItem.current = nextPlaylistUri;
return nextPlaylistUri;
} else {
return prevNextItem.current;
}
} else {
return autoplayNext ? nextRecommendedUri : undefined;
}
}, [autoplayNext, currentPlaylistItemIndex, nextPlaylistUri, nextRecommendedUri]);
// and "play previous" behaviours
const prevPreviousItem = React.useRef(previousListUri);
const previousPlaylistItem = React.useMemo(() => {
if (currentPlaylistItemIndex !== null) {
prevPreviousItem.current = previousListUri;
return previousListUri;
} else {
return prevPreviousItem.current;
}
}, [currentPlaylistItemIndex, previousListUri]);
const permanentUrl = claim && claim.permanent_url;
const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : [];
const claimId = claim && claim.claim_id;
const channelClaimId = claim && claim.signing_channel && claim.signing_channel.claim_id;
const channelTitle =
(claim && claim.signing_channel && claim.signing_channel.value && claim.signing_channel.value.title) || '';
const isAudio = contentType.includes('audio');
const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType);
const {
push,
location: { pathname },
} = useHistory();
const [playerControlBar, setControlBar] = useState();
const [playerElem, setPlayer] = useState();
const [doNavigate, setDoNavigate] = useState(false);
const [playNextUrl, setPlayNextUrl] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [ended, setEnded] = useState(false);
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
const [isEndedEmbed, setIsEndedEmbed] = useState(false);
const vjsCallbackDataRef: any = React.useRef();
const previousUri = usePrevious(uri);
const embedded = useContext(EmbedContext);
const approvedVideo = Boolean(channelClaimId) && adApprovedChannelIds.includes(channelClaimId);
const adsEnabled = ENABLE_PREROLL_ADS && !authenticated && !embedded && approvedVideo;
const [adUrl, setAdUrl, isFetchingAd] = useGetAds(approvedVideo, adsEnabled);
/* 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 */
const [videoNode, setVideoNode] = useState();
const [localAutoplayNext, setLocalAutoplayNext] = useState(autoplayNext);
const isFirstRender = React.useRef(true);
const playerRef = React.useRef(null);
const addAutoplayNextButton = useAutoplayNext(playerRef, autoplayNext);
const addTheaterModeButton = useTheaterMode(playerRef, videoTheaterMode);
React.useEffect(() => {
if (isPlaying) {
// save the updated watch time
doSetContentHistoryItem(claim.permanent_url);
}
}, [isPlaying]);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
toggleAutoplayNext();
}, [localAutoplayNext]);
useInterval(
() => {
if (playerRef.current && isPlaying && !isLivestreamClaim) {
handlePosition(playerRef.current);
}
},
!isLivestreamClaim ? PLAY_POSITION_SAVE_INTERVAL_MS : null
);
const updateVolumeState = React.useCallback(
debounce((volume, muted) => {
changeVolume(volume);
changeMute(muted);
}, 500),
[]
);
// 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);
setIsEndedEmbed(false);
}
}, [uri, previousUri]);
// Update vjsCallbackDataRef (ensures videojs callbacks are not using stale values):
useEffect(() => {
vjsCallbackDataRef.current = {
embedded: embedded,
videoPlaybackRate: videoPlaybackRate,
};
}, [embedded, videoPlaybackRate]);
const doPlay = useCallback(
(playUri, isNext) => {
if (!playUri) return;
setDoNavigate(false);
if (!isFloating) {
const navigateUrl = formatLbryUrlForWeb(playUri);
push({
pathname: navigateUrl,
search: isNext && !nextPlaylistUri ? undefined : collectionId && generateListSearchUrlParams(collectionId),
state: { collectionId: isNext && !nextPlaylistUri ? undefined : collectionId, forceAutoplay: true },
});
} else {
doPlayUri({
uri: playUri,
collection: { collectionId: isNext && !nextPlaylistUri ? undefined : collectionId },
});
}
},
[collectionId, doPlayUri, isFloating, nextPlaylistUri, push]
);
/** handle play next/play previous buttons **/
useEffect(() => {
if (!doNavigate) return;
// playNextUrl is set (either true or false) when the Next/Previous buttons are clicked
const shouldPlayNextUrl = playNextUrl && nextPlaylistItem && permanentUrl !== nextPlaylistItem;
const shouldPlayPreviousUrl = !playNextUrl && previousPlaylistItem && permanentUrl !== previousPlaylistItem;
// play next video if someone hits Next button
if (shouldPlayNextUrl) {
doPlay(nextPlaylistItem, true);
// rewind if video is over 5 seconds and they hit the back button
} else if (videoNode && videoNode.currentTime > 5) {
videoNode.currentTime = 0;
// move to previous video when they hit back button if behind 5 seconds
} else if (shouldPlayPreviousUrl) {
doPlay(previousPlaylistItem);
} else {
if (playerElem) playerElem.currentTime(0);
}
setDoNavigate(false);
setEnded(false);
setPlayNextUrl(true);
}, [
collectionId,
doNavigate,
doPlay,
ended,
nextPlaylistItem,
permanentUrl,
playNextUrl,
playerElem,
previousPlaylistItem,
videoNode,
]);
React.useEffect(() => {
if (!playerControlBar) return;
const existingPlayPreviousButton = playerControlBar.getChild('PlayPreviousButton');
if (!previousListUri) {
if (existingPlayPreviousButton) playerControlBar.removeChild('PlayPreviousButton');
} else if (playerElem) {
if (!existingPlayPreviousButton) addPlayPreviousButton(playerElem, doPlayPrevious);
}
const existingPlayNextButton = playerControlBar.getChild('PlayNextButton');
if (!nextPlaylistItem) {
if (existingPlayNextButton) playerControlBar.removeChild('PlayNextButton');
} else if (playerElem) {
if (!existingPlayNextButton) addPlayNextButton(playerElem, doPlayNext);
}
}, [nextPlaylistItem, playerControlBar, playerElem, previousListUri]);
// functionality to run on video end
React.useEffect(() => {
if (!ended) return;
analytics.videoIsPlaying(false);
if (adUrl) {
setAdUrl(null);
return;
}
if (embedded) {
setIsEndedEmbed(true);
// show autoplay countdown div if not playlist
} else if ((!collectionId || (playNextUrl && !nextPlaylistUri && nextPlaylistItem)) && autoplayNext) {
setShowAutoplayCountdown(true);
// if a playlist, navigate to next item
} else if (collectionId && nextPlaylistItem) {
setDoNavigate(true);
}
clearPosition(uri);
if (IS_IOS && !autoplayNext) {
// show play button on ios if video is paused with no autoplay on
// $FlowFixMe
document.querySelector('.vjs-touch-overlay')?.classList.add('show-play-toggle'); // eslint-disable-line no-unused-expressions
}
}, [
adUrl,
autoplayNext,
clearPosition,
collectionId,
embedded,
ended,
nextPlaylistItem,
nextPlaylistUri,
playNextUrl,
setAdUrl,
uri,
]);
// MORE ON PLAY STUFF
function onPlay(player) {
setEnded(false);
setIsPlaying(true);
setShowAutoplayCountdown(false);
setIsEndedEmbed(false);
setDoNavigate(false);
analytics.videoIsPlaying(true, player);
}
function onPause(event, player) {
setIsPlaying(false);
handlePosition(player);
analytics.videoIsPlaying(false, player);
}
function onPlayerClosed(event, player) {
handlePosition(player);
analytics.videoIsPlaying(false, player);
}
function handlePosition(player) {
if (!isLivestreamClaim) savePosition(uri, player.currentTime());
}
function restorePlaybackRate(player) {
if (!vjsCallbackDataRef.current.embedded) {
player.playbackRate(vjsCallbackDataRef.current.videoPlaybackRate);
}
}
const playerReadyDependencyList = [uri, adUrl, embedded, autoplayIfEmbedded];
const doPlayNext = () => {
setPlayNextUrl(true);
setEnded(true);
};
const doPlayPrevious = () => {
setPlayNextUrl(false);
setEnded(true);
};
const onPlayerReady = useCallback((player: Player, videoNode: any) => {
playerEndedDuration.current = false;
// add buttons and initialize some settings for the player
if (!embedded) {
setVideoNode(videoNode);
player.muted(muted);
player.volume(volume);
player.playbackRate(videoPlaybackRate);
if (!isMarkdownOrComment) {
addTheaterModeButton(player, toggleVideoTheaterMode);
// if part of a playlist
// remove old play next/previous buttons if they exist
const controlBar = player.controlBar;
if (controlBar) {
const existingPlayNextButton = controlBar.getChild('PlayNextButton');
if (existingPlayNextButton) controlBar.removeChild('PlayNextButton');
const existingPlayPreviousButton = controlBar.getChild('PlayPreviousButton');
if (existingPlayPreviousButton) controlBar.removeChild('PlayPreviousButton');
const existingAutoplayButton = controlBar.getChild('AutoplayNextButton');
if (existingAutoplayButton) controlBar.removeChild('AutoplayNextButton');
setControlBar(controlBar);
setPlayer(player);
}
if (collectionId) {
addPlayNextButton(player, doPlayNext);
addPlayPreviousButton(player, doPlayPrevious);
}
addAutoplayNextButton(player, () => setLocalAutoplayNext((e) => !e), autoplayNext);
}
}
// 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.
const restorePlaybackRateEvent = () => restorePlaybackRate(player);
// Override the "auto" algorithm to post-process the result
const overrideAutoAlgorithm = () => {
const vhs = player.tech(true).vhs;
if (vhs) {
// https://github.com/videojs/http-streaming/issues/749#issuecomment-606972884
vhs.selectPlaylist = lastBandwidthSelector;
}
};
const onPauseEvent = (event) => onPause(event, player);
const onPlayerClosedEvent = (event) => onPlayerClosed(event, player);
const onVolumeChange = () => {
if (player) {
updateVolumeState(player.volume(), player.muted());
}
};
const onPlayerEnded = () => {
setEnded(true);
playerEndedDuration.current = true;
};
const onError = () => {
const error = player.error();
if (error) {
analytics.sentryError('Video.js error', error);
}
};
const onRateChange = () => {
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());
}
};
const moveToPosition = () => {
// update current time based on previous position
if (position && !isLivestreamClaim) {
// $FlowFixMe
player.currentTime(position >= claim.value.video.duration - 100 ? 0 : position);
}
};
// load events onto player
player.on('play', onPlay);
player.on('pause', onPauseEvent);
player.on('playerClosed', onPlayerClosedEvent);
player.on('ended', onPlayerEnded);
player.on('error', onError);
player.on('volumechange', onVolumeChange);
player.on('ratechange', onRateChange);
player.on('loadedmetadata', overrideAutoAlgorithm);
player.on('loadedmetadata', restorePlaybackRateEvent);
player.one('loadedmetadata', moveToPosition);
const cancelOldEvents = () => {
player.off('play', onPlay);
player.off('pause', onPauseEvent);
player.off('playerClosed', onPlayerClosedEvent);
player.off('ended', onPlayerEnded);
player.off('error', onError);
player.off('volumechange', onVolumeChange);
player.off('ratechange', onRateChange);
player.off('loadedmetadata', overrideAutoAlgorithm);
player.off('loadedmetadata', restorePlaybackRateEvent);
player.off('playerClosed', cancelOldEvents);
player.off('loadedmetadata', moveToPosition);
};
// turn off old events to prevent duplicate runs
player.on('playerClosed', cancelOldEvents);
// add (or remove) chapters button and time tooltips when video is ready
player.one('loadstart', () => Chapters.parseAndLoad(player, claim));
playerRef.current = player;
}, playerReadyDependencyList); // eslint-disable-line
return (
<div
className={classnames('file-viewer', {
'file-viewer--is-playing': isPlaying,
'file-viewer--ended-embed': isEndedEmbed,
})}
onContextMenu={stopContextMenu}
>
{showAutoplayCountdown && (
<AutoplayCountdown
nextRecommendedUri={nextRecommendedUri}
doNavigate={() => setDoNavigate(true)}
doReplay={() => {
if (playerElem) {
if (playerEndedDuration.current) {
playerElem.play();
} else {
playerElem.currentTime(0);
}
}
playerEndedDuration.current = false;
setEnded(false);
setShowAutoplayCountdown(false);
}}
onCanceled={() => {
setEnded(false);
setShowAutoplayCountdown(false);
}}
/>
)}
{isEndedEmbed && <FileViewerEmbeddedEnded uri={uri} />}
{embedded && !isEndedEmbed && <FileViewerEmbeddedTitle uri={uri} />}
{!isFetchingAd && adUrl && (
<>
<span className="ads__video-notify">
{__('Advertisement')}{' '}
<Button
className="ads__video-close"
icon={ICONS.REMOVE}
title={__('Close')}
onClick={() => setAdUrl(null)}
/>
</span>
<span className="ads__video-nudge">
<I18nMessage
tokens={{
sign_up: (
<Button
button="secondary"
className="ads__video-link"
label={__('Sign Up')}
navigate={`/$/${PAGES.AUTH}?redirect=${pathname}&src=video-ad`}
/>
),
}}
>
%sign_up% to turn ads off.
</I18nMessage>
</span>
</>
)}
<VideoJs
adUrl={adUrl}
source={adUrl || source}
sourceType={forcePlayer || adUrl ? 'video/mp4' : contentType}
isAudio={isAudio}
poster={isAudio || (embedded && !autoplayIfEmbedded) ? thumbnail : ''}
onPlayerReady={onPlayerReady}
startMuted={autoplayIfEmbedded}
toggleVideoTheaterMode={toggleVideoTheaterMode}
autoplay={!embedded || autoplayIfEmbedded}
claimId={claimId}
title={claim && ((claim.value && claim.value.title) || claim.name)}
channelTitle={channelTitle}
userId={userId}
allowPreRoll={!authenticated} // TODO: pull this into ads functionality so it's self contained
internalFeatureEnabled={internalFeature}
shareTelemetry={shareTelemetry}
playNext={doPlayNext}
playPrevious={doPlayPrevious}
embedded={embedded}
embeddedInternal={isMarkdownOrComment}
claimValues={claim.value}
doAnalyticsView={doAnalyticsView}
doAnalyticsBuffer={doAnalyticsBuffer}
claimRewards={claimRewards}
uri={uri}
userClaimId={claim && claim.signing_channel && claim.signing_channel.claim_id}
isLivestreamClaim={isLivestreamClaim}
activeLivestreamForChannel={activeLivestreamForChannel}
defaultQuality={defaultQuality}
doToast={doToast}
/>
</div>
);
}
export default VideoViewer;