livestream + old APIs

This commit is contained in:
Rafael 2022-03-15 13:18:08 -03:00 committed by Thomas Zarebczan
parent f7bceb3734
commit 6dea79819d
28 changed files with 638 additions and 434 deletions

View file

@ -229,7 +229,7 @@
"unist-util-visit": "^2.0.3", "unist-util-visit": "^2.0.3",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"vast-client": "^3.1.1", "vast-client": "^3.1.1",
"video.js": "^7.13.3", "video.js": "^7.18.1",
"videojs-contrib-quality-levels": "^2.0.9", "videojs-contrib-quality-levels": "^2.0.9",
"videojs-event-tracking": "^1.0.1", "videojs-event-tracking": "^1.0.1",
"villain-react": "^1.0.9", "villain-react": "^1.0.9",

View file

@ -1753,7 +1753,7 @@
"Select advanced mode from the dropdown at the top.": "Select advanced mode from the dropdown at the top.", "Select advanced mode from the dropdown at the top.": "Select advanced mode from the dropdown at the top.",
"Ensure the following settings are selected under the streaming tab:": "Ensure the following settings are selected under the streaming tab:", "Ensure the following settings are selected under the streaming tab:": "Ensure the following settings are selected under the streaming tab:",
"Bitrate: 1000 to 2500 kbps": "Bitrate: 1000 to 2500 kbps", "Bitrate: 1000 to 2500 kbps": "Bitrate: 1000 to 2500 kbps",
"Keyframes: 1": "Keyframes: 1", "Keyframes: 2": "Keyframes: 2",
"Profile: High": "Profile: High", "Profile: High": "Profile: High",
"Tune: Zerolatency": "Tune: Zerolatency", "Tune: Zerolatency": "Tune: Zerolatency",
"If using other streaming software, make sure the bitrate is below 4500 kbps or the stream will not work.": "If using other streaming software, make sure the bitrate is below 4500 kbps or the stream will not work.", "If using other streaming software, make sure the bitrate is below 4500 kbps or the stream will not work.": "If using other streaming software, make sure the bitrate is below 4500 kbps or the stream will not work.",

View file

@ -1,5 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectTitleForUri, makeSelectClaimWasPurchased, selectClaimForUri } from 'redux/selectors/claims'; import { selectTitleForUri, makeSelectClaimWasPurchased } from 'redux/selectors/claims';
import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info'; import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info';
import { import {
makeSelectNextUrlForCollectionAndUrl, makeSelectNextUrlForCollectionAndUrl,
@ -17,7 +17,6 @@ import { selectCostInfoForUri } from 'lbryinc';
import { doUriInitiatePlay, doSetPlayingUri } from 'redux/actions/content'; import { doUriInitiatePlay, doSetPlayingUri } from 'redux/actions/content';
import { doFetchRecommendedContent } from 'redux/actions/search'; import { doFetchRecommendedContent } from 'redux/actions/search';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { getChannelIdFromClaim } from 'util/claim';
import { selectMobilePlayerDimensions } from 'redux/selectors/app'; import { selectMobilePlayerDimensions } from 'redux/selectors/app';
import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream'; import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream';
import { doSetMobilePlayerDimensions } from 'redux/actions/app'; import { doSetMobilePlayerDimensions } from 'redux/actions/app';
@ -29,8 +28,6 @@ const select = (state, props) => {
const playingUri = selectPlayingUri(state); const playingUri = selectPlayingUri(state);
const { uri, collectionId } = playingUri || {}; const { uri, collectionId } = playingUri || {};
const claim = selectClaimForUri(state, uri);
return { return {
uri, uri,
playingUri, playingUri,
@ -47,7 +44,6 @@ const select = (state, props) => {
previousListUri: collectionId && makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state), previousListUri: collectionId && makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state),
collectionId, collectionId,
isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri), isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri),
channelClaimId: claim && getChannelIdFromClaim(claim),
mobilePlayerDimensions: selectMobilePlayerDimensions(state), mobilePlayerDimensions: selectMobilePlayerDimensions(state),
}; };
}; };

View file

@ -17,7 +17,6 @@ import debounce from 'util/debounce';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { isURIEqual } from 'util/lbryURI'; import { isURIEqual } from 'util/lbryURI';
import AutoplayCountdown from 'component/autoplayCountdown'; import AutoplayCountdown from 'component/autoplayCountdown';
import LivestreamIframeRender from 'component/livestreamLayout/iframe-render';
import usePlayNext from 'effects/use-play-next'; import usePlayNext from 'effects/use-play-next';
import { getScreenWidth, getScreenHeight, clampFloatingPlayerToScreen, calculateRelativePos } from './helper-functions'; import { getScreenWidth, getScreenHeight, clampFloatingPlayerToScreen, calculateRelativePos } from './helper-functions';
@ -53,9 +52,7 @@ type Props = {
doFetchRecommendedContent: (uri: string) => void, doFetchRecommendedContent: (uri: string) => void,
doUriInitiatePlay: (uri: string, collectionId: ?string, isPlayable: ?boolean, isFloating: ?boolean) => void, doUriInitiatePlay: (uri: string, collectionId: ?string, isPlayable: ?boolean, isFloating: ?boolean) => void,
doSetPlayingUri: ({ uri?: ?string }) => void, doSetPlayingUri: ({ uri?: ?string }) => void,
// mobile only
isCurrentClaimLive?: boolean, isCurrentClaimLive?: boolean,
channelClaimId?: any,
mobilePlayerDimensions?: any, mobilePlayerDimensions?: any,
doSetMobilePlayerDimensions: ({ height?: ?number, width?: ?number }) => void, doSetMobilePlayerDimensions: ({ height?: ?number, width?: ?number }) => void,
}; };
@ -79,9 +76,7 @@ export default function FileRenderFloating(props: Props) {
doFetchRecommendedContent, doFetchRecommendedContent,
doUriInitiatePlay, doUriInitiatePlay,
doSetPlayingUri, doSetPlayingUri,
// mobile only
isCurrentClaimLive, isCurrentClaimLive,
channelClaimId,
mobilePlayerDimensions, mobilePlayerDimensions,
doSetMobilePlayerDimensions, doSetMobilePlayerDimensions,
} = props; } = props;
@ -116,7 +111,7 @@ export default function FileRenderFloating(props: Props) {
const isFree = costInfo && costInfo.cost === 0; const isFree = costInfo && costInfo.cost === 0;
const canViewFile = isFree || claimWasPurchased; const canViewFile = isFree || claimWasPurchased;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode) || isCurrentClaimLive; const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode) || isCurrentClaimLive;
const isReadyToPlay = isPlayable && streamingUrl; const isReadyToPlay = isCurrentClaimLive || (isPlayable && streamingUrl);
// **************************************************************************** // ****************************************************************************
// FUNCTIONS // FUNCTIONS
@ -306,7 +301,7 @@ export default function FileRenderFloating(props: Props) {
[FLOATING_PLAYER_CLASS]: isFloating, [FLOATING_PLAYER_CLASS]: isFloating,
'content__viewer--inline': !isFloating, 'content__viewer--inline': !isFloating,
'content__viewer--secondary': isComment, 'content__viewer--secondary': isComment,
'content__viewer--theater-mode': !isFloating && videoTheaterMode && playingUri?.uri === primaryUri, 'content__viewer--theater-mode': videoTheaterMode && mainFilePlaying && !isCurrentClaimLive && !isMobile,
'content__viewer--disable-click': wasDragging, 'content__viewer--disable-click': wasDragging,
'content__viewer--mobile': isMobile, 'content__viewer--mobile': isMobile,
})} })}
@ -334,9 +329,7 @@ export default function FileRenderFloating(props: Props) {
/> />
)} )}
{isCurrentClaimLive && channelClaimId ? ( {isReadyToPlay ? (
<LivestreamIframeRender channelClaimId={channelClaimId} showLivestream mobileVersion />
) : isReadyToPlay ? (
<FileRender className="draggable" uri={uri} /> <FileRender className="draggable" uri={uri} />
) : collectionId && !canViewFile ? ( ) : collectionId && !canViewFile ? (
<div className="content__loading"> <div className="content__loading">

View file

@ -1,6 +1,10 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doUriInitiatePlay, doSetPlayingUri, doSetPrimaryUri } from 'redux/actions/content'; import { doUriInitiatePlay } from 'redux/actions/content';
import { selectThumbnailForUri, makeSelectClaimWasPurchased } from 'redux/selectors/claims'; import {
selectThumbnailForUri,
makeSelectClaimWasPurchased,
selectIsLivestreamClaimForUri,
} from 'redux/selectors/claims';
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info'; import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { selectCostInfoForUri } from 'lbryinc'; import { selectCostInfoForUri } from 'lbryinc';
@ -31,13 +35,12 @@ const select = (state, props) => {
claimWasPurchased: makeSelectClaimWasPurchased(uri)(state), claimWasPurchased: makeSelectClaimWasPurchased(uri)(state),
authenticated: selectUserVerifiedEmail(state), authenticated: selectUserVerifiedEmail(state),
isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri), isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri),
isLivestreamClaim: selectIsLivestreamClaimForUri(state, uri),
}; };
}; };
const perform = { const perform = {
doUriInitiatePlay, doUriInitiatePlay,
doSetPlayingUri,
doSetPrimaryUri,
}; };
export default withRouter(connect(select, perform)(FileRenderInitiator)); export default withRouter(connect(select, perform)(FileRenderInitiator));

View file

