livestream + old APIs
This commit is contained in:
parent
f7bceb3734
commit
6dea79819d
28 changed files with 638 additions and 434 deletions
|
@ -229,7 +229,7 @@
|
|||
"unist-util-visit": "^2.0.3",
|
||||
"uuid": "^8.3.2",
|
||||
"vast-client": "^3.1.1",
|
||||
"video.js": "^7.13.3",
|
||||
"video.js": "^7.18.1",
|
||||
"videojs-contrib-quality-levels": "^2.0.9",
|
||||
"videojs-event-tracking": "^1.0.1",
|
||||
"villain-react": "^1.0.9",
|
||||
|
|
|
@ -1753,7 +1753,7 @@
|
|||
"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:",
|
||||
"Bitrate: 1000 to 2500 kbps": "Bitrate: 1000 to 2500 kbps",
|
||||
"Keyframes: 1": "Keyframes: 1",
|
||||
"Keyframes: 2": "Keyframes: 2",
|
||||
"Profile: High": "Profile: High",
|
||||
"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.",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
makeSelectNextUrlForCollectionAndUrl,
|
||||
|
@ -17,7 +17,6 @@ import { selectCostInfoForUri } from 'lbryinc';
|
|||
import { doUriInitiatePlay, doSetPlayingUri } from 'redux/actions/content';
|
||||
import { doFetchRecommendedContent } from 'redux/actions/search';
|
||||
import { withRouter } from 'react-router';
|
||||
import { getChannelIdFromClaim } from 'util/claim';
|
||||
import { selectMobilePlayerDimensions } from 'redux/selectors/app';
|
||||
import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream';
|
||||
import { doSetMobilePlayerDimensions } from 'redux/actions/app';
|
||||
|
@ -29,8 +28,6 @@ const select = (state, props) => {
|
|||
const playingUri = selectPlayingUri(state);
|
||||
const { uri, collectionId } = playingUri || {};
|
||||
|
||||
const claim = selectClaimForUri(state, uri);
|
||||
|
||||
return {
|
||||
uri,
|
||||
playingUri,
|
||||
|
@ -47,7 +44,6 @@ const select = (state, props) => {
|
|||
previousListUri: collectionId && makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state),
|
||||
collectionId,
|
||||
isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri),
|
||||
channelClaimId: claim && getChannelIdFromClaim(claim),
|
||||
mobilePlayerDimensions: selectMobilePlayerDimensions(state),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -17,7 +17,6 @@ import debounce from 'util/debounce';
|
|||
import { useHistory } from 'react-router';
|
||||
import { isURIEqual } from 'util/lbryURI';
|
||||
import AutoplayCountdown from 'component/autoplayCountdown';
|
||||
import LivestreamIframeRender from 'component/livestreamLayout/iframe-render';
|
||||
import usePlayNext from 'effects/use-play-next';
|
||||
import { getScreenWidth, getScreenHeight, clampFloatingPlayerToScreen, calculateRelativePos } from './helper-functions';
|
||||
|
||||
|
@ -53,9 +52,7 @@ type Props = {
|
|||
doFetchRecommendedContent: (uri: string) => void,
|
||||
doUriInitiatePlay: (uri: string, collectionId: ?string, isPlayable: ?boolean, isFloating: ?boolean) => void,
|
||||
doSetPlayingUri: ({ uri?: ?string }) => void,
|
||||
// mobile only
|
||||
isCurrentClaimLive?: boolean,
|
||||
channelClaimId?: any,
|
||||
mobilePlayerDimensions?: any,
|
||||
doSetMobilePlayerDimensions: ({ height?: ?number, width?: ?number }) => void,
|
||||
};
|
||||
|
@ -79,9 +76,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
doFetchRecommendedContent,
|
||||
doUriInitiatePlay,
|
||||
doSetPlayingUri,
|
||||
// mobile only
|
||||
isCurrentClaimLive,
|
||||
channelClaimId,
|
||||
mobilePlayerDimensions,
|
||||
doSetMobilePlayerDimensions,
|
||||
} = props;
|
||||
|
@ -116,7 +111,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
const isFree = costInfo && costInfo.cost === 0;
|
||||
const canViewFile = isFree || claimWasPurchased;
|
||||
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode) || isCurrentClaimLive;
|
||||
const isReadyToPlay = isPlayable && streamingUrl;
|
||||
const isReadyToPlay = isCurrentClaimLive || (isPlayable && streamingUrl);
|
||||
|
||||
// ****************************************************************************
|
||||
// FUNCTIONS
|
||||
|
@ -306,7 +301,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
[FLOATING_PLAYER_CLASS]: isFloating,
|
||||
'content__viewer--inline': !isFloating,
|
||||
'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--mobile': isMobile,
|
||||
})}
|
||||
|
@ -334,9 +329,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{isCurrentClaimLive && channelClaimId ? (
|
||||
<LivestreamIframeRender channelClaimId={channelClaimId} showLivestream mobileVersion />
|
||||
) : isReadyToPlay ? (
|
||||
{isReadyToPlay ? (
|
||||
<FileRender className="draggable" uri={uri} />
|
||||
) : collectionId && !canViewFile ? (
|
||||
<div className="content__loading">
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doUriInitiatePlay, doSetPlayingUri, doSetPrimaryUri } from 'redux/actions/content';
|
||||
import { selectThumbnailForUri, makeSelectClaimWasPurchased } from 'redux/selectors/claims';
|
||||
import { doUriInitiatePlay } from 'redux/actions/content';
|
||||
import {
|
||||
selectThumbnailForUri,
|
||||
makeSelectClaimWasPurchased,
|
||||
selectIsLivestreamClaimForUri,
|
||||
} from 'redux/selectors/claims';
|
||||
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
|
||||
import * as SETTINGS from 'constants/settings';
|
||||
import { selectCostInfoForUri } from 'lbryinc';
|
||||
|
@ -31,13 +35,12 @@ const select = (state, props) => {
|
|||
claimWasPurchased: makeSelectClaimWasPurchased(uri)(state),
|
||||
authenticated: selectUserVerifiedEmail(state),
|
||||
isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri),
|
||||
isLivestreamClaim: selectIsLivestreamClaimForUri(state, uri),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = {
|
||||
doUriInitiatePlay,
|
||||
doSetPlayingUri,
|
||||
doSetPrimaryUri,
|
||||
};
|
||||
|
||||
export default withRouter(connect(select, perform)(FileRenderInitiator));
|
||||
|
|
|
@ -14,6 +14,7 @@ import Nag from 'component/common/nag';
|
|||
// $FlowFixMe cannot resolve ...
|
||||
import FileRenderPlaceholder from 'static/img/fileRenderPlaceholder.png';
|
||||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import { LayoutRenderContext } from 'page/livestream/view';
|
||||
|
||||
type Props = {
|
||||
isPlaying: boolean,
|
||||
|
@ -33,8 +34,8 @@ type Props = {
|
|||
videoTheaterMode: boolean,
|
||||
isCurrentClaimLive?: boolean,
|
||||
doUriInitiatePlay: (uri: string, collectionId: ?string, isPlayable: boolean) => void,
|
||||
doSetPlayingUri: ({ uri: ?string }) => void,
|
||||
doSetPrimaryUri: (uri: ?string) => void,
|
||||
isLivestreamClaim: boolean,
|
||||
customAction?: any,
|
||||
};
|
||||
|
||||
export default function FileRenderInitiator(props: Props) {
|
||||
|
@ -54,15 +55,16 @@ export default function FileRenderInitiator(props: Props) {
|
|||
authenticated,
|
||||
videoTheaterMode,
|
||||
isCurrentClaimLive,
|
||||
isLivestreamClaim,
|
||||
customAction,
|
||||
doUriInitiatePlay,
|
||||
doSetPlayingUri,
|
||||
doSetPrimaryUri,
|
||||
} = props;
|
||||
|
||||
const containerRef = React.useRef<any>();
|
||||
const layountRendered = React.useContext(LayoutRenderContext);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const containerRef = React.useRef<any>();
|
||||
const [thumbnail, setThumbnail] = React.useState(FileRenderPlaceholder);
|
||||
|
||||
const { search, href, state: locationState } = location;
|
||||
|
@ -73,27 +75,20 @@ export default function FileRenderInitiator(props: Props) {
|
|||
const urlTimeParam = href && href.indexOf('t=') > -1;
|
||||
const forceAutoplayParam = locationState && locationState.forceAutoplay;
|
||||
const shouldAutoplay = forceAutoplayParam || urlTimeParam || autoplay;
|
||||
|
||||
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 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 disabled = renderUnsupported || (!fileInfo && insufficientCredits && !claimWasPurchased);
|
||||
const disabled =
|
||||
(isLivestreamClaim && !isCurrentClaimLive) ||
|
||||
renderUnsupported ||
|
||||
(!fileInfo && insufficientCredits && !claimWasPurchased);
|
||||
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() {
|
||||
history.push(`/$/${PAGES.AUTH}?redirect=${encodeURIComponent(location.pathname)}`);
|
||||
}
|
||||
|
@ -148,11 +143,11 @@ export default function FileRenderInitiator(props: Props) {
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={disabled || isMobileClaimLive ? undefined : shouldRedirect ? doAuthRedirect : viewFile}
|
||||
onClick={disabled ? undefined : shouldRedirect ? doAuthRedirect : viewFile}
|
||||
style={thumbnail && !obscurePreview ? { backgroundImage: `url("${thumbnail}")` } : {}}
|
||||
className={classnames('content__cover', {
|
||||
'content__cover--disabled': disabled,
|
||||
'content__cover--theater-mode': videoTheaterMode,
|
||||
'content__cover--theater-mode': videoTheaterMode && !isMobile,
|
||||
'content__cover--text': isText,
|
||||
'card__media--nsfw': obscurePreview,
|
||||
})}
|
||||
|
@ -178,7 +173,7 @@ export default function FileRenderInitiator(props: Props) {
|
|||
)
|
||||
)}
|
||||
|
||||
{!disabled && !isMobileClaimLive && (
|
||||
{!disabled && (
|
||||
<Button
|
||||
requiresAuth={shouldRedirect}
|
||||
onClick={viewFile}
|
||||
|
@ -190,6 +185,8 @@ export default function FileRenderInitiator(props: Props) {
|
|||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customAction}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ type Props = {
|
|||
doSuperChatList: (uri: string) => void,
|
||||
claimsByUri: { [string]: any },
|
||||
doFetchUserMemberships: (claimIdCsv: string) => void,
|
||||
setLayountRendered: (boolean) => void,
|
||||
};
|
||||
|
||||
export default function LivestreamChatLayout(props: Props) {
|
||||
|
@ -73,6 +74,7 @@ export default function LivestreamChatLayout(props: Props) {
|
|||
doSuperChatList,
|
||||
doFetchUserMemberships,
|
||||
claimsByUri,
|
||||
setLayountRendered,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile() && !isPopoutWindow;
|
||||
|
@ -102,7 +104,7 @@ export default function LivestreamChatLayout(props: Props) {
|
|||
}
|
||||
|
||||
// get commenter claim ids for checking premium status
|
||||
const commenterClaimIds = commentsByChronologicalOrder.map(function(comment) {
|
||||
const commenterClaimIds = commentsByChronologicalOrder.map((comment) => {
|
||||
return comment.channel_id;
|
||||
});
|
||||
|
||||
|
@ -114,7 +116,7 @@ export default function LivestreamChatLayout(props: Props) {
|
|||
claimsByUri,
|
||||
doFetchUserMemberships,
|
||||
[commentsByChronologicalOrder],
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const commentsToDisplay =
|
||||
|
@ -167,6 +169,10 @@ export default function LivestreamChatLayout(props: Props) {
|
|||
if (setCustomViewMode) setCustomViewMode(VIEW_MODES.SUPERCHAT);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (setLayountRendered) setLayountRendered(true);
|
||||
}, [setLayountRendered]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (customViewMode && customViewMode !== viewMode) {
|
||||
setViewMode(customViewMode);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,9 +1,6 @@
|
|||
// @flow
|
||||
import 'scss/component/_swipeable-drawer.scss';
|
||||
|
||||
// $FlowFixMe
|
||||
import { Global } from '@emotion/react';
|
||||
|
||||
import { lazyImport } from 'util/lazyImport';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
|
||||
|
@ -12,7 +9,7 @@ import LivestreamLink from 'component/livestreamLink';
|
|||
import React from 'react';
|
||||
import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view';
|
||||
import FileRenderInitiator from 'component/fileRenderInitiator';
|
||||
import LivestreamIframeRender from './iframe-render';
|
||||
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import SwipeableDrawer from 'component/swipeableDrawer';
|
||||
import { DrawerExpandButton } from 'component/swipeableDrawer/view';
|
||||
|
@ -63,28 +60,21 @@ export default function LivestreamLayout(props: Props) {
|
|||
|
||||
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 (
|
||||
<>
|
||||
{!isMobile && <GlobalStyles />}
|
||||
|
||||
<div className="section card-stack">
|
||||
<React.Suspense fallback={null}>
|
||||
{isMobile && isCurrentClaimLive ? (
|
||||
<div className={PRIMARY_PLAYER_WRAPPER_CLASS}>
|
||||
{/* Mobile needs to handle the livestream player like any video player */}
|
||||
<FileRenderInitiator uri={claim.canonical_url} />
|
||||
</div>
|
||||
) : (
|
||||
<LivestreamIframeRender
|
||||
channelClaimId={channelClaimId}
|
||||
release={release}
|
||||
showLivestream={showLivestream}
|
||||
showScheduledInfo={showScheduledInfo}
|
||||
/>
|
||||
)}
|
||||
</React.Suspense>
|
||||
<div className={PRIMARY_PLAYER_WRAPPER_CLASS}>
|
||||
<FileRenderInitiator
|
||||
uri={claim.canonical_url}
|
||||
customAction={showScheduledInfo && <LivestreamScheduledInfo release={release} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hideComments && !showScheduledInfo && (
|
||||
<div className="help--notice">
|
||||
|
@ -104,7 +94,7 @@ export default function LivestreamLayout(props: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeStreamUri && (
|
||||
{activeStreamUri !== uri && (
|
||||
<LivestreamLink
|
||||
title={__("Click here to access the stream that's currently active")}
|
||||
claimUri={activeStreamUri}
|
||||
|
@ -204,18 +194,3 @@ const ChatDrawerTitle = (titleProps: any) => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GlobalStyles = () => (
|
||||
<Global
|
||||
styles={{
|
||||
body: {
|
||||
'scrollbar-width': '0px',
|
||||
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -101,7 +101,7 @@ function Page(props: Props) {
|
|||
<div
|
||||
className={classnames('main-wrapper__inner', {
|
||||
'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--popout-chat': isPopoutWindow,
|
||||
})}
|
||||
|
@ -135,16 +135,14 @@ function Page(props: Props) {
|
|||
'main--file-page': filePage,
|
||||
'main--settings-page': settingsPage,
|
||||
'main--markdown': isMarkdown,
|
||||
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream && !isMarkdown,
|
||||
'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream && !isMarkdown && !isMobile,
|
||||
'main--livestream': livestream && !chatDisabled,
|
||||
'main--popout-chat': isPopoutWindow,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
{!isMobile && rightSide && (!livestream || !chatDisabled) && (
|
||||
<div className="main__right-side">{rightSide}</div>
|
||||
)}
|
||||
{!isMobile && (!livestream || !chatDisabled) && rightSide}
|
||||
</main>
|
||||
|
||||
{!noFooter && (
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
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 {
|
||||
makeSelectNextUrlForCollectionAndUrl,
|
||||
makeSelectPreviousUrlForCollectionAndUrl,
|
||||
|
@ -30,6 +32,8 @@ const select = (state, props) => {
|
|||
const autoplay = urlParams.get('autoplay');
|
||||
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)
|
||||
const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(uri)(state);
|
||||
const userId = selectUser(state) && selectUser(state).id;
|
||||
|
@ -62,12 +66,14 @@ const select = (state, props) => {
|
|||
muted: selectMute(state),
|
||||
videoPlaybackRate: selectClientSetting(state, SETTINGS.VIDEO_PLAYBACK_RATE),
|
||||
thumbnail: selectThumbnailForUri(state, uri),
|
||||
claim: makeSelectClaimForUri(uri)(state),
|
||||
claim,
|
||||
homepageData: selectHomepageData(state),
|
||||
authenticated: selectUserVerifiedEmail(state),
|
||||
shareTelemetry: IS_WEB || selectDaemonSettings(state).share_usage_data,
|
||||
isFloating: makeSelectIsPlayerFloating(props.location)(state),
|
||||
videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE),
|
||||
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, getChannelIdFromClaim(claim)),
|
||||
isLivestreamClaim: isStreamPlaceholderClaim(claim),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ const VideoJsEvents = ({
|
|||
doAnalyticsView,
|
||||
claimRewards,
|
||||
playerServerRef,
|
||||
isLivestreamClaim,
|
||||
}: {
|
||||
tapToUnmuteRef: any, // DOM element
|
||||
tapToRetryRef: any, // DOM element
|
||||
|
@ -51,6 +52,7 @@ const VideoJsEvents = ({
|
|||
doAnalyticsView: (string, number) => any,
|
||||
claimRewards: () => void,
|
||||
playerServerRef: any,
|
||||
isLivestreamClaim: boolean,
|
||||
}) => {
|
||||
/**
|
||||
* Analytics functionality that is run on first video start
|
||||
|
@ -63,29 +65,32 @@ const VideoJsEvents = ({
|
|||
|
||||
analytics.playerVideoStartedEvent(embedded);
|
||||
|
||||
// convert bytes to bits, and then divide by seconds
|
||||
const contentInBits = Number(claimValues.source.size) * 8;
|
||||
const durationInSeconds = claimValues.video && claimValues.video.duration;
|
||||
let bitrateAsBitsPerSecond;
|
||||
if (durationInSeconds) {
|
||||
bitrateAsBitsPerSecond = Math.round(contentInBits / durationInSeconds);
|
||||
// don't send this data on livestream
|
||||
if (!isLivestreamClaim) {
|
||||
// convert bytes to bits, and then divide by seconds
|
||||
const contentInBits = Number(claimValues.source.size) * 8;
|
||||
const durationInSeconds = claimValues.video && claimValues.video.duration;
|
||||
let bitrateAsBitsPerSecond;
|
||||
if (durationInSeconds) {
|
||||
bitrateAsBitsPerSecond = Math.round(contentInBits / durationInSeconds);
|
||||
}
|
||||
|
||||
// figure out what server the video is served from and then run start analytic event
|
||||
// server string such as 'eu-p6'
|
||||
const playerPoweredBy = playerServerRef.current;
|
||||
|
||||
// populates data for watchman, sends prom and matomo event
|
||||
analytics.videoStartEvent(
|
||||
claimId,
|
||||
timeToStartVideo,
|
||||
playerPoweredBy,
|
||||
userId,
|
||||
uri,
|
||||
this, // pass the player
|
||||
bitrateAsBitsPerSecond
|
||||
);
|
||||
}
|
||||
|
||||
// figure out what server the video is served from and then run start analytic event
|
||||
// server string such as 'eu-p6'
|
||||
const playerPoweredBy = playerServerRef.current;
|
||||
|
||||
// populates data for watchman, sends prom and matomo event
|
||||
analytics.videoStartEvent(
|
||||
claimId,
|
||||
timeToStartVideo,
|
||||
playerPoweredBy,
|
||||
userId,
|
||||
uri,
|
||||
this, // pass the player
|
||||
bitrateAsBitsPerSecond
|
||||
);
|
||||
|
||||
// hit backend to mark a view
|
||||
doAnalyticsView(uri, timeToStartVideo).then(() => {
|
||||
claimRewards();
|
||||
|
@ -194,9 +199,10 @@ const VideoJsEvents = ({
|
|||
const player = playerRef.current;
|
||||
if (player) {
|
||||
const controlBar = player.getChild('controlBar');
|
||||
controlBar
|
||||
.getChild('TheaterModeButton')
|
||||
.controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)'));
|
||||
const theaterButton = controlBar.getChild('TheaterModeButton');
|
||||
if (theaterButton) {
|
||||
theaterButton.controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)'));
|
||||
}
|
||||
}
|
||||
}, [videoTheaterMode]);
|
||||
|
||||
|
@ -300,6 +306,22 @@ const VideoJsEvents = ({
|
|||
document.querySelector('.vjs-big-play-button').style.setProperty('display', 'none', 'important');
|
||||
});
|
||||
// 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 {
|
||||
|
|
|
@ -97,10 +97,14 @@ const VideoJsKeyboardShorcuts = ({
|
|||
playNext,
|
||||
playPrevious,
|
||||
toggleVideoTheaterMode,
|
||||
isMobile,
|
||||
isLivestreamClaim,
|
||||
}: {
|
||||
playNext: any, // function
|
||||
playPrevious: any, // function
|
||||
toggleVideoTheaterMode: any, // function
|
||||
isMobile: boolean,
|
||||
isLivestreamClaim: boolean,
|
||||
}) => {
|
||||
function toggleTheaterMode(playerRef) {
|
||||
const player = playerRef.current;
|
||||
|
@ -141,7 +145,7 @@ const VideoJsKeyboardShorcuts = ({
|
|||
if (e.keyCode === KEYCODES.M) toggleMute(containerRef);
|
||||
if (e.keyCode === KEYCODES.UP) volumeUp(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.J) seekVideo(-SEEK_STEP, playerRef, containerRef);
|
||||
if (e.keyCode === KEYCODES.RIGHT) seekVideo(SEEK_STEP_5, playerRef, containerRef);
|
||||
|
|
|
@ -21,6 +21,9 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||
import recsys from './plugins/videojs-recsys/plugin';
|
||||
// import runAds from './ads';
|
||||
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');
|
||||
|
||||
require('@silvermine/videojs-chromecast')(videojs);
|
||||
|
@ -80,6 +83,9 @@ type Props = {
|
|||
claimValues: any,
|
||||
clearPosition: (string) => 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];
|
||||
|
@ -143,8 +149,13 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
uri,
|
||||
clearPosition,
|
||||
centerPlayButton,
|
||||
userClaimId,
|
||||
isLivestreamClaim,
|
||||
activeLivestreamForChannel,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// will later store the videojs player
|
||||
const playerRef = useRef();
|
||||
const containerRef = useRef();
|
||||
|
@ -154,8 +165,17 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
|
||||
const playerServerRef = useRef();
|
||||
|
||||
const { url: livestreamVideoUrl } = activeLivestreamForChannel || {};
|
||||
const showQualitySelector = !isLivestreamClaim || (livestreamVideoUrl && livestreamVideoUrl.includes('/transcode/'));
|
||||
|
||||
// initiate keyboard shortcuts
|
||||
const { curried_function } = keyboardShorcuts({ toggleVideoTheaterMode, playNext, playPrevious });
|
||||
const { curried_function } = keyboardShorcuts({
|
||||
isMobile,
|
||||
isLivestreamClaim,
|
||||
toggleVideoTheaterMode,
|
||||
playNext,
|
||||
playPrevious,
|
||||
});
|
||||
|
||||
const [reload, setReload] = useState('initial');
|
||||
|
||||
|
@ -178,6 +198,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
uri,
|
||||
playerServerRef,
|
||||
clearPosition,
|
||||
isLivestreamClaim,
|
||||
});
|
||||
|
||||
const videoJsOptions = {
|
||||
|
@ -186,23 +207,40 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
responsive: true,
|
||||
controls: true,
|
||||
html5: {
|
||||
vhs: {
|
||||
hls: {
|
||||
overrideNative: !videojs.browser.IS_ANY_SAFARI,
|
||||
allowSeeksWithinUnsafeLiveWindow: true,
|
||||
enableLowInitialPlaylist: false,
|
||||
handlePartialData: true,
|
||||
smoothQualityChange: true,
|
||||
},
|
||||
},
|
||||
liveTracker: {
|
||||
trackingThreshold: 0,
|
||||
liveTolerance: 10,
|
||||
},
|
||||
inactivityTimeout: 2000,
|
||||
autoplay: autoplay,
|
||||
muted: startMuted,
|
||||
poster: poster, // thumb looks bad in app, and if autoplay, flashing poster is annoying
|
||||
plugins: { eventTracking: true, overlay: OVERLAY.OVERLAY_DATA },
|
||||
// fixes problem of errant CC button showing up on iOS
|
||||
// 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'],
|
||||
chromecast: {
|
||||
requestTitleFn: (src) => title || '',
|
||||
requestSubtitleFn: (src) => channelName || '',
|
||||
},
|
||||
bigPlayButton: embedded, // only show big play button if embedded
|
||||
liveui: true,
|
||||
suppressNotSupportedError: true,
|
||||
};
|
||||
|
||||
// Initialize video.js
|
||||
|
@ -236,12 +274,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
if (bigPlayButton) bigPlayButton.style.setProperty('display', 'block', 'important');
|
||||
}
|
||||
|
||||
Chromecast.initialize(player);
|
||||
|
||||
// Add quality selector to player
|
||||
player.hlsQualitySelector({
|
||||
displayCurrentQuality: true,
|
||||
});
|
||||
if (showQualitySelector) player.hlsQualitySelector({ displayCurrentQuality: true });
|
||||
|
||||
// Add recsys plugin
|
||||
if (shareTelemetry) {
|
||||
|
@ -281,6 +315,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
}
|
||||
window.player.userActive(true);
|
||||
}
|
||||
|
||||
Chromecast.initialize(player);
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 **/
|
||||
// This lifecycle hook is only called once (on mount), or when `isAudio` or `source` changes.
|
||||
useEffect(() => {
|
||||
|
@ -315,26 +359,63 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
// $FlowFixMe
|
||||
document.querySelector('.vjs-control-bar').style.setProperty('opacity', '1', 'important');
|
||||
|
||||
// change to m3u8 if applicable
|
||||
const response = await fetch(source, { method: 'HEAD', cache: 'no-store' });
|
||||
if (isLivestreamClaim && userClaimId) {
|
||||
// $FlowFixMe
|
||||
vjsPlayer.addClass('livestreamPlayer');
|
||||
|
||||
playerServerRef.current = response.headers.get('x-powered-by');
|
||||
// @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();
|
||||
// });
|
||||
|
||||
if (response && response.redirected && response.url && response.url.endsWith('m3u8')) {
|
||||
// use m3u8 source
|
||||
// $FlowFixMe
|
||||
vjsPlayer.src({
|
||||
type: 'application/x-mpegURL',
|
||||
src: response.url,
|
||||
src: livestreamVideoUrl,
|
||||
});
|
||||
} else {
|
||||
// use original mp4 source
|
||||
// $FlowFixMe
|
||||
vjsPlayer.src({
|
||||
type: sourceType,
|
||||
src: source,
|
||||
});
|
||||
vjsPlayer.removeClass('livestreamPlayer');
|
||||
videojs.Vhs.xhr.beforeRequest = (options) => {};
|
||||
|
||||
// change to m3u8 if applicable
|
||||
const response = await fetch(source, { method: 'HEAD', cache: 'no-store' });
|
||||
|
||||
playerServerRef.current = response.headers.get('x-powered-by');
|
||||
|
||||
if (response && response.redirected && response.url && response.url.endsWith('m3u8')) {
|
||||
// use m3u8 source
|
||||
// $FlowFixMe
|
||||
vjsPlayer.src({
|
||||
type: 'application/x-mpegURL',
|
||||
src: response.url,
|
||||
});
|
||||
} else {
|
||||
// use original mp4 source
|
||||
// $FlowFixMe
|
||||
vjsPlayer.src({
|
||||
type: sourceType,
|
||||
src: source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// load video once source setup
|
||||
// $FlowFixMe
|
||||
vjsPlayer.load();
|
||||
|
@ -381,7 +462,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
window.player = undefined;
|
||||
}
|
||||
};
|
||||
}, [isAudio, source, reload]);
|
||||
}, [isAudio, source, reload, userClaimId, isLivestreamClaim]);
|
||||
|
||||
return (
|
||||
<div className={classnames('video-js-parent', { 'video-js-parent--ios': IS_IOS })} ref={containerRef}>
|
||||
|
|
|
@ -67,6 +67,8 @@ type Props = {
|
|||
isMarkdownOrComment: boolean,
|
||||
doAnalyticsView: (string, number) => void,
|
||||
claimRewards: () => void,
|
||||
isLivestreamClaim: boolean,
|
||||
activeLivestreamForChannel: any,
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -109,7 +111,10 @@ function VideoViewer(props: Props) {
|
|||
previousListUri,
|
||||
videoTheaterMode,
|
||||
isMarkdownOrComment,
|
||||
isLivestreamClaim,
|
||||
activeLivestreamForChannel,
|
||||
} = props;
|
||||
|
||||
const permanentUrl = claim && claim.permanent_url;
|
||||
const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : [];
|
||||
const claimId = claim && claim.claim_id;
|
||||
|
@ -149,11 +154,14 @@ function VideoViewer(props: Props) {
|
|||
toggleAutoplayNext();
|
||||
}, [localAutoplayNext]);
|
||||
|
||||
useInterval(() => {
|
||||
if (playerRef.current && isPlaying) {
|
||||
handlePosition(playerRef.current);
|
||||
}
|
||||
}, PLAY_POSITION_SAVE_INTERVAL_MS);
|
||||
useInterval(
|
||||
() => {
|
||||
if (playerRef.current && isPlaying && !isLivestreamClaim) {
|
||||
handlePosition(playerRef.current);
|
||||
}
|
||||
},
|
||||
!isLivestreamClaim ? PLAY_POSITION_SAVE_INTERVAL_MS : null
|
||||
);
|
||||
|
||||
const updateVolumeState = React.useCallback(
|
||||
debounce((volume, muted) => {
|
||||
|
@ -181,10 +189,12 @@ function VideoViewer(props: Props) {
|
|||
|
||||
// TODO: analytics functionality
|
||||
function doTrackingBuffered(e: Event, data: any) {
|
||||
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
|
||||
data.playerPoweredBy = response.headers.get('x-powered-by');
|
||||
doAnalyticsBuffer(uri, data);
|
||||
});
|
||||
if (!isLivestreamClaim) {
|
||||
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
|
||||
data.playerPoweredBy = response.headers.get('x-powered-by');
|
||||
doAnalyticsBuffer(uri, data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const doPlay = useCallback(
|
||||
|
@ -294,7 +304,7 @@ function VideoViewer(props: Props) {
|
|||
}
|
||||
|
||||
function handlePosition(player) {
|
||||
savePosition(uri, player.currentTime());
|
||||
if (!isLivestreamClaim) savePosition(uri, player.currentTime());
|
||||
}
|
||||
|
||||
function restorePlaybackRate(player) {
|
||||
|
@ -426,7 +436,7 @@ function VideoViewer(props: Props) {
|
|||
}
|
||||
});
|
||||
|
||||
if (position) {
|
||||
if (position && !isLivestreamClaim) {
|
||||
player.currentTime(position);
|
||||
}
|
||||
|
||||
|
@ -510,6 +520,9 @@ function VideoViewer(props: Props) {
|
|||
uri={uri}
|
||||
clearPosition={clearPosition}
|
||||
centerPlayButton={centerPlayButton}
|
||||
userClaimId={claim && claim.signing_channel && claim.signing_channel.claim_id}
|
||||
isLivestreamClaim={isLivestreamClaim}
|
||||
activeLivestreamForChannel={activeLivestreamForChannel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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_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_RTMP_URL = 'rtmp://stream.odysee.com/live';
|
||||
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 LIVESTREAM_STARTS_SOON_BUFFER = 15;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { connect } from 'react-redux';
|
||||
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 { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
|
||||
|
@ -24,7 +24,7 @@ const select = (state, props) => {
|
|||
};
|
||||
|
||||
const perform = {
|
||||
doSetPlayingUri,
|
||||
doSetPrimaryUri,
|
||||
doUserSetReferrer,
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
|
|
|
@ -7,9 +7,9 @@ import LivestreamLayout from 'component/livestreamLayout';
|
|||
import moment from 'moment';
|
||||
import Page from 'component/page';
|
||||
import React from 'react';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
|
||||
const LivestreamChatLayout = lazyImport(() => import('component/livestreamChatLayout' /* webpackChunkName: "chat" */));
|
||||
const LIVESTREAM_STATUS_CHECK_INTERVAL = 30000;
|
||||
|
||||
type Props = {
|
||||
activeLivestreamForChannel: any,
|
||||
|
@ -19,13 +19,15 @@ type Props = {
|
|||
claim: StreamClaim,
|
||||
isAuthenticated: boolean,
|
||||
uri: string,
|
||||
doSetPlayingUri: ({ uri: ?string }) => void,
|
||||
doSetPrimaryUri: (uri: ?string) => void,
|
||||
doCommentSocketConnect: (string, string, string) => void,
|
||||
doCommentSocketDisconnect: (string, string) => void,
|
||||
doFetchChannelLiveStatus: (string) => void,
|
||||
doUserSetReferrer: (string) => void,
|
||||
};
|
||||
|
||||
export const LayoutRenderContext = React.createContext<any>();
|
||||
|
||||
export default function LivestreamPage(props: Props) {
|
||||
const {
|
||||
activeLivestreamForChannel,
|
||||
|
@ -35,19 +37,18 @@ export default function LivestreamPage(props: Props) {
|
|||
claim,
|
||||
isAuthenticated,
|
||||
uri,
|
||||
doSetPlayingUri,
|
||||
doSetPrimaryUri,
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
doFetchChannelLiveStatus,
|
||||
doUserSetReferrer,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
|
||||
const [showLivestream, setShowLivestream] = React.useState(false);
|
||||
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
|
||||
const [hideComments, setHideComments] = React.useState(false);
|
||||
const [layountRendered, setLayountRendered] = React.useState(chatDisabled);
|
||||
|
||||
const isInitialized = Boolean(activeLivestreamForChannel) || activeLivestreamInitialized;
|
||||
const isChannelBroadcasting = Boolean(activeLivestreamForChannel);
|
||||
|
@ -82,10 +83,14 @@ export default function LivestreamPage(props: Props) {
|
|||
};
|
||||
}, [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(() => {
|
||||
doFetchChannelLiveStatus(livestreamChannelId);
|
||||
const intervalId = setInterval(() => doFetchChannelLiveStatus(livestreamChannelId), 30000);
|
||||
const fetch = () => doFetchChannelLiveStatus(livestreamChannelId);
|
||||
|
||||
fetch();
|
||||
|
||||
const intervalId = setInterval(fetch, LIVESTREAM_STATUS_CHECK_INTERVAL);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [livestreamChannelId, doFetchChannelLiveStatus]);
|
||||
|
||||
|
@ -144,14 +149,10 @@ export default function LivestreamPage(props: Props) {
|
|||
}, [uri, stringifiedClaim, isAuthenticated, doUserSetReferrer]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
|
||||
// This can be removed when we start using the app video player, not a LIVESTREAM iframe
|
||||
doSetPlayingUri({ uri: null });
|
||||
doSetPrimaryUri(uri);
|
||||
|
||||
return () => {
|
||||
if (isMobile) doSetPlayingUri({ uri: null });
|
||||
};
|
||||
}, [doSetPlayingUri, isMobile]);
|
||||
return () => doSetPrimaryUri(null);
|
||||
}, [doSetPrimaryUri, uri]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
|
@ -163,21 +164,23 @@ export default function LivestreamPage(props: Props) {
|
|||
!hideComments &&
|
||||
isInitialized && (
|
||||
<React.Suspense fallback={null}>
|
||||
<LivestreamChatLayout uri={uri} />
|
||||
<LivestreamChatLayout uri={uri} setLayountRendered={setLayountRendered} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isInitialized && (
|
||||
<LivestreamLayout
|
||||
uri={uri}
|
||||
hideComments={hideComments}
|
||||
release={release}
|
||||
isCurrentClaimLive={isCurrentClaimLive}
|
||||
showLivestream={showLivestream}
|
||||
showScheduledInfo={showScheduledInfo}
|
||||
activeStreamUri={activeStreamUri}
|
||||
/>
|
||||
<LayoutRenderContext.Provider value={layountRendered}>
|
||||
<LivestreamLayout
|
||||
uri={uri}
|
||||
hideComments={hideComments}
|
||||
release={release}
|
||||
isCurrentClaimLive={isCurrentClaimLive}
|
||||
showLivestream={showLivestream}
|
||||
showScheduledInfo={showScheduledInfo}
|
||||
activeStreamUri={activeStreamUri}
|
||||
/>
|
||||
</LayoutRenderContext.Provider>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
|
|
|
@ -24,7 +24,6 @@ import { normalizeURI } from 'util/lbryURI';
|
|||
import * as COLLECTIONS_CONSTS from 'constants/collections';
|
||||
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
|
||||
import { selectBlacklistedOutpointMap } from 'lbryinc';
|
||||
import { doAnalyticsView } from 'redux/actions/app';
|
||||
import ShowPage from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
|
@ -95,7 +94,6 @@ const perform = {
|
|||
doResolveUri,
|
||||
doBeginPublish,
|
||||
doFetchItemsInCollection,
|
||||
doAnalyticsView,
|
||||
};
|
||||
|
||||
export default withRouter(connect(select, perform)(ShowPage));
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { lazyImport } from 'util/lazyImport';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import Spinner from 'component/spinner';
|
||||
|
@ -40,7 +40,6 @@ type Props = {
|
|||
doResolveUri: (uri: string, returnCached: boolean, resolveReposts: boolean, options: any) => void,
|
||||
doBeginPublish: (name: ?string) => void,
|
||||
doFetchItemsInCollection: ({ collectionId: string }) => void,
|
||||
doAnalyticsView: (uri: string) => void,
|
||||
};
|
||||
|
||||
export default function ShowPage(props: Props) {
|
||||
|
@ -62,7 +61,6 @@ export default function ShowPage(props: Props) {
|
|||
doResolveUri,
|
||||
doBeginPublish,
|
||||
doFetchItemsInCollection,
|
||||
doAnalyticsView,
|
||||
} = props;
|
||||
|
||||
const { push } = useHistory();
|
||||
|
@ -136,16 +134,6 @@ export default function ShowPage(props: Props) {
|
|||
}
|
||||
}, [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
|
||||
// Always redirect to the actual content
|
||||
if (claim && claim.repost_url === uri) {
|
||||
|
|
|
@ -1,24 +1,23 @@
|
|||
// @flow
|
||||
import * as ACTIONS from 'constants/action_types';
|
||||
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';
|
||||
|
||||
const LiveStatus = Object.freeze({
|
||||
LIVE: 'LIVE',
|
||||
NOT_LIVE: 'NOT_LIVE',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
});
|
||||
|
||||
type LiveStatusType = $Keys<typeof LiveStatus>;
|
||||
|
||||
type LiveChannelStatus = { channelStatus: LiveStatusType, channelData?: LivestreamInfo };
|
||||
const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({
|
||||
type: ACTIONS.FETCH_NO_SOURCE_CLAIMS_STARTED,
|
||||
data: channelId,
|
||||
});
|
||||
|
||||
try {
|
||||
await dispatch(
|
||||
doClaimSearch({
|
||||
|
@ -44,51 +43,8 @@ export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dis
|
|||
}
|
||||
};
|
||||
|
||||
const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
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(
|
||||
const fetchUpcomingLivestreamClaims = (channelIds: Array<string>, lang: ?Array<string> = null) =>
|
||||
doClaimSearch(
|
||||
{
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
|
@ -105,14 +61,13 @@ const fetchUpcomingLivestreamClaims = (channelIds: Array<string>, lang: ?Array<s
|
|||
useAutoPagination: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const fetchMostRecentLivestreamClaims = (
|
||||
channelIds: Array<string>,
|
||||
orderBy: Array<string> = ['release_time'],
|
||||
lang: ?Array<string> = null
|
||||
) => {
|
||||
return doClaimSearch(
|
||||
) =>
|
||||
doClaimSearch(
|
||||
{
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
|
@ -129,36 +84,6 @@ const fetchMostRecentLivestreamClaims = (
|
|||
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 (
|
||||
channelIDs: Array<string>,
|
||||
|
@ -185,81 +110,82 @@ const findActiveStreams = async (
|
|||
return determineLiveClaim(allClaims, liveChannels);
|
||||
};
|
||||
|
||||
export const doFetchChannelLiveStatus = (channelId: string) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const { channelStatus, channelData } = await fetchLiveChannel(channelId);
|
||||
export const doFetchChannelLiveStatus = (channelId: string) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const { channelStatus, channelData } = await fetchLiveChannel(channelId);
|
||||
|
||||
if (channelStatus === LiveStatus.NOT_LIVE) {
|
||||
dispatch({ type: ACTIONS.REMOVE_CHANNEL_FROM_ACTIVE_LIVESTREAMS, data: { channelId } });
|
||||
return;
|
||||
}
|
||||
if (channelStatus === LiveStatus.UNKNOWN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentlyLiveClaims = await findActiveStreams([channelId], ['release_time'], channelData, dispatch);
|
||||
const liveClaim = currentlyLiveClaims[channelId];
|
||||
|
||||
if (channelData && liveClaim) {
|
||||
channelData[channelId].claimId = liveClaim.stream.claim_id;
|
||||
channelData[channelId].claimUri = liveClaim.stream.canonical_url;
|
||||
dispatch({ type: ACTIONS.ADD_CHANNEL_TO_ACTIVE_LIVESTREAMS, data: { ...channelData } });
|
||||
}
|
||||
} catch (err) {
|
||||
if (channelStatus === LiveStatus.NOT_LIVE) {
|
||||
dispatch({ type: ACTIONS.REMOVE_CHANNEL_FROM_ACTIVE_LIVESTREAMS, data: { channelId } });
|
||||
return;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const doFetchActiveLivestreams = (orderBy: Array<string> = ['release_time'], lang: ?Array<string> = null) => {
|
||||
return async (dispatch: Dispatch, getState: GetState) => {
|
||||
const state = getState();
|
||||
const now = Date.now();
|
||||
const timeDelta = now - state.livestream.activeLivestreamsLastFetchedDate;
|
||||
|
||||
const prevOptions = state.livestream.activeLivestreamsLastFetchedOptions;
|
||||
const nextOptions = { order_by: orderBy, ...(lang ? { any_languages: lang } : {}) };
|
||||
const sameOptions = JSON.stringify(prevOptions) === JSON.stringify(nextOptions);
|
||||
|
||||
if (sameOptions && timeDelta < FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_SKIPPED });
|
||||
if (channelStatus === LiveStatus.UNKNOWN) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED });
|
||||
const currentlyLiveClaims = await findActiveStreams([channelId], ['release_time'], channelData, dispatch);
|
||||
const liveClaim = currentlyLiveClaims[channelId];
|
||||
|
||||
try {
|
||||
const liveChannels = await fetchLiveChannels();
|
||||
const liveChannelIds = Object.keys(liveChannels);
|
||||
|
||||
const currentlyLiveClaims = await findActiveStreams(
|
||||
liveChannelIds,
|
||||
nextOptions.order_by,
|
||||
liveChannels,
|
||||
dispatch,
|
||||
nextOptions.any_languages
|
||||
);
|
||||
Object.values(currentlyLiveClaims).forEach((claim: any) => {
|
||||
const channelId = claim.stream.signing_channel.claim_id;
|
||||
|
||||
liveChannels[channelId] = {
|
||||
...liveChannels[channelId],
|
||||
claimId: claim.stream.claim_id,
|
||||
claimUri: claim.stream.canonical_url,
|
||||
};
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_COMPLETED,
|
||||
data: {
|
||||
activeLivestreams: liveChannels,
|
||||
activeLivestreamsLastFetchedDate: now,
|
||||
activeLivestreamsLastFetchedOptions: nextOptions,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
|
||||
if (channelData && liveClaim) {
|
||||
channelData[channelId].claimId = liveClaim.stream.claim_id;
|
||||
channelData[channelId].claimUri = liveClaim.stream.canonical_url;
|
||||
dispatch({ type: ACTIONS.ADD_CHANNEL_TO_ACTIVE_LIVESTREAMS, data: { ...channelData } });
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
dispatch({ type: ACTIONS.REMOVE_CHANNEL_FROM_ACTIVE_LIVESTREAMS, data: { channelId } });
|
||||
}
|
||||
};
|
||||
|
||||
export const doFetchActiveLivestreams = (
|
||||
orderBy: Array<string> = ['release_time'],
|
||||
lang: ?Array<string> = null
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
const state = getState();
|
||||
const now = Date.now();
|
||||
const timeDelta = now - state.livestream.activeLivestreamsLastFetchedDate;
|
||||
|
||||
const prevOptions = state.livestream.activeLivestreamsLastFetchedOptions;
|
||||
const nextOptions = { order_by: orderBy, ...(lang ? { any_languages: lang } : {}) };
|
||||
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) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_SKIPPED });
|
||||
return;
|
||||
}
|
||||
|
||||
// start fetching livestreams
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED });
|
||||
|
||||
try {
|
||||
const liveChannels = await fetchLiveChannels();
|
||||
const liveChannelIds = Object.keys(liveChannels);
|
||||
|
||||
const currentlyLiveClaims = await findActiveStreams(
|
||||
liveChannelIds,
|
||||
nextOptions.order_by,
|
||||
liveChannels,
|
||||
dispatch,
|
||||
nextOptions.any_languages
|
||||
);
|
||||
Object.values(currentlyLiveClaims).forEach((claim: any) => {
|
||||
const channelId = claim.stream.signing_channel.claim_id;
|
||||
|
||||
liveChannels[channelId] = {
|
||||
...liveChannels[channelId],
|
||||
claimId: claim.stream.claim_id,
|
||||
claimUri: claim.stream.canonical_url,
|
||||
};
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_COMPLETED,
|
||||
data: {
|
||||
activeLivestreams: liveChannels,
|
||||
activeLivestreamsLastFetchedDate: now,
|
||||
activeLivestreamsLastFetchedOptions: nextOptions,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -323,6 +323,11 @@ export const makeSelectTotalPagesInChannelSearch = (uri: string) =>
|
|||
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) => {
|
||||
const claim = selectClaimForUri(state, uri);
|
||||
const metadata = claim && claim.value;
|
||||
|
@ -381,6 +386,9 @@ export const makeSelectEffectiveAmountForUri = (uri: string) =>
|
|||
|
||||
export const makeSelectContentTypeForUri = (uri: string) =>
|
||||
createSelector(makeSelectClaimForUri(uri), (claim) => {
|
||||
const isLivestreamClaim = isStreamPlaceholderClaim(claim);
|
||||
if (isLivestreamClaim) return 'livestream';
|
||||
|
||||
const source = claim && claim.value && claim.value.source;
|
||||
return source ? source.media_type : undefined;
|
||||
});
|
||||
|
|
|
@ -106,7 +106,7 @@ export const makeSelectFileRenderModeForUri = (uri: string) =>
|
|||
makeSelectMediaTypeForUri(uri),
|
||||
makeSelectFileExtensionForUri(uri),
|
||||
(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;
|
||||
}
|
||||
if (mediaType === 'audio') {
|
||||
|
|
|
@ -8,7 +8,7 @@ $recent-msg-button__height: 2rem;
|
|||
width: 100%;
|
||||
background-color: rgba(var(--color-header-background-base), 1);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--spacing-m);
|
||||
margin-left: var(--spacing-m);
|
||||
|
||||
.credit-amount {
|
||||
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) {
|
||||
margin: 0;
|
||||
min-width: var(--livestream-comments-width);
|
||||
width: var(--livestream-comments-width);
|
||||
height: calc(100vh - var(--header-height) - var(--spacing-l));
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0px;
|
||||
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
|
||||
@media (max-width: $breakpoint-small) {
|
||||
padding: 0 !important;
|
||||
|
|
|
@ -577,17 +577,9 @@ body {
|
|||
|
||||
.main--livestream {
|
||||
@extend .main--file-page;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $breakpoint-small) {
|
||||
> * {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.card-stack {
|
||||
margin-bottom: var(--spacing-m);
|
||||
|
||||
@media (min-width: ($breakpoint-large + 300px)) {
|
||||
max-width: calc(var(--page-max-width--filepage) / 1.25);
|
||||
margin-left: auto;
|
||||
|
@ -595,12 +587,6 @@ body {
|
|||
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) {
|
||||
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) {
|
||||
padding: 0 var(--spacing-s);
|
||||
.card__main-actions {
|
||||
|
|
|
@ -272,33 +272,59 @@ button.vjs-big-play-button {
|
|||
.vjs-volume-panel {
|
||||
order: 4 !important;
|
||||
}
|
||||
.vjs-current-time {
|
||||
|
||||
.vjs-seek-to-live-control {
|
||||
order: 5 !important;
|
||||
}
|
||||
.vjs-time-divider {
|
||||
|
||||
.vjs-live-control {
|
||||
order: 5 !important;
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
|
||||
.vjs-current-time {
|
||||
order: 6 !important;
|
||||
}
|
||||
.vjs-duration {
|
||||
.vjs-time-divider {
|
||||
order: 7 !important;
|
||||
}
|
||||
.vjs-custom-control-spacer {
|
||||
.vjs-duration {
|
||||
order: 8 !important;
|
||||
}
|
||||
.vjs-button--autoplay-next {
|
||||
.vjs-custom-control-spacer {
|
||||
order: 9 !important;
|
||||
}
|
||||
.vjs-playback-rate {
|
||||
.vjs-button--autoplay-next {
|
||||
order: 10 !important;
|
||||
}
|
||||
.vjs-chromecast-button {
|
||||
.vjs-playback-rate {
|
||||
order: 11 !important;
|
||||
}
|
||||
.vjs-quality-selector {
|
||||
.vjs-chromecast-button {
|
||||
order: 12 !important;
|
||||
}
|
||||
.vjs-button--theater-mode {
|
||||
.vjs-quality-selector {
|
||||
order: 13 !important;
|
||||
}
|
||||
.vjs-fullscreen-control {
|
||||
.vjs-button--theater-mode {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,27 @@
|
|||
// @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
|
||||
|
@ -43,3 +66,133 @@ export function getTipValues(superChatsByAmount: Array<Comment>) {
|
|||
|
||||
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;
|
||||
};
|
||||
|
|
69
yarn.lock
69
yarn.lock
|
@ -2190,6 +2190,20 @@
|
|||
mux.js "5.13.0"
|
||||
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":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.3.tgz#708bc50742e9481712039695299b32da6582ef92"
|
||||
|
@ -2199,19 +2213,19 @@
|
|||
global "^4.4.0"
|
||||
url-toolkit "^2.2.1"
|
||||
|
||||
"@videojs/vhs-utils@^3.0.0", "@videojs/vhs-utils@^3.0.2":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.2.tgz#0203418ecaaff29bc33c69b6ad707787347b7614"
|
||||
integrity sha512-r8Yas1/tNGsGRNoIaDJuiWiQgM0P2yaEnobgzw5JcBiEqxnS8EXoUm4QtKH7nJtnppZ1yqBx1agBZCvBMKXA2w==
|
||||
"@videojs/vhs-utils@3.0.4", "@videojs/vhs-utils@^3.0.3", "@videojs/vhs-utils@^3.0.4":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.4.tgz#e253eecd8e9318f767e752010d213587f94bb03a"
|
||||
integrity sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
global "^4.4.0"
|
||||
url-toolkit "^2.2.1"
|
||||
|
||||
"@videojs/vhs-utils@^3.0.3":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.4.tgz#e253eecd8e9318f767e752010d213587f94bb03a"
|
||||
integrity sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg==
|
||||
"@videojs/vhs-utils@^3.0.0", "@videojs/vhs-utils@^3.0.2":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.2.tgz#0203418ecaaff29bc33c69b6ad707787347b7614"
|
||||
integrity sha512-r8Yas1/tNGsGRNoIaDJuiWiQgM0P2yaEnobgzw5JcBiEqxnS8EXoUm4QtKH7nJtnppZ1yqBx1agBZCvBMKXA2w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
global "^4.4.0"
|
||||
|
@ -11659,6 +11673,16 @@ mpd-parser@0.19.0:
|
|||
"@xmldom/xmldom" "^0.7.2"
|
||||
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:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
|
@ -11699,6 +11723,14 @@ mux.js@5.13.0:
|
|||
dependencies:
|
||||
"@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:
|
||||
version "2.14.0"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.15.4.tgz#0f96ef138035138cb30bf00a989b6174f0d16bac"
|
||||
integrity sha512-hghxkgptLUvfkpktB4wxcIVF3VpY/hVsMkrjHSv0jpj1bW9Jplzdt8IgpTm9YhlB1KYAp07syVQeZcBFUBwhkw==
|
||||
|
@ -17256,6 +17288,25 @@ vfile@^2.0.0:
|
|||
videojs-font "3.2.0"
|
||||
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:
|
||||
version "6.9.0"
|
||||
resolved "https://registry.yarnpkg.com/videojs-contrib-ads/-/videojs-contrib-ads-6.9.0.tgz#c792d6fda77254b277545cc3222352fc653b5833"
|
||||
|
|
Loading…
Reference in a new issue