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",
"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",

View file

@ -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.",

View file

@ -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),
};
};

View file

@ -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">

View file

@ -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));

View file

@ -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>
);
}

View file

@ -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);

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
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',
},
},
}}
/>
);

View file

@ -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 && (

View file

@ -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),
};
};

View file

@ -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 {

View file

@ -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);

View file

@ -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}>

View file

@ -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>
);

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_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;

View file

@ -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,

View file

@ -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>
);

View file

@ -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));

View file

@ -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) {

View file

@ -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 });
}
};

View file

@ -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;
});

View file

@ -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') {

View file

@ -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;

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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;
};

View file

@ -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"