@ -14,6 +14,7 @@ import Nag from 'component/common/nag';
// $FlowFixMe cannot resolve ... // $FlowFixMe cannot resolve ...
import FileRenderPlaceholder from 'static/img/fileRenderPlaceholder.png'; import FileRenderPlaceholder from 'static/img/fileRenderPlaceholder.png';
import * as COLLECTIONS_CONSTS from 'constants/collections'; import * as COLLECTIONS_CONSTS from 'constants/collections';
import { LayoutRenderContext } from 'page/livestream/view';
type Props = { type Props = {
isPlaying: boolean, isPlaying: boolean,
@ -33,8 +34,8 @@ type Props = {
videoTheaterMode: boolean, videoTheaterMode: boolean,
isCurrentClaimLive?: boolean, isCurrentClaimLive?: boolean,
doUriInitiatePlay: (uri: string, collectionId: ?string, isPlayable: boolean) => void, doUriInitiatePlay: (uri: string, collectionId: ?string, isPlayable: boolean) => void,
doSetPlayingUri: ({ uri: ?string }) => void, isLivestreamClaim: boolean,
doSetPrimaryUri: (uri: ?string) => void, customAction?: any,
}; };
export default function FileRenderInitiator(props: Props) { export default function FileRenderInitiator(props: Props) {
@ -54,15 +55,16 @@ export default function FileRenderInitiator(props: Props) {
authenticated, authenticated,
videoTheaterMode, videoTheaterMode,
isCurrentClaimLive, isCurrentClaimLive,
isLivestreamClaim,
customAction,
doUriInitiatePlay, doUriInitiatePlay,
doSetPlayingUri,
doSetPrimaryUri,
} = props; } = props;
const containerRef = React.useRef<any>(); const layountRendered = React.useContext(LayoutRenderContext);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const containerRef = React.useRef<any>();
const [thumbnail, setThumbnail] = React.useState(FileRenderPlaceholder); const [thumbnail, setThumbnail] = React.useState(FileRenderPlaceholder);
const { search, href, state: locationState } = location; const { search, href, state: locationState } = location;
@ -73,27 +75,20 @@ export default function FileRenderInitiator(props: Props) {
const urlTimeParam = href && href.indexOf('t=') > -1; const urlTimeParam = href && href.indexOf('t=') > -1;
const forceAutoplayParam = locationState && locationState.forceAutoplay; const forceAutoplayParam = locationState && locationState.forceAutoplay;
const shouldAutoplay = forceAutoplayParam || urlTimeParam || autoplay; const shouldAutoplay = forceAutoplayParam || urlTimeParam || autoplay;
const isFree = costInfo && costInfo.cost === 0; const isFree = costInfo && costInfo.cost === 0;
const canViewFile = isFree || claimWasPurchased; const canViewFile = isLivestreamClaim
? (layountRendered || isMobile) && isCurrentClaimLive
: isFree || claimWasPurchased;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode) || isCurrentClaimLive; const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode) || isCurrentClaimLive;
const isText = RENDER_MODES.TEXT_MODES.includes(renderMode); const isText = RENDER_MODES.TEXT_MODES.includes(renderMode);
const isMobileClaimLive = isMobile && isCurrentClaimLive;
const foundCover = thumbnail !== FileRenderPlaceholder;
const renderUnsupported = RENDER_MODES.UNSUPPORTED_IN_THIS_APP.includes(renderMode); const renderUnsupported = RENDER_MODES.UNSUPPORTED_IN_THIS_APP.includes(renderMode);
const disabled = renderUnsupported || (!fileInfo && insufficientCredits && !claimWasPurchased); const disabled =
(isLivestreamClaim && !isCurrentClaimLive) ||
renderUnsupported ||
(!fileInfo && insufficientCredits && !claimWasPurchased);
const shouldRedirect = !authenticated && !isFree; const shouldRedirect = !authenticated && !isFree;
React.useEffect(() => {
// Set livestream as playing uri so it can be rendered by <FileRenderFloating /> on mobile
// instead of showing an empty cover image. Needs cover to fill the space with the player.
if (isMobileClaimLive && foundCover) {
doSetPlayingUri({ uri });
doSetPrimaryUri(uri);
}
}, [doSetPlayingUri, doSetPrimaryUri, foundCover, isMobileClaimLive, uri]);
function doAuthRedirect() { function doAuthRedirect() {
history.push(`/$/${PAGES.AUTH}?redirect=${encodeURIComponent(location.pathname)}`); history.push(`/$/${PAGES.AUTH}?redirect=${encodeURIComponent(location.pathname)}`);
} }
@ -148,11 +143,11 @@ export default function FileRenderInitiator(props: Props) {
return ( return (
<div <div
ref={containerRef} ref={containerRef}
onClick={disabled || isMobileClaimLive ? undefined : shouldRedirect ? doAuthRedirect : viewFile} onClick={disabled ? undefined : shouldRedirect ? doAuthRedirect : viewFile}
style={thumbnail && !obscurePreview ? { backgroundImage: `url("${thumbnail}")` } : {}} style={thumbnail && !obscurePreview ? { backgroundImage: `url("${thumbnail}")` } : {}}
className={classnames('content__cover', { className={classnames('content__cover', {
'content__cover--disabled': disabled, 'content__cover--disabled': disabled,
'content__cover--theater-mode': videoTheaterMode, 'content__cover--theater-mode': videoTheaterMode && !isMobile,
'content__cover--text': isText, 'content__cover--text': isText,
'card__media--nsfw': obscurePreview, 'card__media--nsfw': obscurePreview,
})} })}
@ -178,7 +173,7 @@ export default function FileRenderInitiator(props: Props) {
) )
)} )}
{!disabled && !isMobileClaimLive && ( {!disabled && (
<Button <Button
requiresAuth={shouldRedirect} requiresAuth={shouldRedirect}
onClick={viewFile} onClick={viewFile}
@ -190,6 +185,8 @@ export default function FileRenderInitiator(props: Props) {
})} })}
/> />
)} )}
{customAction}
</div> </div>
); );
} }

View file

@ -52,6 +52,7 @@ type Props = {
doSuperChatList: (uri: string) => void, doSuperChatList: (uri: string) => void,
claimsByUri: { [string]: any }, claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void, doFetchUserMemberships: (claimIdCsv: string) => void,
setLayountRendered: (boolean) => void,
}; };
export default function LivestreamChatLayout(props: Props) { export default function LivestreamChatLayout(props: Props) {
@ -73,6 +74,7 @@ export default function LivestreamChatLayout(props: Props) {
doSuperChatList, doSuperChatList,
doFetchUserMemberships, doFetchUserMemberships,
claimsByUri, claimsByUri,
setLayountRendered,
} = props; } = props;
const isMobile = useIsMobile() && !isPopoutWindow; const isMobile = useIsMobile() && !isPopoutWindow;
@ -102,7 +104,7 @@ export default function LivestreamChatLayout(props: Props) {
} }
// get commenter claim ids for checking premium status // get commenter claim ids for checking premium status
const commenterClaimIds = commentsByChronologicalOrder.map(function(comment) { const commenterClaimIds = commentsByChronologicalOrder.map((comment) => {
return comment.channel_id; return comment.channel_id;
}); });
@ -114,7 +116,7 @@ export default function LivestreamChatLayout(props: Props) {
claimsByUri, claimsByUri,
doFetchUserMemberships, doFetchUserMemberships,
[commentsByChronologicalOrder], [commentsByChronologicalOrder],
true, true
); );
const commentsToDisplay = const commentsToDisplay =
@ -167,6 +169,10 @@ export default function LivestreamChatLayout(props: Props) {
if (setCustomViewMode) setCustomViewMode(VIEW_MODES.SUPERCHAT); if (setCustomViewMode) setCustomViewMode(VIEW_MODES.SUPERCHAT);
} }
React.useEffect(() => {
if (setLayountRendered) setLayountRendered(true);
}, [setLayountRendered]);
React.useEffect(() => { React.useEffect(() => {
if (customViewMode && customViewMode !== viewMode) { if (customViewMode && customViewMode !== viewMode) {
setViewMode(customViewMode); setViewMode(customViewMode);

View file

@ -1,39 +0,0 @@
// @flow
import { LIVESTREAM_EMBED_URL } from 'constants/livestream';
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
import React from 'react';
import classnames from 'classnames';
type Props = {
channelClaimId: string,
release?: any,
showLivestream: boolean,
showScheduledInfo?: boolean,
mobileVersion?: boolean,
};
export default function LivestreamIframeRender(props: Props) {
const { channelClaimId, release, showLivestream, showScheduledInfo, mobileVersion } = props;
const className = mobileVersion
? 'file-render file-render--video'
: classnames('file-render file-render--video livestream', {
'file-render--scheduledLivestream': !showLivestream,
});
return (
<div className={className}>
<div className="file-viewer">
{showLivestream && (
<iframe
src={`${LIVESTREAM_EMBED_URL}/${channelClaimId}?skin=odysee&autoplay=1`}
scrolling="no"
allowFullScreen
/>
)}
{showScheduledInfo && release && <LivestreamScheduledInfo release={release} />}
</div>
</div>
);
}

View file

@ -1,9 +1,6 @@
// @flow // @flow
import 'scss/component/_swipeable-drawer.scss'; import 'scss/component/_swipeable-drawer.scss';
// $FlowFixMe
import { Global } from '@emotion/react';
import { lazyImport } from 'util/lazyImport'; import { lazyImport } from 'util/lazyImport';
import { useIsMobile } from 'effects/use-screensize'; import { useIsMobile } from 'effects/use-screensize';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button'; import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
@ -12,7 +9,7 @@ import LivestreamLink from 'component/livestreamLink';
import React from 'react'; import React from 'react';
import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view'; import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view';
import FileRenderInitiator from 'component/fileRenderInitiator'; import FileRenderInitiator from 'component/fileRenderInitiator';
import LivestreamIframeRender from './iframe-render'; import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import SwipeableDrawer from 'component/swipeableDrawer'; import SwipeableDrawer from 'component/swipeableDrawer';
import { DrawerExpandButton } from 'component/swipeableDrawer/view'; import { DrawerExpandButton } from 'component/swipeableDrawer/view';
@ -63,28 +60,21 @@ export default function LivestreamLayout(props: Props) {
if (!claim || !claim.signing_channel) return null; if (!claim || !claim.signing_channel) return null;
const { name: channelName, claim_id: channelClaimId } = claim.signing_channel; const { name: channelName } = claim.signing_channel;
// TODO: use this to show the 'user is not live functionality'
// console.log('show livestream, currentclaimlive, activestreamurl');
// console.log(showLivestream, isCurrentClaimLive, activeStreamUri);
return ( return (
<> <>
{!isMobile && <GlobalStyles />}
<div className="section card-stack"> <div className="section card-stack">
<React.Suspense fallback={null}>
{isMobile && isCurrentClaimLive ? (
<div className={PRIMARY_PLAYER_WRAPPER_CLASS}> <div className={PRIMARY_PLAYER_WRAPPER_CLASS}>
{/* Mobile needs to handle the livestream player like any video player */} <FileRenderInitiator
<FileRenderInitiator uri={claim.canonical_url} /> uri={claim.canonical_url}
</div> customAction={showScheduledInfo && <LivestreamScheduledInfo release={release} />}
) : (
<LivestreamIframeRender
channelClaimId={channelClaimId}
release={release}
showLivestream={showLivestream}
showScheduledInfo={showScheduledInfo}
/> />
)} </div>
</React.Suspense>
{hideComments && !showScheduledInfo && ( {hideComments && !showScheduledInfo && (
<div className="help--notice"> <div className="help--notice">
@ -104,7 +94,7 @@ export default function LivestreamLayout(props: Props) {
</div> </div>
)} )}
{activeStreamUri && ( {activeStreamUri !== uri && (
<LivestreamLink <LivestreamLink
title={__("Click here to access the stream that's currently active")} title={__("Click here to access the stream that's currently active")}
claimUri={activeStreamUri} claimUri={activeStreamUri}
@ -204,18 +194,3 @@ const ChatDrawerTitle = (titleProps: any) => {
</div> </div>
); );
}; };
const GlobalStyles = () => (
<Global
styles={{
body: {
'scrollbar-width': '0px',
'&::-webkit-scrollbar': {
width: '0px',
height: '0px',
},
},
}}
/>
);

View file

@ -101,7 +101,7 @@ function Page(props: Props) {
<div <div
className={classnames('main-wrapper__inner', { className={classnames('main-wrapper__inner', {
'main-wrapper__inner--filepage': isOnFilePage, 'main-wrapper__inner--filepage': isOnFilePage,
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode, 'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode && !isMobile,
'main-wrapper__inner--auth': authPage, 'main-wrapper__inner--auth': authPage,
'main--popout-chat': isPopoutWindow, 'main--popout-chat': isPopoutWindow,
})} })}
@ -135,16 +135,14 @@ function Page(props: Props) {
'main--file-page': filePage, 'main--file-page': filePage,
'main--settings-page': settingsPage, 'main--settings-page': settingsPage,
'main--markdown': isMarkdown, 'main--markdown': isMarkdown,
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream && !isMarkdown, 'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream && !isMarkdown && !isMobile,
'main--livestream': livestream && !chatDisabled, 'main--livestream': livestream && !chatDisabled,
'main--popout-chat': isPopoutWindow, 'main--popout-chat': isPopoutWindow,
})} })}
> >
{children} {children}
{!isMobile && rightSide && (!livestream || !chatDisabled) && ( {!isMobile && (!livestream || !chatDisabled) && rightSide}
<div className="main__right-side">{rightSide}</div>
)}
</main> </main>
{!noFooter && ( {!noFooter && (

View file

@ -1,5 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeSelectClaimForUri, selectThumbnailForUri } from 'redux/selectors/claims'; import { selectClaimForUri, selectThumbnailForUri } from 'redux/selectors/claims';
import { isStreamPlaceholderClaim, getChannelIdFromClaim } from 'util/claim';
import { selectActiveLivestreamForChannel } from 'redux/selectors/livestream';
import { import {
makeSelectNextUrlForCollectionAndUrl, makeSelectNextUrlForCollectionAndUrl,
makeSelectPreviousUrlForCollectionAndUrl, makeSelectPreviousUrlForCollectionAndUrl,
@ -30,6 +32,8 @@ const select = (state, props) => {
const autoplay = urlParams.get('autoplay'); const autoplay = urlParams.get('autoplay');
const uri = props.uri; const uri = props.uri;
const claim = selectClaimForUri(state, uri);
// TODO: eventually this should be received from DB and not local state (https://github.com/lbryio/lbry-desktop/issues/6796) // TODO: eventually this should be received from DB and not local state (https://github.com/lbryio/lbry-desktop/issues/6796)
const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(uri)(state); const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(uri)(state);
const userId = selectUser(state) && selectUser(state).id; const userId = selectUser(state) && selectUser(state).id;
@ -62,12 +66,14 @@ const select = (state, props) => {
muted: selectMute(state), muted: selectMute(state),
videoPlaybackRate: selectClientSetting(state, SETTINGS.VIDEO_PLAYBACK_RATE), videoPlaybackRate: selectClientSetting(state, SETTINGS.VIDEO_PLAYBACK_RATE),
thumbnail: selectThumbnailForUri(state, uri), thumbnail: selectThumbnailForUri(state, uri),
claim: makeSelectClaimForUri(uri)(state), claim,
homepageData: selectHomepageData(state), homepageData: selectHomepageData(state),
authenticated: selectUserVerifiedEmail(state), authenticated: selectUserVerifiedEmail(state),
shareTelemetry: IS_WEB || selectDaemonSettings(state).share_usage_data, shareTelemetry: IS_WEB || selectDaemonSettings(state).share_usage_data,
isFloating: makeSelectIsPlayerFloating(props.location)(state), isFloating: makeSelectIsPlayerFloating(props.location)(state),
videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE), videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE),
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, getChannelIdFromClaim(claim)),
isLivestreamClaim: isStreamPlaceholderClaim(claim),
}; };
}; };

View file

@ -34,6 +34,7 @@ const VideoJsEvents = ({
doAnalyticsView, doAnalyticsView,
claimRewards, claimRewards,
playerServerRef, playerServerRef,
isLivestreamClaim,
}: { }: {
tapToUnmuteRef: any, // DOM element tapToUnmuteRef: any, // DOM element
tapToRetryRef: any, // DOM element tapToRetryRef: any, // DOM element
@ -51,6 +52,7 @@ const VideoJsEvents = ({
doAnalyticsView: (string, number) => any, doAnalyticsView: (string, number) => any,
claimRewards: () => void, claimRewards: () => void,
playerServerRef: any, playerServerRef: any,
isLivestreamClaim: boolean,
}) => { }) => {
/** /**
* Analytics functionality that is run on first video start * Analytics functionality that is run on first video start
@ -63,6 +65,8 @@ const VideoJsEvents = ({
analytics.playerVideoStartedEvent(embedded); analytics.playerVideoStartedEvent(embedded);
// don't send this data on livestream
if (!isLivestreamClaim) {
// convert bytes to bits, and then divide by seconds // convert bytes to bits, and then divide by seconds
const contentInBits = Number(claimValues.source.size) * 8; const contentInBits = Number(claimValues.source.size) * 8;
const durationInSeconds = claimValues.video && claimValues.video.duration; const durationInSeconds = claimValues.video && claimValues.video.duration;
@ -85,6 +89,7 @@ const VideoJsEvents = ({
this, // pass the player this, // pass the player
bitrateAsBitsPerSecond bitrateAsBitsPerSecond
); );
}
// hit backend to mark a view // hit backend to mark a view
doAnalyticsView(uri, timeToStartVideo).then(() => { doAnalyticsView(uri, timeToStartVideo).then(() => {
@ -194,9 +199,10 @@ const VideoJsEvents = ({
const player = playerRef.current; const player = playerRef.current;
if (player) { if (player) {
const controlBar = player.getChild('controlBar'); const controlBar = player.getChild('controlBar');
controlBar const theaterButton = controlBar.getChild('TheaterModeButton');
.getChild('TheaterModeButton') if (theaterButton) {
.controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)')); theaterButton.controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)'));
}
} }
}, [videoTheaterMode]); }, [videoTheaterMode]);
@ -300,6 +306,22 @@ const VideoJsEvents = ({
document.querySelector('.vjs-big-play-button').style.setProperty('display', 'none', 'important'); document.querySelector('.vjs-big-play-button').style.setProperty('display', 'none', 'important');
}); });
// player.on('ended', onEnded); // player.on('ended', onEnded);
if (isLivestreamClaim) {
player.liveTracker.on('liveedgechange', async () => {
// Only respond to when we fall behind
if (player.liveTracker.atLiveEdge()) return;
// Don't respond to when user has paused the player
if (player.paused()) return;
setTimeout(() => {
// Do not jump ahead if user has paused the player
if (player.paused()) return;
player.liveTracker.seekToLiveEdge();
}, 5 * 1000);
});
}
} }
return { return {

View file

@ -97,10 +97,14 @@ const VideoJsKeyboardShorcuts = ({
playNext, playNext,
playPrevious, playPrevious,
toggleVideoTheaterMode, toggleVideoTheaterMode,
isMobile,
isLivestreamClaim,
}: { }: {
playNext: any, // function playNext: any, // function
playPrevious: any, // function playPrevious: any, // function
toggleVideoTheaterMode: any, // function toggleVideoTheaterMode: any, // function
isMobile: boolean,
isLivestreamClaim: boolean,
}) => { }) => {
function toggleTheaterMode(playerRef) { function toggleTheaterMode(playerRef) {
const player = playerRef.current; const player = playerRef.current;
@ -141,7 +145,7 @@ const VideoJsKeyboardShorcuts = ({
if (e.keyCode === KEYCODES.M) toggleMute(containerRef); if (e.keyCode === KEYCODES.M) toggleMute(containerRef);
if (e.keyCode === KEYCODES.UP) volumeUp(e, playerRef); if (e.keyCode === KEYCODES.UP) volumeUp(e, playerRef);
if (e.keyCode === KEYCODES.DOWN) volumeDown(e, playerRef); if (e.keyCode === KEYCODES.DOWN) volumeDown(e, playerRef);
if (e.keyCode === KEYCODES.T) toggleTheaterMode(playerRef); if (e.keyCode === KEYCODES.T && !isMobile && !isLivestreamClaim) toggleTheaterMode(playerRef);
if (e.keyCode === KEYCODES.L) seekVideo(SEEK_STEP, playerRef, containerRef); if (e.keyCode === KEYCODES.L) seekVideo(SEEK_STEP, playerRef, containerRef);
if (e.keyCode === KEYCODES.J) seekVideo(-SEEK_STEP, playerRef, containerRef); if (e.keyCode === KEYCODES.J) seekVideo(-SEEK_STEP, playerRef, containerRef);
if (e.keyCode === KEYCODES.RIGHT) seekVideo(SEEK_STEP_5, playerRef, containerRef); if (e.keyCode === KEYCODES.RIGHT) seekVideo(SEEK_STEP_5, playerRef, containerRef);

View file

@ -21,6 +21,9 @@ import React, { useEffect, useRef, useState } from 'react';
import recsys from './plugins/videojs-recsys/plugin'; import recsys from './plugins/videojs-recsys/plugin';
// import runAds from './ads'; // import runAds from './ads';
import videojs from 'video.js'; import videojs from 'video.js';
import { LIVESTREAM_STREAM_X_PULL, LIVESTREAM_CDN_DOMAIN, LIVESTREAM_STREAM_DOMAIN } from 'constants/livestream';
import { useIsMobile } from 'effects/use-screensize';
const canAutoplay = require('./plugins/canAutoplay'); const canAutoplay = require('./plugins/canAutoplay');
require('@silvermine/videojs-chromecast')(videojs); require('@silvermine/videojs-chromecast')(videojs);
@ -80,6 +83,9 @@ type Props = {
claimValues: any, claimValues: any,
clearPosition: (string) => void, clearPosition: (string) => void,
centerPlayButton: () => void, centerPlayButton: () => void,
isLivestreamClaim: boolean,
userClaimId: ?string,
activeLivestreamForChannel: any,
}; };
const videoPlaybackRates = [0.25, 0.5, 0.75, 1, 1.1, 1.25, 1.5, 1.75, 2]; const videoPlaybackRates = [0.25, 0.5, 0.75, 1, 1.1, 1.25, 1.5, 1.75, 2];
@ -143,8 +149,13 @@ export default React.memo<Props>(function VideoJs(props: Props) {
uri, uri,
clearPosition, clearPosition,
centerPlayButton, centerPlayButton,
userClaimId,
isLivestreamClaim,
activeLivestreamForChannel,
} = props; } = props;
const isMobile = useIsMobile();
// will later store the videojs player // will later store the videojs player
const playerRef = useRef(); const playerRef = useRef();
const containerRef = useRef(); const containerRef = useRef();
@ -154,8 +165,17 @@ export default React.memo<Props>(function VideoJs(props: Props) {
const playerServerRef = useRef(); const playerServerRef = useRef();
const { url: livestreamVideoUrl } = activeLivestreamForChannel || {};
const showQualitySelector = !isLivestreamClaim || (livestreamVideoUrl && livestreamVideoUrl.includes('/transcode/'));
// initiate keyboard shortcuts // initiate keyboard shortcuts
const { curried_function } = keyboardShorcuts({ toggleVideoTheaterMode, playNext, playPrevious }); const { curried_function } = keyboardShorcuts({
isMobile,
isLivestreamClaim,
toggleVideoTheaterMode,
playNext,
playPrevious,
});
const [reload, setReload] = useState('initial'); const [reload, setReload] = useState('initial');
@ -178,6 +198,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
uri, uri,
playerServerRef, playerServerRef,
clearPosition, clearPosition,
isLivestreamClaim,
}); });
const videoJsOptions = { const videoJsOptions = {
@ -186,23 +207,40 @@ export default React.memo<Props>(function VideoJs(props: Props) {
responsive: true, responsive: true,
controls: true, controls: true,
html5: { html5: {
vhs: { hls: {
overrideNative: !videojs.browser.IS_ANY_SAFARI, overrideNative: !videojs.browser.IS_ANY_SAFARI,
allowSeeksWithinUnsafeLiveWindow: true,
enableLowInitialPlaylist: false,
handlePartialData: true,
smoothQualityChange: true,
}, },
}, },
liveTracker: {
trackingThreshold: 0,
liveTolerance: 10,
},
inactivityTimeout: 2000,
autoplay: autoplay, autoplay: autoplay,
muted: startMuted, muted: startMuted,
poster: poster, // thumb looks bad in app, and if autoplay, flashing poster is annoying poster: poster, // thumb looks bad in app, and if autoplay, flashing poster is annoying
plugins: { eventTracking: true, overlay: OVERLAY.OVERLAY_DATA }, plugins: { eventTracking: true, overlay: OVERLAY.OVERLAY_DATA },
// fixes problem of errant CC button showing up on iOS // fixes problem of errant CC button showing up on iOS
// the true fix here is to fix the m3u8 file, see: https://github.com/lbryio/lbry-desktop/pull/6315 // the true fix here is to fix the m3u8 file, see: https://github.com/lbryio/lbry-desktop/pull/6315
controlBar: { subsCapsButton: false }, controlBar: {
subsCapsButton: false,
currentTimeDisplay: !isLivestreamClaim,
timeDivider: !isLivestreamClaim,
durationDisplay: !isLivestreamClaim,
remainingTimeDisplay: !isLivestreamClaim,
},
techOrder: ['chromecast', 'html5'], techOrder: ['chromecast', 'html5'],
chromecast: { chromecast: {
requestTitleFn: (src) => title || '', requestTitleFn: (src) => title || '',
requestSubtitleFn: (src) => channelName || '', requestSubtitleFn: (src) => channelName || '',
}, },
bigPlayButton: embedded, // only show big play button if embedded bigPlayButton: embedded, // only show big play button if embedded
liveui: true,
suppressNotSupportedError: true,
}; };
// Initialize video.js // Initialize video.js
@ -236,12 +274,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
if (bigPlayButton) bigPlayButton.style.setProperty('display', 'block', 'important'); if (bigPlayButton) bigPlayButton.style.setProperty('display', 'block', 'important');
} }
Chromecast.initialize(player);
// Add quality selector to player // Add quality selector to player
player.hlsQualitySelector({ if (showQualitySelector) player.hlsQualitySelector({ displayCurrentQuality: true });
displayCurrentQuality: true,
});
// Add recsys plugin // Add recsys plugin
if (shareTelemetry) { if (shareTelemetry) {
@ -281,6 +315,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
} }
window.player.userActive(true); window.player.userActive(true);
} }
Chromecast.initialize(player);
}); });
// fixes #3498 (https://github.com/lbryio/lbry-desktop/issues/3498) // fixes #3498 (https://github.com/lbryio/lbry-desktop/issues/3498)
@ -290,6 +326,14 @@ export default React.memo<Props>(function VideoJs(props: Props) {
return vjs; return vjs;
} }
useEffect(() => {
if (showQualitySelector) {
// Add quality selector to player
const player = playerRef.current;
if (player) player.hlsQualitySelector({ displayCurrentQuality: true });
}
}, [showQualitySelector]);
/** instantiate videoJS and dispose of it when done with code **/ /** 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. // This lifecycle hook is only called once (on mount), or when `isAudio` or `source` changes.
useEffect(() => { useEffect(() => {
@ -315,6 +359,41 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// $FlowFixMe // $FlowFixMe
document.querySelector('.vjs-control-bar').style.setProperty('opacity', '1', 'important'); document.querySelector('.vjs-control-bar').style.setProperty('opacity', '1', 'important');
if (isLivestreamClaim && userClaimId) {
// $FlowFixMe
vjsPlayer.addClass('livestreamPlayer');
// @if process.env.NODE_ENV!='production'
videojs.Vhs.xhr.beforeRequest = (options) => {
if (!options.headers) options.headers = {};
options.headers['X-Pull'] = LIVESTREAM_STREAM_X_PULL;
options.uri = options.uri.replace(LIVESTREAM_CDN_DOMAIN, LIVESTREAM_STREAM_DOMAIN);
return options;
};
// @endif
// const newPoster = livestreamData.ThumbnailURL;
// pretty sure it's not working
// vjsPlayer.poster(newPoster);
// here specifically because we don't allow rewinds at the moment
// $FlowFixMe
// vjsPlayer.on('play', function () {
// // $FlowFixMe
// vjsPlayer.liveTracker.seekToLiveEdge();
// });
// $FlowFixMe
vjsPlayer.src({
type: 'application/x-mpegURL',
src: livestreamVideoUrl,
});
} else {
// $FlowFixMe
vjsPlayer.removeClass('livestreamPlayer');
videojs.Vhs.xhr.beforeRequest = (options) => {};
// change to m3u8 if applicable // change to m3u8 if applicable
const response = await fetch(source, { method: 'HEAD', cache: 'no-store' }); const response = await fetch(source, { method: 'HEAD', cache: 'no-store' });
@ -335,6 +414,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
src: source, src: source,
}); });
} }
}
// load video once source setup // load video once source setup
// $FlowFixMe // $FlowFixMe
vjsPlayer.load(); vjsPlayer.load();
@ -381,7 +462,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
window.player = undefined; window.player = undefined;
} }
}; };
}, [isAudio, source, reload]); }, [isAudio, source, reload, userClaimId, isLivestreamClaim]);
return ( return (
<div className={classnames('video-js-parent', { 'video-js-parent--ios': IS_IOS })} ref={containerRef}> <div className={classnames('video-js-parent', { 'video-js-parent--ios': IS_IOS })} ref={containerRef}>

View file

@ -67,6 +67,8 @@ type Props = {
isMarkdownOrComment: boolean, isMarkdownOrComment: boolean,
doAnalyticsView: (string, number) => void, doAnalyticsView: (string, number) => void,
claimRewards: () => void, claimRewards: () => void,
isLivestreamClaim: boolean,
activeLivestreamForChannel: any,
}; };
/* /*
@ -109,7 +111,10 @@ function VideoViewer(props: Props) {
previousListUri, previousListUri,
videoTheaterMode, videoTheaterMode,
isMarkdownOrComment, isMarkdownOrComment,
isLivestreamClaim,
activeLivestreamForChannel,
} = props; } = props;
const permanentUrl = claim && claim.permanent_url; const permanentUrl = claim && claim.permanent_url;
const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : []; const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : [];
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
@ -149,11 +154,14 @@ function VideoViewer(props: Props) {
toggleAutoplayNext(); toggleAutoplayNext();
}, [localAutoplayNext]); }, [localAutoplayNext]);
useInterval(() => { useInterval(
if (playerRef.current && isPlaying) { () => {
if (playerRef.current && isPlaying && !isLivestreamClaim) {
handlePosition(playerRef.current); handlePosition(playerRef.current);
} }
}, PLAY_POSITION_SAVE_INTERVAL_MS); },
!isLivestreamClaim ? PLAY_POSITION_SAVE_INTERVAL_MS : null
);
const updateVolumeState = React.useCallback( const updateVolumeState = React.useCallback(
debounce((volume, muted) => { debounce((volume, muted) => {
@ -181,11 +189,13 @@ function VideoViewer(props: Props) {
// TODO: analytics functionality // TODO: analytics functionality
function doTrackingBuffered(e: Event, data: any) { function doTrackingBuffered(e: Event, data: any) {
if (!isLivestreamClaim) {
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => { fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
data.playerPoweredBy = response.headers.get('x-powered-by'); data.playerPoweredBy = response.headers.get('x-powered-by');
doAnalyticsBuffer(uri, data); doAnalyticsBuffer(uri, data);
}); });
} }
}
const doPlay = useCallback( const doPlay = useCallback(
(playUri) => { (playUri) => {
@ -294,7 +304,7 @@ function VideoViewer(props: Props) {
} }
function handlePosition(player) { function handlePosition(player) {
savePosition(uri, player.currentTime()); if (!isLivestreamClaim) savePosition(uri, player.currentTime());
} }
function restorePlaybackRate(player) { function restorePlaybackRate(player) {
@ -426,7 +436,7 @@ function VideoViewer(props: Props) {
} }
}); });
if (position) { if (position && !isLivestreamClaim) {
player.currentTime(position); player.currentTime(position);
} }
@ -510,6 +520,9 @@ function VideoViewer(props: Props) {
uri={uri} uri={uri}
clearPosition={clearPosition} clearPosition={clearPosition}
centerPlayButton={centerPlayButton} centerPlayButton={centerPlayButton}
userClaimId={claim && claim.signing_channel && claim.signing_channel.claim_id}
isLivestreamClaim={isLivestreamClaim}
activeLivestreamForChannel={activeLivestreamForChannel}
/> />
</div> </div>
); );

View file

@ -1,9 +1,18 @@
export const LIVESTREAM_CDN_DOMAIN = 'cdn.odysee.live';
export const LIVESTREAM_STREAM_DOMAIN = 'stream.odysee.com';
export const LIVESTREAM_STREAM_X_PULL = 'bitwaveCDN';
export const LIVESTREAM_EMBED_URL = 'https://player.odysee.live/odysee'; export const LIVESTREAM_EMBED_URL = 'https://player.odysee.live/odysee';
export const LIVESTREAM_LIVE_API = 'https://api.live.odysee.com/v1/odysee/live'; export const LIVESTREAM_LIVE_API = 'https://api.live.odysee.com/v1/odysee/live';
export const LIVESTREAM_REPLAY_API = 'https://api.live.odysee.com/v1/replays/odysee'; export const LIVESTREAM_REPLAY_API = 'https://api.live.odysee.com/v1/replays/odysee';
export const LIVESTREAM_RTMP_URL = 'rtmp://stream.odysee.com/live'; export const LIVESTREAM_RTMP_URL = 'rtmp://stream.odysee.com/live';
export const LIVESTREAM_KILL = 'https://api.stream.odysee.com/stream/kill'; export const LIVESTREAM_KILL = 'https://api.stream.odysee.com/stream/kill';
// new livestream endpoints (old can be removed at some future point)
// export const NEW_LIVESTREAM_RTMP_URL = 'rtmp://publish.odysee.live/live';
// export const NEW_LIVESTREAM_REPLAY_API = 'https://api.odysee.live/replays/list';
// export const NEW_LIVESTREAM_LIVE_API = 'https://api.odysee.live/livestream/is_live';
export const MAX_LIVESTREAM_COMMENTS = 50; export const MAX_LIVESTREAM_COMMENTS = 50;
export const LIVESTREAM_STARTS_SOON_BUFFER = 15; export const LIVESTREAM_STARTS_SOON_BUFFER = 15;

View file

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeSelectTagInClaimOrChannelForUri, selectClaimForUri } from 'redux/selectors/claims'; import { makeSelectTagInClaimOrChannelForUri, selectClaimForUri } from 'redux/selectors/claims';
import { doSetPlayingUri } from 'redux/actions/content'; import { doSetPrimaryUri } from 'redux/actions/content';
import { doUserSetReferrer } from 'redux/actions/user'; import { doUserSetReferrer } from 'redux/actions/user';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { DISABLE_COMMENTS_TAG } from 'constants/tags'; import { DISABLE_COMMENTS_TAG } from 'constants/tags';
@ -24,7 +24,7 @@ const select = (state, props) => {
}; };
const perform = { const perform = {
doSetPlayingUri, doSetPrimaryUri,
doUserSetReferrer, doUserSetReferrer,
doCommentSocketConnect, doCommentSocketConnect,
doCommentSocketDisconnect, doCommentSocketDisconnect,

View file

@ -7,9 +7,9 @@ import LivestreamLayout from 'component/livestreamLayout';
import moment from 'moment'; import moment from 'moment';
import Page from 'component/page'; import Page from 'component/page';
import React from 'react'; import React from 'react';
import { useIsMobile } from 'effects/use-screensize';
const LivestreamChatLayout = lazyImport(() => import('component/livestreamChatLayout' /* webpackChunkName: "chat" */)); const LivestreamChatLayout = lazyImport(() => import('component/livestreamChatLayout' /* webpackChunkName: "chat" */));
const LIVESTREAM_STATUS_CHECK_INTERVAL = 30000;
type Props = { type Props = {
activeLivestreamForChannel: any, activeLivestreamForChannel: any,
@ -19,13 +19,15 @@ type Props = {
claim: StreamClaim, claim: StreamClaim,
isAuthenticated: boolean, isAuthenticated: boolean,
uri: string, uri: string,
doSetPlayingUri: ({ uri: ?string }) => void, doSetPrimaryUri: (uri: ?string) => void,
doCommentSocketConnect: (string, string, string) => void, doCommentSocketConnect: (string, string, string) => void,
doCommentSocketDisconnect: (string, string) => void, doCommentSocketDisconnect: (string, string) => void,
doFetchChannelLiveStatus: (string) => void, doFetchChannelLiveStatus: (string) => void,
doUserSetReferrer: (string) => void, doUserSetReferrer: (string) => void,
}; };
export const LayoutRenderContext = React.createContext<any>();
export default function LivestreamPage(props: Props) { export default function LivestreamPage(props: Props) {
const { const {
activeLivestreamForChannel, activeLivestreamForChannel,
@ -35,19 +37,18 @@ export default function LivestreamPage(props: Props) {
claim, claim,
isAuthenticated, isAuthenticated,
uri, uri,
doSetPlayingUri, doSetPrimaryUri,
doCommentSocketConnect, doCommentSocketConnect,
doCommentSocketDisconnect, doCommentSocketDisconnect,
doFetchChannelLiveStatus, doFetchChannelLiveStatus,
doUserSetReferrer, doUserSetReferrer,
} = props; } = props;
const isMobile = useIsMobile();
const [activeStreamUri, setActiveStreamUri] = React.useState(false); const [activeStreamUri, setActiveStreamUri] = React.useState(false);
const [showLivestream, setShowLivestream] = React.useState(false); const [showLivestream, setShowLivestream] = React.useState(false);
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false); const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
const [hideComments, setHideComments] = React.useState(false); const [hideComments, setHideComments] = React.useState(false);
const [layountRendered, setLayountRendered] = React.useState(chatDisabled);
const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized; const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized;
const isChannelBroadcasting = Boolean(activeLivestreamForChannel); const isChannelBroadcasting = Boolean(activeLivestreamForChannel);
@ -82,10 +83,14 @@ export default function LivestreamPage(props: Props) {
}; };
}, [claim, uri, doCommentSocketConnect, doCommentSocketDisconnect]); }, [claim, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
// Find out current channels status + active live claim. // Find out current channels status + active live claim every 30 seconds
React.useEffect(() => { React.useEffect(() => {
doFetchChannelLiveStatus(livestreamChannelId); const fetch = () => doFetchChannelLiveStatus(livestreamChannelId);
const intervalId = setInterval(() => doFetchChannelLiveStatus(livestreamChannelId), 30000);
fetch();
const intervalId = setInterval(fetch, LIVESTREAM_STATUS_CHECK_INTERVAL);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [livestreamChannelId, doFetchChannelLiveStatus]); }, [livestreamChannelId, doFetchChannelLiveStatus]);
@ -144,14 +149,10 @@ export default function LivestreamPage(props: Props) {
}, [uri, stringifiedClaim, isAuthenticated, doUserSetReferrer]); }, [uri, stringifiedClaim, isAuthenticated, doUserSetReferrer]);
React.useEffect(() => { React.useEffect(() => {
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back doSetPrimaryUri(uri);
// This can be removed when we start using the app video player, not a LIVESTREAM iframe
doSetPlayingUri({ uri: null });
return () => { return () => doSetPrimaryUri(null);
if (isMobile) doSetPlayingUri({ uri: null }); }, [doSetPrimaryUri, uri]);
};
}, [doSetPlayingUri, isMobile]);
return ( return (
<Page <Page
@ -163,12 +164,13 @@ export default function LivestreamPage(props: Props) {
!hideComments && !hideComments &&
isInitialized && ( isInitialized && (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<LivestreamChatLayout uri={uri} /> <LivestreamChatLayout uri={uri} setLayountRendered={setLayountRendered} />
</React.Suspense> </React.Suspense>
) )
} }
> >
{isInitialized && ( {isInitialized && (
<LayoutRenderContext.Provider value={layountRendered}>
<LivestreamLayout <LivestreamLayout
uri={uri} uri={uri}
hideComments={hideComments} hideComments={hideComments}
@ -178,6 +180,7 @@ export default function LivestreamPage(props: Props) {
showScheduledInfo={showScheduledInfo} showScheduledInfo={showScheduledInfo}
activeStreamUri={activeStreamUri} activeStreamUri={activeStreamUri}
/> />
</LayoutRenderContext.Provider>
)} )}
</Page> </Page>
); );

View file

@ -24,7 +24,6 @@ import { normalizeURI } from 'util/lbryURI';
import * as COLLECTIONS_CONSTS from 'constants/collections'; import * as COLLECTIONS_CONSTS from 'constants/collections';
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions'; import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
import { selectBlacklistedOutpointMap } from 'lbryinc'; import { selectBlacklistedOutpointMap } from 'lbryinc';
import { doAnalyticsView } from 'redux/actions/app';
import ShowPage from './view'; import ShowPage from './view';
const select = (state, props) => { const select = (state, props) => {
@ -95,7 +94,6 @@ const perform = {
doResolveUri, doResolveUri,
doBeginPublish, doBeginPublish,
doFetchItemsInCollection, doFetchItemsInCollection,
doAnalyticsView,
}; };
export default withRouter(connect(select, perform)(ShowPage)); export default withRouter(connect(select, perform)(ShowPage));

View file

@ -1,7 +1,7 @@
// @flow // @flow
import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config'; import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { lazyImport } from 'util/lazyImport'; import { lazyImport } from 'util/lazyImport';
import { Redirect, useHistory } from 'react-router-dom'; import { Redirect, useHistory } from 'react-router-dom';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
@ -40,7 +40,6 @@ type Props = {
doResolveUri: (uri: string, returnCached: boolean, resolveReposts: boolean, options: any) => void, doResolveUri: (uri: string, returnCached: boolean, resolveReposts: boolean, options: any) => void,
doBeginPublish: (name: ?string) => void, doBeginPublish: (name: ?string) => void,
doFetchItemsInCollection: ({ collectionId: string }) => void, doFetchItemsInCollection: ({ collectionId: string }) => void,
doAnalyticsView: (uri: string) => void,
}; };
export default function ShowPage(props: Props) { export default function ShowPage(props: Props) {
@ -62,7 +61,6 @@ export default function ShowPage(props: Props) {
doResolveUri, doResolveUri,
doBeginPublish, doBeginPublish,
doFetchItemsInCollection, doFetchItemsInCollection,
doAnalyticsView,
} = props; } = props;
const { push } = useHistory(); const { push } = useHistory();
@ -136,16 +134,6 @@ export default function ShowPage(props: Props) {
} }
}, [doResolveUri, isResolvingUri, canonicalUrl, uri, claimExists, haventFetchedYet, isMine, claimIsPending, search]); }, [doResolveUri, isResolvingUri, canonicalUrl, uri, claimExists, haventFetchedYet, isMine, claimIsPending, search]);
// Regular claims will call the file/view event when a user actually watches the claim
// This can be removed when we get rid of the livestream iframe
const [viewTracked, setViewTracked] = useState(false);
useEffect(() => {
if (showLiveStream && !viewTracked) {
doAnalyticsView(uri);
setViewTracked(true);
}
}, [showLiveStream, viewTracked]);
// Don't navigate directly to repost urls // Don't navigate directly to repost urls
// Always redirect to the actual content // Always redirect to the actual content
if (claim && claim.repost_url === uri) { if (claim && claim.repost_url === uri) {

View file

@ -1,24 +1,23 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { doClaimSearch } from 'redux/actions/claims'; import { doClaimSearch } from 'redux/actions/claims';
import { LIVESTREAM_LIVE_API, LIVESTREAM_STARTS_SOON_BUFFER } from 'constants/livestream'; import {
LiveStatus,
fetchLiveChannel,
fetchLiveChannels,
determineLiveClaim,
filterUpcomingLiveStreamClaims,
} from 'util/livestream';
import moment from 'moment'; import moment from 'moment';
const LiveStatus = Object.freeze({ const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000;
LIVE: 'LIVE',
NOT_LIVE: 'NOT_LIVE',
UNKNOWN: 'UNKNOWN',
});
type LiveStatusType = $Keys<typeof LiveStatus>;
type LiveChannelStatus = { channelStatus: LiveStatusType, channelData?: LivestreamInfo };
export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dispatch, getState: GetState) => { export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ dispatch({
type: ACTIONS.FETCH_NO_SOURCE_CLAIMS_STARTED, type: ACTIONS.FETCH_NO_SOURCE_CLAIMS_STARTED,
data: channelId, data: channelId,
}); });
try { try {
await dispatch( await dispatch(
doClaimSearch({ doClaimSearch({
@ -44,51 +43,8 @@ export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dis
} }
}; };
const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000; const fetchUpcomingLivestreamClaims = (channelIds: Array<string>, lang: ?Array<string> = null) =>
doClaimSearch(
const transformLivestreamData = (data: Array<any>): LivestreamInfo => {
return data.reduce((acc, curr) => {
acc[curr.claimId] = {
live: curr.live,
viewCount: curr.viewCount,
creatorId: curr.claimId,
startedStreaming: moment(curr.timestamp),
};
return acc;
}, {});
};
const fetchLiveChannels = async (): Promise<LivestreamInfo> => {
const response = await fetch(LIVESTREAM_LIVE_API);
const json = await response.json();
if (!json.data) throw new Error();
return transformLivestreamData(json.data);
};
const fetchLiveChannel = async (channelId: string): Promise<LiveChannelStatus> => {
try {
const response = await fetch(`${LIVESTREAM_LIVE_API}/${channelId}`);
const json = await response.json();
if (json.data?.live === false) return { channelStatus: LiveStatus.NOT_LIVE };
return { channelStatus: LiveStatus.LIVE, channelData: transformLivestreamData([json.data]) };
} catch {
return { channelStatus: LiveStatus.UNKNOWN };
}
};
const filterUpcomingLiveStreamClaims = (upcomingClaims) => {
const startsSoonMoment = moment().startOf('minute').add(LIVESTREAM_STARTS_SOON_BUFFER, 'minutes');
const startingSoonClaims = {};
Object.keys(upcomingClaims).forEach((key) => {
if (moment.unix(upcomingClaims[key].stream.value.release_time).isSameOrBefore(startsSoonMoment)) {
startingSoonClaims[key] = upcomingClaims[key];
}
});
return startingSoonClaims;
};
const fetchUpcomingLivestreamClaims = (channelIds: Array<string>, lang: ?Array<string> = null) => {
return doClaimSearch(
{ {
page: 1, page: 1,
page_size: 50, page_size: 50,
@ -105,14 +61,13 @@ const fetchUpcomingLivestreamClaims = (channelIds: Array<string>, lang: ?Array<s
useAutoPagination: true, useAutoPagination: true,
} }
); );
};
const fetchMostRecentLivestreamClaims = ( const fetchMostRecentLivestreamClaims = (
channelIds: Array<string>, channelIds: Array<string>,
orderBy: Array<string> = ['release_time'], orderBy: Array<string> = ['release_time'],
lang: ?Array<string> = null lang: ?Array<string> = null
) => { ) =>
return doClaimSearch( doClaimSearch(
{ {
page: 1, page: 1,
page_size: 50, page_size: 50,
@ -129,36 +84,6 @@ const fetchMostRecentLivestreamClaims = (
useAutoPagination: true, useAutoPagination: true,
} }
); );
};
const distanceFromStreamStart = (claimA: any, claimB: any, channelStartedStreaming) => {
return [
Math.abs(moment.unix(claimA.stream.value.release_time).diff(channelStartedStreaming, 'minutes')),
Math.abs(moment.unix(claimB.stream.value.release_time).diff(channelStartedStreaming, 'minutes')),
];
};
const determineLiveClaim = (claims: any, activeLivestreams: any) => {
const activeClaims = {};
Object.values(claims).forEach((claim: any) => {
const channelID = claim.stream.signing_channel.claim_id;
if (activeClaims[channelID]) {
const [distanceA, distanceB] = distanceFromStreamStart(
claim,
activeClaims[channelID],
activeLivestreams[channelID].startedStreaming
);
if (distanceA < distanceB) {
activeClaims[channelID] = claim;
}
} else {
activeClaims[channelID] = claim;
}
});
return activeClaims;
};
const findActiveStreams = async ( const findActiveStreams = async (
channelIDs: Array<string>, channelIDs: Array<string>,
@ -185,8 +110,7 @@ const findActiveStreams = async (
return determineLiveClaim(allClaims, liveChannels); return determineLiveClaim(allClaims, liveChannels);
}; };
export const doFetchChannelLiveStatus = (channelId: string) => { export const doFetchChannelLiveStatus = (channelId: string) => async (dispatch: Dispatch) => {
return async (dispatch: Dispatch) => {
try { try {
const { channelStatus, channelData } = await fetchLiveChannel(channelId); const { channelStatus, channelData } = await fetchLiveChannel(channelId);
@ -209,11 +133,12 @@ export const doFetchChannelLiveStatus = (channelId: string) => {
} catch (err) { } catch (err) {
dispatch({ type: ACTIONS.REMOVE_CHANNEL_FROM_ACTIVE_LIVESTREAMS, data: { channelId } }); dispatch({ type: ACTIONS.REMOVE_CHANNEL_FROM_ACTIVE_LIVESTREAMS, data: { channelId } });
} }
};
}; };
export const doFetchActiveLivestreams = (orderBy: Array<string> = ['release_time'], lang: ?Array<string> = null) => { export const doFetchActiveLivestreams = (
return async (dispatch: Dispatch, getState: GetState) => { orderBy: Array<string> = ['release_time'],
lang: ?Array<string> = null
) => async (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
const now = Date.now(); const now = Date.now();
const timeDelta = now - state.livestream.activeLivestreamsLastFetchedDate; const timeDelta = now - state.livestream.activeLivestreamsLastFetchedDate;
@ -222,11 +147,13 @@ export const doFetchActiveLivestreams = (orderBy: Array<string> = ['release_time
const nextOptions = { order_by: orderBy, ...(lang ? { any_languages: lang } : {}) }; const nextOptions = { order_by: orderBy, ...(lang ? { any_languages: lang } : {}) };
const sameOptions = JSON.stringify(prevOptions) === JSON.stringify(nextOptions); const sameOptions = JSON.stringify(prevOptions) === JSON.stringify(nextOptions);
// already fetched livestreams within the interval, skip for now
if (sameOptions && timeDelta < FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS) { if (sameOptions && timeDelta < FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS) {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_SKIPPED }); dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_SKIPPED });
return; return;
} }
// start fetching livestreams
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED }); dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED });
try { try {
@ -261,5 +188,4 @@ export const doFetchActiveLivestreams = (orderBy: Array<string> = ['release_time
} catch (err) { } catch (err) {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED }); dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
} }
};
}; };

View file

@ -323,6 +323,11 @@ export const makeSelectTotalPagesInChannelSearch = (uri: string) =>
return byChannel['pageCount']; return byChannel['pageCount'];
}); });
export const selectIsLivestreamClaimForUri = (state: State, uri: string) => {
const claim = selectClaimForUri(state, uri);
return isStreamPlaceholderClaim(claim);
};
export const selectMetadataForUri = (state: State, uri: string) => { export const selectMetadataForUri = (state: State, uri: string) => {
const claim = selectClaimForUri(state, uri); const claim = selectClaimForUri(state, uri);
const metadata = claim && claim.value; const metadata = claim && claim.value;
@ -381,6 +386,9 @@ export const makeSelectEffectiveAmountForUri = (uri: string) =>
export const makeSelectContentTypeForUri = (uri: string) => export const makeSelectContentTypeForUri = (uri: string) =>
createSelector(makeSelectClaimForUri(uri), (claim) => { createSelector(makeSelectClaimForUri(uri), (claim) => {
const isLivestreamClaim = isStreamPlaceholderClaim(claim);
if (isLivestreamClaim) return 'livestream';
const source = claim && claim.value && claim.value.source; const source = claim && claim.value && claim.value.source;
return source ? source.media_type : undefined; return source ? source.media_type : undefined;
}); });

View file

@ -106,7 +106,7 @@ export const makeSelectFileRenderModeForUri = (uri: string) =>
makeSelectMediaTypeForUri(uri), makeSelectMediaTypeForUri(uri),
makeSelectFileExtensionForUri(uri), makeSelectFileExtensionForUri(uri),
(contentType, mediaType, extension) => { (contentType, mediaType, extension) => {
if (mediaType === 'video' || FORCE_CONTENT_TYPE_PLAYER.includes(contentType)) { if (mediaType === 'video' || FORCE_CONTENT_TYPE_PLAYER.includes(contentType) || mediaType === 'livestream') {
return RENDER_MODES.VIDEO; return RENDER_MODES.VIDEO;
} }
if (mediaType === 'audio') { if (mediaType === 'audio') {

View file

@ -8,7 +8,7 @@ $recent-msg-button__height: 2rem;
width: 100%; width: 100%;
background-color: rgba(var(--color-header-background-base), 1); background-color: rgba(var(--color-header-background-base), 1);
border-radius: var(--border-radius); border-radius: var(--border-radius);
margin-bottom: var(--spacing-m); margin-left: var(--spacing-m);
.credit-amount { .credit-amount {
font-weight: unset; font-weight: unset;
@ -25,11 +25,19 @@ $recent-msg-button__height: 2rem;
} }
} }
@media (min-width: $breakpoint-large) {
margin-left: var(--spacing-l);
}
@media (min-width: $breakpoint-small) {
top: calc(var(--header-height) + var(--spacing-m)) !important;
position: sticky;
}
@media (min-width: $breakpoint-medium) { @media (min-width: $breakpoint-medium) {
margin: 0; min-width: var(--livestream-comments-width);
width: var(--livestream-comments-width); width: var(--livestream-comments-width);
height: calc(100vh - var(--header-height) - var(--spacing-l)); height: calc(100vh - var(--header-height) - var(--spacing-l));
position: absolute;
right: 0; right: 0;
top: 0px; top: 0px;
bottom: 0; bottom: 0;
@ -79,6 +87,12 @@ $recent-msg-button__height: 2rem;
} }
} }
@media (max-width: $breakpoint-medium) {
width: 100%;
margin: 0;
margin-top: var(--spacing-l);
}
// Mobile // Mobile
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
padding: 0 !important; padding: 0 !important;

View file

@ -577,17 +577,9 @@ body {
.main--livestream { .main--livestream {
@extend .main--file-page; @extend .main--file-page;
padding: 0;
@media (min-width: $breakpoint-small) {
> * {
width: auto;
}
}
.card-stack { .card-stack {
margin-bottom: var(--spacing-m); margin-bottom: var(--spacing-m);
@media (min-width: ($breakpoint-large + 300px)) { @media (min-width: ($breakpoint-large + 300px)) {
max-width: calc(var(--page-max-width--filepage) / 1.25); max-width: calc(var(--page-max-width--filepage) / 1.25);
margin-left: auto; margin-left: auto;
@ -595,12 +587,6 @@ body {
width: calc(100% - var(--livestream-comments-width)); width: calc(100% - var(--livestream-comments-width));
} }
@media (min-width: $breakpoint-medium) and (max-width: ($breakpoint-large + 300px)) {
max-width: calc(100vw - var(--livestream-comments-width) - var(--spacing-m) * 3);
margin-left: var(--spacing-m);
margin-right: var(--spacing-m);
}
@media (max-width: $breakpoint-medium) { @media (max-width: $breakpoint-medium) {
max-width: none; max-width: none;
} }
@ -620,19 +606,6 @@ body {
} }
} }
.main__right-side {
position: sticky !important;
top: 76px !important;
width: var(--livestream-comments-width);
margin-left: var(--spacing-l);
@media (max-width: $breakpoint-medium) {
width: 100%;
margin-bottom: var(--spacing-m);
}
}
@media (max-width: $breakpoint-medium) { @media (max-width: $breakpoint-medium) {
padding: 0 var(--spacing-s); padding: 0 var(--spacing-s);
.card__main-actions { .card__main-actions {

View file

@ -272,33 +272,59 @@ button.vjs-big-play-button {
.vjs-volume-panel { .vjs-volume-panel {
order: 4 !important; order: 4 !important;
} }
.vjs-current-time {
.vjs-seek-to-live-control {
order: 5 !important; order: 5 !important;
} }
.vjs-time-divider {
.vjs-live-control {
order: 5 !important;
margin-left: 8px !important;
}
.vjs-current-time {
order: 6 !important; order: 6 !important;
} }
.vjs-duration { .vjs-time-divider {
order: 7 !important; order: 7 !important;
} }
.vjs-custom-control-spacer { .vjs-duration {
order: 8 !important; order: 8 !important;
} }
.vjs-button--autoplay-next { .vjs-custom-control-spacer {
order: 9 !important; order: 9 !important;
} }
.vjs-playback-rate { .vjs-button--autoplay-next {
order: 10 !important; order: 10 !important;
} }
.vjs-chromecast-button { .vjs-playback-rate {
order: 11 !important; order: 11 !important;
} }
.vjs-quality-selector { .vjs-chromecast-button {
order: 12 !important; order: 12 !important;
} }
.vjs-button--theater-mode { .vjs-quality-selector {
order: 13 !important; order: 13 !important;
} }
.vjs-fullscreen-control { .vjs-button--theater-mode {
order: 14 !important; order: 14 !important;
} }
.vjs-fullscreen-control {
order: 15 !important;
}
// livestream player
.livestreamPlayer {
.vjs-current-time,
.vjs-duration,
.vjs-time-divider,
.vjs-button--theater-mode,
//.vjs-quality-selector,
.vjs-chromecast-button, // hopefully we can use chromecast in the future at some point
.vjs-button--autoplay-next
//,.vjs-progress-control
{
// hiding progress control for now
display: none !important;
}
}

View file

@ -1,4 +1,27 @@
// @flow // @flow
import { LIVESTREAM_LIVE_API, LIVESTREAM_KILL, LIVESTREAM_STARTS_SOON_BUFFER } from 'constants/livestream';
import { toHex } from 'util/hex';
import Lbry from 'lbry';
import moment from 'moment';
export const LiveStatus = Object.freeze({
LIVE: 'LIVE',
NOT_LIVE: 'NOT_LIVE',
UNKNOWN: 'UNKNOWN',
});
type LiveStatusType = $Keys<typeof LiveStatus>;
type LiveChannelStatus = {
channelStatus: LiveStatusType,
channelData?: LivestreamInfo,
};
type StreamData = {
d: string,
s: string,
t: string,
};
/** /**
* Helper to extract livestream claim uris from the output of * Helper to extract livestream claim uris from the output of
@ -43,3 +66,133 @@ export function getTipValues(superChatsByAmount: Array<Comment>) {
return { superChatsChannelUrls, superChatsFiatAmount, superChatsLBCAmount }; return { superChatsChannelUrls, superChatsFiatAmount, superChatsLBCAmount };
} }
const transformLivestreamData = (data: Array<any>): LivestreamInfo => {
return data.reduce((acc, curr) => {
acc[curr.claimId] = {
url: curr.url,
type: curr.type,
live: curr.live,
viewCount: curr.viewCount,
creatorId: curr.claimId,
startedStreaming: moment(curr.timestamp),
};
return acc;
}, {});
};
export const fetchLiveChannels = async (): Promise<LivestreamInfo> => {
const response = await fetch(LIVESTREAM_LIVE_API);
const json = await response.json();
if (!json.data) throw new Error();
return transformLivestreamData(json.data);
};
/**
* Check whether or not the channel is used, used for long polling to display live status on claim viewing page
* @param channelId
* @returns {Promise<{channelStatus: string}|{channelData: LivestreamInfo, channelStatus: string}>}
*/
export const fetchLiveChannel = async (channelId: string): Promise<LiveChannelStatus> => {
const newApiEndpoint = LIVESTREAM_LIVE_API;
const newApiResponse = await fetch(`${newApiEndpoint}/${channelId}`);
const newApiData = (await newApiResponse.json()).data;
const isLive = newApiData.live;
// transform data to old API standard
const translatedData = {
url: newApiData.url,
type: 'application/x-mpegurl',
viewCount: newApiData.viewCount,
claimId: newApiData.claimId,
timestamp: newApiData.timestamp,
};
try {
if (isLive === false) {
return { channelStatus: LiveStatus.NOT_LIVE };
}
return { channelStatus: LiveStatus.LIVE, channelData: transformLivestreamData([translatedData]) };
} catch {
return { channelStatus: LiveStatus.UNKNOWN };
}
};
const getStreamData = async (channelId: string, channelName: string): Promise<StreamData> => {
if (!channelId || !channelName) throw new Error('Invalid channel data provided.');
const channelNameHex = toHex(channelName);
let channelSignature;
try {
channelSignature = await Lbry.channel_sign({ channel_id: channelId, hexdata: channelNameHex });
if (!channelSignature || !channelSignature.signature || !channelSignature.signing_ts) {
throw new Error('Error getting channel signature.');
}
} catch (e) {
throw e;
}
return { d: channelNameHex, s: channelSignature.signature, t: channelSignature.signing_ts };
};
export const killStream = async (channelId: string, channelName: string) => {
try {
const streamData = await getStreamData(channelId, channelName);
fetch(`${LIVESTREAM_KILL}/${channelId}`, {
method: 'POST',
mode: 'no-cors',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(streamData),
}).then((res) => {
if (res.status !== 200) throw new Error('Kill stream API failed.');
});
} catch (e) {
throw e;
}
};
const distanceFromStreamStart = (claimA: any, claimB: any, channelStartedStreaming) => {
return [
Math.abs(moment.unix(claimA.stream.value.release_time).diff(channelStartedStreaming, 'minutes')),
Math.abs(moment.unix(claimB.stream.value.release_time).diff(channelStartedStreaming, 'minutes')),
];
};
export const determineLiveClaim = (claims: any, activeLivestreams: any) => {
const activeClaims = {};
Object.values(claims).forEach((claim: any) => {
const channelID = claim.stream.signing_channel.claim_id;
if (activeClaims[channelID]) {
const [distanceA, distanceB] = distanceFromStreamStart(
claim,
activeClaims[channelID],
activeLivestreams[channelID].startedStreaming
);
if (distanceA < distanceB) {
activeClaims[channelID] = claim;
}
} else {
activeClaims[channelID] = claim;
}
});
return activeClaims;
};
export const filterUpcomingLiveStreamClaims = (upcomingClaims: any) => {
const startsSoonMoment = moment().startOf('minute').add(LIVESTREAM_STARTS_SOON_BUFFER, 'minutes');
const startingSoonClaims = {};
Object.keys(upcomingClaims).forEach((key) => {
if (moment.unix(upcomingClaims[key].stream.value.release_time).isSameOrBefore(startsSoonMoment)) {
startingSoonClaims[key] = upcomingClaims[key];
}
});
return startingSoonClaims;
};

View file

@ -2190,6 +2190,20 @@
mux.js "5.13.0" mux.js "5.13.0"
video.js "^6 || ^7" video.js "^6 || ^7"
"@videojs/http-streaming@2.13.1":
version "2.13.1"
resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-2.13.1.tgz#b7688d91eec969181430e00868b514b16b3b21b7"
integrity sha512-1x3fkGSPyL0+iaS3/lTvfnPTtfqzfgG+ELQtPPtTvDwqGol9Mx3TNyZwtSTdIufBrqYRn7XybB/3QNMsyjq13A==
dependencies:
"@babel/runtime" "^7.12.5"
"@videojs/vhs-utils" "3.0.4"
aes-decrypter "3.1.2"
global "^4.4.0"
m3u8-parser "4.7.0"
mpd-parser "0.21.0"
mux.js "6.0.1"
video.js "^6 || ^7"
"@videojs/vhs-utils@3.0.3": "@videojs/vhs-utils@3.0.3":
version "3.0.3" version "3.0.3"
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.3.tgz#708bc50742e9481712039695299b32da6582ef92" resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.3.tgz#708bc50742e9481712039695299b32da6582ef92"
@ -2199,19 +2213,19 @@
global "^4.4.0" global "^4.4.0"
url-toolkit "^2.2.1" url-toolkit "^2.2.1"
"@videojs/vhs-utils@^3.0.0", "@videojs/vhs-utils@^3.0.2": "@videojs/vhs-utils@3.0.4", "@videojs/vhs-utils@^3.0.3", "@videojs/vhs-utils@^3.0.4":
version "3.0.2" version "3.0.4"
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.2.tgz#0203418ecaaff29bc33c69b6ad707787347b7614" resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.4.tgz#e253eecd8e9318f767e752010d213587f94bb03a"
integrity sha512-r8Yas1/tNGsGRNoIaDJuiWiQgM0P2yaEnobgzw5JcBiEqxnS8EXoUm4QtKH7nJtnppZ1yqBx1agBZCvBMKXA2w== integrity sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg==
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
global "^4.4.0" global "^4.4.0"
url-toolkit "^2.2.1" url-toolkit "^2.2.1"
"@videojs/vhs-utils@^3.0.3": "@videojs/vhs-utils@^3.0.0", "@videojs/vhs-utils@^3.0.2":
version "3.0.4" version "3.0.2"
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.4.tgz#e253eecd8e9318f767e752010d213587f94bb03a" resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.2.tgz#0203418ecaaff29bc33c69b6ad707787347b7614"
integrity sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg== integrity sha512-r8Yas1/tNGsGRNoIaDJuiWiQgM0P2yaEnobgzw5JcBiEqxnS8EXoUm4QtKH7nJtnppZ1yqBx1agBZCvBMKXA2w==
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
global "^4.4.0" global "^4.4.0"
@ -11659,6 +11673,16 @@ mpd-parser@0.19.0:
"@xmldom/xmldom" "^0.7.2" "@xmldom/xmldom" "^0.7.2"
global "^4.4.0" global "^4.4.0"
mpd-parser@0.21.0:
version "0.21.0"
resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.21.0.tgz#c2036cce19522383b93c973180fdd82cd646168e"
integrity sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==
dependencies:
"@babel/runtime" "^7.12.5"
"@videojs/vhs-utils" "^3.0.2"
"@xmldom/xmldom" "^0.7.2"
global "^4.4.0"
ms@2.0.0: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -11699,6 +11723,14 @@ mux.js@5.13.0:
dependencies: dependencies:
"@babel/runtime" "^7.11.2" "@babel/runtime" "^7.11.2"
mux.js@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-6.0.1.tgz#65ce0f7a961d56c006829d024d772902d28c7755"
integrity sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==
dependencies:
"@babel/runtime" "^7.11.2"
global "^4.4.0"
nan@^2.12.1: nan@^2.12.1:
version "2.14.0" version "2.14.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
@ -17237,7 +17269,7 @@ vfile@^2.0.0:
unist-util-stringify-position "^1.0.0" unist-util-stringify-position "^1.0.0"
vfile-message "^1.0.0" vfile-message "^1.0.0"
"video.js@^6 || ^7", "video.js@^6.0.1 || ^7", video.js@^7.0.0, video.js@^7.13.3: "video.js@^6 || ^7", "video.js@^6.0.1 || ^7", video.js@^7.0.0:
version "7.15.4" version "7.15.4"
resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.15.4.tgz#0f96ef138035138cb30bf00a989b6174f0d16bac" resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.15.4.tgz#0f96ef138035138cb30bf00a989b6174f0d16bac"
integrity sha512-hghxkgptLUvfkpktB4wxcIVF3VpY/hVsMkrjHSv0jpj1bW9Jplzdt8IgpTm9YhlB1KYAp07syVQeZcBFUBwhkw== integrity sha512-hghxkgptLUvfkpktB4wxcIVF3VpY/hVsMkrjHSv0jpj1bW9Jplzdt8IgpTm9YhlB1KYAp07syVQeZcBFUBwhkw==
@ -17256,6 +17288,25 @@ vfile@^2.0.0:
videojs-font "3.2.0" videojs-font "3.2.0"
videojs-vtt.js "^0.15.3" videojs-vtt.js "^0.15.3"
video.js@^7.18.1:
version "7.18.1"
resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.18.1.tgz#d93cd4992710d4d95574a00e7d29a2518f9b30f7"
integrity sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==
dependencies:
"@babel/runtime" "^7.12.5"
"@videojs/http-streaming" "2.13.1"
"@videojs/vhs-utils" "^3.0.4"
"@videojs/xhr" "2.6.0"
aes-decrypter "3.1.2"
global "^4.4.0"
keycode "^2.2.0"
m3u8-parser "4.7.0"
mpd-parser "0.21.0"
mux.js "6.0.1"
safe-json-parse "4.0.0"
videojs-font "3.2.0"
videojs-vtt.js "^0.15.3"
videojs-contrib-ads@^6.6.5, videojs-contrib-ads@^6.7.0, videojs-contrib-ads@^6.9.0: videojs-contrib-ads@^6.6.5, videojs-contrib-ads@^6.7.0, videojs-contrib-ads@^6.9.0:
version "6.9.0" version "6.9.0"
resolved "https://registry.yarnpkg.com/videojs-contrib-ads/-/videojs-contrib-ads-6.9.0.tgz#c792d6fda77254b277545cc3222352fc653b5833" resolved "https://registry.yarnpkg.com/videojs-contrib-ads/-/videojs-contrib-ads-6.9.0.tgz#c792d6fda77254b277545cc3222352fc653b5833"